From caa454e04a1f2b0b05d35d7cd9cdac86b8401c59 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 19 Jun 2026 09:22:33 +0200 Subject: [PATCH] fix(watch): don't rebuild dependencies when a service changes When a watched service is rebuilt, compose also rebuilt the services it depends on (which were then left not restarted). up builds its BuildOptions with Deps=true. The same options are reused by watch's rebuild(), which narrows Build.Services to the changed service but never cleared Build.Deps, so build() still included dependencies via IncludeDependencies. Clear Build.Deps in rebuild() so only the changed services are built. Fixes #13853 Signed-off-by: David Gageot --- pkg/compose/watch.go | 7 ++- pkg/compose/watch_test.go | 31 +++++++++++++ pkg/e2e/fixtures/watch/rebuild-deps.yaml | 19 ++++++++ pkg/e2e/watch_test.go | 55 ++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 pkg/e2e/fixtures/watch/rebuild-deps.yaml diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 0af3650ede5..07f0813b23e 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -138,6 +138,10 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, opti return wait() } +func selectWatchServices(project *types.Project, services []string) (*types.Project, error) { + return project.WithSelectedServices(services, types.IgnoreDependencies) +} + type watchRule struct { types.Trigger include watch.PathMatcher @@ -188,7 +192,7 @@ func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping { func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) { //nolint: gocyclo var err error - if project, err = project.WithSelectedServices(options.Services); err != nil { + if project, err = selectWatchServices(project, options.Services); err != nil { return nil, err } syncer, err := s.getSyncImplementation(project) @@ -636,6 +640,7 @@ func (s *composeService) rebuild(ctx context.Context, project *types.Project, se options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services)) // restrict the build to ONLY this service, not any of its dependencies options.Build.Services = services + options.Build.Deps = false options.Build.Progress = string(progressui.PlainMode) options.Build.Out = cutils.GetWriter(func(line string) { options.LogTo.Log(api.WatchLogger, line) diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 0c59b884ba6..18357b93ae2 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -180,6 +180,37 @@ func TestWatch_Sync(t *testing.T) { // TODO: there's not a great way to assert that the rebuild attempt happened } +func TestSelectWatchServicesIgnoresDependencies(t *testing.T) { + project := &types.Project{ + Name: "myProjectName", + Services: types.Services{ + "backend": { + Name: "backend", + }, + "stats": { + Name: "stats", + DependsOn: types.DependsOnConfig{ + "backend": { + Condition: types.ServiceConditionStarted, + Restart: true, + Required: true, + }, + }, + }, + }, + } + + selected, err := selectWatchServices(project, []string{"stats"}) + assert.NilError(t, err) + + _, ok := selected.Services["stats"] + assert.Assert(t, ok) + _, ok = selected.Services["backend"] + assert.Assert(t, !ok) + assert.Assert(t, len(selected.Services["stats"].DependsOn) == 0) + assert.Assert(t, len(project.Services["stats"].DependsOn) != 0) +} + type fakeSyncer struct { synced chan []*sync.PathMapping } diff --git a/pkg/e2e/fixtures/watch/rebuild-deps.yaml b/pkg/e2e/fixtures/watch/rebuild-deps.yaml new file mode 100644 index 00000000000..f32a8e9e72e --- /dev/null +++ b/pkg/e2e/fixtures/watch/rebuild-deps.yaml @@ -0,0 +1,19 @@ +services: + backend: + build: + dockerfile_inline: | + FROM nginx + RUN mkdir /data + COPY backend /data/backend + frontend: + build: + dockerfile_inline: | + FROM nginx + RUN mkdir /data + COPY frontend /data/frontend + depends_on: + - backend + develop: + watch: + - path: frontend + action: rebuild diff --git a/pkg/e2e/watch_test.go b/pkg/e2e/watch_test.go index 1716be05d03..7b55681909d 100644 --- a/pkg/e2e/watch_test.go +++ b/pkg/e2e/watch_test.go @@ -368,6 +368,61 @@ func TestWatchMultiServices(t *testing.T) { c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9") } +func TestWatchRebuildIgnoresDependencies(t *testing.T) { + c := NewCLI(t) + const projectName = "test_watch_rebuild_deps" + + defer c.cleanupWithDown(t, projectName) + + tmpdir := t.TempDir() + composeFilePath := filepath.Join(tmpdir, "compose.yaml") + CopyFile(t, filepath.Join("fixtures", "watch", "rebuild-deps.yaml"), composeFilePath) + + for _, svc := range []string{"backend", "frontend"} { + assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, svc), []byte("v1"), 0o600)) + } + + cmd := c.NewDockerComposeCmd(t, "-p", projectName, "-f", composeFilePath, "up", "--build", "--watch") + buffer := bytes.NewBuffer(nil) + cmd.Stdout = buffer + watch := icmd.StartCmd(cmd) + t.Cleanup(func() { + if watch.Cmd.Process != nil { + _ = watch.Cmd.Process.Kill() + } + }) + + poll.WaitOn(t, func(l poll.LogT) poll.Result { + if strings.Contains(watch.Stdout(), "Attaching to ") { + return poll.Success() + } + return poll.Continue("%v", watch.Stdout()) + }, poll.WithTimeout(90*time.Second)) + + containerID := func(service string) string { + res := c.RunDockerComposeCmd(t, "-p", projectName, "ps", "-q", service) + return strings.TrimSpace(res.Stdout()) + } + backendID := containerID("backend") + assert.Assert(t, backendID != "") + + t.Log("editing frontend code only") + assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "frontend"), []byte("v2"), 0o600)) + + poll.WaitOn(t, func(l poll.LogT) poll.Result { + cat := c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "exec", "frontend", "cat", "/data/frontend") + if strings.Contains(cat.Stdout(), "v2") { + return poll.Success() + } + return poll.Continue("%v", cat.Combined()) + }, poll.WithTimeout(90*time.Second)) + + t.Log("backend must not be rebuilt nor recreated") + assert.Equal(t, backendID, containerID("backend")) + + c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9") +} + func TestWatchIncludes(t *testing.T) { c := NewCLI(t) const projectName = "test_watch_includes"