Rewriting history with git filter-repo Jump to heading

Sometimes a repository’s history has to change: a directory of generated binaries bloats every clone, a vendored library must move to its own repository, a confidential file was committed years ago. git filter-branch used to be the tool, and it was slow, easy to misuse and full of traps. git filter-repo replaced it and is now what the Git project itself recommends. It rewrites every commit in one fast pass, refuses to run on a repository that is not a fresh clone, and removes the leftovers that made old rewrites leak. The tool is the easy part; the difficult parts are choosing the right operation and coordinating every clone that holds the old history. This page covers both, within history rewriting and recovery.

When to use this approach Jump to heading

  • A path must disappear from all history β€” large binaries, generated output, a file that should never have been committed.
  • File contents must change throughout history, such as replacing a leaked value with a placeholder.
  • A repository is being restructured: a subdirectory becomes its own repository, or the whole tree moves under a prefix for a merge into a monorepo.
  • For a leaked credential, revoke it first; rewriting is cleanup, as explained in removing a leaked secret from Git history.

Step 1 β€” Install it and start from a fresh mirror clone Jump to heading

git filter-repo is a single Python script, packaged by most distributions. It refuses to run on a repository with existing work, to stop you rewriting your only copy. Work in a fresh mirror clone, and keep a second, untouched backup.

python3 -m pip install --user git-filter-repo     # or your package manager
git clone --mirror [email protected]:acme/app.git app-rewrite.git
git clone --mirror [email protected]:acme/app.git app-backup-$(date +%F).git
cd app-rewrite.git
filter-branch against filter-repofilter-branch runs a shell command per commit, is very slow on large histories and leaves backup refs and reflogs that keep the old objects alive. filter-repo rewrites in a single fast pass, insists on a fresh clone, and cleans up so the old objects are truly gone.filter-branchfilter-repospeed on large historyhoursminutesfresh clone requiredno β€” riskyyes β€” enforcedold objects left behindrefs/original, reflogscleaned upstatusdeprecatedrecommendedif a guide tells you to use filter-branch, it predates filter-repo

Step 2 β€” Analyse before you rewrite Jump to heading

Generate a report of what the history contains. It lists the largest paths, deleted files that still occupy space, and renames β€” usually enough to decide exactly what to remove.

git filter-repo --analyze
ls .git/filter-repo/analysis/ 2>/dev/null || ls filter-repo/analysis/
head -20 filter-repo/analysis/path-deleted-sizes.txt
head -20 filter-repo/analysis/directories-all-sizes.txt

For a detailed walk-through of finding what is large, see auditing a repository for large blobs.

Step 3 β€” Choose the operation Jump to heading

Most rewrites are one of four operations. Pick the narrowest that does the job.

# Remove a path from all history
git filter-repo --invert-paths --path build/artifacts/

# Remove files by glob, wherever they are
git filter-repo --invert-paths --path-glob '*.psd'

# Replace text in every version of every file (one rule per line in the file)
printf 'literal:hunter2==>REDACTED\nregex:AKIA[0-9A-Z]{16}==>AKIA_REDACTED\n' > replacements.txt
git filter-repo --replace-text replacements.txt

# Keep only a subdirectory, and make it the new root
git filter-repo --subdirectory-filter services/billing
The four common rewrite operationsRemoving a path deletes it from every commit. A glob removal does the same by pattern. Text replacement edits file contents in every version. A subdirectory filter keeps one directory and promotes it to the root, which is how a component is split out.--invert-pathsdrop a path--path-globdrop by pattern--replace-textedit contents--subdirectory-filterextract a direach one rewrites every commit hash from the first affected commit onwards

⚠️ SAFETY WARNING: Every one of these changes the hash of every commit from the first affected commit onwards. All existing clones, forks, open pull requests, CI caches and links to commit hashes become references to history that no longer exists on the server. Do not run the rewrite against your only copy, and do not push it until every step of the coordination plan in Step 5 is ready. The untouched mirror from Step 1 is your recovery path: git push --mirror --force from it restores the original history.

Step 4 β€” Verify the result before pushing Jump to heading

Check that the unwanted content is gone, that nothing else changed, and that the repository is the size you expect.

# The path no longer appears in any commit
git log --all --oneline -- build/artifacts/ | wc -l         # expect 0
# Replaced text no longer appears anywhere
git grep -n 'hunter2' $(git rev-list --all) | head -1        # expect no output
# Commit count is unchanged (unless the rewrite emptied some commits)
git rev-list --all --count; git -C ../app-backup-*.git rev-list --all --count
# Size after repacking
git count-objects -vH | grep size-pack

Commits that only touched removed paths become empty and are pruned by default, so the count may drop slightly. A large drop means the filter removed more than intended.

Step 5 β€” Coordinate the push and every other clone Jump to heading

The rewrite is local until pushed. Plan the cut-over: freeze pushes, push the rewritten history, and have everyone re-clone. Old clones that push again will reintroduce the removed history.

# filter-repo removes the origin remote as a safety measure β€” add it back deliberately
git remote add origin [email protected]:acme/app.git
git push --force --mirror origin
A coordinated history rewriteAnnounce the rewrite and the freeze in advance. At the freeze, merge or close open pull requests, then push the rewritten history. Everyone re-clones; old clones are archived rather than reused. Forge caches and forks are handled last.Announcedate, freeze windowTβˆ’5 daysFreezeno pushes, PRs closedTβˆ’1 hourForce-pushrewritten mirrorTRe-cloneeveryone, CI tooT+1 hourCleanupforks, caches, linksT+1 weekthe freeze is what stops someone pushing the old history straight back

After the push, hosted forges may still serve the removed content through cached views of old pull requests or forks until their own garbage collection runs; contact the forge’s support for removal if the content is sensitive.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can I rewrite only one branch? Jump to heading

filter-repo can be limited with --refs, but a partial rewrite leaves the unwanted content reachable from the other branches and tags. For removals that matter, rewrite everything.

What happens to signed commits? Jump to heading

Rewritten commits are new objects, so their signatures no longer apply and are dropped. If your gates require signatures, plan for that: either re-sign the rewritten history with a dedicated key, or record the rewrite as an exception in your verification policy.

Is BFG Repo-Cleaner still a reasonable choice? Jump to heading

It is fast and focused on removing large files and secrets, and many teams have used it successfully. filter-repo covers the same cases and many more, and is maintained alongside Git itself, so it is the better default today.