Skip to main content

Recover from a mistake

This page is a decision list. Find the situation you're in, run the command next to it, and read the section below for what it actually does and does not undo. Every claim here is verified against source, and every terminal block came from a real run in a throwaway repo, with /Users/you/demo standing in for the repo and a fictional hash standing in for the real one in cache paths.

SituationCommand
I want to undo the last operationgitq undo
A branch diverged from origin (someone force-pushed it)gitq reset <branch>
I am stuck mid cascadegitq abort
My store is wrong or gonegitq import --replace
A lease is stuckFinish or abort the cascade holding it
I just want to see what happenedgitq log

See what happened first​

gitq log lists every recorded operation for the current repo, most recent last, each with its timestamp and the branches it touched:

bash
gitq log
2026-07-25T20:49:32.866Z sync (main, feat-a, feat-b)
2026-07-25T20:49:41.241Z sync (main, feat-a, feat-b)

It reads the same global operation-log.json that undo reads, filtered to this repo's common dir, so it's the fastest way to check "did that actually happen" and "what would undo even do" before you run it. It never modifies anything. See Where state lives.

Undo the last operation​

bash
gitq undo

undo takes the most recent operation-log entry that belongs to this repo: not a specific one you name, and not filtered by branch or stack, just "the last thing that happened here." If that entry isn't reversible, it says so and stops:

gitq: cannot undo "split" (not reversible)

Only four operation types are reversible: sync, cascade-rebase, reparent, and absorb. split, fold, and rename are logged but not reversible (they were deliberate one-way structural changes when you made them), and reset is never logged at all, so there's nothing for undo to find. See Restructure a stack for why.

A failed absorb leaves undo pointing at the wrong operation

absorbCommand runs inside withOperationLog with no custom shouldLog, so the default rule applies: an entry is only written on a clean exit ((code) => code === 0). When the restack after a successful commit conflicts, absorb aborts the rebase, keeps the branch edits it already committed, and exits 1. No entry is written for that run.

gitq undo cannot tell any of this happened. It still just takes the most recent entry belonging to this repo, and after a failed absorb that entry belongs to whatever operation ran before it. undo does not fail, does not warn, and silently restores that earlier operation instead, as if the absorb had never run.

Check gitq log first if you are not sure what the last logged entry actually is. The real recovery for a failed absorb is gitq sync, not undo. See Absorb uncommitted changes for the full explanation.

A plain undo, restoring every branch the operation touched:

bash
gitq sync --stack demo
gitq undo
undone: restored main, feat-a, feat-b

