git.tools / guides

How to remove a file from git history

A .env full of secrets, a 300MB binary, a build artifact that never should've been committed — how to purge a file from every commit with git filter-repo.

Last updated September 18, 2026 · 7 min read

Deleting the file and committing the deletion doesn't remove it. It sits in every historical commit — downloadable by anyone who has a SHA, and keeping your clone fat forever. Actually removing a file means rewriting history so no commit ever contained it.

TL;DR:

pip install git-filter-repo
git clone git@github.com:you/repo.git && cd repo
git filter-repo --path path/to/file --invert-paths
git remote add origin git@github.com:you/repo.git
git push --force --all && git push --force --tags

Details, variants, and the fine print below.

First, know which problem you have

A sensitive file — .env, id_rsa, credentials.json. Scrubbing removes it from your repo's history, but every secret inside it is still compromised the moment it reached a remote. Rotate those credentials first; our secret removal guide covers the full playbook, including the GitHub Support step.

A large file — a binary, a video, a database dump. The goal is repo size. One thing to know up front: GitHub's reported size won't drop the moment you force-push (more on that below).

Plain junk — node_modules, .DS_Store, build output. The goal is hygiene. Whatever your reason, add the file to .gitignore before you finish, or someone will commit it right back.

One nuance that applies to all three: the commands below remove the file from every commit, including the latest. If a version of the file legitimately belongs in the repo going forward — a sanitized config template, a compressed asset — scrub first, then commit the clean version fresh on top of the rewritten history.

Find the offenders

Not sure what's actually bloating the repo? filter-repo has an analysis mode that changes nothing and reports everything:

git filter-repo --analyze

It writes reports into .git/filter-repo/analysis/ — the path-all-sizes.txt report lists every path by total size across all revisions. Sort by the biggest offenders and you have your hit list.

Remove the file

Work in a fresh clone — filter-repo refuses dirty or previously-rewritten repos unless you pass --force, and that refusal exists to protect you.

By exact path:

git filter-repo --path config/secrets.json --invert-paths

The --invert-paths flag means "remove this, keep everything else" — without it you'd keep only that path, which is a very different afternoon.

By name, wherever it lived. If .env got committed in three different directories over the years:

git filter-repo --path .env --use-base-name --invert-paths

By pattern, for whole families of files:

git filter-repo --path-glob '*.zip' --invert-paths

By size, when you want every oversized blob gone regardless of name:

git filter-repo --strip-blobs-bigger-than 100M

Whole directories. Removing node_modules or a build output folder works the same way — pass the directory with a trailing slash so filter-repo treats it as a path prefix:

git filter-repo --path node_modules/ --invert-paths

For truly enormous repos, the BFG Repo-Cleaner (bfg --delete-files, bfg --strip-blobs-bigger-than) does the same jobs faster, at the cost of being blunter. Skip git filter-branch entirely — deprecated, slow, and footgun-shaped.

filter-repo rewrites every branch and tag in one pass and then deliberately removes the origin remote so you can't half-push a rewrite. Re-add it and force-push everything:

git remote add origin git@github.com:you/repo.git
git push --force --all
git push --force --tags

Verify the scrub worked

Before announcing anything, prove it. The path should appear in zero commits and zero objects across every branch and tag:

git log --oneline --all -- path/to/file
git rev-list --objects --all | grep path/to/file

Both should come back empty. Then sanity-check that the rest of history survived: git log --oneline -5 should show your recent commits (with new SHAs — that's expected, rewriting changes every descendant hash), and your tags should still exist, now pointing at rewritten commits.

What stays true afterwards

Be honest with yourself (and your incident report) about what force-pushing does and doesn't do:

Old commits remain reachable by SHA on GitHub until its garbage collector removes the unreachable objects. There's no client-side way to trigger that — no API, no setting — and it can take a long time. Commits referenced by pull requests survive even a force-push, because refs/pull/* is a namespace your push never touches; only GitHub Support can purge those and clear cached views. The walkthrough for filing that request is in our secret-removal guide.

Forks and existing clones keep the file. You can't rewrite repos you don't own, and old clones can drag the purged history back in on a careless pull. Every collaborator should delete their clone and re-clone.

The repo's size on GitHub won't shrink immediately for the same garbage-collection reason. Fresh clones will be lean right away; the server-side number catches up when it catches up.

Keep it from coming back

Add the path to .gitignore and commit that as the first thing on your rewritten history. If the file was large and legitimately belongs in the repo — design assets, datasets — this is your cue to move it to Git LFS instead of plain git, so history stops carrying every revision of every binary forever.

Worth the extra five minutes: a pre-commit hook like gitleaks or detect-secrets blocks secret-shaped content before it ever becomes a commit, and GitHub's push protection does the same server-side for known token formats. Scrubbing history is a once-a-career chore if you make the leak hard to repeat.

The git.tools way

Back up before the scary thing

Backup seals an encrypted, restorable copy of your full git history — locked with a key only you hold. Take one before you rewrite history, and a botched scrub becomes a minor inconvenience instead of a bad week.

Open Backup →

Common questions

Doesn't git rm plus a commit remove the file?

Only from the latest commit. Every earlier commit still contains it, and history is the first place anyone looks. Removal from history requires a rewrite — that's the entire point of filter-repo.

Why is my repo still the same size on GitHub after force-pushing?

Because the old objects still exist server-side until GitHub garbage-collects them, and you can't trigger that from the client. It resolves on GitHub's schedule. New clones get the slim history immediately.

Should I use git filter-branch?

No. Git's own documentation warns against it, it's dramatically slower, and it leaves backup refs and original objects lying around in ways that regularly confuse people into thinking the rewrite failed. filter-repo is the maintained, recommended replacement.

Can I undo a filter-repo run?

Not in the clone where you ran it — filter-repo deliberately clears the original refs. Your safety net is that the remote still has the old history until you force-push; that's your window to walk away. After the force-push, the old history lives only in other clones and in GitHub's not-yet-collected objects. This is exactly why the workflow starts with a fresh clone and a backup.

What happens to my teammates' clones?

They still contain the old history. If they pull, git may merge the old and rewritten histories into a mess that resurrects the file. The clean path: everyone deletes their local clone and re-clones after the force-push.

Will this break open pull requests?

The PRs themselves survive, but any PR whose diff touched the removed file will look odd, and commits inside PRs keep their copies until GitHub purges the pull-request refs. For sensitive files, include the PR numbers in a GitHub Support request. Expect some PRs to need a rebase onto the rewritten history before they can merge cleanly.