repo-dive 0.4.1 → 0.4.2

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.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/dist/cli.js +149 -23
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -81,6 +81,38 @@ export default defineConfig({
81
81
 
82
82
  `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; a group can also carry a `displayName`, a profile `url` and a `kind` (`human`/`bot`/`ai`, otherwise auto-derived — the dashboard badges bots and AI agents and lists them apart from humans). The config is read by `index`. See [docs/specs/07-config.md](docs/specs/07-config.md) for details.
83
83
 
84
+ ## AI agents (MCP)
85
+
86
+ `repo-dive mcp` serves the metrics cube over the Model Context Protocol on stdio, so an agent can explore a repository's history by asking SQL questions. Two tools:
87
+
88
+ - **`schema`** — tables, available metrics with row counts, sample category keys per metric and the commit range; worth calling before writing queries.
89
+ - **`query`** — one read-only statement (`SELECT`/`WITH`/`EXPLAIN`) against the cube, returning `{ columns, rows, truncated }` (up to 200 rows).
90
+
91
+ Run `scan` and `index` first: the server exits immediately if there is no cube at `.repo-dive/index/metrics.sqlite`. The database is opened read-only, so nothing an agent asks can change the catalog.
92
+
93
+ For [Claude Code](https://code.claude.com/docs/en/mcp), run this inside the repository you want to ask questions about:
94
+
95
+ ```sh
96
+ claude mcp add repo-dive -- npx -y repo-dive mcp
97
+ ```
98
+
99
+ Or commit a project-scoped `.mcp.json` at the repository root, so everyone on the team gets the same server:
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "repo-dive": {
105
+ "command": "npx",
106
+ "args": ["-y", "repo-dive", "mcp"]
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ Then ask things like "which languages grew fastest last year?" or "how has the share of AI-assisted commits changed?".
113
+
114
+ The same stdio server works with any MCP client — point yours at `npx repo-dive mcp`, adding `--repo /path/to/repo` if the client does not start it inside the repository being analyzed.
115
+
84
116
  ## Development
85
117
 
86
118
  The project is written in TypeScript with [Effect](https://effect.website) v4 (beta) and its built-in CLI toolkit (`effect/unstable/cli`).
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import http from "node:http";
5
5
  import * as Crypto$1 from "node:crypto";
6
6
  import { createHash } from "node:crypto";
7
7
  import * as NFS from "node:fs";
8
- import { existsSync, mkdirSync } from "node:fs";
8
+ import { existsSync, mkdirSync, statSync } from "node:fs";
9
9
  import * as OS from "node:os";
10
10
  import os from "node:os";
11
11
  import * as Path from "node:path";
@@ -52170,7 +52170,7 @@ var runWith = (command, config) => {
52170
52170
  };
52171
52171
  var package_default = {
52172
52172
  name: "repo-dive",
52173
- version: "0.4.1",
52173
+ version: "0.4.2",
52174
52174
  description: "Dive into a git repository's history: per-commit snapshots, an indexed metrics catalog and an interactive dashboard",
52175
52175
  keywords: [
52176
52176
  "git",
@@ -52240,6 +52240,7 @@ var package_default = {
52240
52240
  "lint-staged": { "**": ["prettier --ignore-unknown --write"] },
52241
52241
  prettier: "@kachkaev/prettier-config",
52242
52242
  devDependencies: {
52243
+ "@changesets/changelog-github": "0.7.0",
52243
52244
  "@changesets/cli": "2.31.1",
52244
52245
  "@changesets/config": "3.1.4",
52245
52246
  "@effect/platform-node": "4.0.0-beta.99",
@@ -52723,12 +52724,12 @@ var writeCollectorOutput = ({ catalog, sha, collector, cacheKey, output, duratio
52723
52724
  var openCaches = /* @__PURE__ */ new Map();
52724
52725
  /** Bump when the table shape changes; a mismatch drops the cache and rebuilds. */
52725
52726
  var schemaVersion = 2;
52727
+ var blobCachePath = (repoRoot) => path.join(repoRoot, catalogDirName, "cache", "blob-cache.sqlite");
52726
52728
  var getBlobCache = (repoRoot) => {
52727
52729
  const existing = openCaches.get(repoRoot);
52728
- if (existing) return existing;
52729
- const cacheDir = path.join(repoRoot, catalogDirName, "cache");
52730
- mkdirSync(cacheDir, { recursive: true });
52731
- const db = new DatabaseSync(path.join(cacheDir, "blob-cache.sqlite"));
52730
+ if (existing) return existing.cache;
52731
+ mkdirSync(path.join(repoRoot, catalogDirName, "cache"), { recursive: true });
52732
+ const db = new DatabaseSync(blobCachePath(repoRoot));
52732
52733
  db.exec("PRAGMA journal_mode = WAL");
52733
52734
  const storedSchema = db.prepare("PRAGMA user_version").get();
52734
52735
  if (Number(storedSchema?.["user_version"] ?? 0) !== schemaVersion) {
@@ -52768,9 +52769,82 @@ var getBlobCache = (repoRoot) => {
52768
52769
  }
52769
52770
  }
52770
52771
  };
52771
- openCaches.set(repoRoot, cache);
52772
+ openCaches.set(repoRoot, {
52773
+ db,
52774
+ cache
52775
+ });
52772
52776
  return cache;
52773
52777
  };
52778
+ /** Bytes the cache occupies, counting the write-ahead log SQLite keeps beside it. */
52779
+ var blobCacheSizeBytes = (repoRoot) => {
52780
+ const base = blobCachePath(repoRoot);
52781
+ return [
52782
+ "",
52783
+ "-wal",
52784
+ "-shm"
52785
+ ].map((suffix) => `${base}${suffix}`).reduce((total, filePath) => total + (existsSync(filePath) ? statSync(filePath).size : 0), 0);
52786
+ };
52787
+ /** Opens the cache file for maintenance, or `undefined` when there is none. */
52788
+ var openForMaintenance = (repoRoot) => {
52789
+ const filePath = blobCachePath(repoRoot);
52790
+ if (!existsSync(filePath)) return;
52791
+ const open = openCaches.get(repoRoot);
52792
+ if (open) {
52793
+ open.db.close();
52794
+ openCaches.delete(repoRoot);
52795
+ }
52796
+ const db = new DatabaseSync(filePath);
52797
+ if (!db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'blob_results'").get()) {
52798
+ db.close();
52799
+ return;
52800
+ }
52801
+ return db;
52802
+ };
52803
+ /** What the cache currently holds, one row per (collector, fingerprint) pair. */
52804
+ var listBlobCacheNamespaces = (repoRoot) => {
52805
+ const db = openForMaintenance(repoRoot);
52806
+ if (!db) return [];
52807
+ try {
52808
+ return db.prepare("SELECT collector, cache_key, count(*) AS entry_count FROM blob_results GROUP BY collector, cache_key").all().flatMap((row) => {
52809
+ const collector = row["collector"];
52810
+ const cacheKey = row["cache_key"];
52811
+ return typeof collector === "string" && typeof cacheKey === "string" ? [{
52812
+ collector,
52813
+ cacheKey,
52814
+ entryCount: Number(row["entry_count"])
52815
+ }] : [];
52816
+ });
52817
+ } finally {
52818
+ db.close();
52819
+ }
52820
+ };
52821
+ /**
52822
+ * Deletes the given namespaces and compacts the file, returning how many bytes
52823
+ * that gave back. Entries are content-derived, so removing them costs at most
52824
+ * a re-scan of the blobs involved — never any recorded data.
52825
+ */
52826
+ var pruneBlobCacheNamespaces = (repoRoot, namespaces) => {
52827
+ if (namespaces.length === 0) return 0;
52828
+ const sizeBefore = blobCacheSizeBytes(repoRoot);
52829
+ const db = openForMaintenance(repoRoot);
52830
+ if (!db) return 0;
52831
+ try {
52832
+ const deleteNamespace = db.prepare("DELETE FROM blob_results WHERE collector = ? AND cache_key = ?");
52833
+ db.exec("BEGIN");
52834
+ try {
52835
+ for (const { collector, cacheKey } of namespaces) deleteNamespace.run(collector, cacheKey);
52836
+ db.exec("COMMIT");
52837
+ } catch (error) {
52838
+ db.exec("ROLLBACK");
52839
+ throw error;
52840
+ }
52841
+ db.exec("VACUUM");
52842
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
52843
+ } finally {
52844
+ db.close();
52845
+ }
52846
+ return Math.max(0, sizeBefore - blobCacheSizeBytes(repoRoot));
52847
+ };
52774
52848
  //#endregion
52775
52849
  //#region src/lib/collectors/tree-files.ts
52776
52850
  /**
@@ -54459,9 +54533,12 @@ var buildPlan = (repoRoot) => gen(function* () {
54459
54533
  "rev-list",
54460
54534
  "HEAD"
54461
54535
  ])).split("\n").filter(Boolean));
54536
+ const firstParentShas = yield* listFirstParentShas(repoRoot);
54462
54537
  const config = yield* loadConfig(repoRoot);
54463
54538
  const currentCacheKeys = new Map(builtInCollectors.map((collector) => [collector.name, collectorCacheKey(collector, config)]));
54539
+ const snapshotCollectorNames = new Set(builtInCollectors.filter((collector) => describesTreeState(collector)).map((collector) => collector.name));
54464
54540
  const unreachableShas = [];
54541
+ const offMainlineOutputs = [];
54465
54542
  const staleOutputs = [];
54466
54543
  const countsByCollector = /* @__PURE__ */ new Map();
54467
54544
  for (const sha of catalogShas) {
@@ -54472,6 +54549,10 @@ var buildPlan = (repoRoot) => gen(function* () {
54472
54549
  const collectorNames = yield* readdirIfExists(path.join(commitsPath, sha));
54473
54550
  for (const collectorName of collectorNames) {
54474
54551
  countsByCollector.set(collectorName, (countsByCollector.get(collectorName) ?? 0) + 1);
54552
+ if (!firstParentShas.has(sha) && snapshotCollectorNames.has(collectorName)) offMainlineOutputs.push({
54553
+ sha,
54554
+ collectorName
54555
+ });
54475
54556
  const currentCacheKey = currentCacheKeys.get(collectorName);
54476
54557
  if (currentCacheKey === void 0) {
54477
54558
  staleOutputs.push({
@@ -54492,12 +54573,18 @@ var buildPlan = (repoRoot) => gen(function* () {
54492
54573
  });
54493
54574
  }
54494
54575
  }
54576
+ const liveNamespaces = new Set([...currentCacheKeys].map(([collectorName, cacheKey]) => `${collectorName}:${cacheKey}`));
54495
54577
  return {
54496
54578
  catalogPath,
54497
54579
  commitsPath,
54498
54580
  unreachableShas,
54581
+ offMainlineOutputs,
54499
54582
  staleOutputs,
54500
- countsByCollector
54583
+ countsByCollector,
54584
+ staleCacheNamespaces: (yield* try_({
54585
+ try: () => listBlobCacheNamespaces(repoRoot),
54586
+ catch: toError$2
54587
+ })).filter((namespace) => !liveNamespaces.has(`${namespace.collector}:${namespace.cacheKey}`))
54501
54588
  };
54502
54589
  });
54503
54590
  var removePaths = (paths, dryRun) => dryRun ? void_$1 : forEach$1(paths, (target) => tryPromise({
@@ -54528,11 +54615,30 @@ var pruneEmptyCommitDirs = (commitsPath) => gen(function* () {
54528
54615
  }
54529
54616
  return pruned;
54530
54617
  });
54531
- var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes = false }) => gen(function* () {
54532
- const plan = yield* buildPlan(yield* resolveRepoRoot(repoPath));
54618
+ var staleCacheEntryCount = (plan) => plan.staleCacheNamespaces.reduce((total, namespace) => total + namespace.entryCount, 0);
54619
+ /** Human-readable size, e.g. "3.4 MB". */
54620
+ var formatBytes = (bytes) => {
54621
+ const units = [
54622
+ "B",
54623
+ "KB",
54624
+ "MB",
54625
+ "GB"
54626
+ ];
54627
+ let value = bytes;
54628
+ let unitIndex = 0;
54629
+ while (value >= 1024 && unitIndex < units.length - 1) {
54630
+ value /= 1024;
54631
+ unitIndex += 1;
54632
+ }
54633
+ return `${unitIndex === 0 ? value : value.toFixed(1)} ${units[unitIndex]}`;
54634
+ };
54635
+ var runGc = ({ repoPath, unreachable, offMainline, stale, collectorNames, dryRun = false, yes = false }) => gen(function* () {
54636
+ const repoRoot = yield* resolveRepoRoot(repoPath);
54637
+ const plan = yield* buildPlan(repoRoot);
54533
54638
  const requestedCollectors = (collectorNames ?? "").split(",").map((name) => name.trim()).filter(Boolean);
54534
- const anyFlagGiven = unreachable === true || stale === true || requestedCollectors.length > 0;
54639
+ const anyFlagGiven = unreachable === true || offMainline === true || stale === true || requestedCollectors.length > 0;
54535
54640
  let removeUnreachable = unreachable === true;
54641
+ let removeOffMainline = offMainline === true;
54536
54642
  let removeStale = stale === true;
54537
54643
  let collectorsToRemove = requestedCollectors;
54538
54644
  if (!anyFlagGiven) {
@@ -54541,10 +54647,17 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54541
54647
  title: `Data for ${plan.unreachableShas.length} commits no longer reachable from HEAD`,
54542
54648
  value: { kind: "unreachable" }
54543
54649
  });
54544
- if (plan.staleOutputs.length > 0) choices.push({
54545
- title: `${plan.staleOutputs.length} stale collector outputs (old versions or removed collectors)`,
54546
- value: { kind: "stale" }
54650
+ if (plan.offMainlineOutputs.length > 0) choices.push({
54651
+ title: `${plan.offMainlineOutputs.length} snapshot outputs taken off HEAD's first-parent chain (left out of the cube)`,
54652
+ value: { kind: "offMainline" }
54547
54653
  });
