mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
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).
651 lines
26 KiB
Go
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
|
|
}
|