Undo is a design decision, not a feature

The fastest way to learn how a version control system really thinks is to break something and try to get it back. Not a lost commit — those are easy — but the messy cases: a rebase that dropped two commits, a git checkout . that wiped an hour of uncommitted work, a botched merge resolution you accepted before reading it. What you can recover in those moments was decided years earlier, by whoever chose what the tool writes down before it changes state. Undo is not a command someone bolts on. It is a consequence of the data model.

What makes a safety net complete rather than partial

Before comparing tools, it helps to have criteria. A recovery mechanism is complete to the degree that it satisfies all of these:

  • Coverage. Every state-mutating operation is recorded, not just the ones that move a branch pointer. That includes the working copy, the index or staging area, conflict resolutions, and configuration of tracked state.
  • Granularity that matches user intent. A user thinks "undo the rebase", not "reset three ref movements". If one command produces fourteen log entries, the log is a transaction journal, not an undo history.
  • Global ordering. One timeline for the repository, so you can answer "what did I do at 14:32" without knowing which ref to inspect.
  • Discoverability. Reading the log should not require you to already know what happened.
  • Undo is itself undoable. If reverting is destructive, you have swapped one cliff for another.
  • Bounded, predictable retention. You should know exactly how long recovery is possible and what prunes it.

Most systems score well on two or three of these. The interesting differences are in the rest.

Git undo, and the reflog explained honestly

Git's reflog is a per-reference append-only list of the values a ref has held. HEAD has one; every local branch has one; refs/stash is literally implemented as one. It lives in .git/logs/ and it is the reason most "lost" commits in Git are not actually lost.

git reflog                          # HEAD's history, newest first
git reflog show feature/pricing     # one branch's history
git log -g --oneline                # same data, log formatting

git reset --hard HEAD@{1}           # back to where HEAD was one move ago
git reset --hard ORIG_HEAD          # set by rebase/merge/reset before they run

A typical entry looks like this:

3f9a2c1 HEAD@{0}: rebase (finish): returning to refs/heads/feature/pricing
3f9a2c1 HEAD@{1}: rebase (pick): add currency rounding
a71bd44 HEAD@{2}: rebase (pick): extract tax table
9c0e5f2 HEAD@{3}: rebase (start): checkout origin/main

That is genuinely good. It is also partial, in specific ways worth knowing:

  • It only records ref movements. Uncommitted working-tree changes are invisible to it. git restore ., git checkout -- . and git clean -fd are unrecoverable through the reflog because nothing was ever written to the object database. Staged content is recoverable, but only via git fsck archaeology.
  • It is per-ref, not per-repository. There is no single timeline. If an operation touched three branches, you reconstruct the sequence by reading three logs and comparing timestamps.
  • It has no notion of a transaction. A twenty-commit interactive rebase leaves twenty-odd entries. Modern Git labels them rebase (start) / rebase (finish), which helps, but "undo that operation" is still your inference, not the tool's guarantee.
  • It expires. By default, reachable entries are pruned after 90 days and unreachable ones after 30, during garbage collection. Those are configurable via gc.reflogExpire and gc.reflogExpireUnreachable.
  • It is local and untracked. A fresh clone has no reflog. Nothing is shared, which is correct for privacy and unhelpful for shared debugging.
The reflog answers "where has this pointer been". It does not answer "what did I do".

Getting the most out of what you already have

You can make Git's net meaningfully better without changing tools:

# Keep recovery windows long on machines with disk to spare
git config --global gc.reflogExpire "never"
git config --global gc.reflogExpireUnreachable "never"

# Prefer resets that refuse to clobber uncommitted work
git reset --keep <commit>

# Verify a rebase did what you meant before you trust it
git range-diff ORIG_HEAD...HEAD

# Last resort: dangling objects the reflog no longer references
git fsck --unreachable --no-reflogs | grep commit

Two habits matter more than any configuration. First, commit before you experiment — a throwaway wip commit converts an unrecoverable class of loss into a recoverable one. Second, use git worktree add instead of stashing when you need to switch context; the stash is a reflog on a single ref and dropping entries is easier than people expect.

Why operation logs beat ref logs

Jujutsu takes a different design decision. Every jj command runs as a single transaction against the repository, and the transaction is recorded in an operation log. Crucially, jj snapshots the working copy into a commit before each command, so the working copy is inside the versioned state rather than outside it.

jj op log                 # one timeline for the whole repo
jj undo                   # revert the last operation
jj op restore <op-id>     # restore repo state as of that operation
jj op diff --op <op-id>   # what that operation actually changed

Score that against the criteria. Coverage now includes uncommitted edits. Granularity matches intent, because one command is one operation regardless of how many refs it moved. Ordering is global. And undo is itself an operation, appended to the log, so undoing an undo is unremarkable. Sapling's sl undo and sl redo arrive at a similar place from Mercurial's lineage. Mercurial's own hg rollback was the cautionary version: single-step, transaction-scoped, and eventually deprecated for good reason.

Operation logs are not magic. They still expire, they still cannot recover a file you deleted before the repository ever saw it, and they grow. But the structural point holds: recording operations gives you an undo model, while recording ref values gives you raw material from which an undo model must be reconstructed by hand.

Where kal takes the operation log

kal is built on an operation log for the reasons above, and adds two fields that turn recovery into explanation. Each operation records the intent — why the change was made — and the author kind — whether a human or a model produced it.

kal ops
# op 8f21  14:32  model:claude  rebase onto main — resolve tax-table conflict
# op 8f20  14:29  human         edit pricing/rounding.rs — fix banker's rounding
# op 8f1e  14:11  model:claude  refactor extract tax table (14 files)

kal undo                  # revert the last operation, recorded as a new op
kal undo 8f1e             # revert a specific operation
kal ops show 8f1e         # intent, author, diff, and what it superseded

The case this is aimed at is the one that has become common: an agent made forty edits across fourteen files, tests went red, and the question is not "which ref moved" but "which of those changes was the model's guess and which was mine". A ref log cannot answer that. An operation log with authorship can.

kal mirrors into Git, so the commits your colleagues and CI see are ordinary Git commits and the remote does not need to know kal exists. The limitations are worth stating plainly. Undo is local to your repository: once an operation has been mirrored and pushed, reverting it locally does not rewrite what others have already fetched — the same constraint any distributed system has. kal does not track changes made outside the repository, so a migration your agent ran against a database is not in the log. And an operation log is only as honest as the metadata written into it; intent recorded automatically from an agent's own reasoning is a claim, not a proof.

Whatever you use, treat undo as an architectural property and test it deliberately. Break a scratch repository on purpose. Time how long recovery takes and how much you had to already know. That number tells you more about a version control system than its merge algorithm ever will.

Try kal on a repository you already have

kal runs alongside git — kal init inside an existing repo changes nothing until you ask it to. Snapshots record intent and human-vs-AI authorship, every operation is undoable, and kal git sync mirrors everything back into git.

Install kal 5-minute tour