Limiting feature branch lifetime Jump to heading
Every day a feature branch stays open, main moves further away from it. Conflicts grow, reviews get larger and harder, and the risk at merge rises. Most teams agree branches should be short-lived, and most teams have branches open for a month anyway, because nothing in the workflow pushes against it. Setting a limit and making it visible changes that — as long as it is paired with the means to meet it: smaller slices of work, feature flags for unfinished functionality, and reviews fast enough that a branch is not waiting on people. This page puts those together, within feature branch isolation.
When to use this approach Jump to heading
- Many branches stay open for weeks, and merges are painful when they finally happen.
- You have data showing conflict risk rising with branch age, as in measuring branch age against conflict risk.
- The team wants to move towards trunk-based practices without a big-bang change.
- Long-lived branches are an accident of habit rather than a deliberate choice.
Step 1 — Measure current branch lifetimes Jump to heading
Start from data: how long do branches live from first commit to merge today? Pull request timestamps give a direct answer.
gh pr list --state merged --limit 300 --json createdAt,mergedAt \
--jq '[.[] | ((.mergedAt|fromdateiso8601) - (.createdAt|fromdateiso8601)) / 86400] | sort |
{median: .[length/2|floor], p90: .[(length*0.9)|floor], max: .[-1]} | map_values(. * 10 | floor / 10)' Step 2 — Set a limit people can see Jump to heading
Choose a limit from the data — often the point where the conflict rate starts climbing, typically three to five working days — and make each branch’s age visible where its owner looks: on the pull request.
# Label pull requests by age, daily
on: { schedule: [{ cron: "23 7 * * 1-5" }] }
jobs:
age-labels:
runs-on: ubuntu-latest
steps:
- run: |
gh pr list --state open --json number,createdAt --jq '.[] | "\(.number) \(.createdAt)"' |
while read -r n created; do
days=$(( ( $(date +%s) - $(date -d "$created" +%s) ) / 86400 ))
if [ "$days" -ge 10 ]; then gh pr edit "$n" --add-label "age:overdue" --remove-label "age:due-soon"
elif [ "$days" -ge 5 ]; then gh pr edit "$n" --add-label "age:due-soon"; fi
done
env: { GH_TOKEN: "${{ github.token }}", GH_REPO: "${{ github.repository }}" } A label is enough. Hard blocks — refusing to merge old branches — push people to close and re-open pull requests to reset the clock, which hides the problem.
Step 3 — Make smaller slices possible Jump to heading
Branches live long because the work is large. The fix is slicing: delivering the feature in pieces that each merge on their own. Three techniques make that possible.
# Unfinished feature merged behind a flag — safe to ship, invisible to users
if flags.enabled("export-scheduling", account=account):
show_schedule_controls() The techniques are covered in feature flags vs feature branches for unfinished work, branch by abstraction for large changes and stacked pull requests without a dedicated tool.
Step 4 — Remove the waits that age branches Jump to heading
A branch often ages not while being worked on but while waiting: for review, for CI, for a decision. Find where the time goes before blaming authors.
# Time from opening to first review, for recent pull requests
gh pr list --state merged --limit 100 --json number,createdAt,reviews \
--jq '.[] | select(.reviews|length>0) |
(((.reviews[0].submittedAt|fromdateiso8601) - (.createdAt|fromdateiso8601))/3600|floor) as $h | $h' |
sort -n | awk '{a[NR]=$1} END {print "median hours to first review:", a[int(NR/2)+1]}' If review latency dominates, the fix is in review practice, covered in setting review turnaround targets and escalation.
Step 5 — Handle legitimate exceptions explicitly Jump to heading
Some branches should live longer: a framework upgrade developed alongside main, a spike that may be thrown away. Label them as such, keep them in sync with main deliberately, and exclude them from age warnings.
gh pr edit 812 --add-label long-lived --body "$(gh pr view 812 --json body --jq .body)
Long-lived by agreement: framework upgrade, synced with main weekly (owner: priya)." Step 6 — Review the trend, not individual branches Jump to heading
The point of a limit is a shift in how the team works, not a list of offenders. Review the distribution monthly — median and tail of pull request lifetime — and discuss what is driving the tail: large features that were not sliced, slow reviews, unclear requirements. Individual overdue labels are prompts for the owner; the monthly trend is the team’s measure of progress.
# Monthly: share of merged pull requests open longer than the limit
gh pr list --state merged --search "merged:>=$(date -d '30 days ago' +%F)" --limit 500 --json createdAt,mergedAt \
--jq '[.[] | ((.mergedAt|fromdateiso8601) - (.createdAt|fromdateiso8601)) / 86400] |
(map(select(. > 5)) | length) as $over | "\($over) of \(length) over 5 days"' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
What limit should we choose? Jump to heading
Use your own data. Many teams find the conflict rate rises sharply after three to five working days; a limit there catches the long tail without pressuring normal work.
Won’t feature flags clutter the code? Jump to heading
They do if they are never removed. Treat each flag as temporary, with an owner and a removal date, as described in retiring feature flags after launch.
Does this apply to bug fixes too? Jump to heading
Bug fixes are usually small and naturally short-lived. The limit mostly affects features; if bug-fix branches age, the cause is almost always review latency.
Related Jump to heading
- Feature Branch Isolation — the parent topic.
- Keeping Branches Short-Lived in Trunk-Based Development — the same goal in a trunk-based model.
- Iterating Refs with for-each-ref — branch-age reports from Git data.
- Enforcing Pull Request Size Limits — small pull requests age less.