← All writing
Craft · · 6 min

The reflog

On git not really deleting things, the log of everywhere you've been, and the three situations where it genuinely can't help you.

Git

“I think I’ve just deleted three hours of work.”

Rebase went wrong, somebody panicked, ran a hard reset to get back to a known state, and the known state turned out to be from before lunch. The commits were not in the log. The branch pointed somewhere earlier. As far as every command they knew was concerned, the work did not exist.

It took about 90 seconds to get back, and the mechanism is worth understanding properly rather than as an incantation, because knowing why it works tells you exactly when it won’t.

Git doesn’t delete, it stops pointing

The thing to internalize: a commit is an object in a database, identified by its hash. Branches and tags are just labels pointing at particular objects, and HEAD is a label pointing at your current position.

When you reset, or force a branch somewhere else, or abandon a rebase, you’re moving labels. The commits themselves don’t go anywhere. They become unreachable, meaning no label points at them any more, which means none of the ordinary commands will show them to you, which looks identical to being deleted.

Unreachable objects do eventually get cleaned up by garbage collection, and the defaults keep them around for weeks to months. So in practice, on a normal working timescale, “I deleted it” nearly always means “nothing is currently pointing at it.”

Almost nothing is ever deleted. You moved a label, and everything the label used to point at is still sitting there with nobody looking at it.

Reading the reflog

The reflog is a local record of every position HEAD has occupied. Every commit, checkout, reset, rebase, merge, pull, everything that moved you.

terminal
$ git reflog
a4f91c2 HEAD@{0}: reset: moving to origin/main
9c2b1e8 HEAD@{1}: rebase (abort): updating HEAD
9c2b1e8 HEAD@{2}: commit: Wire the filters to the query string
3fe0a17 HEAD@{3}: commit: Extract the result-list markup into a partial
1b8d902 HEAD@{4}: checkout: moving from main to feature/filters
Shell

Read down from the top and the story is right there. Entry zero is the reset that caused the panic. Entry two is the last commit that existed before it, which is the three hours.

The HEAD@{n} syntax works anywhere a commit reference works, so git show HEAD@{2} lets you check you’ve got the right one before doing anything.

Branch, don’t reset

The instinct once you’ve found it is to reset back to it, which works and is the advice everybody gives, and I’d do something slightly gentler:

terminal
git branch rescue HEAD@{2}       # a label on the lost work, nothing else moves
git log rescue                   # have a look. is this actually it?
git diff HEAD rescue             # what would come back
Shell

Creating a branch is non-destructive. Your current state is untouched, the lost work now has a label so it can’t be garbage collected, and you can look at both and decide calmly. Whereas a hard reset performed by an alarmed person at 4:10 is how the second problem gets created.

Once you’re sure, merge it, cherry-pick from it, or reset onto it. And when it turns out to be the wrong entry, nothing has happened and you try HEAD@{3}.

The three it can’t save you from

This is the important half, because the reflog’s reputation as a safety net makes people think git is safer than it is.

Uncommitted changes. The reflog records movements of HEAD, and HEAD only moves between commits. Work in your editor that has never been committed is not an object in the database, so there is nothing to recover. git checkout -- file and git reset --hard on a dirty working tree destroy that work permanently, and they do it without a confirmation.

This is the actual danger and it’s the exact opposite of how most people rank the risk. Everyone is frightened of rebasing, which is essentially always recoverable, and casual about reset --hard, which is the one command in daily use that can lose work.

Untracked files. git clean -fd removes files git was never tracking. They were never in the database either. That one’s gone.

Anything that happened on somebody else’s machine. The reflog is local and per-clone. It is not pushed, not fetched, and not shared. If you force-push over a colleague’s commits, your reflog can’t help them, and theirs can only help if they still have the objects locally. On a shared branch the recovery conversation is “whose clone still has it,” which is a much worse conversation.

Which is really an argument for committing rubbish

Here’s the practical conclusion, and it’s the one I’d actually like people to take.

Everything above divides cleanly. Once work is a commit, it is recoverable more or less indefinitely and it takes 90 seconds. Until it’s a commit, it’s a file on a disk with no history and one careless command away from nothing.

So the safety comes from committing, not from knowing the recovery commands. Commit half-finished things. Commit at lunchtime. Commit the version that doesn’t work with a message saying it doesn’t work. You can tidy history later with a rebase, and the rebase is itself recoverable, and none of it matters if the work is on a branch nobody has seen.

I’ve noticed that everything I believe about version control now reduces to “make it smaller and do it more often,” which is also what I concluded about deploys, and about code review, and about commits, and I’ve stopped pretending those are separate opinions.

Was it ever a commit?

They got the three hours back and I got a coffee out of it.

The bit I’d emphasize if I were teaching this to somebody: the reflog isn’t a magic undo, it’s a list of where you’ve been, and once you see it that way you can work out for yourself whether a given catastrophe is recoverable. The question is always the same one. Was it ever a commit?

Read similar posts
6 min

.gitignore

A live payment key in a tracked `.env` file. The repository had `.env` in its `.gitignore`, and had done for two years.

7 min

Reading a codebase

I wrote a post a few years ago about the order I read an unfamiliar project in, and I've since decided the order was never really the point, and that the procedure's actual job is to stop me opening files at random and hoping.