feat(repo): add repo +rename to rename the current repository

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 13:49:41 +08:00
parent 9749a4c832
commit 41d2f5bbb0
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
5 changed files with 155 additions and 0 deletions

View File

@ -0,0 +1,66 @@
# repo +rename 仓库重命名命令
## 背景
`gitlink-cli repo` 已提供创建、Fork、删除等仓库管理能力但缺少对标 `gh repo rename` 的重命名命令。用户或 AI Agent 想改名当前仓库时,过去只能手动调用 PATCH 更新项目接口。
本次变更把重命名封装为 `repo +rename`,与 GitHub CLI 的 `gh repo rename` 语义对齐。
## 变更内容
- 新增 `gitlink-cli repo +rename` Shortcut。
- 调用 `PATCH /{owner}/{repo}` 更新项目,请求体同时设置 `name`(项目名称)与 `identifier`(项目标识)。
- 支持 `--name, -n` 指定新名称,必填;空值或纯空白会在调用接口前被拒绝。
- 复用现有仓库上下文解析、API 调用和统一输出格式。
- 补充中英文 i18n 文案,避免命令帮助信息硬编码。
## 命令示例
```bash
# 重命名当前仓库owner/repo 从 git remote 自动解析)
gitlink-cli repo +rename --name new-name
# 显式指定仓库
gitlink-cli repo +rename --owner Gitlink --repo forgeplus --name new-name
```
## 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 新的仓库名称与标识 |
| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 |
| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 |
| `--format` | 否 | 全局参数,输出格式:`json`、`table` 或 `yaml` |
## API 映射
| Shortcut | Method | API path | 请求体字段 |
|----------|--------|----------|-----------|
| `repo +rename` | PATCH | `/api/{owner}/{repo}.json` | `name`、`identifier` |
参照 API 文档「PATCH 更新项目」与「PATCH 更新项目(完整)」,`name` 与 `identifier` 均为必填。`name` 是展示用项目名称,`identifier` 是 URL 标识(等价于 `gh repo rename` 改动的仓库 URL 标识),两者都从 `--name` 取值。
## 兼容性提示
重命名会同时修改 `identifier`,也就是仓库的 URL 标识,因此**克隆地址会随之变化**(与 `gh repo rename` 行为一致)。执行后本地已有的 remote 需要相应更新。
## 测试覆盖
- 表驱动单元测试断言请求方法为 `PATCH`、路径为 `/owner/repo.json`,且请求体 `name``identifier` 均为新名称。
- 覆盖前后空白被裁剪的场景。
- 缺失或纯空白的 `--name` 在调用接口前即返回校验错误,不触发任何 HTTP 请求。
验证命令:
```bash
go build ./...
go test ./shortcuts/repo/
```
## 交付要求核对
- 功能代码:`shortcuts/repo/repo.go`
- 单元测试:`shortcuts/repo/repo_test.go`
- i18n 文案:`internal/i18n/locales/en-US.json`、`internal/i18n/locales/zh-CN.json`
- 变更说明文档:`doc/changes/repo-rename.md`

View File

@ -86,6 +86,7 @@
"cmd.repo.fork.short": "Fork a repository",
"cmd.repo.info.short": "Show repository details",
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.rename.short": "Rename the current repository",
"cmd.repo.short": "Repository operations",
"cmd.repo.tree.short": "List repository files and directories",
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
@ -212,6 +213,7 @@
"flag.repo.description": "Repository description",
"flag.repo.name": "Repository name",
"flag.repo.private": "Make repository private (true/false)",
"flag.repo.rename.name": "New repository name and identifier (changes the remote URL)",
"flag.repo.tree.path": "Directory path to list (default: repository root)",
"flag.repo.tree.ref": "Branch, tag, or commit ref",
"flag.search.keyword": "Search keyword",

View File

@ -86,6 +86,7 @@
"cmd.repo.fork.short": "Fork 仓库",
"cmd.repo.info.short": "显示仓库详情",
"cmd.repo.list.short": "列出用户或组织的仓库",
"cmd.repo.rename.short": "重命名当前仓库",
"cmd.repo.short": "仓库操作",
"cmd.repo.tree.short": "列出仓库文件和目录",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
@ -212,6 +213,7 @@
"flag.repo.description": "仓库描述",
"flag.repo.name": "仓库名称",
"flag.repo.private": "设为私有仓库true/false",
"flag.repo.rename.name": "新的仓库名称与标识(会改变远程 URL",
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
"flag.repo.tree.ref": "分支、标签或提交引用",
"flag.search.keyword": "搜索关键词",

View File

@ -225,6 +225,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "rename",
Description: tr.T("cmd.repo.rename.short"),
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: tr.T("flag.repo.rename.name"), Required: true},
},
Run: runRename,
},
{
Name: "fork",
Description: tr.T("cmd.repo.fork.short"),
@ -263,6 +271,31 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
return i18n.Default()
}
func runRename(ctx *common.RuntimeContext) error {
if _, err := ctx.RequireArg("name"); err != nil {
return err
}
name := strings.TrimSpace(ctx.Arg("name"))
if name == "" {
return fmt.Errorf("invalid --name: repository name must not be empty")
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// The update endpoint requires both the display name and the identifier; the
// identifier is the URL slug, so renaming it also changes the repository's
// remote clone URL, mirroring `gh repo rename`.
body := map[string]interface{}{
"name": name,
"identifier": name,
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return err
}
return ctx.Output(env)
}
func runLanguages(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err

View File

@ -488,6 +488,58 @@ func TestRepoCreateWithOptions(t *testing.T) {
}
}
// --- rename ---
func TestRepoRename(t *testing.T) {
cases := []struct {
name string
arg string
wantName string
}{
{name: "simple name", arg: "new-repo", wantName: "new-repo"},
{name: "trims surrounding whitespace", arg: " renamed ", wantName: "renamed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "PATCH", "/owner/repo.json")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{
"id": float64(21),
"name": tc.wantName,
"identifier": tc.wantName,
})
}))
defer server.Close()
if err := runShortcut(t, server, "rename", map[string]string{"name": tc.arg}); err != nil {
t.Fatalf("rename failed: %v", err)
}
// Renaming updates both the display name and the URL identifier, so the
// PATCH payload must carry the new name in each field.
assertEqual(t, body["name"], tc.wantName)
assertEqual(t, body["identifier"], tc.wantName)
})
}
}
func TestRepoRenameRejectsBlankName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("blank name should not call API, got: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
for _, args := range []map[string]string{nil, {"name": " "}} {
if err := runShortcut(t, server, "rename", args); err == nil {
t.Fatal("expected validation error for missing or blank name")
}
}
}
// --- validation/error paths ---
func TestRepoInsightValidation(t *testing.T) {