diff --git a/cmd/devbox/update.go b/cmd/devbox/update.go index 40eb25c..fc0ad15 100644 --- a/cmd/devbox/update.go +++ b/cmd/devbox/update.go @@ -131,6 +131,8 @@ func syncSources(ctx context.Context, p *project.Project, onSyncing, onSynced, o ctx, cancelSync := context.WithCancel(ctx) defer cancelSync() + excludes := p.SourceCleanExcludes() + const maxConcurrency = 4 sem := make(chan struct{}, maxConcurrency) @@ -149,7 +151,7 @@ func syncSources(ctx context.Context, p *project.Project, onSyncing, onSynced, o repoDir := filepath.Join(p.WorkingDir, app.SourcesDir, name) - g := git.New(repoDir) + g := git.New(repoDir, excludes[name]...) err := g.Sync(ctx, src.URL, src.Branch, src.SparseCheckout) if err != nil { onFailed(name) diff --git a/internal/git/git.go b/internal/git/git.go index 9be5cc3..d3505fe 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -23,12 +23,16 @@ var _ Service = (*svc)(nil) type svc struct { targetPath string runner CommandRunner + excludes []string } -func New(targetFolder string) Service { +// New returns a git Service for targetFolder. Any excludes are passed to `git clean` as +// `-e ` so live nested bind-mount points are preserved during source sync. +func New(targetFolder string, excludes ...string) Service { return &svc{ targetPath: targetFolder, runner: &defaultRunner{}, + excludes: excludes, } } @@ -178,7 +182,12 @@ func (s *svc) reset(ctx context.Context, removeIgnored bool) error { cleanFlags = "-fdx" } - out, err = s.runner.Run(ctx, "git", "-C", s.targetPath, "clean", cleanFlags) + cleanArgs := []string{"-C", s.targetPath, "clean", cleanFlags} + for _, e := range s.excludes { + cleanArgs = append(cleanArgs, "-e", e) + } + + out, err = s.runner.Run(ctx, "git", cleanArgs...) if err != nil { return fmt.Errorf("failed to clean: %s %w", out, err) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 19ce032..e25ca67 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -175,6 +176,135 @@ func TestPull(t *testing.T) { } } +// ============================================================================ +// Excludes (git clean -e) tests +// ============================================================================ + +func TestResetWithExcludes(t *testing.T) { + tests := []struct { + name string + excludes []string + removeIgnored bool + wantCleanArgs []any + }{ + { + name: "single exclude with -fdx", + excludes: []string{"cmd/service-a/shared"}, + removeIgnored: true, + wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fdx", "-e", "cmd/service-a/shared"}, + }, + { + name: "single exclude with -fd", + excludes: []string{"cmd/service-a/shared"}, + removeIgnored: false, + wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fd", "-e", "cmd/service-a/shared"}, + }, + { + name: "two excludes keep separate -e in slice order", + excludes: []string{"a", "b"}, + removeIgnored: true, + wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fdx", "-e", "a", "-e", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := NewMockCommandRunner(t) + runner.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard").Return("", nil) + runner.EXPECT().Run(mock.Anything, "git", tt.wantCleanArgs...).Return("", nil) + + s := &svc{targetPath: "/tmp/test", runner: runner, excludes: tt.excludes} + if err := s.reset(context.Background(), tt.removeIgnored); err != nil { + t.Errorf("reset() error = %v", err) + } + }) + } +} + +// TestSyncWithExcludes proves both git clean invocations Sync makes on an existing repo +// (reset's -fdx and the trailing Pull's -fd) receive the excludes. +func TestSyncWithExcludes(t *testing.T) { + dir := t.TempDir() + targetPath := filepath.Join(dir, "repo") + _ = os.MkdirAll(filepath.Join(targetPath, ".git"), 0o755) + + exclude := "cmd/service-a/shared" + + runner := NewMockCommandRunner(t) + // Sync's reset (removeIgnored=true) + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "reset", "--hard").Return("", nil).Once() + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "clean", "-fdx", "-e", exclude).Return("", nil).Once() + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "disable").Return("", nil) + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "checkout", "main").Return("", nil) + // Trailing Pull's reset (removeIgnored=false) + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "reset", "--hard").Return("", nil).Once() + runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "clean", "-fd", "-e", exclude).Return("", nil).Once() + runner.EXPECT().RunWithTTY(mock.Anything, "git", "-C", targetPath, "pull", "--rebase").Return("", nil) + + s := &svc{targetPath: targetPath, runner: runner, excludes: []string{exclude}} + if err := s.Sync(context.Background(), "https://github.com/org/repo.git", "main", nil); err != nil { + t.Errorf("Sync() error = %v", err) + } +} + +// TestGitCleanExcludePreservesMountpoint runs real `git clean -fdx -e ` against a temp +// repo to prove the computed exclude string actually anchors: the excluded dir survives while +// sibling untracked cruft is removed. A wrong string format would slip past the mock tests but +// fail here. +func TestGitCleanExcludePreservesMountpoint(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + dir := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + trackedDir := filepath.Join(dir, "cmd", "service-a") + if err := os.MkdirAll(trackedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(trackedDir, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + runGit("init") + runGit("add", ".") + runGit("commit", "-m", "init") + + mountpoint := filepath.Join(trackedDir, "shared") + if err := os.MkdirAll(mountpoint, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mountpoint, "keep.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + stale := filepath.Join(trackedDir, "stale.tmp") + if err := os.WriteFile(stale, []byte("y"), 0o644); err != nil { + t.Fatal(err) + } + + runGit("clean", "-fdx", "-e", "cmd/service-a/shared") + + if _, err := os.Stat(mountpoint); err != nil { + t.Errorf("excluded mountpoint should survive clean: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale.tmp should have been removed by clean, stat err = %v", err) + } +} + // ============================================================================ // GetInfo tests // ============================================================================ diff --git a/internal/manager/manager.go b/internal/manager/manager.go index d15f8ca..6196235 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -446,7 +446,7 @@ func (m *Manager) matchSourcePath( // Check if cwd matches the service's source path: // - If service uses root (serviceSubpath=""), cwd must also be at root - // - If service uses subpath (e.g., "cmd/risk-engine"), cwd must be inside it + // - If service uses subpath (e.g., "cmd/app"), cwd must be inside it if !cwdMatchesServiceSubpath(cwdRelPath, serviceSubpath) { return "", "", false } diff --git a/internal/project/mounts_exclude.go b/internal/project/mounts_exclude.go new file mode 100644 index 0000000..a95f914 --- /dev/null +++ b/internal/project/mounts_exclude.go @@ -0,0 +1,107 @@ +package project + +import ( + "path" + "path/filepath" + "slices" + "strings" + + "github.com/compose-spec/compose-go/v2/types" + + "github.com/pilat/devbox/internal/app" +) + +// SourceCleanExcludes returns, per source name, the repo-relative paths `git clean` must skip +// because Docker mounts a different source into them as a live nested bind mount. Keys match the +// on-disk sources/ segment; values carry no leading slash. +func (p *Project) SourceCleanExcludes() map[string][]string { + sourcesRoot := filepath.Join(p.WorkingDir, app.SourcesDir) + + return nestedForeignMountExcludes(p.Services, sourcesRoot) +} + +// nestedForeignMountExcludes derives cross-source nested-mount excludes from the compose volume +// topology: a bind volume (child) of a source is mounted nested inside a same-service parent bind +// whose host source is a *different* source. The different-source discriminator keeps self-nested +// and file/config mounts out, so clean is never weakened where it should still run. +func nestedForeignMountExcludes(services types.Services, sourcesRoot string) map[string][]string { + type bindVol struct { + source string + target string + } + + result := make(map[string][]string) + + for _, service := range services { + binds := make([]bindVol, 0, len(service.Volumes)) + for _, v := range service.Volumes { + if v.Type != "bind" { + continue + } + binds = append(binds, bindVol{source: v.Source, target: path.Clean(v.Target)}) + } + + for _, child := range binds { + childSrc, ok := sourceSegment(child.source, sourcesRoot) + if !ok { + continue + } + + var parent *bindVol + for i := range binds { + cand := &binds[i] + if !strings.HasPrefix(child.target, cand.target+"/") { + continue + } + if parent == nil || len(cand.target) > len(parent.target) { + parent = cand + } + } + if parent == nil { + continue + } + + parentSrc, ok := sourceSegment(parent.source, sourcesRoot) + if !ok { + continue + } + + if childSrc == parentSrc { + continue + } + + childRel := strings.TrimPrefix(strings.TrimPrefix(child.target, parent.target), "/") + hostMountpoint := filepath.Join(parent.source, childRel) + repoRel, err := filepath.Rel(filepath.Join(sourcesRoot, parentSrc), hostMountpoint) + if err != nil { + continue + } + + result[parentSrc] = append(result[parentSrc], repoRel) + } + } + + for src, excludes := range result { + slices.Sort(excludes) + result[src] = slices.Compact(excludes) + } + + return result +} + +// sourceSegment reports the sources/ segment of an absolute host path, if it lives under +// sourcesRoot. It returns false for anything outside sourcesRoot (envs/, named volumes, or a +// LocalMounts-rewritten local path). +func sourceSegment(hostPath, sourcesRoot string) (string, bool) { + prefix := sourcesRoot + string(filepath.Separator) + if !strings.HasPrefix(hostPath, prefix) { + return "", false + } + + seg, _, _ := strings.Cut(hostPath[len(prefix):], string(filepath.Separator)) + if seg == "" { + return "", false + } + + return seg, true +} diff --git a/internal/project/mounts_exclude_test.go b/internal/project/mounts_exclude_test.go new file mode 100644 index 0000000..7546ba5 --- /dev/null +++ b/internal/project/mounts_exclude_test.go @@ -0,0 +1,119 @@ +package project + +import ( + "path/filepath" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/stretchr/testify/assert" +) + +func TestNestedForeignMountExcludes(t *testing.T) { + root := "/work" + sourcesRoot := filepath.Join(root, "sources") + + bind := func(source, target string) types.ServiceVolumeConfig { + return types.ServiceVolumeConfig{Type: "bind", Source: source, Target: target} + } + + tests := []struct { + name string + services types.Services + want map[string][]string + }{ + { + name: "cross-source nested dir mount is excluded, .env file mount is not", + services: types.Services{ + "service-a": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "service-a", "cmd", "service-a"), "/app"), + bind(filepath.Join(root, "envs", "service-a", ".env"), "/app/.env"), + bind(filepath.Join(sourcesRoot, "shared"), "/app/shared"), + }, + }, + }, + want: map[string][]string{ + "service-a": {"cmd/service-a/shared"}, + }, + }, + { + name: "self-nested same source is not excluded", + services: types.Services{ + "service-b": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "service-b"), "/app"), + bind(filepath.Join(sourcesRoot, "service-b", "cmd", "service-b"), "/app/cmd/service-b"), + }, + }, + }, + want: map[string][]string{}, + }, + { + name: "file/config mount only is not excluded", + services: types.Services{ + "svc": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "svc"), "/app"), + bind(filepath.Join(root, "envs", "svc", ".env"), "/app/.env"), + }, + }, + }, + want: map[string][]string{}, + }, + { + name: "locally-mounted parent (source outside sources/) is not excluded", + services: types.Services{ + "service-a": { + Volumes: []types.ServiceVolumeConfig{ + bind("/Users/me/local/service-a", "/app"), + bind(filepath.Join(sourcesRoot, "shared"), "/app/shared"), + }, + }, + }, + want: map[string][]string{}, + }, + { + name: "multiple cross-source children under one source are sorted", + services: types.Services{ + "foo": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "foo"), "/app"), + bind(filepath.Join(sourcesRoot, "zebra"), "/app/zebra"), + bind(filepath.Join(sourcesRoot, "alpha"), "/app/alpha"), + }, + }, + }, + want: map[string][]string{ + "foo": {"alpha", "zebra"}, + }, + }, + { + name: "two services feeding one source key are merged, sorted and deduped", + services: types.Services{ + "svc-a": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "foo"), "/app"), + bind(filepath.Join(sourcesRoot, "shared"), "/app/shared"), + bind(filepath.Join(sourcesRoot, "extra"), "/app/extra"), + }, + }, + "svc-b": { + Volumes: []types.ServiceVolumeConfig{ + bind(filepath.Join(sourcesRoot, "foo"), "/app"), + bind(filepath.Join(sourcesRoot, "shared"), "/app/shared"), + }, + }, + }, + want: map[string][]string{ + "foo": {"extra", "shared"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nestedForeignMountExcludes(tt.services, sourcesRoot) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 2c23bfe..be81977 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -16,16 +16,21 @@ import ( type E2ESuite struct { suite.Suite - home string - projectDir string - tempDir string - manifestRepo string - manifestDir string - source1Repo string - source1Dir string - hostsFile string - fixturesDir string - origDir string + home string + projectDir string + tempDir string + manifestRepo string + manifestDir string + source1Repo string + source1Dir string + hostsFile string + fixturesDir string + origDir string + nestedProjectDir string + nestedManifestRepo string + nestedManifestDir string + source2Repo string + source2Dir string } func TestE2ESuite(t *testing.T) { @@ -41,6 +46,7 @@ func (s *E2ESuite) SetupSuite() { s.home, err = os.UserHomeDir() s.Require().NoError(err) s.projectDir = filepath.Join(s.home, ".devbox", "test-app") + s.nestedProjectDir = filepath.Join(s.home, ".devbox", "nested-app") s.fixturesDir = filepath.Join(s.origDir, "fixtures") @@ -57,12 +63,19 @@ func (s *E2ESuite) SetupSuite() { s.manifestDir = filepath.Join(s.tempDir, "manifest") s.source1Repo = filepath.Join(s.tempDir, "source-1.git") s.source1Dir = filepath.Join(s.tempDir, "source-1") + s.nestedManifestRepo = filepath.Join(s.tempDir, "manifest-nested.git") + s.nestedManifestDir = filepath.Join(s.tempDir, "manifest-nested") + s.source2Repo = filepath.Join(s.tempDir, "source-2.git") + s.source2Dir = filepath.Join(s.tempDir, "source-2") s.hostsFile = filepath.Join(s.tempDir, "hosts") - // Clean previous test-app project if exists (only this specific project) + // Clean previous test projects if they exist (only these specific projects) if _, err := os.Stat(s.projectDir); err == nil { _ = os.RemoveAll(s.projectDir) } + if _, err := os.Stat(s.nestedProjectDir); err == nil { + _ = os.RemoveAll(s.nestedProjectDir) + } // Setup manifest repo s.runGit("", "init", "--bare", s.manifestRepo) @@ -103,6 +116,8 @@ func (s *E2ESuite) SetupSuite() { s.runGit(s.source1Dir, "push", "-u", "origin", "main") s.runGit(s.source1Repo, "symbolic-ref", "HEAD", "refs/heads/main") + s.setupNestedFixtures() + // Create hosts file f, err := os.Create(s.hostsFile) s.Require().NoError(err) @@ -111,12 +126,14 @@ func (s *E2ESuite) SetupSuite() { func (s *E2ESuite) TearDownSuite() { // Stop and remove containers - out, _ := exec.Command("docker", "ps", "-aq", "--filter", "name=test-app").Output() - containers := strings.Split(strings.TrimSpace(string(out)), "\n") - for _, c := range containers { - if c != "" { - _ = exec.Command("docker", "stop", "-t0", c).Run() - _ = exec.Command("docker", "rm", "-f", c).Run() + for _, name := range []string{"test-app", "nested-app"} { + out, _ := exec.Command("docker", "ps", "-aq", "--filter", "name="+name).Output() + containers := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, c := range containers { + if c != "" { + _ = exec.Command("docker", "stop", "-t0", c).Run() + _ = exec.Command("docker", "rm", "-f", c).Run() + } } } @@ -127,6 +144,9 @@ func (s *E2ESuite) TearDownSuite() { if s.projectDir != "" { _ = os.RemoveAll(s.projectDir) } + if s.nestedProjectDir != "" { + _ = os.RemoveAll(s.nestedProjectDir) + } if s.tempDir != "" { _ = os.RemoveAll(s.tempDir) } @@ -160,6 +180,43 @@ func (s *E2ESuite) copyDir(src, dst string) { } } +// setupNestedFixtures builds a second source repo and a "nested" manifest whose web service mounts +// service-1 at /app and the foreign service-2 nested at /app/shared. Once the stack is up, +// Docker materializes a live mountpoint at sources/service-1/shared — the exact shape that +// used to make source-sync's git clean fail with "Permission denied". +func (s *E2ESuite) setupNestedFixtures() { + // Source 2 reuses the service-1 fixture content; only its role (foreign nested mount) matters. + s.runGit("", "init", "--bare", s.source2Repo) + s.Require().NoError(os.Mkdir(s.source2Dir, 0o755)) + s.copyDir(filepath.Join(s.fixturesDir, "service-1"), s.source2Dir) + s.runGit(s.source2Dir, "init") + s.runGit(s.source2Dir, "remote", "add", "origin", s.source2Repo) + s.runGit(s.source2Dir, "add", ".") + s.runGit(s.source2Dir, "commit", "-m", "Initial commit") + s.runGit(s.source2Dir, "branch", "-M", "main") + s.runGit(s.source2Dir, "push", "-u", "origin", "main") + s.runGit(s.source2Repo, "symbolic-ref", "HEAD", "refs/heads/main") + + s.runGit("", "init", "--bare", s.nestedManifestRepo) + s.Require().NoError(os.Mkdir(s.nestedManifestDir, 0o755)) + s.runGit(s.nestedManifestDir, "init") + s.copyDir(filepath.Join(s.fixturesDir, "manifest-nested"), s.nestedManifestDir) + + composeFile := filepath.Join(s.nestedManifestDir, "docker-compose.yml") + content, err := os.ReadFile(composeFile) + s.Require().NoError(err) + content = []byte(strings.ReplaceAll(string(content), "SOURCE_1_REPO", s.source1Repo)) + content = []byte(strings.ReplaceAll(string(content), "SOURCE_2_REPO", s.source2Repo)) + s.Require().NoError(os.WriteFile(composeFile, content, 0o644)) + + s.runGit(s.nestedManifestDir, "remote", "add", "origin", s.nestedManifestRepo) + s.runGit(s.nestedManifestDir, "add", ".") + s.runGit(s.nestedManifestDir, "commit", "-m", "Initial commit") + s.runGit(s.nestedManifestDir, "branch", "-M", "main") + s.runGit(s.nestedManifestDir, "push", "-u", "origin", "main") + s.runGit(s.nestedManifestRepo, "symbolic-ref", "HEAD", "refs/heads/main") +} + func (s *E2ESuite) devbox(args ...string) (stdout, stderr string, err error) { cmd := exec.Command("devbox", args...) cmd.Env = append(os.Environ(), "DEVBOX_TEST_HOSTS_FILE="+s.hostsFile) @@ -218,6 +275,11 @@ func (s *E2ESuite) checkContainersUp(count int) bool { return strings.Count(string(out), "Up") == count } +func (s *E2ESuite) checkContainersUpNamed(name string, count int) bool { + out, _ := exec.Command("docker", "ps", "--filter", "name="+name).Output() + return strings.Count(string(out), "Up") == count +} + func (s *E2ESuite) checkContainersDown() bool { out, _ := exec.Command("docker", "ps", "--filter", "name=test-app").Output() return !strings.Contains(string(out), "Up") @@ -523,6 +585,45 @@ func (s *E2ESuite) Test57_MountForVolumeOperations() { }, 30*time.Second), "Service should return updated response") } +// ============================================================================ +// Test 58: Update while a live cross-source nested bind mount is active +// ============================================================================ + +func (s *E2ESuite) Test58_UpdateWithLiveNestedMount() { + s.devboxRun("destroy", "--name", "nested-app") + _ = os.RemoveAll(s.nestedProjectDir) + + s.devboxRun("init", s.nestedManifestRepo, "--name", "nested-app", "--branch", "main") + + stdout, _, err := s.devbox("update", "--name", "nested-app") + s.Require().NoError(err) + s.Contains(stdout, "service-1 Synced") + s.Contains(stdout, "service-2 Synced") + + _, _, err = s.devbox("up", "--name", "nested-app") + s.Require().NoError(err) + s.True(s.waitFor(func() bool { return s.checkContainersUpNamed("nested-app", 1) }, 30*time.Second), + "nested-app web container should be running") + + // Docker materialized the foreign source-2 as a live mountpoint inside source-1's checkout. + mountpoint := filepath.Join(s.nestedProjectDir, "sources", "service-1", "shared") + _, err = os.Stat(mountpoint) + s.Require().NoError(err, "nested mountpoint should exist while the stack is up") + + // The regression: updating sources while the stack is up used to fail here because git clean + // tried to rmdir the live mountpoint. It must now succeed and leave the mount untouched. + stdout, stderr, err := s.devbox("update", "--name", "nested-app") + s.Require().NoError(err, "update while stack is up must not fail") + s.Contains(stdout, "service-1 Synced") + s.NotContains(stdout+stderr, "Permission denied") + + _, err = os.Stat(mountpoint) + s.Require().NoError(err, "nested mountpoint should survive the update") + s.True(s.checkContainersUpNamed("nested-app", 1), "stack should still be up after update") + + s.devboxRun("destroy", "--name", "nested-app") +} + // ============================================================================ // Test 60: Project Operations // ============================================================================ diff --git a/tests/e2e/fixtures/manifest-nested/docker-compose.yml b/tests/e2e/fixtures/manifest-nested/docker-compose.yml new file mode 100644 index 0000000..fcf96e9 --- /dev/null +++ b/tests/e2e/fixtures/manifest-nested/docker-compose.yml @@ -0,0 +1,16 @@ +x-devbox-sources: + service-1: + url: "SOURCE_1_REPO" + branch: main + service-2: + url: "SOURCE_2_REPO" + branch: main +services: + web: + image: nginx:alpine + stop_grace_period: 0s + volumes: + - ./sources/service-1:/app + - ./sources/service-2:/app/shared + ports: + - "8090:80"