Repository navigation
Rewrite a stack's commits from an absorb plan - #36
Merged
Merged
Conversation
DomBlack
added this pull request to stack #38
October 15, 2026 12:10
absorb rewrites commits it never checks out, so it needs to patch one commit's tree on its own and have git check that the lines being replaced really are what the patch says they are, before anything in the repository moves. Doing that through git apply rather than editing blobs ourselves also means modes, line endings and odd file names behave the way git makes them behave. PatchTree reads the commit's tree into a temporary index in the git dir, runs apply --cached --unidiff-zero against it and writes the tree back out; the temporary index goes whether or not it worked, and a patch git won't take comes back as a PatchError with git's output. Whitespace settings are ignored (--whitespace=nowarn) so an apply.whitespace=fix in someone's config can't quietly change the lines being absorbed. Alongside it are the zero context diff readers for a commit and for the index, with the a/ b/ prefixes forced whatever diff.noprefix says, and a check for merge commits in a range.
The first half of absorb: work out which commits are fair game, read the diffs and hand them to the planner, and stop there on a dry run or a no. The commits a hunk can land in are those of the current branch and the branches below it, down to trunk or the first branch whose PR has merged (a merged branch is never rewritten, and nothing under it either). Every one of those branches bar the bottom of the stack has to be sitting on its parent, otherwise the commit ranges don't mean anything, so a branch that needs restacking refuses with the restack to run first; the bottom branch is allowed to be behind trunk and its own commits are found the way restack finds them. A merge commit anywhere in there refuses too, since absorb only rewrites linear history. Staging happens before any of that, through the same -a / -u / -p path create and modify use, so a dry run with -u still stages (that's what you asked for; the dry run only protects the commits). The plan is shown through a Preview hook before the question is asked, so the CLI can print it and the MCP tool can read it off the result, and the question itself only comes on a terminal without --force. Applying it is the next commit.
The bottom branch's own commits were taken from its recorded base whenever that was still in its history, which is wrong once the branch has been rebased onto a newer trunk by hand: the base stays where it was, so every trunk commit since then counted as one of the branch's and could have been picked as somewhere to put a hunk. Restack checks the trunk tip first and only falls back to the base, so do the same here.
The second half of absorb: once the plan is accepted, the change has to end up in the destination commit and in every tree above it (a child's tree that doesn't contain it would just undo it), and the branches that point at those commits have to move together or not at all. So this makes every rewritten commit as an object first, oldest to newest, each on the previous rewritten one, by patching the original commit's tree through PatchTree and committing it with the original message, author and date. Nothing in the repository has moved by then, so a patch git won't take (which would be our bug) stops with nothing changed. Then every affected branch, the current one included, moves in one update-ref transaction with the old tips as expected values. Moving the ref under the checkout leaves the index and working tree exactly as they were, which is the point: the absorbed hunks are now in HEAD and simply drop out of the staged diff, while anything not absorbed stays staged. A branch checked out in another worktree is reset there if the tree is clean and refused up front if it isn't; unlike restack, absorb can't skip a branch, since everything above depends on it. Heads and bases go into the metadata and everything above the current branch is restacked the way modify does it, conflict handling included; a conflict there leaves the absorb in place, as the result says.
After an absorb the index usually still holds the hunks nothing could take, and often there are unstaged edits too, so the one time a branch above then conflicts the restack refuses to start its rebase in a dirty tree. That came back as "needs a clean working tree, then try again", which reads as run absorb again; absorb was done, it's the restack that's missing. The error now says the absorb happened and which branches above weren't restacked, and the step is to commit or stash and run git stack restack. The restack runs under that command name too, so anything it records or says to re-run points at the right thing. A real conflict still comes back as before, with continue and abort.
git apply looks for a hunk's removed lines around the line number it was given and quietly applies at an offset when it finds them elsewhere, so the check absorb leans on (the lines being replaced really are where the planner said) was weaker than it looked: a planner mistake over a blank line or a closing brace could have landed in the nearest lookalike rather than failing. A correct plan always matches at the stated line, since git tries that position first, so PatchTree now runs apply with -v and treats an offset in its output as the patch not fitting. While in there: the "a branch moved while absorbing" error now only claims that when it was the ref transaction's expected values that failed, and a held ref lock says so instead; the "not restacked" message after a failed restack above names only the branches that didn't move; and diffs are read with --submodule=short so a diff.submodule=diff setting can't put a submodule's files in the plan.
The check that a hunk applied where the planner said it would went by the word "offset" anywhere in git's verbose output, and git prints the file's path on every successful apply, so a file called offset.go could never be absorbed. Match the hunk message itself.
The heads and bases update shared with restack moves into one helper rather than two copies, the "no longer exists" refusals say to run git stack sync, and PatchError's comment matches what it carries (any apply failure, with git's output). The tests now pin the cases the reviews listed: a rebase already in progress, a current branch with no commits of its own, three branches moving at once, untracked files and an unaffected dirty checkout elsewhere being allowed through, the restacked list and c's tip after an abort, every rewritten commit's author, and the temporary index going when read-tree itself fails. The refusals check their messages and next steps, not just the kind.
DomBlack
force-pushed
the
absorb-engine
branch
from
October 15, 2026 12:21
f8c7442 to
2714fa0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of the three absorb PRs: the git plumbing and the use case that turns a plan into rewritten commits. The use case decides which commits are fair game (the current branch and the branches below it, down to trunk or the first branch whose PR has merged, each sitting on its parent, no merge commits), reads the staged diff and each commit's diff, hands them to the planner, shows the plan, and then either stops (a dry run, nothing placeable, or a no at the prompt) or applies it.
Applying it never checks anything out. Each rewritten commit's tree is patched through a temporary index with git apply --cached --unidiff-zero and committed with commit-tree on the previous rewritten commit, keeping the message, author and date. Going through git apply rather than editing blobs ourselves means git checks the removed lines really are what the patch says before anything in the repository moves; while reviewing this I found git quietly applies at an offset if it finds the lines nearby, so apply runs with -v and an offset counts as the patch not fitting (a correct plan always matches at the stated line). Modes, line endings and odd file names behave the way git makes them behave for free.
Once every object exists, every affected branch moves in one update-ref transaction with the old tips as expected values, the current branch included. Moving the ref under the checkout leaves the index and working tree exactly as they were, so the absorbed hunks simply drop out of the staged diff and anything not absorbed stays staged. A branch checked out in another worktree is reset there if clean and refused up front if not (absorb can't skip a branch the way restack can, everything above depends on it). Then the branches above go through the same restack modify uses, conflicts and all; a conflict there leaves the absorb in place, and if the restack can't even start because the tree is dirty (the normal state after an absorb, with leftover hunks staged) the error says the absorb happened and points at git stack restack rather than "try again".
There's no absorb specific abort. The old tips are in the result and the reflog; the README in the next PR has the recipe for putting things back.