package helpers import ( "bufio" "context" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "github.com/spf13/cobra" ) // keep-both 的本地保留副本通过同一个固定父目录 os.Root 原子建立硬链接;以下 seam // 仅用于文件系统失败与并发替换的确定性测试,测试必须通过 testseam.Swap 替换。 var ( syncRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) } syncPullOneFilePinned = pullOneFilePinned ) // ========================================================== // drive sync — 本地与钉盘双向同步(本地 ⇄ Drive) // ========================================================== // ────────────────────────────────────────────────────────── // dws drive sync — 把 --local-folder 与 --remote-folder 做文件级双向同步。 // // 复用 status 的差异判定(exact MD5 / --quick modified_time)先算出 diff,再按方向执行: // new_local 仅本地存在 → push(缺失的远端目录按需创建,再上传) // new_remote 仅钉盘存在 → pull(下载到本地对应路径) // modified 两侧都变更 → 按 --on-conflict 解决: // skip 默认:两侧都不动并保留两边内容 // remote-wins :拉取远端覆盖本地 // local-wins :上传本地覆盖远端 // keep-both :本地改名保留,再拉取远端到原路径 // ask :交互式逐个询问 // unknown exact 模式远端无可靠 md5、内容无法核对 → 跳过(记 skipped,提示改用 --quick) // unchanged 两侧一致 → 不动 // // 文件级同步——只新增/覆盖,不删除任何一侧的多余文件。summary.failed > 0 时以非零 // 退出码退出,结构化 summary + diff + items 仍打印在 stdout 上。 // ────────────────────────────────────────────────────────── // --on-conflict 的五种冲突解决策略。 const ( syncConflictLocalWins = "local-wins" // 上传本地覆盖远端 syncConflictRemoteWins = "remote-wins" // 拉取远端覆盖本地 syncConflictKeepBoth = "keep-both" // 本地改名保留,再拉取远端到原路径 syncConflictAsk = "ask" // 交互式逐个询问 syncConflictSkip = "skip" // 默认:两侧都变更时什么都不做,两边内容都保留 ) // sync 动作分类(direction 标注每条记录属于哪个方向)。 const ( syncDirectionPull = "pull" syncDirectionPush = "push" syncDirectionConflict = "conflict" syncActionDownloaded = "downloaded" syncActionUploaded = "uploaded" syncActionOverwritten = "overwritten" syncActionFolderCreated = "folder_created" syncActionRenamedLocal = "renamed_local" syncActionSkipped = "skipped" syncActionFailed = "failed" syncActionPlannedDownload = "planned_download" syncActionPlannedUpload = "planned_upload" syncActionPlannedOverwrite = "planned_overwrite" syncActionPlannedFolderCreate = "planned_folder_create" syncActionPlannedRenameLocal = "planned_rename_local" syncActionPlannedSkip = "planned_skip" syncActionDecisionRequired = "decision_required" ) // driveSyncItem 是输出 items[] 中每条操作的明细。 type driveSyncItem struct { RelPath string `json:"rel_path"` Action string `json:"action"` Direction string `json:"direction,omitempty"` Error string `json:"error,omitempty"` } // driveSyncSummary 是各方向的计数汇总。 type driveSyncSummary struct { Pulled int `json:"pulled"` Pushed int `json:"pushed"` Skipped int `json:"skipped"` Failed int `json:"failed"` PlannedPulls int `json:"planned_pulls,omitempty"` PlannedPushes int `json:"planned_pushes,omitempty"` PlannedSkips int `json:"planned_skips,omitempty"` PlannedFolders int `json:"planned_folders,omitempty"` } // driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。 type driveSyncDiff struct { NewLocal []driveStatusEntry `json:"new_local"` NewRemote []driveStatusEntry `json:"new_remote"` Modified []driveStatusEntry `json:"modified"` Unchanged []driveStatusEntry `json:"unchanged"` Unknown []driveStatusEntry `json:"unknown"` } // driveSyncResult 是 sync 命令的输出 schema。 type driveSyncResult struct { Detection string `json:"detection"` Diff driveSyncDiff `json:"diff"` Summary driveSyncSummary `json:"summary"` Items []driveSyncItem `json:"items"` } type driveSyncDryRunResult struct { DryRun bool `json:"dry_run"` Executed bool `json:"executed"` PreviewKind string `json:"preview_kind"` Operation string `json:"operation"` Plan driveSyncResult `json:"plan"` } // driveSyncFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, // 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 drivePushFailure 一致)。 type driveSyncFailure struct{ failed int } func (e *driveSyncFailure) Error() string { return fmt.Sprintf("drive sync: %d item(s) failed", e.failed) } func (e *driveSyncFailure) RawStderr() string { return e.Error() } func (e *driveSyncFailure) ExitCode() int { return 1 } func runDriveSync(cmd *cobra.Command, _ []string) error { if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil { return err } localDir := mustGetFlag(cmd, "local-folder") remoteDirID := mustGetFlag(cmd, "remote-folder") // space-id 可选:不传则由底层使用「我的文件」对应的空间。 spaceID := mustGetFlag(cmd, "space-id") onConflict, _ := cmd.Flags().GetString("on-conflict") if onConflict == "" { // 安全默认:两侧都变更时不擅自覆盖任何一侧。 onConflict = syncConflictSkip } switch onConflict { case syncConflictSkip, syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk: default: return fmt.Errorf("--on-conflict 取值非法: %s(可选 skip|local-wins|remote-wins|keep-both|ask)", onConflict) } quick, _ := cmd.Flags().GetBool("quick") detection := "exact" if quick { detection = "quick" } absDir, err := validateLocalDirAbs(localDir) if err != nil { return err } pinnedRoot, err := openExistingPinnedPullRoot(absDir) if err != nil { return fmt.Errorf("扫描本地目录失败: %w", err) } defer pinnedRoot.close() // sync 的本地快照与后续 hash / upload / pull 全部绑定到同一个固定根。 localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot) if err != nil { return fmt.Errorf("扫描本地目录失败: %w", err) } ctx := cmd.Context() // 远端现状:文件(含 hash/mtime/fileId)与目录(rel_path → fileId,"" 即根本身)。 remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID) if err != nil { return err } localByRel := make(map[string]localPushFile, len(localFiles)) for _, f := range localFiles { localByRel[f.RelPath] = f } preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders) addPinnedLocalTargetPreflightFailures(pinnedRoot, remoteFiles, preflight) // 在计算 diff 前先识别文件/目录同路径冲突。冲突根及其全部后代都不能再进入 // new_local / new_remote 或后续执行阶段,否则会在远端制造同名异类对象,或尝试 // 把远端目录内容下载到本地文件之下。 typeConflicts := make(map[string]string) for rel, msg := range preflight { typeConflicts[rel] = msg } for _, dir := range localDirs { if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { typeConflicts[dir] = msg } } for rel := range localByRel { if msg := pushTypeConflictError(rel, false, remoteFiles, remoteFolders); msg != "" { typeConflicts[rel] = msg } } // 计算 diff:双端都存在的文件复用 judgeFileMatch,保持与 status 完全一致的判定。 diff := driveSyncDiff{ NewLocal: []driveStatusEntry{}, NewRemote: []driveStatusEntry{}, Modified: []driveStatusEntry{}, Unchanged: []driveStatusEntry{}, Unknown: []driveStatusEntry{}, } var newLocal, newRemote, modified, unknown []string for rel, pf := range localByRel { if syncPathBlockedByTypeConflict(rel, typeConflicts) { continue } rf, ok := remoteFiles[rel] if !ok { newLocal = append(newLocal, rel) continue } verdict, jerr := judgePinnedPushFileMatch(pinnedRoot, pf, rf, quick) if jerr != nil { return jerr } switch verdict { case matchUnchanged: diff.Unchanged = append(diff.Unchanged, driveStatusEntry{RelPath: rel}) case matchUnknown: unknown = append(unknown, rel) default: modified = append(modified, rel) } } for rel := range remoteFiles { if syncPathBlockedByTypeConflict(rel, typeConflicts) { continue } if _, ok := localByRel[rel]; !ok { newRemote = append(newRemote, rel) } } sort.Strings(newLocal) sort.Strings(newRemote) sort.Strings(modified) sort.Strings(unknown) for _, rel := range newLocal { diff.NewLocal = append(diff.NewLocal, driveStatusEntry{RelPath: rel}) } for _, rel := range newRemote { diff.NewRemote = append(diff.NewRemote, driveStatusEntry{RelPath: rel}) } for _, rel := range modified { diff.Modified = append(diff.Modified, driveStatusEntry{RelPath: rel}) } for _, rel := range unknown { diff.Unknown = append(diff.Unknown, driveStatusEntry{RelPath: rel}) } sortEntries(diff.Unchanged) res := driveSyncResult{Detection: detection, Diff: diff, Items: []driveSyncItem{}} conflictRels := make([]string, 0, len(typeConflicts)) for rel := range typeConflicts { conflictRels = append(conflictRels, rel) } sort.Strings(conflictRels) for _, rel := range conflictRels { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{ RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: typeConflicts[rel], }) } if err := pinnedRoot.verify(); err != nil { return err } // --dry-run:只算差异、不执行任何同步动作。 if deps.Caller.DryRun() { if len(preflight) == 0 && res.Summary.Failed == 0 { appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{ LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders, NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict, }) } return deps.Out.PrintJSON(driveSyncDryRunResult{ DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res, }) } // 镜像前置检查是整批门禁:发现任一本地非法名称或跨端等价路径歧义后, // sync 不得执行 create/download/upload 等任一写动作。 if len(preflight) > 0 { if perr := deps.Out.PrintJSON(res); perr != nil { return perr } return &driveSyncFailure{failed: res.Summary.Failed} } // unknown:exact 模式下远端无可靠 md5、内容无法核对,不擅自覆盖任何一侧,记 skipped。 for _, rel := range unknown { res.Summary.Skipped++ res.Items = append(res.Items, driveSyncItem{ RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict, Error: "远端无可靠 md5,内容无法核对,已跳过(可改用 --quick 按 modified_time 比对)", }) } // ask 模式:先把所有 modified 的解决策略问全,再统一执行,避免边执行边交互。 resolutions := make(map[string]string, len(modified)) for _, rel := range modified { strategy := onConflict switch strategy { case syncConflictAsk: strategy, err = driveSyncAskConflict(rel) if err != nil { return err } case syncConflictSkip: strategy = "" // 与 ask 选择「跳过」同一条落地路径 } resolutions[rel] = strategy } // 交互决策期间本地根也可能被替换;首个双端写动作前再次核对固定身份。 if err := pinnedRoot.verify(); err != nil { return err } // occupied:keep-both 生成不冲突的本地重命名目标时用,覆盖两侧全部已知路径。 occupied := make(map[string]bool) for rel := range localByRel { occupied[rel] = true } for _, d := range localDirs { occupied[d] = true } for rel := range remoteFiles { occupied[rel] = true } for rel := range remoteFolders { if rel != "" { occupied[rel] = true } } // 阶段 1:镜像本地目录结构到远端(缺失则创建),保证空目录与 push 目标的父目录先存在。 for _, dir := range localDirs { if _, ok := remoteFolders[dir]; ok { continue // 远端已存在,复用 fileId } parentRel, name := splitRel(dir) parentID, ok := remoteFolders[parentRel] if !ok || parentID == "" { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) continue } if err := pinnedRoot.verify(); err != nil { return err } fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name) if cerr != nil || fid == "" { res.Summary.Failed++ msg := "create_folder 未返回 fileId" if cerr != nil { msg = cerr.Error() } res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: msg}) continue } remoteFolders[dir] = fid res.Summary.Pushed++ res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFolderCreated, Direction: syncDirectionPush}) } // 阶段 2:new_remote 下载到本地。 for _, rel := range newRemote { syncPullFilePinned(&res, ctx, spaceID, remoteFiles[rel], pinnedRoot, rel, syncDirectionPull) } // 阶段 3:new_local 上传到远端。 for _, rel := range newLocal { pf := localByRel[rel] syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, "") } // 阶段 4:modified 按 --on-conflict 解决。 for _, rel := range modified { pf := localByRel[rel] rf := remoteFiles[rel] switch resolutions[rel] { case syncConflictRemoteWins: syncPullFilePinned(&res, ctx, spaceID, rf, pinnedRoot, rel, syncDirectionPull) case syncConflictLocalWins: syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, rf.FileID) case syncConflictKeepBoth: syncKeepBothPinned(&res, ctx, spaceID, rf, pinnedRoot, rel, occupied) default: // "" — ask 选择跳过 res.Summary.Skipped++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict}) } } // 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。 if perr := deps.Out.PrintJSON(res); perr != nil { return perr } if res.Summary.Failed > 0 { return &driveSyncFailure{failed: res.Summary.Failed} } return nil } type driveSyncDryRunPlanInput struct { LocalDirs []string LocalByRel map[string]localPushFile RemoteFiles map[string]*remoteFile RemoteFolders map[string]string NewLocal []string NewRemote []string Modified []string Unknown []string OnConflict string } func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) { for _, rel := range input.Unknown { res.Summary.PlannedSkips++ res.Items = append(res.Items, driveSyncItem{ RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict, Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)", }) } plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs)) for rel, fileID := range input.RemoteFolders { plannedFolders[rel] = fileID } for _, dir := range input.LocalDirs { if _, ok := plannedFolders[dir]; ok { continue } parentRel, _ := splitRel(dir) if plannedFolders[parentRel] == "" { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) continue } plannedFolders[dir] = "dry-run-planned-folder" res.Summary.PlannedFolders++ res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush}) } for _, rel := range input.NewRemote { res.Summary.PlannedPulls++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) } for _, rel := range input.NewLocal { parentRel, _ := splitRel(rel) if plannedFolders[parentRel] == "" { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) continue } res.Summary.PlannedPushes++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush}) } occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders)) for rel := range input.LocalByRel { occupied[rel] = true } for _, rel := range input.LocalDirs { occupied[rel] = true } for rel := range input.RemoteFiles { occupied[rel] = true } for rel := range input.RemoteFolders { if rel != "" { occupied[rel] = true } } for _, rel := range input.Modified { switch input.OnConflict { case syncConflictRemoteWins: res.Summary.PlannedPulls++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) case syncConflictLocalWins: res.Summary.PlannedPushes++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict}) case syncConflictKeepBoth: candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied) occupied[candidate] = true res.Summary.PlannedPulls++ res.Items = append(res.Items, driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict}, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}, ) case syncConflictAsk: res.Items = append(res.Items, driveSyncItem{ RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict, Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip", }) default: res.Summary.PlannedSkips++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict}) } } } // syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。 // 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。 func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool { for rel != "" { if _, blocked := conflicts[rel]; blocked { return true } rel, _ = splitRel(rel) } return false } // addPinnedLocalTargetPreflightFailures 从 sync 已固定的本地根核对所有远端落盘点。 // 每一层既有祖先都必须是真实目录,末段若存在则必须是常规文件;软链即使仍指向根内 // 也不属于可移植镜像树,必须在任何双端写之前整批拒绝。 func addPinnedLocalTargetPreflightFailures(root *pinnedPullRoot, remote map[string]*remoteFile, failures map[string]string) { for rel := range remote { if mirrorPreflightFailureForRel(rel, failures) != "" { continue } parts := strings.Split(filepath.FromSlash(rel), string(filepath.Separator)) for i := range parts { name := filepath.Join(parts[:i+1]...) info, err := pullRootLstat(root.root, name) if err != nil { if os.IsNotExist(err) { break } failures[rel] = fmt.Sprintf("检查本地目标失败: %v", err) break } if i < len(parts)-1 { if !info.IsDir() { failures[rel] = fmt.Sprintf("本地目标 %q 的祖先 %q 已存在且不是目录,已拒绝镜像", rel, filepath.ToSlash(name)) break } continue } if !info.Mode().IsRegular() { failures[rel] = fmt.Sprintf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", rel) } } } } // syncPullFile 下载单个远端文件到本地 rel 对应路径(总是覆盖,Drive 为该项权威源), // 并把结果计入 res。命中大小写/规范化冲突或逃逸的路径记 failed、不落盘。 func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, direction string) { root, err := openPinnedPullRoot(absDir) if err != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction, Error: err.Error()}) return } defer root.close() syncPullFilePinned(res, ctx, spaceID, rf, root, rel, direction) } func syncPullFilePinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, direction string) { action, perr := pullRemoteFilePinned(ctx, spaceID, rf, root, rel, ifExistsOverwrite) if action == pullActionDownloaded { res.Summary.Pulled++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: direction}) return } res.Summary.Failed++ item := driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction} if perr != nil { item.Error = perr.Error() } res.Items = append(res.Items, item) } // syncPushFile 上传单个本地文件到远端;overwriteID 非空时走覆盖上传(原地覆盖同名远端文件)。 func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, remoteFolders map[string]string, root *pinnedPullRoot, pf localPushFile, rel, overwriteID string) { parentRel, name := splitRel(rel) parentID, ok := remoteFolders[parentRel] if !ok || parentID == "" { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) return } if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, root, pf); err != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: err.Error()}) return } res.Summary.Pushed++ action := syncActionUploaded direction := syncDirectionPush if overwriteID != "" { action = syncActionOverwritten direction = syncDirectionConflict } res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: action, Direction: direction}) } // syncKeepBoth 解决 modified 冲突的 keep-both 策略:先为本地文件原子建立带冲突后缀 // 的 no-clobber 硬链接,再把远端文件拉取到原 rel 路径。拉取失败时保留两个本地名字, // 不执行可能误删或覆盖并发文件的回滚。 func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, occupied map[string]bool) { root, err := openPinnedPullRoot(absDir) if err != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()}) return } defer root.close() syncKeepBothPinned(res, ctx, spaceID, rf, root, rel, occupied) } func syncKeepBothPinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, occupied map[string]bool) { pinned, e1 := root.openTarget(rel) if e1 != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e1.Error()}) return } defer pinned.close() localInfo, err := pinned.regularTargetInfo() if err != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()}) return } if localInfo == nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本不存在"}) return } // Root.Link(original,candidate) 在同一固定父目录中原子 no-clobber 地保留本地版本。 // occupied 的精确字符串查重覆盖不到的文件系统等价名称,由 Link 的 EEXIST 兜底。 suffixedRel, newName, e2 := reserveSyncKeepBothTargetPinned(pinned, rel, rf.FileID, occupied) if e2 != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e2.Error()}) return } savedInfo, err := pullRootLstat(pinned.parentRoot, newName) if err != nil || !sameKeepBothVersion(localInfo, savedInfo) { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本身份不一致"}) return } occupied[suffixedRel] = true action, perr := syncPullOneFilePinned(ctx, spaceID, rf, pinned, ifExistsOverwrite) if action != pullActionDownloaded { res.Summary.Failed++ msg := "" if perr != nil { msg = perr.Error() } if msg == "" { msg = "远端版本未能拉取,本地保留版本已保留" } res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: msg}) return } currentSaved, err := pullRootLstat(pinned.parentRoot, newName) if err != nil || !sameKeepBothVersion(savedInfo, currentSaved) { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本在传输期间被删除、替换或修改"}) return } res.Summary.Pulled++ res.Items = append(res.Items, driveSyncItem{RelPath: suffixedRel, Action: syncActionRenamedLocal, Direction: syncDirectionConflict}, driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: syncDirectionPull}, ) } func sameKeepBothVersion(expected, current os.FileInfo) bool { return expected != nil && current != nil && current.Mode().IsRegular() && os.SameFile(expected, current) && expected.Size() == current.Size() && expected.ModTime().Equal(current.ModTime()) } // syncKeepBothCandidate 生成 keep-both 的第 n 个候选相对路径(n=0 为首选):在扩展名前 // 插入基于远端 fileId 末段的后缀(缺失时用 conflict),n>0 再追加序号消歧。 func syncKeepBothCandidate(rel, fileID string, n int) string { suffix := "conflict" if fileID != "" { s := fileID if len(s) > 8 { s = s[len(s)-8:] } suffix = "conflict-" + s } dir, base := splitRel(rel) ext := filepath.Ext(base) // base 无路径分隔符,取末尾扩展名安全 stem := strings.TrimSuffix(base, ext) name := stem + "." + suffix + ext if n > 0 { name = fmt.Sprintf("%s.%s.%d%s", stem, suffix, n, ext) } if dir == "" { return name } return dir + "/" + name } // driveSyncSuffixedRel 返回首个不在 occupied(纯字符串查重)中的候选。仅用于快速路径与 // 单元测试;keep-both 实际执行走 reserveSyncKeepBothTarget,以 OS 兜底等价性。 func driveSyncSuffixedRel(rel, fileID string, occupied map[string]bool) string { for n := 0; ; n++ { if cand := syncKeepBothCandidate(rel, fileID, n); !occupied[cand] { return cand } } } // reserveSyncKeepBothTarget 生成候选并通过 Root.Link(original,candidate) 原子建立 // no-clobber 本地保留版本。EEXIST(含文件系统等价名称)时换下一个候选。 func reserveSyncKeepBothTarget(absDir, rel, fileID string, occupied map[string]bool) (string, string, error) { target, err := openPinnedPullTarget(absDir, rel) if err != nil { return "", "", err } defer target.close() candidate, _, err := reserveSyncKeepBothTargetPinned(target, rel, fileID, occupied) if err != nil { return "", "", err } return candidate, filepath.Join(absDir, filepath.FromSlash(candidate)), nil } func reserveSyncKeepBothTargetPinned(target *pinnedPullTarget, rel, fileID string, occupied map[string]bool) (string, string, error) { parentRel, _ := splitRel(rel) for n := 0; ; n++ { cand := syncKeepBothCandidate(rel, fileID, n) if occupied[cand] { continue // 本次运行内已知占用,快速跳过 } candidateParent, candidateName := splitRel(cand) if candidateParent != parentRel || !isSafeRemoteSegment(candidateName) { return "", "", fmt.Errorf("keep-both 候选路径 %q 不在原目标目录内", cand) } if err := target.verifyParent(); err != nil { return "", "", err } oerr := syncRootLink(target.parentRoot, target.name, filepath.FromSlash(candidateName)) if oerr == nil { return cand, filepath.FromSlash(candidateName), nil } if os.IsExist(oerr) { occupied[cand] = true // 等价目标已存在,记下避免重试,换下一个后缀 continue } return "", "", oerr } } // syncAskStdin 是 --on-conflict=ask 交互提问的输入源,默认 os.Stdin;测试可替换它以 // 注入非交互(EOF)场景。 var syncAskStdin io.Reader = os.Stdin // driveSyncAskConflict 在 --on-conflict=ask 下交互式询问单个冲突文件的解决策略。 // 返回四种具体策略之一,或 ""(跳过)。当 stdin 在给出选择前结束(管道/无 TTY 等非交互 // 环境)时,按文档约定等价于「跳过」,返回 ""——绝不因此中止整个同步,new_local/new_remote // 等其余差异仍会照常处理。 func driveSyncAskConflict(rel string) (string, error) { fmt.Fprintf(os.Stderr, "冲突: 本地与远端都修改了 %q。请选择 [R]远端优先 / [L]本地优先 / [K]保留两者 / [S]跳过 (默认 R): ", rel) line, err := bufio.NewReader(syncAskStdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", fmt.Errorf("读取冲突选择失败 (%s): %w", rel, err) } switch strings.TrimSpace(strings.ToLower(line)) { case "r", "remote", "remote-wins": return syncConflictRemoteWins, nil case "l", "local", "local-wins": return syncConflictLocalWins, nil case "k", "keep", "keep-both": return syncConflictKeepBoth, nil case "s", "skip": return "", nil case "": if errors.Is(err, io.EOF) { // 非交互(管道/无 TTY):stdin 在给出选择前结束。按文档约定等价于跳过, // 返回 "" 而非报错——否则单个冲突会让 runDriveSync 直接中止,连 // new_local/new_remote 都不再处理。 return "", nil } return syncConflictRemoteWins, nil // 交互式回车 → 默认远端优先 default: return "", fmt.Errorf("无效的冲突选择: %q(可选 remote/local/keep/skip)", strings.TrimSpace(line)) } }