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

# Conflicts:
#	doc/changes/branch-lifecycle-shortcuts.md
#	shortcuts/branch/branch.go
#	shortcuts/branch/branch_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:07 +08:00
commit 7dc9bcb647
7 changed files with 348 additions and 308 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 操作 |
@ -511,6 +511,37 @@ gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --c
gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run
```
### 分支管理
```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

@ -1,28 +1,28 @@
# Branch Lifecycle Shortcuts
## Background
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.
GitLink OpenAPI exposes branch lifecycle capabilities that were not fully reachable from `gitlink-cli`: keyword/state branch listing, no-pagination listing, default branch switching, and deleted branch restoration.
## Commands
## What Changed
- `branch +all`
- `branch +set-default`
- `branch +restore`
Extended the `branch` shortcut group with documented OpenAPI coverage:
## Improvements
- `branch +list --keyword --state` maps to `GET /api/v1/{owner}/{repo}/branches.json` query parameters.
- `branch +all` maps to `GET /api/v1/{owner}/{repo}/branches/all.json`.
- `branch +set-default --name` maps to `PATCH /api/v1/{owner}/{repo}/branches/update_default_branch.json?name=...`.
- `branch +restore --id --name` maps to `POST /api/v1/{owner}/{repo}/branches/restore.json` with `branch_id` and `branch_name`.
- `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.
Write operations support `--dry-run` so users and Agents can inspect the exact request before changing branch state.
## API Mapping
## Validation
| 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` |
```bash
git diff --check
GOPROXY=https://goproxy.cn,direct go test ./shortcuts/branch ./shortcuts
go vet ./shortcuts/branch ./shortcuts
go run . branch +set-default --help
go run . branch +restore --help
GOPROXY=https://goproxy.cn,direct go test ./...
go vet ./...
```
## 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",
@ -123,6 +126,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",
@ -139,7 +143,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 构建",
@ -123,6 +126,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}",
@ -139,7 +143,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,14 +1,13 @@
package branch
import (
"encoding/json"
"errors"
"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"
)
@ -19,29 +18,25 @@ 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: "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 {
return err
}
state, err := normalizeBranchState(ctx.Arg("state"))
if err != nil {
return err
}
q := url.Values{}
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("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if state := strings.TrimSpace(ctx.Arg("state")); state != "" {
q.Set("state", state)
}
env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q)
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
}
@ -50,16 +45,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
},
{
Name: "all",
Description: "List all branches without pagination",
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", branchPath(ctx)+"/all", nil)
env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil)
if err != nil {
return err
}
return outputBranchEnvelope(ctx, env)
return ctx.Output(env)
},
},
{
@ -68,25 +63,21 @@ 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, err := ctx.RequireArg("name")
if err != nil {
return err
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
}
from := defaultBranchValue(ctx.Arg("from"), "master")
payload := map[string]interface{}{
"new_branch_name": name,
"old_branch_name": from,
}
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)
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
if err != nil {
return err
}
@ -98,21 +89,16 @@ 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, err := ctx.RequireArg("name")
if err != nil {
return err
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_name": name,
}
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)
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
if err != nil {
return err
}
@ -121,26 +107,18 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
},
{
Name: "set-default",
Description: "Set the repository default branch",
Description: tr.T("cmd.branch.set_default.short"),
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"},
{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, err := ctx.RequireArg("name")
if err != nil {
return err
}
path := branchPath(ctx) + "/update_default_branch"
name, _ := ctx.RequireArg("name")
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)
env, err := ctx.CallAPIWithQuery("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", q)
if err != nil {
return err
}
@ -149,37 +127,25 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
},
{
Name: "restore",
Description: "Restore a deleted branch",
Description: tr.T("cmd.branch.restore.short"),
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"},
{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
}
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")
branchID, err := parseBranchID(ctx)
if err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_id": branchID,
"branch_name": name,
}
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)
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload)
if err != nil {
return err
}
@ -235,65 +201,11 @@ 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
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,16 +20,15 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
Tr: i18n.Default(),
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
shortcuts := Shortcuts()
for _, s := range shortcuts {
if s.Name == name {
return s
}
@ -37,205 +37,220 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
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()
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
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)
}
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"},
})
}))
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 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{}{}})
})
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{
"keyword": "feature",
"state": "deleted",
"page": "2",
"limit": "5",
"limit": "50",
"state": "deleted",
"keyword": "release/",
})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
t.Fatalf("list with filters 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"}})
})
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()
if err := runShortcut(t, server, "all", nil); err != nil {
t.Fatalf("all shortcut failed: %v", err)
err := runShortcut(t, server, "all", nil)
if err != nil {
t.Fatalf("all 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"})
})
// --- 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/a",
"from": "master",
})
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
t.Fatalf("create failed: %v", err)
}
assertBranchEqual(t, payload["new_branch_name"], "feature/a")
assertBranchEqual(t, payload["old_branch_name"], "master")
}
func TestBranchCreateDefaultFrom(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-y"})
})
// 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"})
}))
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")
}
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()
// --- delete ---
err := runShortcut(t, server, "create", map[string]string{
"name": "feature/a",
"from": "master",
"dry-run": "true",
})
if err != nil {
t.Fatalf("create dry-run failed: %v", err)
}
}
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())
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)
}
writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
writeJSON(w, map[string]interface{}{"message": "deleted"})
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "feature/old"})
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
t.Fatalf("delete 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"})
})
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": "develop"})
err := runShortcut(t, server, "set-default", map[string]string{"name": "release/1.0"})
if err != nil {
t.Fatalf("set-default shortcut failed: %v", err)
t.Fatalf("set-default 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"})
})
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": "7",
"name": "feature/deleted",
"branch-id": "9",
"name": "feature/restore-me",
})
if err != nil {
t.Fatalf("restore shortcut failed: %v", err)
t.Fatalf("restore 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)
})
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()
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 {
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) {
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"})
})
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"})
}))
defer server.Close()
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
@ -244,11 +259,18 @@ func TestBranchProtect(t *testing.T) {
}
}
// --- unprotect ---
func TestBranchUnprotect(t *testing.T) {
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"})
})
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"})
}))
defer server.Close()
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
@ -257,10 +279,13 @@ func TestBranchUnprotect(t *testing.T) {
}
}
// --- HTTP error paths ---
func TestBranchListHTTPError(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
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, "list", map[string]string{"page": "1", "limit": "20"})
@ -269,10 +294,24 @@ 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 := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
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, "create", map[string]string{"name": "feature-x"})
@ -281,10 +320,40 @@ func TestBranchCreateHTTPError(t *testing.T) {
}
}
func TestBranchDeleteHTTPError(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
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)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
@ -294,9 +363,10 @@ func TestBranchDeleteHTTPError(t *testing.T) {
}
func TestBranchProtectHTTPError(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
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, "protect", map[string]string{"name": "master"})
@ -306,9 +376,10 @@ func TestBranchProtectHTTPError(t *testing.T) {
}
func TestBranchUnprotectHTTPError(t *testing.T) {
server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeBranchText(t, w, http.StatusInternalServerError, "server error")
})
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, "unprotect", map[string]string{"name": "master"})