Repository navigation
Parse diffs and plan where each staged hunk belongs - #35
Merged
Merged
Conversation
git stack absorb needs to know exactly which lines a staged change touches and which lines each commit in the stack changed, and the honest unit for that is a contiguous run of changed lines, not git's default hunk with three lines of context either side (which happily glues two unrelated edits together). So this reads git's own diffs with no context and keeps each run as one unit, including the "no newline at end of file" marker, quoted paths and renames. Nothing uses it yet; the planner that decides which commit each run belongs to and the writer that turns a run back into a patch come next.
On the --- and +++ lines git follows a file name that contains a space with a tab, so "has space.txt" came through as "has space.txt<tab>" and a quoted name never got unquoted at all. Strip it before reading the path.
A staged fix belongs to the commit that last changed those lines, and the only honest way to find that commit is to walk the stack backwards from HEAD, shifting the hunk's line numbers past every change that doesn't touch it, until one does. There's no guessing from commit messages or file names here; it's line ranges, and the commit that created the file counts as touching every line in it. So each staged hunk starts at the newest commit and tries to get past each of that commit's hunks for the same file, newest first. Disjoint ranges pass (with the line numbers moved up or down by what the older change added or removed); the first overlap makes that commit the destination, and a hunk that gets past everything is reported as not absorbed and left alone. One run of changed lines that spans lines two commits changed goes to the newer one, since that's the first overlap the walk meets. The hunk is also recorded for every newer commit it walked through, in that commit's own coordinates and path (renames are followed back), because those trees have to contain the change too or the child commit would undo it.
Once the planner has decided which hunks go into which commit, each of those commits needs a patch git apply will take against that commit's tree. The planner only tracks a hunk's old side line number, and git apply places an insertion by the new side number, so if two hunks land in the same file the second one's new side has to account for the first (an insertion with a stale number lands after the previous insertion's lines rather than after the old line it was meant to follow). So this sorts a file's hunks by old line, with an insertion after line k going after an edit of line k, and renumbers the new side cumulatively as it writes them. Paths are quoted the way git reads them (C style with octal for anything outside printable ASCII), on both sides of the header, since the patch is always applied at the path the file has in that commit's tree.
DomBlack
added this pull request to stack #38
October 15, 2026 12:10
Small things the reviews left for later: the planner's loop variable no longer shadows the staged parameter and the deleted file check in the path lookup goes (the path is never empty there), the patch writer quotes each side once, and atoi takes its default plainly. The tests now cover the cases that were only argued about: an insertion at the top of a file, a backslash in a path, a single byte path, a deletion straight after a context line, a marker after a context line, and git's quoted header for a non ASCII mode only change. The hunk ordering test checks the exact patch rather than two indexes.
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.
This is the first of three PRs adding git stack absorb, the command that takes whatever is staged and folds each run of changed lines into the commit in the stack that last touched those lines. This one is the thinking part with no git in it at all: a new pkg/absorb package that reads diffs as text, decides where each hunk goes, and writes the patches the later PRs apply.
The decision is line geometry, nothing cleverer. Each staged hunk starts at HEAD and walks backwards through the stack's commits; when a commit didn't touch its lines the hunk passes with its line numbers shifted by whatever that commit added or removed, and the first commit whose change overlaps it (or the commit that created the file) is where it goes. One thing to know is that a single run of changed lines spanning lines two commits changed goes to the newer one, since that's the first overlap the walk meets; it isn't split and it isn't called ambiguous. A hunk that gets past everything is reported as not absorbed and left alone. New files, deleted files, binaries and submodules are skipped with a reason rather than guessed at.
The hunk is also recorded for every newer commit it walked through, in that commit's own coordinates (renames followed back), because a child's tree has to contain the change too or the child would undo it. The writer then renders each commit's patch with the new side line numbers recomputed cumulatively per file, which matters because git apply places an insertion by its new side number; with a stale number the second insertion in a file lands after the first one's lines rather than after the old line it was meant to follow. Paths are quoted the way git reads them.
It works with zero context diffs (git diff -U0), so every hunk is exactly one contiguous change, and the parser splits on any context line it does see just in case.