diff --git a/docs/pages/configuration/dev/connections/file-sync.mdx b/docs/pages/configuration/dev/connections/file-sync.mdx index 348e8994d3..a2c2e4328a 100644 --- a/docs/pages/configuration/dev/connections/file-sync.mdx +++ b/docs/pages/configuration/dev/connections/file-sync.mdx @@ -294,6 +294,12 @@ The `initialSync` option expects a string with an initial sync strategy. The fol `disabled` disabled the initial sync completely +:::caution Remote-preferred conflicts +When resolving an initial-sync conflict, `mirrorRemote` and `preferRemote` replace the local file even if its last-modified timestamp is newer than the container file's timestamp. Save any local changes you want to keep before starting the sync. Use `preferNewest` to resolve conflicts by modification time instead. + +This remote preference applies only to initial sync. Subsequent downloads retain the normal protection against overwriting newer local edits. +::: + ```yaml deployments: app-backend: diff --git a/pkg/devspace/sync/initial_test.go b/pkg/devspace/sync/initial_test.go new file mode 100644 index 0000000000..9796cffedd --- /dev/null +++ b/pkg/devspace/sync/initial_test.go @@ -0,0 +1,105 @@ +package sync + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/loft-sh/devspace/helper/remote" + "github.com/loft-sh/devspace/helper/server" + "github.com/loft-sh/devspace/pkg/devspace/config/versions/latest" + "github.com/loft-sh/devspace/pkg/util/log" +) + +func TestInitialSyncRemotePreference(t *testing.T) { + for _, testCase := range []struct { + strategy latest.InitialSyncStrategy + want string + }{ + {latest.InitialSyncStrategyMirrorRemote, "remote"}, + {latest.InitialSyncStrategyPreferRemote, "remote"}, + {latest.InitialSyncStrategyPreferNewest, "local"}, + {latest.InitialSyncStrategyMirrorLocal, "local"}, + {latest.InitialSyncStrategyPreferLocal, "local"}, + {latest.InitialSyncStrategyKeepAll, "local"}, + {latest.InitialSyncStrategyDisabled, "local"}, + } { + t.Run(string(testCase.strategy), func(t *testing.T) { + local, remotePath := t.TempDir(), t.TempDir() + localFile := filepath.Join(local, "conflict.txt") + remoteFile := filepath.Join(remotePath, "conflict.txt") + writeFile := func(filename, content string, modified time.Time) { + t.Helper() + if err := os.WriteFile(filename, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filename, modified, modified); err != nil { + t.Fatal(err) + } + } + writeFile(localFile, "local", time.Unix(2000000000, 0)) + writeFile(remoteFile, "remote", time.Unix(1700000000, 0)) + + client, err := NewSync(context.Background(), local, Options{ + InitialSync: testCase.strategy, + UpstreamDisabled: true, + Log: log.Discard, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { client.Stop(nil) }) + // Download-only sync still marks the upstream initial-sync state complete. + client.upstream = &upstream{} + + clientConn, serverConn := net.Pipe() + t.Cleanup(func() { + _ = clientConn.Close() + _ = serverConn.Close() + }) + go func() { + if err := server.StartDownstreamServer(serverConn, serverConn, &server.DownstreamOptions{ + RemotePath: remotePath, + Polling: true, + }); err != nil { + panic(err) + } + }() + if err := client.InitDownstream(clientConn, clientConn); err != nil { + t.Fatal(err) + } + uploadDone := make(chan struct{}) + if err := client.initialSync(uploadDone, nil); err != nil { + t.Fatal(err) + } + <-uploadDone + content, err := os.ReadFile(localFile) + if err != nil { + t.Fatal(err) + } + if string(content) != testCase.want { + t.Errorf("initial sync: got %q, want %q", content, testCase.want) + } + + // Later downloads must continue to protect a newer, pending local edit. + writeFile(localFile, "pending local edit", time.Unix(2000000000, 0)) + writeFile(remoteFile, "later remote edit", time.Unix(1700000001, 0)) + if err := client.downstream.applyChanges([]*remote.Change{{ + ChangeType: remote.ChangeType_CHANGE, + Path: "/conflict.txt", + }}, false); err != nil { + t.Fatal(err) + } + content, err = os.ReadFile(localFile) + if err != nil { + t.Fatal(err) + } + if string(content) != "pending local edit" { + t.Errorf("normal sync overwrote a newer local edit: %q", content) + } + }) + } +} diff --git a/pkg/devspace/sync/sync.go b/pkg/devspace/sync/sync.go index 251021ffec..57936efb79 100644 --- a/pkg/devspace/sync/sync.go +++ b/pkg/devspace/sync/sync.go @@ -275,6 +275,12 @@ func (s *Sync) startDownstream() { } func (s *Sync) initialSync(onInitUploadDone chan struct{}, onInitDownloadDone chan struct{}) error { + // Remote preference overrides mtimes only during initial sync, not while watching. + s.downstream.unarchiver.forceOverride = s.Options.InitialSync == latest.InitialSyncStrategyMirrorRemote || s.Options.InitialSync == latest.InitialSyncStrategyPreferRemote + defer func() { + s.downstream.unarchiver.forceOverride = false + }() + initialSync := newInitialSyncer(&initialSyncOptions{ LocalPath: s.LocalPath, Strategy: s.Options.InitialSync,