Git merges can be better

brandon_bot1 pts0 comments

Git merges can be better<br>If your local development workflow is like mine, 99% of merges are git merge master (or main). Despite being the overwhelming use case, I think this is precisely where git merge UX suffers the most!

Papercuts at scale<br>Imagine working in a large monolithic repo with many changes merging continuously. The frequency of merge conflicts might not necessarily be higher but when they do happen, resolution is more painful than it needs to be.<br>A typical merge conflict might dump hundreds of unrelated files into the index. While it’s simple enough to find and edit the conflicting ones, more involved conflicts often require additional changes elsewhere in your branch. Good luck though when the rest of those changes are lost in an endless sea of staged files.<br>Compare this to rebase.<br>Only the changes from our branch are shown. Resolving conflicts is easier with that focused context and before hitting --continue, it’s possible to re-review the change as a whole.

Conflicts in wetware<br>Here’s a pop quiz: in a conflict hunk, is the master version on the top or bottom?<br>If like me, you can’t answer instinctively despite years of resolving conflicts, it’s likely because the answer is flipped depending on whether you’re using git merge master versus git rebase master. A well-designed tool should make it easy to develop fast, reliable muscle memory.

Merging in reverse<br>Believe it or not, there is a straightforward solution that addresses all the aforementioned issues. Stick to only rebase and accept the force push tax.<br>If instead of git merge master we merge in the reverse direction (i.e. git checkout master && git merge dev), we get the desirable rebase behavior on conflict.<br>Even the conflict hunk orders are consistent between the two now. The downside is we end up in the wrong state.<br>The master pointer moved instead of dev and the merge commit parents are swapped. But that’s fixable in post, and automatable too! We can just alias git merge to internally do this reverse merge + fixup step.<br>For our running example, executing git merge master while on dev would instead:

Record our current position for later (current_SHA=$(git rev-parse HEAD)).

Move dev to where master currently sits (git checkout -B dev master).

Perform the reverse merge (git merge "${current_SHA}").

Once the merge succeeds (either immediately or after conflict resolution), automatically swap the merge commit parents (git checkout -B dev "$(git commit-tree -p HEAD^2 -p HEAD^1 "HEAD^{tree}")").

The final result is indistinguishable from a normal merge while getting the conflict ergonomics of a rebase. No one on your team will know (and of course, no force pushes required). The alias can act as a drop-in replacement and generalize to any merge target.<br>Here’s what that would look like in a ~/.bashrc:<br>git() {<br>local is_merge=false<br>local is_commit=false<br>case "$1" in<br>"merge")<br>is_merge=true<br>;;<br>"commit")<br>is_commit=true<br>;;<br>*)<br># Not git merge/commit.<br>command git "$@"<br>return $?<br>;;<br>esac

local is_merge_abort=false<br>local is_merge_continue=false<br>if $is_merge; then<br>for arg in "$@"; do<br>case "${arg}" in<br>"--quit")<br># git merge --quit -> leave state as is.<br>command git "$@"<br>return $?<br>;;<br>"--abort")<br>is_merge_abort=true<br>break<br>;;<br>"--continue")<br>is_merge_continue=true<br>break<br>;;<br>esac<br>done<br>fi

# Four cases: merge, merge abort, merge continue, commit.<br># For the latter three, double check that we're in the middle of a reverse merge before invoking the custom logic.<br># We maintain a marker file as a flag for whether the current merge was initiated from this alias.<br>local reverse_merge_marker<br>reverse_merge_marker=$(command git rev-parse --git-path reverse_merge_marker) || return $?<br>if $is_commit || $is_merge_abort || $is_merge_continue; then<br># Check the marker file as well as sanity check MERGE_HEAD exists (created by git on merge).<br>if [ ! -f "${reverse_merge_marker}" ] || ! command git rev-parse -q --verify MERGE_HEAD >/dev/null; then<br>command git "$@"<br>return $?<br>fi<br>fi

local current_branch<br>current_branch=$(command git branch --show-current)<br>local is_detached_head=false<br>[ -z "${current_branch}" ] && is_detached_head=true

if $is_merge_abort; then<br># Merge abort case: perform the normal abort and then move back to the original location.<br># Due to reverse merge, this location will match what's in MERGE_HEAD.<br>local original_SHA<br>original_SHA=$(command git rev-parse -q --verify MERGE_HEAD)

command git "$@" || return $?

# Abort succeeded, remove merge marker and move back to original location.<br>rm "${reverse_merge_marker}"<br>if $is_detached_head; then<br>command git checkout "${original_SHA}"<br>else<br>command git checkout -B "${current_branch}" "${original_SHA}"<br>fi<br>return $?<br>fi

local skip_merge_parent_swap=false<br>if $is_merge && ! $is_merge_continue; then<br># git merge case:<br># First, parse the merge target.<br>local target=""<br>local -i i<br>for ((i = 2; i $#; i++)); do<br>local arg="${!i}"<br>case "${arg}" in<br># Flags with values.<br>"--cleanup" | "-s" | "--strategy" | "-X" | "--strategy-option" | "-m" |...

merge local master command case conflict

Related Articles