Git, CI/CD & GitOpsXXXVI · Git History RewritingOperations
Replacing content — when a string must change across history
What you'll learn
- Identify the four text-replacement use cases (email, credential, name, organisation)
- Run git filter-repo --replace-text expressions.txt and the process-substitution one-shot pattern
- Run git filter-repo --name-callback for path rewrites and --mailmap for identity rewrites
- Distinguish --replace-text (literal->replacement) from BFG --replace-text (single replacement token)
- Verify the rewrite with git log -S<literal> before the force-push
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Text replacement is the right tool when a string must change
across every commit. The tool is git filter-repo --replace-text;
the alternative for large repos is BFG --replace-text.
When text must be replaced
The four text-replacement use cases:
- Email address. A team member changed companies; the old email should not appear in history.
- Credential value. The credential is staying but the value is rotating and the new value must be in history (a public key, a configuration endpoint).
- Name. Married name, preferred name, legal name change. The name should not appear in the old form in history.
- Organisation. Company rebrand, division merge, legal entity change. The old name should not appear in history.
flowchart LR
A["string must change"] --> B{"single token or per-string?"}
B -->|"single ***REMOVED***"| C["BFG --replace-text"]
B -->|"per-string replacement"| D["filter-repo --replace-text"]
C --> E["reflog expire + gc"]
D --> E
E --> F["force-push --force-with-lease"]
The decision is between BFG (fast, single replacement token) and filter-repo (slower, per-string replacement values).
The procedure with filter-repo
The file is plain text, one literal->replacement pair per
line:
olduser@oldcorp.com==>newuser@newcorp.com
api.example.com/v1==>api.example.com/v2
The rewrite:
git filter-repo --replace-text expressions.txt
Every blob containing any literal is rewritten with the corresponding replacement. Commit messages are scanned too because commit messages are stored as blobs in the object store. The new blobs have new SHAs; the commit graph follows.
The one-shot pattern for a single replacement uses process substitution:
git filter-repo --replace-text <(echo 'olduser@oldcorp.com==>newuser@newcorp.com')
The pattern is right when there is exactly one literal to replace; the file form is right when there are several.
The procedure with BFG
BFG’s --replace-text replaces every match with the same
***REMOVED*** token:
bfg --replace-text passwords.txt
passwords.txt is one string per line; every match becomes
***REMOVED***. BFG is faster than filter-repo --replace-text for very large repositories because BFG walks
the object store directly and skips the commit-graph parsing.
The BFG form is right when the match should be replaced with
the same sentinel; the filter-repo form is right when each
match has its own replacement value.
Callbacks for path and identity
Path rewrites use --name-callback:
git filter-repo --name-callback 'return name.replace(b"old/", b"new/")'
The callback is a one-line Python expression that takes a
bytes path and returns a bytes path (or None to leave
unchanged). The mode is right for renaming a path across
history (a directory move, a file rename in every commit).
Identity corrections use --mailmap:
git filter-repo --mailmap mailmap.txt
The mailmap.txt is in the standard .mailmap format:
New Name <new@example.com> <old@example.com>
New Name <new@example.com> Old Name <old@example.com>
Every author and committer identity in the history is rewritten to the canonical form. The mode is right when the correction is an identity (name + email), not a string in file contents.
Verification before push
Three checks before the force-push:
git log --all -S'olduser@oldcorp.com'
git log --all --grep='olduser@oldcorp.com'
git rev-list --all --objects | grep -i 'oldcorp'
The first checks blob contents (pickaxe); the second checks commit messages; the third checks every object reachable from any ref. All three should print nothing before the force-push.
Edge cases
- The literal appears in many files. Use
--replace-text; the file form is more maintainable than per-file callbacks. - The replacement is sensitive. Use BFG
--replace-textso the replacement is the same***REMOVED***token and no new credential appears in history. - The literal is a regex.
filter-repo --replace-textis literal-only; for regex substitution use--blob-callbackwith Python’sremodule.
Production discipline
- The expressions file is the audit trail. The file lists every literal that was replaced; preserve the file for the security audit.
- Verify with three checks before pushing. Pickaxe for blob contents, grep for commit messages, rev-list for every reachable object.
--force-with-leasealways. The safety catch against clobbering commits that landed during the rewrite window.
Cross-course references
- Linux for Production Sysadmins - Part XXXIV (ConfigMgmt) covers the configuration-file rewrites that mirror the text replacement.
- Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers the inventory rewrites that benefit from the mailmap mode.
- Terraform for Production Sysadmins - Parts IX-XII (State) cover the state-file backend rewrites that may follow a history rewrite.
Quiz
Knowledge check · 4 questions
Q1. Which command replaces a string in every blob that contains it, with per-string replacement values?
Q2. git filter-repo --replace-text rewrites blob contents but does not rewrite commit messages.
Q3. Describe the difference between git filter-repo --replace-text and BFG --replace-text.
Q4. A team rewrites history with git filter-repo --replace-text to scrub an old email address, force-pushes, and reports the rewrite completed. Diagnose the residual exposure.
Time T0: team runs git filter-repo --replace-text expressions.txt on a fresh clone. expressions.txt contains the old email address but does not contain the literal that appears in a commit message by the former engineer. Time T0+10m: team force-pushes with --force-with-lease. Time T0+1h: an external auditor finds the old email address in commit a3f9d2's commit message.
Passing score: 75%. Answers are checked in this browser.