Files
larksuite-cli/shortcuts/drive/drive_sync.go
fangshuyu-768 df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00

651 lines
26 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveSyncOnConflictLocalWins = "local-wins"
driveSyncOnConflictRemoteWins = "remote-wins"
driveSyncOnConflictKeepBoth = "keep-both"
driveSyncOnConflictAsk = "ask"
)
type driveSyncItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
}
// DriveSync performs a two-way sync between a local directory and a Drive
// folder. It computes a diff (like +status), then:
// - new_remote → pull (download to local)
// - new_local → push (upload to Drive)
// - modified → resolve by --on-conflict strategy:
// local-wins: push local over remote;
// remote-wins: pull remote over local;
// keep-both: rename the local file with a hash suffix and pull the remote;
// ask: prompt the user per conflict.
var DriveSync = common.Shortcut{
Service: "drive",
Command: "+sync",
Description: "Two-way sync between a local directory and a Drive folder",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{
"drive:file:download",
"drive:file:upload",
"space:folder:create",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
{Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"},
},
Tips: []string{
"Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.",
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
onConflict := strings.TrimSpace(runtime.Str("on-conflict"))
if onConflict == "" {
onConflict = driveSyncOnConflictRemoteWins
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
quick := runtime.Bool("quick")
if !quick {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
// --- Phase 1: Compute diff (same logic as +status) ---
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// A local regular file at the same rel_path as a remote
// folder/docx/shortcut is a type conflict: +sync would
// classify it as new_local and attempt to upload, which either
// fails at the API or leaves the remote in a broken state
// (same rel_path with mixed types). Detect early and hard-fail.
// Symmetrically, a local directory at the same rel_path as a
// remote file/docx/shortcut would attempt create_folder and
// produce the same broken mixed-type state.
var typeConflicts []string
for _, entry := range entries {
if entry.Type == driveTypeFile {
continue
}
if _, hasLocal := localFiles[entry.RelPath]; hasLocal {
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type))
}
}
// Check local directories vs remote non-folder entries.
// localDirs is not available yet (walked later), so check
// the filesystem directly for the subset of remote paths
// that are non-folder.
for _, entry := range entries {
if entry.Type == driveTypeFolder {
continue
}
dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
if info, err := os.Stat(dirPath); err == nil && info.IsDir() { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type))
}
}
if len(typeConflicts) > 0 {
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
}
// Build the exact remote-file views that later execution will use so the
// diff phase classifies files against the same duplicate-resolution choice.
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified []driveStatusEntry
var unchanged []driveStatusEntry
for _, relPath := range paths {
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if quick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
detection := driveStatusDetectionExact
if quick {
detection = driveStatusDetectionQuick
}
fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n",
len(newLocal), len(newRemote), len(modified), len(unchanged), detection)
conflictResolutions := make(map[string]string, len(modified))
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
}
for _, entry := range modified {
resolved := onConflict
if resolved == driveSyncOnConflictAsk {
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
if err != nil {
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": 0,
"pushed": 0,
"skipped": 0,
"failed": 1,
},
"items": []driveSyncItem{{
RelPath: entry.RelPath,
FileToken: entry.FileToken,
Action: "failed",
Direction: "conflict",
Error: err.Error(),
}},
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
Detail: payload,
},
}
}
}
conflictResolutions[entry.RelPath] = resolved
}
// --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int
items := make([]driveSyncItem, 0)
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
if len(plannedUploads) > 0 {
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
return err
}
}
// Build push infrastructure: local walk for push + remote views + folder cache.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Walk local filesystem early so we can include empty directories
// in the scope preflight (they also need space:folder:create).
pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
return err
}
}
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
pushed++
}
// 2a. Pull new_remote files.
for _, entry := range newRemote {
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
// Non-file type (doc, shortcut, etc.) — skip.
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
}
// 2b. Push new_local files.
for _, entry := range newLocal {
localFile, ok := pushLocalFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
skipped++
continue
}
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
pushed++
}
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
remoteFile := remoteFiles[entry.RelPath]
localFile, hasLocal := pushLocalFiles[entry.RelPath]
if !hasLocal {
// Should not happen — modified means both sides exist.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"})
skipped++
continue
}
resolved := conflictResolutions[entry.RelPath]
if resolved == "" {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"})
skipped++
continue
}
switch resolved {
case driveSyncOnConflictRemoteWins:
// Pull remote over local.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
case driveSyncOnConflictLocalWins:
// Push local over remote.
existingToken := remoteFile.FileToken
if existingToken == "" {
if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok {
existingToken = chosen.FileToken
}
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
if upErr != nil {
// Token contract on overwrite failure (same as +push):
// a partial-success response can return a non-empty
// file_token alongside an error. Prefer the freshly
// returned token when one was produced, fall back to
// existingToken otherwise.
failedToken := token
if failedToken == "" {
failedToken = existingToken
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
pushed++
case driveSyncOnConflictKeepBoth:
// Rename the local file with a hash suffix, then pull the remote.
// Use the remote file token to generate a stable suffix (same
// pattern as +pull --on-duplicate-remote=rename).
occupied := occupiedRemotePaths(entries)
// Add current local paths to occupied set so the renamed
// local file doesn't collide with an existing file or directory.
for p := range pushLocalFiles {
occupied[p] = struct{}{}
}
for _, relDir := range localDirs {
occupied[relDir] = struct{}{}
}
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
if err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
failed++
continue
}
// Rename the local file.
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
failed++
continue
}
occupied[suffixedRel] = struct{}{}
// Now pull the remote version to the original path.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := "remote file not found in pull views after rename"
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := err.Error()
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
default:
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)})
skipped++
}
}
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": pulled,
"pushed": pushed,
"skipped": skipped,
"failed": failed,
},
"items": items,
}
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile {
remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles))
for relPath, target := range pullRemoteFiles {
fileToken := target.ItemFileToken
if fileToken == "" {
fileToken = target.DownloadToken
}
remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime}
}
return remoteFiles
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
if runtime.IO().In == nil {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
}
reader, ok := runtime.IO().In.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(runtime.IO().In)
runtime.IO().In = reader
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer == "" {
if errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
}
return driveSyncOnConflictRemoteWins, nil
}
switch answer {
case "l", "local", "local-wins":
return driveSyncOnConflictLocalWins, nil
case "k", "keep", "keep-both":
return driveSyncOnConflictKeepBoth, nil
case "s", "skip":
return "", nil
case "r", "remote", "remote-wins":
return driveSyncOnConflictRemoteWins, nil
default:
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
}
}
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
if len(newRemote) > 0 {
return true
}
for _, entry := range modified {
switch conflictResolutions[entry.RelPath] {
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
return true
}
}
return false
}
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
planned := make([]string, 0, len(newLocal)+len(modified))
for _, entry := range newLocal {
planned = append(planned, entry.RelPath)
}
for _, entry := range modified {
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
planned = append(planned, entry.RelPath)
}
}
return planned
}
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
for _, relPath := range uploadPaths {
parentRel := drivePushParentRel(relPath)
if parentRel == "" {
continue
}
if _, ok := folderCache[parentRel]; !ok {
return true
}
}
// Empty local directories also need create_folder if not already on Drive.
for _, relDir := range localDirs {
if _, ok := folderCache[relDir]; !ok {
return true
}
}
return false
}
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
}
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
}
} else if !os.IsNotExist(err) {
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
}
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
}
return nil
}