Can you undo what an AI coding agent did to your repository?
Git gives back exactly what it saw at least once, and the line is the index rather than the commit. On August 15, 2026, with git 2.50.1, we destroyed the same piece of work six different ways in six throwaway repositories and tried to recover it. Four came back and two were gone. The two that were gone had never been through a single git add. That is the whole rule: one git add you never committed is enough to get the file back, and work an AI coding agent created and destroyed without staging it never existed as far as git is concerned.
The script that destroys work an AI coding agent could destroy
We wrote a script that creates a fresh repository for each scenario, puts the same string into a file, destroys it with a different command each time, and then tries to recover it. Nothing here asks you to take our word for it: this is the whole script, and running it is the point.
#!/usr/bin/env bash
# What git gives back after an AI coding agent destroys work.
# The six scenarios live in one temporary directory, created once and
# removed on exit. The check after mktemp is not ceremony: git -C ""
# does not fail, it falls back to the current directory.
set -uo pipefail
LAB=$(mktemp -d)
[ -n "$LAB" ] && [ -d "$LAB" ] || { printf 'could not create a temporary directory, aborting\n' >&2; exit 1; }
trap 'rm -rf "$LAB"' EXIT
new_repo() {
d="$LAB/$1"
git init -q "$d"
git -C "$d" config user.email lab@example.com
git -C "$d" config user.name lab
printf 'base\n' > "$d/tracked.txt"
git -C "$d" add tracked.txt
git -C "$d" commit -qm base
printf '%s' "$d"
}
GOLD='work worth keeping'
check() { [ "$2" = "$GOLD" ] && printf '%-44s %s\n' "$1" "RECOVERED" || printf '%-44s %s\n' "$1" "LOST"; }
# 1. The agent committed, then reset --hard threw the commit away.
r=$(new_repo one)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" commit -qam work
git -C "$r" reset -q --hard HEAD~1
git -C "$r" reset -q --hard 'HEAD@{1}'
check "committed, killed by reset --hard" "$(cat "${r}/tracked.txt" 2>/dev/null)"
# 2. The agent edited a tracked file, never staged it, checkout threw it away.
r=$(new_repo two)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" checkout -q -- tracked.txt
check "edited, never staged, git checkout --" "$(cat "${r}/tracked.txt" 2>/dev/null)"
# 3. Same edit, but it reached the index once before being thrown away.
r=$(new_repo three)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" add tracked.txt
git -C "$r" reset -q --hard HEAD
b=$(git -C "$r" fsck --lost-found 2>/dev/null | awk '/dangling blob/ {print $3; exit}')
check "edited, git add once, reset --hard" "$(git -C "$r" cat-file -p ${b} 2>/dev/null)"
# 4. The agent created a new file, never added it, git clean removed it.
r=$(new_repo four)
printf '%s\n' "$GOLD" > "${r}/newfile.txt"
git -C "$r" clean -qfd
check "created, never added, git clean -fd" "$(cat "${r}/newfile.txt" 2>/dev/null)"
# 5. The agent deleted the branch its commits lived on.
r=$(new_repo five)
git -C "$r" checkout -q -b feature
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" commit -qam feature-work
git -C "$r" checkout -q -
git -C "$r" branch -qD feature
s=$(git -C "$r" reflog 2>/dev/null | awk '/feature-work/ {print $1; exit}')
check "committed on a branch, branch -D" "$(git -C "$r" show ${s}:tracked.txt 2>/dev/null)"
# 6. The agent stashed the work and then dropped the stash.
r=$(new_repo six)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" stash -q
git -C "$r" stash drop -q
c=$(git -C "$r" fsck --lost-found 2>/dev/null | awk '/dangling commit/ {print $3; exit}')
check "stashed, then git stash drop" "$(git -C "$r" show ${c}:tracked.txt 2>/dev/null)"
printf '\ngit %s\n' "$(git --version | awk '{print $3}')"
Each of the six blocks destroys the same string, work worth keeping, in a different way, and then tries to read it back. The script prints RECOVERED when the recovered content matches the original exactly and LOST when it does not, so every verdict is a comparison rather than a judgement call. The two lines that validate the temporary directory before anything else are not ceremony, and the section below on git -C explains what they are defending against.
What the six scenarios show about recovering AI coding agent work
This is the transcribed output of that script on git 2.50.1 on macOS. We ran it under bash, zsh and sh, and the output was byte for byte identical in all three, so the result below is not an artefact of one shell:
committed, killed by reset --hard RECOVERED
edited, never staged, git checkout -- LOST
edited, git add once, reset --hard RECOVERED
created, never added, git clean -fd LOST
committed on a branch, branch -D RECOVERED
stashed, then git stash drop RECOVERED
git 2.50.1
Read the list by what the six scenarios have in common rather than one by one. Every RECOVERED line is a case where the content reached git's object database: a commit writes it there, git add writes it there, and git stash writes it there because a stash is a commit wearing a different name. Every LOST line is a case where the content only ever existed in the working directory. The commands that did the destroying are not what decides the outcome, which is the part most people get backwards: reset --hard sounds far more violent than checkout --, and yet it is the survivable one.
Why does one git add save work you never committed?
Because git add is not bookkeeping, it is a write. When you stage a file, git computes the hash of that exact content and writes a blob object into .git/objects immediately. The index then points at the blob. A later git reset --hard moves the index and the working tree back, but it does not go hunting for the blob it orphaned. The object stays on disk with nothing referring to it, which is precisely what git fsck --lost-found reports as dangling.
This is why the third scenario recovers. An AI coding agent staged a file, someone ran git reset --hard, and the content is still there under a hash nobody remembers. You get it back with two commands, without needing to know the hash in advance:
git fsck --lost-found # lists dangling blobs and commits
git cat-file -p <hash> # prints the content of one of them
The practical consequence for anyone working with AI coding agents is worth stating plainly, because it is cheap and it is not obvious: staging is a backup. If an agent is about to do something broad and you have uncommitted work you care about, git add -A costs nothing, requires no commit message, and moves your work from the category that is unrecoverable into the category that is.
What can you never recover after an AI coding agent deletes it?
Two things, and both share the same cause. The first is an edit to a tracked file that was never staged, thrown away by git checkout -- file or by its modern spelling git restore file. The second is a file the agent created and never added, removed by git clean -fd. In both cases git had no copy of the content, because nothing had ever asked it to make one.
There is no repository-side recovery for either, and we want to be exact about the scope of that sentence: it means git itself has nothing to give you back, not that the bytes are necessarily gone from your machine. Whatever else might hold a copy lives entirely outside git, in a backup, a filesystem snapshot, or an editor that keeps its own local history, and none of that is what we measured here. Whether any of it exists on your machine is a question this article cannot answer for you.
How do you actually run the recovery?
Each recovered scenario has one command that does the work, and they are worth having in front of you before you need them rather than after. A commit destroyed by reset --hard comes back with git reset --hard 'HEAD@{1}', which is the reflog entry for where the branch pointed one move ago. A branch deleted with branch -D comes back by finding its last commit in git reflog and running git branch <name> <sha>. A dropped stash comes back through git fsck --lost-found, which reports it as a dangling commit that you can inspect with git show and reapply with git stash apply <sha>.
The first thing to do, before any of those, is to stop writing to the repository. Every recovery above depends on objects that are unreferenced but not yet collected, and the command that collects them is git gc, which git also runs on its own: the manual page states that common porcelain operations check whether the repository has grown substantially since the last maintenance and run git gc automatically if so. An AI coding agent that keeps working in that directory is running exactly the commands that can close the window you are trying to reach through.
Why does git -C with an empty path delete files in the wrong repository?
Because git -C "" does not fail. It falls back to the directory you are standing in, and we measured what that costs. On a throwaway repository with two commits, one tracked file and one untracked file, git -C "" log --oneline printed that repository's history instead of an error. Then git -C "" clean -qfd deleted the untracked file, and git -C "" reset -q --hard HEAD~1 threw away the newest commit and reverted the tracked file to its previous content. Two commits became one, and none of it happened where the empty path pointed, because an empty path points nowhere and git resolved it to here.
That is why the script above validates its temporary directory before doing anything, and the failure it defends against is closer than it looks: any shell variable that ends up empty turns every git -C "$dir" into git -C "". Two habits that feel like protection do not protect you here. set -u rejects an unset variable, not an empty one, so it stays silent. And exit inside a function whose output you capture with $(...) only exits the subshell, so the script carries on with the variable empty. This is the same accident the rest of this article is about, arriving through a script instead of through an agent.
Why is the first unreachable commit the wrong one to trust?
This one cost us a wrong result before it became a finding. Our first version of the stash scenario reported LOST, and the data was not what was wrong: the call was. git stash creates two commits, not one. One holds your working tree ("WIP on main") and the other holds the index at that moment ("index on main"). Our script took the first commit that git fsck --unreachable printed, which happened to be the index one, whose content is the old version. It compared the old version against the expected content, found them different, and honestly reported a loss that had not happened.
Two things came out of fixing it. The order in which git fsck --unreachable prints objects is not something to build on: across our runs the same repository listed the two commits in different orders. And --lost-found is the better instrument for this specific job, because it reports only the stash commit as dangling: the index commit is the stash commit's parent, so it is reachable from it and is correctly not listed. The lesson generalises past git, and it is the one we keep relearning: when a measurement reports a surprising zero, suspect your own call before you suspect the world.
How long does the recovery window stay open?
Long enough that panic is the bigger risk, and not forever. The defaults are documented in git help gc, which on this machine is git 2.50.1 (Apple Git-155): gc.reflogExpire removes reflog entries older than 90 days; gc.reflogExpireUnreachable removes entries not reachable from the current tip after 30 days, which is the one that covers commits orphaned by a reset; and gc.pruneExpire makes git gc call prune --expire 2.weeks.ago, which is what eventually deletes the dangling blobs and commits themselves.
So the honest version is that a commit an agent destroyed this morning is recoverable for weeks, and the two-week prune window is the tightest of the three. None of these clocks start over because you noticed. If you find yourself recovering work an AI coding agent destroyed last month, check the numbers above against your own configuration with git config --get gc.pruneExpire, because a repository that sets them explicitly follows its own rules and hosted platforms may run garbage collection on their own schedule.
Should the AI coding agent that broke it be the one to fix it?
Our answer is no for the recovery itself, and the reason is mechanical rather than a matter of trust. Recovery is a short read against the object database: find the object, print it, put it back. The agent that caused the damage is carrying the context that produced it, and the failure mode is specific and bad. Asked to fix a repository, an agent reaches for the same broad commands that destroy the remaining evidence, and git clean and git checkout -- are exactly the two operations our measurement shows to be unrecoverable.
The sequence that costs least is to stop the agent, run git fsck --lost-found yourself, and only then decide what to restore. Delegating the repair afterwards is reasonable once the objects you need are identified and safe. What does not survive contact with reality is asking the thing that just deleted your work to go figure out how to bring it back, in the same working directory, while the two-week clock runs.
Where this measurement stops
Six scenarios in throwaway repositories are not a survey of how work gets lost. The script runs on a repository with one file and one commit, with no remote, no submodules, no hooks and no LFS, and each of those changes the picture: a branch that was pushed is recoverable from the remote regardless of everything above, which is the cheapest safety net of all and the reason this article is less useful for work that was already published. We ran it on git 2.50.1 on macOS and confirmed the output is identical across three consecutive runs and across three shells; we have not run it on older git versions, and the reflog defaults we quote come from the documentation installed with that version rather than from a measurement of expiry.
What we did not measure at all is how often each of these six things actually happens when AI coding agents are involved. We can tell you what is recoverable. We cannot tell you, from this, which mistake your agent is most likely to make.