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.

Does this actually need a rewrite?If the commits have not been pushed, reset or amend them locally at no cost. If they are shared and merely wrong, revert them so history stays intact. Only if the content itself must not exist — a leaked secret or an oversized binary — is a rewrite justified, and then only as a coordinated migration.history is wrongwhat exactly is wrong?not pushed yetonly your clone has itshared but merely wronga bad change, not a secretcontent must not existsecret, or an unusable reporebase or amendfree, nobody affectedgit reverthistory stays intactcoordinated rewriteeveryone re-clones

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) and git log --branches --not --remotes (empty) before starting.

Step 3 — Choose the Right Rewriting Tool Jump to heading

SituationToolWhy
Reorder, squash or reword the last few local commitsgit rebase -iInteractive, reversible with the reflog, no extra tooling
Fix the most recent commit onlygit commit --amendOne command; still a rewrite, so do not amend after pushing
Remove a file from all of historygit filter-repo --path <p> --invert-pathsPurpose-built, fast, refuses unsafe operations by default
Rewrite author or committer identitygit filter-repo --mailmapHandles both fields and all refs consistently
Extract a directory into its own repositorygit filter-repo --subdirectory-filter <dir>Preserves the history of only that path
Move large assets into LFS across historygit lfs migrate importUnderstands pointers; safer than a hand-written filter
Anything at allgit filter-branchDeprecated: 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

Four things to check before a rewrite leaves your machineConfirm every branch and tag survived the rewrite, confirm the content you meant to remove is genuinely absent from all history, confirm the working tree at HEAD is byte-identical to before, and confirm the repository got smaller if that was the goal.1 · refs survivedfor-each-ref | wc -lsame branch andtag count as beforea missing tag is theclassic silent loss2 · content gonelog --all -S'<secret>'must return nothingsearch every ref,not just the branchyou were working on3 · tree unchangeddiff -r old newthe checkout at HEADmust be identicala rewrite changes thepast, not the present4 · size fellcount-objects -vHif shrinking wasthe goal at allno change meansthe filter missed
# 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
Why colleagues must re-clone, not pullAfter the remote is rewritten, a colleague who pulls merges their local copy of the old history back into the new one, so the removed commits return. A colleague who re-clones takes only the rewritten history, and the removed commits stay gone.remote, rewrittensecret is gonecolleague runs git pulltheir clone still holds everyold commit, including the secretmerge reintroduces itthe rewrite is undone, silentlycolleague re-clonesold clone is deleted afterpushing any unmerged workrewrite holdsevery clone agrees on history

--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.