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)'
How long pull requests stay openAn illustrative quarter of merged pull requests. Most merge within two days, but a long tail stays open for weeks. The tail is where conflicts and painful reviews concentrate, so it is the part a lifetime limit targets.merged PRs by days open (illustrative)< 1 day961–2 days713–7 days381–3 weeks22> 3 weeks9the goal is to shrink the right-hand bars, not to rush the left-hand ones

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.

Techniques for smaller slicesFeature flags let unfinished functionality merge while staying hidden. Branch by abstraction lets a large replacement land behind an interface step by step. Stacked pull requests let dependent pieces be reviewed separately while still being built in order.Feature flagsmerge unfinishedhidden at runtimeBranch by abstractionreplace behindan interfaceStacked PRsdependent piecesreviewed in ordereach technique turns one long branch into several short ones
# 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)."
A large feature delivered as short branchesInstead of one branch open for five weeks, the feature ships as a sequence of short branches: the flag and data model first, then the API behind the flag, then the UI, then enabling the flag for a pilot and finally for everyone. Each branch lives one to three days.Flag + model2 days openweek 1API behind flag3 days openweek 2UI behind flag2 days openweek 3Pilot accountsflag rolloutweek 4Everyoneflag removed laterweek 5same feature, same five weeks — but no branch ever drifts far from main

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.