Skip to content

Throughput metrics (LTFC + Integration/Deployment Frequency + failure proxy)

Repeatable delivery metrics sourced from a local git clone. Works against Bitbucket and Azure DevOps repos, with or without squash merges.

Why these sources are trustworthy

  • LTFC (lead time for changes) uses git author dates — immune to rebase (rebase rewrites committer dates only). Confirm author == committer dates for the target repo before trusting it. Not recoverable at all from squash merges.
  • IF/DF counts PR merges on the trunk's first-parent path. First-parent is essential: without it, every commit inside a merged branch is counted as its own change and as a "direct push".
  • Merge == deploy? Only label the number "deployment frequency" when a merge to the measured branch actually deploys. Otherwise it is integration frequency.

Pure-Python, stdlib only — no pip install required, no API token, no network.

Files

File Role
metrics.py Pure throughput engine (no IO). Formulas, classification, alias map.
failures.py Pure change-failure-proxy engine (no IO). Fix classification + attribution.
collect.py IO layer: reads git, runs both engines, prints or emits JSON.
batch_size.py Cross-repo batch profile: net commits, churn and batch age per landing.
verify_registry_branches.py Compare repos.yml default_branch to git ls-remote --symref per local clone.
verify_portfolio_throughput_tables.py Portfolio review Progress since + Batch size rows vs portfolio_monthly.change-throughput.
_gitfixture.py Builds deterministic throwaway git repos for the tests.
test_metrics.py test_failures.py test_collect.py test_batch_size.py unittest suites (87 tests).

Tests

python -m unittest discover -p 'test_*.py'

No repository access is required. The suite needs only Python and git on PATH — it clones nothing and reads none of the repos under review. _gitfixture.py builds small throwaway repos under the system temp directory and removes them afterwards, so a teammate can check out this library alone and run everything. It works the same on Windows (PowerShell or Git Bash), macOS and Linux.

The pure engines (metrics, failures) are tested directly. The git-reading layers (collect, batch_size) are tested against those generated repositories rather than against mocks: every defect these suites guard against was a misreading of real git output, so a mock of git would only have asserted the mock's own behaviour. Generated repos give real git output with none of the coupling.

_gitfixture.py can produce each landing shape seen across the portfolio:

Helper Shape
land_bitbucket two-parent merge; subject is the branch, PR title is the first body line
land_ado_merge two-parent merge; subject carries the title
land_ado_squash single parent; author date rewritten to the squash time
land_rebase_ff no wrapper; author date preserved, committer rewritten
land_direct_push no wrapper; author == committer

Regressions pinned by these tests

Test Defect it prevents
FirstParentTests Counting all reachable commits instead of --first-parent — overstated one repo's PR count 8× and invented 370 direct pushes
MergeStyleDetectionTests Reading ADO two-parent merges as squashes (losing LTFC); guessing a policy from one sample
RebaseFastForwardTests Reporting rebase fast-forward landings as direct pushes
BitbucketTitleTests Reading the PR title from the wrong field; multi-line bodies corrupting the file list
WindowBoundsTests Bare date bounds resolving in local time, shifting the window by the UTC offset
TestObservedSubjects The seven real subjects that a looser classifier scored as fixes (50% fix rate from zero actual fixes)
PathScopeTests Sub-scope filtering the failure proxy but not the throughput metrics
TargetSpecTests / DefaultBranchTests Hardcoded clone paths, and measuring the checked-out branch instead of the remote default

Batch size (batch_size.py)

collect.py answers how often changes land. batch_size.py answers how much lands each time, measured in net commits per landing on the trunk — not PRs and not branches, since a single PR can carry a hundred commits.

# PowerShell
py -3 batch_size.py --since 2026-07-21 --until 2026-08-20 `
  --repo "plumbing prod=$env:USERPROFILE\source\repos\plumbing:master" `
  --repo "plumbing dev=$env:USERPROFILE\source\repos\plumbing:origin/develop" `
  --repo "$env:USERPROFILE\source\repos\pipe-dream" `
  --json $env:TEMP\batch.json
# Git Bash / WSL / macOS / Linux
python batch_size.py --since 2026-07-21 --until 2026-08-20 \
  --repo "plumbing prod=/c/Users/<you>/source/repos/plumbing:master" \
  --repo "plumbing dev=/c/Users/<you>/source/repos/plumbing:origin/develop" \
  --repo ~/src/pipe-dream \
  --json /tmp/batch.json

Targets are [LABEL=]PATH[:BRANCH], repeatable. Windows drive letters are handled — C:\src\repo is read as a path, while C:\src\repo:master splits on the last colon. Always quote the whole target so the shell does not split it.

Clone paths differ per machine, so they are never baked into the script. Omit :BRANCH and it resolves the remote's default branch — not whatever is checked out, since clones are routinely parked on a feature branch and several repos here are master rather than main. Point two labels at one clone to profile a release train's production and integration branches separately.

Per landing it reports:

