diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 8b6b5bd8f6..82fa20aaa8 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,19 @@ 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) { + if service.Build == nil { + return watch.EmptyMatcher{}, nil + } + name := service.Build.Dockerfile + if name == "" { + name = "Dockerfile" + } + return watch.NewDockerPatternMatcher("/", []string{"**/" + filepath.Base(name)}) +} + // 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 +803,14 @@ 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 := dockerFileIgnoreMatcher(service) + if err != nil { + return err } - dockerFileIgnore, err := watch.NewDockerPatternMatcher("/", dockerFilePatterns) + composeFiles := append([]string{}, cli.DefaultFileNames...) + composeFiles = append(composeFiles, cli.DefaultOverrideFileNames...) + composeFileIgnore, err := watch.NewDockerPatternMatcher("/", composeFiles) if err != nil { return err } @@ -795,6 +820,7 @@ func (s *composeService) initialSync(ctx context.Context, service types.ServiceC 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 963b5180b5..f045ccb583 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -316,6 +316,70 @@ func TestInitialSync_ExcludesNestedCustomNamedDockerfile(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", + Build: &types.BuildConfig{Context: t.TempDir()}, + }) + assert.NilError(t, err) + assert.Equal(t, 1, len(rules)) + + 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{ + 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/Dockerfile")) != 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