feat(branch): 补齐分支筛选、默认分支切换与恢复能力

This commit is contained in:
Mengz 2026-06-25 16:54:50 +08:00
parent 71ca2bb683
commit 7dd6fe478b
7 changed files with 339 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "步骤编号",

View File

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

View File

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