Exit 0. Every branch (including the stack's root) is reset to the head it had before the operation being undone, read from that log entry's branchSnapshots.

skippedBranches​

Between the operation running and you undoing it, a snapshotted branch's git ref can stop existing: someone deleted it by hand, a script cleaned it up, whatever the reason. undo does not fail for that; it restores everything it still can and reports the rest separately:

bash
git branch -D feat-b
gitq undo
undone: restored main, feat-a; dropped from stack (branch no longer exists): feat-b (Skipped deleted branches: feat-b)
bash
echo $?
0

Exit 0, on a partial restore. success in the JSON result is about whether the restore mechanism itself worked, not about whether every branch came back. Check skippedBranches (also in the JSON, alongside restoredBranches) if you need to know exactly what didn't. The stack tree is updated to match: feat-b's node is dropped from the store (its children, if it had any, are reparented to its parent) rather than left pointing at a branch that no longer exists.

bash
gitq stacks
demo (root main): feat-a

A branch diverged from origin​

Someone force-pushed the branch (a rebase, an amend, whatever) and your local ref no longer matches. gitq reset <branch> snaps it back:

bash
gitq reset feat-a
reset feat-a to origin/feat-a (757f53788a6be23a4f8fabb94a3e4de304339409)

Exit 0. Three things to know before you reach for it:

  • It does not fetch. reset reads origin/<branch> as it already is; if your remote-tracking ref is stale, git fetch first or you'll reset to old data.

  • It requires a clean launch worktree, even though it never checks anything out there:

    bash
    gitq reset feat-a
    gitq: Working tree has uncommitted changes. Commit or stash first.

    Exit 1, and nothing moves. Like rename, it also refuses up front, with the same message shape as rename's refusal, if the branch is checked out somewhere other than your launch worktree, dirty or not. When it does run, it compare-and-swaps the branch ref and leaves your checkout exactly where it was: gitq reset feat-a from main leaves you on main.

  • It is never recorded in the operation log, so gitq undo has nothing to give back afterward. Whatever the branch pointed at before reset ran is gone unless you noted the sha yourself:

    bash
    gitq log
    2026-07-25T20:49:32.866Z sync (main, feat-a, feat-b)
    2026-07-25T20:49:41.241Z sync (main, feat-a, feat-b)

    No reset entry, before or after.

Stuck mid cascade​

You're not sure what state a paused sync left things in, you don't want to resolve it, or you just want out. gitq abort is the escape hatch:

bash
gitq sync --stack demo
paused on feat-a in /Users/you/.mattstack/gitq/work/9f13ab2c7e0451dd/gitq-1 (commit ?/?):
UU README.md
resolve with git in that worktree, stage, then: gitq continue (or gitq abort)
bash
gitq abort
aborted

Exit 0. It aborts the in-progress rebase where it actually lives (the leased slot, not your checkout), clears the pause file, re-detaches the slot, and releases the lease:

bash
cat "$(git rev-parse --git-common-dir)/gitq/leases.json"
json
{ "leases": [] }

The branch that conflicted goes back to exactly where it was:

bash
git log --oneline -1 feat-a
fb1414b feat-a touches README

It does not rewind branches the cascade already finished before it hit the conflict: those refs were already moved, and stay moved. A paused cascade is never written to the operation log either (a pause is resolved with continue/abort, not undo), so there's no log entry for gitq undo to act on afterward. If you need to walk back branches the cascade did finish before you aborted the rest, that's a manual git reset to whatever you remember, or re-running the cascade from a known-good state. See Resolve a conflict for the full walkthrough of what abort does and doesn't touch.

The store is wrong or gone​

Deleted ~/.mattstack/gitq/stacks/<hash>.json by accident, or the tree in it just doesn't match reality anymore and hand-editing isn't worth it. If the forge still has the MRs, gitq import --replace rebuilds the store from there: the reverse direction of publish, forge state overwriting local state:

bash
gitq import --replace

Two refusals guard it, both checked from source rather than a live run here since import hits a real forge API:

  • Without --replace, over a non-empty store:

    gitq: import would discard 2 locally tracked stack(s) and re-mint stack ids; pass --replace to overwrite the local store

    Exit 1, checked before the token or network, so you find out what would be lost even offline.

  • While any cascade is active anywhere in the repo, --replace or not:

    gitq: cascades are active; finish or abort them first

    Exit 1. Unlike every other mutating command's per-stack lease guard, this one fires on any lease, running or parked, for any stack: import is about to discard the store those leases are tracked against, not just one stack's.

It re-mints stack ids, every time

Every stack import creates gets a brand-new random id (crypto.randomUUID() in StackManager.createStack), with no relationship to whatever id the same stack had before, even if you're importing back the exact MRs you just exported. The name you see in gitq stacks is a readable slug derived from the tip branch's MR title, so it can look stable across two imports, but the underlying id never is. Anything keyed to the old id, most importantly any gitq undo history for operations run before the import, stops resolving to a current stack the moment you replace it.

Treat it as a recovery tool for a broken store, not a routine refresh: gitq sync is what you want for a stack that's merely behind. See Publish a stack for the full command reference.

A lease is stuck​

Something claims a stack is mid-cascade (stack has a parked sync lease on ... on every mutating command) and you don't believe it, or the process behind it is long dead. The lease registry is one file per repo:

<commonDir>/gitq/leases.json

<commonDir> is the repo's shared .git, the same one every worktree of the repo points at, so this is one file for the whole repo regardless of how many worktrees you have. While something is genuinely running or parked, it looks like this:

json
{
"leases": [
{
"slotPath": "/Users/you/.mattstack/gitq/work/9f13ab2c7e0451dd/gitq-1",
"stackId": "f75110fc-52cd-44a0-b26d-8cbe0095158f",
"action": "sync",
"pid": 66119,
"acquiredAt": 1785012631757,
"state": "parked"
}
]
}

running leases whose process (pid) is already dead are reaped automatically the next time any lease is acquired, so a killed cascade doesn't block the next one. parked leases are never auto-reaped, on purpose: a conflict waiting on you is legitimately long-lived, and clearing it out from under you would strand a real mid-rebase state in the slot.

The supported fix is finishing or aborting the cascade the lease belongs to: gitq continue or gitq abort, exactly as in the pause protocol. That's true even if you're certain the process is dead and the conflict is unrecoverable: gitq abort doesn't care whether anything is still running, it just aborts the rebase where it lives and releases the lease.

Editing or deleting leases.json by hand while a cascade is genuinely running or parked is how you actually lose track of things: the pause file is still on disk, the rebase is still half-done in the slot, and now neither gitq continue nor gitq abort can find it, since both start from a parked lease and answer nothing to continue (no parked cascade) without one. If you've done this, run git rebase --abort in the slot named by the pause file's worktreePath, then delete that gitq-pause.json. gitq abort does handle the opposite split, a parked lease whose pause file is missing. See Where state lives for the rest of what's safe and not safe to touch by hand.

Next​