Merge PR #330: feat(file): 新增 file 快捷命令组,无需克隆即可读写仓库文件

# Conflicts:
#	README.zh-CN.md
#	shortcuts/file/file.go
#	shortcuts/file/file_test.go
#	shortcuts/register.go
#	shortcuts/register_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:04 +08:00
commit 9308bef3cb
11 changed files with 716 additions and 304 deletions

View File

@ -116,6 +116,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔍 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 |
@ -705,6 +706,36 @@ 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"
# Multiple file operations in a single commit (JSON spec)
# spec.json: [{"action_type":"create","file_path":"a.txt","content":"A"},
# {"action_type":"delete","file_path":"old.txt"}]
gitlink-cli file +batch --owner me --repo proj -s spec.json -b master -m "batch ops"
```
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:

View File

@ -116,6 +116,7 @@
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
@ -550,23 +551,27 @@ 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
# 查看当前登录用户
gitlink-cli user +me
# 查看文件(--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
# 列出 SSH 公钥
gitlink-cli user +keys
# 按文件名搜索
gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller
# 通过内容或文件添加 SSH 公钥
gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..."
gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub
gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub
# 创建 / 更新文件(内容可内联或来自本地文件)
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
# 删除 SSH 公钥
gitlink-cli user +delete-key --id 123
# 提交到从 --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
@ -639,7 +644,7 @@ git push gitlink
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息、SSH 公钥等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |
| `gitlink-workflow` | AI 自动化工作流Issue 分类、PR Review、Release Notes 等) |

View File

@ -0,0 +1,64 @@
# 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 的功能表和使用示例。
### 国际化
命令与全部 flag 文案已接入 i18n`cmd.file.*` / `flag.file.*`,含 en-US 与
zh-CN 两套 locale`GITLINK_LANG=zh-CN` 下 `file --help` 输出中文帮助。
### 验证
- `go test ./...`
- `go vet ./...`
- `go run . file --help`
- `go run . file +view --help`
- 在生产 gitlink.org.cn 真实仓库验证 `+view --raw`、`+search`、`+create`、
`+update`、`+delete` 全链路

View File

@ -43,6 +43,13 @@
"cmd.dataset.view.short": "View a repository's dataset",
"cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.",
"cmd.doctor.short": "Diagnose gitlink-cli environment problems",
"cmd.file.batch.short": "Apply multiple file operations in a single commit",
"cmd.file.create.short": "Create a new file in the repository",
"cmd.file.delete.short": "Delete a file from the repository",
"cmd.file.search.short": "Search files in the repository by name",
"cmd.file.short": "Repository file content operations",
"cmd.file.update.short": "Update an existing file in the repository",
"cmd.file.view.short": "View the contents of a file",
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
@ -160,6 +167,15 @@
"flag.description": "Description",
"flag.doctor.skip_network": "Skip authenticated API connectivity checks",
"flag.dry_run": "Preview the request without creating it",
"flag.file.batch_spec": "Path to a JSON array of file operations: [{action_type, file_path, content, encoding}]",
"flag.file.branch": "Branch to commit to",
"flag.file.content": "File content",
"flag.file.content_file": "Read file content from a local file",
"flag.file.message": "Commit message",
"flag.file.new_branch": "Create a new branch from --branch for the commit",
"flag.file.path": "File path",
"flag.file.raw": "Print only the decoded file content",
"flag.file.ref": "Branch, tag, or commit SHA (defaults to the default branch)",
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",

View File

@ -43,6 +43,13 @@
"cmd.dataset.view.short": "查看仓库数据集",
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
"cmd.doctor.short": "诊断 gitlink-cli 环境问题",
"cmd.file.batch.short": "在单个提交中应用多个文件操作",
"cmd.file.create.short": "在仓库中创建文件",
"cmd.file.delete.short": "删除仓库中的文件",
"cmd.file.search.short": "按文件名搜索仓库文件",
"cmd.file.short": "仓库文件内容操作",
"cmd.file.update.short": "更新仓库中的文件",
"cmd.file.view.short": "查看文件内容",
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。",
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
@ -160,6 +167,15 @@
"flag.description": "描述",
"flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查",
"flag.dry_run": "预览请求,不实际创建",
"flag.file.batch_spec": "文件操作 JSON 数组路径:[{action_type, file_path, content, encoding}]",
"flag.file.branch": "提交到的分支",
"flag.file.content": "文件内容",
"flag.file.content_file": "从本地文件读取内容",
"flag.file.message": "提交信息",
"flag.file.new_branch": "从 --branch 新建分支并提交",
"flag.file.path": "文件路径",
"flag.file.raw": "仅输出解码后的文件正文",
"flag.file.ref": "分支、标签或提交 SHA默认使用默认分支",
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",

View File

@ -2,178 +2,334 @@ package file
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"os"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
// Shortcuts returns all file shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
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)
},
viewShortcut(tr),
searchShortcut(tr),
writeShortcut(tr, "create"),
writeShortcut(tr, "update"),
deleteShortcut(tr),
batchShortcut(tr),
}
}
func batchShortcut(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "batch",
Description: tr.T("cmd.file.batch.short"),
Flags: []common.Flag{
{Name: "spec", Short: "s", Usage: tr.T("flag.file.batch_spec"), Required: true},
{Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true},
{Name: "new-branch", Usage: tr.T("flag.file.new_branch")},
{Name: "message", Short: "m", Usage: tr.T("flag.file.message"), Required: true},
},
{
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
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
specPath, err := ctx.RequireArg("spec")
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
data, err := os.ReadFile(specPath)
if err != nil {
return fmt.Errorf("read spec file: %w", err)
}
var files []map[string]interface{}
if err := json.Unmarshal(data, &files); err != nil {
return fmt.Errorf("spec must be a JSON array of file operations: %w", err)
}
if len(files) == 0 {
return fmt.Errorf("spec contains no file operations")
}
for i, f := range files {
action, _ := f["action_type"].(string)
switch action {
case "create", "update", "delete":
default:
return fmt.Errorf("files[%d]: action_type must be create, update, or delete; got %q", i, action)
}
sha := ctx.Arg("sha")
if sha == "" {
sha = "master"
if path, _ := f["file_path"].(string); path == "" {
return fmt.Errorf("files[%d]: file_path is required", i)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("recursive") == "true" {
q.Set("recursive", "true")
if _, ok := f["content"]; !ok {
f["content"] = ""
}
env, err := ctx.CallAPIWithQuery("GET",
fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
if _, ok := f["encoding"]; !ok {
f["encoding"] = "text"
}
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)
},
}
payload := map[string]interface{}{
"files": files,
"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 shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func refFlag(tr *i18n.Translator) common.Flag {
return common.Flag{Name: "ref", Usage: tr.T("flag.file.ref")}
}
func viewShortcut(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "view",
Description: tr.T("cmd.file.view.short"),
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true},
refFlag(tr),
{Name: "raw", Usage: tr.T("flag.file.raw"), 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(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "search",
Description: tr.T("cmd.file.search.short"),
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true},
refFlag(tr),
},
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(tr *i18n.Translator, action string) *common.Shortcut {
return &common.Shortcut{
Name: action,
Description: tr.T("cmd.file." + action + ".short"),
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true},
{Name: "content", Short: "c", Usage: tr.T("flag.file.content")},
{Name: "content-file", Usage: tr.T("flag.file.content_file")},
{Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true},
{Name: "new-branch", Usage: tr.T("flag.file.new_branch")},
{Name: "message", Short: "m", Usage: tr.T("flag.file.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(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "delete",
Description: tr.T("cmd.file.delete.short"),
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true},
{Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true},
{Name: "new-branch", Usage: tr.T("flag.file.new_branch")},
{Name: "message", Short: "m", Usage: tr.T("flag.file.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
}

View File

@ -4,191 +4,208 @@ 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 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) {
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 r.URL.Query().Get("filepath") != "README.md" {
t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath"))
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{}{"name": "README.md", "type": "file"})
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "README.md", "type": "file", "content": "# hello",
},
})
}))
defer server.Close()
err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"})
if err != nil {
t.Fatalf("get failed: %v", err)
if err := runFileShortcut(t, server, "view", map[string]string{"path": "README.md"}); err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestFileGetRequiresPath(t *testing.T) {
func TestFileSearch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
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()
err := runFileShortcut(t, server, "get", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
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 == "/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
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
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": "test.txt", "content": "hello world", "message": "add test",
"path": "notes.md", "content": "hello", "branch": "master", "message": "add notes",
})
if err != nil {
t.Fatalf("create failed: %v", err)
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 == "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
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
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": "old.txt", "sha": "def456", "message": "remove old",
"path": "notes.md", "branch": "master",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
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 TestFileDeleteRequiresPath(t *testing.T) {
func TestFileCreateContentConflicts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
t.Fatal("should not reach server")
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{})
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 missing --path")
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")
}
}
// === helpers ===
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,
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
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
@ -198,5 +215,49 @@ func findFileShortcut(t *testing.T, name string) *common.Shortcut {
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func TestFileBatchPostsAllOperations(t *testing.T) {
dir := t.TempDir()
spec := filepath.Join(dir, "spec.json")
os.WriteFile(spec, []byte(`[
{"action_type": "create", "file_path": "a.txt", "content": "A"},
{"action_type": "delete", "file_path": "b.txt"}
]`), 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": "abc"}})
}))
defer server.Close()
err := runFileShortcut(t, server, "batch", map[string]string{
"spec": spec, "branch": "master", "message": "batch ops",
})
if err != nil {
t.Fatalf("batch shortcut failed: %v", err)
}
files := payload["files"].([]interface{})
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
del := files[1].(map[string]interface{})
if del["action_type"] != "delete" || del["content"] != "" || del["encoding"] != "text" {
t.Fatalf("delete entry not normalized: %v", del)
}
bad := filepath.Join(dir, "bad.json")
os.WriteFile(bad, []byte(`[{"action_type": "rename", "file_path": "x"}]`), 0600)
if err := runFileShortcut(t, server, "batch", map[string]string{
"spec": bad, "branch": "master", "message": "m",
}); err == nil {
t.Fatal("expected error for invalid action_type")
}
}

View File

@ -4,28 +4,29 @@ import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/action"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/feedback"
"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"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/message"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/profile"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -36,52 +37,54 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"action": action.Shortcuts(tr),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"message": message.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"commit": commit.Shortcuts(),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"feedback": feedback.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"file": file.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"action": tr.T("cmd.action.short"),
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"message": "Message center operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"commit": "Commit history, view, diff and blame operations",
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"feedback": "Submit feedback and suggestions",
"dataset": tr.T("cmd.dataset.short"),
"file": tr.T("cmd.file.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
}

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", "action",
"dataset", "file", "health", "ignore", "wiki",
}
groupSet := map[string]bool{}

View File

@ -103,6 +103,8 @@ skills/
│ └── SKILL.md # Pipeline 操作指南
├── gitlink-wiki/ # Wiki 页面管理
│ └── SKILL.md # Wiki 操作指南
├── gitlink-file/ # 文件内容操作
│ └── SKILL.md # 文件操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
@ -144,6 +146,7 @@ skills/
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
| **gitlink-file** | 文件内容操作(无需克隆) | `file +view`, `file +search`, `file +create`, `file +update`, `file +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |

