Deleting a committed file only hides it from the latest commit. The blob still lives in history, and if it was a secret, it's already compromised.
Someone commits a 400MB training dataset. Or an .env with a live AWS key. They notice, run git rm, commit, push, and figure it's handled. It isn't. git rm only removes the file from the tip of the branch. Every previous commit still points at the blob, and the blob still sits in the pack files. GitHub still rejects your push over the 100MB limit. The secret is still readable by anyone who clones and runs git log -p.
History in Git is content-addressed and append-only. You don't edit it, you rewrite it: build a new chain of commits that never contained the file, then throw the old chain away. That's the whole job, and the tooling matters.
If what leaked was a credential, stop reading and go invalidate it. Rotate the key, revoke the token, change the password. Do it before you touch the history.
Here's the reasoning people skip. The moment a secret hits a push, assume it's captured. Bots scrape public pushes within seconds. On GitHub the commit is cached, and forks and pull requests can hold their own copy of the blob that your rewrite won't touch. Even a private repo has CI logs, clones on laptops, and backups. Scrubbing history makes the secret hard to find again. It does not make it safe. The only thing that makes a leaked credential safe is that it no longer works.
So the order is fixed: rotate, confirm the old value is dead, then clean the repo so you're not shipping a landmine to the next person who clones.
The old advice was git filter-branch. Don't. It's slow, it's easy to get wrong, and the Git project itself now points you elsewhere in filter-branch's own man page. On a repo of any size it crawls, and a subtle mistake leaves the blob behind while looking like it worked.
The modern tool is git filter-repo. Install it with pip install git-filter-repo or brew install git-filter-repo.
Work on a fresh clone. filter-repo is aggressive by design and removes your remotes as a safety measure, so don't run it on your only copy.
git clone https://github.com/acme/api.git
cd api
To strip a specific file everywhere it ever existed:
git filter-repo --path config/secrets.yml --invert-paths
--path names the file, --invert-paths flips the meaning to "keep everything except this." You can pass --path multiple times, or use --path-glob '*.pem' to catch a pattern.
For a bloated repo where you don't care which file, only that it's huge:
git filter-repo --strip-blobs-bigger-than 50M
That rewrites every commit, dropping any blob over 50MB. Good for cleaning up a history where someone kept committing build artifacts or model checkpoints.
Once it finishes, re-add your remote and force-push:
git remote add origin https://github.com/acme/api.git
git push origin --force --all
git push origin --force --tags
BFG Repo-Cleaner does less than filter-repo but does it fast, and the syntax is friendlier if you're doing one of the two things it's built for: deleting files by name or replacing secret strings.
git clone --mirror https://github.com/acme/api.git
java -jar bfg.jar --delete-files id_rsa api.git
java -jar bfg.jar --strip-blobs-bigger-than 50M api.git
The --mirror clone gives BFG the bare repo it wants. After it runs, garbage-collect and push:
cd api.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
BFG protects your latest commit by default, on the theory that HEAD is your current good state. That's usually what you want, but if the bad file is still in HEAD, remove it with a normal commit first.
Rewriting history changes every commit hash from the rewrite point forward. Your force-push replaces the remote's history, but nobody else's local copy knows that. If a teammate pulls the old way, Git happily merges the deleted file right back in, and you're back where you started.
There's no clean fix, only coordination. Before you push, tell everyone what's coming. After you push, everyone re-clones fresh, or at minimum resets their branches hard to the new remote. Any open pull request built on old commits has to be recreated. On a shared repo this is a scheduled event, not a Tuesday-afternoon whim. Treat it that way and it's fine. Surprise people and you'll spend a week chasing the file as it reappears from stale branches.
Cleaning history is expensive enough that you only want to do it once. Three cheap guards keep the same mistake from landing twice.
Large files belong in Git LFS. Track the file types that keep bloating the repo and Git stores a pointer while the bytes go to LFS storage:
git lfs install
git lfs track "*.psd" "*.zip" "*.model"
git add .gitattributes
Obvious things belong in .gitignore before anyone can stage them: .env, *.pem, credentials.json, node_modules/, build output directories.
For secrets specifically, run a scanner as a pre-commit hook so the leak never reaches a push. Gitleaks is the common pick:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Then pre-commit install, and a commit carrying an AWS key or a private key gets blocked locally before it ever hits the remote. It won't catch everything, but it catches the careless 90%, which is most real leaks.
If you want more on untangling Git states before they escalate, our Git troubleshooting guide covers the everyday cases.
If it's a secret, rotate it right now, this minute, and consider the value burned no matter what you do next. Then clean the history with git filter-repo unless your case is a simple file-delete, in which case BFG is faster to reach for. Force-push, then get everyone on a fresh clone the same day so the blob doesn't crawl back. Finally, spend the twenty minutes to wire up LFS, .gitignore, and gitleaks. The scrub is the emergency; the guards are how you avoid the next one.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Datadog does everything and bills you for all of it. SigNoz covers the core APM story on your own ClickHouse. Here's when the trade is worth it.
A practitioner's guide to tracing, cost tracking, and evaluating LLM apps in production with Langfuse, Helicone, Arize Phoenix, and LangSmith.
Explore more articles in this category
The IaC landscape fractured after the Terraform license change. This is the map to what each tool is actually best at, and how to choose without regret.
Terragrunt keeps large Terraform setups DRY and orchestrated, but small teams often pay its learning curve for little return.
A practical comparison of Kubernetes-native continuous reconciliation against the classic CLI-driven, state-file IaC model for platform teams.
Evergreen posts worth revisiting.