feat(repo): 新增仓库文件批量提交命令

This commit is contained in:
Mengz 2026-06-09 21:55:13 +08:00
parent 71ca2bb683
commit 31accd64d2
5 changed files with 587 additions and 2 deletions

View File

@ -103,7 +103,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
| 📦 Repo | List, create, fork, delete repositories, search files, batch commit file changes, view repo info, insights, and interactions |
| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
@ -227,6 +227,19 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
# Search repository files by name
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
# Commit one file change
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --message "docs: update README" \
--path README.md --content "# Project"
# Commit several file changes from a JSON operations file
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --new-branch docs/batch-update \
--message "docs: batch update" --ops changes.json --dry-run
# Show language breakdown
gitlink-cli repo +languages --owner Gitlink --repo forgeplus

View File

@ -103,7 +103,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| 📦 仓库 | 列出、创建、Fork、删除仓库搜索文件、批量提交文件变更,查看仓库信息、洞察数据和互动状态 |
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
@ -238,6 +238,19 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
# 按文件名搜索仓库文件
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
# 提交单个文件变更
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --message "docs: update README" \
--path README.md --content "# Project"
# 从 JSON 操作文件一次提交多个文件变更
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --new-branch docs/batch-update \
--message "docs: batch update" --ops changes.json --dry-run
# 查看语言占比
gitlink-cli repo +languages --owner Gitlink --repo forgeplus

View File

@ -0,0 +1,81 @@
# Repository File Search and Batch Commit Shortcuts
## Summary
This change adds repository file workflow shortcuts for users and automation agents that need to find files and commit multiple file changes without manually assembling Raw API calls.
## Commands
| Command | Purpose |
|---------|---------|
| `gitlink-cli repo +files` | Search repository files by name with optional branch, tag, or commit filtering |
| `gitlink-cli repo +commit-files` | Commit one file operation or a batch JSON operation list through the `contents/batch` API |
## Examples
Search files on a branch:
```bash
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
```
Update one text file:
```bash
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --message "docs: update README" \
--path README.md --content "# Project"
```
Create or update a binary file by reading local bytes and encoding them as base64:
```bash
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --message "assets: update logo" \
--action update --path assets/logo.png --from ./logo.png --encoding base64
```
Preview a multi-file commit before sending it:
```bash
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
--branch main --new-branch docs/batch-update \
--message "docs: batch update" --ops changes.json --dry-run
```
`changes.json` can be either an array of file operations:
```json
[
{
"action_type": "create",
"file_path": "docs/guide.md",
"content": "# Guide\n",
"encoding": "text"
},
{
"action_type": "delete",
"file_path": "docs/old-guide.md"
}
]
```
or an object with a `files` array. The CLI supplies `branch`, `message`, optional author and committer fields, and optional `new_branch` from command flags.
## Validation
- `repo +commit-files` requires `--branch` and `--message`.
- Single-file mode requires `--path`; `create` and `update` require exactly one of `--content` or `--from`.
- `delete` operations reject `content` and `encoding` so the request body matches the API intent.
- `--encoding` accepts only `text` and `base64`.
- `--ops` cannot be combined with single-file flags.
- Author and committer names must be provided together with their matching email fields.
- `--dry-run` prints the resolved request and does not call the remote API.
## Tests
Unit tests cover file search query mapping, single-file request bodies, base64 local file reading, JSON batch operation files, dry-run behavior, and validation failures that must not perform an API request.
## 中文说明
本次变更补齐了仓库文件工作流中常用的两个能力:先用 `repo +files` 按文件名和分支搜索仓库文件,再用 `repo +commit-files` 把单个或多个文件变更提交到目标分支。批量提交支持创建新分支、设置提交信息、指定作者和提交者、从本地文件读取内容、对二进制内容做 base64 编码,并提供 `--dry-run` 预览请求体适合脚本、CI 和 AI Agent 在真正写入仓库前检查即将提交的内容。验证覆盖了端点路径、查询参数、请求体字段、JSON 批量文件、base64 编码和无效参数不触网等关键路径。

View File

