Skip to content

Teach the metadata adapter and restack engine about reshapes - #40

Merged
DomBlack merged 8 commits into
mainfrom
reshape-engine
Oct 15, 2026
Merged

DomBlack merged 8 commits into
mainfrom
reshape-engine

Conversation

@DomBlack

@DomBlack DomBlack commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Reordering a stack, or moving a branch to another stack, has meant leaving git-stack for gh stack's interactive modify and then restacking by hand. This is the groundwork for doing it natively; the commands themselves come in the two PRs above this one.

gh stack keeps a stack as a flat list of branches, so changing its shape is a metadata edit followed by a restack. Two things stood in the way. The metadata editor couldn't add a stack (a branch moved onto trunk, or branches adopted by hand) or forget the id GitHub gave a stack, which matters because GitHub's stacks API can only create or append, so a reordered stack has to be dissolved and made again at the next submit. And the restack planner called a branch "in place" whenever its parent's tip was in its history, which after a reorder is true of a branch that still carries the commits of the branch that used to sit below it. The planner now also looks at the recorded base and replays from it when the base sits above the new parent; one knock on effect is that a child of a branch sync deleted (closed PR) no longer keeps that branch's commits, which is what already happened whenever trunk had moved.

The rest is plumbing for the reshape driver: a snapshot of the stacks being changed that travels with an interrupted restack, so git stack abort puts the order, membership, bases and remote ids back as well as the refs; queued segments, so a move that leaves branches behind restacks both halves as one operation that continue finishes; and a repo wide list of GitHub stacks to dissolve. While reviewing this I found that branch records in gh stack's file (the pull request record and anything we don't model) were looked up per stack record, so a branch moving between stacks would have lost them; the adapter now pools them across the whole file. A branch checked out with uncommitted changes in another worktree refuses a reshape outright rather than being skipped, because a skipped branch would keep its old parent's commits and lose its own on the next restack.

Copilot AI balanced review requested due to automatic review settings October 15, 2026 14:56
@DomBlack
DomBlack added this pull request to stack #43 October 15, 2026 14:56
@DomBlack DomBlack changed the title reshape engine Teach the metadata adapter and restack engine about reshapes 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.

Copilot AI balanced review requested due to automatic review settings October 15, 2026 15:19

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.

Reordering and moving branches needs two things the metadata editor could not do: start a new stack (a branch moved onto trunk, or branches adopted into a stack) and forget the id GitHub gave a stack, so the next submit creates it again instead of trying to update one the API will not reorder.

Update now accepts stacks appended to the graph and writes them to the current worktree's file (the main checkout's otherwise, or a fresh file when the repository has none yet), and clears or sets a record's id and number to match the graph. Existing stacks still can't be removed or reordered.
Update only locked directories that already had a metadata file, so the file it creates for a repository's first stack was written with no lock held and a second writer at the same moment could drop it. The common dir is now always locked. The cleared id test also reads the file back instead of matching text.
Changing the shape of a stack is a metadata edit followed by a restack, but the engine assumed a branch whose parent's tip is already in its history is in place. After a reorder that is wrong: trunk is in the lower branch's history, yet the branch still carries the commits of the branch that used to be below it. The planner now also looks at the recorded base, and replays from it when the base sits above the new parent.

The rest is the plumbing a reshape driver needs: a snapshot of the stacks it changes, saved with an interrupted restack so git stack abort puts the order, membership, bases and remote ids back as well as the refs; queued segments so a move that leaves branches behind restacks both runs as one operation that continue finishes; and a repository wide list of stacks GitHub has to be told about on the next submit, since the stacks API can only create or append.
Restoring a reshape found each old stack by the first of its branches it could see, so after a move across trunks the old stack came back on the wrong trunk, or in the wrong slot, and a branch the snapshot did not know about could vanish. The snapshot now carries each stack's trunk and worktree, a stack goes back where its bottom branch and trunk agree, leftovers stay where they are, and the rollback after a failed start only claims nothing changed when no ref moved. The dissolve entry names the bottom branch GitHub knows, moves remember their stack for reporting, and a child of a branch that sync deleted now replays from its recorded base, which the planner rule already implied.
Undoing a move through gh stack's own file lost data. The metadata adapter only looked a branch's record up inside the stack record it landed in, so a branch moved to another stack (or another worktree's file) came out with just its name, head and base, i.e. no pull request record and none of the members we don't model. New records never got an id or number either, and on abort the old stack went home to whichever slot now held its bottom branch, which after a move is the destination's. Together that left the destination with no GitHub id and every pull request record involved gone, so the next submit fought with the stack GitHub still had.

So the adapter now collects every branch's node across all the files before writing, and a branch takes its node wherever it goes. A new record gets the id and number when the stack has them, and a stack whose trunk changed gets its trunk rewritten (keeping the recorded head). Restoring picks each stack's home by its remote id first, then by its bottom branch on the same trunk, then the first free slot on that trunk, so two stacks no longer swap records.
A few ways a reorder or move could stop part way left things worse than a plain restack would. The planner skips a branch that's checked out with uncommitted changes in another worktree, which is fine for a restack but not after a reorder: the skipped branch still holds the commits of the branch that used to sit below it, the branch above lands on top of them, and the next restack quietly drops that branch's work. A conflict on a branch checked out elsewhere refused before any state was saved, so the new order stuck and abort said there was nothing to abort. And when the branches a move --only left behind were refused after the moved branch had already gone, nothing was saved either, so there was no way to undo half a move.

So during a reshape the planner refuses instead of skipping (dirty elsewhere, or untracked files in the way), and a conflict on a branch checked out elsewhere is refused as invalid_args rather than handed to a restack there that wouldn't know about the reshape. When the engine stops, the driver now looks at whether a restack state was saved rather than at the error kind. With a state it leaves it for continue and abort; with none and no ref moved it puts the shape back and says nothing changed; with none after refs moved it saves a state with no conflict, i.e. what moved, the segments still to do and the snapshot. Continue then picks up from the failed segment and abort puts every ref, base and stack back.

While doing this I also moved queueing the remote stacks to dissolve inside the metadata write, before the cleared ids land, and drop them again if the write fails. Before, a failure to save the queue left a stack with its GitHub id cleared and nothing queued to dissolve it.
When a reorder or move stops part way, the saved state is what lets git stack continue finish it or git stack abort undo it. A few paths left that state in a bad spot. If continue was refused for the same reason as the first run, the error told you to run the original command again, which is refused too because the state is still there. A continue with nothing left to restack never cleared the state, so every command kept reporting an interrupted reshape. And the checked out branch was only recorded as moved after the ref update, so if that update failed, abort left the branch rebased.

Continue now swaps the steps of a refusal for the continue and abort pair whenever the state is still saved. It also ends the run properly when every queued branch is gone. The checked out branch is recorded (and the state saved) as soon as it is reset, before the other refs are touched.
A stack record whose id or number was cleared on its own ended up with an empty string or a zero in the file. Both are now removed instead, so a record only ever carries the values it has.
@DomBlack
DomBlack merged commit 6661b3a into main Oct 15, 2026
9 of 10 checks passed
@DomBlack
DomBlack deleted the reshape-engine branch October 15, 2026 15:40
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