Skip to content

Add git stack undo - #58

Merged
DomBlack merged 6 commits into
renamefrom
undo
Oct 16, 2026
Merged

DomBlack merged 6 commits into
renamefrom
undo

Conversation

@DomBlack

@DomBlack DomBlack commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Reorder, move, fold, pop and sync all rewrite or delete branches, and when one of them does something you didn't expect, getting back meant digging through the reflog for each branch and fixing gh stack's metadata by hand. Absorb even documents a manual recipe for it.

git stack undo (and git undo) reverses the last git-stack command that changed local state. Run it again to step further back, up to 20 commands. It covers create (--insert too), modify, absorb, restack, reorder, move, fold, pop, untrack, init, sync and a local rename. Branches go back where they were (deleted ones come back, created ones go), the stack metadata is restored exactly, and so is your checkout. After create, modify or absorb the committed changes come back staged; after a pop you're back on the popped branch. Your own uncommitted changes are never thrown away, and if one is in the way undo refuses before it moves anything. --list shows the history. Agents get stack_undo.

A few decisions worth knowing:

  • Each command records a snapshot before it runs, rather than every command knowing its own inverse. That's one mechanism for everything, including commands added later.
  • Undo is per worktree, so an undo in your checkout never reverses something an agent did in another one. If a branch moved since the command (say you committed on top), it refuses and names it; --force goes ahead anyway.
  • It never touches GitHub. Submit, merge and a rename on GitHub change what's there, so a successful one clears the history.
  • Deleted commits are kept alive for as long as undo might need them by a hidden ref, refs/git-stack/undo, which is rewritten as entries come and go. It does show up in git log --all.
  • Only the gh-stack adapter writes gh stack's metadata file, so undo restores it through a new snapshot and restore on the metadata port.

Undo needs to put gh stack's metadata back exactly as it was before a command, including the members we don't model and the files of other worktrees. Rebuilding that from the graph would lose things, and only the gh stack adapter is allowed to write those files anyway.

So the metadata port gains a Snapshot and Restore pair. The adapter reads every metadata file Load would look at, under gh stack's lock, and keeps them byte for byte (a file that didn't exist is recorded as empty). Restore writes them back under the same lock and removes the ones that weren't there, leaving files it doesn't know about alone. Everything else treats the snapshot as opaque.
For undo to put things back it needs to know what they looked like before a command ran, and the old commits need to still exist when you ask (a restack or a sync can leave commits nothing points at, and gc would happily take them).

Covered commands now run inside one wrapper that takes a snapshot first: every local branch tip, gh stack's metadata through the new port, the queue of GitHub stacks to dissolve and what each worktree had checked out. When the command finishes it reads the same again and adds an entry to <common git dir>/git-stack/undo.json, newest first and at most 20, unless nothing changed. Only the branches the graph tracked before or after are kept, so an entry stays small. Each entry remembers the worktree it ran in.

refs/git-stack/undo points at an empty commit whose parents are every commit the entries would put back, so they stay reachable for as long as undo might need them. It's rewritten whenever the history changes and deleted when it's empty.

A restack that stops on a conflict saves the snapshot with its state instead of recording; continue records the whole thing once it finishes and abort just drops it, since abort has already put everything back. A failed command records nothing, except a partial one, since the part it did can still be undone. Restack is the first command wired in; the rest follow.
The history only had restack in it, so there was nothing much to undo yet.

Create, modify and absorb now run inside the recording wrapper in soft mode, since they turn your working tree changes into commits and undo should hand those back as staged changes. Restack, reorder, move, init and sync run in keep mode, since they rewrite history and undo should move the branches back while keeping whatever you have locally. Modify's continue and abort stay outside it, they finish or drop the restack the original command saved.

A successful submit or merge clears the history. Once GitHub has a state, putting the local branches back before it would only make the two disagree. Merge clears it as soon as the merge goes through, and the sync it runs afterwards isn't recorded either. A dry run submit leaves it alone.
With the history recorded, this is the part that puts it back. It undoes the newest command recorded in the worktree you run it from, so an undo in one checkout never reverses what an agent did in another.

It refuses while a restack or rebase is in progress, and when any branch the command left behind has moved since (you committed on top, say), naming them; --force goes ahead and throws away what happened on them, but never your uncommitted changes. It also works out how every worktree follows its branch back before touching anything, so if your local changes in any of them are in the way it refuses with nothing moved.

Then the branches go back in one transaction (deleted ones recreated, created ones deleted), the metadata and the GitHub queue are put back exactly, and the worktrees follow. The one you're in follows by the command's mode: after create, modify or absorb HEAD goes back with reset --soft so what they committed is staged again, after everything else it's reset --keep so local changes come along. Other worktrees always use reset --keep. Finally the entry is dropped, so the next undo steps further back. --list shows this worktree's history without changing anything.
Getting a stack command wrong (a create that should have been a modify, a move onto the wrong stack, a sync that deleted a branch you still wanted) meant working out by hand which refs to reset and fixing gh stack's metadata yourself.

git stack undo (git undo) takes back the last command you ran in this worktree, and running it again steps further back. --list shows what can be undone without changing anything, and --force goes ahead when branches have moved since, throwing away what changed on them but never your uncommitted changes. Agents get the same thing as stack_undo, whose list form returns each entry with what it would put back.

The README has a section on it, including that submit and merge clear the history and that refs/git-stack/undo shows up in git log --all. The architecture doc says why it restores snapshots rather than running inverses, why it never touches GitHub and how the worktrees follow.
Undo was built alongside fold, pop, untrack, rename and create --insert, so it only knew the commands that existed when it started. Those are exactly the ones where you most want a way back: fold and pop delete a branch, untrack drops one from its stack.

Fold, untrack and a local rename are recorded like the other commands that rewrite history, so undo moves the branches back and keeps your local changes. Pop is recorded the other way round: undo brings the branch back and points HEAD at it again, so the changes pop left staged become its checkout once more. Create --insert was already covered by create. A rename that also renamed the branch on GitHub clears the history instead, like submit and merge do, since undo can't put GitHub back.
@DomBlack
DomBlack added this pull request to stack #59 October 16, 2026 13:30
@DomBlack DomBlack changed the title undo Add git stack undo Oct 16, 2026
@DomBlack DomBlack added the enhancement New feature or request label Oct 16, 2026
@DomBlack
DomBlack merged commit 0aa3723 into main Oct 16, 2026
5 checks passed
@DomBlack
DomBlack deleted the undo branch October 16, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant