From 52416fcdecd1bfbe05c02ef554bc8690c1b78fba Mon Sep 17 00:00:00 2001 From: orivital Date: Sat, 12 Sep 2026 21:51:23 +0900 Subject: [PATCH 1/2] fix(watch): exclude Dockerfile and compose files from the sync loop initialSync already skipped these after #14117, but getWatchRules never did. A later edit was still copied into the container. Anchor the shared ignore with **/ so it matches the watch loop's absolute paths, and apply it only to copy actions so rebuild still fires. Signed-off-by: orivital --- pkg/compose/watch.go | 48 +++++++++++++++++++++--------- pkg/compose/watch_test.go | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 8b6b5bd8f6..66d12235e4 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -336,6 +336,11 @@ func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([] return nil, err } + dockerFileIgnore, err := dockerFileIgnoreMatcher(service) + if err != nil { + return nil, err + } + for _, trigger := range config.Watch { ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore) if err != nil { @@ -352,15 +357,22 @@ func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([] } } + ignores := []watch.PathMatcher{ + dockerIgnores, + watch.EphemeralPathMatcher(), + dotGitIgnore, + ignore, + } + // Copy actions only: rebuild on the same tree must still fire. + switch trigger.Action { + case types.WatchActionSync, types.WatchActionSyncRestart, types.WatchActionSyncExec: + ignores = append(ignores, dockerFileIgnore) + } + rules = append(rules, watchRule{ Trigger: trigger, include: include, - ignore: watch.NewCompositeMatcher( - dockerIgnores, - watch.EphemeralPathMatcher(), - dotGitIgnore, - ignore, - ), + ignore: watch.NewCompositeMatcher(ignores...), service: service.Name, }) } @@ -760,6 +772,21 @@ func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, proje } } +// **/anchored so the matcher hits both initialSync's basenames and the +// watch loop's absolute host paths. +func dockerFileIgnoreMatcher(service types.ServiceConfig) (watch.PathMatcher, error) { + names := append([]string{"Dockerfile"}, cli.DefaultFileNames...) + names = append(names, cli.DefaultOverrideFileNames...) + if service.Build != nil && service.Build.Dockerfile != "" { + names = append(names, filepath.Base(service.Build.Dockerfile)) + } + patterns := make([]string, len(names)) + for i, name := range names { + patterns[i] = "**/" + name + } + return watch.NewDockerPatternMatcher("/", patterns) +} + // Walks develop.watch.path and checks which files should be copied inside the container // ignores develop.watch.ignore, Dockerfile, compose files, bind mounted paths and .git func (s *composeService) initialSync(ctx context.Context, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error { @@ -778,14 +805,7 @@ func (s *composeService) initialSync(ctx context.Context, service types.ServiceC return err } - // also exclude override compose files and any custom-named Dockerfile - dockerFilePatterns := append([]string{"Dockerfile"}, cli.DefaultFileNames...) - dockerFilePatterns = append(dockerFilePatterns, cli.DefaultOverrideFileNames...) - if service.Build != nil && service.Build.Dockerfile != "" { - dockerFilePatterns = append(dockerFilePatterns, filepath.Base(service.Build.Dockerfile)) - } - - dockerFileIgnore, err := watch.NewDockerPatternMatcher("/", dockerFilePatterns) + dockerFileIgnore, err := dockerFileIgnoreMatcher(service) if err != nil { return err } diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 963b5180b5..5c5b3ef1ab 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -316,6 +316,67 @@ func TestInitialSync_ExcludesNestedCustomNamedDockerfile(t *testing.T) { }}) } +// getWatchRules historically never excluded Dockerfile/compose files (see +// #14117). The continuous loop matches absolute host paths, so the same +// basename-only matcher initialSync used would miss them. +func TestGetWatchRules_ExcludesDockerfileAndComposeFilesFromSync(t *testing.T) { + rules, err := getWatchRules(&types.DevelopConfig{ + Watch: []types.Trigger{{ + Path: "/proj", + Action: types.WatchActionSync, + Target: "/app", + }}, + }, types.ServiceConfig{Name: "svc"}) + assert.NilError(t, err) + assert.Equal(t, 1, len(rules)) + + for _, name := range []string{"Dockerfile", "compose.yaml", "docker-compose.yml", "compose.override.yml"} { + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/"+name)) == nil, name) + } + + got := rules[0].Matches(watch.NewFileEvent("/proj/app.go")) + assert.DeepEqual(t, got, &sync.PathMapping{ + HostPath: "/proj/app.go", + ContainerPath: "/app/app.go", + }) +} + +func TestGetWatchRules_ExcludesCustomNamedDockerfileFromSync(t *testing.T) { + rules, err := getWatchRules(&types.DevelopConfig{ + Watch: []types.Trigger{{ + Path: "/proj", + Action: types.WatchActionSync, + Target: "/app", + }}, + }, types.ServiceConfig{ + Name: "svc", + Build: &types.BuildConfig{Context: t.TempDir(), Dockerfile: "docker/Dockerfile.prod"}, + }) + assert.NilError(t, err) + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/docker/Dockerfile.prod")) == nil) + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/app.go")) != nil) +} + +func TestGetWatchRules_CopyActionsExcludeDockerfile(t *testing.T) { + rules, err := getWatchRules(&types.DevelopConfig{ + Watch: []types.Trigger{ + {Path: "/proj", Action: types.WatchActionSync, Target: "/app"}, + {Path: "/proj", Action: types.WatchActionRebuild}, + {Path: "/proj", Action: types.WatchActionSyncExec, Target: "/app"}, + }, + }, types.ServiceConfig{ + Name: "svc", + Build: &types.BuildConfig{Context: t.TempDir()}, + }) + assert.NilError(t, err) + assert.Equal(t, 3, len(rules)) + + event := watch.NewFileEvent("/proj/Dockerfile") + assert.Assert(t, rules[0].Matches(event) == nil) + assert.Assert(t, rules[1].Matches(event) != nil) + assert.Assert(t, rules[2].Matches(event) == nil) +} + // TestPruneDanglingImagesOnRebuild verifies the post-rebuild prune only // removes superseded dangling images: a dangling image whose ID matches one // of the freshly built images must be spared. The lookup used to probe the From fe986b565b811751dc501d8cdb839f7c2c8518b6 Mon Sep 17 00:00:00 2001 From: orivital Date: Tue, 15 Sep 2026 21:14:26 +0900 Subject: [PATCH 2/2] fix(watch): ignore only the service Dockerfile in the sync loop Default compose filenames and an unused Dockerfile stay eligible for sync. initialSync still excludes compose files. Signed-off-by: orivital --- pkg/compose/watch.go | 22 ++++++++++++++-------- pkg/compose/watch_test.go | 19 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 66d12235e4..82fa20aaa8 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -775,16 +775,14 @@ func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, proje // **/anchored so the matcher hits both initialSync's basenames and the // watch loop's absolute host paths. func dockerFileIgnoreMatcher(service types.ServiceConfig) (watch.PathMatcher, error) { - names := append([]string{"Dockerfile"}, cli.DefaultFileNames...) - names = append(names, cli.DefaultOverrideFileNames...) - if service.Build != nil && service.Build.Dockerfile != "" { - names = append(names, filepath.Base(service.Build.Dockerfile)) + if service.Build == nil { + return watch.EmptyMatcher{}, nil } - patterns := make([]string, len(names)) - for i, name := range names { - patterns[i] = "**/" + name + name := service.Build.Dockerfile + if name == "" { + name = "Dockerfile" } - return watch.NewDockerPatternMatcher("/", patterns) + return watch.NewDockerPatternMatcher("/", []string{"**/" + filepath.Base(name)}) } // Walks develop.watch.path and checks which files should be copied inside the container @@ -810,11 +808,19 @@ func (s *composeService) initialSync(ctx context.Context, service types.ServiceC return err } + composeFiles := append([]string{}, cli.DefaultFileNames...) + composeFiles = append(composeFiles, cli.DefaultOverrideFileNames...) + composeFileIgnore, err := watch.NewDockerPatternMatcher("/", composeFiles) + if err != nil { + return err + } + ignoreInitialSync := watch.NewCompositeMatcher( dockerIgnores, watch.EphemeralPathMatcher(), dotGitIgnore, dockerFileIgnore, + composeFileIgnore, triggerIgnore) pathsToCopy, err := s.initialSyncFiles(service, trigger, ignoreInitialSync) diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 5c5b3ef1ab..f045ccb583 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -316,23 +316,25 @@ func TestInitialSync_ExcludesNestedCustomNamedDockerfile(t *testing.T) { }}) } -// getWatchRules historically never excluded Dockerfile/compose files (see -// #14117). The continuous loop matches absolute host paths, so the same -// basename-only matcher initialSync used would miss them. -func TestGetWatchRules_ExcludesDockerfileAndComposeFilesFromSync(t *testing.T) { +// getWatchRules historically never excluded the service Dockerfile (see +// #14117). The continuous loop matches absolute host paths, so a +// basename-only matcher would miss them. +func TestGetWatchRules_ExcludesDockerfileFromSync(t *testing.T) { rules, err := getWatchRules(&types.DevelopConfig{ Watch: []types.Trigger{{ Path: "/proj", Action: types.WatchActionSync, Target: "/app", }}, - }, types.ServiceConfig{Name: "svc"}) + }, types.ServiceConfig{ + Name: "svc", + Build: &types.BuildConfig{Context: t.TempDir()}, + }) assert.NilError(t, err) assert.Equal(t, 1, len(rules)) - for _, name := range []string{"Dockerfile", "compose.yaml", "docker-compose.yml", "compose.override.yml"} { - assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/"+name)) == nil, name) - } + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/Dockerfile")) == nil) + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/compose.yaml")) != nil) got := rules[0].Matches(watch.NewFileEvent("/proj/app.go")) assert.DeepEqual(t, got, &sync.PathMapping{ @@ -354,6 +356,7 @@ func TestGetWatchRules_ExcludesCustomNamedDockerfileFromSync(t *testing.T) { }) assert.NilError(t, err) assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/docker/Dockerfile.prod")) == nil) + assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/Dockerfile")) != nil) assert.Assert(t, rules[0].Matches(watch.NewFileEvent("/proj/app.go")) != nil) }