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¶
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 daysin the window (require_both=Trueto 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.