diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index acbd8bfc..b5455fb6 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -3319,7 +3319,7 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 预览,确认后以相同参数追加 --yes 执行。 输出 summary(downloaded/skipped/failed)与逐文件 items。 -若有文件下载失败,命令以非零退出码退出(error.type=partial_failure)。`, +若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`, Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart dws drive pull --local-folder /abs/path/repo --remote-folder --space-id xxxx`, diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 1a8d3894..ceeea8c5 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -2,7 +2,6 @@ package helpers import ( "context" - "encoding/json" "fmt" "os" "path/filepath" @@ -23,8 +22,8 @@ import ( // // 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到 // --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定 -// overwrite / smart / skip。summary.failed > 0 时以非零退出码退出,并在 -// error.type=partial_failure / error.detail 里返回同一份 summary + items。 +// overwrite / smart / skip。结构化 summary + items 始终打印到 stdout; +// summary.failed > 0 时额外以非零退出码退出。 // ────────────────────────────────────────────────────────── // --if-exists 的三种策略。 @@ -74,13 +73,14 @@ type drivePullDryRunResult struct { Plan drivePullResult `json:"plan"` } -// drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把 -// {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。 -// 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。 -type drivePartialFailure struct{ raw string } +// drivePartialFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, +// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。 +type drivePartialFailure struct{ failed int } -func (e *drivePartialFailure) Error() string { return e.raw } -func (e *drivePartialFailure) RawStderr() string { return e.raw } +func (e *drivePartialFailure) Error() string { + return fmt.Sprintf("drive pull: %d file(s) failed", e.failed) +} +func (e *drivePartialFailure) RawStderr() string { return e.Error() } func (e *drivePartialFailure) ExitCode() int { return 1 } // pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个 @@ -233,18 +233,14 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { res.Items = append(res.Items, item) } - if res.Summary.Failed > 0 { - // partial_failure:非零退出码,summary+items 一并放进 error.detail。 - payload := map[string]any{ - "error": map[string]any{ - "type": "partial_failure", - "detail": res, - }, - } - b, _ := json.Marshal(payload) - return &drivePartialFailure{raw: string(b)} + // 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。 + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr } - return deps.Out.PrintJSON(res) + if res.Summary.Failed > 0 { + return &drivePartialFailure{failed: res.Summary.Failed} + } + return nil } func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error { diff --git a/internal/helpers/drive_pull_coverage_test.go b/internal/helpers/drive_pull_coverage_test.go index f59c93b5..8809f9b5 100644 --- a/internal/helpers/drive_pull_coverage_test.go +++ b/internal/helpers/drive_pull_coverage_test.go @@ -150,13 +150,25 @@ func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testi SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) t.Cleanup(func() { SetHTTPGetFile(nil) }) - err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT") + var out strings.Builder + 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": root, "remote-folder": "ROOT"}) + err := runDrivePull(cmd, nil) var pf *drivePartialFailure if !errors.As(err, &pf) { t.Fatalf("expected drivePartialFailure, got %T %v", err, err) } - if !strings.Contains(pf.raw, "partial_failure") { - t.Errorf("detail missing partial_failure: %s", pf.raw) + if pf.failed != 1 || pf.RawStderr() != "drive pull: 1 file(s) failed" { + t.Errorf("partial failure = %#v, stderr = %q", pf, pf.RawStderr()) + } + stdout := out.String() + if !strings.Contains(stdout, `"failed": 1`) || !strings.Contains(stdout, `"rel_path": "a.txt"`) { + t.Errorf("stdout must retain the structured partial result: %s", stdout) } } diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go index 83233eca..f7543bab 100644 --- a/internal/helpers/drive_pull_test.go +++ b/internal/helpers/drive_pull_test.go @@ -371,16 +371,16 @@ func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) { } // ────────────────────────────────────────────────────────── -// drivePartialFailure — pull 部分失败错误:exit=1,JSON 原样透传 +// drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) { - raw := `{"error":{"type":"partial_failure","detail":{}}}` - e := &drivePartialFailure{raw: raw} + e := &drivePartialFailure{failed: 2} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) } - if e.Error() != raw || e.RawStderr() != raw { - t.Errorf("Error()/RawStderr() should return the raw JSON verbatim") + want := "drive pull: 2 file(s) failed" + if e.Error() != want || e.RawStderr() != want { + t.Errorf("Error()/RawStderr() = %q / %q, want %q", e.Error(), e.RawStderr(), want) } } diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index e2655b68..c8c3cfbf 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -134,6 +134,21 @@ func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) // PrintJSON 失败分支 // ────────────────────────────────────────────────────────── +func TestCrossPlatformCoverageDrivePull_printFailurePropagates(t *testing.T) { + dir := t.TempDir() + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: failingWriter{}}} + 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": dir, "remote-folder": "ROOT"}) + if err := runDrivePull(cmd, nil); err == nil { + t.Fatal("expected the PrintJSON writer failure to propagate") + } +} + func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 6ee96349..5289cf76 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -437,7 +437,7 @@ Flags: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 - 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 -- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 ### 把本地文件夹推送(镜像)到钉盘 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index e9d26c24..04703443 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -422,7 +422,7 @@ Flags: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 - 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 -- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 ### 把本地文件夹推送(镜像)到钉盘