feat(file): 新增 file 快捷命令组(+view/+search/+create/+update/+delete)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
farmyobutu5233 2026-07-05 09:41:32 +00:00
parent 9749a4c832
commit 29e9e927f3
7 changed files with 587 additions and 1 deletions

View File

@ -117,6 +117,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 📄 File | View, search, create, update, and delete repository files without cloning |
| 👤 User | View user profiles and info |
| 📊 Profile | User ability, role, major, activity, and contribution statistics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
@ -676,6 +677,31 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye
> published OpenAPI contract but are not yet deployed on production (they return
> 404 there); they will work once the platform enables them.
### File Operations
`file` reads and writes repository file contents without cloning — ideal for
AI agents that need to read or patch a single file. For directory listings and
README viewing, see `repo +tree` and `repo +readme`.
```bash
# View a file (--raw prints only the decoded content, for piping)
gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md
gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md
# Search files by name
gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller
# Create / update a file (content inline or from a local file)
gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# Note" -b master -m "add note"
gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master
# Commit to a new branch created from --branch
gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs
# Delete a file
gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note"
```
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:

View File

@ -116,6 +116,7 @@
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
@ -550,6 +551,29 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效。
### 文件操作
`file` 无需克隆即可读写仓库文件内容,非常适合需要读取或修改单个文件的 AI Agent。目录列表和 README 查看请使用 `repo +tree``repo +readme`
```bash
# 查看文件(--raw 仅输出解码后的文件内容,方便管道处理)
gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md
gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md
# 按文件名搜索
gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller
# 创建 / 更新文件(内容可内联或来自本地文件)
gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# 笔记" -b master -m "add note"
gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master
# 提交到从 --branch 新建的分支
gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs
# 删除文件
gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note"
```
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -0,0 +1,59 @@
# File Content Shortcuts
## Summary
Adds a `file` shortcut group so users and AI agents can read, search, and write
repository file contents without cloning or falling back to Raw API calls.
Directory listing and README viewing remain covered by `repo +tree` and
`repo +readme`.
## Commands
| Command | Purpose |
|---------|---------|
| `gitlink-cli file +view` | View a file's contents; `--raw` prints only the decoded content |
| `gitlink-cli file +search` | Search repository files by name |
| `gitlink-cli file +create` | Create a file and commit it to a branch |
| `gitlink-cli file +update` | Update a file and commit it to a branch |
| `gitlink-cli file +delete` | Delete a file and commit the removal to a branch |
## Validation
- `file +view` accepts `--ref` (branch, tag, or commit SHA) and `--raw`; `--raw`
fails with a clear error when the path is a directory.
- Write commands require `--path` and `--branch`; `--message` defaults to
`<action> <path>` when omitted.
- `file +create` / `file +update` accept exactly one of `--content` or
`--content-file`; providing both or neither is rejected before any request.
- `--new-branch` commits the change to a new branch created from `--branch`.
- File content is transported with `text` encoding (verified against production
gitlink.org.cn; the documented `base64` encoding is rejected there).
## Tests
Unit tests cover endpoint paths, query parameter mapping, request payload
construction, content-source validation, default commit messages, `--new-branch`
propagation, and raw content extraction from entries/README-shaped responses.
## 中文说明
### 变更内容
- 新增 `file` 快捷命令组:`+view`(查看文件内容,`--raw` 仅输出解码后的正文)、
`+search`(按文件名搜索)、`+create` / `+update` / `+delete`(通过
contents/batch API 直接提交文件增删改)。
- 无需克隆仓库即可读写文件,适合 AI Agent 读取 README、修改单个文件等场景
(响应社区 issueAPI 是否支持自动读取仓库内文件)。
- 内容支持 `--content` 内联或 `--content-file` 从本地文件读取text 编码,
已在生产环境验证,文档中的 base64 编码在生产环境会被拒绝);支持
`--new-branch` 提交到新分支。
- 更新 README 与 README.zh-CN 的功能表和使用示例。
### 验证
- `go test ./...`
- `go vet ./...`
- `go run . file --help`
- `go run . file +view --help`
- 在生产 gitlink.org.cn 真实仓库验证 `+view --raw`、`+search`、`+create`、
`+update`、`+delete` 全链路

