How to Recover Lost Commits and Files in Git With reflog and fsck, and Snapshot Uncommitted Work Before a Script or AI Agent Can Wipe It
Recover a reset, a deleted branch, a dropped stash and a staged file with git reflog and fsck, then measure what Git can never return and build a snapshot tool that saves it.
You run one command, the output scrolls past, and an hour of work is gone. Maybe it was a git reset --hard in the wrong terminal, a git clean -fd meant for a build folder, a branch deleted with -D, or a stash dropped out of habit. The same thing can happen when a script or an AI coding agent runs those commands for you. Git has a reputation for never losing anything, and that reputation is half right: Git is remarkably good at keeping what it has stored, and it cannot give back what it never stored.
Table Of Content
- The words you need first
- What you need before you start
- Step 1: Make a practice repository you cannot hurt
- Step 2: Undo a hard reset with the reflog
- Gotcha: HEAD@{1} is not HEAD~1
- Step 3: Bring back a deleted branch
- Gotcha: this works because HEAD visited the branch
- Step 4: Rescue a dropped stash
- Gotcha: a repository with a long history has many dangling commits
- Step 5: Undo a bad merge or a bad amend
- Gotcha: ORIG_HEAD lasts one command
- Step 6: Get back a file you staged but never committed
- Gotcha: blobs have no names and no dates
- Step 7: Find out what Git cannot give back
- Step 8: Save a snapshot by hand with Git’s own plumbing
- Step 9: Turn the recipe into a tool
- Gotcha: a snapshot copies everything it sees
- Where to run it
- Step 10: Measure what the snapshot saves
- Step 11: Know how long recovery lasts
- Step 12: Test the tool, then test the tests
- The test that found a bug I could not reproduce
- Plant bugs on purpose
- A short decision guide
- Habits that make this boring
- Check that everything works end to end
In this tutorial you will learn where that line falls. You will recover from six mistakes (a hard reset, a deleted branch, a dropped stash, a bad merge and a bad amend, and a file you staged but never committed), then measure which kinds of lost work Git can never return, and then build a small Python tool, gitsnap, that saves exactly those kinds of work before the damage happens. In my eight-row test, plain Git got back 3 of the 8 things I destroyed (one from the reflog, two with git fsck). After one gitsnap save, 7 of the 8 came back, and the eighth was an ignored file I had not told the tool to keep.
Everything below ran in throwaway repositories with Git 2.55.0 (Git for Windows) and Python 3.13.14 on Windows 11, and I tested no other versions. The commands are written for a Bash shell and use only plain Git, so they behave the same on macOS and Linux. I did not test any particular AI agent; the wrapper in Step 9 works with any command, and the steps show it with a stand-in command that does the damage on purpose.
The words you need first
Recovery is much easier once you can picture where your work lives. These six terms appear in every step.
- Working tree. The files in your project folder, exactly as your editor shows them.
- Index (staging area). The list of files and contents that will go into your next commit. Running
git addcopies a file’s current content into Git’s database and records it here. - Object database. Git’s store, inside
.git/objects. Everything Git saves is an object named by a hash: a blob holds the content of one file, a tree holds a folder listing, and a commit points to a tree and to its parent commits. - Ref. A name that points at a commit: a branch, a tag, or
HEAD(the commit you have checked out right now). - Reachable and dangling. An object is reachable if you can get to it by starting at a ref and following parents and folders. The Git glossary defines a dangling object as “An unreachable object which is not reachable even from other unreachable objects”. In plain words, nothing at all points at it.
- Reflog. Git’s diary of where each ref has been. The git-reflog documentation says reflogs “record when the tips of branches and other references were updated in the local repository”. The key words are in the local repository: the diary describes your copy only.
One rule explains almost everything that follows: Git can only recover what it has stored, and it stores your work at two moments, when you run git add and when you run git commit (plus commands such as git stash that run those for you). Anything you changed in a file and never added exists only in that file. If a command overwrites the file, Git has nothing to give back.
What you need before you start
- Git 2.28 or newer (the practice repositories use
git init -b). I used Git for Windows 2.55.0. Rungit --versionto check yours. - A Bash shell: Git Bash on Windows, Terminal on macOS, or any Linux shell. In PowerShell, put
HEAD@{1}in single quotes because PowerShell reads braces specially. - Python 3 for Steps 9 to 12 (I tested 3.13.14 only), plus
pip install pytestfor the tests. On macOS and Linux the command is usuallypython3instead ofpython. - Nothing valuable in the folders you use. You will create two practice repositories next to each other and run destructive commands inside them. Do not run them in a real project.
The commit hashes in my output are seven-character abbreviations and will differ from yours, so wherever a command contains a hash, paste the one from your own screen. I ran the recorded session with a fixed commit date and an empty Git configuration so that my results are repeatable; on your machine only the hashes and times will differ.
Step 1: Make a practice repository you cannot hurt
Create an empty folder, open a Bash shell in it, and run these commands. The first group makes the repository and tells Git who you are:
$ mkdir lost-work-lab
$ cd lost-work-lab
$ git init -q -b main
$ git config user.name "Lab User"
$ git config user.email "[email protected]"
$ git config core.autocrlf false
git init -q -b main creates the repository (-q is quiet, -b main names the first branch). The two git config lines set a name and email for commits made in this repository only. core.autocrlf false switches off line-ending conversion, which keeps that detail out of the way so your output matches mine on every platform.
Now write a four-line story, one line per commit:
$ printf 'chapter 1\n' > story.txt
$ git add story.txt
$ git commit -q -m "add chapter 1"
$ printf 'chapter 2\n' >> story.txt
$ git commit -q -am "add chapter 2"
$ printf 'chapter 3\n' >> story.txt
$ git commit -q -am "add chapter 3"
$ printf 'chapter 4\n' >> story.txt
$ git commit -q -am "add chapter 4"
printf writes text into story.txt (> replaces the file, >> appends). git add copies the file into Git’s database. git commit -m records a commit with a message, and -a in git commit -am means stage every change to files Git already tracks first. Check the result:
$ git log --oneline
3a9097c add chapter 4
0fb823f add chapter 3
9b479c9 add chapter 2
a97e15e add chapter 1
Four commits, newest first. If your list has four entries with those messages, you are ready.
Step 2: Undo a hard reset with the reflog
git reset --hard HEAD~2 tells Git to move the current branch back two commits (HEAD~2 means two commits before HEAD) and rewrite your files to match. The git-reset documentation says hard mode updates the index “so nothing will be staged”. Run it and look at the damage:
$ git reset --hard HEAD~2
HEAD is now at 9b479c9 add chapter 2
$ git log --oneline
9b479c9 add chapter 2
a97e15e add chapter 1
$ cat story.txt
chapter 1
chapter 2
The two newest commits are gone from git log and story.txt has lost its last two lines. This is the moment of panic, and the moment the reflog earns its keep:
$ git reflog
9b479c9 HEAD@{0}: reset: moving to HEAD~2
3a9097c HEAD@{1}: commit: add chapter 4
0fb823f HEAD@{2}: commit: add chapter 3
9b479c9 HEAD@{3}: commit: add chapter 2
a97e15e HEAD@{4}: commit (initial): add chapter 1
Each line is one move of HEAD: the hash HEAD moved to, a label HEAD@{n}, and what caused the move. The documentation explains the label this way: HEAD@{2} means “where HEAD used to be two moves ago”. Line HEAD@{1} is the commit we were on before the reset, add chapter 4. The Pro Git book puts the idea plainly: “Git silently records what your HEAD is every time you change it”. Move back to it:
$ git reset --hard HEAD@{1}
HEAD is now at 3a9097c add chapter 4
$ git log --oneline
3a9097c add chapter 4
0fb823f add chapter 3
9b479c9 add chapter 2
a97e15e add chapter 1
$ cat story.txt
chapter 1
chapter 2
chapter 3
chapter 4
All four commits and all four lines of the story are back. Nothing was ever deleted; the reset only moved the branch name, and the old commits stayed in the object database the whole time.
Gotcha: HEAD@{1} is not HEAD~1
The two look alike and mean different things. HEAD~1 walks back through history (the parent commit). HEAD@{1} walks back through time (the previous position of HEAD, wherever it was). After a reset they point to completely different commits, so read git reflog before you type a number. The reflog also accepts times: the documentation’s own example is master@{one.week.ago}, meaning “where master used to point to one week ago in this local repository”.
Step 3: Bring back a deleted branch
Create a feature branch with two commits on it, then go back to main:
$ git switch -q -c feature
$ printf 'bonus\n' > bonus.txt
$ git add bonus.txt
$ git commit -q -m "add bonus chapter"
$ printf 'epilogue\n' >> bonus.txt
$ git commit -q -am "add epilogue"
$ git switch -q main
Now delete the branch the forceful way. -D deletes a branch even if its commits are not merged anywhere else, which is exactly when deleting hurts:
$ git branch -D feature
Deleted branch feature (was e69290b).
$ git branch
* main
$ git reflog show feature
fatal: ambiguous argument 'feature': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
Look at the message from git branch -D: Deleted branch feature (was e69290b). That hash is the branch’s last commit, and if you copy it you can recreate the branch with git branch feature e69290b. The third command shows a dead end worth knowing about: once the branch is deleted, git reflog show feature cannot even find the name, so the branch’s own reflog is no help. The diary you want is the one for HEAD:
$ git reflog -4
3a9097c HEAD@{0}: checkout: moving from feature to main
e69290b HEAD@{1}: commit: add epilogue
4b7d516 HEAD@{2}: commit: add bonus chapter
3a9097c HEAD@{3}: checkout: moving from main to feature
$ git branch feature-restored HEAD@{1}
$ git log --oneline feature-restored
e69290b add epilogue
4b7d516 add bonus chapter
3a9097c add chapter 4
0fb823f add chapter 3
9b479c9 add chapter 2
a97e15e add chapter 1
The line HEAD@{1}: commit: add epilogue is the last commit made on feature before we switched away. git branch feature-restored HEAD@{1} creates a new branch at that commit, and git log confirms both commits are back. I used a new name rather than feature so I could inspect it before deciding what to keep.
Gotcha: this works because HEAD visited the branch
The HEAD reflog records commits made while the branch was checked out. If a branch was never checked out in this copy of the repository (for example, you only ever fetched it), its commits are not in the HEAD reflog, and you are back to the printed hash or the git fsck technique in the next step.
Step 4: Rescue a dropped stash
A stash is a special commit Git makes to hold your uncommitted changes while it cleans your folder. Edit a file, stash it, then drop the stash as if you no longer needed it:
$ printf 'a much better chapter 4\n' >> story.txt
$ git stash
Saved working directory and index state WIP on main: 3a9097c add chapter 4
$ git stash drop
Dropped refs/stash@{0} (a578b2d22dc3c5f7dc1c35745ea982fd50862bc6)
$ git status --short
git stash drop prints a line you should never ignore: Dropped refs/stash@{0} (a578b2d22dc3c5f7dc1c35745ea982fd50862bc6). That is the full hash of the stash commit, and git status --short printing nothing shows the edit is gone from your folder. If you still have the line, you are done: git stash apply a578b2d brings the changes back. If you scrolled past it, git fsck can find it:
$ git fsck --no-reflogs
dangling commit a578b2d22dc3c5f7dc1c35745ea982fd50862bc6
$ git log -1 --format='%h %s' a578b2d
a578b2d WIP on main: 3a9097c add chapter 4
Why the --no-reflogs flag? By default git fsck counts every reflog entry as a reason to keep a commit, so a commit that only the reflog still points to is not reported as dangling. The git-fsck documentation describes the flag as “Do not consider commits that are referenced only by an entry in a reflog to be reachable”, which asks for everything that no ref points to. Our dropped stash is the only dangling commit, and git log -1 --format='%h %s' prints its subject, WIP on main: 3a9097c add chapter 4, which is how Git labels a stash made without a message. Apply it:
$ git stash apply a578b2d
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: story.txt
no changes added to commit (use "git add" and/or "git commit -a")
$ git status --short
M story.txt
$ git restore story.txt
The edit is back as an uncommitted change. I ran git restore story.txt as the last command only to tidy the practice folder.
Gotcha: a repository with a long history has many dangling commits
Every abandoned commit, amended commit and old stash shows up in git fsck --no-reflogs. The git log -1 --format='%h %s' trick (print a short hash and the subject for one commit) is how you tell them apart, and a stash made without a message is labelled WIP on followed by the branch name, as in the output above.
Step 5: Undo a bad merge or a bad amend
Merge the restored branch into main with a merge commit, look at the history, then decide that was a mistake:
$ git merge --no-ff -m "merge bonus chapters" feature-restored
Merge made by the 'ort' strategy.
bonus.txt | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 bonus.txt
$ git log --oneline --graph
* 12d99c0 merge bonus chapters
|\
| * e69290b add epilogue
| * 4b7d516 add bonus chapter
|/
* 3a9097c add chapter 4
* 0fb823f add chapter 3
* 9b479c9 add chapter 2
* a97e15e add chapter 1
$ git reset --hard ORIG_HEAD
HEAD is now at 3a9097c add chapter 4
$ git log --oneline --graph --all
* e69290b add epilogue
* 4b7d516 add bonus chapter
* 3a9097c add chapter 4
* 0fb823f add chapter 3
* 9b479c9 add chapter 2
* a97e15e add chapter 1
Git helps here with a special name, ORIG_HEAD. The gitrevisions documentation says commands that move HEAD in a drastic way, among them git merge and git reset, set it “to record the position of the HEAD before their operation, so that you can easily change the tip of the branch back to the state before you ran them”. So git reset --hard ORIG_HEAD undoes the merge in one command, and the graph afterwards shows main at add chapter 4 with the feature branch beside it, unmerged. The git-reset page uses the same trick in its own examples for undoing a merge or a pull.
git commit --amend replaces the latest commit with a new one, and it is not on the list of commands that set ORIG_HEAD (in a quick test of mine it left ORIG_HEAD exactly where an earlier reset had put it), so use the reflog. Amend the message, find the old commit in the reflog, and move back:
$ git commit -q --amend -m "add chapter 4 (typo fixed)"
$ git reflog -3
525ce61 HEAD@{0}: commit (amend): add chapter 4 (typo fixed)
3a9097c HEAD@{1}: reset: moving to ORIG_HEAD
12d99c0 HEAD@{2}: merge feature-restored: Merge made by the 'ort' strategy.
$ git reset --hard HEAD@{1}
HEAD is now at 3a9097c add chapter 4
$ git log --oneline -1
3a9097c add chapter 4
The reflog shows three moves: the amend (HEAD@{0}), the reset we just did (HEAD@{1}, which is also the original add chapter 4 commit), and the merge. Resetting to HEAD@{1} puts the original commit back.
Gotcha: ORIG_HEAD lasts one command
The next merge, rebase or reset overwrites it. Use it immediately, or copy the hash from the reflog first. The reflog keeps the whole sequence; ORIG_HEAD keeps only the latest.
Step 6: Get back a file you staged but never committed
Now the surprising one. Start a second practice repository, so the output stays short:
$ cd ..
$ mkdir second-lab
$ cd second-lab
$ git init -q -b main
$ git config user.name "Lab User"
$ git config user.email "[email protected]"
$ git config core.autocrlf false
$ printf 'chapter 1\n' > story.txt
$ git add story.txt
$ git commit -q -m "add chapter 1"
Create a new file, stage it, and then run a hard reset by mistake:
$ printf 'precious idea: tell the story backwards\n' > ideas.txt
$ git add ideas.txt
$ git reset --hard
HEAD is now at a97e15e add chapter 1
$ ls
story.txt
ideas.txt is gone from the folder. The git-reset documentation says hard mode removes tracked files that are not in the target commit, and a staged file counts as tracked. But git add had already copied the file’s content into the object database as a blob, and nothing deleted that blob. It is dangling now, which means git fsck can list it:
$ git fsck --lost-found
dangling blob 6053febc4a021f5a6eb6712c017316b2f660183c
$ git show 6053feb
precious idea: tell the story backwards
$ git cat-file -p 6053feb > ideas.txt
$ cat ideas.txt
precious idea: tell the story backwards
$ git add ideas.txt
$ git commit -q -m "recover the idea"
git fsck --lost-found printed dangling blob 6053febc4a021f5a6eb6712c017316b2f660183c. The documentation says the flag will “Write dangling objects into .git/lost-found/commit/ or .git/lost-found/other/, depending on type. If the object is a blob, the contents are written into the file, rather than its object name.” git show 6053feb printed the file’s text, git cat-file -p 6053feb > ideas.txt wrote it back to disk, and the last two commands committed it so that it is safe again. The Pro Git book describes the same recovery for a missing commit: “You can recover it the same way, by adding a branch that points to that SHA-1.”
Gotcha: blobs have no names and no dates
A dangling blob is only content: no file name, no time. With many of them you have to recognise yours by reading it, or search every stored object for a phrase, which Step 7 shows. And only files that were staged at some point are in the database; Step 7 shows what that leaves out.
Step 7: Find out what Git cannot give back
Now lose two kinds of work that Git never saw. Edit a tracked file without staging the edit, and create a brand-new file you never add:
$ printf 'chapter 1, edited\n' > story.txt
$ printf 'scratch notes\n' > scratch.txt
$ git status --short
M story.txt
?? scratch.txt
In git status --short, M in the second column means modified but not staged, and ?? means untracked. Now run the two cleanup commands people reach for most:
$ git reset --hard
HEAD is now at 0c2a18d recover the idea
$ git clean -fd
Removing scratch.txt
$ git status --short
git reset --hard threw away the edit to story.txt. git clean -fd deleted scratch.txt: the git-clean documentation says it “Cleans the working tree by recursively removing files that are not under version control”, and adds that with the -x option “ignored files are also removed”. Ask Git whether it can help:
$ git fsck --lost-found
$ git cat-file --batch-all-objects --batch | grep -a -c 'scratch notes'
0
$ git cat-file --batch-all-objects --batch | grep -a -c 'chapter 1, edited'
0
git fsck --lost-found found nothing dangling. The last two commands are the proof. git cat-file --batch-all-objects --batch prints every object in the database one after another, and grep -a -c counts the lines that contain a phrase (-a treats the binary stream as text). Both counts are 0: the words scratch notes and chapter 1, edited exist nowhere inside .git. Git never saw them, so Git cannot return them.
| State of your work | In Git’s database? | How to get it back |
|---|---|---|
| Committed | Yes, as a commit | The reflog (git reflog), Steps 2 to 5 |
Staged with git add, not committed |
Yes, as a blob | git fsck --lost-found, Step 6 |
| Edited, never staged | No | Nothing in Git |
| New file, never added | No | Nothing in Git |
Ignored file such as .env |
No | Nothing in Git |
Your editor’s local history or an operating system backup may still hold copies of the last three rows, but those live outside Git, so do not count on them. Before you run git clean on anything you care about, run it with -n first: the documentation describes that option as “Don’t actually remove anything, just show what would be done”, which is exactly the safety check you want.
Step 8: Save a snapshot by hand with Git’s own plumbing
The fix for the bottom three rows is to make Git store them before anything goes wrong. Git can do that without disturbing your work, using a trick: build a commit of your whole working tree through a temporary index, so your real index, your branch and your stash are never touched. Create unsaved work first, then follow the recipe one command at a time:
$ printf 'chapter 1, edited\n' > story.txt
$ printf 'scratch notes\n' > scratch.txt
$ export GIT_INDEX_FILE=.git/snapshot-index
$ cp -p .git/index "$GIT_INDEX_FILE"
$ git add -A
$ tree=$(git write-tree)
$ commit=$(git commit-tree "$tree" -p HEAD -m "manual snapshot")
$ unset GIT_INDEX_FILE
$ git update-ref refs/snapshots/manual "$commit"
$ rm .git/snapshot-index
export GIT_INDEX_FILE=.git/snapshot-indextells every Git command to use that file as the index instead of the real.git/index. (The relative path only works from the top of the repository.)cp -p .git/index "$GIT_INDEX_FILE"starts the temporary index as a copy of the real one. The-pkeeps the file’s timestamp, which matters more than it looks; Step 12 shows why.git add -Astages everything into the temporary index. The git-add documentation says-A“adds, modifies, and removes index entries to match the working tree”, so new, changed and deleted files are all included, and files your.gitignoreexcludes are not. This is the moment your unsaved text becomes real objects in the database.git write-treeturns that index into a tree object: “Creates a tree object using the current index”, and prints its hash, which the shell keeps intree.git commit-treewraps the tree in a commit withHEADas its parent. In the documentation’s words it “Creates a new commit object based on the provided tree object and emits the new commit object id on stdout”.unset GIT_INDEX_FILEreturns to the real index.git update-ref refs/snapshots/manual "$commit"gives the commit a name. A commit nothing points at is dangling and eventually collected; one with a ref is safe. A ref underrefs/snapshots/is not a branch, so it stays out of your daily way.
Check that your real state was not disturbed and that the snapshot holds everything:
$ git status --short
M story.txt
?? scratch.txt
$ git ls-tree -r --name-only refs/snapshots/manual
ideas.txt
scratch.txt
story.txt
The first status still shows M story.txt and ?? scratch.txt, exactly as before, and the snapshot’s file list includes the untracked scratch.txt. git stash create sounds like the same thing, so here is why it is not:
$ git ls-tree -r --name-only $(git stash create)
ideas.txt
story.txt
The git-stash documentation says create will “Create a stash entry (which is a regular commit object) and return its object name, without storing it anywhere in the ref namespace”. It is a useful building block, but as the output shows it skipped scratch.txt, because stashing leaves untracked files alone unless you use git stash push -u, which cleans your folder as a side effect. The temporary-index recipe captures untracked files too. Now do the damage and restore from the snapshot:
$ git reset --hard -q
$ git clean -fdq
$ git status --short
$ git restore --source=refs/snapshots/manual --worktree --overlay -- .
$ git status --short
M story.txt
?? scratch.txt
$ cat story.txt scratch.txt
chapter 1, edited
scratch notes
git restore --source=refs/snapshots/manual --worktree --overlay -- . reads the files from the snapshot commit (--source), writes them into your folder only (--worktree), and never deletes anything (--overlay). The git-restore documentation explains that “In overlay mode, never remove files when restoring”, while the default no-overlay mode removes tracked files that are not in the source tree so the folder matches it exactly. Overlay is the safe choice when you are rescuing work. Status shows the edit and the untracked file back, and cat prints their text.
Step 9: Turn the recipe into a tool
Typing eight commands in a hurry, with a broken repository in front of you, is how mistakes happen. Save the recipe as gitsnap.py in the folder that holds your practice repositories (not inside one of them). It uses only the Python standard library and calls the same Git commands you just ran. Read it in three parts. The first part is the heart: build() is the recipe, written to be safe in awkward situations.
# gitsnap.py
"""Save the whole working tree as a commit under refs/snapshots/ without touching HEAD, the index, or the stash.
python gitsnap.py save [-m LABEL] [--also PATH ...]
python gitsnap.py list
python gitsnap.py restore SNAPSHOT [PATH ...] [--exact]
python gitsnap.py prune --keep N
python gitsnap.py guard [-m LABEL] [--also PATH ...] -- COMMAND [ARGS ...]
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
NS = "refs/snapshots/"
FALLBACK = {
"GIT_AUTHOR_NAME": "gitsnap", "GIT_AUTHOR_EMAIL": "gitsnap@localhost",
"GIT_COMMITTER_NAME": "gitsnap", "GIT_COMMITTER_EMAIL": "gitsnap@localhost",
}
def git(args, cwd, env=None):
p = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if p.returncode != 0:
raise SystemExit(f"gitsnap: git {' '.join(args)} failed: {p.stderr.strip()}")
return p.stdout.strip()
def ok(args, cwd, env=None):
return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True).returncode == 0
def root_of(cwd="."):
return git(["rev-parse", "--show-toplevel"], cwd)
def numbered(root):
"""Existing snapshot refs as a sorted list of (number, refname)."""
names = git(["for-each-ref", "--format=%(refname)", NS], root).split()
return sorted((int(n.rsplit("/", 1)[1]), n) for n in names if n.rsplit("/", 1)[1].isdigit())
def build(root, also=(), label=""):
"""Return (tree, commit) for the working tree as it is right now, using a throwaway index."""
gitdir = git(["rev-parse", "--absolute-git-dir"], root)
parents = ["-p", "HEAD"] if ok(["rev-parse", "--verify", "--quiet", "HEAD"], root) else []
with tempfile.TemporaryDirectory() as tmp:
env = dict(os.environ, GIT_INDEX_FILE=os.path.join(tmp, "index"))
real = os.path.join(gitdir, "index")
if os.path.exists(real):
shutil.copy2(real, env["GIT_INDEX_FILE"]) # copy2 keeps the timestamp Git's racy-edit check relies on
git(["add", "-A"], root, env)
for path in also:
git(["add", "-f", "--", path], root, env)
tree = git(["write-tree"], root, env)
if not ok(["var", "GIT_COMMITTER_IDENT"], root, env):
env.update(FALLBACK)
message = f"snapshot: {label}" if label else "snapshot"
commit = git(["commit-tree", tree, *parents, "-m", message], root, env)
return tree, commit
Here is what the details are for. git rev-parse --absolute-git-dir finds the real Git folder even in a linked worktree or a subfolder. The parent is added only if HEAD exists, because a brand-new repository has no commits. The temporary index lives in a temporary folder that Python deletes afterwards. --also names ignored files you do want saved, such as .env, using git add -f (force). git var GIT_COMMITTER_IDENT asks Git whether it knows who you are, and if it does not, the tool uses the name gitsnap so the snapshot never fails for lack of an identity. shutil.copy2 copies the index with its timestamp; Step 12 returns to that.
The second part names, lists, restores and prunes snapshots:
# gitsnap.py (continued)
def store(root, commit):
"""Point the next free refs/snapshots/NNNN name at the commit; never overwrite an existing name."""
for _ in range(20):
ref = f"{NS}{max((n for n, _ in numbered(root)), default=0) + 1:04d}"
if ok(["update-ref", ref, commit, ""], root):
return ref
raise SystemExit("gitsnap: could not allocate a snapshot name")
def save(cwd=".", label="", also=()):
root = root_of(cwd)
_, commit = build(root, also, label)
return store(root, commit), commit
def listing(cwd="."):
fmt = "%(refname:short)%09%(objectname:short)%09%(committerdate:iso-strict)%09%(subject)"
return git(["for-each-ref", "--sort=refname", f"--format={fmt}", NS], root_of(cwd))
def full_ref(name):
if name.isdigit():
return f"{NS}{int(name):04d}"
return "refs/" + name if name.startswith("snapshots/") else name
def restore(cwd, name, paths=(), exact=False):
root = root_of(cwd)
ref = full_ref(name)
if not ok(["rev-parse", "--verify", "--quiet", ref + "^{commit}"], root):
raise SystemExit(f"gitsnap: no such snapshot: {name}")
mode = "--no-overlay" if exact else "--overlay"
git(["restore", f"--source={ref}", "--worktree", mode, "--", *(paths or ["."])], root)
return ref
def prune(cwd, keep):
root = root_of(cwd)
old = numbered(root)[:-keep] if keep > 0 else numbered(root)
for _, ref in old:
git(["update-ref", "-d", ref], root)
return len(old)
Snapshots are numbered 0001, 0002 and so on. store() creates the ref with git update-ref ref commit "", and the empty string matters: the git-update-ref documentation says an empty old value is how you ask “to make sure that the ref you are creating does not exist”. If two snapshots race for the same number, one fails and the loop tries the next number instead of overwriting. restore() uses the --overlay mode from Step 8, and --exact switches to no-overlay for the rare case where you want the folder to match the snapshot exactly. prune() deletes the oldest refs and keeps the newest N.
The third part is guard, which wraps any command in a snapshot before and another after, and the command-line wiring:
# gitsnap.py (continued)
def guard(cwd, command, also=()):
root = root_of(cwd)
text = " ".join(command)
tree0, commit0 = build(root, also, f"before {text}")
print(f"gitsnap: saved {store(root, commit0)} before the command", flush=True)
try:
code = subprocess.run(command, cwd=cwd).returncode
except FileNotFoundError:
print(f"gitsnap: command not found: {command[0]}", file=sys.stderr)
code = 127
tree1, commit1 = build(root, also, f"after {text}")
if tree1 == tree0:
print(f"gitsnap: command exited {code}, working tree unchanged", flush=True)
else:
print(f"gitsnap: command exited {code}, saved {store(root, commit1)} after the command", flush=True)
return code
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
command = []
if argv and argv[0] == "guard" and "--" in argv:
cut = argv.index("--")
argv, command = argv[:cut], argv[cut + 1:]
p = argparse.ArgumentParser(prog="gitsnap")
sub = p.add_subparsers(dest="cmd", required=True)
for name in ("save", "guard"):
s = sub.add_parser(name)
s.add_argument("-m", "--message", default="")
s.add_argument("--also", action="append", default=[], metavar="PATH")
sub.add_parser("list")
r = sub.add_parser("restore")
r.add_argument("snapshot")
r.add_argument("paths", nargs="*")
r.add_argument("--exact", action="store_true")
q = sub.add_parser("prune")
q.add_argument("--keep", type=int, required=True)
a = p.parse_args(argv)
if a.cmd == "save":
ref, commit = save(".", a.message, a.also)
print(f"saved {ref} ({commit[:7]})")
elif a.cmd == "list":
print(listing("."))
elif a.cmd == "restore":
print(f"restored files from {restore('.', a.snapshot, a.paths, a.exact)}")
elif a.cmd == "prune":
print(f"removed {prune('.', a.keep)} old snapshot(s)")
else:
if not command:
p.error("guard needs a command after --")
return guard(".", command, a.also)
return 0
if __name__ == "__main__":
sys.exit(main())
guard runs your command as an argument list, not through a shell, and returns its exit status unchanged, so it is safe to put in front of other tools (our subprocess tutorial explains why that matters). It saves an after snapshot only if the working tree changed, so a command that changed nothing does not leave a duplicate behind. Try it, still inside second-lab. First save, list, and confirm the snapshots do not disturb your branches or your stash:
$ python ../gitsnap.py save -m "before the agent runs"
saved refs/snapshots/0001 (c0b2433)
$ python ../gitsnap.py list
snapshots/0001 c0b2433 2026-11-11T09:00:00Z snapshot: before the agent runs
snapshots/manual d340627 2026-11-11T09:00:00Z manual snapshot
$ git branch
* main
$ git stash list
gitsnap list shows each snapshot’s name, short hash, time and label, and git branch and git stash list are unchanged (the stash list is empty). Next, let guard wrap a command that misbehaves on purpose. This stand-in does what a careless script or agent might do, a hard reset followed by a clean:
$ python ../gitsnap.py guard -- bash -c "git reset --hard -q && git clean -fdq"
gitsnap: saved refs/snapshots/0002 before the command
gitsnap: command exited 0, saved refs/snapshots/0003 after the command
$ git status --short
$ python ../gitsnap.py list
snapshots/0001 c0b2433 2026-11-11T09:00:00Z snapshot: before the agent runs
snapshots/0002 f7b2ad0 2026-11-11T09:00:00Z snapshot: before bash -c git reset --hard -q && git clean -fdq
snapshots/0003 aee07d0 2026-11-11T09:00:00Z snapshot: after bash -c git reset --hard -q && git clean -fdq
snapshots/manual d340627 2026-11-11T09:00:00Z manual snapshot
The command ran, git status --short printed nothing because all your work was wiped, and the list now holds three numbered snapshots: 0001 (the save you ran first), 0002 (before the command) and 0003 (after it), plus the manual one from Step 8. Roll back to the one taken before:
$ python ../gitsnap.py restore 0002
restored files from refs/snapshots/0002
$ git status --short
M story.txt
?? scratch.txt
$ python ../gitsnap.py prune --keep 2
removed 1 old snapshot(s)
The edit and the untracked file are back. prune --keep 2 then removed the oldest numbered snapshot, 0001; Step 11 looks at what that leaves behind.
Gotcha: a snapshot copies everything it sees
Snapshots are real commits holding full copies of your files, including anything secret that is not ignored and any large build output that is not ignored. They are stored inside .git on your machine and are not sent anywhere by ordinary commands, but a mirror push does: the git-push documentation says --mirror sends all refs under refs/, which includes refs/snapshots/. Keep your ignore rules right, and use --also .env only for files you accept storing in the repository’s database. To protect a file the ignore rules skip, run python gitsnap.py save --also .env.
Where to run it
Put python /path/to/gitsnap.py guard -- in front of the command that starts a script or an AI coding tool, and you get a restore point on every launch. If your tool can run a command before each action it takes, you can call python gitsnap.py save from there; check that tool’s own documentation for how, since I did not test any.
Step 10: Measure what the snapshot saves
Claims about recovery should be measured, so matrix.py runs eight kinds of loss in throwaway repositories, twice each: once with plain Git, once after gitsnap save. First the rows and the scenarios:
# matrix.py
"""For each way of losing work, ask whether anything can be recovered afterwards, and how.
Every row runs twice in a throwaway repository: once with plain Git, once after `gitsnap save`.
"""
import subprocess
import tempfile
from pathlib import Path
import gitsnap
def git(repo, *args):
p = subprocess.run(["git", *args], cwd=repo, capture_output=True)
if p.returncode != 0:
raise RuntimeError(p.stderr.decode(errors="replace"))
return p.stdout.decode(errors="replace")
def put(path, text):
Path(path).write_bytes(text.encode())
def fresh(base):
repo = Path(tempfile.mkdtemp(dir=base))
git(repo, "init", "-q", "-b", "main")
git(repo, "config", "user.name", "Matrix")
git(repo, "config", "user.email", "[email protected]")
git(repo, "config", "core.autocrlf", "false")
put(repo / ".gitignore", ".env\n")
put(repo / "story.txt", "one\n")
git(repo, "add", ".")
git(repo, "commit", "-q", "-m", "base")
return repo
def committed(repo):
put(repo / "work.txt", "NOTE-COMMITTED\n")
git(repo, "add", "work.txt")
git(repo, "commit", "-q", "-m", "work")
def staged(repo):
put(repo / "idea.txt", "NOTE-STAGED\n")
git(repo, "add", "idea.txt")
def unstaged(repo):
put(repo / "story.txt", "one\nNOTE-UNSTAGED\n")
def untracked(repo):
put(repo / "scratch.txt", "NOTE-UNTRACKED\n")
def ignored(repo):
put(repo / ".env", "NOTE-IGNORED\n")
def restaged(repo):
put(repo / "idea.txt", "NOTE-FIRST-DRAFT\n")
git(repo, "add", "idea.txt")
put(repo / "idea.txt", "NOTE-SECOND-DRAFT\n")
# (what you lost, command shown, setup, git arguments that do the damage, --also paths for the snapshot, notes to look for)
ROWS = [
("Committed work", "git reset --hard HEAD~1", committed, ["reset", "--hard", "-q", "HEAD~1"], [],
[("", "NOTE-COMMITTED")]),
("New file, staged only", "git reset --hard", staged, ["reset", "--hard", "-q"], [],
[("", "NOTE-STAGED")]),
("Edit to a tracked file", "git restore .", unstaged, ["restore", "."], [],
[("", "NOTE-UNSTAGED")]),
("New file, never added", "git clean -fd", untracked, ["clean", "-fdq"], [],
[("", "NOTE-UNTRACKED")]),
("Ignored file (.env)", "git clean -fdx", ignored, ["clean", "-fdxq"], [],
[("", "NOTE-IGNORED")]),
("Same, saved with --also .env", "git clean -fdx", ignored, ["clean", "-fdxq"], [".env"],
[("", "NOTE-IGNORED")]),
("Staged, then edited again", "git reset --hard", restaged, ["reset", "--hard", "-q"], [],
[(" (staged draft)", "NOTE-FIRST-DRAFT"), (" (later edit)", "NOTE-SECOND-DRAFT")]),
]
Save these two blocks together as matrix.py. Each row is a way of losing work: a setup that creates the work, the damaging command, any --also paths, and a marker text to look for. The last row stages a first draft and then edits the file again, which gives two markers: the staged draft and the later edit. Now the part that decides where a marker can still be found:
# matrix.py (continued)
def where(repo, note):
"""Say where a note can still be found: working tree, snapshot, reflog, fsck (dangling), or gone."""
needle = note.encode()
for path in repo.rglob("*"):
if ".git" not in path.parts and path.is_file() and needle in path.read_bytes():
return "working tree"
data = subprocess.run(["git", "cat-file", "--batch-all-objects", "--batch"], cwd=repo,
capture_output=True).stdout
found, i = [], 0
while i < len(data):
j = data.index(b"\n", i)
oid, kind, size = data[i:j].split()
body = data[j + 1:j + 1 + int(size)]
i = j + 1 + int(size) + 1
if kind == b"blob" and needle in body:
found.append(oid.decode())
if not found:
return "gone"
snaps = git(repo, "for-each-ref", "--format=%(refname)", "refs/snapshots/").split()
in_snap = {l.split()[0] for l in git(repo, "rev-list", "--objects", *snaps).splitlines()} if snaps else set()
in_reflog = {l.split()[0] for l in git(repo, "rev-list", "--objects", "--reflog").splitlines()}
if any(oid in in_snap for oid in found):
return "snapshot"
if any(oid in in_reflog for oid in found):
return "reflog"
return "fsck"
def attempt(base, setup, damage, also, notes, snapshot):
repo = fresh(base)
setup(repo)
if snapshot:
gitsnap.save(repo, "matrix", also)
git(repo, *damage)
return [where(repo, note) for _, note in notes]
def main():
print(f"{'What you lost':<42}{'Damage done by':<26}{'Plain Git':<11}After gitsnap save")
with tempfile.TemporaryDirectory() as base:
for lost, command, setup, damage, also, notes in ROWS:
plain = attempt(base, setup, damage, also, notes, snapshot=False)
snap = attempt(base, setup, damage, also, notes, snapshot=True)
for (suffix, _), a, b in zip(notes, plain, snap):
print(f"{lost + suffix:<42}{command:<26}{a:<11}{b}")
if __name__ == "__main__":
main()
where() searches in order: the working tree, then every object in the database. If the text is stored, it asks whether a snapshot ref reaches it (snapshot), or a reflog entry reaches it (reflog); if neither does, the object is dangling and only git fsck can find it (fsck). If no stored object contains the text, the answer is gone. Run it from the folder that holds gitsnap.py:
$ cd ..
$ python matrix.py
What you lost Damage done by Plain Git After gitsnap save
Committed work git reset --hard HEAD~1 reflog snapshot
New file, staged only git reset --hard fsck snapshot
Edit to a tracked file git restore . gone snapshot
New file, never added git clean -fd gone snapshot
Ignored file (.env) git clean -fdx gone gone
Same, saved with --also .env git clean -fdx gone snapshot
Staged, then edited again (staged draft) git reset --hard fsck fsck
Staged, then edited again (later edit) git reset --hard gone snapshot
Read it row by row. A committed change survives a reset through the reflog, and a snapshot also holds it. A new file staged with git add survives git reset --hard as a dangling blob (fsck). An edit to a tracked file killed by git restore . and a new file killed by git clean -fd are gone in plain Git, and a snapshot brings both back. The ignored .env is gone in both columns unless the snapshot was told about it with --also .env, and then it comes back.
The last two rows teach the most. Stage a file, then keep editing it, then reset: Git can return the staged draft (fsck in both columns), but only a snapshot can return the later edit, because the snapshot was taken from the working tree and Git never stored the newer text. Count the columns: plain Git recovered 3 of the 8 losses, and with a snapshot saved first 7 of 8 came back.
Step 11: Know how long recovery lasts
Recovery from the reflog and git fsck has a clock on it. Three settings in the git-gc documentation set it: gc.reflogExpire “removes reflog entries older than this time; defaults to 90 days”, gc.reflogExpireUnreachable does the same for entries “not reachable from the current tip; defaults to 30 days”, and git gc --prune removes loose unreachable objects older than a date whose “default is 2 weeks ago”. You do not even have to run git gc yourself: the same page says common commands “check whether the repository has grown substantially since the last maintenance” and run it automatically if so. Reflogs also exist only where Git writes them; the git-config documentation says core.logAllRefUpdates is “true by default in a repository that has a working directory associated with it, and false by default in a bare repository”, so a bare repository (the usual kind on a server) has no reflog by default.
See the clock in action. Go back to second-lab, stage a file, reset it away, and list what is dangling:
$ cd second-lab
$ printf 'a fleeting thought\n' > thought.txt
$ git add thought.txt
$ git reset --hard -q
$ git fsck --lost-found
dangling commit c0b2433fe6dabbb2251a2c1e1b978b188983dd59
dangling commit 22be496560271fc97bf82d30003a001a23a2f10e
dangling blob 3c766982ae109e3b89f97bae49a04e11eb526bef
There are three dangling objects: the blob of thought.txt, a commit from git stash create in Step 8 (nothing was ever named to point at it), and the snapshot 0001 that Step 9 pruned (deleting its ref did not delete the commit). Run an ordinary garbage collection:
$ git gc -q
$ git fsck --lost-found
dangling commit c0b2433fe6dabbb2251a2c1e1b978b188983dd59
dangling commit 22be496560271fc97bf82d30003a001a23a2f10e
dangling blob 3c766982ae109e3b89f97bae49a04e11eb526bef
Nothing changed. All three objects are only minutes old, well inside the two-week grace period, so a plain git gc keeps them. Now do what some guides recommend for tidying a repository: expire every reflog entry immediately and prune with no grace period.
$ git reflog expire --expire=now --all
$ git gc -q --prune=now
$ git fsck --lost-found
$ git cat-file -p 3c76698
fatal: Not a valid object name 3c76698
$ python ../gitsnap.py list
snapshots/0002 f7b2ad0 2026-11-11T09:00:00Z snapshot: before bash -c git reset --hard -q && git clean -fdq
snapshots/0003 aee07d0 2026-11-11T09:00:00Z snapshot: after bash -c git reset --hard -q && git clean -fdq
snapshots/manual d340627 2026-11-11T09:00:00Z manual snapshot
git reflog expire --expire=now --all emptied the reflogs, and git gc --prune=now removed everything unreachable: the documentation says --prune=now “prunes loose objects regardless of their age”. The dangling list is empty, and git cat-file -p 3c76698 reports Not a valid object name. The snapshots 0002, 0003 and manual are untouched, because a ref keeps its commit and everything under it reachable. Those two commands are the right tool when you must destroy a leaked secret for good (see our git filter-repo tutorial), and exactly the wrong ones to run while you are still trying to get something back.
Step 12: Test the tool, then test the tests
A recovery tool that fails silently is worse than none, so gitsnap comes with tests that run against real Git in temporary repositories. Save the next three code blocks together as test_gitsnap.py. The first holds the setup that makes every test repeatable:
# test_gitsnap.py
import os
import subprocess
import sys
import pytest
import gitsnap
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(autouse=True)
def hermetic(monkeypatch, tmp_path):
"""Ignore the machine's own Git configuration so every test starts from Git's defaults."""
empty = tmp_path / "empty-gitconfig"
empty.write_bytes(b"")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(empty))
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
for name in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"):
monkeypatch.delenv(name, raising=False)
def run(repo, *args):
p = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, encoding="utf-8")
assert p.returncode == 0, p.stderr
return p.stdout.strip()
def put(path, text):
path.write_bytes(text.encode())
def get(path):
return path.read_bytes().decode()
def make_repo(path):
path.mkdir()
run(path, "init", "-q", "-b", "main")
run(path, "config", "user.name", "Test User")
run(path, "config", "user.email", "[email protected]")
run(path, "config", "core.autocrlf", "false")
return path
@pytest.fixture
def repo(tmp_path):
path = make_repo(tmp_path / "repo")
put(path / "story.txt", "one\n")
run(path, "add", "story.txt")
run(path, "commit", "-q", "-m", "first")
return path
def state(repo):
return {
"status": run(repo, "status", "--porcelain=v1", "-uall"),
"index": run(repo, "ls-files", "--stage"),
"head": run(repo, "rev-parse", "HEAD"),
"reflog": run(repo, "reflog"),
"stash": run(repo, "stash", "list"),
"branches": run(repo, "branch", "--list"),
}
def dirty(repo):
put(repo / "story.txt", "one\nunstaged edit\n")
put(repo / "staged.txt", "staged only\n")
run(repo, "add", "staged.txt")
put(repo / "scratch.txt", "untracked\n")
def wreck(repo):
run(repo, "reset", "--hard", "-q")
run(repo, "clean", "-fdq")
def tree_files(repo, ref):
return run(repo, "ls-tree", "-r", "--name-only", ref).splitlines()
The hermetic fixture points Git at an empty configuration file so your own settings (and mine) cannot change a result. state() collects the status, the index, HEAD, the reflog, the stash list and the branches, so a test can prove that saving a snapshot changed none of them. put() and get() read and write bytes, because writing text on Windows would turn each line feed into a carriage return and a line feed. The first group of tests covers saving, including from a subfolder and from a linked worktree:
# test_gitsnap.py (continued)
def test_save_captures_everything_and_changes_nothing(repo):
dirty(repo)
before = state(repo)
ref, commit = gitsnap.save(repo, "test")
assert ref == "refs/snapshots/0001"
assert state(repo) == before
assert sorted(tree_files(repo, ref)) == ["scratch.txt", "staged.txt", "story.txt"]
assert run(repo, "show", f"{ref}:story.txt") == "one\nunstaged edit"
assert run(repo, "rev-parse", f"{ref}^") == before["head"]
def test_ignored_files_need_also(repo):
put(repo / ".gitignore", ".env\n")
run(repo, "add", ".gitignore")
run(repo, "commit", "-q", "-m", "ignore")
put(repo / ".env", "SECRET=1\n")
ref, _ = gitsnap.save(repo)
assert ".env" not in tree_files(repo, ref)
ref2, _ = gitsnap.save(repo, also=[".env"])
assert ".env" in tree_files(repo, ref2)
def test_tracked_file_that_matches_gitignore_is_kept(repo):
put(repo / ".gitignore", "*.log\n")
put(repo / "keep.log", "v1\n")
run(repo, "add", ".gitignore")
run(repo, "add", "-f", "keep.log")
run(repo, "commit", "-q", "-m", "force-added log")
put(repo / "keep.log", "v2\n")
ref, _ = gitsnap.save(repo)
assert run(repo, "show", f"{ref}:keep.log") == "v2"
def test_the_copied_index_keeps_the_timestamp_of_the_real_index(repo, monkeypatch):
long_ago = 10**18 # nanoseconds since 1970, an instant in 2001
os.utime(repo / ".git" / "index", ns=(long_ago, long_ago))
seen = []
real_git = gitsnap.git
def spy(args, cwd, env=None):
if args[:2] == ["add", "-A"]:
seen.append(os.stat(env["GIT_INDEX_FILE"]).st_mtime_ns)
return real_git(args, cwd, env)
monkeypatch.setattr(gitsnap, "git", spy)
gitsnap.save(repo)
assert seen == [long_ago]
def test_repository_without_any_commit(tmp_path):
path = make_repo(tmp_path / "fresh")
put(path / "a.txt", "a\n")
ref, commit = gitsnap.save(path)
assert tree_files(path, ref) == ["a.txt"]
assert run(path, "rev-list", "--parents", "-n1", commit) == commit
def test_works_from_a_subdirectory(repo):
sub = repo / "deep" / "er"
sub.mkdir(parents=True)
put(sub / "x.txt", "x\n")
ref, _ = gitsnap.save(sub)
assert "deep/er/x.txt" in tree_files(repo, ref)
def test_works_in_a_linked_worktree(repo, tmp_path):
other = tmp_path / "other"
run(repo, "worktree", "add", "-q", "-b", "side", str(other))
put(other / "side.txt", "side\n")
ref, _ = gitsnap.save(other)
assert tree_files(repo, ref) == ["side.txt", "story.txt"]
assert run(repo, "rev-parse", f"{ref}^") == run(other, "rev-parse", "HEAD")
def test_fallback_identity_when_git_has_none(repo):
run(repo, "config", "--unset", "user.name")
run(repo, "config", "--unset", "user.email")
run(repo, "config", "user.useConfigOnly", "true")
_, commit = gitsnap.save(repo)
assert run(repo, "log", "-1", "--format=%an <%ae>", commit) == "gitsnap <gitsnap@localhost>"
Two of these deserve a closer look. test_tracked_file_that_matches_gitignore_is_kept forces an ignored-looking file into the repository and edits it; the snapshot must hold the new version, which only happens because the temporary index starts as a copy of the real one. And test_the_copied_index_keeps_the_timestamp_of_the_real_index exists because of a failure I did not expect. The second group covers restoring, pruning and the wrapper:
# test_gitsnap.py (continued)
def test_restore_after_reset_and_clean(repo):
dirty(repo)
gitsnap.save(repo)
wreck(repo)
assert get(repo / "story.txt") == "one\n"
assert not (repo / "scratch.txt").exists() and not (repo / "staged.txt").exists()
gitsnap.restore(repo, "1")
assert get(repo / "story.txt") == "one\nunstaged edit\n"
assert get(repo / "scratch.txt") == "untracked\n"
assert get(repo / "staged.txt") == "staged only\n"
def test_restore_one_path_only(repo):
dirty(repo)
gitsnap.save(repo)
wreck(repo)
gitsnap.restore(repo, "0001", ["scratch.txt"])
assert get(repo / "scratch.txt") == "untracked\n"
assert get(repo / "story.txt") == "one\n"
def test_overlay_keeps_files_created_later_and_exact_removes_them(repo):
gitsnap.save(repo)
put(repo / "late.txt", "late\n")
run(repo, "add", "late.txt")
gitsnap.restore(repo, "1")
assert (repo / "late.txt").exists()
gitsnap.restore(repo, "1", exact=True)
assert not (repo / "late.txt").exists()
def test_restore_rejects_an_unknown_snapshot(repo):
with pytest.raises(SystemExit, match="no such snapshot"):
gitsnap.restore(repo, "42")
def test_save_during_a_merge_conflict(repo):
run(repo, "switch", "-q", "-c", "other")
put(repo / "story.txt", "other side\n")
run(repo, "commit", "-q", "-am", "other")
run(repo, "switch", "-q", "main")
put(repo / "story.txt", "main side\n")
run(repo, "commit", "-q", "-am", "main")
merge = subprocess.run(["git", "merge", "other"], cwd=repo, capture_output=True, text=True)
assert merge.returncode != 0
unmerged = run(repo, "ls-files", "-u")
assert unmerged
ref, _ = gitsnap.save(repo)
assert run(repo, "ls-files", "-u") == unmerged
assert "<<<<<<<" in run(repo, "show", f"{ref}:story.txt")
def test_numbering_and_prune_keep_the_newest(repo):
refs = [gitsnap.save(repo, str(i))[0] for i in range(3)]
assert [r[-4:] for r in refs] == ["0001", "0002", "0003"]
assert gitsnap.prune(repo, 1) == 2
assert run(repo, "for-each-ref", "--format=%(refname)", "refs/snapshots/") == "refs/snapshots/0003"
assert gitsnap.save(repo)[0] == "refs/snapshots/0004"
def test_snapshots_survive_reflog_expiry_and_gc(repo):
dirty(repo)
gitsnap.save(repo)
wreck(repo)
run(repo, "reflog", "expire", "--expire=now", "--all")
run(repo, "gc", "-q", "--prune=now")
gitsnap.restore(repo, "1")
assert get(repo / "scratch.txt") == "untracked\n"
def test_guard_returns_the_exit_code_and_saves_before_and_after(repo):
cmd = [sys.executable, "-c", "import pathlib; pathlib.Path('made.txt').write_bytes(b'x'); raise SystemExit(3)"]
assert gitsnap.guard(repo, cmd) == 3
assert [n for n, _ in gitsnap.numbered(str(repo))] == [1, 2]
assert "made.txt" not in tree_files(repo, "refs/snapshots/0001")
assert "made.txt" in tree_files(repo, "refs/snapshots/0002")
def test_guard_skips_the_after_snapshot_when_nothing_changed(repo):
assert gitsnap.guard(repo, [sys.executable, "-c", "pass"]) == 0
assert len(gitsnap.numbered(str(repo))) == 1
def test_concurrent_saves_get_distinct_names(repo):
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(6) as pool:
refs = list(pool.map(lambda i: gitsnap.save(repo, str(i))[0], range(6)))
assert len(set(refs)) == 6
assert len(gitsnap.numbered(str(repo))) == 6
def test_command_line_interface(repo):
put(repo / "n.txt", "n\n")
tool = os.path.join(HERE, "gitsnap.py")
saved = subprocess.run([sys.executable, tool, "save", "-m", "cli"], cwd=repo, capture_output=True, text=True)
assert saved.returncode == 0 and saved.stdout.startswith("saved refs/snapshots/0001 (")
listed = subprocess.run([sys.executable, tool, "list"], cwd=repo, capture_output=True, text=True).stdout
assert "snapshots/0001" in listed and "snapshot: cli" in listed
Run the suite from the folder that holds the Python files:
$ cd ..
$ python -m pytest -q
................... [100%]
19 passed in 16.10s
The test that found a bug I could not reproduce
While I was writing these tests, one full run failed on the tracked-but-ignored case, and three reruns of that test on its own passed. The failing test edits a file from v1 to v2, the same number of bytes, a moment after committing it. Git’s racy-git documentation describes exactly this hazard: if an edit happens so fast that the file’s modification time does not change, “the cached stat information the index entry records still exactly match what you would see in the filesystem, even though the file foo is now different”. Git’s defence is to compare contents when the file’s time is “the same as (or newer than) the timestamp of the index file itself”. My first version copied the index with shutil.copyfile, which stamps the copy with the current time, so an entry could look older than the index and skip the content comparison. Switching to shutil.copy2, which keeps the original timestamp, was the fix.
I should be honest about the limits. I never made the original failure happen on demand, so the root cause is my best explanation, supported by the documentation and by the suite passing in every full run since, and not something I proved. What I did verify is narrower: the new test spies on the temporary index and checks that its timestamp equals the real index’s, and the mutation check below shows that test failing when the code goes back to copyfile.
Plant bugs on purpose
Passing tests only prove the tests pass. Save the next block as mutate.py; it copies gitsnap.py, plants one bug at a time (stage tracked files only, drop the parent, overwrite snapshot names, hide the exit status, and so on), and checks that the suite fails each time:
# mutate.py
"""Plant one bug at a time in a copy of gitsnap.py and check that the test suite notices."""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
MUTANTS = [
("add -A runs against the real index", 'git(["add", "-A"], root, env)', 'git(["add", "-A"], root)'),
("tracked files only", '["add", "-A"]', '["add", "-u"]'),
("snapshot has no parent", '["-p", "HEAD"] if ok(["rev-parse", "--verify", "--quiet", "HEAD"], root) else []', "[]"),
("real index is not copied", ' shutil.copy2(real, env["GIT_INDEX_FILE"])', " if False:\n pass"),
("index copy gets a new timestamp", "shutil.copy2(real,", "shutil.copyfile(real,"),
("--also without force", '["add", "-f", "--", path]', '["add", "--", path]'),
("restore always removes files", 'mode = "--no-overlay" if exact else "--overlay"', 'mode = "--no-overlay"'),
("prune keeps the oldest", "old = numbered(root)[:-keep] if keep > 0 else numbered(root)", "old = numbered(root)[keep:]"),
("guard hides the exit code", " return code\n", " return 0\n"),
("no fallback identity", " env.update(FALLBACK)", " pass"),
("guard always saves an after snapshot", " if tree1 == tree0:", " if False:"),
("snapshot numbers are reused", "max((n for n, _ in numbered(root)), default=0) + 1", "len(numbered(root)) + 1"),
("update-ref may overwrite", 'ok(["update-ref", ref, commit, ""], root)', 'ok(["update-ref", ref, commit], root)'),
("restore ignores the paths", '*(paths or ["."])', '"."'),
]
def main():
source = (HERE / "gitsnap.py").read_text(encoding="utf-8")
caught = 0
for name, old, new in MUTANTS:
assert source.count(old) == 1, f"mutant pattern must occur once: {name}"
with tempfile.TemporaryDirectory() as tmp:
shutil.copy(HERE / "test_gitsnap.py", tmp)
Path(tmp, "gitsnap.py").write_bytes(source.replace(old, new).encode("utf-8"))
result = subprocess.run([sys.executable, "-m", "pytest", "-x", "-q", "--no-header", "-p", "no:cacheprovider"],
cwd=tmp, capture_output=True, text=True)
verdict = "caught" if result.returncode != 0 else "SURVIVED"
caught += result.returncode != 0
print(f"{verdict:<9}{name}")
print(f"{caught} of {len(MUTANTS)} planted bugs caught")
return 0 if caught == len(MUTANTS) else 1
if __name__ == "__main__":
sys.exit(main())
Run it. It takes about a minute, because each mutant runs the suite until its first failure:
$ python mutate.py
caught add -A runs against the real index
caught tracked files only
caught snapshot has no parent
caught real index is not copied
caught index copy gets a new timestamp
caught --also without force
caught restore always removes files
caught prune keeps the oldest
caught guard hides the exit code
caught no fallback identity
caught guard always saves an after snapshot
caught snapshot numbers are reused
caught update-ref may overwrite
caught restore ignores the paths
14 of 14 planted bugs caught
All 14 planted bugs were caught. An earlier version of this check let one survive: my first attempt at a timestamp test never failed even with the bug planted, which is how I learned that test was not testing anything, and I replaced it with the spy.
A short decision guide
| What happened | First command | Then |
|---|---|---|
| Hard reset, bad merge, bad rebase | git reflog |
git reset --hard HEAD@{n} or ORIG_HEAD |
| Deleted a branch | Read the hash git branch -D printed |
git branch name hash, or find it in git reflog |
| Dropped a stash | Read the hash git stash drop printed |
git stash apply hash, or git fsck --no-reflogs |
| Staged a file, then lost it | git fsck --lost-found |
git cat-file -p hash > file |
| Edited or created it and never added it | python gitsnap.py list |
python gitsnap.py restore NNNN, if you saved one |
Habits that make this boring
- Commit early on a throwaway branch. A commit is the state Git is built to keep;
git commit -am wipevery few minutes turns the last three rows of the Step 7 table into the first row. - Look before you clean.
git clean -nfirst, every time. - Give agents their own space. Run parallel agents in separate worktrees, as in our git worktree tutorial, so one agent’s
git cleancannot reach another’s files. - Wrap risky runs in
guard. Two snapshots per run give you a restore point and turn the question of what a run changed intogit diff refs/snapshots/0002 refs/snapshots/0003. - Do not wipe the reflog while you still need it. Keep
git reflog expireandgit gc --prune=nowfor secrets you must destroy. - Keep an off-machine copy. The reflog and snapshots live in one folder on one disk. Push branches to a remote, and keep ordinary backups for everything Git does not store.
Check that everything works end to end
You are finished when all of these are true. Your practice repositories show the stories from Steps 2 to 6 recovered; git fsck --no-reflogs lists the dangling objects you expect and git fsck --lost-found prints nothing after the Step 11 wipe; python gitsnap.py list shows numbered snapshots and git branch shows only your branches; python matrix.py prints the eight rows with 3 of 8 recovered by plain Git and 7 of 8 after a snapshot; python -m pytest -q reports 19 passed; and python mutate.py ends with 14 of 14 planted bugs caught.
From here, learn git bisect to find the commit that broke something, or build the agent harness that limits what a model can touch in the first place. The best recovery is the one you never need, and the second best is the one you saved ten seconds before the mistake.








No Comment! Be the first one.