Git Replace and Grafts: History Surgery Without Force-Push¶
git replace and grafts provide surgical, in-place history modification that preserves existing commit SHAs. Unlike git filter-repo or git filter-branch (which rewrite history and generate new SHAs), git replace lets you:
- Swap one object (commit, tree, blob) for another globally, without changing the SHA of the replaced object.
- Graft new parentage onto existing commits (change a commit's parents) while keeping its SHA identical.
- Perform history fixes that are safe to share with CI systems, code-review tools, and downstream clones that have already cached the original commit SHAs.
- Undo mistakes like commits with wrong parents, merge commits that should have been fast-forwards, or blobs that need replacement (e.g. a secret-leaking file) — all without force-push.
Why a DevOps Engineer Needs Replace and Grafts¶
- CI Pipeline SHA Stability
- Your CI system caches build artifacts keyed by commit SHA. If you rewrite history with
filter-repo, every SHA changes, breaking caches and forcing full rebuilds. -
With
git replace, the offending commit stays at its original SHA; only its logical content or parentage changes. CI continues to use the same SHA. -
Hotfixing History Without Breaking Forks or Mirrors
- You discover that commit
abc123(already pushed and mirrored) has the wrong parent or contains a secret. -
A force-push would require every downstream consumer to recover; a replace lets you fix the logical history while keeping
abc123identical on disk. -
Grafting Maintenance Backports
-
You backported a fix to an old release branch, but the merge-base got miscalculated. Instead of rebasing the entire branch (new SHAs), you can graft the backported commit onto the correct base.
-
Repairing Corrupt or Malformed History
-
A botched
git filter-repoor a bad merge left a commit with nonsensical parents. Replace lets you splice in a corrected version. -
Temporary Experimentation
- Try out a history rewrite (e.g., squash a series) via replace, test it in CI, and only then decide whether to make it permanent with a real rewrite.
How Git Replace Works Under the Hood¶
When you run git replace <old> <new>, Git writes a ref:
whose contents are the SHA-1 of <new>. Whenever Git needs to read object <old> (as a commit, tree, tag, or blob), it transparently substitutes <new> instead. The replacement is:
- Local by default (stored in
.git/refs/replace/and not pushed). - Globally visible if you push the replace refs (
git push --refs='*:refs/replace/*'). - Stackable: you can replace a replaced object again.
- Overrideable:
GIT_NO_REPLACE_OBJECTS=1or--no-replace-objectsdisables substitution for debugging.
Grafts (.git/info/grafts or git replace --graft) work similarly: they let you alter a commit's recorded parents without changing the commit's own SHA.
Enabling and Using Replace¶
Replace is enabled by default in modern Git (since v1.6.5). You can explicitly ensure it is on:
# Global setting (recommended)
git config --global replace.enabled true
# Per-repo (overrides global)
git config replace.enabled true
1. Replace a Commit (Change Its Parents) — Grafting¶
Suppose commit C has wrong parents; you want to re-parent it onto NewBase while keeping C's SHA identical:
# Step 1: create a replacement commit with the desired parents
# (keeps the same tree and message as C)
git commit-tree -p NewBase -p <second-parent-if-merge> -m "$(git log --format=%B -n 1 C)" ^{tree} > /tmp/newcsha
NEW_C=$(cat /tmp/newcsha)
# Step 2: tell Git to replace C with NEW_C whenever C is read
git replace $C $NEW_C
# Verify: Git now shows C's parents as NewBase and <second-parent>
git log --format=%P -n 1 $C
2. Replace a Blob (Swap a File's Contents) Without Changing History¶
You committed a large binary or a secret at blob B. You want to replace it with a sanitized version B_clean but keep every commit that references B at its original SHA.
# Create the sanitized blob (e.g. from a filtered version of the file)
git hash-object -w --no-filters path/to/clean-file > /tmp/bclean
B_CLEAN=$(cat /tmp/bclean)
# Replace B with B_clean globally
git replace $B $B_CLEAN
# Now `git show <any-commit>:path/to/file` returns the clean contents
3. Replace a Tree (Swap an Entire Directory Snapshot)¶
Useful when a directory was accidentally committed with wrong permissions or symlinks.
# Build the corrected tree (e.g. from a filtered checkout)
git write-tree --prefix=corrected/ > /tmp/newtree
TREE_CORRECTED=$(cat /tmp/newtree)
# Replace the erroneous tree T with T_CORRECTED
git replace $T $TREE_CORRECTED
4. Graft via .git/info/grafts (Deprecated but Still Functional)¶
For compatibility, Git still reads .git/info/grafts if present. Each line:
Example: make deadbeef a child of main and feature (a merge):
Note: git replace --graft is the preferred modern equivalent.
Common DevOps Workflows & Scenarios¶
Scenario 1: Fixing a Merge Commit That Should Have Been a Fast-Forward¶
A feature branch was merged with --no-ff by mistake, creating an unnecessary merge commit M. You want M to appear as if it were a simple fast-forward (i.e., have only one parent) without changing M's SHA.
# Assume:
# M = the merge commit (to be fixed)
# P = its first parent (should-be-only parent)
# S = its second parent (the feature branch tip we want to drop)
# Step 1: create a replacement commit that copies M's tree/message but has only parent P
git commit-tree -p P -m "$(git log --format=%B -n 1 M)" ^{tree} > /tmp/mfix
M_FIXED=$(cat /tmp/mfix)
# Step 2: replace M with the corrected version
git replace $M $M_FIXED
# Verify: M now appears as a single-parent commit
git log --format=%P -n 1 $M # should output only P
CI continues to use SHA M; the history now linearizes as expected.
Scenario 2: Grafting a Backport onto the Correct Base¶
You cherry-picked fix fix123 onto release/v1.2 branch, but the merge-base was miscalculated, causing spurious conflicts. Instead of redoing the entire cherry-pick chain, graft fix123 onto the proper base.
# Suppose:
# fix123 = the backported commit (currently based on old-base)
# correct-base = the commit it should have been based on
# Step 1: create a grafted copy with the same tree/message but new parent
git commit-tree -p correct-base -m "$(git log --format=%B -n 1 fix123)" ^{tree} > /tmp/fixgraft
FIX_GRAFTED=$(cat /tmp/fixgraft)
# Step 2: replace fix123 with the grafted version
git replace $fix123 $FIX_GRAFTED
# Now `git show-branch` shows fix123 correctly descended from correct-base
Scenario 3: Removing a Large Binary from History Without Rewriting SHAs¶
You found that a 100 MB video was committed at blob BIG and spread across many commits. You want to replace it with a small pointer file but keep all commit SHAs identical.
# Step 1: create the replacement blob (e.g. a text note)
git hash-object -w -t blob <<<"[REMOVED: large video stored in LTS]" > /tmp/note
NOTE=$(cat /tmp/note)
# Step 2: replace the big blob with the note
git replace $BIG $NOTE
# Verify: any commit that once showed the video now shows the note
git show <some-old-commit>:path/to/video
Scenario 4: Sharing Replace Refs Across CI Runners or Teams¶
By default, replace refs are local. To make them visible to everyone (useful for a team-wide fix):
# Push all replace refs to the remote
git push origin --refs='*:refs/replace/*'
# On another clone, fetch them:
git fetch origin 'refs/replace/*:refs/replace/*'
# Ensure replacement is enabled
git config replace.enabled true
Caution: Only share replace refs when all consumers agree on the replacement; conflicting replacements cause confusion.
Managing Replace Refs¶
Listing Active Replacements¶
# Show all replace refs in this repository
git replace -l
# Verbose: show what each old object is replaced with
git replace -l | while read old new; do
echo "$old -> $new"
done
Editing a Replace Ref¶
Replace refs are ordinary refs; you can update them like any other:
Deleting a Replace Ref¶
Exporting / Importing Replace Refs¶
# Bundle all replace refs for backup or migration
git pack-refs --all --prune
mkdir -p /tmp/replace-backup
for ref in $(git show-ref --refs=refs/replace/*); do
sha=$(echo "$ref" | cut -d' ' -f1)
name=$(echo "$ref" | cut -d' ' -f2)
git show $sha > "/tmp/replace-backup/$(basename $name)"
done
# To import on another machine:
cd /tmp/replace-backup
for file in *; do
sha=$(git hash-object -w -t ref "$file")
git update-ref refs/replace/"$(basename "$file")" "$sha"
done
Disabling Replace Temporarily¶
# For one command (disable)
git -c replace.enabled=false log --graph
# Or shell-wide
export GIT_NO_REPLACE_OBJECTS=1
Advanced: Combining Replace with Grafts for Complex Surgery¶
You can chain replaces to perform multi-step history edits while keeping the final SHAs identical to the originals.
Example: change the parents of a merge commit M and swap a faulty blob B inside it, all without changing M's SHA.
# 1. Replace blob B with B_clean
git replace $B $B_CLEAN
# 2. Create a corrected version of M that uses B_clean (via the replace)
# Note: Git will automatically use B_clean when reading M's tree because of the replace above.
git commit-tree -p <correct-parent1> -p <correct-parent2> -m "$(git log --format=%B -n 1 M)" ^{tree} > /tmp/mcorrect
M_CORRECTED=$(cat /tmp/mcorrect)
# 3. Replace M with the corrected version
git replace $M $M_CORRECTED
# Result: M now has the correct parents and the clean blob, but its SHA remains unchanged.
Safety, Limitations, and Best Practices¶
- Replace refs are not transferred by
git cloneorgit fetchby default — you must explicitly push/fetch them. - They do not work with dumb HTTP/SMART HTTP servers that lack ref advertisement (modern servers support them fine).
- Avoid circular replaces (A replaces B, B replaces A) — Git will detect and break the cycle.
- Replace does not work for objects that are not reachable from any ref (dangling objects) unless you explicitly read them.
- For history sharing, agree on a namespace: e.g., only replace objects under
refs/replace/teamfix/*and document the convention. - Prefer
git replaceover direct.git/info/graftsedits — the former is safer and more visible. - Test replacements in an isolated clone before pushing to shared remotes.
- Document why a replace exists — add a note in
docs/history-fixes.mdor similar so future engineers know the intent.
Reference Commands¶
| Goal | Command |
|---|---|
| Enable replace globally | git config --global replace.enabled true |
| List current replacements | git replace -l |
Replace object <old> with <new> |
git replace <old> <new> |
Stop replacing <old> |
git replace -d <old> |
| Create a grafted commit (change parents) | git commit-tree -p <newparent1> [-p <newparent2> ...] -m "msg" ^{tree} |
| Replace a blob with new contents | git replace <old-blob> $(git hash-object -w -t blob < /path/to/new) |
| Push replace refs to remote | git push origin --refs='*:refs/replace/*' |
| Fetch replace refs from remote | git fetch origin 'refs/replace/*:refs/replace/*' |
| Temporarily disable replace | git -c replace.enabled=false <cmd> |
| Check if replace is enabled | git config --get replace.enabled |
See Also¶
- git-replace(1)
- git-commit-tree(1)
- git-update-ref(1)
- git-push(1)
- git-fetch(1)
git filter-repo— for when you do need to rewrite SHAs (seedocs/devops-workflows/git-filter-repo.md)git reflog— for recovering lost commits (seedocs/troubleshooting/reflog-rescue.md)
Last updated: 2026-08-29