This document describes what git-undo writes into a repository and how it
restores from it, precisely enough that another implementation (in another
language, or inside Git itself) could read and write the same data. Nothing
here requires code outside Git: every snapshot is ordinary Git data, readable
with git ls-tree, git cat-file and git log, and removable with
git update-ref -d.
- Complete. A snapshot holds everything that makes up the state of a repository from the user's point of view: where every ref points, what HEAD is, the index (including conflicts), the working tree (including untracked files), and the stash stack.
- Plain Git. Snapshots are commits and trees in the object database,
referenced from
refs/undo/. No database, no daemon, no side files that Git cannot read.git gckeeps what a snapshot needs and reclaims what a deleted snapshot no longer needs. - Atomic where Git is atomic. Refs move in one
update-reftransaction. The working tree is changed withread-tree -m -u, which refuses to overwrite a file that differs from what the snapshot says it should contain, so an undo never destroys work no snapshot has. - Cheap. Recording a snapshot after every Git command must not be noticeable. Trees are content-addressed, so an unchanged file costs nothing; the only per-snapshot cost is hashing files that changed.
| Worktree | Snapshot refs |
|---|---|
| main worktree | refs/undo/snapshots/<seq> |
linked worktree w |
refs/undo/worktrees/<w>/snapshots/<seq> |
<seq> is a decimal sequence number, zero-padded to ten digits so refs sort
in order. Numbers only grow; deleting a snapshot never renumbers another.
<w> is the directory name Git gave the worktree under .git/worktrees/.
Refs under refs/undo/ are ordinary refs. They are visible to
git for-each-ref and git log --all; they are not branches or tags and no
porcelain command creates, moves or pushes them. Pushing them is allowed
and is how a team can share a history of what happened to a repository.
A snapshot is a commit object.
Tree: the state (next section).
Parents: the commits every ref pointed at when the snapshot was taken,
plus each stash entry and a detached HEAD's commit. A ref that pointed at an
annotated tag contributes the commit the tag peels to; refs to trees or
blobs contribute nothing. Parents exist only to keep those commits alive:
as long as the snapshot exists, git gc cannot prune a commit the snapshot
would need to point a ref back at. When more than 64 commits need keeping,
they are grouped under intermediate keep commits: commits with the empty
tree, the message git-undo keep, author and committer git-undo <git-undo@localhost> and a fixed date (1000000000 +0000, so identical
groups hash identically), each with at most 64 parents; the snapshot's
parents are then the keep commits. A reader that wants the recorded ref
targets must read the refs file, never the parent list.
Author: git-undo <git-undo@localhost>. Committer: the user's
identity from Git's configuration, or the same placeholder when none is
configured. The committer date is the time the snapshot was recorded.
Message: a summary line, an empty line, then trailers:
commit: Fix the parser
Undo-Format: 1
Undo-Kind: change
Undo-Head: main
Undo-Reflog: 3f2a9c1d… commit: Fix the parser
| Trailer | Meaning |
|---|---|
Undo-Format |
This document's version. Readers must refuse a higher number. |
Undo-Kind |
change (recorded after a Git command or by the watcher), manual (git undo snapshot), restore (recorded after a restore). |
Undo-Head |
The short branch name HEAD was on, or detached. |
Undo-Restored |
Restore snapshots only: the number of the snapshot whose state was restored. |
Undo-From |
Restore snapshots only: the number of the snapshot recording the state that was left. |
Undo-Reflog |
The newest entry of HEAD's reflog, <commit> <message>, if any. Used to phrase the summary of the next snapshot. |
The summary line is descriptive only. When Git wrote a reflog entry for the
change it is that message (commit: …, rebase (finish): …, reset: moving to …); otherwise it names what differs from the previous snapshot (branch feature deleted, staged: a.c, edited a.c, b.c).
| Entry | Type | Present | Content |
|---|---|---|---|
HEAD |
blob | always | ref: <refname>\n when HEAD is symbolic (the branch may not exist yet), otherwise <commit>\n. |
refs |
blob | always | One line per ref outside refs/undo/, sorted by name: <refname>\t<object>\t<type>\t<symref-target>\t<peeled-object>\t<peeled-type>\n. <object> is what the ref (or, for a symbolic ref, its target) points at; <type> its object type. The last three fields are empty when they do not apply. refs/stash is included and points at the top of the stash. |
stash |
blob | when the stash is not empty | One line per stash entry, newest first: <commit>\t<reflog message>\n. |
state |
blob | when an operation is in progress | One marker name per line, from: rebase-merge, rebase-apply, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, BISECT_LOG, sequencer. Informational: a restore does not recreate an in-progress operation. |
index |
tree | in repositories with a worktree | The index written as a tree. When the index has unmerged entries, those paths are left out of this tree and recorded in index-unmerged. Intent-to-add entries are omitted, as git write-tree omits them. |
index-unmerged |
blob | when the index has conflicts | The unmerged entries exactly as git ls-files -z --unmerged prints them (<mode> <object> <stage>\t<path>\0), suitable for git update-index -z --index-info. |
index-blobs |
tree | when the index has conflicts | A flat tree whose entries are the blobs named in index-unmerged, each stored under its own id as the name. It exists only to keep those blobs reachable. |
worktree |
tree | in repositories with a worktree, unless undo.worktree is false |
The working tree: every tracked path with its current content, plus every untracked file that is not ignored. Produced by git add -A on a private copy of the index followed by git write-tree, so it obeys .gitignore, core.excludesFile, sparse checkout and the usual attribute filters. Submodules appear as gitlinks with their currently checked-out commit. Empty directories do not appear, as in any Git tree. |
Two snapshots with identical trees describe identical states. A recorder
must not create a change or manual snapshot whose tree equals the latest
snapshot's tree; a restore snapshot is always created, because it marks a
position (see below).
- Take the process-level lock (
<gitdir>/undo/capture.lock). - Read HEAD, the refs, the stash list, the in-progress markers and HEAD's newest reflog entry.
- Copy the index to a private file. With
GIT_INDEX_FILEpointing at the copy: remove unmerged paths (if any) andwrite-treeforindex; thenadd -Aandwrite-treeforworktree. The real index is never written, so a command holdingindex.lockis never disturbed. - Write the blobs and the top-level tree with
hash-object -wandmktree. - If the tree equals the latest snapshot's tree, stop.
commit-treewith the parents described above and the message. Create the ref withupdate-ref --stdin(create), retrying with the next sequence number if it exists.- Enforce
undo.limit(default 1000) by deleting the oldest refs.
All of the recorder's git invocations run with GIT_UNDO_INTERNAL=1 in
the environment and core.hooksPath pointing at an empty directory, so no
hook (ours or anyone else's) observes them.
git undo init writes two hooks, reference-transaction and
post-index-change. Between them they observe every command that moves a
ref, writes the index or checks out files. Each hook only writes a marker
file (<gitdir>/undo/pending) and, if no worker is alive, starts one
detached; Git never waits for a snapshot. The worker waits until the marker
has been quiet for undo.settle milliseconds (default 700) and no operation
is in progress (bounded), records one snapshot, and repeats while new marks
arrive. A rebase of forty commits is therefore one snapshot.
Edits made outside Git are not seen by hooks. They are captured by the next
Git command's snapshot, by git undo snapshot, or continuously by
git undo watch.
Snapshots form a sequence by number. The repository's position is the
number of the latest snapshot, except that when the latest snapshot has kind
restore, the position is its Undo-Restored value. A run of restore
snapshots at the end of the sequence is the navigation streak; its
members are never targets.
git undo [N]moves the position back N snapshots, skipping the streak.git undo redo [N]moves it forward N snapshots, skipping the streak.git undo restore <n>moves it ton.
Consecutive undos therefore walk back through history, including through
earlier undos (as an operation log does), and redo walks forward again.
Recording any new change or manual snapshot ends the streak, so after new
work git undo undoes that work, and there is nothing to redo.
Before moving, the current state is recorded if it is not already the latest snapshot, so what gets undone is always what the user actually has, and the restore itself can be undone.
Given a target snapshot T, with the state being left recorded as snapshot S:
- Refuse if an operation is in progress, unless forced; when forced, remove the operation's state files first.
- Verify with
cat-file --batch-checkthat every object T's refs, HEAD and stash name still exists. Refuse otherwise, naming the refs affected. - Refs. In one
update-ref --stdin -z --no-dereftransaction:updateeach ref whose target differs (with the current value as the expected old value),createrefs T has that the repository lacks,deleterefs the repository has that T lacks.refs/stashis excluded here and handled in step 6. Symbolic refs other than HEAD are set afterwards withsymbolic-ref. HEAD is then set withsymbolic-reforupdate-ref --no-deref. - Working tree. If S and T record different
worktreetrees: on a private copy of the index,read-tree --reset <S.worktree>,update-index --refresh, thenread-tree -m -u <S.worktree> <T.worktree>. The two-tree merge touches only paths that differ between S and T, creates untracked files T had, removes files T lacked, and fails without changing anything if a file on disk does not match S. Ignored files are never touched. - Index.
read-tree --reset <T.index>on the real index, then, if T hasindex-unmerged,update-index -z --index-infowith its content, thenupdate-index --refresh. - Stash. If the stash lists differ:
update-ref -d refs/stash, thenstash store -m <message> <commit>for each entry, oldest first. - Record a
restoresnapshot withUndo-Restored: <T>andUndo-From: <S>. If its tree differs from T's, report which parts differ.
Every ref update carries the reflog message git undo: restore #<T>, so
Git's own reflog shows the restore too.
| Key | Default | Meaning |
|---|---|---|
undo.worktree |
true |
Record the working tree. false records refs and index only. |
undo.settle |
700 |
Milliseconds of quiet before the hook worker records. |
undo.limit |
1000 |
Snapshots kept per worktree; the oldest are deleted first. |
undo.expire |
90d |
Age after which git undo gc forgets snapshots. |
- Anything a snapshot points at must still be in the object database. A
snapshot keeps its own commits alive, but a snapshot deleted by
gcorundo.limitreleases them; objects pruned since cannot come back. - Remotes are not touched. Restoring
refs/remotes/*changes what the local repository believes about a remote, not the remote. - Repository configuration, hooks,
info/exclude, notes refs' semantics and submodule working trees are outside the state. - The
stateblob is informational: a restore does not re-create a stopped rebase or merge, it only records that one was in progress. - Each worktree has its own snapshot sequence; refs are shared, so a restore in one worktree can move a branch another worktree has checked out.
- Index flags (
assume-unchanged,skip-worktree) are not recorded.
Readers must check Undo-Format and refuse numbers they do not know. New
optional tree entries or trailers may be added without a version bump;
changing the meaning of an existing one requires one. git-undo requires
Git 2.31 or newer (rev-parse --path-format) and, for automatic recording,
Git 2.28 or newer (reference-transaction hook).