From 6e300b59dd17660ed50b8f8df0d11c48f2533942 Mon Sep 17 00:00:00 2001 From: chroe Date: Thu, 9 Jul 2026 21:35:26 +0800 Subject: [PATCH 1/3] feat(shortcut): add file shortcuts (list/get/content/tree/create/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 file 命令模块,支持仓库文件与目录操作: - file +list 列出目录文件 - file +get 获取文件内容 - file +content 读取文件原始内容 - file +tree 目录树 - file +create 创建/更新文件 - file +delete 删除文件 - file +recursive 递归列出 - file +search 搜索文件 遵循 common.Shortcut 规范,复用 RuntimeContext。 含单元测试 shortcuts/file/file_test.go。 --- shortcuts/file/file.go | 179 ++++++++++++++++++++++++++++++++ shortcuts/file/file_test.go | 202 ++++++++++++++++++++++++++++++++++++ shortcuts/register.go | 3 + 3 files changed, 384 insertions(+) create mode 100644 shortcuts/file/file.go create mode 100644 shortcuts/file/file_test.go diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go new file mode 100644 index 0000000..145b3b6 --- /dev/null +++ b/shortcuts/file/file.go @@ -0,0 +1,179 @@ +package file + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository files", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "search", Short: "s", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if search := ctx.Arg("search"); search != "" { + q.Set("search", search) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tree", + Description: "List file tree for a branch or commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, + {Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha := ctx.Arg("sha") + if sha == "" { + sha = "master" + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("recursive") == "true" { + q.Set("recursive", "true") + } + env, err := ctx.CallAPIWithQuery("GET", + fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "Get file or directory contents", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File or directory path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filePath) + q.Set("ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a new file in the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a file from the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "sha": sha, + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go new file mode 100644 index 0000000..3ef1bf8 --- /dev/null +++ b/shortcuts/file/file_test.go @@ -0,0 +1,202 @@ +package file + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFileList(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) + } + writeJSON(t, w, []map[string]interface{}{ + {"name": "README.md", "path": "README.md", "type": "file"}, + {"name": "src", "path": "src", "type": "dir"}, + }) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestFileListWithRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != "dev" { + t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref")) + } + writeJSON(t, w, []map[string]interface{}{}) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil { + t.Fatalf("list with ref failed: %v", err) + } +} + +func TestFileTree(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "entries": []map[string]interface{}{{"name": "main.go", "type": "file"}}, + }) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil { + t.Fatalf("tree failed: %v", err) + } +} + +func TestFileTreeRecursive(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("recursive") != "true" { + t.Fatalf("expected recursive=true") + } + writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}}) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil { + t.Fatalf("tree recursive failed: %v", err) + } +} + +func TestFileGet(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 r.URL.Query().Get("filepath") != "README.md" { + t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"}) + if err != nil { + t.Fatalf("get failed: %v", err) + } +} + +func TestFileGetRequiresPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --path") + })) + defer server.Close() + + err := runFileShortcut(t, server, "get", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing --path") + } +} + +func TestFileCreate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" { + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["filepath"] != "test.txt" { + t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"]) + } + if payload["message"] != "add test" { + t.Fatalf("expected message=add test, got %v", payload["message"]) + } + if payload["branch"] != "master" { + t.Fatalf("expected branch=master, got %v", payload["branch"]) + } + if _, ok := payload["content"].(string); !ok || payload["content"] == "" { + t.Fatal("content should be a non-empty Base64 string") + } + writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runFileShortcut(t, server, "create", map[string]string{ + "path": "test.txt", "content": "hello world", "message": "add test", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } +} + +func TestFileDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" { + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["filepath"] != "old.txt" { + t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"]) + } + if payload["sha"] != "def456" { + t.Fatalf("expected sha=def456, got %v", payload["sha"]) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runFileShortcut(t, server, "delete", map[string]string{ + "path": "old.txt", "sha": "def456", "message": "remove old", + }) + if err != nil { + t.Fatalf("delete failed: %v", err) + } +} + +func TestFileDeleteRequiresPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --path") + })) + defer server.Close() + + err := runFileShortcut(t, server, "delete", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing --path") + } +} + +// === helpers === + +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 _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + 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") + json.NewEncoder(w).Encode(payload) +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..b7605d2 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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" @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), + "file": file.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -75,6 +77,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), + "file": "File and directory content operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), From 8161193fa6eaca4a4c24fadcfb1c1d721585b0c4 Mon Sep 17 00:00:00 2001 From: chroe Date: Thu, 9 Jul 2026 22:22:50 +0800 Subject: [PATCH 2/3] docs(file): add command help doc for file shortcut --- doc/changes/file-shortcut.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 doc/changes/file-shortcut.md diff --git a/doc/changes/file-shortcut.md b/doc/changes/file-shortcut.md new file mode 100644 index 0000000..4b21305 --- /dev/null +++ b/doc/changes/file-shortcut.md @@ -0,0 +1,33 @@ +# File shortcut + +新增 `file` Shortcut 组,补齐 GitLink 仓库文件与目录内容操作的常用封装: + +- `file +list` 列出仓库文件(`--ref` 指定分支/标签/commit,`--search` 关键词过滤) +- `file +tree` 列出文件树(`--sha` 默认 master,`--recursive` 递归,支持分页) +- `file +get` 获取文件或目录内容(`--path` 必填,`--ref` 默认 master) +- `file +create` 创建文件(`--path`/`--content`/`--message` 必填,content 自动 Base64 编码) +- `file +delete` 删除文件(`--path`/`--sha`/`--message` 必填,SHA 取自 `file +list`) + +实现要点: + +- `+tree` 走 `/v1/{owner}/{repo}/git/trees/{sha}`,与 git 树对象语义一致,支持 `--recursive` 与分页。 +- `+get` / `+list` 经 `/sub_entries`、`/files` 等接口读取文件或目录内容。 +- `+create` 调用 `/create_file`,文件内容 Base64 编码后提交;`+delete` 调用 `/delete_file`,需先从 `file +list` 取得文件 blob SHA。 +- 路径统一使用 `/v1/{owner}/{repo}/` 前缀,与现有 Shortcut 组保持一致。 + +补充单元测试 `shortcuts/file/file_test.go`,覆盖各命令的参数解析与路径构造。 + +## Examples + +```bash +gitlink-cli file +list --owner Gitlink --repo gitlink-cli +gitlink-cli file +tree --owner Gitlink --repo gitlink-cli --recursive +gitlink-cli file +get --owner Gitlink --repo gitlink-cli --path README.md +gitlink-cli file +create --owner Gitlink --repo gitlink-cli --path docs/note.md --content "hello" --message "add note" +``` + +## Tests + +```bash +go test ./shortcuts/file/... +``` From 56fc4c3a75afb6dcfec79596972f93d6e1ff0285 Mon Sep 17 00:00:00 2001 From: chroe Date: Thu, 9 Jul 2026 22:41:55 +0800 Subject: [PATCH 3/3] test(shortcuts): include file in register_test expected groups --- shortcuts/register_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..7bc89a5 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -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", "health", "ignore", "wiki", "file", } groupSet := map[string]bool{}