Skip to content
shellhacks.dev
Git

How to Use git filter repo to Rewrite History the Safe Way

Use git filter-repo to rewrite history safely: remove files, split a directory into its own repository, rename paths and authors, and skip filter-branch.

6 min read
Summarize in AI
Illustration for "How to Use git filter repo to Rewrite History the Safe Way": a shell script in an editor, gears, a git branch graph.

Sometimes history must change: a large file that should never have been committed, a directory that deserves its own repository, a leaked secret, or an author name that needs correcting. Git's own filter-branch is slow and easy to misuse, and its manual points to git-filter-repo instead. The commands below are git-filter-repo 2.45 with Git 2.45; the secret case in particular is in removing a secret from Git history.

Install and start in a clean clone

Shell
pip install --user git-filter-repo
git clone --no-local ~/src/app app-rewrite
cd app-rewrite
git filter-repo --analyze
sort -k2 -n -r .git/filter-repo/analysis/path-all-sizes.txt | head

The script is a single Python file, git-filter-repo, found on PATH so that Git can call it as a subcommand. It refuses to run in a repository that is not a fresh clone unless you pass --force, which is a deliberate safety. --analyze writes reports about sizes and renames under .git/filter-repo/analysis, a good start when the goal is to shrink a repository.

Common rewrites

Shell
git filter-repo --invert-paths --path assets/video.mp4
git filter-repo --path services/billing/ --path-rename services/billing/:
git filter-repo --path-glob '*.psd' --invert-paths
git filter-repo --mailmap ../mailmap.txt

The first removes a file from all history, the second keeps only the billing directory and moves it to the repository root, which is how to split a subdirectory into its own repository with its commit history. The third removes all files matching a glob. The fourth applies a mailmap to fix author and committer names and addresses, using lines such as New Name <[email protected]> <[email protected]>. Empty commits left by the filter are pruned by default.

What changes and what to do after

Shell
git log --oneline | head -3
git remote -v
git remote add origin [email protected]:team/app.git
git push --force --all origin
git push --force --tags origin

Every commit from the first rewritten one onward gets a new hash, and the tool removes the origin remote so you cannot overwrite the original by accident. After checking the result, add the remote back and force-push branches and tags. Signed commits lose their signatures, since the content they signed no longer exists. Open pull requests refer to the old commits and must be recreated, and collaborators must re-clone rather than pull.

Check before you publish

Verify by running the tests, comparing the tree of the last commit with the old one using git diff old/main new/main after fetching both, and searching the history for the removed content with git log -S. Keep the original clone until everyone is satisfied. In the rewritten repository, run git count-objects -vH after git gc --prune=now --aggressive to see the size reduction, as discussed in shrinking a Git repository.

How the tool works internally

git filter-repo reads the repository through git fast-export, applies filters in a Python process to each commit, tree entry and blob, and feeds the result to git fast-import, which builds a new history. Because the process is a stream, it does not check out each commit, and it runs in a fraction of the time of the older filter-branch on large repositories. It also rewrites commit messages that mention old hashes so that references to earlier commits point at the new ones, and prunes commits that become empty after filtering, which keeps the history clean. A mapping file in the .git/filter-repo directory records the old and new hashes, which is useful for updating links in issue trackers and documentation after the rewrite.

The tool refuses to run on a repository that looks like a working clone with remotes and history in the reflog, unless you pass --force. That check protects against the common mistake of rewriting the only copy of a repository. A fresh clone made with --no-local, as in the example, satisfies it and leaves the original untouched.

Splitting a repository

A frequent use is extracting a directory into its own repository while keeping its history. Run git filter-repo --subdirectory-filter path/ or the equivalent --path with --path-rename, and the result contains only the commits that touched that path, with files moved to the root. Merge commits that no longer carry changes are dropped, and branches that contain no relevant commits disappear. Tags that pointed at unrelated commits are removed. Check the result by listing the log with --stat and comparing the final tree with the original directory. Then push the new repository to its own remote, and replace the directory in the old repository with a dependency, a submodule or a package reference, and a note in the README.

Rewriting authors and messages

A mailmap file fixes names and addresses across history: each line maps an old identity to a new one, and --mailmap applies it to authors, committers and taggers. For message edits, --message-callback takes a Python snippet that receives each message as bytes and returns the new one, which can remove trailers, redact ticket numbers or fix a typo that appears in many commits. Callback options exist for blobs, names, emails, refs and tags, and cover almost anything a rewrite requires. Keep the callback code in a file, review it, and run it on a clone first, since a mistake changes every commit.

Coordinating a rewrite with a team

A rewritten history is a different repository as far as every other clone is concerned. Plan the cutover: announce a freeze, rewrite and verify, force-push, archive the old repository read-only for reference, and tell everyone to make a fresh clone. People with unpushed work should rebase it onto the new history with git rebase --onto new-base old-base branch, or export patches with git format-patch before discarding the old clone. Open pull requests are tied to old commits and must be recreated. Update CI configuration, deploy keys, branch protection rules and any submodule pointers that referred to old hashes. The more of this is written down in advance, the shorter the disruption.

When a rewrite is the wrong tool

Rewriting is justified for secrets that cannot be revoked, content that cannot legally be hosted, and repository restructuring that the team has agreed on. It is the wrong answer for a large file that is merely inconvenient, where Git LFS or a shallow clone solves the pain going forward without changing history, and for stylistic cleanups, where the cost to everyone else exceeds the benefit. Ask who will be affected and what they must do before starting, and choose the lightest option that solves the real problem.

Where this does not apply

Do not rewrite history on a shared branch that others use without agreement, since it forces everyone to throw away their local copies. For small changes to recent unpushed commits, git rebase -i and git commit --amend are enough, see interactive rebase. A rewrite cannot recall copies already cloned elsewhere. The options are described in the git-filter-repo manual, and Git's own filter-branch warning explains why it was replaced.

Also inSecurity
Taggedgit
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.