Files
dingtalk-workspace-cli/internal/helpers/drive_sync.go

801 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}