54654
+ if (plan.staleOutputs.length > 0 || plan.staleCacheNamespaces.length > 0) {
54655
+ const parts = [...plan.staleOutputs.length > 0 ? [`${plan.staleOutputs.length} collector outputs`] : [], ...plan.staleCacheNamespaces.length > 0 ? [`${staleCacheEntryCount(plan)} blob-cache entries`] : []];
54656
+ choices.push({
54657
+ title: `Stale ${parts.join(" and ")} (old versions or removed collectors)`,
54658
+ value: { kind: "stale" }
54659
+ });
54660
+ }
54548
54661
  for (const [name, count] of [...plan.countsByCollector.entries()].toSorted(([left], [right]) => left.localeCompare(right))) choices.push({
54549
54662
  title: `All ${count} outputs of collector "${name}"`,
54550
54663
  value: {
@@ -54565,19 +54678,26 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54565
54678
  return;
54566
54679
  }
54567
54680
  removeUnreachable = selected.some((action) => action.kind === "unreachable");
54681
+ removeOffMainline = selected.some((action) => action.kind === "offMainline");
54568
54682
  removeStale = selected.some((action) => action.kind === "stale");
54569
54683
  collectorsToRemove = selected.flatMap((action) => action.kind === "collector" ? [action.name] : []);
54570
54684
  }
54571
- const targets = [];
54685
+ const targets = /* @__PURE__ */ new Set();
54572
54686
  const reportLines = [];
54573
54687
  if (removeUnreachable && plan.unreachableShas.length > 0) {
54574
- targets.push(...plan.unreachableShas.map((sha) => path.join(plan.commitsPath, sha)));
54688
+ for (const sha of plan.unreachableShas) targets.add(path.join(plan.commitsPath, sha));
54575
54689
  reportLines.push(`${plan.unreachableShas.length} unreachable commit folders`);
54576
54690
  }
54691
+ if (removeOffMainline && plan.offMainlineOutputs.length > 0) {
54692
+ for (const output of plan.offMainlineOutputs) targets.add(path.join(plan.commitsPath, output.sha, output.collectorName));
54693
+ reportLines.push(`${plan.offMainlineOutputs.length} off-mainline snapshot outputs`);
54694
+ }
54577
54695
  if (removeStale && plan.staleOutputs.length > 0) {
54578
- targets.push(...plan.staleOutputs.map((output) => path.join(plan.commitsPath, output.sha, output.collectorName)));
54696
+ for (const output of plan.staleOutputs) targets.add(path.join(plan.commitsPath, output.sha, output.collectorName));
54579
54697
  reportLines.push(`${plan.staleOutputs.length} stale collector outputs`);
54580
54698
  }
54699
+ const pruneCacheNamespaces = removeStale && plan.staleCacheNamespaces.length > 0;
54700
+ if (pruneCacheNamespaces) reportLines.push(`${staleCacheEntryCount(plan)} stale blob-cache entries`);
54581
54701
  for (const name of collectorsToRemove) {
54582
54702
  const count = plan.countsByCollector.get(name) ?? 0;
54583
54703
  if (count === 0) {
@@ -54585,10 +54705,10 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54585
54705
  continue;
54586
54706
  }
54587
54707
  const shas = yield* readdirIfExists(plan.commitsPath);
54588
- for (const sha of shas) targets.push(path.join(plan.commitsPath, sha, name));
54708
+ for (const sha of shas) targets.add(path.join(plan.commitsPath, sha, name));
54589
54709
  reportLines.push(`${count} outputs of collector "${name}"`);
54590
54710
  }
54591
- if (targets.length === 0) {
54711
+ if (targets.size === 0 && !pruneCacheNamespaces) {
54592
54712
  yield* log("Nothing to garbage-collect.");
54593
54713
  return;
54594
54714
  }
@@ -54603,22 +54723,28 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54603
54723
  return;
54604
54724
  }
54605
54725
  }
54606
- yield* removePaths(targets, false);
54726
+ yield* removePaths([...targets], false);
54607
54727
  const pruned = yield* pruneEmptyCommitDirs(plan.commitsPath);
54608
- yield* log(`${summary}${pruned > 0 ? ` Pruned ${pruned} empty commit folders.` : ""} Run \`repo-dive index\` to refresh rollups.`);
54728
+ const cacheBytesReclaimed = pruneCacheNamespaces ? yield* try_({
54729
+ try: () => pruneBlobCacheNamespaces(repoRoot, plan.staleCacheNamespaces),
54730
+ catch: toError$2
54731
+ }) : 0;
54732
+ yield* log(`${summary}${pruned > 0 ? ` Pruned ${pruned} empty commit folders.` : ""}${cacheBytesReclaimed > 0 ? ` Blob cache shrank by ${formatBytes(cacheBytesReclaimed)}.` : ""} Run \`repo-dive index\` to refresh rollups.`);
54609
54733
  }).pipe(provide(layer$1));
54610
54734
  //#endregion
54611
54735
  //#region src/commands/gc.ts
54612
54736
  var gcCommand = make$3("gc", {
54613
54737
  repoPath: string("repo").pipe(withDefault("."), withDescription$1("Path to the git repository whose catalog to clean (defaults to the current directory)")),
54614
54738
  unreachable: boolean("unreachable").pipe(withDescription$1("Remove data for commits no longer reachable from HEAD")),
54615
- stale: boolean("stale").pipe(withDescription$1("Remove outputs written by old collector versions or by collectors that no longer exist")),
54739
+ offMainline: boolean("off-mainline").pipe(withDescription$1("Remove tree snapshots stored under commits that are not on HEAD's first-parent chain")),
54740
+ stale: boolean("stale").pipe(withDescription$1("Remove catalog outputs and blob-cache entries written by old collector versions or by collectors that no longer exist")),
54616
54741
  collectorNames: optional$1(string("collectors").pipe(withDescription$1("Comma-separated collector names whose outputs should be removed entirely"))),
54617
54742
  dryRun: boolean("dry-run").pipe(withDescription$1("Report what would be removed without removing it")),
54618
54743
  yes: boolean("yes").pipe(withAlias("y"), withDescription$1("Skip the confirmation prompt"))
54619
- }).pipe(withDescription("Garbage-collect the catalog: unreachable commits, stale versions or whole collectors (interactive without flags)"), withHandler((config) => runGc({
54744
+ }).pipe(withDescription("Garbage-collect the catalog: unreachable commits, off-mainline snapshots, stale versions or whole collectors (interactive without flags)"), withHandler((config) => runGc({
54620
54745
  repoPath: config.repoPath,
54621
54746
  unreachable: config.unreachable,
54747
+ offMainline: config.offMainline,
54622
54748
  stale: config.stale,
54623
54749
  collectorNames: getOrUndefined$1(config.collectorNames),
54624
54750
  dryRun: config.dryRun,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-dive",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Dive into a git repository's history: per-commit snapshots, an indexed metrics catalog and an interactive dashboard",
5
5
  "keywords": [
6
6
  "git",
@@ -52,6 +52,7 @@
52
52
  },
53
53
  "prettier": "@kachkaev/prettier-config",
54
54
  "devDependencies": {
55
+ "@changesets/changelog-github": "0.7.0",
55
56
  "@changesets/cli": "2.31.1",
56
57
  "@changesets/config": "3.1.4",
57
58
  "@effect/platform-node": "4.0.0-beta.99",