Rewriting history is the most disruptive thing you can do to a shared repository, and occasionally the only correct response. This lesson is about doing it in the right order, and about the fact that the rewrite is never the step that actually protects you.
Topic 1: What a Rewrite Costs
Because a commit’s hash covers its tree and its parent’s hash, changing anything rewrites every commit after it. The consequences are all downstream of that one fact:
- Every clone diverges. Colleagues must re-clone or reset hard; anyone who pulls without knowing gets a duplicated history.
- Open pull requests break. They reference commits that no longer exist on the branch.
- Tags must be re-pointed, or they point into an abandoned lineage.
- Anything pinned by SHA breaks — submodules, dependency locks, deploy manifests, CI caches.
- The old objects survive on the forge until it garbage-collects, so a rewritten secret can still be fetched by hash for a while.
That last point is why the ordering in the next topic matters more than the commands.
Topic 2: Removing a Secret — the Correct Order
1. ROTATE THE CREDENTIAL. ← this is the step that protects you
2. Confirm the old credential is dead.
3. Remove it from history (filter-repo).
4. Force-push every ref.
5. Everyone re-clones.
6. Ask the forge to garbage-collect and purge cached views.
7. Add a pre-commit secret scan so it cannot recur.
Step 1 is not negotiable and everything else is hygiene. Assume the secret was read the moment it was pushed. Public repositories are scraped by bots within seconds; private ones are visible to everyone with read access, every fork, every CI log, and every clone anyone ever made. A rewrite removes it from your history — it cannot remove it from a copy.
Teams get this backwards constantly: hours of careful history surgery, then rotating the key the next day, having treated a disclosure as a tidiness problem.
Why deleting the file is not enough:
git rm secrets.env && git commit -m "remove secrets"
# the secret is still right here:
git log -p -- secrets.env
git cat-file -p <old-blob-sha>
The blob is an object in history. Deleting the file adds a commit; it does not remove anything.
Topic 3: filter-repo
git filter-branch is what older documentation recommends. It is slow, has subtle bugs, mangles tags, and Git’s own manual now recommends against it. Use git-filter-repo (a separate install, and the officially suggested replacement) or BFG.
# Always work on a fresh clone — filter-repo refuses on a repo with a remote
git clone --mirror git@github.com:org/repo.git repo-rewrite
cd repo-rewrite
# Remove a file from all of history
git filter-repo --invert-paths --path secrets.env
# Remove a directory
git filter-repo --invert-paths --path config/private/
# Replace a string everywhere it appears
echo 'AKIAIOSFODNN7EXAMPLE==>REDACTED' > replacements.txt
git filter-repo --replace-text replacements.txt
# Fix an email address across all commits
git filter-repo --email-callback '
return email.replace(b"old@example.com", b"new@example.com")'
# Extract a subdirectory into its own repository, keeping its history
git filter-repo --subdirectory-filter services/api
Then publish and coordinate:
git push --force --all
git push --force --tags
Tell everyone before you push, not after. The message people need:
main was rewritten at 14:00 to remove a committed credential.
The credential was rotated at 13:20 — the old one is dead.
Please re-clone. If you have unpushed work:
git fetch origin
git rebase --onto origin/main <old-main-tip> your-branch
Do NOT git pull — it will duplicate the entire history.
That last line prevents the second incident, which is otherwise guaranteed.
Topic 4: What Survives the Rewrite
Even after a correct rewrite, the old objects can persist:
- On the forge, until its garbage collection runs. GitHub keeps unreachable objects accessible by direct SHA for a while; support can expedite removal, and for a public repository you should ask.
- In every fork, which the forge does not rewrite for you. A fork of a public repository keeps the secret until its owner acts.
- In pull request views, which many forges cache independently of the branch.
- In CI logs and build artifacts, which nobody thinks to check and which frequently print environment variables.
- In every clone on every laptop, indefinitely.
This list is not an argument against rewriting. It is the argument for rotating first.
Topic 5: Legitimate Rewrites Other Than Secrets
Splitting a repository — extracting a service from a monorepo with its history intact:
git clone --no-local monorepo services-api
cd services-api
git filter-repo --subdirectory-filter services/api
git remote add origin git@github.com:org/api.git
git push -u origin main
Joining repositories — merging one repo into a subdirectory of another, preserving history:
git remote add api ../api-repo
git fetch api
git merge --allow-unrelated-histories api/main
git mv <files> services/api/
Removing accidentally committed large files, which is the other reason a repository becomes unpleasant:
git filter-repo --strip-blobs-bigger-than 10M
Find them first, so you know what you are removing:
git rev-list --objects --all |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
awk '$1=="blob" {print $3, $4}' | sort -rn | head -20
That pipeline lists the largest blobs in the entire history by size — usually a revealing five seconds on any repository that has grown mysteriously.
Correcting authorship across a history where commits were made with the wrong email is the same mechanism, and is usually worth doing only if a DCO or CLA check demands it.
Topic 6: Prevention, Which Is Much Cheaper
Client-side scanning catches the mistake before it becomes history:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks: [{ id: gitleaks }]
Server-side push protection catches what the client hook did not, because client hooks are skippable with --no-verify. GitHub secret scanning with push protection, GitLab secret detection, or a pre-receive hook on a self-hosted forge.
Architectural prevention, which removes the class of problem:
- Secrets come from a secret manager at runtime, never from a file in the repository.
.envis in the global gitignore template, and the repository ships.env.examplewith placeholder values.- Config files that must exist are generated, not committed.
- CI never echoes environment variables — and masks them if it must.
The one-minute audit worth running on any repository you inherit:
git log --all --full-history --diff-filter=A --name-only --pretty=format: |
sort -u | grep -Ei '\.(env|pem|key|p12|pfx|jks)$|secret|credential|password'
That lists every file ever added whose name suggests a credential. It is not exhaustive, and it finds something more often than it should.
Try it yourself: commit a fake key, delete it in a later commit, then retrieve it with git log -p and again with git cat-file -p on the blob. Watching the “deleted” secret come back twice is what makes the rotate-first rule stick.
Common mistake: performing the rewrite, telling the team afterwards, and letting someone git pull into a rewritten branch. Their merge reintroduces every old commit — including the secret — and pushes it back. The announcement has to precede the push, and it has to say “do not pull, re-clone” in exactly those words.