Field Meaning
net_commits Non-merge commits the landing introduced (merge: p1..p2; squash: 1)
files / churn First-parent diff size
span_h Oldest authored commit in the batch → landing time — batch age

Reading it: before treating a median of 1.0 commit per landing as real, check the repo's landing strategy — squash collapses every branch to one commit and will always report 1.0. collect.py already reports this as bitbucket/merge, ado/merge or ado/squash. For two-parent merges the count is directly recoverable and therefore real.

What each landing strategy preserves

Verified in a controlled repo, and cross-checked against live repos:

Strategy Landing boundary Net commits recoverable Original author dates
Squash ✅ (one commit) ❌ always 1 ❌ destroyed — rewritten to the squash time
Rebase + fast-forward (strictly linear) ❌ none ⚠️ each commit reads as its own landing ✅ preserved
Rebase + merge commit (ADO semi-linear merge) ✅ merge commit ✅ walkable via p1..p2 ✅ preserved
Plain merge (no rebase) ✅ merge commit ✅ ✅

Rebase rewrites the committer date and leaves the author date intact — which is why LTFC and span_h use author dates. Squash is the only strategy that destroys authoring time: a squash landing shows author date == committer date.

If a team wants better measurement, recommend ADO "semi-linear merge" (rebase, then a merge commit). A strict fast-forward rebase preserves authoring times but removes the landing boundary, making batch size unmeasurable — you can no longer tell what shipped together.

Churn and batch age are strategy-independent and can always be compared.

Run

Windows is a common baseline, so examples are given PowerShell-first.

# PowerShell — 30-day window against another clone (no need to cd into it)
py -3 collect.py --repo "$env:USERPROFILE\source\repos\pipe-dream" --branch main `
  --since 2026-07-21 --until 2026-08-20

# monorepo sub-scope
py -3 collect.py --repo "C:\Users\<you>\source\repos\plumbing" --branch master `
  --since 2026-07-21 --until 2026-08-20 --path pricing
# Git Bash / WSL / macOS / Linux — same thing
python collect.py --repo "/c/Users/<you>/source/repos/pipe-dream" --branch main \
  --since 2026-07-21 --until 2026-08-20

python collect.py --repo ~/src/plumbing --branch master \
  --since 2026-07-21 --until 2026-08-20 --path pricing
# emit raw JSON for the portfolio scans folder the user confirms
python collect.py --repo <clone> --branch <trunk> --since <d> --until <d> \
  --json docs/management/compliance/scans/<repo_id>/raw/throughput-<YYYY-MM>.json

# tests
python -m unittest discover -p 'test_*.py'

--path always uses forward slashes, on every platform — it is a git pathspec, not a filesystem path. --repo takes whatever your shell uses.

On Windows prefer the workspace interpreter (.venv\Scripts\python.exe) or the launcher (py -3) over a bare python, which may resolve to the Microsoft Store stub.

Flags

Flag Purpose
--repo Path to the target clone (default: cwd).
--branch Trunk to measure. Use the real default branch — several repos are master.
--path Repeatable. Limits every metric to a sub-tree.
--merge-style auto (default), bitbucket, ado, ado-squash.
--fix-lookback-days Attribution window for the failure proxy (default 14).
--no-failures Skip the change-failure proxy.

Merge-style detection

Two orthogonal properties are detected separately, because ADO does both:

Property Values Consequence
PR wrapper Bitbucket Merged in <branch> (pull request #N) · ADO Merged PR N: <title> Where the human-readable title lives
Landing two-parent merge · single-parent squash Squash ⇒ LTFC unmeasurable

Bitbucket subjects contain only the branch name — the PR title is the first line of the body. failures.py reads the right field per host; getting this wrong makes every Bitbucket repo report a 0% fix rate.

Definitions (tunable in metrics.py)

  • Micro change: files<=1 and churn<=5 and commits<=1 (trivial one-liner).
  • Meaningful change: not micro.
  • Genuinely-active contributor: >=3 PRs OR >=5 active days in the window (require_both=True to AND them).
  • Benchmark: 1 good change per 2 active contributors per work-day (contributors_per_change=2 → 0.5 meaningful PRs / active contributor / work-day).

Change-failure proxy (see failures.py)

Confidence Signal Counted in headline?
HIGH conventional prefix — fix:, hotfix:, revert:, Revert "…" yes
MEDIUM strong keyword in the title — fix / bug / hotfix / patch / rollback / defect / regression / broken yes
LOW ambiguous word ("correct", "failure"), fix wording only in the body, or bare file overlap no — goes to the confirmation queue
  • fix_rate = fixes / changes in window.
  • attributed_fix_rate = in-window changes that later drew a fix / non-fix changes. This is the CFR proxy.
  • Collection reaches back one extra lookback period so a fix landing on day 1 can still be attributed; those earlier changes never count toward the rates.
  • The proxy is only as good as commit-message hygiene. A repo whose titles are bare Jira IDs will report ~0% and a long confirmation queue — that is a message-quality finding, not a quality-of-code finding.