View File

@ -0,0 +1,57 @@
---
name: gitlink-file
version: 1.0.0
description: "文件内容操作:无需克隆即可查看、搜索、创建、更新、删除 GitLink 仓库文件。当用户需要读取或修改仓库中的单个文件(如 README、配置文件而不想克隆仓库时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli file --help"
---
# gitlink-file文件内容操作
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — `file +create` / `+update` / `+delete` 会直接产生提交,执行前务必先确认用户意图,并优先使用 `--new-branch` 提交到新分支。**
> 目录列表和 README 查看请使用 `repo +tree``repo +readme`(见 [`../gitlink-repo/SKILL.md`](../gitlink-repo/SKILL.md))。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `file +view` | 查看文件内容;`--raw` 仅输出解码后的正文 | 否(公开项目) |
| `file +search` | 按文件名搜索仓库文件 | 否(公开项目) |
| `file +create` | 创建文件并提交到指定分支 | 是 |
| `file +update` | 更新文件并提交到指定分支 | 是 |
| `file +delete` | 删除文件并提交 | 是 |
## 示例
```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 +view --owner Gitlink --repo forgeplus --path app/models/user.rb --ref develop
# 按文件名搜索
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 +create --owner me --repo proj --path docs/note.md --content-file note.md -b master
# 更新文件并提交到从 master 新建的分支(推荐,便于走 PR 流程)
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"
```
## Notes
- `file +view` 调用 `/api/{owner}/{repo}/sub_entries``+search` 调用 `/api/{owner}/{repo}/files`
- 写操作调用 `/api/v1/{owner}/{repo}/contents/batch`,内容使用 `text` 编码传输(生产环境不接受 base64
- `--message` 缺省为 `<action> <path>``--new-branch` 从 `--branch` 新建分支并提交到新分支。
- 修改公共仓库文件时,优先 `--new-branch` + `pr +create`(见 [`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md)),避免直接推主分支。