feat(file): add +batch for multi-file single-commit operations

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>
This commit is contained in:
1os21ka23r9navae6mrro 2026-07-08 14:52:09 +00:00
parent 2d1e75da16
commit 9588c113cf
5 changed files with 124 additions and 0 deletions

View File

@ -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

View File

@ -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",

View File

@ -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": "从本地文件读取内容",

View File

@ -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)
},
}
}

View File

@ -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")
}
}