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
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ git down # go back down one branch (alias for git stack down)
# ...fix something the reviewer spotted on the first PR...
git modify -a # stage everything, amend that branch's commit and restack everything above
# (alias for git stack modify)
git ab -u # or let absorb work out which commit each fix belongs to and put it there
# (alias for git stack absorb)
git top # jump to the top of the stack (alias for git stack top)
git ss # push the whole stack and create or update the PRs
# (alias for git stack submit)
Expand Down Expand Up @@ -141,8 +143,8 @@ 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|restack|continue|abort|submit|sync|up|down|top|bottom`
and the short ones `git c|m|rs|ss|u|d|t|b|co`, written with `git config --global`. Anything that
- **Aliases** (`--aliases`): `git create|modify|absorb|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.
- **Completion** (`--completion`, `--shell bash|zsh|fish`, repeatable, defaults to your `$SHELL`):
Expand Down Expand Up @@ -254,6 +256,48 @@ Flags:
- `--reset-author` set the author to you when amending
- `--no-verify` skip git hooks when committing

### `git stack absorb`

> Aliases: `git absorb`, `git ab`

Takes whatever is staged and folds each changed run of lines into the commit in this stack that
last changed those lines, e.g. the fix for a review comment three PRs down goes into that PR's
commit without you going there. It looks at the current branch's commits and the branches below
it (never trunk, and never a branch whose PR has merged), rewrites the commit it picks and every
commit above it keeping messages and authors, then restacks the branches above you. It doesn't
push.

It goes by lines, not by meaning, so two things to know. A change to lines no commit in the stack
touched can't be placed and stays staged, as does a new or deleted file; the output says so. And one
run of changed lines that spans lines two different commits changed goes into the newer of the two,
not split between them.

```
❯ git absorb
pkg/app/sync.go:120-125 into 7ac32ef Give up on a webhook after five attempts (billing-webhook-retries)
pkg/app/sync.go:300 into 4f21abc Version the billing webhook payload (billing-webhook-schema)
README.md:12 not absorbed; no commit in this stack changed those lines
? Absorb 2 hunks into 2 commits? [Y/n]
✔ Absorbed 2 hunks into 2 commits
billing-webhook-schema rewritten, was 9b1d2e0
billing-webhook-retries rewritten, was 7ac32ef
🧱 Restacking 1 branch above billing-webhook-retries…
✔ Restacked 1 branch above billing-webhook-retries
⚠ Still staged: 1 hunk
```

On a terminal you see the plan and get asked first. The old tips are printed. If it picked the wrong
commit, put things back with `git reset --soft <old tip of the branch you're on>` (your changes come
back staged), `git branch -f <branch> <old tip>` for each other branch it rewrote, then
`git restack`. Every branch it needs to rewrite has to be sitting on its parent already (run
`git restack` if not), and the bottom branch of the stack is fine behind main.

Flags:
- `-a` / `--all`, `-u` / `--update`, `-p` / `--patch` stage first, like `modify`; they stage even
with `--dry-run`
- `-d` / `--dry-run` show where each change would go and change nothing
- `-f` / `--force` don't ask before rewriting

### `git stack submit`

> Aliases: `git submit`, `git ss`
Expand Down
148 changes: 148 additions & 0 deletions cmd/absorb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package cmd

import (
"fmt"
"strings"

"github.com/spf13/cobra"

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

func newAbsorbCmd(c *cli) *cobra.Command {
var (
st stagingFlags
dryRun bool
force bool
)
cmd := &cobra.Command{
Use: "absorb",
Aliases: []string{"ab"},
Short: "Fold staged changes into the commits in this stack that last touched those lines",
Long: `Takes what is staged and folds each changed run of lines into the commit in
this stack that last changed those lines: the current branch's commits and the
branches below it, down to (not including) trunk or a merged branch. Those
commits and everything above them are rewritten, keeping their messages and
authors, and the branches above the current one are restacked.

It goes by lines, not by meaning. A run of changed lines spanning lines that
two commits changed goes into the newer of the two. A change to lines no commit
in the stack touched stays staged, as does a new or deleted file. On a
terminal you see the plan and get asked before anything is rewritten.

-a, -u and -p stage first, dry run or not. The old tips are printed. If it
picked the wrong commit, put things back with "git reset --soft <old tip of the
branch you're on>" (your changes come back staged), "git branch -f <branch> <old
tip>" for each other branch it rewrote, then "git stack restack".`,
Example: ` # stage your fixes, see where each one would go
git add -p
git absorb --dry-run

# fold every change to tracked files into the right commits
git absorb -u`,
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.Absorb(ctx, repo, app.AbsorbOptions{
Staging: st.mode(), DryRun: dryRun, Force: force,
Preview: func(p *app.AbsorbPlan) { renderAbsorbPlan(rep, p) },
})
if res.Applied {
// Shown before any error: the absorb happened even when the
// restack above it stopped on a conflict.
renderAbsorbResult(rep, res)
}
if err != nil {
return err
}
if !res.Applied {
renderNotices(rep, res.Notices)
switch {
case res.Plan.Absorbed == 0:
rep.Success("Nothing to absorb")
case res.DryRun:
rep.Warn("Dry run; nothing was absorbed")
}
}
return nil
},
}
st.add(cmd)
cmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "show where each change would go without rewriting any commit")
cmd.Flags().BoolVarP(&force, "force", "f", false, "don't ask before rewriting the commits")
return cmd
}

