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:
parent
2d1e75da16
commit
9588c113cf
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "从本地文件读取内容",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue