From d408443c84bdf439be499f7bafa6ec138139ae4c Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Fri, 26 Jun 2026 15:37:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(repo):=20=E5=A2=9E=E5=8A=A0=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E4=BB=93=E5=BA=93=E4=BB=BB=E6=84=8F=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=20+file=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + README.zh-CN.md | 4 + doc/changes/repo-file-shortcut.md | 19 +++ shortcuts/repo/repo.go | 100 ++++++++++++ shortcuts/repo/repo_test.go | 150 ++++++++++++++++++ skills/gitlink-repo/SKILL.md | 11 +- .../references/gitlink-repo-file.md | 44 +++++ 7 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 doc/changes/repo-file-shortcut.md create mode 100644 skills/gitlink-repo/references/gitlink-repo-file.md diff --git a/README.md b/README.md index e5e4318..ffa3fc0 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,10 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# Read any repository file +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path go.mod --ref master +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path .gitignore --content-only + # List repository files at root or a directory gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..3620784 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -234,6 +234,10 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# 读取仓库任意文件 +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path go.mod --ref master +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path .gitignore --content-only + # 列出仓库根目录或指定目录文件 gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main diff --git a/doc/changes/repo-file-shortcut.md b/doc/changes/repo-file-shortcut.md new file mode 100644 index 0000000..1dbfe3d --- /dev/null +++ b/doc/changes/repo-file-shortcut.md @@ -0,0 +1,19 @@ +# repo +file 仓库文件读取快捷命令 + +`gitlink-cli repo` 已经支持查看仓库信息、README 和目录树,但当用户想直接读取 `go.mod`、`.gitignore`、配置文件、脚本或许可证内容时,仍然需要回退到 Raw API。对于脚本、Agent 和日常排查来说,这是一个很常见的能力缺口。 + +这次新增 `gitlink-cli repo +file`,把仓库任意文件读取封装成高层 Shortcut。命令基于 `GET /{owner}/{repo}/sub_entries` 的文件模式实现,支持 `--path` 指定仓库内文件路径,支持 `--ref` 读取指定分支、标签或提交,也支持 `--content-only` 只输出文件正文,方便直接做管道消费或作为 Agent 上下文输入。 + +为了让这个命令在真实使用里更顺手,这次还补了两个常见边界处理: + +- `--path` 设为必填,并对空路径或仅 `/` 这类无效输入给出明确报错。 +- 如果用户传入的是目录路径,而不是文件路径,命令会直接提示改用 `repo +tree`,避免得到难以理解的 API 结果。 + +测试覆盖了默认分支、显式 `--ref`、路径归一化、帮助参数注册、目录误传报错、`--content-only` 缺少内容报错以及结果扁平化输出等关键分支。 + +本次交付包含: + +- 功能代码:`shortcuts/repo/repo.go` +- 单元测试:`shortcuts/repo/repo_test.go` +- 帮助文档更新:`README.md`、`README.zh-CN.md`、`skills/gitlink-repo/SKILL.md`、`skills/gitlink-repo/references/gitlink-repo-file.md` +- 变更说明:本文档 diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..012b3ab 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -81,6 +81,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "file", + Description: "Show repository file content", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "Repository file path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + {Name: "content-only", Usage: "Output file content only", Bool: true, Default: "false"}, + }, + Run: runFile, + }, { Name: "tree", Description: tr.T("cmd.repo.tree.short"), @@ -285,6 +295,44 @@ func runContributors(ctx *common.RuntimeContext) error { return ctx.Output(env) } +func runFile(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := normalizeRepoFilePath(ctx) + if err != nil { + return err + } + + ref := strings.TrimSpace(ctx.Arg("ref")) + if ref == "" { + ref = "master" + } + + q := url.Values{} + q.Set("filepath", path) + q.Set("ref", ref) + + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + + entry, err := extractRepoFileEntry(env.Data, path) + if err != nil { + return err + } + if ctx.Arg("content-only") == "true" { + content, _ := entry["content"].(string) + if content == "" { + return fmt.Errorf("file response did not include content for %q", path) + } + return ctx.OutputData(content) + } + + return ctx.OutputData(buildRepoFileResult(entry, path, ref)) +} + func runContributorStats(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -482,6 +530,58 @@ func setRepoQueryIfPresent(q url.Values, key, value string) { } } +func normalizeRepoFilePath(ctx *common.RuntimeContext) (string, error) { + path, err := ctx.RequireArg("path") + if err != nil { + return "", err + } + path = strings.TrimLeft(strings.TrimSpace(path), "/") + if path == "" { + return "", fmt.Errorf("invalid --path %q: provide a repository file path", ctx.Arg("path")) + } + return path, nil +} + +func extractRepoFileEntry(data interface{}, path string) (map[string]interface{}, error) { + payload, ok := data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected file response format") + } + + entry, ok := payload["entries"] + if !ok { + return nil, fmt.Errorf("unexpected file response format") + } + + if _, isDir := entry.([]interface{}); isDir { + return nil, fmt.Errorf("path %q is a directory; use repo +tree instead", path) + } + + fileEntry, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected file response format") + } + + if entryType, _ := fileEntry["type"].(string); entryType != "" && entryType != "file" { + return nil, fmt.Errorf("path %q is not a file; use repo +tree instead", path) + } + + return fileEntry, nil +} + +func buildRepoFileResult(entry map[string]interface{}, path, ref string) map[string]interface{} { + result := map[string]interface{}{ + "path": path, + "ref": ref, + } + for _, key := range []string{"name", "type", "size", "sha", "content"} { + if value, ok := entry[key]; ok { + result[key] = value + } + } + return result +} + func parseOptionalRepoNonNegativeInt(value, name string) (int, bool, error) { if strings.TrimSpace(value) == "" { return 0, false, nil diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..2aa45b5 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -152,6 +152,50 @@ func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { } } +func TestRepoFileUsesSubEntriesAndDefaultsToMaster(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + assertEqual(t, r.URL.Query().Get("filepath"), "README.md") + assertEqual(t, r.URL.Query().Get("ref"), "master") + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "README.md", + "type": "file", + "sha": "abc123", + "size": float64(12), + "content": "# docs\n", + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "file", map[string]string{"path": "/README.md"}) + if err != nil { + t.Fatalf("file shortcut failed: %v", err) + } +} + +func TestRepoFileUsesExplicitRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + assertEqual(t, r.URL.Query().Get("filepath"), "go.mod") + assertEqual(t, r.URL.Query().Get("ref"), "release/v1") + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "go.mod", + "type": "file", + "content": "module example.com/demo\n", + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "file", map[string]string{"path": "go.mod", "ref": "release/v1"}) + if err != nil { + t.Fatalf("file shortcut failed: %v", err) + } +} + func TestRepoTreeListsRootOnDefaultRef(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") @@ -218,6 +262,42 @@ func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) { } } +func TestRepoFileShortcutRegistersHelpFlags(t *testing.T) { + file := findShortcut(t, "file") + if file.Description == "" { + t.Fatal("file shortcut description is empty") + } + + flags := map[string]common.Flag{} + for _, flag := range file.Flags { + flags[flag.Name] = flag + } + + pathFlag, ok := flags["path"] + if !ok { + t.Fatal("file shortcut missing path flag") + } + if pathFlag.Short != "p" || !pathFlag.Required || pathFlag.Usage == "" { + t.Fatalf("unexpected path flag: %+v", pathFlag) + } + + refFlag, ok := flags["ref"] + if !ok { + t.Fatal("file shortcut missing ref flag") + } + if refFlag.Short != "r" || refFlag.Default != "master" || refFlag.Usage == "" { + t.Fatalf("unexpected ref flag: %+v", refFlag) + } + + contentOnlyFlag, ok := flags["content-only"] + if !ok { + t.Fatal("file shortcut missing content-only flag") + } + if !contentOnlyFlag.Bool || contentOnlyFlag.Default != "false" || contentOnlyFlag.Usage == "" { + t.Fatalf("unexpected content-only flag: %+v", contentOnlyFlag) + } +} + 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") @@ -506,6 +586,16 @@ func TestRepoInsightValidation(t *testing.T) { shortcut: "contributor-stats", args: map[string]string{"pass-year": "0"}, }, + { + name: "missing file path", + shortcut: "file", + args: map[string]string{}, + }, + { + name: "invalid file path", + shortcut: "file", + args: map[string]string{"path": "/"}, + }, { name: "invalid start timestamp", shortcut: "watchers", @@ -621,6 +711,66 @@ func TestRepoCreateUserNoLogin(t *testing.T) { } } +func TestRepoFileRejectsDirectoryPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + writeJSON(t, w, map[string]interface{}{ + "entries": []map[string]interface{}{ + {"name": "main.go", "type": "file"}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "file", map[string]string{"path": "cmd"}) + if err == nil { + t.Fatal("expected directory error") + } +} + +func TestRepoFileContentOnlyRequiresContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "README.md", + "type": "file", + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "file", map[string]string{ + "path": "README.md", + "content-only": "true", + }) + if err == nil { + t.Fatal("expected missing content error") + } +} + +func TestBuildRepoFileResult(t *testing.T) { + result := buildRepoFileResult(map[string]interface{}{ + "name": "go.mod", + "type": "file", + "sha": "abc123", + "size": float64(42), + "content": "module demo\n", + "commit": map[string]interface{}{"sha": "nested"}, + }, "go.mod", "master") + + assertEqual(t, result["path"], "go.mod") + assertEqual(t, result["ref"], "master") + assertEqual(t, result["name"], "go.mod") + assertEqual(t, result["type"], "file") + assertEqual(t, result["sha"], "abc123") + assertEqual(t, result["size"], float64(42)) + assertEqual(t, result["content"], "module demo\n") + if _, ok := result["commit"]; ok { + t.Fatal("did not expect nested commit metadata in flattened file result") + } +} + func assertRequest(t *testing.T, r *http.Request, method, path string) { t.Helper() if r.Method != method || r.URL.Path != path { diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 5d59705..d3813ce 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -23,6 +23,7 @@ metadata: | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | | `repo +readme` | README 内容 | 否(公开项目) | +| `repo +file` | 任意仓库文件内容 | 否(公开项目) | | `repo +tree` | 仓库文件树 | 否(公开项目) | | `repo +languages` | 仓库语言统计 | 否(公开项目) | | `repo +contributors` | 仓库贡献者列表 | 否(公开项目) | @@ -54,6 +55,8 @@ gitlink-cli repo +list --user zhangsan # 查看文件树、语言占比和贡献者 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 +file --owner Gitlink --repo forgeplus --path go.mod --ref master +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path .gitignore --content-only gitlink-cli repo +languages --owner Gitlink --repo forgeplus gitlink-cli repo +contributors --owner Gitlink --repo forgeplus @@ -93,11 +96,15 @@ gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20' # 获取标签列表 gitlink-cli api GET /:owner/:repo/tags -# 获取文件内容 -gitlink-cli api GET /:owner/:repo/raw/main/README.md +# 获取文件内容(Shortcut 优先) +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref master + +# Raw API 仍可用于未封装场景 +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=README.md&ref=master' ``` ## 注意事项 - `repo +delete` 是不可逆操作,执行前必须确认用户意图 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库 +- `repo +file` 只接受文件路径;如果目标是目录,请改用 `repo +tree` diff --git a/skills/gitlink-repo/references/gitlink-repo-file.md b/skills/gitlink-repo/references/gitlink-repo-file.md new file mode 100644 index 0000000..e2e2baf --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-file.md @@ -0,0 +1,44 @@ +# repo +file + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +读取 GitLink 仓库中的任意文件内容。该命令基于 `sub_entries` API 的文件模式封装,适合查看 `go.mod`、`.gitignore`、配置文件、脚本、许可证文本和示例数据文件。 + +## 命令 + +```bash +# 读取默认分支上的文件 +gitlink-cli repo +file --owner someone --repo myrepo --path go.mod + +# 指定分支、标签或提交 +gitlink-cli repo +file --owner someone --repo myrepo --path .gitignore --ref main + +# 只输出文件内容 +gitlink-cli repo +file --owner someone --repo myrepo --path README.md --content-only + +# Agent 场景建议使用 JSON +gitlink-cli repo +file --owner someone --repo myrepo --path package.json --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--path, -p` | 是 | 仓库内文件路径,如 `go.mod`、`docs/guide.md` | +| `--ref, -r` | 否 | 分支、标签或提交引用,默认 `master` | +| `--content-only` | 否 | 只输出文件内容,不附带路径、SHA、大小等元数据 | +| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 | +| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 | +| `--format` | 否 | 输出格式:`json` / `table` / `yaml` | + +## 注意事项 + +- `repo +file` 只能读取文件;如果传入目录路径,命令会提示改用 `repo +tree`。 +- GitLink 仓库常见默认分支是 `master`。如果仓库使用 `main`,请显式传入 `--ref main`。 +- Agent 或脚本场景建议使用 `--format json`,方便读取 `data.content`。 + +## 参考 + +- [gitlink-repo](../SKILL.md) +- [gitlink-repo-tree](./gitlink-repo-tree.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md)