How to make reviewing multiple PRs easier in squash-merge-style git repository

Background settings

You work in a Git repository, and as per your team's policy, you must always squash-merge your PRs. This is nice as it helps keep the main branch clean and moves forward in big commit nodes, making it easier for everyone to understand the changes at a glance. However, there may be times when you need to stack up your PRs because your senior reviewer doesn't have the time to review the PR right after you publish it or because you simply code too fast.

Review

It would be nice if all PRs were mutually exclusive with each other so that the reviewer could review them in any order, and you could merge them in any order without impacting other PRs. However, more often than not, your 2nd PR depends on your 1st PR, and your 3rd depends on your 2nd, etc. Your PR sequence looks more like a linear line than a radial shape.

Screenshot 2023-03-13 135628.png

Typically, the main branch (or dev, trunk, or whatever-you-name-it) is used as the PR target branch. However, in the case of a "linear line" style PR sequence, using the main branch as the PR target branch would cause confusion. The 2nd PR would contain every change from the 1st PR. Things get worse if you have even more PRs in the stack. I once stacked 5 PRs containing more than a dev-month of workload, 50-ish commits in total. It was barely reviewable if 50 commits are dumped altogether in the last PR. To make the reviewing process easier in a linear line style PR sequence, the PR target branch should be the previous PR branch instead of the main branch. It ensures that the changes in the current PR are built on top of the changes in the previous PR, which can streamline the review process.

Screenshot 2023-03-13 140932.png

Merge

Things become trickier when you try to squash-merge a bunch of stacking PRs. When you merge the first PR (let's call this PR-1 from now on), it becomes a new squashed commit with all the changes from PR-1. Then, after you switch the target branch of the PR-2 from branch-1 to the main branch, you suddenly encounter lots of conflicts.

Screenshot 2023-03-13 131508.png

Because in the commit history, the main branch has one big commit, for example feat: add feature 1, changing these four files; while in the branch-2, there are several tiny, different commits changing these four files. These tiny commits come from branch-1, which were already squashed and merged as a big commit with different sha.

To address this issue, you need to rebase branch-2 onto the main branch. A useful trick is to use the "-Xours" tag as follows:

# on branch-2
git rebase main -Xours --empty=drop

This can change the history log of branch-2 from this:

| - tiny bit 3 for feature 2 --- [branch-2]
| - tiny bit 2 for feature 2
| - tiny bit for feature 2
| - tiny bit 2 for feature 1 --- [branch-1]
| - tiny bit for feature 1
| - feat: some historical feature

to this:

| - tiny bit 3 for feature 2 --- [branch-2]
| - tiny bit 2 for feature 2
| - tiny bit for feature 2
| - feat: add feature 1 --- [main, origin/main]
| - feat: some historical feature

This command allows the history log of branch-2 to change, avoiding conflicts. Specifically, the "tiny bit for feature 1s" conflict with "feat: add feature 1." The -Xours tag tells Git always to pick changes from the main branch in case of conflicts. After Git picks the changes from the main branch, the tiny bits will contain empty content. The --empty=drop flag instructs Git to drop these commits, as they are essentially the commits that compose the big "feat: add feature 1" commit.

Conclusion

  1. For linear-style PRs, instead of pointing the target branch to the main branch for all PRs, you should point the target branch to the branch of the preceding PR.
  2. After you merge a PR to the main branch, rebase the following PRs to the main branch with git rebase main -Xours --empty=drop. This command ensures that Git always picks the changes from the main branch using the -Xours tag and drops the empty commits from the previous PR using the --empty=drop tag.
© 2023, Up-to-date