Measuring branch age against conflict risk Jump to heading
“Keep branches short-lived” is standard advice, but it lands as an opinion until someone shows the numbers for their own repository. How much more likely is a conflict for a branch open a week versus a day? How far behind main can a branch drift before merging becomes painful? Git’s history contains the answer: every merge records how long the branch existed and how much main moved in the meantime, and re-running merges shows which ones conflicted. This page measures that relationship for your repository, turns it into a simple limit, and puts the live numbers where people see them, within conflict prevention by design.
When to use this approach Jump to heading
- The team debates how long branches should live, without data.
- Merges are frequently painful, and you suspect long-lived branches are the cause.
- You want an evidence-based limit for branch age or drift, for limiting feature branch lifetime.
- History uses merge commits, or pull-request metadata records branch open and merge times.
Step 1 — Collect age and drift for past merges Jump to heading
For each merge commit on main, compute two numbers: the branch’s age (time from its first commit to the merge) and its drift (commits main gained between the branch’s base and the merge).
#!/bin/sh
# merge-stats.sh — age (hours), drift (commits) per merge on main
git rev-list --merges --first-parent --since=6.months main | while read -r m; do
base=$(git merge-base "$m^1" "$m^2")
first=$(git rev-list --reverse "$base..$m^2" | head -1)
[ -n "$first" ] || continue
age=$(( ($(git log -1 --format=%ct "$m") - $(git log -1 --format=%at "$first")) / 3600 ))
drift=$(git rev-list --count "$base..$m^1")
echo "$m $age $drift"
done > merge-stats.txt
wc -l merge-stats.txt For squash-merged repositories, use pull-request data instead: creation time, merge time, and the base commit the forge recorded.
Step 2 — Re-run each merge to see whether it conflicted Jump to heading
The merge commit does not record whether a conflict happened. Re-running the merge with git merge-tree does, without touching any branch or working tree.
while read -r m age drift; do
if git merge-tree --write-tree "$m^1" "$m^2" >/dev/null 2>&1; then c=0; else c=1; fi
echo "$m $age $drift $c"
done < merge-stats.txt > merge-conflicts.txt
awk '{n++; c+=$4} END {printf "conflict rate: %.0f%% of %d merges\n", 100*c/n, n}' merge-conflicts.txt git merge-tree --write-tree exits non-zero when the merge has conflicts; it needs Git 2.38 or newer. The technique is covered in depth in previewing merges with git merge-tree.
Step 3 — Bucket and compare Jump to heading
Group merges by age and by drift and compute the conflict rate in each bucket. The shape matters more than the exact numbers: most repositories show a sharp rise after a few days or a few dozen commits of drift.
awk '{ b = ($2<24)?"<1d":($2<72)?"1-3d":($2<168)?"3-7d":">7d"; n[b]++; c[b]+=$4 }
END { for (k in n) printf "%-5s %4d merges %3.0f%% conflicted\n", k, n[k], 100*c[k]/n[k] }' \
merge-conflicts.txt | sort # The same by drift: commits main gained while the branch was open
awk '{ b = ($3<20)?"<20":($3<60)?"20-59":($3<150)?"60-149":"150+"; n[b]++; c[b]+=$4 }
END { for (k in n) printf "%-7s %4d merges %3.0f%% conflicted\n", k, n[k], 100*c[k]/n[k] }' \
merge-conflicts.txt | sort -n Drift is usually the better predictor, because a branch can be old in a quiet week or young in a busy one. Use whichever shows the clearer step.
Step 4 — Turn the knee into a visible limit Jump to heading
Pick the point where the rate climbs — say, drift above 60 commits or age above 3 days — and show it on open pull requests. A warning is enough; a hard block tends to produce worse behaviour, like rushed merges.
# Warn on pull requests whose branch has drifted past the limit
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: |
base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
drift=$(git rev-list --count "$base..origin/${{ github.base_ref }}")
echo "Branch is $drift commits behind ${{ github.base_ref }}"
[ "$drift" -le 60 ] || echo "::warning::$drift commits behind — merges past 60 conflict often here. Update the branch." Step 5 — Publish the trend Jump to heading
Re-run the measurement monthly and publish one chart: conflict rate by bucket, and the median drift at merge. If the median drifts upward, review practice is slowing down or branches are getting larger, and the conversation can start from numbers.
awk '{print $3}' merge-conflicts.txt | sort -n | awk '{a[NR]=$1} END {print "median drift at merge:", a[int(NR/2)+1]}' The related measure — how long pull requests wait for review — often explains why branches age, and is covered in measuring review latency from Git history.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just block merges of old branches? Jump to heading
A block encourages people to merge quickly to beat the clock, or to recreate branches to reset their age. A visible warning plus a clear number tends to change behaviour without those side effects.
Does rebasing reset a branch’s age? Jump to heading
It resets the drift, which is the part that drives conflicts, but not the age of the work. That is why drift is usually the better metric to warn on.
What if our history is squash-merged? Jump to heading
Use the forge’s pull request data: created-at, merged-at, and the base commit. Re-run each merge as the merge of the base branch at merge time with the pull request’s head, which the forge also records.
Related Jump to heading
- Conflict Prevention by Design — the parent topic.
- Detecting Conflict-Prone Files from History — the per-file view of the same data.
- Keeping Branches Short-Lived in Trunk-Based Development — practices that keep drift low.
- Measuring Trunk-Based Adoption from Git History — related metrics at team level.