Recovering a file git never committed
How I helped a colleague get back an ASP.NET code-behind file that was never committed, using git fsck and unreachable blobs, with commands for bash and PowerShell on Windows, macOS and Linux.
Contents
A few days ago a colleague came to me with a file that was gone. It was part of a product feature that had already been built twice, each time taking a couple of months, and nobody wanted to build it a third time.
The product was built with ASP.NET Web Forms and kept in git. In Web Forms each page is mainly a pair of files: an .aspx file with the page’s markup, and a .cs “code-behind” file with the C# that runs on the server. Visual Studio usually adds a third, generated .designer.cs file. The missing file was ImageCropping.aspx.cs, the code-behind for an image-cropping page in the product’s media upload section.
My colleague had been working on it for a while, and it had never been committed. git log had nothing to show for it, and neither did the reflog.
We got it back anyway, because at some point they had run git add on it, and git keeps what you stage.
In short: git add writes a copy of the file into .git/objects the moment you stage it. A git reset --hard, for example, removes a newly added file from your working tree, but the copy stays in the object database, unreferenced, until garbage collection removes it. git fsck --unreachable lists these copies. Search them for a string you know was in the file, then copy the right one out of .git/lost-found.
Git has always made me curious
Every time I’ve tried to learn git properly, or to build a piece of it myself, I’ve come away with more questions than I started with. That curiosity is the reason I knew where to look when my colleague’s file disappeared.
I’ve had a GitHub account since 2013, when my first repositories were coursework from online courses. For a long time after that, git was just how things got online. This blog started as a Jekyll site on GitHub Pages. In 2017 I rebuilt it with Hugo and the cocoa-eh theme, and in 2018 moved to the jane theme. From the Hugo rebuild on, it ran on two git repositories: one for the content, and one for the HTML that Hugo generated from it, which GitHub Pages served. Every post meant building the site and pushing it.
That setup kept making me ask how far I could push git. In 2018 I wanted to post from my phone, so I installed git on it (it was rooted). That let me commit new posts, but not build the site, which still needed Hugo on a computer. I thought about a server that would rebuild and push the site whenever a commit landed, then found Forestry, a web-based CMS that let me edit posts from a browser. I wrote that up in a static blog with a web-based CMS.
In 2019 I set out to learn git properly. That summer, on my third try at the dgplug summer training, a session took us through
git basics and commit messages, and I wrote up what I read about
git log. Knowing the commands wasn’t enough; I wanted to know how they worked. So I tried to
write my own git by following the Write Yourself a Git tutorial. My first attempt stalled, and I started over in a short series of posts, building it step by step in Python. I got as far as init, which creates the .git folder and the .git/objects directory inside it.
That directory is where the file came back from. Everything git stores goes into .git/objects, compressed and named by the hash of its contents, and git rarely deletes anything from it the moment you lose it.
What git keeps, and what it doesn’t
Here is the whole recovery in one loop: what git add writes, what a hard reset removes, and how git fsck gets the file back.
Before running anything, work out which case you’re in. They need different tools.
| What you did with the work | Can git get it back? | Where to look |
|---|---|---|
| Committed it, then reset or deleted the branch | Yes | git reflog |
| Stashed it, then dropped or cleared the stash | Yes | git fsck, dangling commits |
Staged it with git add, never committed | Yes, the staged version | git fsck, unreachable blobs |
| Committed it, then deleted the file in a later commit | Yes | git log --diff-filter=D |
Edited it but never ran git add | No | Your editor’s local history |
If a file was only ever saved to disk, git never had a copy of it, but your editor might. VS Code’s Timeline view and JetBrains’ Local History both keep their own snapshots of saved files. Not every editor does, and the ones that do keep them for a limited time, so find out what yours keeps before you need it.
Git also cleans up after a while. Garbage collection runs on its own now and then, after commits and fetches, and when it runs it removes unreachable loose objects older than two weeks by default. Reflog entries last 90 days, or 30 days for commits that are no longer reachable. So don’t run git gc while you’re recovering, and start soon.
The commands, in bash and PowerShell
Every block below comes in two versions:
- bash: Git Bash on Windows, and the terminal on macOS and Linux. The commands also work in zsh, the default shell on macOS.
- PowerShell: Windows PowerShell 5.1, and PowerShell 7 (
pwsh) on any OS.
Two PowerShell problems to know about first.
- Quote anything with braces. PowerShell reads
{1}inHEAD@{1}as a script block, so git receivesHEAD@and stops withfatal: ambiguous argument 'HEAD@'. Write'HEAD@{1}'in single quotes. Single quotes work in bash and zsh too, so it’s easiest to always use them. - Don’t restore files with
>. In Windows PowerShell 5.1,git cat-file -p <hash> > filere-encodes the output: by default the file comes out as UTF-16, other settings add a byte-order mark, and binary files are corrupted either way. PowerShell 7.4 and later keep the bytes intact. Copy from.git/lost-foundinstead, as shown below. Git writes those files itself, so you get exactly the bytes it stored.
1. Make sure it was never committed
Search every branch and the reflog for the path:
| |
This one is the same in PowerShell. In our case it printed nothing: the file had never been in a commit. If it prints commits, skip to section 6 or section 7.
2. Find the staged copies
git fsck --unreachable --no-reflogs lists every object that nothing points to. --no-reflogs matters here: without it, anything the reflog still mentions counts as reachable and is left out. Filter for blobs, which hold file contents, then keep the ones that contain a string you know was in your file. A class name works well.
bash:
PowerShell:
We got seven matches:
| |
There are seven because a class name doesn’t appear in just one file. The three large blobs, 300 KB to 2.8 MB, are far too big to be one page’s source; compiled output such as a DLL contains class names as strings too. A source file like this one is a few kilobytes, which left four candidates.
Several candidates is normal anyway. Every git add stores a new blob, so if you staged a file three times while working on it, you’ll find three versions.
3. Work out which file each candidate is
A Web Forms page can leave up to three files behind, and all of them mention ImageCropping:
ImageCropping.aspx, the markup, which starts with<%@ Page.ImageCropping.aspx.cs, the code-behind, which containspartial class ImageCropping.ImageCropping.aspx.designer.cs, which Visual Studio generates. It also containspartial class ImageCropping, but it starts with an<auto-generated>comment.
Print the start of each candidate to see which one it is:
bash:
PowerShell:
Git accepts a short hash wherever it accepts a full one, as long as it’s unique.
4. Save every candidate and compare them
Don’t pick one yet. Save all of them to a folder outside the repository, each named by its hash and the kind of file it is, and list when each was staged.
The staging time comes from the blob itself. Until git packs it, a blob is a file under .git/objects, named by its hash: the first two characters are the folder and the rest are the filename. Its modified time is when that content was last staged, so the newest version of each file is usually the one you want.
bash:
| |
PowerShell (copying out of .git/lost-found, for the reason above):
| |
git fsck --lost-found writes every dangling blob to .git/lost-found/other/, named by its full hash, and git writes those files itself, so the copies are byte for byte what it stored. If git has already packed a blob, it has no file of its own: it won’t be in lost-found and has no staging time to show. You can still save it with git cat-file -p <hash> > file in bash.
If two candidates are versions of the same file, compare them. This works the same in both shells, and doesn’t need either file to be in a repository:
| |
5. Put each file back
Copy the version you chose of each file to where it belongs.
bash:
PowerShell:
Restore only the files that are actually missing. If you didn’t find a designer file, Visual Studio regenerates it from the markup when you open and save the .aspx.
Build the project, open the page, then commit:
Commit straight away. A recovered file you haven’t committed can be lost the same way again. Once it’s committed, delete ../recovered and .git/lost-found.
One thing to check after restoring: if core.autocrlf is on, as it often is on Windows, git stored the files with LF line endings, and that is how they come back. Most editors don’t mind. If you want CRLF back, after committing, delete the restored files and check out just those files again, for example git checkout -- path/to/ImageCropping.aspx.cs, which writes them out with CRLF. Name the files: checking out a whole folder also throws away any other uncommitted changes in it.
6. A file deleted in a commit
This is the easy case, and the one people usually mean by “recovering a deleted file”. Find the commit that deleted it, then restore the file from the commit before that one. The ~1 means “the parent of this commit”.
bash:
PowerShell:
git restore needs git 2.23 or newer. On an older git, use git checkout "$sha~1" -- "path/to/file". I use ~1 rather than ^ on purpose: in cmd.exe ^ is an escape character, and in zsh with extended globbing it is a pattern character.
7. Commits lost to a reset
git reflog lists every position HEAD has been at, including commits no branch points to any more. Find the commit and put a branch on it. Making a branch is safer than git reset --hard, which throws away any uncommitted changes you have now.
The command is the same in PowerShell, with the quotes, which bash ignores. Replace HEAD@{1} with whichever entry has your work, or with its hash.
8. A dropped stash
A stash is a commit. After git stash drop or git stash clear it becomes a dangling commit, and git fsck can find it. The entries labelled WIP on <branch> are the stashes.
bash:
PowerShell:
This lists commits lost to a reset as well. Those will show their own commit messages, not WIP on.
Use the full hash with git stash apply. It reads an argument made only of digits as a stash number, so a short hash like 3298776 turns into stash@{3298776} and fails.
How to avoid needing this
- Commit early, on a branch. A commit called
wipcosts nothing, and you can squash it before anyone sees it. Every recovery above is easier when there’s a commit. - Use
git reset --keepinstead of--hard. It keeps your uncommitted changes, and stops instead of overwriting them. - Use
git stash -ubefore risky operations. Without-u, untracked files aren’t stashed, and a new file you haven’t rungit addon is untracked. - Read
git clean -nbefore runninggit clean -f.cleandeletes untracked files, and git keeps no copy of those. - Know the editor you work in. Find out whether it keeps a local history of saved files, and where. Also find out what its git buttons do: a “Discard changes” or “Undo changes” button runs git commands for you, and on a file git doesn’t track yet, it can delete the file outright.
My colleague had given up on that file. It was still in .git/objects, and a search for its class name found it.