diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 39c94f7972..bbf155141a 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -248,7 +248,7 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, opti if shouldInitialSync && isSync(trigger) { // Need to check initial files are in container that are meant to be synced from watch action - err := s.initialSync(ctx, project, service, trigger, syncer) + err := s.initialSync(ctx, service, trigger, syncer) if err != nil { return nil, err } @@ -730,7 +730,7 @@ func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, proje // 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, project *types.Project, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error { +func (s *composeService) initialSync(ctx context.Context, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error { dockerIgnores, err := watch.LoadDockerIgnore(service.Build) if err != nil { return err @@ -752,7 +752,7 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project dotGitIgnore, triggerIgnore) - pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync) + pathsToCopy, err := s.initialSyncFiles(service, trigger, ignoreInitialSync) if err != nil { return err } @@ -760,18 +760,12 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project return syncer.Sync(ctx, service.Name, pathsToCopy) } -// Syncs files from develop.watch.path if thy have been modified after the image has been created -// -//nolint:gocyclo -func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) { +// Syncs files from develop.watch.path, ignoring bind-mounted and excluded paths. +func (s *composeService) initialSyncFiles(service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) { fi, err := os.Stat(trigger.Path) if err != nil { return nil, err } - timeImageCreated, err := s.imageCreatedTime(ctx, project, service.Name) - if err != nil { - return nil, err - } var pathsToCopy []*sync.PathMapping switch mode := fi.Mode(); { case mode.IsDir(): @@ -793,15 +787,7 @@ func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Pr } 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 @@ -816,7 +802,7 @@ func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Pr }) case mode.IsRegular(): // process file - if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) { + if !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) { pathsToCopy = append(pathsToCopy, &sync.PathMapping{ HostPath: trigger.Path, ContainerPath: trigger.Target, @@ -831,28 +817,3 @@ func shouldIgnore(name string, ignore watch.PathMatcher) bool { // ignore files that match any ignore pattern return shouldIgnore } - -// gets the image creation time for a service -func (s *composeService) imageCreatedTime(ctx context.Context, project *types.Project, serviceName string) (time.Time, error) { - res, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{ - All: true, - Filters: projectFilter(project.Name).Add("label", serviceFilter(serviceName)), - }) - if err != nil { - return time.Now(), err - } - if len(res.Items) == 0 { - return time.Now(), fmt.Errorf("could not get created time for service's image") - } - - img, err := s.apiClient().ImageInspect(ctx, res.Items[0].ImageID) - if err != nil { - return time.Now(), err - } - // Need to get the oldest one? - timeCreated, err := time.Parse(time.RFC3339Nano, img.Created) - if err != nil { - return time.Now(), err - } - return timeCreated, nil -} diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 0c59b884ba..a3d97f5abf 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -19,6 +19,7 @@ import ( "context" "fmt" "os" + "path/filepath" "slices" "testing" "time" @@ -194,3 +195,93 @@ func (f *fakeSyncer) Sync(ctx context.Context, service string, paths []*sync.Pat f.synced <- paths return nil } + +func TestInitialSyncFiles_DirectoryIncludesExistingFilesEvenIfOlderThanImage(t *testing.T) { + dir := t.TempDir() + hostDir := filepath.Join(dir, "src") + assert.NilError(t, os.MkdirAll(hostDir, 0o755)) + hostFile := filepath.Join(hostDir, "test.txt") + assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644)) + + fileTime := time.Now().Add(-24 * time.Hour) + assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime)) + + service := composeService{} + trigger := types.Trigger{ + Path: hostDir, + Action: types.WatchActionSync, + Target: "/app/src", + InitialSync: true, + } + + got, err := service.initialSyncFiles(types.ServiceConfig{Name: "test"}, trigger, watch.EmptyMatcher{}) + assert.NilError(t, err) + + expected := []*sync.PathMapping{{ + HostPath: hostFile, + ContainerPath: "/app/src/test.txt", + }} + assert.DeepEqual(t, expected, got) +} + +func TestInitialSyncFiles_FileIncludesExistingFileEvenIfOlderThanImage(t *testing.T) { + dir := t.TempDir() + hostFile := filepath.Join(dir, "test.txt") + assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644)) + + fileTime := time.Now().Add(-24 * time.Hour) + assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime)) + + service := composeService{} + trigger := types.Trigger{ + Path: hostFile, + Action: types.WatchActionSync, + Target: "/app/test.txt", + InitialSync: true, + } + + got, err := service.initialSyncFiles(types.ServiceConfig{Name: "test"}, trigger, watch.EmptyMatcher{}) + assert.NilError(t, err) + + expected := []*sync.PathMapping{{ + HostPath: hostFile, + ContainerPath: "/app/test.txt", + }} + assert.DeepEqual(t, expected, got) +} + +func TestInitialSync_SyncsExistingFilesEvenIfOlderThanImage(t *testing.T) { + dir := t.TempDir() + hostDir := filepath.Join(dir, "src") + assert.NilError(t, os.MkdirAll(hostDir, 0o755)) + hostFile := filepath.Join(hostDir, "test.txt") + assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644)) + + fileTime := time.Now().Add(-24 * time.Hour) + assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime)) + + service := composeService{} + syncer := newFakeSyncer() + trigger := types.Trigger{ + Path: hostDir, + Action: types.WatchActionSync, + Target: "/app/src", + InitialSync: true, + } + serviceConfig := types.ServiceConfig{ + Name: "test", + Build: &types.BuildConfig{Context: dir}, + } + + errCh := make(chan error, 1) + go func() { + errCh <- service.initialSync(t.Context(), serviceConfig, trigger, syncer) + }() + + expected := []*sync.PathMapping{{ + HostPath: hostFile, + ContainerPath: "/app/src/test.txt", + }} + assert.DeepEqual(t, expected, <-syncer.synced) + assert.NilError(t, <-errCh) +}