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
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ 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 and adopting branches you made by hand are `git stack reorder`,
`git stack move` and `git stack init`. The one thing left that needs gh stack directly (and then
`git restack`) is folding a branch into another, or dropping one from the middle of a stack:
`gh stack modify`.
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`.

## Install

Expand Down 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|track|restack|continue|abort|submit|sync|up|down|top|bottom`
- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|track|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 @@ -387,6 +387,33 @@ Flags:
- `-s` / `--source <branch>` move this branch instead of the current one
- `--only` move just the branch and leave the ones above it behind

### `git stack fold`

> Alias: `git fold`

Merges the current branch into the branch below it, for when two PRs should really have been one.
The parent keeps its name and PR and ends up with both sets of commits; the current branch is
deleted and you're left on the parent, with any uncommitted changes carried along. With `--keep`
it's the other way round: the current branch keeps its name and PR and takes the parent's place in
the stack, and the parent is deleted.

Nothing is rebased, since the branches above already have every commit involved and only their
parent changes, so the branch has to be sitting on its parent first (`git restack` if it isn't).
It won't fold the bottom branch of a stack into trunk, and it stops before anything changes when
the parent is checked out in another worktree. The PR of the branch that went is closed at the next
`git ss`, with a comment pointing at the one it went into, and its branch on GitHub is deleted once
nothing is based on it any more.

```
# on billing-webhook-alerts
❯ git fold
✔ Folded billing-webhook-alerts into billing-webhook-schema
#418 (billing-webhook-alerts) will be closed at the next submit
```

Flags:
- `--keep` keep this branch's name and pull request instead of the parent's

### `git stack init [branch...]`

> Alias: `git track`; `git stack track` works too
Expand Down
52 changes: 52 additions & 0 deletions cmd/fold.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"github.com/spf13/cobra"

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

func newFoldCmd(c *cli) *cobra.Command {
var keep bool
cmd := &cobra.Command{
Use: "fold",
Short: "Fold the current branch into the branch below it",
Long: `Merges the current branch into its parent, so the two become one branch with
one pull request and both sets of commits. The parent keeps its name and pull
request and you're left on it; with --keep it's the other way round, the
current branch takes the parent's place and the parent is deleted.

The current branch has to sit on its parent (run "git stack restack" first if
it doesn't). Nothing is rebased: the branches above already have every commit
involved, only their parent changes. Local changes come along. The pull request
of the branch that went is closed at the next submit, with a comment saying
where it went.`,
Example: ` # fold this branch into the one below it
git fold

# keep this branch's name and pull request instead
git fold --keep`,
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.Fold(ctx, repo, app.FoldOptions{Keep: keep})
renderNotices(rep, res.Notices)
if err != nil {
return err
}
rep.Success("Folded %s into %s", res.Folded, res.Into)
if res.Closing != 0 {
rep.Info("#%d (%s) will be closed at the next submit", res.Closing, res.Folded)
}
return nil
},
}
cmd.Flags().BoolVar(&keep, "keep", false, "keep this branch's name and pull request instead of the parent's")
return cmd
}
26 changes: 26 additions & 0 deletions cmd/fold_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cmd

import (
"strings"
"testing"

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

func TestFoldCommand(t *testing.T) {
dir, run := stackOf3(t)
out, errOut, err := run("fold")
if err != nil || !strings.Contains(out, "ok: Folded c into b") {
t.Errorf("fold: %q %q %v", out, errOut, err)
}
if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "b" {
t.Errorf("current = %s", cur)
}
out, errOut, err = run("fold", "--keep")
if err != nil || !strings.Contains(out, "ok: Folded a into b") {
t.Errorf("fold --keep: %q %q %v", out, errOut, err)
}
if _, _, err = run("fold"); err == nil || !strings.Contains(err.Error(), "bottom of its stack") {
t.Errorf("bottom: %v", err)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ func newRootCmd(c *cli) *cobra.Command {
newAbsorbCmd(c),
newReorderCmd(c),
newMoveCmd(c),
newFoldCmd(c),
newInitCmd(c),
newRestackCmd(c),
newContinueCmd(c),
Expand Down
8 changes: 8 additions & 0 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"

"github.com/DomBlack/git-stack/pkg/app"
"github.com/DomBlack/git-stack/pkg/forge"
)

func newSubmitCmd(c *cli) *cobra.Command {
Expand Down Expand Up @@ -116,6 +117,13 @@ really do want them gone.`,
rep.Info("%s", rep.Branch(pr.Branch))
}
}
for _, c := range res.Closed {
gone := ", and deleted its branch"
if !c.BranchDeleted {
gone = ""
}
rep.Info("%s %s closed%s", rep.Branch(c.Branch), rep.Ref(c.Number, "", forge.StateClosed), gone)
}
for _, n := range res.Notices {
rep.Warn("%s", n)
}
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,19 @@ already gone counts as done, and a failure to dissolve keeps the entry and says
Keeping the two apart also means the reshape itself works offline. PRs that are queued for merge or have auto merge on stay stacked on
GitHub's side, so creating the new stack fails with gh stack's usual warning until they land.

**Fold is a reshape without a restack.** Folding C into its parent P only works when C already
sits on P, so after it every branch above still has every commit involved and only its recorded
parent changes. The reshape step does the metadata (P's head becomes C's tip and C is dropped, or
with `--keep` P is dropped and C takes its base) and the dissolve as usual, with no segments to
run. Then P's ref is fast forwarded to C's tip and switched to, which leaves the working tree alone
because they're the same commit, and C is deleted. P can't be checked out anywhere else, since its
ref moves (or is deleted) under that checkout. A failure before the switch puts the ref and the
snapshot back. The PR of the branch that went goes on a second list in the same queue file, closed
at the next submit with a comment saying where it went. That happens after the backend submit,
because deleting a branch on GitHub closes every open PR based on it, and it's the backend submit
that moves the PR above onto the surviving branch; a remote branch something is still based on is
kept and its delete stays queued.

**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 @@ -25,6 +25,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `stack_absorb` | Fold each staged hunk into the commit of the current stack that last changed those lines, rewrite it and everything above, then restack what sits above the current branch; the result's `plan` lists every hunk's destination and what stays staged | `staging` = `all`/`update`/`none`, `dry_run` | destructive |
| `stack_reorder` | Change the order of the current stack's branches and restack them, checking nothing out; the next `stack_submit` dissolves the stale stack on GitHub and creates it again in the new order; a branch checked out with changes in another worktree refuses the call with nothing changed | `order` (required: every branch of the stack once, bottom to top) | destructive |
| `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_abort` | Give up an interrupted restack and put every moved branch back (and, after a reorder or move, the old stack order) | | destructive |
Expand Down
Loading
Loading