Git revert, merge rollbacks, and recovery in CI/CD¶
Production incidents require fast and predictable rollback procedures. Resetting branches with git reset --hard rewrites history and breaks shared remote branches. In contrast, git revert creates forward-moving commits that undo changes without modifying existing history.
This guide explains how to roll back single commits, commit ranges, and merge commits, along with recovery patterns for re-introducing reverted code.
1. Rollback strategies compared¶
Choosing the right rollback method depends on whether the code is local or already pushed to a shared remote.
| Method | History rewritten | Remote safe | Best use case |
|---|---|---|---|
git reset --hard <sha> |
Yes | No | Unpushed local experiments |
git revert <sha> |
No | Yes | Production rollbacks on main or release branches |
git revert -m 1 <merge-sha> |
No | Yes | Backing out an entire merged pull request |
git cherry-pick <sha> |
No | Yes | Selective hotfix backports across release branches |
2. Reverting individual commits and ranges¶
2.1 Single commit rollback¶
To undo a single commit on a shared branch, supply its commit hash:
Git applies an inverse patch and opens the editor to commit the result. To skip the editor and accept the default message:
2.2 Reverting a range of commits¶
When a release contains multiple bad commits, revert the range in reverse chronological order:
Git applies each inverse patch one by one, creating individual revert commits.
2.3 Creating a single atomic rollback commit¶
To bundle the reversion of several commits into one clean commit, use the --no-commit (-n) flag:
# Stage the inverse changes of the range without committing
git revert --no-commit commit-A^..commit-B
# Create one consolidated rollback commit
git commit -m "fix(rollback): revert release v2.4.0 changes"
This approach simplifies pull request reviews and keeps deployment logs uncluttered.
3. Merge commit revert mechanics¶
Standard commits have one parent. Merge commits have two or more parents. Git needs to know which parent branch to keep as the baseline when generating the inverse diff.
If you run git revert <merge-sha> without arguments, Git fails with:
3.1 Inspecting merge commit parents¶
Find the parent order before running the revert:
# Show the merge commit with its parent hashes
git log -1 --pretty=format:"Commit: %h | Parents: %p%n" <merge-sha>
The first hash after Parents: is parent 1 (mainline). The second hash is parent 2 (the merged branch).
You can also view the commit details:
3.2 Executing the merge revert¶
To revert the merge and preserve the mainline state:
Git compares the merge commit M against parent 1 (A2) and creates a new commit on main that reverses all changes introduced by B1 and B2.
4. The merge revert dilemma and recovery¶
Reverting a merge commit introduces a specific problem when you want to bring the feature branch back later.
4.1 Why direct re-merging fails¶
When you merge branch feat into main, Git records that all commits on feat are now part of main history.
When you revert the merge commit on main, you undo the file changes, but the commit ancestry remains intact.
If you later fix bugs on feat and try to merge feat into main again:
Git inspects the commit graph, finds that the earlier commits on feat already exist in the history of main, and skips their changes. The new merge will only apply commits added to feat after the original merge. All original feature code remains missing.
4.2 Pattern A: Revert the revert commit¶
The standard solution is to revert the revert commit on main before re-merging the updated feature branch.
# 1. Identify the hash of the revert commit (R)
git log --grep="Revert" -n 5 --oneline
# 2. Revert the revert commit to restore the original feature changes on main
git checkout main
git revert <revert-commit-sha>
# 3. Merge the updated feature branch
git merge feat
This restores the original feature diff and merges any new commits from feat.
4.3 Pattern B: Rebase or cherry-pick feature commits onto a new base¶
If team policy forbids re-reverting commits on main, rewrite the feature branch commits with new hashes:
# 1. Create a fresh branch from the updated main
git checkout -b feat-reloaded origin/main
# 2. Cherry-pick or rebase the original feature commits onto the new base
git cherry-pick <first-feat-sha>^..<last-feat-sha>
# 3. Add any bug fixes and open a new pull request
Because the commit hashes differ from the original branch, Git treats them as new changes and merges them without dropping code.
5. Automated CI/CD rollback pipelines¶
Automated deployment systems can trigger rollback pipelines when health checks fail.
5.1 Non-interactive automated rollback script¶
Here is an example pipeline step that reverts the latest merge commit, runs test suites, and pushes the rollback:
#!/usr/bin/env bash
set -euo pipefail
TARGET_BRANCH="main"
git checkout "$TARGET_BRANCH"
git pull origin "$TARGET_BRANCH"
# Get the latest commit hash
LATEST_COMMIT=$(git rev-parse HEAD)
PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | awk '{print NF-1}')
if [ "$PARENT_COUNT" -gt 1 ]; then
echo "Latest commit $LATEST_COMMIT is a merge commit. Reverting with -m 1..."
git revert -m 1 --no-edit "$LATEST_COMMIT"
else
echo "Latest commit $LATEST_COMMIT is a standard commit. Reverting..."
git revert --no-edit "$LATEST_COMMIT"
fi
# Run test suite to confirm stability
npm test || pytest
# Push the rollback commit
git push origin "$TARGET_BRANCH"
echo "Rollback successfully deployed."
5.2 Conflict handling during rollbacks¶
If intervening commits modified the same lines, git revert stops with conflict markers.
In automated pipelines, handle conflicts explicitly:
# Attempt revert with a merge strategy preference if desired
git revert -X ours <commit-sha> || {
echo "Conflict detected during revert. Aborting automated rollback."
git revert --abort
exit 1
}
6. Submodule rollbacks¶
When reverting a commit that changed a git submodule pointer, verify the submodule working directory updates accordingly:
# Revert the commit containing the submodule update
git revert --no-edit <commit-sha>
# Update the submodule to match the newly checked out pointer
git submodule update --init --recursive
Without running git submodule update, the submodule working tree remains on the newer commit, showing uncommitted working tree differences in git status.