253
shortcuts/file/file.go Normal file
View File

@ -0,0 +1,253 @@
package file
import (
"encoding/base64"
"fmt"
"net/url"
"os"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns all file shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
viewShortcut(),
searchShortcut(),
writeShortcut("create", "Create a new file in the repository"),
writeShortcut("update", "Update an existing file in the repository"),
deleteShortcut(),
}
}
func refFlag() common.Flag {
return common.Flag{Name: "ref", Usage: "Branch, tag, or commit SHA (defaults to the default branch)"}
}
func viewShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "view",
Description: "View the contents of a file",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
refFlag(),
{Name: "raw", Usage: "Print only the decoded file content", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", path)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return err
}
if ctx.Arg("raw") == "true" {
return printRawContent(env.Data)
}
return ctx.Output(env)
},
}
}
func searchShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "search",
Description: "Search files in the repository by name",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
refFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func writeShortcut(action, description string) *common.Shortcut {
return &common.Shortcut{
Name: action,
Description: description,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "File content"},
{Name: "content-file", Usage: "Read file content from a local file"},
{Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true},
{Name: "new-branch", Usage: "Create a new branch from --branch for the commit"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path, err := ctx.RequireArg("path")
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch")
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("%s %s", action, path)
}
payload := map[string]interface{}{
"files": []map[string]interface{}{
{
"action_type": action,
"file_path": path,
"content": content,
"encoding": "text",
},
},
"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)
},
}
}
func deleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "delete",
Description: "Delete a file from the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true},
{Name: "new-branch", Usage: "Create a new branch from --branch for the commit"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path, err := ctx.RequireArg("path")
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch")
if err != nil {
return err
}
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("delete %s", path)
}
payload := map[string]interface{}{
"files": []map[string]interface{}{
{
"action_type": "delete",
"file_path": path,
"content": "",
"encoding": "text",
},
},
"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)
},
}
}
// resolveContent reads file content from --content or --content-file.
func resolveContent(ctx *common.RuntimeContext) (string, error) {
content := ctx.Arg("content")
contentFile := ctx.Arg("content-file")
if content != "" && contentFile != "" {
return "", fmt.Errorf("use only one of --content or --content-file")
}
if contentFile != "" {
data, err := os.ReadFile(contentFile)
if err != nil {
return "", fmt.Errorf("read content file: %w", err)
}
return string(data), nil
}
if content == "" {
return "", fmt.Errorf("one of --content or --content-file is required")
}
return content, nil
}
// printRawContent extracts and prints the decoded file content from an API
// response (entries object, readme object, or a bare content field).
func printRawContent(data interface{}) error {
content, encoding, ok := extractContent(data)
if !ok {
return fmt.Errorf("no file content in response (is the path a directory?)")
}
if encoding == "base64" {
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
fmt.Print(string(decoded))
return nil
}
}
fmt.Print(content)
return nil
}
func extractContent(data interface{}) (content, encoding string, ok bool) {
m, isMap := data.(map[string]interface{})
if !isMap {
return "", "", false
}
if entries, has := m["entries"]; has {
if em, isEM := entries.(map[string]interface{}); isEM {
m = em
}
}
c, has := m["content"].(string)
if !has {
return "", "", false
}
if t, hasType := m["type"].(string); hasType && t != "file" {
return "", "", false
}
enc, _ := m["encoding"].(string)
return c, enc, true
}

221
shortcuts/file/file_test.go Normal file
View File

