GitRewriting History (filter-repo)

git filter-repo: Rewriting History

git filter-repo is the modern, recommended tool for rewriting Git repository history. It replaces the old git filter-branch command, which was notoriously slow, error-prone, and deprecated since Git 2.36. With git filter-repo you can remove sensitive files that were accidentally committed, rename paths, change author emails, extract a subdirectory into its own repository, and much more — all in a fraction of the time.

Installation

Install git-filter-repo

Bash
# Using pip (recommended, works everywhere)
pip install git-filter-repo

# macOS with Homebrew
brew install git-filter-repo

# Verify installation
git filter-repo --version
# git-filter-repo 2.38.0
Note
`git filter-repo` is a standalone Python script that integrates with Git. It is NOT included with Git itself — you must install it separately.
Warning
`git filter-repo` rewrites every commit in your repository. ALL commit hashes change. This means you MUST force-push after running it, and every collaborator MUST delete their local clone and re-clone. Do this on a backup first, and coordinate with your team.
Remove a File from Entire History

The most common use case: a secret key, password, or large binary was committed and you need to scrub it from every commit in history.

Remove a file from all commits

Bash
# First, make a backup of your repo!
cp -r my-repo my-repo-backup

cd my-repo

# Remove secrets.env from every commit
git filter-repo --path secrets.env --invert-paths

# Remove multiple files
git filter-repo   --path .env   --path config/secrets.json   --path private-key.pem   --invert-paths

Terminal output

Text
Parsed 1847 commits
New history written in 3.21 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
HEAD is now at a9b8c7d Add authentication module
Enumerating objects: 1502, done.
...
git-filter-repo Statistics:
  Commits: 1847 (changed: 1847, removed: 0)
  Blobs:   3012 (changed: 0, removed: 1)     ← 1 blob removed
  Tags:    12   (changed: 0, removed: 0)

Verify the file is gone from all history

Bash
# Should return nothing
git log --all --full-history -- secrets.env

# Force push to remote (WARNING: destructive)
git push origin --force --all
git push origin --force --tags
Remove Files Matching a Pattern

Remove files using glob patterns

Bash
# Remove all .env files anywhere in the repo
git filter-repo --path-glob '**/.env' --invert-paths

# Remove all files in a secrets/ directory
git filter-repo --path-glob 'secrets/*' --invert-paths

# Remove using a regex pattern
git filter-repo --path-regex '.*.(pem|key|pfx)$' --invert-paths
Rename a Path in History

If you renamed a directory or file and want the old history to reflect the new structure:

Rename a directory in all history

Bash
# Rename "old/" to "new/" throughout all commits
git filter-repo --path-rename old/:new/

# Rename a specific file
git filter-repo --path-rename src/utils.js:src/helpers.js

# Move everything into a subdirectory (useful before merging repos)
git filter-repo --to-subdirectory-filter mypackage/
Fix Author Email Across History

Common after merging work done under a personal email that should have been a work email:

Replace email address in all commits

Bash
# Using --email-callback with Python
git filter-repo --email-callback '
return email.replace(b"personal@gmail.com", b"work@company.com")
'

Fix both name and email

Bash
git filter-repo --name-callback '
return name.replace(b"Old Name", b"Correct Name")
' --email-callback '
return email.replace(b"old@email.com", b"new@email.com")
'

Complex remapping using a mailmap file

Bash
# Create a mailmap file
cat > mailmap.txt << 'EOF'
Correct Name <correct@email.com> Old Name <old@email.com>
Correct Name <correct@email.com> <typo@emial.com>
EOF

git filter-repo --mailmap mailmap.txt
Extract a Subdirectory as a New Repository

When a monorepo package grows large enough to deserve its own repository, you can extract it with full history intact:

Extract packages/ui into a standalone repo

Bash
# Clone the monorepo first (don't work on the original!)
git clone git@github.com:org/monorepo.git ui-library
cd ui-library

# Filter to only the packages/ui directory
# and move its contents to the root
git filter-repo --subdirectory-filter packages/ui

# Now the repo root IS packages/ui, with all its history
ls
# Button.tsx  Input.tsx  index.ts  package.json  ...

git log --oneline | head -5
# 8f7e6d5 Fix Button accessibility
# 4c3b2a1 Add Input validation
# ...

# Create a new remote and push
git remote add origin git@github.com:org/ui-library.git
git push -u origin main
Keep Only Specific Paths

Keep only certain files (opposite of invert-paths)

Bash
# Keep ONLY the src/ directory and README.md
git filter-repo --path src/ --path README.md

# Keep only files matching a pattern
git filter-repo --path-glob 'packages/api/**'
Strip Large Files from History

Remove all blobs larger than 10MB

Bash
# Remove any blob larger than 10 megabytes
git filter-repo --strip-blobs-bigger-than 10M

# Remove large files and show what was removed
git filter-repo --strip-blobs-bigger-than 1M --dry-run
Analyzing History Before Filtering

Analyze repo to find large files

Bash
# Show the largest objects in history
git filter-repo --analyze

# Creates .git/filter-repo/analysis/ with reports:
ls .git/filter-repo/analysis/
# blob-shas-and-paths.txt
# directories-all-sizes.txt
# extensions-all-sizes.txt
# filenames-all-sizes.txt

# Find the biggest files
sort -k1 -rn .git/filter-repo/analysis/blob-shas-and-paths.txt | head -10
Comparison: git filter-repo vs git filter-branch

Feature

git filter-repo

git filter-branch

Speed

10–100x faster

Very slow (shell subprocess per commit)

Status

Actively maintained, recommended

Deprecated since Git 2.36

Safety

Refuses to run on repos with remotes by default

No such protection

API style

Python callbacks

Shell scripts

Dry run support

Yes (--dry-run)

No

Analysis tools

Yes (--analyze)

No

Error handling

Clear, actionable messages

Cryptic shell errors

Handles binary files

Yes

Problematic

Installed with Git

No (pip install)

Yes (but deprecated)

After Rewriting: Force Push Protocol

Complete post-filter-repo checklist

Bash
# 1. Verify the local result looks correct
git log --oneline -10
git show HEAD

# 2. Check that the sensitive file is gone
git log --all --full-history -- secrets.env   # should be empty

# 3. Run garbage collection to expire old objects
git reflog expire --expire=now --all
git gc --prune=now --aggressive

# 4. Force push ALL branches and ALL tags
git push origin --force --all
git push origin --force --tags

# 5. Tell every collaborator:
echo "IMPORTANT: Delete your local clone and re-clone from origin.
Your local clone has stale commit hashes that no longer exist on remote."

# 6. Rotate any secrets that were exposed
# (a secret in git history is compromised — rotate it regardless)
Common Errors
  • "Refusing to destructively overwrite repo history since this does not look like a fresh clone" — git filter-repo detected a configured remote. Clone fresh first, or use --force.

  • "No refs were changed" — the path you specified does not exist in history. Check spelling with git log --all -- path/to/file.

  • Collaborators see merge conflicts after force push — they must delete and re-clone, not just pull.

  • git filter-repo: command not found — not installed. Run pip install git-filter-repo.

  • Tags still reference old commits — always include --force --tags when force-pushing after filter-repo.

Warning
If the file you are removing contained a secret (API key, password, private key), assume it is compromised regardless of the filter-repo run. Anyone who cloned the repo before the rewrite has the secret. Rotate all credentials immediately and independently of the history rewrite.
Tip
Always run `git filter-repo --analyze` before choosing what to filter. The analysis report tells you exactly which files are consuming the most space so you can make targeted, informed decisions about what to remove.