Motivation

We needed to change the commit message for an old commit. The problem was that there had been multiple merges since that commit with merge conflicts that had been resolved. If we tried to change the commit message by using git rebase, it seemed that we would need to re-resolve all the merge conflicts between the commit that we wanted to change and the latest commit on the branch.

It seemed like there should be a way to use the merge resolutions that we had already written. Although normally we wouldn’t want to rewrite history, a special character had been pushed as part of the commit message and this was breaking our Jenkin’s build and deploy process so we couldn’t deploy our application.

Pitfalls and Warnings

Before we go further, let’s acknowledge the dangers of rewriting Git history.

Normally when we rebase, we would only want to rewrite local history that had not yet been pushed to a remote repository. Why? Everyone who has already fetched the remote repository would need to coordinate to rewrite their history or else we could get duplicate commits (the original commits and the new ones) for every commit after the earliest commits that were changed. Instead of running git pull on the affected branches, all users would need to git fetch; git rebase to replace their local branch.

Another danger is not being up to date with the remote repository. Since rewriting remote history requires you to force push, any changes to the branch between your last pull and when you force push would be replaced. To protect against losing code that someone else pushes in this time frame, we should use git push --force-with-lease instead of git push --force in almost all situations.

Finally if other branches already have the commit that we are trying to change, all our work could be undone if those branches are merged in. We could change the commit in all affected branches but this would lead to duplicate commits in history since the commit hashes are different since they are being updated at different times. To handle this, we tried to merge all our branches with the bad commit message into one branch before rewriting history so we would only have to update that branch. For the branches that weren’t ready to be merged, we would later branch off the fixed branch and then cherry pick the new commits onto the new replacement branch.

Solution

Here’s the basic solution followed by the explanation below.

  1. Set up git rerere. git config --global rerere.enabled 1
  2. Download the rerere-train.sh script. (In the next step, we also assume you have moved the script to the repository you are trying to rebase. Otherwise just use the path to the script, {PATH}/rerere-train.sh instead of ./rerere-train.sh`.)
  3. Run sh ./rerere-train.sh [branch_name] to save all previous merge resolutions in cache. (You will probably need to hit q a bunch of times to escape the git messages as they show up.)
  4. Rebase interactively to reword the commit. git rebase --rebase-merges --no-verify -i HEAD~46.
  5. Force push with git push --force-with-lease.

git-rerere to record conflicted merge resolutions

git rerere is to “reuse recorded resolution of conflicted merges”. By setting rerere.enabled, we can enable recording conflicted automerge results and the corresponding resolution. Unfortunately this only starts recording when it is enabled which brings us to our next topic.

Recording previous resolutions with rerere-train.sh

We can record how we resolved previous merges with conflicts using rerere-train.sh. The script lists all the merges that have taken place for the branch to get into it’s current state and then walks through these merges to check if there were conflicts. It also saves all the conflicts that it encounters. To refresh the recorded resolutions you can also run the script with the --overwrite flag.

Rebasing interactively with --rebase-merges

I started off with git rebase -i HEAD~46 doing a roughly binary search with the number passed to HEAD~ until I got to the latest commit that was before the commit I wanted to reword and then running git rebase --abort each time (or deleting every line of the rebase todo so it didn’t start the rebase).

A normal git rebase -i is not what we want because it would flatten all the commits that you are rebasing to a linear history.

 o---o---o---o---A---C---E---o---o---o develop
                  \     /
                   B'--D'

After normal git rebase starting from A, this is what the branch would look like:

 o---o---o---o---A---C---B---D---o---o---o develop

Instead we wanted to preserve the branch structure so at first we tried --preserve-merges. From the man pages: “Recreate merge commits instead of flattening the history by replaying commits a merge commit introduces. Merge conflict resolutions or manual amendments to merge commits are not preserved.” However, our recorded conflicts were not being applied even after training git-rerere since the conflict resolutions were not preserved. A git diff should show whether or not conflicts were resolved using the recorded resolutions since the >>>>>>>, =======, <<<<<< lines should be missing but weren’t.

After aborting that attempt, we tried using the --rebase-merges option. From the man pages - “By default, a rebase will simply drop merge commits from the todo list, and put the rebased commits into a single, linear branch. With –rebase-merges, the rebase will instead try to preserve the branching structure within the commits that are to be rebased, by recreating the merge commits. Any resolved merge conflicts or manual amendments in these merge commits will have to be resolved/re-applied manually”. In this case, the recorded resolutions we trained using the rerere-train.sh script were preserved and we double checked each step with git diff and verified that the recorded resolutions were reused since the >>>>>>>, =======, <<<<<< lines were missing.

We also use --no-verify to avoid running our precommit hooks at every step of the rebase.

Force push

After changing the commit message, we used git push --force-with-lease to remove the breaking commit message from remote history. Then every person who had pulled the repository had to checkout the branch and git fetch and then git rebase to replace their local branch with the new history.

References

This Stack Overflow post asked the same question and introduced me to git rerere and Do you even rerere? gave some useful examples. The git-rebase man pages (man git-rebase) led us to the --rebase-merges option which was the final piece of the puzzle. Here is the equivalent documentation.