git replace: Transparent Object Substitution
git replace makes Git use one object as a transparent stand-in for another. When Git reads an object that has a replacement registered, it silently reads the replacement instead. Commands like git log, git show, and git diff behave as if the original object has the replacement's content — without any rewriting of commit hashes in the repository. This is a powerful but obscure mechanism that should be used sparingly and deliberately.
How git replace Works Internally
Replacements are stored as refs under refs/replace/. Each ref is named after the SHA of the original object and points to the SHA of the replacement object. When Git looks up the original hash during any traversal, it first checks refs/replace/ and transparently substitutes the replacement if one is found.
Replace mechanism diagram
Normal object lookup:
request hash ABC123 → return object ABC123
With a replacement registered:
request hash ABC123 → check refs/replace/ABC123 → found: DEF456 → return object DEF456
Storage in .git/:
.git/refs/replace/
└── ABC123abc123... → DEF456def456...
(original) (replacement)Basic Syntax
Core git replace commands
# Register a replacement: make Git use REPLACEMENT whenever ORIGINAL is requested git replace <original-object> <replacement-object> # List all active replacements git replace -l git replace --list # Delete a specific replacement git replace -d <original-object> git replace --delete <original-object>
Output of git replace -l
a3f1c2d83e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b → 9e2b0f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a
Primary Use Case: Grafting Disconnected Histories
The most compelling reason to use git replace is to graft two disconnected repository histories together. This happens when a project migrated from one repository to another and you want the full chronological history to appear continuous — without the old repo's commits being present in the new repo's object store originally.
The problem: two disconnected histories
Old repo (archived, read-only): A --- B --- C --- D (last commit before migration) New repo (active development): E --- F --- G --- H (root commit = "initial commit", no parent) Goal after grafting: A --- B --- C --- D --- E --- F --- G --- H
Step-by-step grafting with git replace
# 1. Add the old repo as a remote and fetch it
git remote add old-repo git@github.com:org/old-repo.git
git fetch old-repo
# 2. Find the last commit in the old history
OLD_LAST=$(git rev-parse old-repo/main)
echo "Last old commit: $OLD_LAST"
# 3. Find the root commit of the new repo (has no parents)
NEW_FIRST=$(git rev-list --max-parents=0 HEAD)
echo "New repo root commit: $NEW_FIRST"
# 4. Create a replacement for the new root commit that has the old tip as its parent
REPLACEMENT=$(git commit-tree $(git rev-parse ${NEW_FIRST}^{tree}) -p $OLD_LAST -m "$(git log -1 --format=%B $NEW_FIRST)")
echo "Replacement commit: $REPLACEMENT"
# 5. Register the replacement
git replace $NEW_FIRST $REPLACEMENT
# 6. Verify the graft worked — old history now visible
git log --oneline | tail -10History now appears continuous
...new commits... e9f0a1b (replaced) Initial commit in new repo d4e5f6a Last commit in old repo ← old history visible here c3d2e1f Previous old commit b2c1d0e Earlier old commit a1b2c3d Very first commit (2017)
Sharing Replacements with Your Team
Push replacements to remote
# Push all replacements git push origin "refs/replace/*" # Team members must fetch them explicitly git fetch origin "refs/replace/*:refs/replace/*" # Or configure automatic fetching in .git/config git config --add remote.origin.fetch "+refs/replace/*:refs/replace/*"
Bypassing Replacements
When debugging or auditing, you may need to see the real, un-replaced history. Use the --no-replace-objects flag before any Git command to disable replacements for that invocation.
Run commands without applying replacements
# See the real unreplaced history git --no-replace-objects log --oneline # See the true content of a replaced object git --no-replace-objects cat-file -p <original-hash> # Show the real commit the branch points to git --no-replace-objects show HEAD
Use Cases
Use Case | What You Do | Benefit |
|---|---|---|
History grafting | Connect two repo histories via parent replacement | Seamless history without force-push rewriting |
Fix a bad commit message | Create corrected commit, replace original | Message fixed without rebasing all downstream commits |
Add missing author info | Replace commit with corrected metadata | Corrects identity without full history rewrite |
Testing history tools | Mock specific historical commits | Test tooling against synthetic history scenarios |
Swap a large binary | Replace tree with cleaned version | Appears removed without destroying others' clones |
Practical Example: Fix a Bad Commit Message
Use git replace to fix a committed typo without rebasing
# Identify the commit with the bad message
git log --oneline | grep "typo"
# abc1234 fix: typo in messge
# Create a corrected replacement commit (same tree, same parent, new message)
REPLACEMENT=$(git commit-tree abc1234^{tree} -p abc1234^ -m "fix: typo in message")
# Register the replacement
git replace abc1234 $REPLACEMENT
# Verify the log now shows the corrected message
git log --oneline | head -5
# (shows corrected message for abc1234)Common Errors
"error: replace ref already exists" — a replacement is already registered for that hash. Use git replace -d
first. Replacement not visible to teammates — they need to fetch refs/replace/* explicitly and configure the refspec.
git log shows different history than a teammate — one of you has replacements the other does not. Compare with git replace -l.
Replacement orphaned after rebase — rebase rewrites commit hashes; the original hash no longer exists and the replacement has no effect.