History Rewriting & Recovery Jump to heading
Git is unusual among tools in that its destructive operations are usually reversible and its reversible operations occasionally are not. A reset --hard that appears to have destroyed a day’s work is normally one reflog lookup from recovery; a filter-repo run that succeeded perfectly can be the thing that costs the team a week, because it invalidated every commit SHA and nobody was told. This part of Conflict Resolution & Safe Merge Operations covers both halves: how to get work back when something goes wrong, and how to change history deliberately without the coordination failure that usually follows.
The organising principle is that Git almost never deletes anything immediately. Commits become unreachable rather than removed, and unreachable objects survive until garbage collection decides otherwise. Recovery is therefore usually a matter of finding a name for an object that still exists — which is exactly what the reflog provides, and why understanding its retention window matters more than memorising recovery commands.
Prerequisites Jump to heading
Step 1 — Decide Whether a Rewrite Is Justified Jump to heading
Most requests for a rewrite are really requests for something cheaper. The test is whether the content must cease to exist, or whether it merely needs to stop being current.
The bottom path is narrow on purpose. A rewrite is justified when a credential is in history, when a repository is unusable because of accumulated binaries, or when splitting a project into separate repositories. It is not justified because a commit message has a typo on a branch three people have pulled.
Step 2 — Take a Snapshot You Can Return To Jump to heading
# 1. A tag that pins the pre-rewrite state — cheap and unambiguous
git tag pre-rewrite-$(git rev-parse --short HEAD)
# 2. A full mirror on disk, in case the rewrite goes badly wrong
git clone --mirror . ../repo-backup.git
# 3. Record every ref as it stands now
git for-each-ref --format='%(refname) %(objectname)' > /tmp/refs-before.txt # Verify the backup is complete and independent
git -C ../repo-backup.git fsck --full 2>&1 | tail -3
git -C ../repo-backup.git rev-list --count --all # compare with the original The mirror is the one that matters. A tag lives in the repository you are about to rewrite; a mirror lives somewhere else. Keep it until every clone has been re-created and the team confirms the result is good — and delete it deliberately afterwards, because a stale mirror holding the secret you just removed is its own problem.
SAFETY WARNING — every command in the rest of this guide changes commit identities. Run them on a clone, never on the working repository other people push to, and never with unpushed work in the tree. Confirm with
git status --porcelain(empty) andgit log --branches --not --remotes(empty) before starting.
Step 3 — Choose the Right Rewriting Tool Jump to heading
| Situation | Tool | Why |
|---|---|---|
| Reorder, squash or reword the last few local commits | git rebase -i | Interactive, reversible with the reflog, no extra tooling |
| Fix the most recent commit only | git commit --amend | One command; still a rewrite, so do not amend after pushing |
| Remove a file from all of history | git filter-repo --path <p> --invert-paths | Purpose-built, fast, refuses unsafe operations by default |
| Rewrite author or committer identity | git filter-repo --mailmap | Handles both fields and all refs consistently |
| Extract a directory into its own repository | git filter-repo --subdirectory-filter <dir> | Preserves the history of only that path |
| Move large assets into LFS across history | git lfs migrate import | Understands pointers; safer than a hand-written filter |
| Anything at all | git filter-branch | Deprecated: slow and error-prone; Git itself warns against it |
# Install the modern tool once
python3 -m pip install --user git-filter-repo
git filter-repo --version The last row deserves emphasis because copy-and-pasted filter-branch invocations remain common on the internet. It is not merely slow — it mishandles tags and nested refs in ways that produce a plausible-looking result with silent gaps.
Step 4 — Verify the Rewritten History Before Publishing Jump to heading
# 1. Every ref survived
git for-each-ref --format='%(refname)' | sort > /tmp/refs-after.txt
diff <(cut -d' ' -f1 /tmp/refs-before.txt | sort) /tmp/refs-after.txt && echo "all refs present"
# 2. The content is genuinely gone from every ref, not just the current branch
git log --all --oneline -S'AKIAIOSFODNN7EXAMPLE' | head # expect no output
# 3. The current checkout is unchanged
git diff --stat pre-rewrite-<sha> HEAD -- . | tail -1 # expect no differences in content
# 4. If shrinking was the point, confirm it happened
git count-objects -vH Step 5 — Coordinate the Cutover Jump to heading
A rewrite is a distributed-systems problem wearing a version-control costume. The repository is not the only copy; every clone holds the old history and will happily merge it back in.
# 1. Freeze: announce a window, ask everyone to push and stop
# 2. Publish the rewritten refs
git push --force-with-lease --all
git push --force-with-lease --tags
# 3. Everyone else re-clones. Not "pull" — re-clone.
# A pull merges the old history straight back in. # What each colleague runs to confirm they are on the new history
git rev-parse HEAD # must match the announced SHA
git log --all --oneline -S'<removed>' | head # must be empty in their clone too --force-with-lease rather than --force is deliberate: it refuses the push if someone else moved the ref since you last fetched, which converts a race into an error message. The other essential message is that colleagues must re-clone, not pull — a pull from an old clone reintroduces the very commits the rewrite removed, and does so silently.
Integration With Adjacent Practice Jump to heading
Boundary with revert. Everything on this page is a last resort relative to git revert. Revert is a normal commit: it merges, it reviews, it needs no coordination. Rewriting is a migration. When a colleague asks for a rewrite, the first question is whether a revert achieves the actual goal.
Boundary with interactive rebase. Interactive rebase is a rewrite too, but scoped to commits that have not been shared, which is what makes it routine. The moment a branch other people track is involved, the coordination cost from Step 5 applies in full.
Boundary with server-side policy. A remote that refuses force pushes will block Step 5 — correctly. The override should be deliberate, logged, and consumed once, which is exactly the break-glass pattern described there.
What Survives a Rewrite, and What Does Not Jump to heading
Three categories of information behave differently, and knowing which is which prevents most post-rewrite surprises.
Content survives exactly. The tree at each rewritten commit is whatever the filter produced, and for every path you did not touch that is byte-identical to before. This is why the working tree at HEAD should be unchanged after a rewrite that only removed an old file: the present is unaffected, only the past is different. If a build breaks after a rewrite, the filter did more than you intended and the rewrite should be redone rather than patched.
Metadata survives if the tool preserves it. Commit messages, author names, author dates and committer dates all carry over under git filter-repo. What changes is the commit’s identity, because a SHA is a hash of the content and that metadata and the parent SHAs — so a single altered ancestor renames every descendant. That cascade is the reason a rewrite near the root of history is so much more disruptive than one near the tip, and why extracting a package with fifty commits is a different proposition from redacting a string committed six years ago.
References to SHAs do not survive at all. Ticket comments citing a commit, changelog entries, deployment records, bisect notes in an incident write-up, and any tooling that stores a SHA as a foreign key all break silently — the reference does not error, it simply finds nothing. git filter-repo writes an old-to-new commit map into .git/filter-repo/commit-map, and that file cannot be reconstructed afterwards. Archive it alongside the migration notes if anything outside Git cites your commit identifiers, and consider publishing the mapping so people can translate an old SHA rather than concluding the commit never existed.
A fourth category deserves its own mention: signatures do not survive. A signature covers the exact bytes of the commit object, so a rewritten commit’s signature is invalid by construction and is dropped. On a repository with verification gates this is not a detail — it means the rewritten history will fail the very check that protects it until the commits are re-signed, and the re-signing has to be planned as part of the migration rather than discovered during it.
Common Failure Modes and Diagnostics Jump to heading
The secret is still findable after the rewrite. Symptom: a search finds the string on a ref you did not think about. Root cause: the rewrite covered branches but not tags, or a fork holds the old commits. Fix: re-run with --all, and remember that the credential must be rotated regardless — a rewrite cannot reach forks or platform caches.
Colleagues keep reintroducing the old history. Symptom: removed commits reappear days later. Root cause: someone pulled instead of re-cloning. Fix: re-run the rewrite, and this time verify each clone’s HEAD against the announced SHA before closing the migration.
The repository did not get smaller. Symptom: count-objects is unchanged after removing a large file. Root cause: the old objects are still referenced by the reflog and by refs/original/. Fix: expire the reflog and prune — but only in the rewritten clone, and only after verification.
Tags point at commits that no longer exist. Symptom: git tag -v fails or tags resolve to nothing. Root cause: a rewriting tool that did not update tag objects. Fix: use filter-repo, which handles tags; recover the tag targets from the mirror taken in Step 2.
A signed commit is no longer verified. Symptom: signatures that used to validate now fail. Root cause: rewriting changes the commit content, and a signature covers exactly that content. Fix: expect this, and re-sign the rewritten commits if signature coverage is required — see Commit Verification Gates.
Team Rollout Jump to heading
Frequently Asked Questions Jump to heading
How long does the reflog keep unreachable commits? Jump to heading
Reachable entries expire after 90 days by default and unreachable ones after 30, controlled by gc.reflogExpire and gc.reflogExpireUnreachable. Those are defaults, not guarantees: an explicit git gc --prune=now discards unreachable objects immediately regardless of the reflog. Treat the reflog as a generous safety net for recent mistakes, not as a backup.
Is git filter-branch still the right tool? Jump to heading
No. Git itself warns against it: it is slow enough to take hours on a large repository and has sharp edges that silently corrupt results, such as mishandled tags and grafts. Use git filter-repo for anything beyond a couple of recent commits, and interactive rebase for small, recent, local edits.
Does rewriting history remove a leaked secret completely? Jump to heading
It removes it from your repository’s history, which is necessary but not sufficient. Forks, clones, CI caches, backups and any platform that indexed the commit may still hold it, and a rewrite cannot reach any of them. Rotate the credential first and treat the rewrite as cleanup afterwards — that ordering is what actually ends the exposure.
What is the difference between reset, revert and rewriting? Jump to heading
Revert adds a new commit that undoes an earlier one, leaving history intact — the only safe option on a shared branch. Reset moves the branch pointer, discarding commits from the branch without adding anything, which is safe locally and destructive once shared. A rewrite changes the commits themselves, producing new SHAs for everything downstream. Prefer revert whenever anyone else has the commits.
Can I recover a branch I deleted? Jump to heading
Almost always, if you act before garbage collection. The branch’s last tip is in the HEAD reflog if you had it checked out; otherwise git fsck --lost-found lists dangling commits you can inspect and re-point a branch at. On the server side, a hosted platform usually keeps its own reflog for a while — worth asking support before assuming the work is gone.
Related Jump to heading
- Recovering Lost Commits with git reflog — the recovery path for a bad reset, a lost branch, or an aborted rebase.
- Removing a Leaked Secret from Git History — the full incident sequence, with rotation before rewriting.
- Splitting a Directory into Its Own Repository — extract one path with its history intact, and keep the two repositories honest afterwards.
- When to Use git revert vs git reset — the cheaper answer to most problems that look like they need a rewrite.
- Interactive Rebase Workflows — rewriting at the scale where it is routine rather than a migration.