● Spec: branch-provenance flow model
- Scope
Given a repo and a target branch (qa), produce:
- A — transition matrix over branch classes: P(source_class | merged_into=qa)
- B — per-change promotion paths with per-hop lag
- C — absorption stats: given a change reaches qa, does it reach main, stall, or get reverted
A is a rollup of B. Build B first; A falls out of it.
- Inputs
┌────────────────┬─────────────────────────────────────────────────────────┐ │ Param │ Notes │ ├────────────────┼─────────────────────────────────────────────────────────┤ │ repo │ org/name, plus a local clone (needed for --contains / │ │ │ patch-id) │ ├────────────────┼─────────────────────────────────────────────────────────┤ │ target │ default qa │ ├────────────────┼─────────────────────────────────────────────────────────┤ │ stages │ ordered list, e.g. ["develop","qa","main"] — defines │ │ │ what counts as a skip │ ├────────────────┼─────────────────────────────────────────────────────────┤ │ since │ ISO date; cap history, avoids old-process noise │ ├────────────────┼─────────────────────────────────────────────────────────┤ │ merge_strategy │ merge | squash | rebase | mixed — selects the identity │ │ │ key (§4) │ └────────────────┴─────────────────────────────────────────────────────────┘
- Ingestion
Two sources, merged, with provenance recorded per record.
Primary — GitHub PRs. Authoritative for head.ref/base.ref; survives squash.
gh pr list --state merged --limit N --json
number,headRefName,baseRefName,mergedAt,mergeCommit,author,labels,additions,
deletions
Paginate; --limit caps silently. Persist raw JSON to disk — this is the slow
step and you'll re-run the analysis many times.
Secondary — merge commits. Covers non-PR merges and pre-GitHub history. git log --merges --first-parent --pretty='%H%x09%P%x09%ct%x09%s' --since= Parse subjects:
- Merge pull request #(\d+) from ([^/\s]+)/(.+) → PR number + head ref
- Merge branch '(.+?)'(?: into (.+))? → head ref, base defaults to the branch being walked
- anything else → source_class = "unknown", keep it (the unknown rate is a quality metric, don't drop it)
Reconciliation: PR record wins on conflict. Join on PR number when present, else merge commit SHA.
- Change identity
The key that lets you follow one change across branches. Pick by merge_strategy:
- merge → merge commit SHA; git branch -a --contains works directly
- squash → PR number, parsed from squash subject (#123); this is the most reliable key on GitHub
- rebase / mixed → git patch-id --stable over the diff, as fallback when PR number is absent
Emit identity_method per record. Any change whose identity can't be established goes to an unresolved bucket, counted and reported, not silently dropped.
- Branch-class normalization
Raw refs are near-unique, so the matrix is meaningless without bucketing. Ordered rule list, first match wins, config-driven so it's tunable per repo:
^(main|master)$ -> main ^(develop|dev)$ -> develop ^qa$ -> qa ^release/ -> release ^hotfix/ -> hotfix ^revert- -> revert ^dependabot/|^renovate/-> bot ^(feature|feat)/ -> feature ^(bugfix|fix)/ -> bugfix .* -> other
Report the other share. If it's above ~10%, the ruleset is wrong for this repo — surface that as a warning rather than shipping a misleading matrix.
- Data model
Change { key # identity per §4 pr_number? # int merge_sha? # str identity_method source: {ref, class} target: {ref, class} merged_at # UTC author size # additions+deletions, for weighting provenance # "gh_pr" | "merge_commit" }
PathObservation { key hops: [ {branch, first_seen_at, method} ] # ordered by first_seen_at reached_main: bool reverted: bool abandoned: bool # in qa, not in main, older than stale_days }
first_seen_at per branch: the merge time of the PR whose base.ref is that branch. Only fall back to git branch --contains + first-parent walk when PR data is missing — --contains gets expensive on large repos and breaks under squash.
- Derived outputs
A — transition matrix. Counts (source_class, target_class), row-normalized per target. Also produce a size-weighted variant (weight by size) — a 3-line hotfix and a 2000-line feature are not the same event, and the two matrices diverging is itself a finding.
B — path distribution. Canonical hop sequences with counts and median per-hop lag: feature -> develop -> qa -> main 412 lag: 1.2d / 3.1d / 0.8d feature -> qa -> main 63 SKIP: develop hotfix -> main -> qa 29 BACKFLOW Flag any path violating stages order as skip or backflow. These are usually the whole point of the exercise.
C — absorption. From qa: P(reach main), P(revert), P(stall > stale_days). Break down by source class.
Emit as JSON (machine-readable, feeds a dashboard) plus a text summary. A Sankey over the path distribution is the one visual worth building.
- Caveats to bake in as warnings, not footnotes
- Not actually Markovian. Promotion is policy, not a memoryless process. Label the output "flow frequencies," not "transition probabilities," or someone will compose the matrix with itself and get nonsense.
- Force-pushes and deleted branches are invisible; PR data survives them, git alone doesn't.
- Small counts. Any row with n < ~20 shouldn't be reported as a percentage. Show raw counts alongside every rate.
- Time-varying process. A branch policy change mid-window makes the pooled matrix an average of two different regimes. Support a --split-at date, or at minimum plot the source mix monthly so a regime change is visible.
-
Build order
-
Ingest + cache PR JSON → Change records. Report coverage: % of merges into qa with known source.
-
Class normalization + matrix A. This alone answers the original question.
-
Path reconstruction B. The bulk of the complexity is here.
-
Absorption C, monthly trend, Sankey.
Stop after 2 if the coverage number turns out to be poor — no point building path analysis on a stream with 40% unknowns.