From 9588c113cf5cd0fa57549adbef6caec46e2d4d42 Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 14:52:09 +0000 Subject: [PATCH] feat(file): add +batch for multi-file single-commit operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contents/batch endpoint accepts multiple file operations per commit, but each existing shortcut sent exactly one. file +batch takes a JSON spec (array of {action_type, file_path, content, encoding}) and applies them atomically in one commit: gitlink-cli file +batch -s spec.json -b master -m 'batch ops' Client-side validation: action_type must be create|update|delete, file_path required. content/encoding are normalized for delete entries — the server rejects entries missing content with '请输入正确的文件参数Files' (production-reproduced). Production-verified on gitlink.org.cn: 2-file create in one commit, then 2-file delete in one commit. Unit test covers payload shape, delete normalization, and action_type validation. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 5 +++ internal/i18n/locales/en-US.json | 2 + internal/i18n/locales/zh-CN.json | 2 + shortcuts/file/file.go | 73 ++++++++++++++++++++++++++++++++ shortcuts/file/file_test.go | 42 ++++++++++++++++++ 5 files changed, 124 insertions(+) diff --git a/README.md b/README.md index 8f52be16..172f348d 100644 --- a/README.md +++ b/README.md @@ -700,6 +700,11 @@ gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b # Delete a file gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" + +# Multiple file operations in a single commit (JSON spec) +# spec.json: [{"action_type":"create","file_path":"a.txt","content":"A"}, +# {"action_type":"delete","file_path":"old.txt"}] +gitlink-cli file +batch --owner me --repo proj -s spec.json -b master -m "batch ops" ``` ### Raw API diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 26476b23..b7a89777 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -34,6 +34,7 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", + "cmd.file.batch.short": "Apply multiple file operations in a single commit", "cmd.file.create.short": "Create a new file in the repository", "cmd.file.delete.short": "Delete a file from the repository", "cmd.file.search.short": "Search files in the repository by name", @@ -151,6 +152,7 @@ "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", "flag.dry_run": "Preview the request without creating it", + "flag.file.batch_spec": "Path to a JSON array of file operations: [{action_type, file_path, content, encoding}]", "flag.file.branch": "Branch to commit to", "flag.file.content": "File content", "flag.file.content_file": "Read file content from a local file", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b283f9d3..6e3a4194 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -34,6 +34,7 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", + "cmd.file.batch.short": "在单个提交中应用多个文件操作", "cmd.file.create.short": "在仓库中创建文件", "cmd.file.delete.short": "删除仓库中的文件", "cmd.file.search.short": "按文件名搜索仓库文件", @@ -151,6 +152,7 @@ "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", "flag.dry_run": "预览请求,不实际创建", + "flag.file.batch_spec": "文件操作 JSON 数组路径:[{action_type, file_path, content, encoding}]", "flag.file.branch": "提交到的分支", "flag.file.content": "文件内容", "flag.file.content_file": "从本地文件读取内容", diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go index bccf48c6..5ee6be27 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -2,6 +2,7 @@ package file import ( "encoding/base64" + "encoding/json" "fmt" "net/url" "os" @@ -19,6 +20,78 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { writeShortcut(tr, "create"), writeShortcut(tr, "update"), deleteShortcut(tr), + batchShortcut(tr), + } +} + +func batchShortcut(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "batch", + Description: tr.T("cmd.file.batch.short"), + Flags: []common.Flag{ + {Name: "spec", Short: "s", Usage: tr.T("flag.file.batch_spec"), Required: true}, + {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, + {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, + {Name: "message", Short: "m", Usage: tr.T("flag.file.message"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + specPath, err := ctx.RequireArg("spec") + if err != nil { + return err + } + branch, err := ctx.RequireArg("branch") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + data, err := os.ReadFile(specPath) + if err != nil { + return fmt.Errorf("read spec file: %w", err) + } + var files []map[string]interface{} + if err := json.Unmarshal(data, &files); err != nil { + return fmt.Errorf("spec must be a JSON array of file operations: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("spec contains no file operations") + } + for i, f := range files { + action, _ := f["action_type"].(string) + switch action { + case "create", "update", "delete": + default: + return fmt.Errorf("files[%d]: action_type must be create, update, or delete; got %q", i, action) + } + if path, _ := f["file_path"].(string); path == "" { + return fmt.Errorf("files[%d]: file_path is required", i) + } + if _, ok := f["content"]; !ok { + f["content"] = "" + } + if _, ok := f["encoding"]; !ok { + f["encoding"] = "text" + } + } + payload := map[string]interface{}{ + "files": files, + "branch": branch, + "message": message, + } + if nb := ctx.Arg("new-branch"); nb != "" { + payload["new_branch"] = nb + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, } } diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go index ef3e5f46..4ba62bfd 100644 --- a/shortcuts/file/file_test.go +++ b/shortcuts/file/file_test.go @@ -219,3 +219,45 @@ func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Fatalf("failed to write response: %v", err) } } + +func TestFileBatchPostsAllOperations(t *testing.T) { + dir := t.TempDir() + spec := filepath.Join(dir, "spec.json") + os.WriteFile(spec, []byte(`[ + {"action_type": "create", "file_path": "a.txt", "content": "A"}, + {"action_type": "delete", "file_path": "b.txt"} +]`), 0600) + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&payload) + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc"}}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "batch", map[string]string{ + "spec": spec, "branch": "master", "message": "batch ops", + }) + if err != nil { + t.Fatalf("batch shortcut failed: %v", err) + } + files := payload["files"].([]interface{}) + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } + del := files[1].(map[string]interface{}) + if del["action_type"] != "delete" || del["content"] != "" || del["encoding"] != "text" { + t.Fatalf("delete entry not normalized: %v", del) + } + + bad := filepath.Join(dir, "bad.json") + os.WriteFile(bad, []byte(`[{"action_type": "rename", "file_path": "x"}]`), 0600) + if err := runFileShortcut(t, server, "batch", map[string]string{ + "spec": bad, "branch": "master", "message": "m", + }); err == nil { + t.Fatal("expected error for invalid action_type") + } +}