From 3ce449f956251a16a9d6a5663e3d2147231152d9 Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Thu, 15 Oct 2026 12:41:54 +0100 Subject: [PATCH 1/5] Add git stack absorb Fixing a review comment on the third PR down currently means git down twice, git modify -p, git top, and getting the hunk selection right by hand each time. absorb does the placing: stage the fixes, and each run of changed lines goes into the commit in this stack that last changed those lines, with everything above rewritten and restacked. On a terminal it shows the plan (file and lines, the commit and branch each hunk goes to, what it couldn't place and why) and asks before rewriting anything; --dry-run just shows the plan, --force skips the question, and -a / -u / -p stage first the same as create and modify. The result names each rewritten branch with its old tip, so a wrong guess can be put back with git branch -f and a restack, and anything left in the index is said so at the end rather than silently. git absorb and git ab are installed as aliases like the other commands. --- cmd/absorb.go | 150 ++++++++++++++++++++++++++++++++++++ cmd/absorb_render_test.go | 51 ++++++++++++ cmd/root.go | 1 + docs/style.md | 1 + pkg/gitconfig/aliases.go | 2 + pkg/install/install_test.go | 2 +- pkg/ui/report.go | 1 + 7 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 cmd/absorb.go create mode 100644 cmd/absorb_render_test.go diff --git a/cmd/absorb.go b/cmd/absorb.go new file mode 100644 index 0000000..a00b1a2 --- /dev/null +++ b/cmd/absorb.go @@ -0,0 +1,150 @@ +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, so +"git branch -f " followed by "git stack restack" undoes it.`, + 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 { + for _, n := range res.Notices { + rep.Warn("%s", n) + } + 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) { + for _, n := range res.Notices { + rep.Warn("%s", n) + } + 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) +} + +// 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") } + +func pluralWord(n int, one, many string) string { + if n == 1 { + return one + } + return many +} diff --git a/cmd/absorb_render_test.go b/cmd/absorb_render_test.go new file mode 100644 index 0000000..a4a975f --- /dev/null +++ b/cmd/absorb_render_test.go @@ -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()) + } +} diff --git a/cmd/root.go b/cmd/root.go index d1f3bb1..6fd3b59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -321,6 +321,7 @@ func newRootCmd(c *cli) *cobra.Command { root.AddCommand( newCreateCmd(c), newModifyCmd(c), + newAbsorbCmd(c), newRestackCmd(c), newContinueCmd(c), newAbortCmd(c), diff --git a/docs/style.md b/docs/style.md index b43355d..2100d27 100644 --- a/docs/style.md +++ b/docs/style.md @@ -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… | diff --git a/pkg/gitconfig/aliases.go b/pkg/gitconfig/aliases.go index 676e76c..44c008c 100644 --- a/pkg/gitconfig/aliases.go +++ b/pkg/gitconfig/aliases.go @@ -28,6 +28,7 @@ func (a Alias) Value() string { return a.Command } var DefaultAliases = []Alias{ {"create", "stack create"}, {"modify", "stack modify"}, + {"absorb", "stack absorb"}, {"restack", "stack restack"}, {"submit", "stack submit"}, {"sync", "stack sync"}, @@ -39,6 +40,7 @@ var DefaultAliases = []Alias{ {"abort", "stack abort"}, {"c", "stack create"}, {"m", "stack modify"}, + {"ab", "stack absorb"}, {"rs", "stack restack"}, {"ss", "stack submit"}, {"u", "stack up"}, diff --git a/pkg/install/install_test.go b/pkg/install/install_test.go index 26acaf6..60a79f15 100644 --- a/pkg/install/install_test.go +++ b/pkg/install/install_test.go @@ -191,7 +191,7 @@ func TestInstallAndUninstall(t *testing.T) { } func gitconfigDefaults() []string { - return []string{"create", "modify", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "c", "m", "rs", "ss", "u", "d", "t", "b", "co"} + return []string{"create", "modify", "absorb", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "c", "m", "ab", "rs", "ss", "u", "d", "t", "b", "co"} } func TestSkillOptInIsAsked(t *testing.T) { diff --git a/pkg/ui/report.go b/pkg/ui/report.go index c90a9d3..5583fb3 100644 --- a/pkg/ui/report.go +++ b/pkg/ui/report.go @@ -107,6 +107,7 @@ const ( var phaseEmoji = map[app.Phase]string{ app.PhaseCreate: "🌱", app.PhaseModify: "✏️", + app.PhaseAbsorb: "🧽", app.PhaseRestack: "🧱", app.PhaseSubmit: "🚀", app.PhaseSync: "🔄", From 11263c4ff14b8a9737aaf8091e19f47bf2110f7d Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Thu, 15 Oct 2026 12:44:31 +0100 Subject: [PATCH 2/5] Give agents stack_absorb An agent fixing a review comment on a PR lower in the stack shouldn't have to navigate down, amend and come back up any more than a person should, so the absorb use case gets a tool. It takes the same staging choice as the others and dry_run for the plan; the result carries the plan either way, i.e. where every hunk went and what stayed staged and why, and when the restack above the rewritten commits hits a conflict the result comes back alongside the conflict error so the agent can see the rewrite happened. The server instructions, the installed skill and the MCP docs describe it. --- docs/mcp.md | 6 ++++++ pkg/install/skill/SKILL.md | 5 +++-- pkg/mcp/instructions.go | 2 +- pkg/mcp/server.go | 1 + pkg/mcp/server_test.go | 41 +++++++++++++++++++++++++++++++++++++- pkg/mcp/tools.go | 35 ++++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 38da417..9aa8f08 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 | @@ -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 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: diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index ceafd99..3c730cf 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -19,6 +19,7 @@ that touches a stack: | See the stacks, PR state, what needs a restack | `stack_view` | | Start a branch on top of the current one (from trunk: a new stack) | `stack_create` — you write `message` | | Change the current branch's commit and rebase everything above | `stack_modify` | +| Fold staged fixes into whichever commits of the stack last touched those lines | `stack_absorb` (`dry_run: true` to see the plan) | | Rebase the stack after edits lower down | `stack_restack` | | Move around | `stack_navigate` (`up`/`down`/`top`/`bottom` or `branch`) | | Push and open/update PRs | `stack_submit` — you write `pull_requests` `{branch: {title, body}}`; ready for review unless `draft: true` | @@ -42,6 +43,6 @@ the one calling. ## CLI equivalents -`git stack create -m "…"`, `git stack modify -a`, `git stack restack`, `git stack up/down/top/bottom`, -`git stack checkout`, `git stack submit --no-edit`, `git stack sync -f`. Aliases: `git c`, `git m`, +`git stack create -m "…"`, `git stack modify -a`, `git stack absorb`, `git stack restack`, `git stack up/down/top/bottom`, +`git stack checkout`, `git stack submit --no-edit`, `git stack sync -f`. Aliases: `git c`, `git m`, `git ab`, `git rs`, `git u`, `git d`, `git t`, `git b`, `git co`, `git ss`. diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 045eba3..23ac6b2 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); stack_modify amends the current branch (or adds a commit) and restacks everything above it; 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 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); 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_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 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 c46bab8..d2ff427 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -214,6 +214,7 @@ var cliToTool = []struct{ from, to string }{ {"git stack restack --abort", "call stack_restack with abort: true"}, {"git stack modify --continue", "call stack_modify with continue: true"}, {"git stack modify --abort", "call stack_modify with abort: true"}, + {"git stack absorb", "stack_absorb"}, {"git stack continue", "call stack_continue"}, {"git stack abort", "call stack_abort"}, {"git stack top", "stack_navigate {direction: \"top\"}"}, diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index d3d4f79..3fbb186 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -219,7 +219,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_restack", "stack_navigate", "stack_submit", "stack_sync"} { + for _, name := range []string{"stack_view", "stack_create", "stack_modify", "stack_absorb", "stack_restack", "stack_navigate", "stack_submit", "stack_sync"} { if got[name] == nil { t.Errorf("missing tool %s", name) } @@ -612,3 +612,42 @@ func mustAbs(t *testing.T, p string) string { func (f *fakeForge) PullRequestURL(_ context.Context, _ git.Repo, n int) (string, error) { return fmt.Sprintf("https://forge.test/o/r/pull/%d", n), nil } + +// stack_absorb plans with dry_run and applies without one; the result +// carries the plan either way. +func TestAbsorb(t *testing.T) { + h := newHarness(t) + defer h.assertNoStdout() + oldA := gittest.Run(t, h.dir, "rev-parse", "a") + gittest.WriteFile(t, h.dir, "a.txt", "A") + gittest.Run(t, h.dir, "add", "a.txt") + + var res app.AbsorbResult + h.call("stack_absorb", map[string]any{"dry_run": true}, &res) + if res.Applied || !res.DryRun || len(res.Plan.Hunks) != 1 || res.Plan.Hunks[0].Target == nil || res.Plan.Hunks[0].Target.Branch != "a" { + t.Fatalf("dry run = %+v", res) + } + if gittest.Run(t, h.dir, "rev-parse", "a") != oldA { + t.Error("dry run moved a") + } + + h.call("stack_absorb", nil, &res) + if !res.Applied || len(res.Rewritten) != 2 || res.Rewritten[0].Name != "a" || res.Rewritten[1].Name != "b" { + t.Fatalf("absorb = %+v", res) + } + if got := gittest.Run(t, h.dir, "show", "a:a.txt"); got != "A" { + t.Errorf("a:a.txt = %q", got) + } + if _, err := gittest.RunErr(t, h.dir, "diff", "--cached", "--quiet"); err != nil { + t.Error("index should be clean") + } + if h.current() != "b" { + t.Errorf("current = %s", h.current()) + } + + // Nothing staged is a structured error with next steps in tool terms. + te := h.toolErr("stack_absorb", nil) + if te.Code != "invalid_args" || len(te.NextSteps) == 0 { + t.Errorf("error = %+v", te) + } +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 10f2088..7d33f0a 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -184,6 +184,35 @@ func (s *Server) modify(ctx context.Context, req *mcp.CallToolRequest, in modify return nil, res, nil } +// --- stack_absorb ------------------------------------------------------- + +type absorbInput struct { + repoArg + Staging string `json:"staging,omitempty" jsonschema:"what to stage first: all (git add -A), update (git add -u) or none (default). The index is what gets absorbed"` + DryRun bool `json:"dry_run,omitempty" jsonschema:"plan only: report where each staged hunk would go without rewriting any commit"` +} + +func (s *Server) absorb(ctx context.Context, req *mcp.CallToolRequest, in absorbInput) (*mcp.CallToolResult, app.AbsorbResult, error) { + staging, err := parseStaging(in.Staging) + if err != nil { + return nil, app.AbsorbResult{}, wrapErr(err) + } + repo, a, err := s.appFor(ctx, req, in.RepoPath) + if err != nil { + return nil, app.AbsorbResult{}, wrapErr(err) + } + res, err := a.Absorb(ctx, repo, app.AbsorbOptions{Staging: staging, DryRun: in.DryRun, Force: true}) + if err != nil && res.Applied { + // The commits were rewritten and the restack above them hit a + // conflict: keep both, as stack_sync does. + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: wrapErr(err).Error()}}}, res, nil + } + if err != nil { + return nil, app.AbsorbResult{}, wrapErr(err) + } + return nil, res, nil +} + // --- stack_continue / stack_abort --------------------------------------- type continueInput struct { @@ -444,6 +473,12 @@ func (s *Server) registerTools() { Description: "Amend the current branch's commit (or add a new commit with mode: commit) with the staged changes, then rebase every branch above it. On conflicts returns code conflict with the files; resolve, git add, then call again with continue: true.", Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, }, s.modify) + mcp.AddTool(s.mcp, &mcp.Tool{ + Name: "stack_absorb", + Title: "Absorb staged changes into the stack's commits", + Description: "Fold each staged hunk into the commit of the current stack that last changed those lines (the current branch's commits and the branches below it, never trunk or a merged branch), rewrite those commits and everything above them keeping messages and authors, then restack the branches above the current one. The result's plan says where every hunk went; hunks no commit touched, new files and deleted files stay staged. Pass dry_run to see the plan without changing anything. Refuses with invalid_args when a branch below needs restacking first. On a conflict in the restack afterwards the commits are already rewritten and the result comes back with code conflict; resolve, git add, then stack_continue.", + Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, + }, s.absorb) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_restack", Title: "Restack", From 46a487a536e574ea789ff21900eaf1be82584e00 Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Thu, 15 Oct 2026 12:46:51 +0100 Subject: [PATCH 3/5] Document absorb The README gets a section for git stack absorb alongside the other commands, with the two things worth knowing before you trust it: a change to lines no commit in the stack touched stays staged (as does a new or deleted file), and one run of changed lines that spans lines two commits changed goes into the newer one rather than being split. It also says how to put a branch back if it guessed wrong, since the old tips are printed. The architecture notes get the why: hunks are placed by line geometry over zero context diffs, each rewritten commit's tree is patched through git apply in a temporary index so git checks the removed lines before anything moves, every affected branch moves in one update-ref transaction (the current one included, which is what keeps the index and working tree alone), and the restack above is modify's. --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++-- docs/architecture.md | 16 +++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ab3616..5a8a82a 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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`): @@ -254,6 +256,47 @@ 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, i.e. 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, so if it picked the +wrong commit, `git branch -f billing-webhook-schema 9b1d2e0` followed by `git restack` puts things +back. 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` diff --git a/docs/architecture.md b/docs/architecture.md index e6e9293..2f5fd00 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 From 8d762be1e9a95264ded8ec4cbe458a6dc05b655c Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Thu, 15 Oct 2026 13:04:52 +0100 Subject: [PATCH 4/5] Fix the undo recipe for absorb The README and the help said to put the lowest rewritten branch back with git branch -f and restack. That loses the fix, since restack replays the current branch's rewritten commits (which no longer carry the hunk) onto the old tip, and git refuses branch -f on the checked out branch anyway. The recipe that works is git reset --soft to the current branch's old tip (the hunks come back staged), git branch -f for each lower branch, then git stack restack. Also: the MCP docs say the merge commit refusal names the branch, as the code does; next steps that say git stack restack are rewritten to stack_restack for agents; and the skill says stack_continue is what follows a stack_absorb conflict. --- README.md | 9 +++++---- cmd/absorb.go | 6 ++++-- docs/mcp.md | 2 +- pkg/install/skill/SKILL.md | 2 +- pkg/mcp/server.go | 1 + 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5a8a82a..86c2744 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Flags: > 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, i.e. the fix for a review comment three PRs down goes into that PR's +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 @@ -286,9 +286,10 @@ not split between them. ⚠ Still staged: 1 hunk ``` -On a terminal you see the plan and get asked first. The old tips are printed, so if it picked the -wrong commit, `git branch -f billing-webhook-schema 9b1d2e0` followed by `git restack` puts things -back. Every branch it needs to rewrite has to be sitting on its parent already (run `git restack` +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 ` (your changes +come back staged), `git branch -f ` 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: diff --git a/cmd/absorb.go b/cmd/absorb.go index a00b1a2..5076a7e 100644 --- a/cmd/absorb.go +++ b/cmd/absorb.go @@ -31,8 +31,10 @@ 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, so -"git branch -f " followed by "git stack restack" undoes it.`, +-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 " (your changes come back staged), "git branch -f " 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 diff --git a/docs/mcp.md b/docs/mcp.md index 9aa8f08..45e1f6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -69,7 +69,7 @@ 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 merge commit it won't rewrite, or the checkout elsewhere +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. diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index 3c730cf..ae8cc86 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -36,7 +36,7 @@ the one calling. in the middle. To change something lower down, `stack_navigate` there, edit, then `stack_modify` (it restacks the rest). - On `code: "conflict"`: resolve the listed files, `git add` them, then call the same tool - again with `continue: true` (or `abort: true`). + again with `continue: true` (or `abort: true`) (after `stack_absorb`, call `stack_continue` or `stack_abort`). - Plain git is fine for reading (`git status`, `git diff`, `git log`) and for staging files. - Run `stack_view` before `stack_submit` so you know which branches get new PRs and need titles and bodies. diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index d2ff427..d580d7c 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -212,6 +212,7 @@ func (e *jsonError) Unwrap() error { return e.cause } var cliToTool = []struct{ from, to string }{ {"git stack restack --continue", "call stack_restack with continue: true"}, {"git stack restack --abort", "call stack_restack with abort: true"}, + {"git stack restack", "stack_restack"}, {"git stack modify --continue", "call stack_modify with continue: true"}, {"git stack modify --abort", "call stack_modify with abort: true"}, {"git stack absorb", "stack_absorb"}, From d6363400e0953153eb4e384cc90dedcdc8ac7cf6 Mon Sep 17 00:00:00 2001 From: Dominic Black Date: Thu, 15 Oct 2026 13:19:15 +0100 Subject: [PATCH 5/5] Tidy up the absorb command and docs after review The notices loop that was printed in two places is one helper, the plural helpers live together, the command's branching (dry run, nothing to absorb, an applied absorb) has a test, and stack_absorb's test covers the result coming back alongside a conflict and checks b's tip moved. The skill's conflict rule reads as one sentence, its CLI line is wrapped, and the two long README lines are too. --- README.md | 10 +++---- cmd/absorb.go | 22 +++++++--------- cmd/absorb_test.go | 54 ++++++++++++++++++++++++++++++++++++++ cmd/modify.go | 9 ++++--- pkg/install/skill/SKILL.md | 10 ++++--- pkg/mcp/server_test.go | 39 +++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 cmd/absorb_test.go diff --git a/README.md b/README.md index 86c2744..22563bd 100644 --- a/README.md +++ b/README.md @@ -286,11 +286,11 @@ not split between them. ⚠ 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 ` (your changes -come back staged), `git branch -f ` 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. +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 ` (your changes come +back staged), `git branch -f ` 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 diff --git a/cmd/absorb.go b/cmd/absorb.go index 5076a7e..62f5dd8 100644 --- a/cmd/absorb.go +++ b/cmd/absorb.go @@ -63,9 +63,7 @@ tip>" for each other branch it rewrote, then "git stack restack".`, return err } if !res.Applied { - for _, n := range res.Notices { - rep.Warn("%s", n) - } + renderNotices(rep, res.Notices) switch { case res.Plan.Absorbed == 0: rep.Success("Nothing to absorb") @@ -112,9 +110,7 @@ func hunkLines(h app.AbsorbHunk) string { // renderAbsorbResult prints what an applied absorb did. func renderAbsorbResult(rep *ui.Reporter, res app.AbsorbResult) { - for _, n := range res.Notices { - rep.Warn("%s", n) - } + 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)) @@ -125,6 +121,13 @@ func renderAbsorbResult(rep *ui.Reporter, res app.AbsorbResult) { 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) { @@ -143,10 +146,3 @@ func stillStaged(rep *ui.Reporter, p app.AbsorbPlan) { func hunkNoun(n int) string { return pluralWord(n, "hunk", "hunks") } func commitNoun(n int) string { return pluralWord(n, "commit", "commits") } - -func pluralWord(n int, one, many string) string { - if n == 1 { - return one - } - return many -} diff --git a/cmd/absorb_test.go b/cmd/absorb_test.go new file mode 100644 index 0000000..3dab2ab --- /dev/null +++ b/cmd/absorb_test.go @@ -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") + } +} diff --git a/cmd/modify.go b/cmd/modify.go index 55acc21..70f90c5 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -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 } diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index ae8cc86..a45ae6e 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -36,13 +36,15 @@ the one calling. in the middle. To change something lower down, `stack_navigate` there, edit, then `stack_modify` (it restacks the rest). - On `code: "conflict"`: resolve the listed files, `git add` them, then call the same tool - again with `continue: true` (or `abort: true`) (after `stack_absorb`, call `stack_continue` or `stack_abort`). + again with `continue: true` (or `abort: true`); after `stack_absorb` that is `stack_continue` + or `stack_abort`. - Plain git is fine for reading (`git status`, `git diff`, `git log`) and for staging files. - Run `stack_view` before `stack_submit` so you know which branches get new PRs and need titles and bodies. ## CLI equivalents -`git stack create -m "…"`, `git stack modify -a`, `git stack absorb`, `git stack restack`, `git stack up/down/top/bottom`, -`git stack checkout`, `git stack submit --no-edit`, `git stack sync -f`. Aliases: `git c`, `git m`, `git ab`, -`git rs`, `git u`, `git d`, `git t`, `git b`, `git co`, `git ss`. +`git stack create -m "…"`, `git stack modify -a`, `git stack absorb`, `git stack restack`, +`git stack up/down/top/bottom`, `git stack checkout`, `git stack submit --no-edit`, +`git stack sync -f`. Aliases: `git c`, `git m`, `git ab`, `git rs`, `git u`, `git d`, `git t`, +`git b`, `git co`, `git ss`. diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 3fbb186..6d52043 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -528,6 +528,41 @@ func TestSyncConflictKeepsTheResult(t *testing.T) { } } +// A conflict restacking the branches above an absorb is an error, but the +// absorb itself happened and the result comes back with it. +func TestAbsorbConflictAboveKeepsTheResult(t *testing.T) { + h := newHarness(t) + defer h.assertNoStdout() + // c changes a.txt too, which clashes with the edit absorbed into a. + gittest.Run(t, h.dir, "switch", "-q", "-c", "c") + gittest.Commit(t, h.dir, "a.txt", "c's version", "feat: c") + gittest.Run(t, h.dir, "switch", "-q", "b") + g := h.backend.Graph() + g.Stacks[0].Branches = append(g.Stacks[0].Branches, stack.Branch{Name: "c"}) + gittest.WriteFile(t, h.dir, "a.txt", "A") + gittest.Run(t, h.dir, "add", "a.txt") + + res, text := h.call("stack_absorb", nil, nil) + if !res.IsError { + t.Fatalf("want a tool error, got %+v", res.StructuredContent) + } + var te toolError + if err := json.Unmarshal([]byte(text), &te); err != nil || te.Code != stack.KindConflict.Code() { + t.Errorf("error = %q %v", text, err) + } + b, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + var ab app.AbsorbResult + if err := json.Unmarshal(b, &ab); err != nil { + t.Fatalf("structured result %s: %v", b, err) + } + if !ab.Applied || len(ab.Rewritten) != 2 || ab.Rewritten[0].Name != "a" || ab.Rewritten[1].Name != "b" { + t.Errorf("absorb = %+v", ab) + } +} + func TestRepoResolution(t *testing.T) { h := newHarness(t) defer h.assertNoStdout() @@ -631,6 +666,7 @@ func TestAbsorb(t *testing.T) { t.Error("dry run moved a") } + oldB := gittest.Run(t, h.dir, "rev-parse", "b") h.call("stack_absorb", nil, &res) if !res.Applied || len(res.Rewritten) != 2 || res.Rewritten[0].Name != "a" || res.Rewritten[1].Name != "b" { t.Fatalf("absorb = %+v", res) @@ -638,6 +674,9 @@ func TestAbsorb(t *testing.T) { if got := gittest.Run(t, h.dir, "show", "a:a.txt"); got != "A" { t.Errorf("a:a.txt = %q", got) } + if gittest.Run(t, h.dir, "rev-parse", "b") == oldB || res.Rewritten[1].From != oldB { + t.Error("b's tip did not move") + } if _, err := gittest.RunErr(t, h.dir, "diff", "--cached", "--quiet"); err != nil { t.Error("index should be clean") }