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
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,9 @@ merging the PRs on GitHub.

gh stack can only add branches at the top of a stack, so `git create` can't start one in the
middle either; create it on top and `git reorder` it into place. Reordering a stack, moving
branches onto another stack, folding a branch into the one below it and adopting branches you made
by hand are `git stack reorder`, `git stack move`, `git stack fold` and `git stack init`. The one
thing left that needs gh stack directly (and then `git restack`) is dropping a branch from the
middle of a stack: `gh stack modify`.
branches onto another stack, folding a branch into the one below it, dropping one from a stack and
adopting branches you made by hand are `git stack reorder`, `git stack move`, `git stack fold`,
`git stack pop` and `git stack init`, so nothing needs gh stack directly any more.

## Install

Expand Down Expand Up @@ -146,7 +145,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|track|restack|continue|abort|submit|sync|up|down|top|bottom`
- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom`
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 @@ -439,6 +438,30 @@ made with `git create`; this only adopts ones that exist.
Flags:
- `-b` / `--base <trunk>` the trunk to stack on

### `git stack pop`

> Alias: `git pop`

Deletes the branch you're on and takes it out of its stack, leaving its changes staged on its
parent, much like `git reset --soft` onto the parent would. Handy when you want to redo a branch
from scratch, or when it shouldn't be its own PR yet. The branches above it are restacked onto the
parent first, so they no longer carry its commits.

It wants a clean working tree (untracked files are fine), so the popped changes don't get mixed up
with anything else, and the branch has to be sitting on its parent; `git restack` puts it there.
It also won't run while the parent is checked out in another worktree, since the parent is about
to be checked out here. A conflict in the restack stops like any other: fix it, `git add` and
`git continue`, which then finishes the pop, or `git abort` to leave everything as it was. If the
branch has an open PR, the next `git ss` closes it with a comment saying why.

```
# on billing-webhook-alerts
❯ git pop
✔ Popped billing-webhook-alerts; its changes are staged on billing-webhook-schema
✔ Restacked billing-webhook-retries
#412 will be closed at the next submit
```

### `git stack submit`

> Aliases: `git submit`, `git ss`
Expand Down Expand Up @@ -564,8 +587,9 @@ Flags:

> Aliases: `git continue`, `git stack cont`

Once you've fixed and `git add`ed the conflicts, finishes the rebase a restack, modify, reorder or
move stopped on and then restacks whatever was left above it.
Once you've fixed and `git add`ed the conflicts, finishes the rebase a restack, modify, reorder,
move or pop stopped on and then restacks whatever was left above it (and, for a pop, then finishes
the pop).

Flags:
- `-a` / `--all` stage every change first
Expand Down
55 changes: 55 additions & 0 deletions cmd/pop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cmd

import (
"strings"

"github.com/spf13/cobra"
)

func newPopCmd(c *cli) *cobra.Command {
cmd := &cobra.Command{
Use: "pop",
Short: "Take the current branch out of its stack, keeping its changes staged",
Long: `Deletes the current branch and takes it out of its stack, leaving its changes
staged on its parent, as if you had run git reset --soft onto the parent. The
branches above it are restacked onto the parent first, so they no longer carry
its commits.

The working tree must have no changes to tracked files (untracked files are
fine), the branch must sit on its parent (git stack restack puts it there), and
the parent can't be checked out in another worktree. A conflict stops like a
restack does: fix the files, git add them and run "git stack continue", which
then finishes the pop, or "git stack abort" to leave everything as it was. If
the branch has an open pull request, the next submit closes it.`,
Example: ` # redo the current branch from scratch
git pop`,
Args: cobra.NoArgs,
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.Pop(ctx, repo)
renderNotices(rep, res.Notices)
if err != nil {
return err
}
gone := ""
if res.StackGone {
gone = " (the stack is gone)"
}
rep.Success("Popped %s; its changes are staged on %s%s", res.Branch, res.Parent, gone)
if len(res.Restacked) > 0 {
rep.Success("Restacked %s", strings.Join(res.Restacked, ", "))
}
if res.Closing > 0 {
rep.Info("#%d will be closed at the next submit", res.Closing)
}
return nil
},
}
return cmd
}
39 changes: 39 additions & 0 deletions cmd/pop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cmd

import (
"strings"
"testing"

"github.com/DomBlack/git-stack/pkg/git/gittest"
)

func TestPopCommand(t *testing.T) {
dir, run := stackOf3(t)
gittest.Run(t, dir, "switch", "-q", "b")
out, errOut, err := run("pop")
if err != nil || !strings.Contains(out, "ok: Popped b; its changes are staged on a") || !strings.Contains(out, "Restacked c") {
t.Errorf("pop: %q %q %v", out, errOut, err)
}
if got := gittest.Run(t, dir, "diff", "--cached", "--name-only"); got != "b.txt" {
t.Errorf("staged = %q", got)
}
gittest.Run(t, dir, "commit", "-q", "-m", "b again")
gittest.Run(t, dir, "switch", "-q", "c")
if _, _, err = run("pop"); err == nil || !strings.Contains(err.Error(), "c isn't on a yet") {
t.Errorf("c is no longer on a: %v", err)
}
}

func TestPopCommandRemovesTheLastBranch(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)
}
out, errOut, err := runWith(t, f, "--cwd", dir, "pop")
if err != nil || !strings.Contains(out, "ok: Popped a; its changes are staged on main (the stack is gone)") {
t.Errorf("pop: %q %q %v", out, errOut, err)
}
}
4 changes: 4 additions & 0 deletions cmd/restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func renderRestack(rep *ui.Reporter, res app.RestackResult, aborted bool) {
}
return
}
if res.Popped != "" {
// A continue that finished a pop says so after what it restacked.
defer rep.Success("Popped %s; its changes are staged on %s", res.Popped, res.PoppedOnto)
}
if len(res.Moved) == 0 {
if len(res.InPlace) == 0 {
rep.Success("Nothing to restack")
Expand Down
9 changes: 9 additions & 0 deletions cmd/restack_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,12 @@ func TestRenderRestackAborted(t *testing.T) {
})
}
}

func TestRenderRestackFinishedAPop(t *testing.T) {
var out, errOut bytes.Buffer
rep := ui.NewReporter(nil, &out, &errOut, ui.ReporterOptions{})
renderRestack(rep, app.RestackResult{Moved: []app.BranchMove{{Name: "c"}}, Popped: "b", PoppedOnto: "a"}, false)
if want := "ok: Restacked c\nok: Popped b; its changes are staged on a\n"; out.String() != want {
t.Errorf("out = %q, want %q", out.String(), want)
}
}
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),
newInitCmd(c),
newPopCmd(c),
newRestackCmd(c),
newContinueCmd(c),
newAbortCmd(c),
Expand Down
12 changes: 12 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ conflict on one checked out elsewhere, refuses the whole reshape up front with n
A failure before any ref has moved rolls the metadata back from the snapshot and the error says
nothing was changed.

**Pop is a reshape with a finish step.** It drops the current branch from its stack, points the
branch above at the popped branch's tip as its base (so the restack replays only its own commits)
and restacks the branches above onto the parent, all while the popped branch stays checked out
and untouched. Only once that's done does it point HEAD at the parent with `git symbolic-ref`,
which leaves the index and working tree alone so the popped changes show as staged, and delete the
branch. Doing it in that order means the restack never moves or rebases the branch that's checked
out here, and a conflict leaves nothing half done: the saved restack state carries the finish step
so `continue` runs it at the end, and `abort` only has to put the dropped branch back in the
metadata (the snapshot lists it), since it was never deleted and HEAD never moved. Its open PR
goes on the close queue next to the dissolves, so the next submit closes it once gh stack has
moved the PR above it onto the new parent.

**GitHub's stack is dissolved and made again, not edited.** The stacks API can create a stack
from a list of PRs and add PRs to the top of one, and that's all. Left alone, `gh stack submit`
after a reorder pushes every branch and fixes each PR's base, then sees the stack on GitHub is
Expand Down
7 changes: 6 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ 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_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_continue` | Finish an interrupted restack, modify, reorder or move once the conflicts are resolved and git added | `stage_all` | destructive |
| `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 |
| `stack_abort` | Give up an interrupted restack and put every moved branch back (and, after a reorder or move, the old stack order) | | destructive |
| `stack_restack` | Local rebase of the stack onto its parents, bottom branch onto the local trunk included; only checks out a branch to resolve a conflict through one git rebase | `scope` = `all`/`upstack`/`downstack`/`only`, `branch`, `continue`, `abort`, `stage_all` | destructive |
| `stack_navigate` | `up`/`down`/`top`/`bottom` with `steps`, or `branch` | `direction`, `steps`, `branch` | idempotent |
Expand Down Expand Up @@ -95,6 +96,10 @@ create ("<branch> has no open pull request, so <bottom>'s stack was not created
from it once it has one"); and a failed create says to submit from it to try again. With no forge
configured the dissolve notice itself says to submit from that stack. If dissolving fails, a
notice says so and the next submit tries again.
`conflict` from `stack_pop` means the branch is already out of the stack and the restack of the
branches above it stopped; the branch itself still exists and is still checked out.
`stack_continue` finishes the restack and then the pop (its result's `popped` names the branch),
and `stack_abort` puts the branch back in its stack with nothing staged.
`checks_failing` and `checks_pending` come from `stack_merge` when a pull request it would land
has a check that failed, or (with none failed) one still queued or running, on its head commit.
Nothing is merged. The error's `checks` lists each held up pull request:
Expand Down
Loading
Loading