Sometimes the problem in Git is not incorrect code. The final code can already be completely correct while the history tells the wrong story about how that change reached the release branch.
That happened in ICanUp. A feature branch already existed, but only one of ten changed files accidentally made it into the feature commit. The incomplete feature was then merged into release.
The remaining nine files stayed in the working tree. When I switched to the release branch, those uncommitted changes moved with the checkout and were later committed directly on release.
The application code was correct and complete. The Git history was not: the feature branch did not contain the full implementation, while release contained a direct change that had never passed through the feature branch.
I cover the already-pushed commit case separately in the separate article about a commit that already landed in the wrong branch. This case is different: a partial feature commit, a premature merge, and uncommitted changes that later became a direct release commit.
What the Broken History Looked Like
Before changing anything, I separated two facts.
First: the final code tree on release already contained every intended change.
Second: the ancestry was wrong. Part of the implementation lived in the feature branch, while the rest appeared only as a direct release commit.
That distinction matters because the goal was not to fix the code. The goal was to rebuild ancestry without changing a single byte of the final result.
base | +-- partial feature commit | | | +-- merge into release | +---------------------------+ | direct release commit with remaining files Final code: correctFeature history: incompleteRelease history: polluted# Base before the featureBASE=fa32bf5 # Feature contains only part of the intended changegit switch feature/content-block-drag-fix git log --oneline --decorate -3 # Later it was merged into release,# while the remaining working-tree changes# were committed directly on release.git switch release/news-publishing git log --oneline --decorate -5First I Preserved the Old Final Tree in a Backup Branch
Before using reset --hard, I did not want reflog to be the only safety mechanism.
The incorrect but functionally complete release tip therefore received its own backup branch.
That branch served two purposes: it gave me an immediate recovery point and a stable reference for the final verification.
git status -sbgit branch --show-current git branch \ backup/before-history-fix \ 7a5c062A backup branch is useful here for more than rollback. It becomes the control sample for the final code tree.
The Feature Branch Had to Be Rebuilt First
Release already had a commit containing the nine files missing from the feature branch. I used that commit not as the desired history, but as a container for the missing content diff.
I switched back to the feature branch and cherry-picked the release-only commit. At that point the feature branch contained the complete final code state.
The ancestry still consisted of two commits: the original partial commit and the cherry-picked continuation. For this feature, I wanted one complete feature commit.
git switch \ feature/content-block-drag-fix # Bring the missing release-only changes# back onto the feature branch.git cherry-pick 7a5c062 # Rebuild the feature as one clean commit# starting from the real base.git reset --soft fa32bf5 git commit \ -m "Prevent ContentBlock drag text corruption"Why I Used reset --soft Instead of reset --hard
At this stage the feature content was already correct. Only the commit structure needed to change.
git reset --soft BASE moves the branch pointer back to the base while keeping the accumulated diff staged.
After the soft reset I could create one new commit containing the complete feature.
reset --hard would have done the opposite here: it would have removed the working-tree changes that I specifically needed to preserve.
Verify the Feature Before Rebuilding Release
After rewriting the feature branch, I did not immediately rebuild release.
I first checked the clean working tree, the new log, the commit stat, and git diff --check from the base to the feature tip.
This does not prove full application-level functional equivalence, but it catches accidental whitespace errors and confirms that the feature now has a coherent shape.
git status -sb git log \ -3 \ --oneline \ --decorate git show \ --stat \ --oneline \ HEAD git diff \ --check \ fa32bf5..HEADRelease Could Then Be Rebuilt From the Clean Base
Once the feature contained the complete implementation, the two incorrect release-side commits were no longer needed.
I moved release back to the commit before the feature and performed a normal --no-ff merge of the complete feature branch.
The history once again reflected the intended workflow: the change lives in feature and reaches release through a merge.
git switch \ release/news-publishing git reset \ --hard \ fa32bf5 git merge \ --no-ff \ feature/content-block-drag-fix \ -m "Merge ContentBlock drag text corruption fix"The Most Important Check Is the Tree, Not the Log
At this point git log already looked correct. But a clean-looking log does not prove that no file was lost or changed during the rewrite.
This is where the backup branch became especially useful.
I compared the old final release tree with the rebuilt release using git diff --exit-code.
If the command exits with code 0, there is no diff between those two trees.
git diff \ --exit-code \ backup/before-history-fix \ release/news-publishing \ && echo \ "OK: rebuilt release has exactly the same final code"base | +-- complete feature commit | +-- clean merge into release backup/before-history-fix | +-- old final tree release/news-publishing | +-- rebuilt final tree git diff --exit-code:no differenceWhat git diff --exit-code Actually Proves
It is important not to overstate what this proves.
git diff --exit-code old new proves that the Git trees contain the same tracked content.
It does not prove that commits have the same hashes, timestamps, or parents. Those are exactly the things we intentionally changed.
It also does not replace application tests when the rewrite includes new code changes. But when the task is specifically to rewrite history without changing content, it is a strong invariant.
The Complete Workflow With Guards
In real work I would not run destructive history commands without first verifying a clean working tree.
The complete version therefore starts with set -euo pipefail, checks the working tree, creates the backup, rebuilds the feature, verifies it, rebuilds release, and finishes with an exact tree comparison.
set -euo pipefail BASE=fa32bf5BROKEN_RELEASE=7a5c062 FEATURE=feature/content-block-drag-fixRELEASE=release/news-publishingBACKUP=backup/before-history-fix echo "===== PRECHECK ====="git status -sb if [ -n "$(git status --porcelain)" ]; then echo "ERROR: working tree is not clean" exit 1fiWhen This Workflow Should Not Be Applied Directly
The most important condition in this case is that the incorrect release history had not been pushed yet.
If other developers already received those commits or the release branch is shared remotely, a local reset --hard followed by a force push creates a different problem involving other clones, open branches, merge requests, and deployment history.
I would not apply this workflow mechanically in that situation. First decide whether rewriting shared history is justified at all or whether the safer solution is to repair the ancestry with new commits.
What This Case Taught Me
- Before committing, verify the full
git status, not only the branch name. - Uncommitted changes can move with a checkout to another branch.
- A merge can succeed even when the feature branch contains only part of the intended change.
- Create an explicit backup reference before rewriting history.
cherry-pickcan be used to transport a content diff without preserving the final ancestry.reset --softis useful when content is correct but commit boundaries are wrong.- Rebuild release only after the feature branch contains the complete change.
- The final
git diff --exit-codeverifies the primary invariant: the tree did not change.
Conclusion
This Git incident was unusual because the application code was already correct. Only the history needed repair.
I preserved the old release tip in a backup branch, moved the release-only changes back into the feature, rebuilt the feature as one complete commit, and created a clean merge again.
Most importantly, I did not stop when git log looked clean. The old and rebuilt release trees were compared directly.
A good history rewrite should not merely look correct. It should have a verifiable invariant proving that the rewrite did not change anything we intended to preserve.



