Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <name>`

> 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
Expand Down
51 changes: 51 additions & 0 deletions cmd/rename.go
Original file line number Diff line number Diff line change
@@ -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 <name>",
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
}
53 changes: 53 additions & 0 deletions cmd/rename_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ func newRootCmd(c *cli) *cobra.Command {
newMoveCmd(c),
newFoldCmd(c),
newUntrackCmd(c),
newRenameCmd(c),
newInitCmd(c),
newPopCmd(c),
newRestackCmd(c),
Expand Down
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
2 changes: 2 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions pkg/app/prs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading