Reducing Git Merge Conflicts in Large Teams

Reducing Git Merge Conflicts in Large Teams

Reading time1 min
#devops#git#merge conflicts#engineering#productivity

Reducing Git Merge Conflicts in Large Teams

Merge conflicts grow with two things: how long branches live and how many people edit the same files. Better tools make conflicts easier to resolve. Changes to process and repository layout make them rare. You want both.

Why conflicts get worse as teams grow

  • Long-lived branches. The longer a branch lives, the further it drifts from main, and the bigger the eventual conflict.
  • Hotspot files. Route tables, dependency injection setup, a shared config file, CHANGELOG.md, lock files, generated code. Every feature touches them.
  • Mass changes. A repository-wide reformat or rename landing while dozens of branches are open.

Find your hotspots

Git history shows which files change most often:

git log --since="90 days ago" --name-only --format= \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -20

Files near the top that many different people touch are where conflicts come from. Your Git host can also show how long pull requests stay open. Both numbers are worth tracking over time.

Keep branches short

Merge to main at least daily and hide unfinished work behind feature flags. Rebase or merge main into your branch often, so you get small conflicts while the context is fresh instead of one large conflict at the end. Smaller pull requests also get reviewed faster, which shortens branch life further.

Fix hotspots in the repository layout

  • Changelog. Replace a single CHANGELOG.md with one fragment file per change, assembled at release time. Tools like towncrier and changesets work this way.
  • Registration lists. Split a central list (routes, plugins, DI bindings) into per-module files, or keep it sorted so additions land in different places instead of all at the end.
  • Lock files. Do not resolve them by hand. Take main's version and let the package manager regenerate it:
git checkout origin/main -- package-lock.json
npm install
git add package-lock.json
  • Append-only text files. For files where line order does not matter, merge=union in .gitattributes keeps both sides. Never use it for code, JSON or lock files: it can produce duplicate or invalid content.
# .gitattributes
CONTRIBUTORS.txt merge=union
  • Generated code. Do not commit it if the build can generate it. If you must commit it, regenerate it on conflict instead of merging.
  • Formatting. Run the formatter in a pre-commit hook and in CI, so diffs never contain style changes. Do any mass reformat in a single commit at a quiet moment, and add its hash to .git-blame-ignore-revs.

Git settings that make resolution easier

git config --global merge.conflictStyle zdiff3
git config --global rerere.enabled true
git config --global rerere.autoUpdate true

zdiff3 (Git 2.35+) adds the common ancestor to conflict markers and trims identical lines from the edges of the conflict. Seeing the base version makes it clear what each side actually changed. rerere records how you resolved a conflict and replays it when the same conflict appears again, which saves a lot of work during repeated rebases. Git 2.34 and later use the ort merge strategy by default, which handles renames better than the old recursive strategy, so keep Git up to date on developer machines and CI images.

Detect conflicts before anyone merges

git merge-tree --write-tree (Git 2.38+) performs a full merge without touching the working tree or index. It exits with 1 on conflicts. Your Git host already warns about conflicts with main. What it does not show is that two open pull requests conflict with each other. This script checks every pair of branches on the remote:

#!/usr/bin/env bash
set -euo pipefail
git fetch -q --prune origin
branches=$(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/ \
  | grep -vx -e HEAD -e main || true)
for a in $branches; do
  for b in $branches; do
    [[ "$a" < "$b" ]] || continue
    if ! git merge-tree --write-tree --name-only --no-messages \
         "origin/$a" "origin/$b" > /tmp/merge-result; then
      echo "$a <-> $b:"
      tail -n +2 /tmp/merge-result
    fi
  done
done

The first line of the output is the resulting tree ID, so tail skips it and prints only the conflicting files. Run it on a schedule in CI with full history (fetch-depth: 0 on GitHub Actions) and post the result where the team will see it. The number of pairs grows quadratically, which is fine for dozens of branches. For hundreds, check each new push only against the other open branches.

Merge queues for semantic conflicts

Two pull requests can merge cleanly as text and still break the build together, for example when one renames a function the other starts calling. A merge queue (GitHub) or merge train (GitLab) tests each pull request on top of main plus everything ahead of it in the queue before merging. On GitHub, workflows that should run in the queue need the merge_group trigger:

on:
  pull_request:
  merge_group:

Make ownership visible

A CODEOWNERS file routes reviews of hotspot files to the people who know them. They also see parallel changes to the same area early and can coordinate before both branches are finished.

Checklist

  • Branches merge to main at least daily, unfinished work hides behind flags.
  • Hotspot files identified from history and restructured.
  • Changelog fragments instead of one shared file.
  • Lock files and generated files regenerated, never hand-merged.
  • Formatter enforced, mass reformats isolated and listed in .git-blame-ignore-revs.
  • zdiff3 and rerere enabled, Git kept current.
  • Cross-branch conflict check running in CI.
  • Merge queue or merge train in front of main.