refactor: flatten watch trigger preparation and initial sync walk

watch (cognitive complexity 52) inlined three passes over each
service's triggers; they become prepareRebuildTriggers (validation +
always-build marking), watchTriggerPaths (paths to monitor, bind-mount
skips, initial sync) and initialSyncRequested (the DEPRECATED
x-initialSync fallback). initialSyncFiles (34) delegates its WalkDir
closure to initialSyncDirectory, keeping the Info() call order so
error behavior on unreadable entries is unchanged.

No behavior change: same validations, same warnings, same collected
paths and sync mappings.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 13:37:48 +02:00
committed by Nicolas De loof
co-authored by Claude Fable 5
parent f46228a609
commit 68ab763674
+114 -86
View File
@@ -186,9 +186,6 @@ func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping {
}
}
// FIXME(ndeloof) complete migration to gocognit
//
//nolint:gocognit
func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) {
var err error
if project, err = project.WithSelectedServices(options.Services); err != nil {
@@ -218,47 +215,16 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, opti
continue
}
for _, trigger := range config.Watch {
if trigger.Action == types.WatchActionRebuild {
if service.Build == nil {
return nil, fmt.Errorf("can't watch service %q with action %s without a build context", service.Name, types.WatchActionRebuild)
}
if options.Build == nil {
return nil, fmt.Errorf("--no-build is incompatible with watch action %s in service %s", types.WatchActionRebuild, service.Name)
}
// set the service to always be built - watch triggers `Up()` when it receives a rebuild event
service.PullPolicy = types.PullPolicyBuild
project.Services[serviceName] = service
}
service, err = prepareRebuildTriggers(project, serviceName, service, config, options)
if err != nil {
return nil, err
}
for _, trigger := range config.Watch {
if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
logrus.Warnf("path '%s' also declared by a bind mount volume, this path won't be monitored!\n", trigger.Path)
continue
} else {
shouldInitialSync := trigger.InitialSync
// Check legacy extension attribute for backward compatibility
if !shouldInitialSync {
var legacyInitialSync bool
success, err := trigger.Extensions.Get("x-initialSync", &legacyInitialSync)
if err == nil && success && legacyInitialSync {
shouldInitialSync = true
logrus.Warnf("x-initialSync is DEPRECATED, please use the official `initial_sync` attribute\n")
}
}
if shouldInitialSync && isSync(trigger) {
// Need to check that initial files meant to be synced from the watch action are in the container
err := s.initialSync(ctx, project, service, trigger, syncer)
if err != nil {
return nil, err
}
}
}
paths = append(paths, trigger.Path)
triggerPaths, err := s.watchTriggerPaths(ctx, project, service, config, syncer)
if err != nil {
return nil, err
}
paths = append(paths, triggerPaths...)
serviceWatchRules, err := getWatchRules(config, service)
if err != nil {
@@ -295,6 +261,63 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, opti
}, nil
}
// prepareRebuildTriggers validates rebuild watch actions and marks the
// service to always be built — watch triggers `Up()` when it receives a
// rebuild event. It returns the possibly-updated service config.
func prepareRebuildTriggers(project *types.Project, serviceName string, service types.ServiceConfig, config *types.DevelopConfig, options api.WatchOptions) (types.ServiceConfig, error) {
for _, trigger := range config.Watch {
if trigger.Action != types.WatchActionRebuild {
continue
}
if service.Build == nil {
return service, fmt.Errorf("can't watch service %q with action %s without a build context", service.Name, types.WatchActionRebuild)
}
if options.Build == nil {
return service, fmt.Errorf("--no-build is incompatible with watch action %s in service %s", types.WatchActionRebuild, service.Name)
}
service.PullPolicy = types.PullPolicyBuild
project.Services[serviceName] = service
}
return service, nil
}
// watchTriggerPaths collects the paths to monitor for a service, skipping
// (with a warning) those already covered by a bind mount volume, and runs the
// initial sync for sync triggers requesting one.
func (s *composeService) watchTriggerPaths(ctx context.Context, project *types.Project, service types.ServiceConfig, config *types.DevelopConfig, syncer sync.Syncer) ([]string, error) {
var paths []string
for _, trigger := range config.Watch {
if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
logrus.Warnf("path '%s' also declared by a bind mount volume, this path won't be monitored!\n", trigger.Path)
continue
}
if initialSyncRequested(trigger) && isSync(trigger) {
// Need to check that initial files meant to be synced from the watch action are in the container
err := s.initialSync(ctx, project, service, trigger, syncer)
if err != nil {
return nil, err
}
}
paths = append(paths, trigger.Path)
}
return paths, nil
}
// initialSyncRequested tells whether a sync trigger requests an initial sync,
// honoring the DEPRECATED x-initialSync extension attribute
func initialSyncRequested(trigger types.Trigger) bool {
if trigger.InitialSync {
return true
}
var legacyInitialSync bool
success, err := trigger.Extensions.Get("x-initialSync", &legacyInitialSync)
if err == nil && success && legacyInitialSync {
logrus.Warnf("x-initialSync is DEPRECATED, please use the official `initial_sync` attribute\n")
return true
}
return false
}
func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([]watchRule, error) {
var rules []watchRule
@@ -777,10 +800,6 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project
}
// Syncs files from develop.watch.path if they have been modified after the image has been created
//
// FIXME(ndeloof) complete migration to gocognit
//
//nolint:gocognit
func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
fi, err := os.Stat(trigger.Path)
if err != nil {
@@ -790,57 +809,66 @@ func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Pr
if err != nil {
return nil, err
}
var pathsToCopy []*sync.PathMapping
switch mode := fi.Mode(); {
case mode.IsDir():
// process directory
err = filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// handle possible path err, just in case...
return err
}
if trigger.Path == path {
// walk starts at the root directory
return nil
}
if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
// By definition sync ignores bind mounted paths
if d.IsDir() {
// skip folder
return fs.SkipDir
}
return nil // skip file
}
info, err := d.Info()
if err != nil {
return err
}
if !d.IsDir() {
if info.ModTime().Before(timeImageCreated) {
// skip file if it was modified before image creation
return nil
}
rel, err := filepath.Rel(trigger.Path, path)
if err != nil {
return err
}
// only copy files (and not full directories)
pathsToCopy = append(pathsToCopy, &sync.PathMapping{
HostPath: path,
ContainerPath: filepath.Join(trigger.Target, rel),
})
}
return nil
})
return initialSyncDirectory(trigger, service, ignore, timeImageCreated)
case mode.IsRegular():
// process file
if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
pathsToCopy = append(pathsToCopy, &sync.PathMapping{
return []*sync.PathMapping{{
HostPath: trigger.Path,
ContainerPath: trigger.Target,
})
}}, nil
}
}
return nil, nil
}
// initialSyncDirectory collects the files of a watched directory which were
// modified after the image was created, skipping ignored and bind-mounted
// paths
func initialSyncDirectory(trigger types.Trigger, service types.ServiceConfig, ignore watch.PathMatcher, timeImageCreated time.Time) ([]*sync.PathMapping, error) {
var pathsToCopy []*sync.PathMapping
err := filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// handle possible path err, just in case...
return err
}
if trigger.Path == path {
// walk starts at the root directory
return nil
}
if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
// By definition sync ignores bind mounted paths
if d.IsDir() {
// skip folder
return fs.SkipDir
}
return nil // skip file
}
info, err := d.Info()
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if info.ModTime().Before(timeImageCreated) {
// skip file if it was modified before image creation
return nil
}
rel, err := filepath.Rel(trigger.Path, path)
if err != nil {
return err
}
// only copy files (and not full directories)
pathsToCopy = append(pathsToCopy, &sync.PathMapping{
HostPath: path,
ContainerPath: filepath.Join(trigger.Target, rel),
})
return nil
})
return pathsToCopy, err
}