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