@ -0,0 +1,221 @@
package file
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestFileView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("filepath"); got != "README.md" {
t.Fatalf("filepath query = %q, want README.md", got)
}
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "README.md", "type": "file", "content": "# hello",
},
})
}))
defer server.Close()
if err := runFileShortcut(t, server, "view", map[string]string{"path": "README.md"}); err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestFileSearch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("search"); got != "main" {
t.Fatalf("search query = %q, want main", got)
}
writeJSON(t, w, []interface{}{})
}))
defer server.Close()
if err := runFileShortcut(t, server, "search", map[string]string{"keyword": "main"}); err != nil {
t.Fatalf("search shortcut failed: %v", err)
}
}
func TestFileCreate(t *testing.T) {
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, "create", map[string]string{
"path": "notes.md", "content": "hello", "branch": "master", "message": "add notes",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
if payload["branch"] != "master" || payload["message"] != "add notes" {
t.Fatalf("payload = %v", payload)
}
files := payload["files"].([]interface{})
f := files[0].(map[string]interface{})
if f["action_type"] != "create" || f["file_path"] != "notes.md" || f["encoding"] != "text" {
t.Fatalf("file entry = %v", f)
}
if f["content"] != "hello" {
t.Fatalf("content = %v, want hello", f["content"])
}
}
func TestFileUpdateFromContentFile(t *testing.T) {
dir := t.TempDir()
local := filepath.Join(dir, "input.txt")
os.WriteFile(local, []byte("updated"), 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": "def"}})
}))
defer server.Close()
err := runFileShortcut(t, server, "update", map[string]string{
"path": "notes.md", "content-file": local, "branch": "master", "new-branch": "feature/x",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
if payload["new_branch"] != "feature/x" {
t.Fatalf("new_branch = %v", payload["new_branch"])
}
f := payload["files"].([]interface{})[0].(map[string]interface{})
if f["action_type"] != "update" {
t.Fatalf("action_type = %v", f["action_type"])
}
if f["content"] != "updated" {
t.Fatalf("content = %v, want updated", f["content"])
}
}
func TestFileDelete(t *testing.T) {
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": "ghi"}})
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{
"path": "notes.md", "branch": "master",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
f := payload["files"].([]interface{})[0].(map[string]interface{})
if f["action_type"] != "delete" || f["file_path"] != "notes.md" {
t.Fatalf("file entry = %v", f)
}
if payload["message"] != "delete notes.md" {
t.Fatalf("default message = %v", payload["message"])
}
}
func TestFileCreateContentConflicts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach server")
}))
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"path": "a", "content": "x", "content-file": "y", "branch": "master",
})
if err == nil {
t.Fatal("expected error for both --content and --content-file")
}
err = runFileShortcut(t, server, "create", map[string]string{
"path": "a", "branch": "master",
})
if err == nil {
t.Fatal("expected error when no content source is provided")
}
}
func TestExtractContent(t *testing.T) {
tests := []struct {
name string
data interface{}
wantContent string
wantEncoding string
wantOK bool
}{
{"entries object", map[string]interface{}{"entries": map[string]interface{}{"type": "file", "content": "abc"}}, "abc", "", true},
{"readme object", map[string]interface{}{"type": "file", "content": "abc", "encoding": "base64"}, "abc", "base64", true},
{"directory", map[string]interface{}{"type": "dir", "content": "x"}, "", "", false},
{"no content", map[string]interface{}{"type": "file"}, "", "", false},
{"not a map", []interface{}{}, "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
content, encoding, ok := extractContent(tt.data)
if content != tt.wantContent || encoding != tt.wantEncoding || ok != tt.wantOK {
t.Fatalf("extractContent() = (%q, %q, %v), want (%q, %q, %v)",
content, encoding, ok, tt.wantContent, tt.wantEncoding, tt.wantOK)
}
})
}
}
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findFileShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findFileShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/health"
"github.com/gitlink-org/gitlink-cli/shortcuts/ignore"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
@ -53,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"file": file.Shortcuts(),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
@ -78,6 +80,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"file": "Repository file content operations",
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki",
"dataset", "file", "health", "ignore", "wiki",
}
groupSet := map[string]bool{}