Derive a CODEOWNERS file and a knowledge-risk report from a repository's git history.
It answers two questions that ordinary commit statistics cannot:
- Which parts of this codebase does exactly one person understand?
- Which parts does nobody understand any more?
The second question is the one git shortlog actively hides. A contributor who
left two years ago still appears as a confident-looking top committer, so an area
that has quietly become unmaintainable looks well-staffed.
| Verdict | Meaning | Consequence |
|---|---|---|
| bus factor 1 | One active person carries half the live knowledge of an area | They take a holiday, the area stalls |
| orphaned | Everyone who ever worked in the area has left | Nobody can review it, at any price |
busfactor reports these separately and never folds them into a single "risk"
ranking. An orphaned area is strictly worse than a low bus factor, and it is a
different problem with a different fix — one needs a second pair of hands, the
other needs someone to read the code from scratch.
Requires Node 18+ and git. No dependencies. Optionally the
GitHub CLI (gh), authenticated, to resolve commit
emails to GitHub handles.
Clone into your agent's skills directory, so it can be invoked by name:
git clone https://github.com/k41n/busfactor ~/.claude/skills/busfactorPer project instead of globally:
git clone https://github.com/k41n/busfactor .claude/skills/busfactorSKILL.md carries the invocation metadata and the interpretation rules the agent
should follow — in particular how to report the two verdicts, and which
conclusions the data does not support.
git clone https://github.com/k41n/busfactor
node busfactor/scripts/ownership.mjs /path/to/repoor without cloning:
npx github:k41n/busfactor /path/to/repoOnce the repository is cloned into a skills directory (above), restart your
session — the skill list is read at startup — and confirm busfactor appears in
the / menu.
There is no command to memorise. SKILL.md carries a description that Claude
matches against your request, so plain questions are the intended entry point:
Who owns what in this repo?
Generate a CODEOWNERS file from the git history.
Which areas does only one person understand?
Which parts of this codebase has everyone who wrote them already left?
Ivan is leaving next month — what knowledge do we lose?
Onboarding, offboarding and "can we still review this?" are the situations the
skill is written for. If Claude does not pick it up on its own, invoke it
explicitly with /busfactor, or say "use the busfactor skill on this repository".
- Run
ownership.mjs— starting with--dry-runon a repository it has not seen before, so nothing is written until you have looked at the result. - Read
unresolvedfrom.github/ownership.jsonand ask you, once and batched, who the unmapped commit emails belong to. Answers go into~/.cache/codeowners/authors.json, which is shared across repositories — you pay that cost once per person, not once per repo. - Show you which authors it considers inactive before the orphan verdicts are trusted. This is the step that catches the contractor who is still around but has not committed in 13 months.
- Build the report: headline numbers, a risk table with orphaned areas kept
separate from bus-factor-1 ones, per-person concentration ("if X leaves"), and
the
shallowHistory/bulkFirstCommitcaveats where they apply. - Hand back the
CODEOWNERSdiff and the risk summary.
Steps 2 and 3 are the only ones that need you. Everything else runs unattended.
Flags do not have to be spelled out — ask for the behaviour and Claude will translate:
| Say this | Gets you |
|---|---|
| "don't write anything into the repo yet" | --dry-run |
| "count anyone who committed in the last 6 months as active" | --active-months 6 |
| "skip GitHub, bare emails are fine" | --no-github |
| "at most two owners per area" | --max-owners 2 |
| "older commits should fade faster" | --half-life 180 |
| "fewer, broader areas" | --max-depth 3 |
The interpretation rules in SKILL.md still apply on top of whatever you ask
for: the two verdicts stay separate, inactive people are never named as owners,
and a young repository is never described as a re-imported one.
node scripts/ownership.mjs <repo-path> [options]Writes two files into the target repository:
.github/CODEOWNERS— ready to commit.github/ownership.json— the full analysis, for reports and diffing
Start with --dry-run on an unfamiliar repo: it prints the CODEOWNERS to
stdout and writes nothing.
| Flag | Default | Effect |
|---|---|---|
--dry-run |
— | Print to stdout, write nothing |
--half-life <days> |
365 |
How fast old commits stop counting |
--active-months <n> |
12 |
Window that defines an "active" author |
--coverage <0..1> |
0.70 |
Share of an area its owners must account for |
--max-owners <n> |
3 |
Never name more than this many people per area |
--orphan-threshold <0..1> |
0.25 |
Active share below which an area is orphaned |
--max-depth <n> |
6 |
Deepest directory that may get its own rule |
--min-zone-weight <n> |
1.5 |
Areas lighter than this inherit from their parent |
--cache <path> |
~/.cache/codeowners/authors.json |
Email → handle cache |
--no-github |
— | Skip handle lookup; emit bare emails |
Typical adjustments:
# Long-lived repo, want only recent work to matter
node scripts/ownership.mjs . --half-life 180
# Team with long gaps between commits; 12 months is too strict
node scripts/ownership.mjs . --active-months 18
# Fewer, broader areas
node scripts/ownership.mjs . --max-depth 3 --max-owners 2The tool is deterministic: "now" is the timestamp of the newest commit, not the wall clock. Two runs on an unchanged repository produce byte-identical output, so a diff between runs is meaningful and can be committed.
A single git log --no-merges --find-renames --name-status invocation. No
per-file git calls, so a repository with thousands of commits finishes in about
a second. Paths are read with core.quotepath=false and unquoted, so non-ASCII
filenames survive.
-M detects renames within a commit; it does not follow a file across its
history (--follow does, but only for one path at a time). So the analysis walks
commits newest → oldest maintaining a historical path → current path map,
registering each rename after attributing the commit that contains it. A file
moved two years ago keeps the ownership it earned under its old name.
Merge commits, bots (*[bot], dependabot, github-actions, renovate), lockfiles,
node_modules, build output, generated code, snapshots, minified bundles and
binary assets. Files no longer present in git ls-files are discarded — a
deleted directory must not appear as an area you still own.
Each commit touching a file contributes:
weight = 0.5 ^ (age_in_days / half_life)
With the default 365-day half-life, a commit from last month counts ~0.95, one from three years ago ~0.13. Old work fades but never vanishes, which matters for stable code that was written once and has needed nothing since.
Commits are counted rather than lines changed: line counts are dominated by formatters, generated files and mass refactors, none of which represent understanding.
Commit emails are resolved to GitHub accounts via
gh api repos/{owner}/{repo}/commits/{sha}, which returns the linked account
even for users.noreply addresses. Results are cached at
~/.cache/codeowners/authors.json, shared across every repository you analyse,
so the lookup cost is paid once per person rather than once per repo.
Emails resolving to the same account are then merged into one identity before weights are aggregated. This matters more than it looks: a person who commits from both a work laptop and a personal address otherwise scores as two people, which splits their weight and makes a bus-factor-1 area read as bus factor 2. That under-reports risk, which is the one direction this tool must not err in.
Weights are aggregated bottom-up over the directory tree, then walked top-down.
A directory gets its own rule only when its parent's owners no longer account
for coverage of its weight.
Comparing owner sets for equality would emit a rule almost everywhere, because
the coverage cutoff naturally lands on one owner in a focused subdirectory and
two or three at the root — the sets "differ" without ownership having actually
changed. Asking whether the parent's rule still describes the child is what
keeps a CODEOWNERS at tens of lines instead of hundreds.
For each area:
- bus factor — how many active authors are needed to reach 50% of the area's active weight.
- orphaned — active authors hold less than
orphan-thresholdof the area's total weight.
# CODEOWNERS — generated from git history, do not edit by hand.
# Source: busfactor · history through 2025-11-14
# Model: commits, half-life 365d · active window 12mo · coverage 70%
# Fallback for anything not matched below.
* @alice @bob
# bus factor 1
/services/billing/ @alice
# packages/legacy-import/ — ORPHANED — no active owner (24 files).
# Last worked on by: someone@example.com, other@example.com
/packages/legacy-import/ @alice @bob
CODEOWNERS contains only the emit areas; a report should cover all
significant ones.
- Run it, with
--dry-runfirst. - Resolve identity gaps. Read
unresolvedinownership.json— git emails GitHub could not map to an account. Add them to the cache and re-run:{ "someone@example.com": { "login": "their-handle" } } - Sanity-check the roster before trusting the orphan verdicts. The active
window is a blunt instrument. Someone still on the team who has not committed
in 13 months will be misclassified as gone; someone who left last month still
counts as present. Override either way:
This step matters most in exactly the repositories where the answer matters most, so do not skip it.
{ "someone@example.com": { "login": "handle", "active": false } } - Read the result, then edit it. The generated file is a strong first draft, not a verdict.
CODEOWNERS is last-match-wins, not first. Rules must be ordered
least-specific → most-specific. The generator sorts by path depth; if you
hand-edit the file, preserve that order or it will quietly do the opposite of
what it reads like.
Never name an inactive person as an owner. GitHub will request review from someone who cannot give it, blocking every pull request that touches the path. The generator selects owners from active authors only, and writes orphaned areas as a comment naming the departed contributors plus the repository-level fallback owner. Keep that shape in any manual edit.
Thin history has two causes; do not conflate them. A young repository and an
imported one both give a short ownership window, but only the second means these
people did not write this code. Age alone cannot distinguish them — the giveaway
is a root commit that lands most of the tree at once. meta.shallowHistory flags
the first, meta.bulkFirstCommit the second.
Hand edits do not survive. The file is regenerated in full on every run. Either commit your edits and stop regenerating, or keep corrections in the identity cache where they persist.
Commit volume is a proxy for knowledge, not a measurement of it.
- Someone who reviews thoroughly but rarely commits looks like a stranger.
- Someone who ran a mechanical refactor across the tree looks like an expert.
- Pair and mob programming are invisible; only the committer is credited.
- Squash-merge workflows collapse a contributor's work into whoever merged it.
- A repository whose history was rewritten or imported knows nothing about who originally wrote the code.
Treat the output as a starting point for a conversation with the team, not as an answer about them.
MIT
{ "meta": { "firstCommit": "2019-03-02", "commitCount": 6735, "fileCount": 1613, "rootCommitFiles": 14, "halfLife": 365, "shallowHistory": false, // history is under ~18 months "bulkFirstCommit": false // root commit holds >25% of tracked files }, "authors": [ { "login": "alice", "aliases": ["alice@personal.example"], "active": true, "commits": 812, "lastCommit": "2025-11-14" } ], "unresolved": [ { "email": "…", "name": "…" } ], "zones": [ { "path": "services/billing", "emit": true, // got its own CODEOWNERS rule "significant": true, // large enough to report on "owners": ["…"], // ranked over everyone "activeOwners": ["…"], // ranked over active people only "files": 96, "weight": 412.5, "activeShare": 0.98, "busFactor": 1, "orphaned": false, "topAuthors": [ { "email": "…", "share": 0.71, "active": true } ] } ] }