// renderAbsorbPlan prints one line per staged hunk with its destination,
// then the files that were skipped.
func renderAbsorbPlan(rep *ui.Reporter, p *app.AbsorbPlan) {
for _, h := range p.Hunks {
loc := ui.QuoteName(h.Path) + ":" + hunkLines(h)
if h.Target == nil {
rep.Info("%s not absorbed; no commit in this stack changed those lines", loc)
continue
}
rep.Info("%s into %s %s (%s)", loc, rep.SHA(h.Target.SHA), h.Target.Subject, rep.Branch(h.Target.Branch))
}
for _, s := range p.Skipped {
rep.Info("%s skipped; %s", ui.QuoteName(s.Path), s.Reason)
}
}

// hunkLines says which lines of the file a hunk changes: "120-125", "12",
// or "after 300" for an insertion.
func hunkLines(h app.AbsorbHunk) string {
switch h.OldCount {
case 0:
return fmt.Sprintf("after %d", h.OldStart)
case 1:
return fmt.Sprintf("%d", h.OldStart)
}
return fmt.Sprintf("%d-%d", h.OldStart, h.OldStart+h.OldCount-1)
}

// renderAbsorbResult prints what an applied absorb did.
func renderAbsorbResult(rep *ui.Reporter, res app.AbsorbResult) {
renderNotices(rep, res.Notices)
rep.Success("Absorbed %d %s into %d %s", res.Plan.Absorbed, hunkNoun(res.Plan.Absorbed), res.Plan.Commits, commitNoun(res.Plan.Commits))
for _, m := range res.Rewritten {
rep.Info("%s rewritten, was %s", rep.Branch(m.Name), rep.SHA(m.From))
}
if n := len(res.Restacked); n > 0 {
rep.Success("Restacked %d %s above %s", n, branchNoun(n), rep.Branch(res.Branch))
}
stillStaged(rep, res.Plan)
}

// renderNotices prints each notice as a warning.
func renderNotices(rep *ui.Reporter, notices []string) {
for _, n := range notices {
rep.Warn("%s", n)
}
}

// stillStaged notes what an applied absorb left in the index: hunks it
// found no commit for and files it skipped.
func stillStaged(rep *ui.Reporter, p app.AbsorbPlan) {
var parts []string
if p.Unabsorbed > 0 {
parts = append(parts, fmt.Sprintf("%d %s", p.Unabsorbed, hunkNoun(p.Unabsorbed)))
}
if n := len(p.Skipped); n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, pluralWord(n, "file", "files")))
}
if len(parts) == 0 {
return
}
rep.Warn("Still staged: %s", strings.Join(parts, ", "))
}

func hunkNoun(n int) string { return pluralWord(n, "hunk", "hunks") }
func commitNoun(n int) string { return pluralWord(n, "commit", "commits") }
51 changes: 51 additions & 0 deletions cmd/absorb_render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"bytes"
"testing"

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

func TestRenderAbsorbPlan(t *testing.T) {
var out, errOut bytes.Buffer
rep := ui.NewReporter(nil, &out, &errOut, ui.ReporterOptions{})
renderAbsorbPlan(rep, &app.AbsorbPlan{
Hunks: []app.AbsorbHunk{
{Path: "pkg/x.go", OldStart: 120, OldCount: 6, NewCount: 6, Target: &app.AbsorbTarget{SHA: "7ac32ef0123456789", Subject: "Give up after five", Branch: "retries"}},
{Path: "a\nb.txt", OldStart: 12, OldCount: 1, NewCount: 1},
{Path: "pkg/x.go", OldStart: 300, OldCount: 0, NewCount: 2, Target: &app.AbsorbTarget{SHA: "4f21abc0123456789", Subject: "Version it", Branch: "schema"}},
},
Skipped: []app.AbsorbSkipped{{Path: "docs/new.md", Reason: "new file"}},
})
want := " pkg/x.go:120-125 into 7ac32ef Give up after five (retries)\n" +
" \"a\\nb.txt\":12 not absorbed; no commit in this stack changed those lines\n" +
" pkg/x.go:after 300 into 4f21abc Version it (schema)\n" +
" docs/new.md skipped; new file\n"
if out.String() != want {
t.Errorf("out = %q\nwant %q", out.String(), want)
}
}

func TestRenderAbsorbResult(t *testing.T) {
var out, errOut bytes.Buffer
rep := ui.NewReporter(nil, &out, &errOut, ui.ReporterOptions{})
renderAbsorbResult(rep, app.AbsorbResult{
Branch: "retries",
Applied: true,
Plan: app.AbsorbPlan{Absorbed: 2, Unabsorbed: 1, Commits: 2, Skipped: []app.AbsorbSkipped{{Path: "n", Reason: "new file"}}},
Rewritten: []app.BranchMove{
{Name: "schema", From: "9b1d2e00000000000", To: "1"},
{Name: "retries", From: "7ac32ef0000000000", To: "2"},
},
Restacked: []string{"ui"},
})
wantOut := "ok: Absorbed 2 hunks into 2 commits\n schema rewritten, was 9b1d2e0\n retries rewritten, was 7ac32ef\nok: Restacked 1 branch above retries\n"
if out.String() != wantOut {
t.Errorf("out = %q\nwant %q", out.String(), wantOut)
}
if errOut.String() != "note: Still staged: 1 hunk, 1 file\n" {
t.Errorf("err = %q", errOut.String())
}
}
54 changes: 54 additions & 0 deletions cmd/absorb_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"strings"
"testing"

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

// The three ways absorb can end: a dry run that only shows the plan, a
// run with nothing it can absorb, and one that rewrites a commit.
func TestAbsorbCommandBranches(t *testing.T) {
gittest.Isolate(t)
dir := gittest.InitRepo(t)
f := fakeGh(t, dir)
gittest.WriteFile(t, dir, "f.txt", "1\n2\n3\n")
if _, _, err := runWith(t, f, "--cwd", dir, "create", "-a", "-m", "Add f"); err != nil {
t.Fatal(err)
}
oldTip := gittest.Run(t, dir, "rev-parse", "HEAD")
absorb := func(args ...string) (string, string, error) {
t.Helper()
return runWith(t, f, append([]string{"--cwd", dir, "absorb"}, args...)...)
}

// Nothing the stack changed is staged: only a new file.
gittest.WriteFile(t, dir, "new.txt", "new\n")
gittest.Run(t, dir, "add", "new.txt")
out, errOut, err := absorb()
if err != nil || !strings.Contains(out, "ok: Nothing to absorb\n") || !strings.Contains(out, "new.txt skipped; new file") {
t.Errorf("nothing to absorb: %q %q %v", out, errOut, err)
}
gittest.Run(t, dir, "reset", "-q", "new.txt")

// A dry run shows where the change goes and rewrites nothing.
gittest.WriteFile(t, dir, "f.txt", "1\ntwo\n3\n")
gittest.Run(t, dir, "add", "f.txt")
out, errOut, err = absorb("--dry-run")
if err != nil || !strings.Contains(out, "f.txt:2 into ") || strings.Contains(out, "Absorbed") || errOut != "note: Dry run; nothing was absorbed\n" {
t.Errorf("dry run: %q %q %v", out, errOut, err)
}
if gittest.Run(t, dir, "rev-parse", "HEAD") != oldTip {
t.Error("a dry run rewrote the commit")
}

// The real thing prints the result block and moves the branch.
out, errOut, err = absorb("--force")
if err != nil || !strings.Contains(out, "ok: Absorbed 1 hunk into 1 commit\n") || !strings.Contains(out, "rewritten, was "+oldTip[:7]) || errOut != "Absorbing 1 hunk into 1 commit...\n" {
t.Errorf("absorb: %q %q %v", out, errOut, err)
}
if gittest.Run(t, dir, "rev-parse", "HEAD") == oldTip || gittest.Run(t, dir, "show", "HEAD:f.txt") != "1\ntwo\n3" {
t.Error("the commit was not rewritten with the change")
}
}
9 changes: 6 additions & 3 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ is created so the parent's commit is never rewritten.`,
}

// branchNoun is "branch" or "branches" for n.
func branchNoun(n int) string {
func branchNoun(n int) string { return pluralWord(n, "branch", "branches") }

// pluralWord is one when n is 1, otherwise many.
func pluralWord(n int, one, many string) string {
if n == 1 {
return "branch"
return one
}
return "branches"
return many
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ func newRootCmd(c *cli) *cobra.Command {
root.AddCommand(
newCreateCmd(c),
newModifyCmd(c),
newAbsorbCmd(c),
newRestackCmd(c),
newContinueCmd(c),
newAbortCmd(c),
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ fails the error says what was left behind and that `modify -c` commits on it.
a `conflict` error listing the files and the `git stack continue` / `git stack abort`
commands.

**`absorb` is line geometry over zero context diffs, applied through a temporary index.** Each
staged hunk starts at HEAD and is moved backwards through the stack's commits (the current branch
and the branches below it, stopping at trunk or a merged branch) past every change that doesn't
overlap its lines, shifting its line numbers as it goes; the first change it can't get past owns
it, as does the commit that created the file. The hunk is then carried into every snapshot from
that commit up to HEAD, because a child's tree has to contain it too or the child would undo it.
Each rewritten commit's tree is patched with `git apply --cached --unidiff-zero` in a temporary
index (`pkg/git.PatchTree`), which has git check the removed lines really are there before
anything in the repository changes, and handles modes, line endings and odd paths the way git
does; the commits are made with `commit-tree`, so like a restack's replayed commits they are not
signed. Every affected branch, the current one included, moves in one `update-ref` transaction:
all or none, and moving the ref under the checkout leaves the index and working tree alone, so the
absorbed hunks simply drop out of the staged diff. Everything above the current branch then goes
through modify's upstack restack, conflict handling included. There is no absorb specific abort;
the old tips are printed and in the reflog.

**`submit --ai` drafts first, then lets gh stack submit, then fixes the PRs up.** The
alternative was to push and create PRs ourselves and only use gh stack to link them. We
didn't, because gh stack owns the push (`--force-with-lease`), the base calculation, the
Expand Down
6 changes: 6 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `stack_view` | Tree of every stack: branches, parents, PR number/state/URL/title, each branch's own `commits`, `needs_restack`, `needs_push`, current branch, untracked branches | `include_untracked`, `fresh` (default true) | read-only, open-world |
| `stack_create` | New branch on top of the current one (from trunk: new stack), commit staged changes | `message` (required), `branch`, `staging` = `all`/`update`/`none`, `use_ai` | non-destructive |
| `stack_modify` | Amend the current branch (or `mode: commit`) and restack everything above | `mode`, `staging`, `message`, `continue`, `abort` | destructive |
| `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_continue` | Finish an interrupted restack or modify once the conflicts are resolved and git added | `stage_all` | destructive |
| `stack_abort` | Give up an interrupted restack and put every moved branch back | | 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 |
Expand Down Expand Up @@ -65,6 +66,11 @@ can type in (the server has no terminal). For a commit nothing was committed; fo
left in progress with the commit rescheduled, so `stack_continue` carries on once signing works
and `stack_abort` still puts everything back. `next_steps` says which key to `ssh-add`, or how to
turn signing off.
`conflict` from `stack_absorb` means the commits were rewritten (the structured result says which
branches moved) and the restack of the branches above then stopped; `stack_continue` and
`stack_abort` apply to that restack only, the absorb itself stays. `invalid_args` from it names the
branch that needs restacking first, the branch with a merge commit it won't rewrite, or the checkout elsewhere
with uncommitted changes in the way; nothing has changed in those cases.
`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
1 change: 1 addition & 0 deletions docs/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ read as section markers rather than confetti because they never show up anywhere
|---|---|---|
| create | 🌱 | Creating branch feat-a… |
| modify | ✏️ | (modify's own work is instant; its restack uses 🧱) |
| absorb | 🧽 | Absorbing 2 hunks into 2 commits… |
| restack | 🧱 | Restacking 2 branches above feat-a… |
| submit | 🚀 | Submitting stack… |
| sync | 🔄 | Syncing with origin… |
Expand Down
Loading
Loading