diff --git a/README.md b/README.md index 1ab3616..22563bd 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,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 ` (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 + 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/cmd/absorb.go b/cmd/absorb.go new file mode 100644 index 0000000..62f5dd8 --- /dev/null +++ b/cmd/absorb.go @@ -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 " (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 + + # 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") } 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/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/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/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 diff --git a/docs/mcp.md b/docs/mcp.md index 38da417..45e1f6e 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 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: 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/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index ceafd99..a45ae6e 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` | @@ -35,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`). + 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 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 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/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..d580d7c 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -212,8 +212,10 @@ 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"}, {"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..6d52043 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) } @@ -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() @@ -612,3 +647,46 @@ 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") + } + + 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) + } + 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") + } + 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", 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: "🔄",