0% прочитано

Git: Rebuilding Feature/Release History After a Premature Merge Without Changing the Final Code

A real ICanUp Git case: only part of a change was committed to the feature branch, that incomplete feature was merged into release, and the remaining files were committed directly on release. This article covers a safety backup, feature rebuild, clean merge, and git diff --exit-code as proof that the final code stayed identical.

27 вересня 2026 р. 8 хв читанняGit

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.

This workflow was appropriate because the incorrect history had not been pushed yet. Rewriting already shared history is a different problem and requires a different strategy.

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.

BASH - Broken history before the rebuild
base  |  +-- partial feature commit  |        |  |        +-- merge into release  |  +---------------------------+                              |                     direct release commit                     with remaining files Final code: correctFeature history: incompleteRelease history: polluted
BASH - Inspect the feature and release history before changing it
# 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 -5

First 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.

BASH - Create a stable safety reference before rewriting history
git status -sbgit branch --show-current git branch \    backup/before-history-fix \    7a5c062

A 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.

BASH - Bring all changes back into 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.

BASH - Verify the rebuilt feature before touching release
git status -sb git log \    -3 \    --oneline \    --decorate git show \    --stat \    --oneline \    HEAD git diff \    --check \    fa32bf5..HEAD

Release 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.

BASH - Rebuild release from the original base and clean feature
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.

BASH - Prove that the rebuilt release contains exactly the same final code
git diff \    --exit-code \    backup/before-history-fix \    release/news-publishing \    && echo \        "OK: rebuilt release has exactly the same final code"
In this case git diff --exit-code was the critical verification: the goal was to change ancestry while preserving an identical final tree.
BASH - Clean history after the rebuild
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 difference

What 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.

BASH - Complete local history rebuild workflow
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 1fi

When 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

  1. Before committing, verify the full git status, not only the branch name.
  2. Uncommitted changes can move with a checkout to another branch.
  3. A merge can succeed even when the feature branch contains only part of the intended change.
  4. Create an explicit backup reference before rewriting history.
  5. cherry-pick can be used to transport a content diff without preserving the final ancestry.
  6. reset --soft is useful when content is correct but commit boundaries are wrong.
  7. Rebuild release only after the feature branch contains the complete change.
  8. The final git diff --exit-code verifies 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.