Remove a Path from History with git filter-repo

git filter-repo is the modern, fast way to remove a file or directory from every commit in your history. It does in minutes what filter-branch used to labour over. You will produce a new clone in which the named path is gone from every rewritten commit. This is useful for scrubbing an accidentally committed secret or shrinking a bloated repository, but it changes commit IDs and is destructive. Allow roughly 15 minutes for a small repository, plus time to coordinate with anyone who already cloned it.

Before you start

Use an ordinary account. You do not need elevated privileges, and sudo can leave the rewritten files owned by root. Install the package that provides git-filter-repo, then check the command and its Git and Python prerequisites:

$ command -v git-filter-repo
/usr/bin/git-filter-repo
$ git --version
$ python3 --version
$ git filter-repo --help | head -n 8

The installed Debian manpage identifies package version 2.38.0-2. The command's own --version output is a build identifier rather than a friendly release number, so record both the package version and the command's output if you will need to reproduce this rewrite later.

1. Make a disposable fresh clone

Do not run the rewrite in your working repository. Clone the source into a new directory, and use --no-local when the source is another local path, which keeps the clone shape compatible with filter-repo's fresh-clone safety check:

$ git clone --no-local https://example.invalid/team/project.git project-rewritten
$ cd project-rewritten
$ git remote -v
$ git status --short

The last command should print nothing. Keep the original clone untouched as your recovery copy. If the source URL contains a sensitive token, do not paste it into a shared terminal recording.

2. Inspect the repository without changing it

Start with analysis. It writes reports under .git/filter-repo/analysis and does not rewrite a single commit. The report exposes large paths and deleted-path sizes, which helps you choose an exact filter instead of guessing:

$ git filter-repo --analyze
$ find .git/filter-repo/analysis -maxdepth 1 -type f -printf '%f\n' | sort | head
$ sed -n '1,12p' .git/filter-repo/analysis/path-deleted-sizes.txt

Analysis refuses to overwrite an existing report directory. Need a fresh one? Choose a different --report-dir, or remove only the old analysis directory once you have confirmed it is disposable; the report directory is not the source repository and is safe to archive for review.

Checkpoint: write down the exact path to remove, including its spelling and whether it was ever renamed. Path filtering does not follow renames, so include each historical name explicitly.

3. Preview the path filter

Use --path to select what remains, or --invert-paths to exclude the selected path. For a repository containing docs/ and src/, this preview removes the documentation directory and keeps everything else:

$ git filter-repo --dry-run --invert-paths --path docs/

--dry-run leaves the repository unchanged and saves original and filtered fast-export streams for comparison. It cannot replicate every empty-commit decision the real fast-import backend makes, so treat it as a preview, not proof the final object graph will be identical.

For a file that was renamed, list each known name:

$ git filter-repo --dry-run --invert-paths \
    --path secrets/old-config.env \
    --path config/production.env

Shell quoting matters for globs: quote patterns so the shell does not expand them before filter-repo ever sees them. --path-glob '*.pem' selects matching paths, while --path is an exact match.

4. Rewrite the fresh clone

This is the irreversible step. Make sure the original clone, or another complete backup, is available, and confirm the current directory before you go any further:

$ pwd
/path/to/project-rewritten
$ git status --short
$ git filter-repo --invert-paths --path docs/

By default, filter-repo creates new commits, prunes commits that become empty, updates abbreviated commit references in messages, removes the origin remote and performs cleanup. It also writes maps and run state below .git/filter-repo/. Exact output varies with repository size, but a successful run ends without an error and leaves you a rewritten checkout.

Warning: do not add --force just because the command refuses a repository. That flag bypasses the fresh-clone safety check and can make a mistake unrecoverable. Use it only after deliberately confirming that the current repository is itself backed up and is the intended target.

5. Verify the result before publishing it

Check that the removed path is absent from both the current tree and every reachable commit. The second command inspects the rewritten history, not just the checkout:

$ test ! -e docs/ && echo 'current tree: docs absent'
current tree: docs absent
$ git log --all --name-only --format= | grep -E '^docs(/|$)' || echo 'history check: docs absent'
history check: docs absent
$ test -s .git/filter-repo/commit-map && sed -n '1,4p' .git/filter-repo/commit-map

The commit map begins with old and new columns; a row whose new value is all zeroes represents a removed commit. Review branches and tags with git show-ref. Partial rewrites using --refs or --partial can leave old and new history mixed together, so avoid those options unless that mixture is exactly what you intend.

6. Publish and recover deliberately

Do not force-push automatically. Create a new remote repository, or agree a maintenance window with every contributor first. A rewritten history is incompatible with existing clones: people must reclone, reset their branches onto the new history, or carefully rebase work created after the rewrite. If the removed material was a credential, revoke and replace it: rewriting Git objects does nothing to undo a credential that was already copied elsewhere.

Recovery: if verification fails, discard this disposable clone and clone the original again. That is the clean undo path. If you have already pushed the rewrite, stop further pushes, preserve the original clone, and coordinate recovery with the repository administrator. Do not assume reflogs will rescue you: the normal rewrite performs cleanup, and reflogs are no substitute for a backup.

Done means