feat(repo): 增加读取仓库任意文件的 +file 命令

This commit is contained in:
Mengz 2026-06-26 15:37:21 +08:00
parent 71ca2bb683
commit d408443c84
7 changed files with 330 additions and 2 deletions

View File

@ -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

View File

@ -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

View File

@ -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`
- 变更说明:本文档

View File

@ -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

View File

@ -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 {

View File

@ -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`

View File

@ -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)