Git merges can be better
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
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.

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.
Compare this to rebase.

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
Here’s a pop quiz: in a conflict hunk, is the master version on the top or bottom?

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

Even the conflict hunk orders are consistent between the two now. The downside is we end up in the wrong state.
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.
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
devto wheremastercurrently 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.
Here’s what that would look like in a ~/.bashrc:
git() {
local is_merge=false
local is_commit=false
case "$1" in
"merge")
is_merge=true
;;
"commit")
is_commit=true
;;
*)
# Not git merge/commit.
command git "$@"
return $?
;;
esac
local is_merge_abort=false
local is_merge_continue=false
if $is_merge; then
for arg in "$@"; do
case "${arg}" in
"--quit")
# git merge --quit -> leave state as is.
command git "$@"
return $?
;;
"--abort")
is_merge_abort=true
break
;;
"--continue")
is_merge_continue=true
break
;;
esac
done
fi
# Four cases: merge, merge abort, merge continue, commit.
# For the latter three, double check that we're in the middle of a reverse merge before invoking the custom logic.
# We maintain a marker file as a flag for whether the current merge was initiated from this alias.
local reverse_merge_marker
reverse_merge_marker=$(command git rev-parse --git-path reverse_merge_marker) || return $?
if $is_commit || $is_merge_abort || $is_merge_continue; then
# Check the marker file as well as sanity check MERGE_HEAD exists (created by git on merge).
if [ ! -f "${reverse_merge_marker}" ] || ! command git rev-parse -q --verify MERGE_HEAD >/dev/null; then
command git "$@"
return $?
fi
fi
local current_branch
current_branch=$(command git branch --show-current)
local is_detached_head=false
[ -z "${current_branch}" ] && is_detached_head=true
if $is_merge_abort; then
# Merge abort case: perform the normal abort and then move back to the original location.
# Due to reverse merge, this location will match what's in MERGE_HEAD.
local original_SHA
original_SHA=$(command git rev-parse -q --verify MERGE_HEAD)
command git "$@" || return $?
# Abort succeeded, remove merge marker and move back to original location.
rm "${reverse_merge_marker}"
if $is_detached_head; then
command git checkout "${original_SHA}"
else
command git checkout -B "${current_branch}" "${original_SHA}"
fi
return $?
fi
local skip_merge_parent_swap=false
if $is_merge && ! $is_merge_continue; then
# git merge case:
# First, parse the merge target.
local target=""
local -i i
for ((i = 2; i <= $#; i++)); do
local arg="${!i}"
case "${arg}" in
# Flags with values.
"--cleanup" | "-s" | "--strategy" | "-X" | "--strategy-option" | "-m" | "--message" | "-F" | "--file" | "--into-name")
i+=1
;;
# Standalone flags.
-*)
;;
*)
if [ -n "${target}" ]; then
# Multiple targets (octopus), fall back to default merge.
command git "$@"
return $?
fi
target="${arg}"
;;
esac
done
if [ -z "${target}" ]; then
command git "$@"
return $?
fi
# Try to move to the merge target.
local current_SHA
current_SHA=$(command git rev-parse HEAD)
if $is_detached_head; then
command git checkout --detach "${target}" || return $?
else
command git checkout -B "${current_branch}" "${target}" --no-track || return $?
fi
# Move succeeded, mark that the merge has started.
touch "${reverse_merge_marker}"
# Attempt reverse merge.
local target_SHA
target_SHA=$(command git rev-parse HEAD)
# Generate a matching commit message to the normal direction.
local merge_into_name
if $is_detached_head; then
merge_into_name="HEAD"
else
merge_into_name="${current_branch}"
fi
local target_type
if [ "${target}" = "${target_SHA}" ]; then
target_type="commit"
else
target_type="branch"
fi
local commit_message="Merge ${target_type} '${target}' into ${merge_into_name}"
command git merge "${current_SHA}" -m "${commit_message}" || return $?
# Merge completed successfully.
# As there were no conflicts, check if it was a fast-forward merge in which case no merge parent swap is needed.
if [ "${target_SHA}" != "$(command git rev-parse -q --verify HEAD^1)" ] ||
[ "${current_SHA}" != "$(command git rev-parse -q --verify HEAD^2)" ]; then
# Merge parents don't match expected merge from/into -> must be fast-forward merge.
skip_merge_parent_swap=true
fi
else
# git commit/merge --continue case: perform command as-is.
command git "$@" || return $?
# Sanity check we're no longer in the merging state (e.g. git commit --dry-run).
if command git rev-parse -q --verify MERGE_HEAD >/dev/null; then
return 0
fi
fi
# Merge completed successfully.
rm "${reverse_merge_marker}"
if ! $skip_merge_parent_swap; then
local existing_commit_message
existing_commit_message=$(command git log -1 --format=%B)
local swap_SHA
swap_SHA=$(command git commit-tree -p HEAD^2 -p HEAD^1 -m "${existing_commit_message}" "HEAD^{tree}")
if $is_detached_head; then
command git checkout "${swap_SHA}" || return $?
else
command git checkout -B "${current_branch}" "${swap_SHA}" || return $?
fi
fi
}
I’ve been using something similar for the past few months and it’s surprising how impactful a tiny UX change like this can feel. No, I’m not more productive. But it’s just nice having tools that are a bit more intuitive and enjoyable to use.