From 7dd6fe478b774acca180067f32367e698dc64a36 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Thu, 25 Jun 2026 16:54:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(branch):=20=E8=A1=A5=E9=BD=90=E5=88=86?= =?UTF-8?q?=E6=94=AF=E7=AD=9B=E9=80=89=E3=80=81=E9=BB=98=E8=AE=A4=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=88=87=E6=8D=A2=E4=B8=8E=E6=81=A2=E5=A4=8D=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- README.zh-CN.md | 33 ++++- doc/changes/branch-lifecycle-shortcuts.md | 28 ++++ internal/i18n/locales/en-US.json | 7 + internal/i18n/locales/zh-CN.json | 7 + shortcuts/branch/branch.go | 81 ++++++++++ shortcuts/branch/branch_test.go | 171 ++++++++++++++++++++++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 doc/changes/branch-lifecycle-shortcuts.md diff --git a/README.md b/README.md index e5e4318..88ee5ad 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | -| 🌿 Branch | Create, delete, list, protect, unprotect branches | +| 🌿 Branch | Create, delete, restore, list, filter, protect, unprotect, and switch default branches | | 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | @@ -440,12 +440,24 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved # List branches gitlink-cli branch +list --owner Gitlink --repo forgeplus +# List deleted branches or search by keyword +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted --keyword release/ + +# List all branches with archive download URLs +gitlink-cli branch +all --owner Gitlink --repo forgeplus + # Create a branch gitlink-cli branch +create --name feature/new-feature # Delete a branch gitlink-cli branch +delete --name feature/old-feature +# Set the default branch +gitlink-cli branch +set-default --name main + +# Restore a deleted branch +gitlink-cli branch +restore --branch-id 7 --name feature/old-feature + # Protect a branch gitlink-cli branch +protect --name main diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..8a91795 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | -| 🌿 分支 | 创建、删除、保护分支 | +| 🌿 分支 | 创建、删除、恢复、筛选、保护分支,并切换默认分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -444,6 +444,37 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" ``` +### 分支管理 + +```bash +# 列出分支 +gitlink-cli branch +list --owner Gitlink --repo forgeplus + +# 列出已删除分支,或按关键字筛选 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted --keyword release/ + +# 列出全部分支及归档下载链接 +gitlink-cli branch +all --owner Gitlink --repo forgeplus + +# 创建分支 +gitlink-cli branch +create --name feature/new-feature + +# 删除分支 +gitlink-cli branch +delete --name feature/old-feature + +# 切换默认分支 +gitlink-cli branch +set-default --name main + +# 恢复已删除分支 +gitlink-cli branch +restore --branch-id 7 --name feature/old-feature + +# 设置分支保护 +gitlink-cli branch +protect --name main + +# 移除分支保护 +gitlink-cli branch +unprotect --name main +``` + ### 发布管理 ```bash diff --git a/doc/changes/branch-lifecycle-shortcuts.md b/doc/changes/branch-lifecycle-shortcuts.md new file mode 100644 index 0000000..6d2e3e7 --- /dev/null +++ b/doc/changes/branch-lifecycle-shortcuts.md @@ -0,0 +1,28 @@ +# Branch Lifecycle Shortcuts + +This change expands branch management coverage so repository maintainers can complete more of the branch lifecycle from `gitlink-cli` without falling back to manual API calls. + +## Commands + +- `branch +all` +- `branch +set-default` +- `branch +restore` + +## Improvements + +- `branch +list` now supports `--state` so users can inspect visible branches, deleted branches, or all branch records. +- `branch +list` now supports `--keyword` to filter branches by name on the server side. +- The README examples document the deleted-branch recovery flow so users can retrieve `branch_id` and restore the branch in one CLI workflow. + +## API Mapping + +| Shortcut | Method | API path | +|----------|--------|----------| +| `branch +all` | GET | `/api/v1/{owner}/{repo}/branches/all.json` | +| `branch +set-default` | PATCH | `/api/v1/{owner}/{repo}/branches/update_default_branch.json?name=...` | +| `branch +restore` | POST | `/api/v1/{owner}/{repo}/branches/restore.json` | + +## Verification + +- Unit tests cover request methods, paths, query parameters, restore payloads, invalid `branch-id` validation, and HTTP error handling. +- Documentation now includes branch filtering, default-branch switching, and deleted-branch restore examples. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..c890226 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -5,10 +5,13 @@ "cmd.auth.logout.short": "Logout from GitLink", "cmd.auth.short": "Authentication commands", "cmd.auth.status.short": "Show authentication status", + "cmd.branch.all.short": "List all branches with archive URLs", "cmd.branch.create.short": "Create a branch", "cmd.branch.delete.short": "Delete a branch", "cmd.branch.list.short": "List branches", "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.restore.short": "Restore a deleted branch", + "cmd.branch.set_default.short": "Set the default branch", "cmd.branch.short": "Branch operations", "cmd.branch.unprotect.short": "Remove branch protection", "cmd.ci.builds.short": "List CI builds", @@ -109,6 +112,7 @@ "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", + "error.branch.id_invalid": "invalid --branch-id: {value}; must be a positive integer", "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", @@ -125,7 +129,10 @@ "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", + "flag.branch.id": "Deleted branch ID", + "flag.branch.keyword": "Filter branches by keyword", "flag.branch.name": "Branch name", + "flag.branch.state": "Branch visibility: visible (default), deleted, or all", "flag.ci.build": "Build number", "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..8ce2cb7 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -5,10 +5,13 @@ "cmd.auth.logout.short": "退出 GitLink 登录", "cmd.auth.short": "认证命令", "cmd.auth.status.short": "显示认证状态", + "cmd.branch.all.short": "列出全部分支及归档链接", "cmd.branch.create.short": "创建分支", "cmd.branch.delete.short": "删除分支", "cmd.branch.list.short": "列出分支", "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.restore.short": "恢复已删除分支", + "cmd.branch.set_default.short": "设置默认分支", "cmd.branch.short": "分支操作", "cmd.branch.unprotect.short": "移除分支保护", "cmd.ci.builds.short": "列出 CI 构建", @@ -109,6 +112,7 @@ "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", + "error.branch.id_invalid": "无效的 --branch-id:{value};必须是正整数", "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", @@ -125,7 +129,10 @@ "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit", + "flag.branch.id": "已删除分支 ID", + "flag.branch.keyword": "按关键字筛选分支", "flag.branch.name": "分支名称", + "flag.branch.state": "分支范围:默认可见分支,也可选 deleted 或 all", "flag.ci.build": "构建编号", "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 0393ad5..2c2a341 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -1,8 +1,11 @@ package branch import ( + "errors" "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -17,6 +20,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + {Name: "state", Usage: tr.T("flag.branch.state")}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.branch.keyword")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -25,6 +30,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if state := strings.TrimSpace(ctx.Arg("state")); state != "" { + q.Set("state", state) + } + if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { + q.Set("keyword", keyword) + } env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { return err @@ -32,6 +43,20 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "all", + Description: tr.T("cmd.branch.all.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.branch.create.short"), @@ -80,6 +105,53 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "set-default", + Description: tr.T("cmd.branch.set_default.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, _ := ctx.RequireArg("name") + q := url.Values{} + q.Set("name", name) + env, err := ctx.CallAPIWithQuery("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "restore", + Description: tr.T("cmd.branch.restore.short"), + Flags: []common.Flag{ + {Name: "branch-id", Usage: tr.T("flag.branch.id"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + branchID, err := parseBranchID(ctx) + if err != nil { + return err + } + name, _ := ctx.RequireArg("name") + payload := map[string]interface{}{ + "branch_id": branchID, + "branch_name": name, + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "protect", Description: tr.T("cmd.branch.protect.short"), @@ -128,3 +200,12 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func parseBranchID(ctx *common.RuntimeContext) (int, error) { + value, _ := ctx.RequireArg("branch-id") + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, errors.New(ctx.Tr.Tf("error.branch.id_invalid", i18n.Args{"value": value})) + } + return parsed, nil +} diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go index 1f1908f..eb712ea 100644 --- a/shortcuts/branch/branch_test.go +++ b/shortcuts/branch/branch_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -19,6 +20,7 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st Repo: "repo", Format: "json", Args: args, + Tr: i18n.Default(), } return shortcut.Run(ctx) } @@ -47,6 +49,12 @@ func TestBranchList(t *testing.T) { if r.URL.Path != "/v1/owner/repo/branches.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("unexpected page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Fatalf("unexpected limit: %s", got) + } writeJSON(w, []interface{}{ map[string]interface{}{"name": "master"}, map[string]interface{}{"name": "develop"}, @@ -60,6 +68,55 @@ func TestBranchList(t *testing.T) { } } +func TestBranchListWithStateAndKeyword(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/branches.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "deleted" { + t.Fatalf("unexpected state: %s", got) + } + if got := r.URL.Query().Get("keyword"); got != "release/" { + t.Fatalf("unexpected keyword: %s", got) + } + writeJSON(w, map[string]interface{}{ + "total_count": 1, + "branches": []interface{}{ + map[string]interface{}{"name": "release/1.0", "branch_id": 7, "is_deleted": true}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{ + "page": "2", + "limit": "50", + "state": "deleted", + "keyword": "release/", + }) + if err != nil { + t.Fatalf("list with filters failed: %v", err) + } +} + +func TestBranchAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/branches/all.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"name": "main"}, + map[string]interface{}{"name": "stable/1.0"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "all", nil) + if err != nil { + t.Fatalf("all failed: %v", err) + } +} + // --- create --- func TestBranchCreate(t *testing.T) { @@ -113,6 +170,78 @@ func TestBranchDelete(t *testing.T) { } } +func TestBranchSetDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Fatalf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/branches/update_default_branch.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "release/1.0" { + t.Fatalf("unexpected default branch name: %s", got) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runShortcut(t, server, "set-default", map[string]string{"name": "release/1.0"}) + if err != nil { + t.Fatalf("set-default failed: %v", err) + } +} + +func TestBranchRestore(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/branches/restore.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode restore payload: %v", err) + } + if got := payload["branch_name"]; got != "feature/restore-me" { + t.Fatalf("unexpected branch_name: %v", got) + } + if got := payload["branch_id"]; got != float64(9) { + t.Fatalf("unexpected branch_id: %v", got) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "9", + "name": "feature/restore-me", + }) + if err != nil { + t.Fatalf("restore failed: %v", err) + } +} + +func TestBranchRestoreRejectsInvalidBranchID(t *testing.T) { + var called bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "abc", + "name": "feature/restore-me", + }) + if err == nil { + t.Fatal("expected invalid branch-id to fail") + } + if called { + t.Fatal("restore should fail before making a request") + } +} + // --- protect --- func TestBranchProtect(t *testing.T) { @@ -165,6 +294,19 @@ func TestBranchListHTTPError(t *testing.T) { } } +func TestBranchAllHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "all", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func TestBranchCreateHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -178,6 +320,35 @@ func TestBranchCreateHTTPError(t *testing.T) { } } +func TestBranchSetDefaultHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "set-default", map[string]string{"name": "main"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestBranchRestoreHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "3", + "name": "feature/restore-me", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func TestBranchDeleteHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError)