Merge PR #78: feat(branch): complete OpenAPI shortcuts

# Conflicts:
#	README.md
#	shortcuts/branch/branch.go
#	shortcuts/branch/branch_test.go
#	skills/gitlink-branch/SKILL.md
This commit is contained in:
wbtiger 2026-07-14 22:50:03 +08:00
commit 011a914f2b
7 changed files with 558 additions and 474 deletions

134
README.md
View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
**[中文文档](./README.zh-CN.md)**
@ -78,11 +78,19 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## Why gitlink-cli?
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
@ -100,19 +108,19 @@ 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 |
| 🏷️ Release | Create, edit, update, view, delete releases, and manage release assets |
| 🌿 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 |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 👤 User | View user profiles and info |
| 📊 Profile | User ability, role, major, activity, and contribution statistics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
The `message` shortcut group adds inbox automation for listing messages, checking unread counters, marking messages as read, and deleting older notifications.
## Installation & Quick Start
### Requirements
@ -287,22 +295,6 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Message Center Management
```bash
# List the latest notifications with unread filtering
gitlink-cli message +list --login Mengz --type notification --status unread --limit 10
# Show unread counters for notifications and @mentions
gitlink-cli message +stats --login Mengz
# Preview marking all notifications as read
gitlink-cli message +read --login Mengz --type notification --all --dry-run
# Delete selected @mention messages
gitlink-cli message +delete --login Mengz --type atme --ids 101,102
```
### Issue Management
```bash
@ -414,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
@ -442,16 +444,6 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.
# View a release
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
# List assets attached to a release
gitlink-cli release +assets --owner Gitlink --repo forgeplus -i <version_id>
# Attach or detach existing asset IDs while preserving release metadata
gitlink-cli release +attach --owner Gitlink --repo forgeplus -i <version_id> --attachment-ids 12,34 --dry-run
gitlink-cli release +detach --owner Gitlink --repo forgeplus -i <version_id> --attachment-ids 34 --dry-run
# Upload a local file and attach it to the release in one step
gitlink-cli release +upload --owner Gitlink --repo forgeplus -i <version_id> --file dist/gitlink-cli_linux_amd64.tar.gz --asset-name gitlink-cli-linux-amd64.tar.gz --description "Linux binary" --dry-run
# Get edit data and update while preserving unspecified fields
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
@ -495,6 +487,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### Ignore File Templates
```bash
# List all available .gitignore templates
gitlink-cli ignore +list
# Filter templates by name
gitlink-cli ignore +list --name Go
```
### Search
```bash
@ -505,17 +507,27 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### Feedback
### User Profile
`profile` surfaces GitLink's native user statistics (ability, role, major, activity,
contribution). When `--user` is omitted it defaults to the authenticated user.
```bash
# Preview a feedback submission without calling the API
gitlink-cli feedback +create --content "The CLI install guide needs a Windows note." --category docs --dry-run
# Development ability scores + language breakdown
gitlink-cli profile +ability --user zhangsan
# Submit longer feedback from a file and attach contact/repository context
gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com --repo-ref Gitlink/gitlink-cli
# Role positioning / major (discipline) categories
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# Pipe feedback from another command
Get-Content feedback.md | gitlink-cli feedback +create --stdin --category feature
# Ability within a time window (Unix timestamps)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# Recent activity (issues / PRs / commits per day) for the current user
gitlink-cli profile +activity
# Contribution heatmap for a given year
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### Workflow Agent Commands
@ -617,6 +629,32 @@ Safety:
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
### Dataset
`dataset` manages and queries GitLink research datasets (title, description,
paper content, license, owning project).
```bash
# List datasets for one or more projects (by numeric project ID)
gitlink-cli dataset +list --ids 5988
# View a repository's dataset and attachments
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# Create / update a repository's dataset (preview first with --dry-run)
gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated"
# Delete a dataset attachment (destructive: preview, then confirm with --yes)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> Note: `dataset +list` (platform dataset query) is verified on production
> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the
> published OpenAPI contract but are not yet deployed on production (they return
> 404 there); they will work once the platform enables them.
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
@ -673,7 +711,7 @@ git push gitlink
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details.
See [skills/README.md](./skills/README.md) for details.
| Skill | Description |
|-------|-------------|
@ -743,7 +781,7 @@ gitlink-cli/
## Documentation
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation
- [Design Document](doc/design.md) — Architecture design and development plan
## FAQ
@ -808,7 +846,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent
### Q: Where can I find the full API reference?
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md).
## License

View File

@ -100,7 +100,7 @@
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🌿 分支 | 列出、创建、删除、恢复、设置默认分支、保护分支 |
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
@ -403,6 +403,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,12 +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"
)
@ -17,28 +19,29 @@ 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"},
{Name: "keyword", Short: "k", Usage: "Filter branches by keyword"},
{Name: "state", Short: "s", Usage: "Branch state: all, deleted, or empty for active branches"},
},
Run: func(ctx *common.RuntimeContext) error {
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"))
if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" {
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 := strings.TrimSpace(ctx.Arg("state")); state != "" {
if err := validateBranchState(state); err != nil {
return err
}
if state != "" {
q.Set("state", state)
}
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q)
if err != nil {
return err
}
@ -52,11 +55,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil)
env, err := ctx.CallAPI("GET", branchPath(ctx)+"/all", nil)
if err != nil {
return err
}
return ctx.Output(env)
return outputBranchEnvelope(ctx, env)
},
},
{
@ -65,21 +68,25 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
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
}
@ -91,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
}
@ -146,25 +225,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "set-default",
Description: "Set repository default branch",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
{Name: "dry-run", Usage: "Preview the request without changing the default branch", Bool: true, Default: "false"},
},
Run: runSetDefault,
},
{
Name: "restore",
Description: "Restore a deleted branch",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Deleted branch ID", Required: true},
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
{Name: "dry-run", Usage: "Preview the request without restoring the branch", Bool: true, Default: "false"},
},
Run: runRestore,
},
}
}
@ -175,80 +235,65 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
return i18n.Default()
}
func runSetDefault(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
path := "/v1" + ctx.RepoPath() + "/branches/update_default_branch"
query := url.Values{}
query.Set("name", name)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "set_default_branch",
"method": "PATCH",
"path": path,
"query": query.Encode(),
})
}
env, err := ctx.CallAPIWithQuery("PATCH", path, query)
if err != nil {
return err
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 runRestore(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
func normalizeBranchState(value string) (string, error) {
state := strings.ToLower(strings.TrimSpace(value))
if state == "" {
return "", nil
}
id, err := parsePositiveInt(ctx.Arg("id"), "id")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
path := "/v1" + ctx.RepoPath() + "/branches/restore"
body := map[string]interface{}{
"branch_id": id,
"branch_name": name,
}
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "restore_branch",
"method": "POST",
"path": path,
"body": body,
})
}
env, err := ctx.CallAPI("POST", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func validateBranchState(state string) error {
switch state {
case "all", "deleted":
return nil
return state, nil
default:
return fmt.Errorf("invalid --state %q: use all or deleted", state)
return "", fmt.Errorf("invalid --state %q: use all or deleted", value)
}
}
func parsePositiveInt(value, name string) (int64, error) {
value = strings.TrimSpace(value)
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, 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 parsed, nil
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,154 +37,205 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
func newBranchTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
// --- 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)
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 TestBranchListFilters(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("keyword"); got != "feature" {
t.Fatalf("keyword = %q, want feature", got)
}
if got := r.URL.Query().Get("state"); got != "deleted" {
t.Fatalf("state = %q, want deleted", got)
}
writeJSON(w, map[string]interface{}{"total_count": 0, "branches": []interface{}{}})
}))
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")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
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 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, "list", map[string]string{
"page": "1", "limit": "20", "keyword": "feature", "state": "deleted",
"keyword": "feature",
"state": "deleted",
"page": "2",
"limit": "5",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestBranchListRejectsInvalidState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid state should not call API, got %s %s", r.Method, r.URL.Path)
}))
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()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20", "state": "open"})
if err == nil {
t.Fatal("expected validation error")
if err := runShortcut(t, server, "all", nil); err != nil {
t.Fatalf("all shortcut failed: %v", err)
}
}
func TestBranchAll(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/branches/all.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(w, []interface{}{map[string]interface{}{"name": "master"}})
}))
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, "all", nil)
err := runShortcut(t, server, "create", map[string]string{
"name": "feature/a",
"from": "master",
})
if err != nil {
t.Fatalf("all failed: %v", err)
}
}
// --- create ---
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"})
}))
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"})
if err != nil {
t.Fatalf("create failed: %v", err)
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 falls back to the repository default branch.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/owner/repo.json":
writeJSON(w, map[string]interface{}{"default_branch": "main"})
case "/v1/owner/repo/branches.json":
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["old_branch_name"] != "main" {
t.Fatalf("expected old_branch_name to be default branch main, got %v", payload["old_branch_name"])
}
writeJSON(w, map[string]interface{}{"name": "feature-y"})
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
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"})
@ -191,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"})
@ -211,76 +257,10 @@ func TestBranchUnprotect(t *testing.T) {
}
}
func TestBranchSetDefault(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/branches/update_default_branch.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("name"); got != "main" {
t.Fatalf("name query = %q, want main", got)
}
writeJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
if err := runShortcut(t, server, "set-default", map[string]string{"name": "main"}); err != nil {
t.Fatalf("set-default failed: %v", err)
}
}
func TestBranchSetDefaultDryRunDoesNotCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(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()
if err := runShortcut(t, server, "set-default", map[string]string{"name": "main", "dry-run": "true"}); err != nil {
t.Fatalf("set-default dry-run failed: %v", err)
}
}
func TestBranchRestore(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 != "/v1/owner/repo/branches/restore.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode body: %v", err)
}
writeJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
if err := runShortcut(t, server, "restore", map[string]string{"id": "7", "name": "feature/deleted"}); err != nil {
t.Fatalf("restore failed: %v", err)
}
if payload["branch_id"] != float64(7) {
t.Fatalf("branch_id = %v, want 7", payload["branch_id"])
}
if payload["branch_name"] != "feature/deleted" {
t.Fatalf("branch_name = %v, want feature/deleted", payload["branch_name"])
}
}
func TestBranchRestoreValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid restore should not call API, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runShortcut(t, server, "restore", map[string]string{"id": "0", "name": "feature/deleted"}); err == nil {
t.Fatal("expected invalid id error")
}
}
// --- 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"})
@ -290,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"})
@ -303,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"})
@ -316,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"})
@ -329,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"})
@ -340,38 +316,3 @@ func TestBranchUnprotectHTTPError(t *testing.T) {
t.Fatal("expected error for HTTP 500")
}
}
func TestBranchAllUsesAllEndpoint(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/branches/all.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(w, []interface{}{map[string]interface{}{"name": "master"}})
}))
defer server.Close()
if err := runShortcut(t, server, "all", nil); err != nil {
t.Fatalf("all failed: %v", err)
}
}
func TestBranchSetDefaultPatchesName(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/branches/update_default_branch.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"})
}))
defer server.Close()
if err := runShortcut(t, server, "set-default", map[string]string{"name": "develop"}); err != nil {
t.Fatalf("set-default failed: %v", err)
}
if payload["name"] != "develop" {
t.Fatalf("expected name=develop, got %v", payload["name"])
}
}

View File

@ -131,7 +131,7 @@ skills/
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` |
| **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.1.0
description: "分支管理:创建、查看、过滤、删除、保护、设置默认分支、恢复已删除分支。当用户需要操作 GitLink 分支时触发。"
version: 1.0.0
description: "分支管理:列出、创建、删除、恢复、设置默认分支、保护分支。当用户需要操作 GitLink 分支时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
@ -20,14 +20,14 @@ metadata:
| Shortcut | 说明 | 操作类型 |
|----------|------|----------|
| `branch +list` | 列出仓库的所有分支 | Read |
| `branch +all` | 无分页列出仓库所有分支 | 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 |
| `branch +set-default` | 设置默认分支 | ⚠️ Write Operation |
| `branch +restore` | 恢复已删除分支 | ⚠️ Write Operation |
## 参数参考
@ -37,19 +37,10 @@ metadata:
|------|------|------|
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--keyword, -k` | 否 | 搜索关键词 |
| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` |
| `--page, -p` | 否 | 页码(默认 `1` |
| `--limit, -l` | 否 | 每页条数(默认 `20` |
| `--keyword, -k` | 否 | 分支关键词过滤 |
| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
### branch +all
| 参数 | 必填 | 说明 |
|------|------|------|
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
@ -59,6 +50,7 @@ metadata:
|------|------|------|
| `--name, -n` | 是 | 新分支名称 |
| `--from, -f` | 否 | 源分支或 commit默认 `master` |
| `--dry-run` | 否 | 只预览请求,不创建分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
@ -69,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
| 参数 | 必填 | 说明 |
@ -94,29 +107,6 @@ metadata:
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
### branch +set-default
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 要设为默认分支的名称 |
| `--dry-run` | 否 | 预览请求,不修改默认分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
### branch +restore
| 参数 | 必填 | 说明 |
|------|------|------|
| `--id, -i` | 是 | 已删除分支的 branch_id |
| `--name, -n` | 是 | 要恢复的分支名称 |
| `--dry-run` | 否 | 预览请求,不恢复分支 |
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
| `--debug` | 否 | 启用调试输出 |
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
## 使用示例
@ -125,20 +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 +list --owner Gitlink --repo forgeplus --keyword fix --state deleted
# 无分页列出所有分支
# 列出全部分支(无分页)
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
@ -146,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
@ -160,14 +155,6 @@ gitlink-cli branch +protect --name main --owner someone --repo myrepo
# 移除分支保护(仅简单分支名,含 / 的路径需通过 Web 操作)
gitlink-cli branch +unprotect --name main
# 设置默认分支(先 dry-run 预览)
gitlink-cli branch +set-default --name main --dry-run
gitlink-cli branch +set-default --name main
# 恢复已删除分支(先 dry-run 预览)
gitlink-cli branch +restore --id 7 --name feature/old --dry-run
gitlink-cli branch +restore --id 7 --name feature/old
```
## Workflow 注意事项
@ -178,8 +165,9 @@ gitlink-cli branch +restore --id 7 --name feature/old
> 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
@ -187,8 +175,30 @@ gitlink-cli branch +restore --id 7 --name feature/old
> 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
@ -208,25 +218,7 @@ gitlink-cli branch +restore --id 7 --name feature/old
2. 执行 `branch +unprotect --name <name>`
3. 输出结果。
### 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` 的命令。
### branch +restoreWrite Operation
> [!CAUTION]
> This is a **Write Operation** — confirm user intent.
1. 通过 `branch +list --state deleted` 确认 `branch_id` 和分支名。
2. 先执行 `branch +restore --id <branch_id> --name <name> --dry-run` 预览。
3. 用户确认后执行不带 `--dry-run` 的命令。
> **注意:**`/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 解除保护(受限于 API 路由),需通过 Web 页面操作。
> **注意:** `branch +delete` 使用 v1 OpenAPI 路径并会对含 `/` 的分支名做路径转义;`branch +unprotect` 仍使用保护分支接口。
## References