feat(repo): 增加读取仓库任意文件的 +file 命令
This commit is contained in:
parent
71ca2bb683
commit
d408443c84
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- 变更说明:本文档
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue