From f04fb9ca8aba3484b63e5fa253a2b0e789c69d63 Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Fri, 16 Oct 2026 13:56:50 +0100 Subject: [PATCH 1/2] Let a branch be renamed in the stack metadata and on GitHub Renaming a stacked branch by hand means git branch -m, then fixing the gh stack file so the stack still knows about it, then renaming it on GitHub so its PR doesn't get orphaned. None of the pieces for doing that properly existed yet. So the forge port gets RenameBranch, which the GitHub adapter does through GitHub's branch rename endpoint (that keeps the PR attached and retargets PRs based on the branch, which deleting and pushing again wouldn't). The git client gets a plain branch rename, and a metadata Update can now rename a branch in place: set its new name and RenamedFrom, and the gh stack backend reuses the old record under the new name, so the PR record and anything we don't model come along. --- pkg/app/prs_test.go | 2 ++ pkg/app/submit_test.go | 2 ++ pkg/backend/ghstack/backend.go | 11 ++++++++-- pkg/backend/ghstack/update_test.go | 32 ++++++++++++++++++++++++++++ pkg/forge/forge.go | 5 +++++ pkg/forge/github/rename.go | 26 +++++++++++++++++++++++ pkg/forge/github/rename_test.go | 34 ++++++++++++++++++++++++++++++ pkg/git/refs.go | 14 ++++++++++++ pkg/git/refs_test.go | 24 +++++++++++++++++++++ pkg/mcp/server_test.go | 5 +++++ pkg/stack/model.go | 4 ++++ pkg/stack/ports.go | 3 ++- 12 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 pkg/forge/github/rename.go create mode 100644 pkg/forge/github/rename_test.go diff --git a/pkg/app/prs_test.go b/pkg/app/prs_test.go index 3605040..1dc6c10 100644 --- a/pkg/app/prs_test.go +++ b/pkg/app/prs_test.go @@ -41,6 +41,8 @@ func (f *fakeForge) CreateStack(context.Context, git.Repo, []int) (forge.StackRe } func (f *fakeForge) ClosePR(context.Context, git.Repo, int, string) error { return nil } +func (f *fakeForge) RenameBranch(context.Context, git.Repo, string, string) error { return nil } + func TestPRsForPrefersOpenThenMergedThenRecent(t *testing.T) { t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) prs := []forge.PullRequest{ diff --git a/pkg/app/submit_test.go b/pkg/app/submit_test.go index 4189c69..457f4d4 100644 --- a/pkg/app/submit_test.go +++ b/pkg/app/submit_test.go @@ -122,6 +122,8 @@ func (f *recordingForge) ClosePR(_ context.Context, _ git.Repo, n int, comment s return nil } +func (f *recordingForge) RenameBranch(context.Context, git.Repo, string, string) error { return nil } + func (f *recordingForge) CreateStack(_ context.Context, _ git.Repo, numbers []int) (forge.StackRef, error) { f.stacks = append(f.stacks, slices.Clone(numbers)) return f.createRef, f.createErr diff --git a/pkg/backend/ghstack/backend.go b/pkg/backend/ghstack/backend.go index ec3f886..1e18875 100644 --- a/pkg/backend/ghstack/backend.go +++ b/pkg/backend/ghstack/backend.go @@ -449,13 +449,20 @@ func writeFile(gitDir string, root *node) error { } // branchNodes renders a stack's branches, reusing each branch's node from -// pool so its pull request record and unmodelled members travel with it, -// and reports whether any head or base it wrote differs from the node. +// pool so its pull request record and unmodelled members travel with it +// (under its old name for a renamed branch), and reports whether any name, +// head or base it wrote differs from the node. func branchNodes(branches []stack.Branch, pool map[string]*node) ([]*node, bool) { edited := false out := make([]*node, 0, len(branches)) for _, gb := range branches { n := pool[gb.Name] + if n == nil && gb.RenamedFrom != "" { + if n = pool[gb.RenamedFrom]; n != nil { + n.set("branch", stringNode(gb.Name)) + edited = true + } + } if n == nil { n = objectNode(member{"branch", stringNode(gb.Name)}) edited = true diff --git a/pkg/backend/ghstack/update_test.go b/pkg/backend/ghstack/update_test.go index 3fb13fb..9e6f861 100644 --- a/pkg/backend/ghstack/update_test.go +++ b/pkg/backend/ghstack/update_test.go @@ -132,6 +132,38 @@ func TestUpdateWritesEachWorktreeFile(t *testing.T) { } } +// A branch renamed with RenamedFrom keeps its record: pull request, +// members we don't model and its place in the stack. +func TestUpdateRenamesABranch(t *testing.T) { + b, repo, mainPath, _ := updateFixture(t) + err := b.Update(context.Background(), repo, func(g *stack.Graph) error { + s, i, _ := g.StackOf("a") + s.Branches[i].Name, s.Branches[i].RenamedFrom = "a2", "a" + return nil + }) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(mainPath) + s := string(got) + for _, want := range []string{`"branch": "a2"`, `"extra": "yes"`, `"number": 1`, `"head": "ha"`} { + if !strings.Contains(s, want) { + t.Errorf("missing %s in:\n%s", want, s) + } + } + if strings.Contains(s, `"branch": "a"`) { + t.Errorf("the old name should be gone:\n%s", s) + } + g, err := b.Load(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + st, i, ok := g.StackOf("a2") + if !ok || i != 0 || st.Branches[1].Name != "b" || st.Branches[0].PR == nil || st.Branches[0].PR.Number != 1 { + t.Errorf("reload = %+v", st) + } +} + func TestUpdateFnErrorWritesNothing(t *testing.T) { b, repo, mainPath, _ := updateFixture(t) before, _ := os.ReadFile(mainPath) diff --git a/pkg/forge/forge.go b/pkg/forge/forge.go index 72015f0..f728f9f 100644 --- a/pkg/forge/forge.go +++ b/pkg/forge/forge.go @@ -132,6 +132,11 @@ type Forge interface { // comment on it first. One that is already closed or merged is not an // error. ClosePR(ctx context.Context, repo git.Repo, number int, comment string) error + // RenameBranch renames branch from to to on the forge, which keeps its + // pull request attached and retargets the pull requests based on it. A + // refusal (the name is taken, no permission) comes back as a + // *stack.Error of kind api_failure carrying the forge's message. + RenameBranch(ctx context.Context, repo git.Repo, from, to string) error // PullRequestURL is the web address of pull request number, worked out // from local configuration only (never the network), so output can // link a PR the forge hasn't been asked about. "" when it can't tell. diff --git a/pkg/forge/github/rename.go b/pkg/forge/github/rename.go new file mode 100644 index 0000000..2896672 --- /dev/null +++ b/pkg/forge/github/rename.go @@ -0,0 +1,26 @@ +package github + +import ( + "context" + "errors" + "net/url" + + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// RenameBranch renames a branch through GitHub's branch rename endpoint, +// which moves its open pull request with it and retargets the ones based +// on it. +func (f *Forge) RenameBranch(ctx context.Context, repo git.Repo, from, to string) error { + path := "repos/{owner}/{repo}/branches/" + url.PathEscape(from) + "/rename" + _, err := f.gh(ctx, repo, "", "api", "--method", "POST", path, "-f", "new_name="+to) + if se, ok := errors.AsType[*stack.Error](err); ok && se.Kind == stack.KindAPIFailure { + msg := apiMessage(err) + if msg == "" { + msg = "GitHub refused to rename " + from + } + return stack.New(stack.KindAPIFailure, msg).WithDetail(se.Detail).WithCause(err) + } + return err +} diff --git a/pkg/forge/github/rename_test.go b/pkg/forge/github/rename_test.go new file mode 100644 index 0000000..88cb088 --- /dev/null +++ b/pkg/forge/github/rename_test.go @@ -0,0 +1,34 @@ +package github + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/exec/exectest" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +func TestRenameBranch(t *testing.T) { + f := exectest.New() + f.On("gh", "api", "--method", "POST").Reply(`{"name":"billing/retry-v2"}`) + if err := New(f).RenameBranch(context.Background(), git.Repo{TopLevel: "/r"}, "billing/retry", "billing/retry-v2"); err != nil { + t.Fatal(err) + } + call := f.CallsTo("gh")[0] + if got := strings.Join(call.Args, " "); got != "api --method POST repos/{owner}/{repo}/branches/billing%2Fretry/rename -f new_name=billing/retry-v2" { + t.Errorf("args = %q", got) + } +} + +func TestRenameBranchRefused(t *testing.T) { + f := exectest.New() + f.On("gh", "api", "--method", "POST").Fail(1, "gh: Branch already exists (HTTP 422)") + err := New(f).RenameBranch(context.Background(), git.Repo{TopLevel: "/r"}, "a", "b") + se, ok := errors.AsType[*stack.Error](err) + if !ok || se.Kind != stack.KindAPIFailure || se.Msg != "Branch already exists" { + t.Errorf("refused rename: %v", err) + } +} diff --git a/pkg/git/refs.go b/pkg/git/refs.go index 50f0ac9..c054459 100644 --- a/pkg/git/refs.go +++ b/pkg/git/refs.go @@ -62,6 +62,20 @@ func (c *Client) UpdateRefs(ctx context.Context, repo Repo, updates []RefUpdate) return nil } +// RenameBranch renames a local branch, its reflog and its config (the +// upstream included, which still names the old remote branch). git refuses +// when to already exists. +func (c *Client) RenameBranch(ctx context.Context, repo Repo, from, to string) error { + _, err := c.gitIn(ctx, repo, "branch", "-m", from, to) + if err != nil { + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + return fmt.Errorf("rename branch %q: %s", from, ee.Result.Err()) + } + return err + } + return nil +} + // DeleteBranch deletes a local branch whether or not it is merged. git // refuses when the branch is checked out in any worktree; callers check. func (c *Client) DeleteBranch(ctx context.Context, repo Repo, name string) error { diff --git a/pkg/git/refs_test.go b/pkg/git/refs_test.go index 398c6b6..c32e26c 100644 --- a/pkg/git/refs_test.go +++ b/pkg/git/refs_test.go @@ -304,3 +304,27 @@ func TestPointHEADKeepsTheIndexAndWorkingTree(t *testing.T) { t.Error("PointHEAD must refuse a branch that does not exist") } } + +// A rename moves the ref, the checkout and the branch's config with it. +func TestRenameBranch(t *testing.T) { + c, repo, dir := remoteFixture(t) + ctx := context.Background() + gittest.Run(t, dir, "switch", "-q", "-c", "f") + tip := gittest.Commit(t, dir, "f.txt", "f", "f") + gittest.Run(t, dir, "push", "-q", "-u", "origin", "f") + if err := c.RenameBranch(ctx, repo, "f", "g"); err != nil { + t.Fatal(err) + } + if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "g" || gittest.Run(t, dir, "rev-parse", "g") != tip { + t.Errorf("current = %s", cur) + } + if _, ok, _ := c.Tip(ctx, repo, "refs/heads/f"); ok { + t.Error("f should be gone") + } + if remote := c.RemoteFor(ctx, repo, "g"); remote != "origin" { + t.Errorf("g's remote = %s", remote) + } + if err := c.RenameBranch(ctx, repo, "g", "main"); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("renaming onto main = %v", err) + } +} diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 7686d15..9d4bf57 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -31,6 +31,7 @@ type fakeForge struct { updates map[int]forge.UpdatePR merged []int checks map[int]forge.CheckSummary + renamed []string } func (f *fakeForge) Checks(context.Context, git.Repo, []int) (map[int]forge.CheckSummary, error) { @@ -46,6 +47,10 @@ func (f *fakeForge) ListPRs(context.Context, git.Repo) ([]forge.PullRequest, err return slices.Clone(f.prs), nil } func (f *fakeForge) ClosePR(context.Context, git.Repo, int, string) error { return nil } +func (f *fakeForge) RenameBranch(_ context.Context, _ git.Repo, from, to string) error { + f.renamed = append(f.renamed, from+" -> "+to) + return nil +} func (f *fakeForge) CreateStack(context.Context, git.Repo, []int) (forge.StackRef, error) { return forge.StackRef{}, nil } diff --git a/pkg/stack/model.go b/pkg/stack/model.go index b24b2f0..d7f6462 100644 --- a/pkg/stack/model.go +++ b/pkg/stack/model.go @@ -19,6 +19,10 @@ type Branch struct { // Base is the parent's tip at the last restack (may be empty). Base string PR *PRRef + // RenamedFrom is the branch's old name when an Update renames it, so + // the backend carries its record (pull request included) over to the + // new name. Leaving it empty while changing Name records a new branch. + RenamedFrom string } // Merged reports whether the branch's PR has been merged. diff --git a/pkg/stack/ports.go b/pkg/stack/ports.go index 10d4244..f33ffd9 100644 --- a/pkg/stack/ports.go +++ b/pkg/stack/ports.go @@ -37,7 +37,8 @@ type Metadata interface { Load(ctx context.Context, repo git.Repo) (*Graph, error) // Update loads the graph fresh under the backend's lock, lets fn edit it // and writes the result back. fn may edit each stack's Branches (remove - // entries, change Head and Base) and may also append stacks (each with a + // entries, change Head and Base, rename one by setting Name and + // RenamedFrom) and may also append stacks (each with a // trunk and at least one branch; they are written to the current // worktree's file, else the main checkout's, else a new file in the // common dir) and may set or clear a stack's ID and Number (clearing From df97fca6c95f639b0cac06530e1b6481ec8eb819 Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Fri, 16 Oct 2026 13:59:35 +0100 Subject: [PATCH 2/2] Add git stack rename Once a branch is in a stack there was no sane way to change its name. git branch -m loses it from the stack, and once it's pushed the PR stays behind on the old name, so you'd end up closing it and opening a new one. So git stack rename (and git rename) renames the current branch and keeps its place in the stack, its commits and its PR; nothing is rebased, the branch above just sits on the new name. If the branch has been pushed it's renamed on GitHub first, so if GitHub says no (offline, no permission, name taken) nothing here has changed and the error points at --local. Then the local branch and the stack record follow, the upstream and remote tracking ref move to the new name, and the cached PR list is dropped since it still names the old branch. --local renames only this copy. The PR stays on the old name and the next submit opens a new one, which a notice says. It refuses on a trunk, an untracked branch, a merged one, during a rebase, or when the name is already taken here or on the remote. Agents get the same thing as stack_rename. --- README.md | 26 ++++- cmd/rename.go | 51 +++++++++ cmd/rename_test.go | 53 +++++++++ cmd/root.go | 1 + docs/architecture.md | 10 ++ docs/mcp.md | 1 + docs/style.md | 1 + pkg/app/app.go | 2 + pkg/app/rename.go | 221 ++++++++++++++++++++++++++++++++++++ pkg/app/rename_test.go | 177 +++++++++++++++++++++++++++++ pkg/gitconfig/aliases.go | 1 + pkg/install/install_test.go | 2 +- pkg/install/skill/SKILL.md | 5 +- pkg/mcp/instructions.go | 2 +- pkg/mcp/server.go | 2 + pkg/mcp/server_test.go | 21 +++- pkg/mcp/tools.go | 24 ++++ pkg/ui/report.go | 1 + 18 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 cmd/rename.go create mode 100644 cmd/rename_test.go create mode 100644 pkg/app/rename.go create mode 100644 pkg/app/rename_test.go diff --git a/README.md b/README.md index f684e13..f60ac91 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Claude Code and Codex if you have them installed**. Each of those is on by defau change before it happens, and `--uninstall` removes what it recorded installing and nothing else (that record lives in `git config --global stack.managed*`). -- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|untrack|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom|pr` +- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|rename|untrack|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom|pr` and the short ones `git c|m|ab|rs|ss|u|d|t|b|co`, written with `git config --global`. Anything that clashes with a git builtin or an installed command is skipped, and an alias you already have is only replaced if you pass `--force` or say yes when asked. @@ -424,6 +424,30 @@ nothing is based on it any more. Flags: - `--keep` keep this branch's name and pull request instead of the parent's +### `git stack rename ` + +> Alias: `git rename` + +Gives the current branch a new name, for when the work turned out to be about something else. It +keeps its place in the stack, its commits and its PR, and nothing is rebased; the branch above now +sits on the new name. The name is used as you give it, without `stack.branchPrefix`. + +If the branch has been pushed it's renamed on GitHub first, which keeps the PR attached and +retargets any PR based on it, and only then here (the upstream follows it). If GitHub won't do it, +because you're offline or don't have permission, nothing changes and the error says so. `--local` +renames just this copy: the PR stays on the old branch name and the next `git ss` opens a new one. +It won't rename a trunk, a branch that isn't in a stack or one whose PR has merged, or take a name +that's already in use here or on GitHub. + +``` +# on billing-webhook-alerts +❯ git rename billing-webhook-retries +✔ Renamed billing-webhook-alerts to billing-webhook-retries here and on origin +``` + +Flags: +- `--local` rename only on this machine, leaving the remote branch and its pull request alone + ### `git stack init [branch...]` > Alias: `git track`; `git stack track` works too diff --git a/cmd/rename.go b/cmd/rename.go new file mode 100644 index 0000000..98686e9 --- /dev/null +++ b/cmd/rename.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/DomBlack/git-stack/pkg/app" +) + +func newRenameCmd(c *cli) *cobra.Command { + var local bool + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename the current branch", + Long: `Gives the current branch a new name, keeping its place in the stack and its +commits; nothing is rebased, and the branch above now sits on the new name. + +If the branch has been pushed it's renamed on GitHub first, which keeps its pull +request attached and retargets any pull request based on it, then here. If +GitHub says no (offline, no permission) nothing changes. With --local only this +copy is renamed: the pull request stays on the old name and the next submit +opens a new one.`, + Example: ` # rename this branch here and on GitHub + git stack rename billing-webhook-retries + + # rename it only on this machine + git stack rename --local billing-webhook-retries`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeNothing, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + a, repo, err := c.app(ctx) + if err != nil { + return err + } + rep := c.report() + res, err := a.Rename(ctx, repo, app.RenameOptions{Name: args[0], Local: local}) + renderNotices(rep, res.Notices) + if err != nil { + return err + } + if res.Remote != "" { + rep.Success("Renamed %s to %s here and on %s", res.From, res.To, res.Remote) + } else { + rep.Success("Renamed %s to %s", res.From, res.To) + } + return nil + }, + } + cmd.Flags().BoolVar(&local, "local", false, "rename only on this machine, leaving the remote branch and its pull request alone") + return cmd +} diff --git a/cmd/rename_test.go b/cmd/rename_test.go new file mode 100644 index 0000000..ed1e408 --- /dev/null +++ b/cmd/rename_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +func TestRenameCommand(t *testing.T) { + dir, run := stackOf3(t) + out, errOut, err := run("rename", "c2") + if err != nil || !strings.Contains(out, "ok: Renamed c to c2") { + t.Errorf("rename: %q %q %v", out, errOut, err) + } + if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "c2" { + t.Errorf("current = %s", cur) + } + if _, _, err = run("rename"); err == nil { + t.Error("rename needs a name") + } + if _, _, err = run("rename", "a"); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("taken: %v", err) + } +} + +func TestRenameCommandOnGitHub(t *testing.T) { + gittest.Isolate(t) + dir := gittest.InitRepo(t) + f := fakeGh(t, dir) + gittest.WriteFile(t, dir, "a.txt", "a") + if _, _, err := runWith(t, f, "--cwd", dir, "create", "-a", "-m", "feat: a", "a"); err != nil { + t.Fatal(err) + } + gittest.InitRemote(t, dir) + f.On("gh", "api", "--method", "POST").Reply(`{"name":"a2"}`) + out, errOut, err := runWith(t, f, "--cwd", dir, "rename", "a2") + if err != nil || !strings.Contains(out, "ok: Renamed a to a2 here and on origin") { + t.Fatalf("rename: %q %q %v", out, errOut, err) + } + var called bool + for _, c := range f.CallsTo("gh") { + called = called || strings.Join(c.Args, " ") == "api --method POST repos/{owner}/{repo}/branches/a/rename -f new_name=a2" + } + if !called { + t.Error("GitHub's rename was not called") + } + + out, errOut, err = runWith(t, f, "--cwd", dir, "rename", "--local", "a3") + if err != nil || !strings.Contains(out, "ok: Renamed a2 to a3") || strings.Contains(out, "on origin") { + t.Errorf("rename --local: %q %q %v", out, errOut, err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 52b22f0..affc471 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -326,6 +326,7 @@ func newRootCmd(c *cli) *cobra.Command { newMoveCmd(c), newFoldCmd(c), newUntrackCmd(c), + newRenameCmd(c), newInitCmd(c), newPopCmd(c), newRestackCmd(c), diff --git a/docs/architecture.md b/docs/architecture.md index b2f9c9b..0c6ace8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -492,6 +492,16 @@ moved branch; the snapshot names it, with its head and base, and abort puts it b stack GitHub knows is queued for dissolving like any reshape, and no pull request is closed, since the branches still exist and may be tracked again. +**Rename goes to GitHub first.** A pushed branch is renamed with GitHub's branch rename endpoint +before anything local changes, because that rename keeps the PR attached and retargets the PRs +based on it, which pushing a new branch and deleting the old one wouldn't do; and if it's refused +(offline, no permission, the name taken) there's nothing to put back. Then `git branch -m`, which +carries the reflog and config, and one `Update` that renames the branch's record in place +(`RenamedFrom` tells the backend to reuse the old node, PR record and all). The remote tracking ref +and the upstream are moved to the new name so the next submit's lease check sees the same tip, and +the PR cache is dropped since it names the old head. The stack on GitHub is left alone: same PRs, +same order. `--local` skips GitHub and drops the PR record, since that PR still has the old head. + **Merged branches are deleted by default.** `stack.sync.prune` decides: `always` (default) deletes branches whose PRs merged or closed, `ask` prompts on a terminal and keeps with a notice otherwise, `never` keeps. `-d` and `-f` delete regardless. A branch checked out in diff --git a/docs/mcp.md b/docs/mcp.md index d44977c..2aeff33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -27,6 +27,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no | `stack_move` | Move a branch, and unless `only` is set everything above it, onto the top of another stack or onto a trunk as a new stack, then restack; moves within one stack are `stack_reorder`'s job | `onto` (required: a trunk or another stack's top branch), `source` (default current), `only` | destructive | | `stack_fold` | Merge the current branch into the branch below it, one branch and one PR; the parent survives and is checked out, or with `keep` the current branch takes the parent's place; nothing is rebased, and the next `stack_submit` closes the PR of the branch that went | `keep` | destructive | | `stack_untrack` | Stop tracking a branch and every branch above it, changing no commit, ref or checkout; with `only` just that branch, and the branches above are restacked onto its parent; pull requests stay open and the next `stack_submit` regroups a stack GitHub knows; `next_steps` says how to track them again | `branch` (default current), `only` | destructive | +| `stack_rename` | Give the current branch a new name, keeping its place, commits and base; a pushed branch is renamed on GitHub first (keeping its PR and retargeting PRs based on it, `remote` in the result) and nothing changes if GitHub refuses; `local` renames only this repository and leaves the PR on the old name | `name` (required), `local` | destructive, open-world | | `stack_init` | Track branches that already exist as a new stack on a trunk; only the metadata changes, and `needsRestack` lists the branches that aren't on their parent | `branches` (bottom to top, default current), `base` | destructive (it writes the metadata, though no branch moves) | | `stack_pop` | Delete the current branch and take it out of its stack, leaving its changes staged on its parent; the branches above are restacked onto the parent first, and its open PR is closed by the next `stack_submit`; refuses with nothing changed when tracked files have changes, the branch isn't on its parent or the parent is checked out in another worktree | | destructive | | `stack_continue` | Finish an interrupted restack, modify, reorder, move or pop once the conflicts are resolved and git added | `stage_all` | destructive | diff --git a/docs/style.md b/docs/style.md index e602898..bf4d8ea 100644 --- a/docs/style.md +++ b/docs/style.md @@ -64,6 +64,7 @@ read as section markers rather than confetti because they never show up anywhere | Claude | 🤖 | Drafting pull request for feat-a… | | update | 📦 | Checking for updates…, then Downloading git-stack v0.3.1… | | merge | 🔀 | Merging 3 pull requests into main… | +| rename | 🏷️ | Renaming feat-a on origin… | | refresh | 📥 | Fetching pull requests… (only when the PR cache is stale) | | install | 🔧 | (install prints a report, no spinner) | diff --git a/pkg/app/app.go b/pkg/app/app.go index 848d3f2..40944e8 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -51,6 +51,8 @@ const ( PhaseMerge Phase = "merge" // PhaseAbsorb: rewriting the stack's commits with the staged changes. PhaseAbsorb Phase = "absorb" + // PhaseRename: renaming a branch on the forge. + PhaseRename Phase = "rename" // PhaseRefresh: fetching pull request state from the forge for a command // that only reads it (log, submit's look at which PRs exist). PhaseRefresh Phase = "refresh" diff --git a/pkg/app/rename.go b/pkg/app/rename.go new file mode 100644 index 0000000..8e04ba9 --- /dev/null +++ b/pkg/app/rename.go @@ -0,0 +1,221 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/DomBlack/git-stack/pkg/forge" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// RenameOptions is one rename of the current branch. +type RenameOptions struct { + // Name is the branch's new name, used as given (no prefix). + Name string + // Local renames the branch in this repository only, leaving the remote + // branch and its pull request under the old name. + Local bool +} + +// RenameResult reports what happened. +type RenameResult struct { + From string `json:"from"` + To string `json:"to"` + // Remote is the remote the branch was renamed on as well, "" when only + // the local branch was renamed. + Remote string `json:"remote,omitempty"` + Notices []string `json:"notices,omitempty"` +} + +const renameCommand = "git stack rename" + +// Rename gives the current branch a new name, keeping its place in the +// stack, its commits and its recorded base; nothing is restacked. A branch +// that has been pushed is renamed on the forge first, which keeps its pull +// request attached and retargets the ones based on it, so a refusal there +// leaves everything here as it was. With Local only this repository +// changes. +func (a *App) Rename(ctx context.Context, repo git.Repo, o RenameOptions) (RenameResult, error) { + if err := a.noRebaseActive(ctx, repo); err != nil { + return RenameResult{}, err + } + current, err := a.d.Git.CurrentBranch(ctx, repo) + if err != nil { + if errors.Is(err, git.ErrDetached) { + return RenameResult{}, stack.New(stack.KindInvalidArgs, "HEAD is detached; check out a stacked branch first") + } + return RenameResult{}, err + } + graph, err := a.d.Meta.Load(ctx, repo) + if err != nil { + return RenameResult{}, err + } + if graph.IsTrunk(current) { + return RenameResult{}, stack.Newf(stack.KindInvalidArgs, "%s is a trunk; rename works on stacked branches", current) + } + s, idx, ok := graph.StackOf(current) + if !ok { + return RenameResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", current). + WithSteps("git stack init " + current + " to start tracking it") + } + branch := s.Branches[idx] + if branch.Merged() { + return RenameResult{}, stack.Newf(stack.KindInvalidArgs, "%s has merged; run git stack sync first", current) + } + if err := a.checkNewName(ctx, repo, graph, current, o.Name); err != nil { + return RenameResult{}, err + } + res := RenameResult{From: current, To: o.Name} + + remote := a.d.Git.RemoteFor(ctx, repo, current) + tracking := "refs/remotes/" + remote + "/" + pushedTip, pushed, err := a.d.Git.Tip(ctx, repo, tracking+current) + if err != nil { + return res, err + } + pushed = pushed || branch.PR != nil + if pushed && !o.Local { + if err := a.renameRemote(ctx, repo, remote, current, o.Name); err != nil { + return res, err + } + res.Remote = remote + } + + if err := a.d.Git.RenameBranch(ctx, repo, current, o.Name); err != nil { + if res.Remote != "" { + return res, asStackError(err).WithSteps(fmt.Sprintf("it is already called %s on %s; git branch -m %s %s renames it here to match", o.Name, remote, current, o.Name)) + } + return res, err + } + // A branch renamed on the remote, or never pushed, is the same branch + // under a new name and keeps its record. One renamed only here leaves + // its pull request behind on the old name. + carry := res.Remote != "" || !pushed + err = a.d.Meta.Update(ctx, repo, func(g *stack.Graph) error { + s, i, ok := g.StackOf(current) + if !ok { + return stack.Newf(stack.KindInvalidArgs, "%s left its stack while it was being renamed", current) + } + b := &s.Branches[i] + b.Name = o.Name + if carry { + b.RenamedFrom = current + } else { + b.PR = nil + } + return nil + }) + if err != nil { + if res.Remote != "" { + return res, asStackError(err).WithSteps(fmt.Sprintf("%s is renamed here and on %s, but its stack still lists it as %s; check git stack log", o.Name, remote, current)) + } + if rerr := a.d.Git.RenameBranch(ctx, repo, o.Name, current); rerr != nil { + return res, asStackError(err).WithSteps(fmt.Sprintf("the branch is called %s now and putting the old name back failed (%v)", o.Name, rerr)) + } + return res, asStackError(err).WithSteps("nothing was changed") + } + + if res.Remote != "" { + res.Notices = append(res.Notices, a.followRemoteRename(ctx, repo, remote, current, o.Name, pushedTip)...) + return res, nil + } + if o.Local && pushed { + res.Notices = append(res.Notices, a.leftBehind(ctx, repo, branch, remote, current, o.Name)) + } + return res, nil +} + +// checkNewName refuses a name that's invalid, unchanged or already taken +// by a local branch or a stale stack record. +func (a *App) checkNewName(ctx context.Context, repo git.Repo, graph *stack.Graph, current, name string) error { + if name == "" { + return stack.New(stack.KindInvalidArgs, "no new name given").WithSteps(renameCommand + " ") + } + if name == current { + return stack.Newf(stack.KindInvalidArgs, "the branch is already called %s", current) + } + if err := a.validateBranchName(ctx, name); err != nil { + return err + } + exists, err := a.d.Git.BranchExists(ctx, repo, name) + if err != nil { + return err + } + if exists || graph.Tracked(name) { + return stack.Newf(stack.KindInvalidArgs, "branch %s already exists", name) + } + return nil +} + +// renameRemote renames the branch on the forge, refusing up front when +// the remote already has a branch by the new name as far as we know. +func (a *App) renameRemote(ctx context.Context, repo git.Repo, remote, from, to string) error { + localOnly := "rename it only on this machine: " + renameCommand + " --local " + to + if _, taken, err := a.d.Git.Tip(ctx, repo, "refs/remotes/"+remote+"/"+to); err != nil { + return err + } else if taken { + return stack.Newf(stack.KindInvalidArgs, "branch %s already exists on %s", to, remote). + WithSteps("pick another name", localOnly) + } + if a.d.Forge == nil { + return stack.Newf(stack.KindUnsupported, "no forge configured to rename %s on %s", from, remote).WithSteps(localOnly) + } + err := a.progress(ctx, PhaseRename, fmt.Sprintf("Renaming %s on %s", from, remote), func(ctx context.Context) error { + return a.d.Forge.RenameBranch(ctx, repo, from, to) + }) + if err != nil { + return asStackError(err).WithSteps("nothing was renamed", localOnly) + } + return nil +} + +// followRemoteRename points the local copy at the renamed remote branch: +// the remote tracking ref moves to the new name and an upstream naming the +// old branch is set to the new one. The PR cache names the old branch, so +// it goes and the next look fetches it again. Anything that fails is a +// notice; the rename itself is done. +func (a *App) followRemoteRename(ctx context.Context, repo git.Repo, remote, from, to, pushedTip string) []string { + var notices []string + tracking := "refs/remotes/" + remote + "/" + if pushedTip != "" { + err := a.d.Git.UpdateRefs(ctx, repo, []git.RefUpdate{ + {Ref: tracking + to, New: pushedTip}, + {Ref: tracking + from, New: strings.Repeat("0", len(pushedTip)), Old: pushedTip}, + }) + if err != nil { + notices = append(notices, fmt.Sprintf("%s/%s could not be moved to %s/%s (%v); git fetch %s sorts it out", remote, from, remote, to, err, remote)) + } + } + key := "branch." + to + ".merge" + if merge, ok, err := a.d.Git.ConfigGet(ctx, repo, git.ScopeLocal, key); err == nil && ok && merge == "refs/heads/"+from { + if err := a.d.Git.ConfigSet(ctx, repo, git.ScopeLocal, key, "refs/heads/"+to); err != nil { + notices = append(notices, fmt.Sprintf("%s still tracks %s/%s (%v); git branch -u %s/%s fixes it", to, remote, from, err, remote, to)) + } + } + if a.d.Cache != nil { + if err := a.d.Cache.Remove(prsCacheName); err != nil { + a.d.Log.Debug("drop PR cache", "err", err) + } + } + return notices +} + +// leftBehind is the notice for a pushed branch renamed only here: what it +// left on the remote under the old name. +func (a *App) leftBehind(ctx context.Context, repo git.Repo, branch stack.Branch, remote, from, to string) string { + number := 0 + if branch.PR != nil { + number = branch.PR.Number + } else if prs, _ := a.loadPRs(ctx, repo, PRsCached); prs != nil { + if pr, ok := PRsFor(prs)[from]; ok && (pr.State == forge.StateOpen || pr.State == forge.StateDraft) { + number = pr.Number + } + } + if number != 0 { + return fmt.Sprintf("#%d stays on %s on %s; the next submit opens a new pull request for %s", number, from, remote, to) + } + return fmt.Sprintf("%s is still called %s on %s; the next submit pushes %s as a new branch", to, from, remote, to) +} diff --git a/pkg/app/rename_test.go b/pkg/app/rename_test.go new file mode 100644 index 0000000..c25398d --- /dev/null +++ b/pkg/app/rename_test.go @@ -0,0 +1,177 @@ +package app_test + +import ( + "context" + "errors" + "os" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/cache" + "github.com/DomBlack/git-stack/pkg/forge" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// renameForge records every RenameBranch call and fails them with err. +type renameForge struct { + fakeForge + renames [][2]string + err error +} + +func (f *renameForge) RenameBranch(_ context.Context, _ git.Repo, from, to string) error { + if f.err != nil { + return f.err + } + f.renames = append(f.renames, [2]string{from, to}) + return nil +} + +// renameFixture is main -> a -> b -> c with HEAD on c, a forge that +// records renames and, with pushed, an origin holding every branch. +func renameFixture(t *testing.T, pushed bool) (*restackFixture, *renameForge) { + t.Helper() + f := newRestackFixture(t) + if pushed { + gittest.InitRemote(t, f.dir) + } + rf := &renameForge{} + f.app = app.New(app.Deps{Git: f.fb.git, Meta: f.fb, Tracker: f.fb, Forge: rf, Cache: cache.New(f.repo)}) + return f, rf +} + +func TestRenameUnpushedBranchStaysLocal(t *testing.T) { + f, rf := renameFixture(t, false) + gittest.Run(t, f.dir, "switch", "-q", "b") + tip, base := f.rev(t, "b"), f.base("b") + res, err := f.app.Rename(context.Background(), f.repo, app.RenameOptions{Name: "b2"}) + if err != nil { + t.Fatal(err) + } + if res.From != "b" || res.To != "b2" || res.Remote != "" { + t.Errorf("result = %+v", res) + } + if len(rf.renames) != 0 { + t.Errorf("a branch that was never pushed has nothing to rename on GitHub: %v", rf.renames) + } + if !slices.Equal(f.names(), []string{"a", "b2", "c"}) { + t.Errorf("stack = %v", f.names()) + } + if branchExists(t, f.dir, "b") || f.rev(t, "b2") != tip || f.base("b2") != base { + t.Error("b2 should be b under a new name, with its base") + } + if cur := gittest.Run(t, f.dir, "branch", "--show-current"); cur != "b2" { + t.Errorf("current = %s", cur) + } +} + +func TestRenamePushedBranchRenamesItOnGitHubToo(t *testing.T) { + f, rf := renameFixture(t, true) + f.fb.graph.Stacks[0].Branches[2].PR = &stack.PRRef{Number: 7} + withPRCache(t, f) + res, err := f.app.Rename(context.Background(), f.repo, app.RenameOptions{Name: "c2"}) + if err != nil { + t.Fatal(err) + } + if res.Remote != "origin" || !slices.Equal(rf.renames, [][2]string{{"c", "c2"}}) { + t.Errorf("result = %+v, renames = %v", res, rf.renames) + } + if pr := f.fb.graph.Stacks[0].Branches[2].PR; pr == nil || pr.Number != 7 { + t.Errorf("c2 should keep its pull request: %+v", pr) + } + if up := gittest.Run(t, f.dir, "rev-parse", "--abbrev-ref", "c2@{upstream}"); up != "origin/c2" { + t.Errorf("upstream = %s", up) + } + if _, err := gittest.RunErr(t, f.dir, "rev-parse", "--verify", "-q", "refs/remotes/origin/c"); err == nil { + t.Error("the old remote tracking ref should be gone") + } + if _, err := os.Stat(cache.New(f.repo).Path("prs")); !os.IsNotExist(err) { + t.Error("the PR cache names the old branch and should be dropped") + } +} + +func withPRCache(t *testing.T, f *restackFixture) { + t.Helper() + if err := cache.Write(cache.New(f.repo), "prs", app.PRSnapshot{PRs: []forge.PullRequest{{Number: 7, Head: "c", State: forge.StateOpen}}}); err != nil { + t.Fatal(err) + } +} + +// When GitHub says no, nothing local has changed and the error says how to +// rename just this copy. +func TestRenameGitHubFailureChangesNothing(t *testing.T) { + f, rf := renameFixture(t, true) + rf.err = stack.New(stack.KindAPIFailure, "Resource not accessible by integration") + _, err := f.app.Rename(context.Background(), f.repo, app.RenameOptions{Name: "c2"}) + se, ok := errors.AsType[*stack.Error](err) + if !ok || !strings.Contains(strings.Join(se.NextSteps, "\n"), "git stack rename --local c2") { + t.Fatalf("err = %v", err) + } + if !branchExists(t, f.dir, "c") || branchExists(t, f.dir, "c2") || !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Error("nothing should have changed") + } +} + +func TestRenameLocalLeavesGitHubAlone(t *testing.T) { + f, rf := renameFixture(t, true) + f.fb.graph.Stacks[0].Branches[2].PR = &stack.PRRef{Number: 7} + res, err := f.app.Rename(context.Background(), f.repo, app.RenameOptions{Name: "c2", Local: true}) + if err != nil { + t.Fatal(err) + } + if res.Remote != "" || len(rf.renames) != 0 { + t.Errorf("result = %+v, renames = %v", res, rf.renames) + } + if len(res.Notices) != 1 || !strings.Contains(res.Notices[0], "#7") { + t.Errorf("notices = %v", res.Notices) + } + if f.fb.graph.Stacks[0].Branches[2].PR != nil { + t.Error("c2 has no pull request of its own on GitHub yet") + } + if !slices.Equal(f.names(), []string{"a", "b", "c2"}) { + t.Errorf("stack = %v", f.names()) + } +} + +func TestRenameRefuses(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T, f *restackFixture) + to string + kind stack.Kind + substr string + }{ + {"trunk", func(t *testing.T, f *restackFixture) { gittest.Run(t, f.dir, "switch", "-q", "main") }, "trunk2", stack.KindInvalidArgs, "is a trunk"}, + {"untracked", func(t *testing.T, f *restackFixture) { gittest.Run(t, f.dir, "switch", "-q", "-c", "loose") }, "loose2", stack.KindNotInStack, "not in a stack"}, + {"taken", nil, "a", stack.KindInvalidArgs, "already exists"}, + {"same", nil, "c", stack.KindInvalidArgs, "already called c"}, + {"invalid", nil, "bad..name", stack.KindInvalidArgs, "not a valid branch name"}, + {"taken on the remote", func(t *testing.T, f *restackFixture) { + gittest.Run(t, f.dir, "update-ref", "refs/remotes/origin/elsewhere", "HEAD") + }, "elsewhere", stack.KindInvalidArgs, "already exists on origin"}, + {"rebasing", func(t *testing.T, f *restackFixture) { + if err := os.MkdirAll(f.repo.GitDir+"/rebase-merge", 0o755); err != nil { + t.Fatal(err) + } + }, "c2", stack.KindRebaseActive, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + f, rf := renameFixture(t, true) + if tc.setup != nil { + tc.setup(t, f) + } + _, err := f.app.Rename(context.Background(), f.repo, app.RenameOptions{Name: tc.to}) + se, ok := errors.AsType[*stack.Error](err) + if !ok || se.Kind != tc.kind || !strings.Contains(se.Msg, tc.substr) { + t.Fatalf("err = %v", err) + } + if len(rf.renames) != 0 { + t.Errorf("nothing should be renamed on GitHub: %v", rf.renames) + } + }) + } +} diff --git a/pkg/gitconfig/aliases.go b/pkg/gitconfig/aliases.go index e4f7d3a..00d5e04 100644 --- a/pkg/gitconfig/aliases.go +++ b/pkg/gitconfig/aliases.go @@ -33,6 +33,7 @@ var DefaultAliases = []Alias{ {"move", "stack move"}, {"fold", "stack fold"}, {"untrack", "stack untrack"}, + {"rename", "stack rename"}, {"track", "stack init"}, {"pop", "stack pop"}, {"restack", "stack restack"}, diff --git a/pkg/install/install_test.go b/pkg/install/install_test.go index c2d9102..8162d86 100644 --- a/pkg/install/install_test.go +++ b/pkg/install/install_test.go @@ -192,7 +192,7 @@ func TestInstallAndUninstall(t *testing.T) { } func gitconfigDefaults() []string { - return []string{"create", "modify", "absorb", "reorder", "move", "fold", "untrack", "track", "pop", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "pr", "c", "m", "ab", "rs", "ss", "u", "d", "t", "b", "co"} + return []string{"create", "modify", "absorb", "reorder", "move", "fold", "rename", "untrack", "track", "pop", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "pr", "c", "m", "ab", "rs", "ss", "u", "d", "t", "b", "co"} } func TestSkillOptInIsAsked(t *testing.T) { diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index 486dc82..96c0ce7 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -25,6 +25,7 @@ that touches a stack: | Change the order of a stack's branches | `stack_reorder` (`order` bottom to top) | | Move a branch and what's above it onto another stack or onto trunk | `stack_move` (`only: true` to leave the rest behind) | | Merge the current branch into the one below it (one branch, one PR) | `stack_fold` (`keep: true` keeps the current branch's name and PR) | +| Rename the current branch (on GitHub too once pushed; its PR follows) | `stack_rename` (`local: true` renames only this repository) | | Track branches made by hand as a stack | `stack_init` | | Take the current branch out of its stack, keeping its changes staged on its parent | `stack_pop` | | Stop tracking a branch and what's above it, leaving the branches alone | `stack_untrack` (`only: true` drops just that branch and restacks the rest onto its parent) | @@ -58,7 +59,7 @@ the one calling. ## CLI equivalents `git stack create -m "…"`, `git stack modify -a`, `git stack absorb`, `git stack reorder a b c`, -`git stack move --onto `, `git stack fold`, `git stack untrack`, `git stack init a b`, `git stack pop`, `git stack restack`, +`git stack move --onto `, `git stack fold`, `git stack rename `, `git stack untrack`, `git stack init a b`, `git stack pop`, `git stack restack`, `git stack up/down/top/bottom`, `git stack checkout`, `git stack submit --no-edit`, -`git stack sync -f`, `git stack pr --print` (a branch's PR URL; `stack_view` has them all). Aliases: `git c`, `git m`, `git ab`, `git reorder`, `git move`, `git fold`, `git untrack`, `git track`, `git pop`, +`git stack sync -f`, `git stack pr --print` (a branch's PR URL; `stack_view` has them all). Aliases: `git c`, `git m`, `git ab`, `git reorder`, `git move`, `git fold`, `git rename`, `git untrack`, `git track`, `git pop`, `git rs`, `git u`, `git d`, `git t`, `git b`, `git co`, `git ss`, `git pr`. diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 27da8b6..d7dfaa8 100644 --- a/pkg/mcp/instructions.go +++ b/pkg/mcp/instructions.go @@ -2,7 +2,7 @@ package mcp // Instructions is sent to clients at initialisation. Codex weighs the start // of it most, so the key guidance comes first. -const Instructions = `git-stack manages stacked branches and stacked pull requests on top of GitHub's gh stack. Prefer these tools over raw git whenever the user works in a stack: stack_view shows every stack (branches, parents, PR state, needs_restack); stack_create starts a new branch on top of the current one (you write the commit message), or with insert: true right above the current branch in the middle of its stack, restacking the branches above onto it; stack_modify amends the current branch (or adds a commit) and restacks everything above it; stack_absorb folds each staged hunk into the commit of the stack that last changed those lines and rewrites that commit and everything above it (dry_run shows the plan first); use it to fix something lower down without navigating there; stack_reorder changes the order of the current stack's branches (order, bottom to top) and restacks them; stack_move puts a branch and what's above it on top of another stack or on a trunk as a new stack (only: true leaves the branches above behind); stack_fold merges the current branch into the branch below it, one branch and one pull request (keep: true keeps the current branch's name and pull request instead of the parent's), and the next stack_submit closes the pull request of the branch that went; stack_init tracks branches you already have as a new stack, without rebasing; stack_untrack is its opposite, taking a branch and everything above it out of its stack without touching the branches (only: true takes just that branch and restacks the ones above onto its parent); stack_pop deletes the current branch and takes it out of its stack, leaving its changes staged on its parent, and restacks the branches above (its pull request is closed by the next stack_submit). stack_reorder, stack_move and stack_create with insert leave the stack's pull requests to be regrouped on GitHub: the next stack_submit dissolves every stale GitHub stack and creates the ones it did not submit again, its notices naming each (a stack with fewer than two pull requests has nothing to group; a branch without an open pull request, or a failed create, gets a notice saying to submit from that stack); stack_restack rebases the stack locally without checking anything out, bottom branch onto the local trunk included; on a conflict it leaves one git rebase in progress for the files to be resolved; stack_continue finishes an interrupted restack once the conflicts are resolved and git added; stack_abort gives it up and puts every moved branch back; stack_navigate moves up/down/top/bottom or checks out a branch; stack_submit pushes the stack (or with down only the current branch and the ones below it) and creates or updates PRs (you write titles and bodies; ready for review unless draft is true); stack_sync fetches, fast forwards trunk, deletes branches whose pull requests merged or closed, fast forwards branches that moved on the remote and restacks every stack without checking anything out; it never pushes. stack_merge merges the stack's pull requests up to a branch into trunk in one all or nothing operation (drafts must be published first) and then syncs; it refuses while any of those pull requests has a check that failed (code checks_failing) or is still running (code checks_pending), listing them in the error's checks field. Fix the failures and push with stack_submit, or wait for running checks and call it again; only pass force: true when the user has said to merge anyway. +const Instructions = `git-stack manages stacked branches and stacked pull requests on top of GitHub's gh stack. Prefer these tools over raw git whenever the user works in a stack: stack_view shows every stack (branches, parents, PR state, needs_restack); stack_create starts a new branch on top of the current one (you write the commit message), or with insert: true right above the current branch in the middle of its stack, restacking the branches above onto it; stack_modify amends the current branch (or adds a commit) and restacks everything above it; stack_absorb folds each staged hunk into the commit of the stack that last changed those lines and rewrites that commit and everything above it (dry_run shows the plan first); use it to fix something lower down without navigating there; stack_reorder changes the order of the current stack's branches (order, bottom to top) and restacks them; stack_move puts a branch and what's above it on top of another stack or on a trunk as a new stack (only: true leaves the branches above behind); stack_fold merges the current branch into the branch below it, one branch and one pull request (keep: true keeps the current branch's name and pull request instead of the parent's), and the next stack_submit closes the pull request of the branch that went; stack_rename gives the current branch a new name, renaming it on GitHub first once it has been pushed so its pull request follows (local: true renames it only in this repository); stack_init tracks branches you already have as a new stack, without rebasing; stack_untrack is its opposite, taking a branch and everything above it out of its stack without touching the branches (only: true takes just that branch and restacks the ones above onto its parent); stack_pop deletes the current branch and takes it out of its stack, leaving its changes staged on its parent, and restacks the branches above (its pull request is closed by the next stack_submit). stack_reorder, stack_move and stack_create with insert leave the stack's pull requests to be regrouped on GitHub: the next stack_submit dissolves every stale GitHub stack and creates the ones it did not submit again, its notices naming each (a stack with fewer than two pull requests has nothing to group; a branch without an open pull request, or a failed create, gets a notice saying to submit from that stack); stack_restack rebases the stack locally without checking anything out, bottom branch onto the local trunk included; on a conflict it leaves one git rebase in progress for the files to be resolved; stack_continue finishes an interrupted restack once the conflicts are resolved and git added; stack_abort gives it up and puts every moved branch back; stack_navigate moves up/down/top/bottom or checks out a branch; stack_submit pushes the stack (or with down only the current branch and the ones below it) and creates or updates PRs (you write titles and bodies; ready for review unless draft is true); stack_sync fetches, fast forwards trunk, deletes branches whose pull requests merged or closed, fast forwards branches that moved on the remote and restacks every stack without checking anything out; it never pushes. stack_merge merges the stack's pull requests up to a branch into trunk in one all or nothing operation (drafts must be published first) and then syncs; it refuses while any of those pull requests has a check that failed (code checks_failing) or is still running (code checks_pending), listing them in the error's checks field. Fix the failures and push with stack_submit, or wait for running checks and call it again; only pass force: true when the user has said to merge anyway. Never run git rebase or git commit --amend by hand inside a stack: descendants would not be restacked. Use stack_modify / stack_restack instead. diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 1cbd06c..316cf4e 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -220,6 +220,8 @@ var cliToTool = []struct{ from, to string }{ {"git stack move", "stack_move"}, {"git stack fold", "stack_fold"}, {"git stack untrack", "stack_untrack"}, + {"git stack rename --local", "stack_rename with local: true and name"}, + {"git stack rename", "stack_rename"}, {"git stack init", "stack_init"}, {"git stack track", "stack_init"}, {"git stack pop", "stack_pop"}, diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 9d4bf57..f7aaa78 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -228,7 +228,7 @@ func TestToolsAreListedWithAnnotationsAndSchemas(t *testing.T) { for _, tool := range res.Tools { got[tool.Name] = tool } - for _, name := range []string{"stack_view", "stack_create", "stack_modify", "stack_absorb", "stack_reorder", "stack_move", "stack_fold", "stack_untrack", "stack_init", "stack_pop", "stack_restack", "stack_navigate", "stack_submit", "stack_sync"} { + for _, name := range []string{"stack_view", "stack_create", "stack_modify", "stack_absorb", "stack_reorder", "stack_move", "stack_fold", "stack_rename", "stack_untrack", "stack_init", "stack_pop", "stack_restack", "stack_navigate", "stack_submit", "stack_sync"} { if got[name] == nil { t.Errorf("missing tool %s", name) } @@ -895,3 +895,22 @@ func TestUntrack(t *testing.T) { t.Errorf("untracked = %+v", te) } } + +func TestRename(t *testing.T) { + h := newHarness(t) + defer h.assertNoStdout() + var res app.RenameResult + h.call("stack_rename", map[string]any{"name": "b2"}, &res) + if res.From != "b" || res.To != "b2" || res.Remote != "origin" || !slices.Equal(h.forge.renamed, []string{"b -> b2"}) { + t.Fatalf("rename = %+v, renamed on the forge: %v", res, h.forge.renamed) + } + if s, i, ok := h.backend.Graph().StackOf("b2"); !ok || i != 1 || s.Bottom() != "a" { + t.Errorf("b2's stack = %+v", s) + } + if h.current() != "b2" { + t.Errorf("current = %s", h.current()) + } + if te := h.toolErr("stack_rename", map[string]any{"name": "a"}); te.Code != "invalid_args" || !strings.Contains(te.Message, "already exists") { + t.Errorf("taken = %+v", te) + } +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index f7ee360..5cca66b 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -312,6 +312,24 @@ func (s *Server) untrack(ctx context.Context, req *mcp.CallToolRequest, in untra return nil, res, nil } +type renameInput struct { + repoArg + Name string `json:"name" jsonschema:"the current branch's new name, used as given"` + Local bool `json:"local,omitempty" jsonschema:"rename only in this repository, leaving the remote branch and its pull request under the old name"` +} + +func (s *Server) rename(ctx context.Context, req *mcp.CallToolRequest, in renameInput) (*mcp.CallToolResult, app.RenameResult, error) { + repo, a, err := s.appFor(ctx, req, in.RepoPath) + if err != nil { + return nil, app.RenameResult{}, wrapErr(err) + } + res, err := a.Rename(ctx, repo, app.RenameOptions{Name: in.Name, Local: in.Local}) + if err != nil { + return nil, app.RenameResult{}, wrapErr(err) + } + return nil, res, nil +} + type initInput struct { repoArg Branches []string `json:"branches,omitempty" jsonschema:"existing branches to track, bottom to top (default: the current branch)"` @@ -644,6 +662,12 @@ func (s *Server) registerTools() { Description: "Take a branch (default the current one) and every branch above it out of its stack; a stack is a single line, so it can't keep a gap. No commit, ref or checkout changes, and a stack left empty goes away. With only: true just that branch is untracked and the branches above it are restacked onto its parent, dropping its commits; conflicts and undo work as for stack_reorder. Pull requests stay open; a stack GitHub knows is regrouped at the next stack_submit. next_steps says how to track the branches again. Refuses with invalid_args on a trunk or when a branch in the stack has merged (stack_sync first), and not_in_stack on an untracked branch.", Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, }, s.untrack) + mcp.AddTool(s.mcp, &mcp.Tool{ + Name: "stack_rename", + Title: "Rename the current branch", + Description: "Give the current branch a new name, keeping its place in the stack, its commits and its base; nothing is rebased or pushed. A branch that has been pushed is renamed on GitHub first (remote in the result), which keeps its pull request attached and retargets pull requests based on it; if GitHub refuses, nothing changes. With local: true only this repository changes and the pull request stays on the old name (a notice says so). Refuses with invalid_args on a trunk or a name already taken here or on the remote, and not_in_stack on an untracked branch.", + Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(true)}, + }, s.rename) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_init", Title: "Track existing branches as a stack", diff --git a/pkg/ui/report.go b/pkg/ui/report.go index 5583fb3..c85423e 100644 --- a/pkg/ui/report.go +++ b/pkg/ui/report.go @@ -115,6 +115,7 @@ var phaseEmoji = map[app.Phase]string{ app.PhaseUpdate: "📦", app.PhaseInstall: "🔧", app.PhaseMerge: "🔀", + app.PhaseRename: "🏷️", app.PhaseRefresh: "📥", }