Skip to content

Latest commit

 

History

History
229 lines (188 loc) · 13.6 KB

File metadata and controls

229 lines (188 loc) · 13.6 KB

git-undo snapshot format, version 1

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.

Goals

  1. 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.
  2. 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 gc keeps what a snapshot needs and reclaims what a deleted snapshot no longer needs.
  3. Atomic where Git is atomic. Refs move in one update-ref transaction. The working tree is changed with read-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.
  4. 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.

Where snapshots live

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.

The snapshot commit

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).

The state tree

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).

Recording

  1. Take the process-level lock (<gitdir>/undo/capture.lock).
  2. Read HEAD, the refs, the stash list, the in-progress markers and HEAD's newest reflog entry.
  3. Copy the index to a private file. With GIT_INDEX_FILE pointing at the copy: remove unmerged paths (if any) and write-tree for index; then add -A and write-tree for worktree. The real index is never written, so a command holding index.lock is never disturbed.
  4. Write the blobs and the top-level tree with hash-object -w and mktree.
  5. If the tree equals the latest snapshot's tree, stop.
  6. commit-tree with the parents described above and the message. Create the ref with update-ref --stdin (create), retrying with the next sequence number if it exists.
  7. 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.

Automatic recording

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.

Position, undo and redo

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 to n.

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.

Restoring

Given a target snapshot T, with the state being left recorded as snapshot S:

  1. Refuse if an operation is in progress, unless forced; when forced, remove the operation's state files first.
  2. Verify with cat-file --batch-check that every object T's refs, HEAD and stash name still exists. Refuse otherwise, naming the refs affected.
  3. Refs. In one update-ref --stdin -z --no-deref transaction: update each ref whose target differs (with the current value as the expected old value), create refs T has that the repository lacks, delete refs the repository has that T lacks. refs/stash is excluded here and handled in step 6. Symbolic refs other than HEAD are set afterwards with symbolic-ref. HEAD is then set with symbolic-ref or update-ref --no-deref.
  4. Working tree. If S and T record different worktree trees: on a private copy of the index, read-tree --reset <S.worktree>, update-index --refresh, then read-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.
  5. Index. read-tree --reset <T.index> on the real index, then, if T has index-unmerged, update-index -z --index-info with its content, then update-index --refresh.
  6. Stash. If the stash lists differ: update-ref -d refs/stash, then stash store -m <message> <commit> for each entry, oldest first.
  7. Record a restore snapshot with Undo-Restored: <T> and Undo-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.

Configuration

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.

Known limits

  • Anything a snapshot points at must still be in the object database. A snapshot keeps its own commits alive, but a snapshot deleted by gc or undo.limit releases 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 state blob 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.

Compatibility

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).