Skip to content

Parse diffs and plan where each staged hunk belongs - #35

Merged
DomBlack merged 5 commits into
mainfrom
absorb-planner
Oct 15, 2026
Merged

DomBlack merged 5 commits into
mainfrom
absorb-planner

Conversation

@DomBlack

@DomBlack DomBlack commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

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.

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.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 12:10
@DomBlack
DomBlack added this pull request to stack #38 October 15, 2026 12:10
@DomBlack DomBlack changed the title absorb planner Parse diffs and plan where each staged hunk belongs Oct 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@DomBlack
DomBlack merged commit 22941d3 into main Oct 15, 2026
5 of 6 checks passed
@DomBlack
DomBlack deleted the absorb-planner branch October 15, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants