repo-dive 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -13
- package/dist/cli.js +105 -34
- package/dist/dashboard/assets/{index-BHAI3PZl.js → index-DsPwNuo-.js} +1 -1
- package/dist/dashboard/assets/index-LsGxMZ0D.css +2 -0
- package/dist/dashboard/index.html +2 -2
- package/package.json +3 -2
- package/dist/dashboard/assets/index-DFKSaas-.css +0 -2
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# repo-dive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
_Dive into a git repo's history: per-commit snapshots, an indexed metrics catalog and an interactive dashboard_ <!-- markdownlint-disable-line MD036 -- a tagline, not a heading -->
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/repo-dive)
|
|
6
|
+
[](https://www.npmjs.com/package/repo-dive)
|
|
7
|
+
[](LICENSE.md)
|
|
8
|
+
[](https://effect.website)
|
|
9
|
+
[](#ai-agents-mcp)
|
|
8
10
|
|
|
9
11
|
## What it does
|
|
10
12
|
|
|
11
|
-
Point
|
|
13
|
+
Point `repo-dive` at any git repository and get an explorable catalog of insights derived from its history:
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
16
|
cd /path/to/your/repo
|
|
@@ -27,10 +29,10 @@ See [docs/specs](docs/specs/README.md) for the architecture and [docs/research/p
|
|
|
27
29
|
|
|
28
30
|
## Usage
|
|
29
31
|
|
|
30
|
-
Run from inside the repository you want to analyze (or pass `--repo /path/to/repo`)
|
|
32
|
+
Run from inside the repository you want to analyze (or pass `--repo /path/to/repo`).
|
|
33
|
+
Node 22.13 or newer is required.
|
|
31
34
|
|
|
32
35
|
```sh
|
|
33
|
-
cd /path/to/your/repo
|
|
34
36
|
npx repo-dive # the whole pipeline: scan + index + dashboard
|
|
35
37
|
npx repo-dive scan # collect snapshots into .repo-dive/
|
|
36
38
|
npx repo-dive index # roll up into the metrics cube + dashboard data
|
|
@@ -38,10 +40,10 @@ npx repo-dive dashboard # serve the interactive dashboard
|
|
|
38
40
|
npx repo-dive status # show catalog coverage
|
|
39
41
|
npx repo-dive collectors # list available collectors
|
|
40
42
|
npx repo-dive report # export one shareable self-contained HTML file
|
|
43
|
+
npx repo-dive mcp # serve the cube to AI agents (Model Context Protocol)
|
|
44
|
+
npx repo-dive gc # clean up the catalog interactively
|
|
45
|
+
npx repo-dive ignore # keep other tools out of the catalog
|
|
41
46
|
npx repo-dive query "SELECT metric, sum(value) FROM facts GROUP BY metric"
|
|
42
|
-
npx repo-dive mcp # serve the cube to AI agents (Model Context Protocol)
|
|
43
|
-
npx repo-dive gc # clean up the catalog interactively
|
|
44
|
-
npx repo-dive ignore # keep other tools out of the catalog
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
`scan` walks the repository's history and runs collectors against every commit (or a sample, per collector), writing raw snapshots into a `.repo-dive/` catalog inside the analyzed repo.
|
|
@@ -53,7 +55,7 @@ Collectors so far:
|
|
|
53
55
|
- **churn** — lines added/deleted per commit, by file extension
|
|
54
56
|
- **file-types** — file count and bytes per extension at each commit's tree
|
|
55
57
|
- **directives** — eslint-disable comments by rule (block disables tracked as gray areas) and `@ts-ignore`/`@ts-expect-error`/`@ts-nocheck`
|
|
56
|
-
- **dependencies** —
|
|
58
|
+
- **dependencies** — resolved package totals from lockfiles, per package manager (pnpm, npm and yarn classic and berry), plus direct/dev/optional dependencies and manifest counts read straight from `package.json` files; version-aware, monorepo-aware and extensible to more managers
|
|
57
59
|
- **todo-comments** — TODO/FIXME/HACK/XXX counts
|
|
58
60
|
- **languages** — lines and file count per language across a commit's source files (lockfiles, minified bundles and generated data excluded)
|
|
59
61
|
- **survival** — `git blame` line survival by extension, author and age cohort (sampled monthly)
|
|
@@ -61,7 +63,8 @@ Collectors so far:
|
|
|
61
63
|
The catalog hides itself from git, but other tools that walk the repository (prettier, markdownlint, cspell, docker builds) each read one ignore file at its root.
|
|
62
64
|
`scan` warns when the catalog is missing from those; `repo-dive ignore` adds it to every one of them.
|
|
63
65
|
|
|
64
|
-
`index` normalizes raw snapshots into `.repo-dive/index/metrics.sqlite`
|
|
66
|
+
`index` normalizes raw snapshots into `.repo-dive/index/metrics.sqlite` — a facts-by-categories cube, rebuildable at any time — plus `dashboard.json`.
|
|
67
|
+
`dashboard` then serves a local React app with interactive charts: languages over time, a GitHub-style commit calendar, monthly commits with AI-assisted share, churn, lint-suppression trends, dependency counts over time, code survival by cohort and author, and more.
|
|
65
68
|
|
|
66
69
|
## Configuration
|
|
67
70
|
|
|
@@ -98,7 +101,8 @@ export default defineConfig({
|
|
|
98
101
|
```
|
|
99
102
|
|
|
100
103
|
`charts.weekStartsOn` sets the first day of the week in calendar-shaped charts such as the commit calendar (`"monday"` by default, `"sunday"` also supported).
|
|
101
|
-
`contributors.aliases` merges the multiple identities one person commits under (work + personal email, GitHub noreply, name variants) so attribution, the contributors table and code-survival-by-contributor count them once
|
|
104
|
+
`contributors.aliases` merges the multiple identities one person commits under (work + personal email, GitHub noreply, name variants) so attribution, the contributors table and code-survival-by-contributor count them once.
|
|
105
|
+
A group can also carry a `displayName`, a profile `url` and a `kind` — `human`/`bot`/`ai`, otherwise auto-derived, with the dashboard badging bots and AI agents and listing them apart from humans.
|
|
102
106
|
`catalog.dir` moves the catalog; point it outside the repository (e.g. `"../repo-dive-catalogs/my-repo"`) to leave the analyzed working tree untouched altogether, ignore files included.
|
|
103
107
|
Apart from `catalog`, which every command needs, the config is read by `index`.
|
|
104
108
|
See [docs/specs/07-config.md](docs/specs/07-config.md) for details.
|
|
@@ -148,9 +152,19 @@ pnpm lint
|
|
|
148
152
|
pnpm fix
|
|
149
153
|
```
|
|
150
154
|
|
|
155
|
+
To see how heavy the published package would be:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
pnpm build && pnpm report-package-size
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
It prints the tarball and unpacked sizes with a per-file breakdown, comparing them against the previous measurement.
|
|
162
|
+
CI runs the same report on every push and adds it to the job summary, comparing against the latest `main`.
|
|
163
|
+
|
|
151
164
|
## Acknowledgements
|
|
152
165
|
|
|
153
166
|
Thanks to [@WillJack20](https://github.com/WillJack20) for suggesting the name **repo-dive**.
|
|
167
|
+
The project was published as [repo-insighter](https://www.npmjs.com/package/repo-insighter) before 0.4.0.
|
|
154
168
|
|
|
155
169
|
## License
|
|
156
170
|
|
package/dist/cli.js
CHANGED
|
@@ -52465,8 +52465,8 @@ var runWith = (command, config) => {
|
|
|
52465
52465
|
};
|
|
52466
52466
|
var package_default = {
|
|
52467
52467
|
name: "repo-dive",
|
|
52468
|
-
version: "0.
|
|
52469
|
-
description: "Dive into a git
|
|
52468
|
+
version: "0.8.0",
|
|
52469
|
+
description: "Dive into a git repo's history: per-commit snapshots, an indexed metrics catalog and an interactive dashboard",
|
|
52470
52470
|
keywords: [
|
|
52471
52471
|
"git",
|
|
52472
52472
|
"repository",
|
|
@@ -52530,6 +52530,7 @@ var package_default = {
|
|
|
52530
52530
|
"lint:tsc": "tsc --noEmit && tsc -p dashboard --noEmit",
|
|
52531
52531
|
"prepack": "pnpm build",
|
|
52532
52532
|
"prepare": "node .husky/install.mjs",
|
|
52533
|
+
"report-package-size": "node scripts/report-package-size.ts",
|
|
52533
52534
|
"test": "vitest run"
|
|
52534
52535
|
},
|
|
52535
52536
|
"lint-staged": { "**": ["prettier --ignore-unknown --write"] },
|
|
@@ -55159,12 +55160,6 @@ var NoCollectedCommitsError = class extends TaggedError("NoCollectedCommitsError
|
|
|
55159
55160
|
}
|
|
55160
55161
|
};
|
|
55161
55162
|
/**
|
|
55162
|
-
* Heuristic for AI coding assistants appearing as commit co-authors.
|
|
55163
|
-
* Automation bots (renovate, dependabot, github-actions, …) are deliberately
|
|
55164
|
-
* not "AI": they don't reflect assisted authorship.
|
|
55165
|
-
*/
|
|
55166
|
-
var isAiCoAuthor = (coAuthor) => !/renovate|dependabot|github-actions/i.test(coAuthor) && /claude|copilot|cursor|chatgpt|openai|gemini|aider|devin|coderabbit|codegen|sweep|windsurf/i.test(coAuthor);
|
|
55167
|
-
/**
|
|
55168
55163
|
* Series labels non-human contributors fold into in survival data. The
|
|
55169
55164
|
* dashboard matches these literal strings to color the bands with the
|
|
55170
55165
|
* reserved kind colors, so change them in both places or not at all.
|
|
@@ -55173,10 +55168,26 @@ var kindGroupLabels = {
|
|
|
55173
55168
|
bot: "Bots",
|
|
55174
55169
|
ai: "AI agents"
|
|
55175
55170
|
};
|
|
55176
|
-
/**
|
|
55177
|
-
|
|
55178
|
-
|
|
55179
|
-
|
|
55171
|
+
/**
|
|
55172
|
+
* Splits a `Co-authored-by:` trailer into its parts:
|
|
55173
|
+
* `"Claude Fable 5 <noreply@anthropic.com>"` → name + email.
|
|
55174
|
+
*
|
|
55175
|
+
* Keeping the email (rather than the display name alone) is what lets a
|
|
55176
|
+
* co-author resolve through the very same `resolveContributor` an author goes
|
|
55177
|
+
* through — so alias groups, `displayName`/`url` overrides and kind derivation
|
|
55178
|
+
* apply identically whether a person wrote a commit or helped with one.
|
|
55179
|
+
*/
|
|
55180
|
+
var parseIdentity = (identity) => {
|
|
55181
|
+
const open = identity.indexOf("<");
|
|
55182
|
+
const close = identity.lastIndexOf(">");
|
|
55183
|
+
if (open === -1 || close < open) return {
|
|
55184
|
+
name: identity.trim(),
|
|
55185
|
+
email: ""
|
|
55186
|
+
};
|
|
55187
|
+
return {
|
|
55188
|
+
name: identity.slice(0, open).trim(),
|
|
55189
|
+
email: identity.slice(open + 1, close).trim()
|
|
55190
|
+
};
|
|
55180
55191
|
};
|
|
55181
55192
|
var sumMetric = (commit, metric, filter) => {
|
|
55182
55193
|
let total = 0;
|
|
@@ -55202,13 +55213,26 @@ var sumByKey = (record, keyOf) => {
|
|
|
55202
55213
|
return merged;
|
|
55203
55214
|
};
|
|
55204
55215
|
var buildDashboardData = (repoRoot, commits, config) => {
|
|
55205
|
-
|
|
55216
|
+
/** The raw `"Name <email>"` trailers on a commit, in the order git listed them. */
|
|
55217
|
+
const coAuthorsOf = (commit) => [...commit.factsByCollector.values()].flat().filter((fact) => fact.metric === "commits.coAuthor").map((fact) => fact.categories?.["coAuthor"] ?? "");
|
|
55218
|
+
/**
|
|
55219
|
+
* A co-author resolved the same way an author is. An identity with no email
|
|
55220
|
+
* (a bare `Co-authored-by: Some Name`) keys off its name instead, so those
|
|
55221
|
+
* don't all collapse into one empty-email bucket.
|
|
55222
|
+
*/
|
|
55223
|
+
const resolveCoAuthor = (identity) => {
|
|
55224
|
+
const { name, email } = parseIdentity(identity);
|
|
55225
|
+
return {
|
|
55226
|
+
name,
|
|
55227
|
+
resolved: config.resolveContributor(email || name, name)
|
|
55228
|
+
};
|
|
55229
|
+
};
|
|
55206
55230
|
const commitRows = commits.map((commit) => ({
|
|
55207
55231
|
sha: commit.sha.slice(0, 10),
|
|
55208
55232
|
date: commit.date,
|
|
55209
55233
|
author: commit.authorEmail,
|
|
55210
55234
|
kind: config.resolveContributor(commit.authorEmail, commit.authorName).kind,
|
|
55211
|
-
ai:
|
|
55235
|
+
ai: coAuthorsOf(commit).some((identity) => resolveCoAuthor(identity).resolved.kind === "ai"),
|
|
55212
55236
|
added: sumMetric(commit, "churn.added"),
|
|
55213
55237
|
deleted: sumMetric(commit, "churn.deleted")
|
|
55214
55238
|
}));
|
|
@@ -55287,32 +55311,80 @@ var buildDashboardData = (repoRoot, commits, config) => {
|
|
|
55287
55311
|
};
|
|
55288
55312
|
});
|
|
55289
55313
|
const contributorMap = /* @__PURE__ */ new Map();
|
|
55290
|
-
|
|
55291
|
-
|
|
55292
|
-
|
|
55293
|
-
|
|
55294
|
-
|
|
55314
|
+
/**
|
|
55315
|
+
* The name as spelled on this commit (author line or trailer): a configured
|
|
55316
|
+
* displayName wins, otherwise the observed spelling, tidied so a bot's
|
|
55317
|
+
* `[bot]` suffix doesn't double up with its kind badge.
|
|
55318
|
+
*/
|
|
55319
|
+
const nameOf = (resolved, observedName) => resolved.displayName ?? (observedName ? normalizeContributorName(observedName) : resolved.label);
|
|
55320
|
+
/**
|
|
55321
|
+
* Fetches or creates a contributor's bucket.
|
|
55322
|
+
*
|
|
55323
|
+
* Humans key off their canonical email alone, so an alias group folds every
|
|
55324
|
+
* spelling of one person together. Bots and AI agents key off their name too:
|
|
55325
|
+
* they share vendor noreply addresses — "Claude Fable 5" and "Claude Opus
|
|
55326
|
+
* 4.8" are both `<noreply@anthropic.com>` — and for them the name carries the
|
|
55327
|
+
* identity worth telling apart. Giving such a group a `displayName` in the
|
|
55328
|
+
* config merges them back into one.
|
|
55329
|
+
*/
|
|
55330
|
+
const bucketFor = (resolved, observedName) => {
|
|
55331
|
+
const name = nameOf(resolved, observedName);
|
|
55332
|
+
const email = resolved.canonicalEmail.toLowerCase();
|
|
55333
|
+
const key = resolved.kind === "human" ? email : `${name.toLowerCase()}${email}`;
|
|
55295
55334
|
const bucket = contributorMap.get(key) ?? {
|
|
55296
55335
|
email: resolved.canonicalEmail,
|
|
55297
|
-
name
|
|
55336
|
+
name,
|
|
55298
55337
|
url: resolved.url,
|
|
55299
55338
|
kind: resolved.kind,
|
|
55300
55339
|
commits: 0,
|
|
55301
55340
|
added: 0,
|
|
55302
|
-
deleted: 0
|
|
55341
|
+
deleted: 0,
|
|
55342
|
+
assistedBy: {},
|
|
55343
|
+
assisted: {}
|
|
55303
55344
|
};
|
|
55304
|
-
bucket.name =
|
|
55305
|
-
bucket.commits += 1;
|
|
55306
|
-
bucket.added += row.added;
|
|
55307
|
-
bucket.deleted += row.deleted;
|
|
55345
|
+
bucket.name = name;
|
|
55308
55346
|
contributorMap.set(key, bucket);
|
|
55309
|
-
|
|
55310
|
-
|
|
55311
|
-
const
|
|
55312
|
-
|
|
55313
|
-
|
|
55314
|
-
|
|
55315
|
-
|
|
55347
|
+
return bucket;
|
|
55348
|
+
};
|
|
55349
|
+
for (const [index, commit] of commits.entries()) {
|
|
55350
|
+
const row = commitRows[index];
|
|
55351
|
+
if (!row) continue;
|
|
55352
|
+
const author = config.resolveContributor(commit.authorEmail, commit.authorName);
|
|
55353
|
+
const authorBucket = bucketFor(author, commit.authorName);
|
|
55354
|
+
authorBucket.commits += 1;
|
|
55355
|
+
authorBucket.added += row.added;
|
|
55356
|
+
authorBucket.deleted += row.deleted;
|
|
55357
|
+
const helpers = /* @__PURE__ */ new Set();
|
|
55358
|
+
for (const identity of coAuthorsOf(commit)) {
|
|
55359
|
+
const { name, resolved } = resolveCoAuthor(identity);
|
|
55360
|
+
if (resolved.kind !== author.kind) helpers.add(bucketFor(resolved, name));
|
|
55361
|
+
}
|
|
55362
|
+
const helperKinds = /* @__PURE__ */ new Set();
|
|
55363
|
+
for (const helper of helpers) {
|
|
55364
|
+
helper.assisted[author.kind] = (helper.assisted[author.kind] ?? 0) + 1;
|
|
55365
|
+
helperKinds.add(helper.kind);
|
|
55366
|
+
}
|
|
55367
|
+
for (const kind of helperKinds) authorBucket.assistedBy[kind] = (authorBucket.assistedBy[kind] ?? 0) + 1;
|
|
55368
|
+
}
|
|
55369
|
+
const sumCounts = (counts) => Object.values(counts).reduce((total, count) => total + count, 0);
|
|
55370
|
+
/** Ranking weight: authoring and helping both count as taking part. */
|
|
55371
|
+
const involvementOf = (bucket) => bucket.commits + sumCounts(bucket.assisted);
|
|
55372
|
+
/** Emitted as absent rather than `{}` so the JSON stays lean. */
|
|
55373
|
+
const countsOrUndefined = (counts) => Object.keys(counts).length === 0 ? void 0 : counts;
|
|
55374
|
+
const perKindCap = config.maxInCharts * 2;
|
|
55375
|
+
const keptPerKind = {
|
|
55376
|
+
human: 0,
|
|
55377
|
+
bot: 0,
|
|
55378
|
+
ai: 0
|
|
55379
|
+
};
|
|
55380
|
+
const contributors = [...contributorMap.values()].toSorted((left, right) => involvementOf(right) - involvementOf(left)).filter((bucket) => {
|
|
55381
|
+
if (keptPerKind[bucket.kind] >= perKindCap) return false;
|
|
55382
|
+
keptPerKind[bucket.kind] += 1;
|
|
55383
|
+
return true;
|
|
55384
|
+
}).map((bucket) => ({
|
|
55385
|
+
...bucket,
|
|
55386
|
+
assistedBy: countsOrUndefined(bucket.assistedBy),
|
|
55387
|
+
assisted: countsOrUndefined(bucket.assisted)
|
|
55316
55388
|
}));
|
|
55317
55389
|
return {
|
|
55318
55390
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -55334,8 +55406,7 @@ var buildDashboardData = (repoRoot, commits, config) => {
|
|
|
55334
55406
|
dependencies,
|
|
55335
55407
|
topRules,
|
|
55336
55408
|
survival,
|
|
55337
|
-
contributors
|
|
55338
|
-
aiIdentities
|
|
55409
|
+
contributors
|
|
55339
55410
|
};
|
|
55340
55411
|
};
|
|
55341
55412
|
var writeSqlite = (dbPath, commits) => {
|