@ -1,8 +1,11 @@
package repo
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"os"
"strconv"
"strings"
@ -108,6 +111,36 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "files",
Description: "Search repository files by name",
Flags: []common.Flag{
{Name: "search", Short: "s", Usage: "File name keyword"},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: runFiles,
},
{
Name: "commit-files",
Description: "Commit one file operation or a batch JSON file through contents/batch",
Flags: []common.Flag{
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "new-branch", Usage: "Create and commit to a new branch"},
{Name: "action", Short: "a", Usage: "Single-file action: create, update, or delete; defaults to update"},
{Name: "path", Short: "p", Usage: "Repository file path for single-file mode"},
{Name: "content", Short: "c", Usage: "Inline file content for single-file mode"},
{Name: "from", Usage: "Read single-file content from a local file"},
{Name: "encoding", Usage: "Content encoding for single-file mode: text or base64; defaults to text"},
{Name: "ops", Usage: "Read batch file operations from a JSON file"},
{Name: "author-name", Usage: "Commit author name"},
{Name: "author-email", Usage: "Commit author email"},
{Name: "committer-name", Usage: "Committer name"},
{Name: "committer-email", Usage: "Committer email"},
{Name: "dry-run", Usage: "Preview the request without committing files", Bool: true, Default: "false"},
},
Run: runCommitFiles,
},
{
Name: "languages",
Description: "Show repository language statistics",
@ -263,6 +296,242 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
return i18n.Default()
}
type repoBatchCommitRequest struct {
Files []repoFileOperation `json:"files"`
AuthorEmail string `json:"author_email,omitempty"`
AuthorName string `json:"author_name,omitempty"`
CommitterEmail string `json:"committer_email,omitempty"`
CommitterName string `json:"committer_name,omitempty"`
Branch string `json:"branch"`
NewBranch string `json:"new_branch,omitempty"`
Message string `json:"message"`
}
type repoFileOperation struct {
ActionType string `json:"action_type"`
Content *string `json:"content,omitempty"`
Encoding string `json:"encoding,omitempty"`
FilePath string `json:"file_path"`
}
func runFiles(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
setRepoQueryIfPresent(q, "search", ctx.Arg("search"))
setRepoQueryIfPresent(q, "ref", ctx.Arg("ref"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runCommitFiles(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
req, err := buildRepoBatchCommitRequest(ctx)
if err != nil {
return err
}
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"method": "POST",
"path": "/v1" + ctx.RepoPath() + "/contents/batch",
"request": req,
})
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", req)
if err != nil {
return err
}
return ctx.Output(env)
}
func buildRepoBatchCommitRequest(ctx *common.RuntimeContext) (repoBatchCommitRequest, error) {
branch, err := requiredRepoString(ctx, "branch")
if err != nil {
return repoBatchCommitRequest{}, err
}
message, err := requiredRepoString(ctx, "message")
if err != nil {
return repoBatchCommitRequest{}, err
}
opsFile := strings.TrimSpace(ctx.Arg("ops"))
files, err := repoCommitFileOperations(ctx, opsFile)
if err != nil {
return repoBatchCommitRequest{}, err
}
req := repoBatchCommitRequest{
Files: files,
Branch: branch,
Message: message,
NewBranch: strings.TrimSpace(ctx.Arg("new-branch")),
AuthorName: strings.TrimSpace(ctx.Arg("author-name")),
AuthorEmail: strings.TrimSpace(ctx.Arg("author-email")),
CommitterName: strings.TrimSpace(ctx.Arg("committer-name")),
CommitterEmail: strings.TrimSpace(ctx.Arg("committer-email")),
}
if err := validateRepoIdentityPair("author", req.AuthorName, req.AuthorEmail); err != nil {
return repoBatchCommitRequest{}, err
}
if err := validateRepoIdentityPair("committer", req.CommitterName, req.CommitterEmail); err != nil {
return repoBatchCommitRequest{}, err
}
return req, nil
}
func repoCommitFileOperations(ctx *common.RuntimeContext, opsFile string) ([]repoFileOperation, error) {
if opsFile != "" {
if repoHasSingleFileArgs(ctx) {
return nil, fmt.Errorf("--ops cannot be combined with --path, --content, --from, --action, or --encoding")
}
return readRepoFileOperations(opsFile)
}
op, err := singleRepoFileOperation(ctx)
if err != nil {
return nil, err
}
return []repoFileOperation{op}, nil
}
func repoHasSingleFileArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"path", "content", "from", "action", "encoding"} {
if strings.TrimSpace(ctx.Arg(name)) != "" {
return true
}
}
return false
}
func readRepoFileOperations(path string) ([]repoFileOperation, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read --ops file: %w", err)
}
var files []repoFileOperation
if err := json.Unmarshal(data, &files); err == nil {
return validateRepoFileOperations(files)
}
var req repoBatchCommitRequest
if err := json.Unmarshal(data, &req); err != nil {
return nil, fmt.Errorf("parse --ops JSON: expected an array of file operations or an object with files: %w", err)
}
return validateRepoFileOperations(req.Files)
}
func singleRepoFileOperation(ctx *common.RuntimeContext) (repoFileOperation, error) {
filePath, err := requiredRepoString(ctx, "path")
if err != nil {
return repoFileOperation{}, err
}
action := strings.TrimSpace(ctx.Arg("action"))
if action == "" {
action = "update"
}
op := repoFileOperation{
ActionType: action,
FilePath: filePath,
Encoding: strings.TrimSpace(ctx.Arg("encoding")),
}
hasContent := ctx.Arg("content") != ""
fromPath := strings.TrimSpace(ctx.Arg("from"))
hasFrom := fromPath != ""
if hasContent && hasFrom {
return repoFileOperation{}, fmt.Errorf("--content and --from cannot be used together")
}
if hasContent {
content := ctx.Arg("content")
op.Content = &content
}
if hasFrom {
content, err := readRepoFileContent(fromPath, op.Encoding)
if err != nil {
return repoFileOperation{}, err
}
op.Content = &content
}
return validateRepoFileOperation(op, 0)
}
func readRepoFileContent(path, encoding string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read --from file: %w", err)
}
if strings.TrimSpace(encoding) == "base64" {
return base64.StdEncoding.EncodeToString(data), nil
}
return string(data), nil
}
func validateRepoFileOperations(files []repoFileOperation) ([]repoFileOperation, error) {
if len(files) == 0 {
return nil, fmt.Errorf("at least one file operation is required")
}
for i := range files {
op, err := validateRepoFileOperation(files[i], i)
if err != nil {
return nil, err
}
files[i] = op
}
return files, nil
}
func validateRepoFileOperation(op repoFileOperation, index int) (repoFileOperation, error) {
prefix := fmt.Sprintf("files[%d]", index)
op.ActionType = strings.TrimSpace(op.ActionType)
op.FilePath = strings.TrimSpace(op.FilePath)
op.Encoding = strings.TrimSpace(op.Encoding)
switch op.ActionType {
case "create", "update", "delete":
default:
return repoFileOperation{}, fmt.Errorf("%s.action_type must be create, update, or delete", prefix)
}
if op.FilePath == "" {
return repoFileOperation{}, fmt.Errorf("%s.file_path is required", prefix)
}
if op.ActionType == "delete" {
if op.Content != nil || op.Encoding != "" {
return repoFileOperation{}, fmt.Errorf("%s delete operation must not include content or encoding", prefix)
}
return op, nil
}
if op.Content == nil {
return repoFileOperation{}, fmt.Errorf("%s content is required for create and update operations", prefix)
}
if op.Encoding == "" {
op.Encoding = "text"
}
if op.Encoding != "text" && op.Encoding != "base64" {
return repoFileOperation{}, fmt.Errorf("%s.encoding must be text or base64", prefix)
}
return op, nil
}
func validateRepoIdentityPair(name, personName, email string) error {
if (personName == "") != (email == "") {
return fmt.Errorf("--%s-name and --%s-email must be provided together", name, name)
}
return nil
}
func requiredRepoString(ctx *common.RuntimeContext, name string) (string, error) {
value := strings.TrimSpace(ctx.Arg(name))
if value == "" {
return "", fmt.Errorf("missing required flag: --%s", name)
}
return value, nil
}
func runLanguages(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err

View File

@ -1,10 +1,12 @@
package repo
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
@ -218,6 +220,198 @@ func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) {
}
}
func TestRepoFilesBuildsSearchAndRefQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/files.json")
assertEqual(t, r.URL.Query().Get("search"), "README")
assertEqual(t, r.URL.Query().Get("ref"), "release/v1")
writeJSON(t, w, []map[string]interface{}{
{"name": "README.md", "path": "README.md", "type": "file"},
})
}))
defer server.Close()
err := runShortcut(t, server, "files", map[string]string{
"search": " README ",
"ref": " release/v1 ",
})
if err != nil {
t.Fatalf("files shortcut failed: %v", err)
}
}
func TestRepoCommitFilesSingleInlineUpdatePostsBatch(t *testing.T) {
var body repoBatchCommitRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc123"}})
}))
defer server.Close()
err := runShortcut(t, server, "commit-files", map[string]string{
"branch": "main",
"new-branch": "docs/update-readme",
"message": "update README",
"path": "README.md",
"content": "# hello\n",
"author-name": "Alice",
"author-email": "alice@example.com",
"committer-name": "Bob",
"committer-email": "bob@example.com",
})
if err != nil {
t.Fatalf("commit-files shortcut failed: %v", err)
}
assertEqual(t, body.Branch, "main")
assertEqual(t, body.NewBranch, "docs/update-readme")
assertEqual(t, body.Message, "update README")
assertEqual(t, body.AuthorName, "Alice")
assertEqual(t, body.AuthorEmail, "alice@example.com")
assertEqual(t, len(body.Files), 1)
assertEqual(t, body.Files[0].ActionType, "update")
assertEqual(t, body.Files[0].FilePath, "README.md")
assertEqual(t, body.Files[0].Encoding, "text")
if body.Files[0].Content == nil || *body.Files[0].Content != "# hello\n" {
t.Fatalf("unexpected content: %#v", body.Files[0].Content)
}
}
func TestRepoCommitFilesReadsLocalFileAsBase64(t *testing.T) {
tempFile := writeTempFile(t, []byte{0x00, 0x01, 0x02, 0xff})
var body repoBatchCommitRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "def456"}})
}))
defer server.Close()
err := runShortcut(t, server, "commit-files", map[string]string{
"branch": "main",
"message": "add binary",
"action": "create",
"path": "assets/logo.bin",
"from": tempFile,
"encoding": "base64",
})
if err != nil {
t.Fatalf("commit-files shortcut failed: %v", err)
}
assertEqual(t, body.Files[0].ActionType, "create")
assertEqual(t, body.Files[0].Encoding, "base64")
want := base64.StdEncoding.EncodeToString([]byte{0x00, 0x01, 0x02, 0xff})
if body.Files[0].Content == nil || *body.Files[0].Content != want {
t.Fatalf("content = %#v, want %q", body.Files[0].Content, want)
}
}
func TestRepoCommitFilesReadsBatchOpsFile(t *testing.T) {
opsFile := writeTempFile(t, []byte(`[
{"action_type":"create","file_path":"docs/a.md","content":"hello","encoding":"text"},
{"action_type":"delete","file_path":"docs/old.md"}
]`))
var body repoBatchCommitRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "789"}})
}))
defer server.Close()
err := runShortcut(t, server, "commit-files", map[string]string{
"branch": "main",
"message": "batch docs",
"ops": opsFile,
})
if err != nil {
t.Fatalf("commit-files shortcut failed: %v", err)
}
assertEqual(t, len(body.Files), 2)
assertEqual(t, body.Files[0].ActionType, "create")
assertEqual(t, body.Files[0].Encoding, "text")
assertEqual(t, body.Files[1].ActionType, "delete")
if body.Files[1].Content != nil || body.Files[1].Encoding != "" {
t.Fatalf("delete operation should omit content and encoding: %+v", body.Files[1])
}
}
func TestRepoCommitFilesDryRunDoesNotCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "commit-files", map[string]string{
"branch": "main",
"message": "preview",
"path": "README.md",
"content": "hello",
"dry-run": "true",
})
if err != nil {
t.Fatalf("dry-run shortcut failed: %v", err)
}
}
func TestRepoCommitFilesValidation(t *testing.T) {
opsFile := writeTempFile(t, []byte(`[{"action_type":"create","file_path":"docs/a.md","content":"hello"}]`))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
cases := []struct {
name string
args map[string]string
}{
{
name: "missing path",
args: map[string]string{"branch": "main", "message": "msg", "content": "hello"},
},
{
name: "missing content for update",
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md"},
},
{
name: "content and from together",
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "from": opsFile},
},
{
name: "delete with content",
args: map[string]string{"branch": "main", "message": "msg", "action": "delete", "path": "README.md", "content": "hello"},
},
{
name: "invalid encoding",
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "encoding": "gzip"},
},
{
name: "ops with single-file args",
args: map[string]string{"branch": "main", "message": "msg", "ops": opsFile, "path": "README.md"},
},
{
name: "partial author identity",
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "author-name": "Alice"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runShortcut(t, server, "commit-files", tc.args); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/languages.json")
@ -621,6 +815,21 @@ func TestRepoCreateUserNoLogin(t *testing.T) {
}
}
func writeTempFile(t *testing.T, data []byte) string {
t.Helper()
file, err := os.CreateTemp(t.TempDir(), "repo-file-*")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
if _, err := file.Write(data); err != nil {
t.Fatalf("write temp file: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close temp file: %v", err)
}
return file.Name()
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {