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 <david.gageot@docker.com>
This commit is contained in:
David Gageot 2026-06-19 09:22:33 +02:00
parent 0afb4c8c4c
commit caa454e04a
No known key found for this signature in database
4 changed files with 111 additions and 1 deletions

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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"