Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

busfactor

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.


Two verdicts, never merged

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.


Install

Requires Node 18+ and git. No dependencies. Optionally the GitHub CLI (gh), authenticated, to resolve commit emails to GitHub handles.

As a coding-agent skill

Clone into your agent's skills directory, so it can be invoked by name:

git clone https://github.com/k41n/busfactor ~/.claude/skills/busfactor

Per project instead of globally:

git clone https://github.com/k41n/busfactor .claude/skills/busfactor

SKILL.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.

As a standalone CLI

git clone https://github.com/k41n/busfactor
node busfactor/scripts/ownership.mjs /path/to/repo

or without cloning:

npx github:k41n/busfactor /path/to/repo

Using it from Claude Code

Once 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.

Just ask

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".

What Claude will do

  1. Run ownership.mjs — starting with --dry-run on a repository it has not seen before, so nothing is written until you have looked at the result.
  2. Read unresolved from .github/ownership.json and 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.
  3. 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.
  4. 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 / bulkFirstCommit caveats where they apply.
  5. Hand back the CODEOWNERS diff and the risk summary.

Steps 2 and 3 are the only ones that need you. Everything else runs unattended.

Steering it in plain language

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.


Usage

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 2

The 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.


How it works

1. One pass over history

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.

2. Renames are replayed

-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.

3. Noise is dropped

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.

4. Commits decay

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.

5. Identities are merged

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.

6. Areas are found by adaptive depth

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.

7. Risk is assessed

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-threshold of the area's total weight.

Output

.github/CODEOWNERS

# 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

.github/ownership.json

{
  "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 } ]
    }
  ]
}

CODEOWNERS contains only the emit areas; a report should cover all significant ones.


Recommended workflow

  1. Run it, with --dry-run first.
  2. Resolve identity gaps. Read unresolved in ownership.json — git emails GitHub could not map to an account. Add them to the cache and re-run:
    { "someone@example.com": { "login": "their-handle" } }
  3. 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:
    { "someone@example.com": { "login": "handle", "active": false } }
    This step matters most in exactly the repositories where the answer matters most, so do not skip it.
  4. Read the result, then edit it. The generated file is a strong first draft, not a verdict.

Things that are easy to get wrong

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.


Limitations

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.


License

MIT

About

Derive a CODEOWNERS file and a knowledge-risk report from a repository's git history — finds single-maintainer and unmaintained areas.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages