feat(branch): complete OpenAPI shortcuts

This commit is contained in:
wangyue789 2026-05-28 15:51:13 +08:00
parent ef7a2c6ac1
commit 044cd0e0d2
7 changed files with 535 additions and 127 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 | List, create, delete, restore, set default, protect, unprotect branches |
| 🏷️ Release | Create, edit, update, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
@ -406,14 +406,24 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
### Branch Management
```bash
# List branches
gitlink-cli branch +list --owner Gitlink --repo forgeplus
# List branches, including deleted branches when needed
gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature
gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted
# Create a branch
gitlink-cli branch +create --name feature/new-feature
# List all branches without pagination
gitlink-cli branch +all --owner Gitlink --repo forgeplus
# Delete a branch
gitlink-cli branch +delete --name feature/old-feature
# Create a branch, with dry-run preview
gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run
# Delete a branch, with dry-run preview
gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run
# Set default branch
gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run
# Restore a deleted branch
gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run
# Protect a branch
gitlink-cli branch +protect --name main

View File

@ -108,7 +108,7 @@
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🌿 分支 | 列出、创建、删除、恢复、设置默认分支、保护分支 |
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
@ -413,6 +413,34 @@ 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 --keyword feature
gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted
# 列出全部分支(无分页)
gitlink-cli branch +all --owner Gitlink --repo forgeplus
# 创建分支,先 dry-run 预览
gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run
# 删除分支,先 dry-run 预览
gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run
# 设置默认分支
gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run
# 恢复已删除分支
gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run
# 保护/取消保护分支
gitlink-cli branch +protect --owner Gitlink --repo forgeplus --name main
gitlink-cli branch +unprotect --owner Gitlink --repo forgeplus --name main
```
### 发布管理
```bash

View File

@ -0,0 +1,40 @@
# Branch OpenAPI Shortcuts
补齐 GitLink 分支 OpenAPI 的生命周期操作,并增强现有 branch shortcut 的安全性和参数能力。
## 新增 / 增强命令
- `branch +list`:新增 `--keyword``--state all|deleted`,对齐 OpenAPI 查询参数。
- `branch +all`:调用无分页分支列表接口。
- `branch +create`:新增 `--dry-run`,预览创建分支请求。
- `branch +delete`:切换到 OpenAPI 文档中的 `DELETE /api/v1/{owner}/{repo}/branches/{branch}.json`,并新增 `--dry-run`
- `branch +set-default`:设置仓库默认分支,支持 `--dry-run`
- `branch +restore`:恢复已删除分支,支持 `--dry-run`
## OpenAPI 对齐
- `GET /api/v1/{owner}/{repo}/branches.json`
- `POST /api/v1/{owner}/{repo}/branches.json`
- `GET /api/v1/{owner}/{repo}/branches/all.json`
- `DELETE /api/v1/{owner}/{repo}/branches/{branch}.json`
- `PATCH /api/v1/{owner}/{repo}/branches/update_default_branch.json`
- `POST /api/v1/{owner}/{repo}/branches/restore.json`
## 安全设计
- `branch +create`、`branch +delete`、`branch +set-default`、`branch +restore` 都支持 `--dry-run`
- `branch +delete` 会对包含 `/` 的分支名进行路径转义,避免把 `feature/foo` 误解析为多级路径。
- `branch +restore` 校验 `--branch-id` 必须为正整数。
- `branch +list --state` 仅允许 `all``deleted`,避免无效状态参数。
## 测试
新增单元测试覆盖:
- list 查询参数。
- all endpoint。
- create payload 与 dry-run。
- delete v1 endpoint 与路径转义。
- set-default query 参数。
- restore payload。
- 无效 state / branch-id 不触发 API。

View File

@ -1,10 +1,14 @@
package branch
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -15,6 +19,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Name: "list",
Description: tr.T("cmd.branch.list.short"),
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "state", Short: "s", Usage: "Branch state: all or deleted"},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
@ -22,37 +28,65 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state, err := normalizeBranchState(ctx.Arg("state"))
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
q.Set("page", defaultBranchValue(ctx.Arg("page"), "1"))
q.Set("limit", defaultBranchValue(ctx.Arg("limit"), "20"))
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if state != "" {
q.Set("state", state)
}
env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "all",
Description: "List all branches without pagination",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", branchPath(ctx)+"/all", nil)
if err != nil {
return err
}
return outputBranchEnvelope(ctx, env)
},
},
{
Name: "create",
Description: tr.T("cmd.branch.create.short"),
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
{Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"},
{Name: "dry-run", Usage: "Preview the request body without creating the branch", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
from := defaultBranchValue(ctx.Arg("from"), "master")
payload := map[string]interface{}{
"new_branch_name": name,
"old_branch_name": from,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
if parseBranchBool(ctx.Arg("dry-run")) {
return ctx.OutputData(branchDryRun("create_branch", "POST", branchPath(ctx), payload, nil))
}
env, err := ctx.CallAPI("POST", branchPath(ctx), payload)
if err != nil {
return err
}
@ -64,16 +98,88 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Description: tr.T("cmd.branch.delete.short"),
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
{Name: "dry-run", Usage: "Preview the delete request without deleting the branch", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
path := fmt.Sprintf("%s/%s", branchPath(ctx), url.PathEscape(name))
if parseBranchBool(ctx.Arg("dry-run")) {
return ctx.OutputData(branchDryRun("delete_branch", "DELETE", path, nil, nil))
}
env, err := ctx.CallAPI("DELETE", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "set-default",
Description: "Set the repository default branch",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
{Name: "dry-run", Usage: "Preview the request without changing the default branch", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
path := branchPath(ctx) + "/update_default_branch"
q := url.Values{}
q.Set("name", name)
if parseBranchBool(ctx.Arg("dry-run")) {
return ctx.OutputData(branchDryRun("set_default_branch", "PATCH", path, nil, q))
}
env, err := ctx.CallAPIWithQuery("PATCH", path, q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "restore",
Description: "Restore a deleted branch",
Flags: []common.Flag{
{Name: "branch-id", Short: "i", Usage: "Deleted branch ID", Required: true},
{Name: "name", Short: "n", Usage: "Deleted branch name", Required: true},
{Name: "dry-run", Usage: "Preview the request body without restoring the branch", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
branchIDValue, err := ctx.RequireArg("branch-id")
if err != nil {
return err
}
branchID, err := parsePositiveBranchInt(branchIDValue, "branch-id")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"branch_id": branchID,
"branch_name": name,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
path := branchPath(ctx) + "/restore"
if parseBranchBool(ctx.Arg("dry-run")) {
return ctx.OutputData(branchDryRun("restore_branch", "POST", path, payload, nil))
}
env, err := ctx.CallAPI("POST", path, payload)
if err != nil {
return err
}
@ -128,3 +234,66 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
}
return i18n.Default()
}
func branchPath(ctx *common.RuntimeContext) string {
return "/v1" + ctx.RepoPath() + "/branches"
}
func outputBranchEnvelope(ctx *common.RuntimeContext, env *output.Envelope) error {
if raw, ok := env.Data.(string); ok {
var parsed interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
return ctx.OutputData(parsed)
}
}
return ctx.Output(env)
}
func normalizeBranchState(value string) (string, error) {
state := strings.ToLower(strings.TrimSpace(value))
if state == "" {
return "", nil
}
switch state {
case "all", "deleted":
return state, nil
default:
return "", fmt.Errorf("invalid --state %q: use all or deleted", value)
}
}
func parsePositiveBranchInt(value, flagName string) (int, error) {
id, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || id <= 0 {
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
}
return id, nil
}
func defaultBranchValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func parseBranchBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}
func branchDryRun(action, method, path string, body map[string]interface{}, query url.Values) map[string]interface{} {
data := map[string]interface{}{
"dry_run": true,
"action": action,
"method": method,
"path": path,
}
if body != nil {
data["body"] = body
}
if len(query) > 0 {
data["query"] = query.Encode()
}
return data
}

View File

@ -20,13 +20,15 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
shortcuts := Shortcuts()
for _, s := range shortcuts {
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
@ -35,93 +37,205 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
func newBranchTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertBranchRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeBranchJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeBranchJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// --- list ---
func TestBranchList(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)
}
writeJSON(w, []interface{}{
map[string]interface{}{"name": "master"},
map[string]interface{}{"name": "develop"},
})
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list failed: %v", err)
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
// --- create ---
func writeBranchText(t *testing.T, w http.ResponseWriter, code int, text string) {
t.Helper()
w.WriteHeader(code)
if _, err := w.Write([]byte(text)); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func TestBranchCreate(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.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"name": "feature-x"})
}))
func assertBranchEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestBranchListBuildsQuery(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "GET", "/v1/owner/repo/branches.json")
query := r.URL.Query()
assertBranchEqual(t, query.Get("keyword"), "feature")
assertBranchEqual(t, query.Get("state"), "deleted")
assertBranchEqual(t, query.Get("page"), "2")
assertBranchEqual(t, query.Get("limit"), "5")
writeBranchJSON(t, w, map[string]interface{}{"total_count": 0, "branches": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"})
err := runShortcut(t, server, "list", map[string]string{
"keyword": "feature",
"state": "deleted",
"page": "2",
"limit": "5",
})
if err != nil {
t.Fatalf("create failed: %v", err)
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestBranchAllUsesAllEndpoint(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "GET", "/v1/owner/repo/branches/all.json")
writeBranchJSON(t, w, []map[string]interface{}{{"name": "master"}})
})
defer server.Close()
if err := runShortcut(t, server, "all", nil); err != nil {
t.Fatalf("all shortcut failed: %v", err)
}
}
func TestBranchCreatePayload(t *testing.T) {
var payload map[string]interface{}
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches.json")
payload = decodeBranchJSON(t, r)
writeBranchJSON(t, w, map[string]interface{}{"name": "feature/a"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"name": "feature/a",
"from": "master",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertBranchEqual(t, payload["new_branch_name"], "feature/a")
assertBranchEqual(t, payload["old_branch_name"], "master")
}
func TestBranchCreateDefaultFrom(t *testing.T) {
// When 'from' is not set, it defaults to "master"
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)
}
writeJSON(w, map[string]interface{}{"name": "feature-y"})
}))
var payload map[string]interface{}
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches.json")
payload = decodeBranchJSON(t, r)
writeBranchJSON(t, w, map[string]interface{}{"name": "feature-y"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-y"})
if err != nil {
t.Fatalf("create failed: %v", err)
}
assertBranchEqual(t, payload["old_branch_name"], "master")
}
// --- delete ---
func TestBranchDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/branches/delete.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "deleted"})
}))
func TestBranchCreateDryRunDoesNotCallAPI(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
err := runShortcut(t, server, "create", map[string]string{
"name": "feature/a",
"from": "master",
"dry-run": "true",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
t.Fatalf("create dry-run failed: %v", err)
}
}
// --- protect ---
func TestBranchDeleteUsesV1EndpointAndEscapesName(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.EscapedPath() != "/v1/owner/repo/branches/feature%2Fold.json" {
t.Fatalf("got request %s %s, want DELETE /v1/owner/repo/branches/feature%%2Fold.json", r.Method, r.URL.EscapedPath())
}
writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "feature/old"})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestBranchSetDefaultUsesQueryName(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "PATCH", "/v1/owner/repo/branches/update_default_branch.json")
assertBranchEqual(t, r.URL.Query().Get("name"), "develop")
writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "set-default", map[string]string{"name": "develop"})
if err != nil {
t.Fatalf("set-default shortcut failed: %v", err)
}
}
func TestBranchRestorePayload(t *testing.T) {
var payload map[string]interface{}
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches/restore.json")
payload = decodeBranchJSON(t, r)
writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "restore", map[string]string{
"branch-id": "7",
"name": "feature/deleted",
})
if err != nil {
t.Fatalf("restore shortcut failed: %v", err)
}
assertBranchEqual(t, payload["branch_id"], float64(7))
assertBranchEqual(t, payload["branch_name"], "feature/deleted")
}
func TestBranchRejectsInvalidInputs(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called for invalid input: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"state": "open"}); err == nil {
t.Fatal("expected invalid state to fail")
}
if err := runShortcut(t, server, "restore", map[string]string{"branch-id": "abc", "name": "deleted"}); err == nil {
t.Fatal("expected invalid branch-id to fail")
}
}
func TestBranchProtect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/protected_branches.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "protected"})
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "POST", "/owner/repo/protected_branches.json")
writeBranchJSON(t, w, map[string]interface{}{"message": "protected"})
})
defer server.Close()
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
@ -130,18 +244,11 @@ func TestBranchProtect(t *testing.T) {
}
}
// --- unprotect ---
func TestBranchUnprotect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/protected_branches/master.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "unprotected"})
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertBranchRequest(t, r, "DELETE", "/owner/repo/protected_branches/master.json")
writeBranchJSON(t, w, map[string]interface{}{"message": "unprotected"})
})
defer server.Close()
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
@ -150,13 +257,10 @@ func TestBranchUnprotect(t *testing.T) {
}
}
// --- HTTP error paths ---
func TestBranchListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
@ -166,10 +270,9 @@ func TestBranchListHTTPError(t *testing.T) {
}
func TestBranchCreateHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x"})
@ -179,10 +282,9 @@ func TestBranchCreateHTTPError(t *testing.T) {
}
func TestBranchDeleteHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
@ -192,10 +294,9 @@ func TestBranchDeleteHTTPError(t *testing.T) {
}
func TestBranchProtectHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
@ -205,10 +306,9 @@ func TestBranchProtectHTTPError(t *testing.T) {
}
func TestBranchUnprotectHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})

View File

@ -138,7 +138,7 @@ skills/
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +all`, `branch +create`, `branch +delete`, `branch +set-default`, `branch +restore` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` |
### 辅助 Skills

View File

@ -1,7 +1,7 @@
---
name: gitlink-branch
version: 1.0.0
description: "分支管理:创建、查看、删除、保护分支。当用户需要操作 GitLink 分支时触发。"
description: "分支管理:列出、创建、删除、恢复、设置默认分支、保护分支。当用户需要操作 GitLink 分支时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
@ -20,9 +20,12 @@ metadata:
| Shortcut | 说明 | 操作类型 |
|----------|------|----------|
| `branch +list` | 列出仓库的所有分支 | Read |
| `branch +create` | 创建新分支 | ⚠️ Write Operation |
| `branch +delete` | 删除分支 | 🔴 Destructive Operation |
| `branch +list` | 分页列出分支,支持 keyword/state 过滤 | Read |
| `branch +all` | 列出全部分支(无分页) | Read |
| `branch +create` | 创建新分支,支持 dry-run | ⚠️ Write Operation |
| `branch +delete` | 删除分支,支持 dry-run | 🔴 Destructive Operation |
| `branch +set-default` | 设置默认分支,支持 dry-run | ⚠️ Write Operation |
| `branch +restore` | 恢复已删除分支,支持 dry-run | ⚠️ Write Operation |
| `branch +protect` | 设置分支保护规则 | ⚠️ Write Operation |
| `branch +unprotect` | 移除分支保护规则 | ⚠️ Write Operation |
@ -34,6 +37,8 @@ metadata:
|------|------|------|
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--keyword, -k` | 否 | 搜索关键词 |
| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` |
| `--page, -p` | 否 | 页码(默认 `1` |
| `--limit, -l` | 否 | 每页条数(默认 `20` |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
@ -45,6 +50,7 @@ metadata:
|------|------|------|
| `--name, -n` | 是 | 新分支名称 |
| `--from, -f` | 否 | 源分支或 commit默认 `master` |
| `--dry-run` | 否 | 只预览请求,不创建分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
@ -55,11 +61,32 @@ metadata:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 要删除的分支名称 |
| `--dry-run` | 否 | 只预览请求,不删除分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
### branch +set-default
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 要设置为默认分支的名称 |
| `--dry-run` | 否 | 只预览请求,不修改默认分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
### branch +restore
| 参数 | 必填 | 说明 |
|------|------|------|
| `--branch-id, -i` | 是 | 已删除分支的 branch_id可从 `branch +list --state deleted` 获取 |
| `--name, -n` | 是 | 已删除分支名称 |
| `--dry-run` | 否 | 只预览请求,不恢复分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
### branch +protect
| 参数 | 必填 | 说明 |
@ -88,14 +115,18 @@ metadata:
# 列出当前仓库的分支
gitlink-cli branch +list
# 指定仓库并分页
# 指定仓库并分页 / 过滤
gitlink-cli branch +list --owner Gitlink --repo forgeplus --page 1 --limit 10
gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature --state all
# 列出全部分支(无分页)
gitlink-cli branch +all --owner Gitlink --repo forgeplus
# 输出为 JSON
gitlink-cli branch +list --format json
# 从 master 创建分支
gitlink-cli branch +create --name feature/new-feature
# 从 master 创建分支(先 dry-run
gitlink-cli branch +create --name feature/new-feature --dry-run
# 从指定分支创建
gitlink-cli branch +create --name hotfix/bug-123 --from develop
@ -103,12 +134,19 @@ gitlink-cli branch +create --name hotfix/bug-123 --from develop
# 指定仓库创建分支
gitlink-cli branch +create --name feature/x --owner someone --repo myrepo
# 删除分支
gitlink-cli branch +delete --name feature/old-feature
# 删除分支(先 dry-run
gitlink-cli branch +delete --name feature/old-feature --dry-run
# 指定仓库删除分支
gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo
# 设置默认分支(先 dry-run
gitlink-cli branch +set-default --name develop --dry-run
# 查看已删除分支并恢复(先 dry-run
gitlink-cli branch +list --state deleted
gitlink-cli branch +restore --branch-id 7 --name feature/old-feature --dry-run
# 保护分支
gitlink-cli branch +protect --name main
@ -127,8 +165,9 @@ gitlink-cli branch +unprotect --name main
> This is a **Write Operation** — confirm user intent.
1. 确认用户希望创建的分支名称和源分支。
2. 执行 `branch +create --name <name> --from <source>`
3. 输出创建结果。
2. 先执行 `branch +create --name <name> --from <source> --dry-run`
3. 用户确认后去掉 `--dry-run` 执行。
4. 输出创建结果。
### branch +deleteDestructive Operation
@ -136,8 +175,30 @@ gitlink-cli branch +unprotect --name main
> This is a **Destructive Operation** — confirm user intent.
1. 确认用户确实希望删除该分支(此操作不可逆)。
2. 执行 `branch +delete --name <name>`
3. 输出删除结果。
2. 先执行 `branch +delete --name <name> --dry-run`
3. 用户确认后去掉 `--dry-run` 执行。
4. 输出删除结果。
### branch +set-defaultWrite Operation
> [!CAUTION]
> This is a **Write Operation** — confirm user intent.
1. 确认用户希望设置的默认分支。
2. 先执行 `branch +set-default --name <name> --dry-run`
3. 用户确认后去掉 `--dry-run` 执行。
4. 输出设置结果。
### branch +restoreWrite Operation
> [!CAUTION]
> This is a **Write Operation** — confirm user intent.
1. 先用 `branch +list --state deleted` 找到 `branch_id` 和分支名。
2. 执行 `branch +restore --branch-id <id> --name <name> --dry-run`
3. 用户确认后去掉 `--dry-run` 执行。
4. 输出恢复结果。
### branch +protectWrite Operation
@ -157,7 +218,7 @@ gitlink-cli branch +unprotect --name main
2. 执行 `branch +unprotect --name <name>`
3. 输出结果。
> **注意:** `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 解除保护(受限于 API 路由),需通过 Web 页面操作
> **注意:** `branch +delete` 使用 v1 OpenAPI 路径并会对含 `/` 的分支名做路径转义;`branch +unprotect` 仍使用保护分支接口
## References