fix(drive): make folder dry-run side-effect free
This commit is contained in:
@@ -65,6 +65,15 @@ type drivePullResult struct {
|
||||
Items []drivePullItem `json:"items"`
|
||||
}
|
||||
|
||||
type drivePullDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
IfExists string `json:"if_exists"`
|
||||
Plan drivePullResult `json:"plan"`
|
||||
}
|
||||
|
||||
// drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把
|
||||
// {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。
|
||||
// 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。
|
||||
@@ -174,6 +183,9 @@ func runDrivePull(cmd *cobra.Command, _ []string) error {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
if deps.Caller.DryRun() {
|
||||
return printDrivePullDryRun(absDir, ifExists, remote, relPaths)
|
||||
}
|
||||
|
||||
// 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上
|
||||
// A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、
|
||||
@@ -234,6 +246,50 @@ func runDrivePull(cmd *cobra.Command, _ []string) error {
|
||||
return deps.Out.PrintJSON(res)
|
||||
}
|
||||
|
||||
func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string) error {
|
||||
plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
collided := detectTargetCollisions(absDir, relPaths, caseInsensitive)
|
||||
for _, rel := range relPaths {
|
||||
if collided[rel] {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePullItem{
|
||||
RelPath: rel, Action: pullActionFailed,
|
||||
Error: "多个远端条目在目标平台默认文件系统上映射到同一本地路径,计划已拒绝",
|
||||
})
|
||||
continue
|
||||
}
|
||||
localPath, err := resolveLocalTarget(absDir, rel)
|
||||
if err != nil {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
action := pullActionDownloaded
|
||||
if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pullActionSkipped
|
||||
case ifExistsSmart:
|
||||
rf := remote[rel]
|
||||
if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
action = pullActionSkipped
|
||||
}
|
||||
}
|
||||
}
|
||||
if action == pullActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
} else {
|
||||
plan.Summary.Downloaded++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action})
|
||||
}
|
||||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
|
||||
// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。
|
||||
// 返回动作分类(downloaded / skipped / failed)及失败时的 error。
|
||||
func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) {
|
||||
|
||||
@@ -64,6 +64,15 @@ type drivePushResult struct {
|
||||
Items []drivePushItem `json:"items"`
|
||||
}
|
||||
|
||||
type drivePushDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
IfExists string `json:"if_exists"`
|
||||
Plan drivePushResult `json:"plan"`
|
||||
}
|
||||
|
||||
// drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。
|
||||
type drivePushFailure struct{ failed int }
|
||||
@@ -111,6 +120,9 @@ func runDrivePush(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||||
if deps.Caller.DryRun() {
|
||||
return printDrivePushDryRun(ifExists, remoteFiles, remoteFolders, localDirs, localFiles)
|
||||
}
|
||||
|
||||
// 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。
|
||||
for _, dir := range localDirs {
|
||||
@@ -224,7 +236,7 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f
|
||||
args["nextToken"] = nextToken
|
||||
}
|
||||
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", args)
|
||||
text, err := callDriveListFiles(ctx, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -275,6 +287,61 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error {
|
||||
plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||||
plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs))
|
||||
for rel, fileID := range remoteFolders {
|
||||
plannedFolders[rel] = fileID
|
||||
}
|
||||
for _, dir := range localDirs {
|
||||
if _, ok := plannedFolders[dir]; ok {
|
||||
continue
|
||||
}
|
||||
parentRel, _ := splitRel(dir)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
}
|
||||
for _, lf := range localFiles {
|
||||
size := lf.Size
|
||||
parentRel, _ := splitRel(lf.RelPath)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
action := pushActionUploaded
|
||||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pushActionSkipped
|
||||
case ifExistsSmart:
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
action = pushActionSkipped
|
||||
} else {
|
||||
action = pushActionOverwritten
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
}
|
||||
}
|
||||
if action == pushActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
} else {
|
||||
plan.Summary.Uploaded++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
return deps.Out.PrintJSON(drivePushDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
|
||||
// walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前)
|
||||
// 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。
|
||||
func walkLocalForPush(root string) ([]string, []localPushFile, error) {
|
||||
|
||||
@@ -163,6 +163,10 @@ func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName str
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (m *driveSyncMockCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return m.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (m *driveSyncMockCaller) Format() string { return "raw" }
|
||||
func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun }
|
||||
func (m *driveSyncMockCaller) Fields() string { return "" }
|
||||
|
||||
@@ -121,7 +121,7 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m
|
||||
args["nextToken"] = nextToken
|
||||
}
|
||||
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", args)
|
||||
text, err := callDriveListFiles(ctx, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,6 +180,15 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m
|
||||
return nil
|
||||
}
|
||||
|
||||
// callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取
|
||||
// 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。
|
||||
func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPReadToolReturnTextOnServer(ctx, "drive", "list_files", args)
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "list_files", args)
|
||||
}
|
||||
|
||||
// driveItem 是 list_files 返回里的一个 dentry 条目。
|
||||
// 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。
|
||||
type driveItem map[string]any
|
||||
|
||||
@@ -42,6 +42,10 @@ func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName strin
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (m *driveScriptCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return m.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (m *driveScriptCaller) Format() string { return "raw" }
|
||||
func (m *driveScriptCaller) DryRun() bool { return m.dryRun }
|
||||
func (m *driveScriptCaller) Fields() string { return "" }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -134,6 +135,149 @@ func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "not-created")
|
||||
caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`)
|
||||
caller.dryRun = true
|
||||
SetHTTPGetFile(func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("dry-run must not download")
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { SetHTTPGetFile(nil) })
|
||||
|
||||
var out bytes.Buffer
|
||||
prevDeps, prevArgs := deps, os.Args
|
||||
deps = &Deps{Caller: caller, Out: &Formatter{w: &out}}
|
||||
os.Args = []string{"dws", "drive", "pull"}
|
||||
t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs })
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run pull: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run must not create local root: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")); got != 0 {
|
||||
t.Fatalf("download_file calls = %d, want 0", got)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) {
|
||||
t.Fatalf("missing pull plan: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local")
|
||||
caller := syncCaller(nil)
|
||||
caller.dryRun = true
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
|
||||
t.Error("dry-run must not upload")
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
var out bytes.Buffer
|
||||
prevDeps, prevArgs := deps, os.Args
|
||||
deps = &Deps{Caller: caller, Out: &Formatter{w: &out}}
|
||||
os.Args = []string{"dws", "drive", "push"}
|
||||
t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs })
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run push: %v", err)
|
||||
}
|
||||
for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} {
|
||||
if got := len(caller.callsFor(tool)); got != 0 {
|
||||
t.Fatalf("%s calls = %d, want 0", tool, got)
|
||||
}
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) {
|
||||
t.Fatalf("missing push plan: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
||||
mustWrite(t, filepath.Join(root, "smart.txt"), "smart")
|
||||
remote := map[string]*remoteFile{
|
||||
"A.txt": {RelPath: "A.txt"},
|
||||
"a.txt": {RelPath: "a.txt"},
|
||||
"../escape": {RelPath: "../escape"},
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
||||
"new.txt": {RelPath: "new.txt"},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
prevDeps := deps
|
||||
deps = &Deps{Out: &Formatter{w: &out}}
|
||||
t.Cleanup(func() { deps = prevDeps })
|
||||
|
||||
if err := printDrivePullDryRun(root, ifExistsSkip, remote,
|
||||
[]string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pullActionSkipped) {
|
||||
t.Fatalf("smart plan must skip newer local file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
prevDeps := deps
|
||||
deps = &Deps{Out: &Formatter{w: &out}}
|
||||
t.Cleanup(func() { deps = prevDeps })
|
||||
remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"}
|
||||
remoteFiles := map[string]*remoteFile{
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true},
|
||||
"smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
||||
"overwrite.txt": {RelPath: "overwrite.txt"},
|
||||
}
|
||||
files := func(paths ...string) []localPushFile {
|
||||
result := make([]localPushFile, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders,
|
||||
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil,
|
||||
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
|
||||
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
||||
t.Fatalf("overwrite plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pushActionOverwritten) {
|
||||
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 2. 明确确认后才发出精确的工具调用
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user