fix(drive): keep pull partial results on stdout
This commit is contained in:
@@ -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 <dentryUuid>
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 即可判断成败。
|
||||
|
||||
### 把本地文件夹推送(镜像)到钉盘
|
||||
|
||||
|
||||
@@ -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 即可判断成败。
|
||||
|
||||
### 把本地文件夹推送(镜像)到钉盘
|
||||
|
||||
|
||||
Reference in New Issue
Block a user