repo-dive 0.4.0 → 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.
package/README.md CHANGED
@@ -68,8 +68,8 @@ export default defineConfig({
68
68
  ["alice@work.example", "alice@personal.example"],
69
69
  // Rich form: a display name, a profile link and an explicit kind.
70
70
  {
71
- emails: ["bob@work.example", "12345+bob@users.noreply.github.com"],
72
71
  displayName: "Bob",
72
+ emails: ["bob@work.example", "12345+bob@users.noreply.github.com"],
73
73
  url: "https://github.com/bob",
74
74
  },
75
75
  ],
@@ -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.0",
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",
@@ -52364,6 +52365,15 @@ var recordAt = (value, key) => asRecord$1(asRecord$1(value)?.[key]) ?? {};
52364
52365
  var arrayAt = (value, key) => asArray(asRecord$1(value)?.[key]);
52365
52366
  //#endregion
52366
52367
  //#region src/lib/collectors/types.ts
52368
+ /**
52369
+ * Whether a collector describes the *state of the tree* at a commit rather than
52370
+ * facts about the commit itself. Such snapshots are only meaningful on the
52371
+ * first-parent chain: a commit that lives on a side branch — or that arrived
52372
+ * with a foreign history absorbed by an unrelated-histories merge — carries a
52373
+ * tree that was never the repository's state, so charting it puts a cliff into
52374
+ * every timeline.
52375
+ */
52376
+ var describesTreeState = (collector) => collector.strategy !== "log";
52367
52377
  /** File extension used as a category key, e.g. ".ts"; files without one map to "(none)". */
52368
52378
  var extensionOf = (filePath) => {
52369
52379
  const basename = filePath.split("/").at(-1) ?? "";
@@ -52714,12 +52724,12 @@ var writeCollectorOutput = ({ catalog, sha, collector, cacheKey, output, duratio
52714
52724
  var openCaches = /* @__PURE__ */ new Map();
52715
52725
  /** Bump when the table shape changes; a mismatch drops the cache and rebuilds. */
52716
52726
  var schemaVersion = 2;
52727
+ var blobCachePath = (repoRoot) => path.join(repoRoot, catalogDirName, "cache", "blob-cache.sqlite");
52717
52728
  var getBlobCache = (repoRoot) => {
52718
52729
  const existing = openCaches.get(repoRoot);
52719
- if (existing) return existing;
52720
- const cacheDir = path.join(repoRoot, catalogDirName, "cache");
52721
- mkdirSync(cacheDir, { recursive: true });
52722
- 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));
52723
52733
  db.exec("PRAGMA journal_mode = WAL");
52724
52734
  const storedSchema = db.prepare("PRAGMA user_version").get();
52725
52735
  if (Number(storedSchema?.["user_version"] ?? 0) !== schemaVersion) {
@@ -52759,9 +52769,82 @@ var getBlobCache = (repoRoot) => {
52759
52769
  }
52760
52770
  }
52761
52771
  };
52762
- openCaches.set(repoRoot, cache);
52772
+ openCaches.set(repoRoot, {
52773
+ db,
52774
+ cache
52775
+ });
52763
52776
  return cache;
52764
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
+ };
52765
52848
  //#endregion
52766
52849
  //#region src/lib/collectors/tree-files.ts
52767
52850
  /**
@@ -53546,12 +53629,57 @@ var resolveCollectors = (names) => {
53546
53629
  return resolved;
53547
53630
  };
53548
53631
  //#endregion
53632
+ //#region src/lib/sampling.ts
53633
+ var parseSamplingPolicy = (input) => {
53634
+ if (input === "all" || input === "weekly" || input === "monthly" || input === "quarterly") return input;
53635
+ const everyNthMatch = /^every-nth:(\d+)$/.exec(input);
53636
+ if (everyNthMatch?.[1]) {
53637
+ const everyNth = Number(everyNthMatch[1]);
53638
+ if (everyNth >= 1) return { everyNth };
53639
+ }
53640
+ return /* @__PURE__ */ new Error(`Unknown sampling policy: ${input}. Expected all, weekly, monthly, quarterly or every-nth:<n>.`);
53641
+ };
53642
+ /** How a policy is spelled in CLI output (`collectors`, `scan`, `status`). */
53643
+ var samplingLabel = (policy) => typeof policy === "object" ? `every-nth:${policy.everyNth}` : policy;
53644
+ var isoWeekOf = (date) => {
53645
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
53646
+ const dayOfWeek = utc.getUTCDay() || 7;
53647
+ utc.setUTCDate(utc.getUTCDate() + 4 - dayOfWeek);
53648
+ const yearStart = Date.UTC(utc.getUTCFullYear(), 0, 1);
53649
+ const week = Math.ceil(((utc.getTime() - yearStart) / 864e5 + 1) / 7);
53650
+ return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
53651
+ };
53652
+ var bucketOf = (policy, authorDate) => {
53653
+ const date = new Date(authorDate);
53654
+ if (policy === "weekly") return isoWeekOf(date);
53655
+ const month = date.getUTCMonth();
53656
+ return policy === "monthly" ? `${date.getUTCFullYear()}-${String(month + 1).padStart(2, "0")}` : `${date.getUTCFullYear()}-Q${Math.floor(month / 3) + 1}`;
53657
+ };
53658
+ /**
53659
+ * Picks the sampled subset of commits for a policy. `commits` must be ordered
53660
+ * newest first (as `git log` emits them); period policies keep the newest
53661
+ * commit of each period, so HEAD is always included.
53662
+ */
53663
+ var sampleCommits = (commits, policy) => {
53664
+ if (policy === "all") return [...commits];
53665
+ if (typeof policy === "object") return commits.filter((_, index) => index % policy.everyNth === 0);
53666
+ const seenBuckets = /* @__PURE__ */ new Set();
53667
+ const sampled = [];
53668
+ for (const commit of commits) {
53669
+ const bucket = bucketOf(policy, commit.authorDate);
53670
+ if (!seenBuckets.has(bucket)) {
53671
+ seenBuckets.add(bucket);
53672
+ sampled.push(commit);
53673
+ }
53674
+ }
53675
+ return sampled;
53676
+ };
53677
+ //#endregion
53549
53678
  //#region src/commands/collectors.ts
53550
- var samplingLabel$1 = (policy) => typeof policy === "object" ? `every-nth:${policy.everyNth}` : policy;
53551
53679
  var collectorsCommand = make$3("collectors").pipe(withDescription("List available collectors, their versions, strategies and default sampling"), withHandler(() => gen(function* () {
53552
53680
  for (const collector of builtInCollectors) yield* log([
53553
53681
  `${collector.name} (v${collector.version})`,
53554
- ` strategy: ${collector.strategy}, sampling: ${samplingLabel$1(collector.defaultSampling)}`,
53682
+ ` strategy: ${collector.strategy}, sampling: ${samplingLabel(collector.defaultSampling)}`,
53555
53683
  ` ${collector.description}`
53556
53684
  ].join("\n"));
53557
53685
  })));
@@ -53817,6 +53945,7 @@ var runIndex = ({ repoPath }) => gen(function* () {
53817
53945
  const commitsPath = path.join(catalogPath, "commits");
53818
53946
  const registry = new Map(builtInCollectors.map((collector) => [collector.name, collector]));
53819
53947
  const gitCommits = yield* listCommits(repoRoot);
53948
+ const firstParentShas = yield* listFirstParentShas(repoRoot);
53820
53949
  const catalogShas = new Set(yield* tryPromise({
53821
53950
  try: async () => {
53822
53951
  try {
@@ -53830,10 +53959,12 @@ var runIndex = ({ repoPath }) => gen(function* () {
53830
53959
  const orderedCommits = gitCommits.toReversed().filter((commit) => catalogShas.has(commit.hash));
53831
53960
  if (orderedCommits.length === 0) return yield* fail$3(/* @__PURE__ */ new Error(`No collected commits found in ${commitsPath} — run \`repo-dive scan\` first.`));
53832
53961
  let unknownCollectorDirs = 0;
53962
+ let offMainlineSnapshots = 0;
53833
53963
  const commitFacts = [];
53834
53964
  yield* forEach$1(orderedCommits, (commit) => tryPromise({
53835
53965
  try: async () => {
53836
53966
  const commitDir = path.join(commitsPath, commit.hash);
53967
+ const onMainline = firstParentShas.has(commit.hash);
53837
53968
  const factsByCollector = /* @__PURE__ */ new Map();
53838
53969
  for (const collectorName of await readdir(commitDir)) {
53839
53970
  const collector = registry.get(collectorName);
@@ -53841,6 +53972,10 @@ var runIndex = ({ repoPath }) => gen(function* () {
53841
53972
  unknownCollectorDirs += 1;
53842
53973
  continue;
53843
53974
  }
53975
+ if (!onMainline && describesTreeState(collector)) {
53976
+ offMainlineSnapshots += 1;
53977
+ continue;
53978
+ }
53844
53979
  const raw = JSON.parse(await readFile(path.join(commitDir, collectorName, "output.json"), "utf8"));
53845
53980
  factsByCollector.set(collectorName, collector.normalize(raw));
53846
53981
  }
@@ -53882,7 +54017,8 @@ var runIndex = ({ repoPath }) => gen(function* () {
53882
54017
  `Indexed ${commitFacts.length} commits into ${factCount} facts.`,
53883
54018
  `Cube: ${dbPath}`,
53884
54019
  `Dashboard data: ${dashboardPath}`,
53885
- ...unknownCollectorDirs > 0 ? [`Skipped ${unknownCollectorDirs} outputs from unknown collectors (see \`gc --stale\`).`] : []
54020
+ ...unknownCollectorDirs > 0 ? [`Skipped ${unknownCollectorDirs} outputs from unknown collectors (see \`gc --stale\`).`] : [],
54021
+ ...offMainlineSnapshots > 0 ? [`Skipped ${offMainlineSnapshots} tree snapshots taken off HEAD's first-parent chain.`] : []
53886
54022
  ].join("\n"));
53887
54023
  });
53888
54024
  //#endregion
@@ -54035,50 +54171,6 @@ var loadConfig = (repoRoot) => gen(function* () {
54035
54171
  });
54036
54172
  });
54037
54173
  //#endregion
54038
- //#region src/lib/sampling.ts
54039
- var parseSamplingPolicy = (input) => {
54040
- if (input === "all" || input === "weekly" || input === "monthly" || input === "quarterly") return input;
54041
- const everyNthMatch = /^every-nth:(\d+)$/.exec(input);
54042
- if (everyNthMatch?.[1]) {
54043
- const everyNth = Number(everyNthMatch[1]);
54044
- if (everyNth >= 1) return { everyNth };
54045
- }
54046
- return /* @__PURE__ */ new Error(`Unknown sampling policy: ${input}. Expected all, weekly, monthly, quarterly or every-nth:<n>.`);
54047
- };
54048
- var isoWeekOf = (date) => {
54049
- const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
54050
- const dayOfWeek = utc.getUTCDay() || 7;
54051
- utc.setUTCDate(utc.getUTCDate() + 4 - dayOfWeek);
54052
- const yearStart = Date.UTC(utc.getUTCFullYear(), 0, 1);
54053
- const week = Math.ceil(((utc.getTime() - yearStart) / 864e5 + 1) / 7);
54054
- return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
54055
- };
54056
- var bucketOf = (policy, authorDate) => {
54057
- const date = new Date(authorDate);
54058
- if (policy === "weekly") return isoWeekOf(date);
54059
- const month = date.getUTCMonth();
54060
- return policy === "monthly" ? `${date.getUTCFullYear()}-${String(month + 1).padStart(2, "0")}` : `${date.getUTCFullYear()}-Q${Math.floor(month / 3) + 1}`;
54061
- };
54062
- /**
54063
- * Picks the sampled subset of commits for a policy. `commits` must be ordered
54064
- * newest first (as `git log` emits them); period policies keep the newest
54065
- * commit of each period, so HEAD is always included.
54066
- */
54067
- var sampleCommits = (commits, policy) => {
54068
- if (policy === "all") return [...commits];
54069
- if (typeof policy === "object") return commits.filter((_, index) => index % policy.everyNth === 0);
54070
- const seenBuckets = /* @__PURE__ */ new Set();
54071
- const sampled = [];
54072
- for (const commit of commits) {
54073
- const bucket = bucketOf(policy, commit.authorDate);
54074
- if (!seenBuckets.has(bucket)) {
54075
- seenBuckets.add(bucket);
54076
- sampled.push(commit);
54077
- }
54078
- }
54079
- return sampled;
54080
- };
54081
- //#endregion
54082
54174
  //#region src/lib/worktree.ts
54083
54175
  var toError$4 = (error) => error instanceof Error ? error : new Error(String(error));
54084
54176
  /**
@@ -54157,6 +54249,18 @@ var listCommits = (repoRoot) => runGit([
54157
54249
  "log",
54158
54250
  `--format=${gitLogFormat}`
54159
54251
  ]).pipe(catch_$2((error) => error instanceof GitCommandError && error.stderr.includes("does not have any commits yet") ? succeed$3("") : fail$3(error)), map$7(parseGitLog));
54252
+ /**
54253
+ * Shas on HEAD's first-parent chain, i.e. the states the repository actually
54254
+ * passed through. See {@link describesTreeState} for why snapshot collectors
54255
+ * are restricted to them.
54256
+ */
54257
+ var listFirstParentShas = (repoRoot) => runGit([
54258
+ "-C",
54259
+ repoRoot,
54260
+ "log",
54261
+ "--first-parent",
54262
+ "--format=%H"
54263
+ ]).pipe(catch_$2((error) => error instanceof GitCommandError && error.stderr.includes("does not have any commits yet") ? succeed$3("") : fail$3(error)), map$7((stdout) => new Set(stdout.split("\n").filter(Boolean))));
54160
54264
  var summarizeCommits = (commits) => {
54161
54265
  const authorEmails = new Set(commits.map((commit) => commit.authorEmail));
54162
54266
  const dates = commits.map((commit) => commit.authorDate).filter(Boolean).toSorted();
@@ -54220,7 +54324,6 @@ var collectCommit = ({ catalog, sha, collectors, cacheKeyOf, force, failures })
54220
54324
  skipped
54221
54325
  };
54222
54326
  });
54223
- var samplingLabel = (policy) => typeof policy === "object" ? `every-nth:${policy.everyNth}` : policy;
54224
54327
  var runScan = ({ repoPath, collectorNames, maxCommits, sample, force = false }) => gen(function* () {
54225
54328
  const collectors = resolveCollectors(collectorNames);
54226
54329
  if (collectors instanceof Error) return yield* fail$3(collectors);
@@ -54238,12 +54341,14 @@ var runScan = ({ repoPath, collectorNames, maxCommits, sample, force = false })
54238
54341
  const config = yield* loadConfig(repoRoot);
54239
54342
  const cacheKeys = new Map(collectors.map((collector) => [collector.name, collectorCacheKey(collector, config)]));
54240
54343
  const cacheKeyOf = (collector) => cacheKeys.get(collector.name) ?? collectorCacheKey(collector, config);
54344
+ const firstParentShas = yield* listFirstParentShas(repoRoot);
54241
54345
  const plans = collectors.map((collector) => {
54242
54346
  const policy = sampleOverride ?? collector.defaultSampling;
54347
+ const candidates = describesTreeState(collector) ? selected.filter((commit) => firstParentShas.has(commit.hash)) : selected;
54243
54348
  return {
54244
54349
  collector,
54245
54350
  policy,
54246
- shas: new Set(sampleCommits(selected, policy).map((commit) => commit.hash))
54351
+ shas: new Set(sampleCommits(candidates, policy).map((commit) => commit.hash))
54247
54352
  };
54248
54353
  });
54249
54354
  yield* log(`Plan: ${plans.map((plan) => `${plan.collector.name} → ${plan.shas.size} commits (${samplingLabel(plan.policy)})`).join(", ")}`);
@@ -54428,9 +54533,12 @@ var buildPlan = (repoRoot) => gen(function* () {
54428
54533
  "rev-list",
54429
54534
  "HEAD"
54430
54535
  ])).split("\n").filter(Boolean));
54536
+ const firstParentShas = yield* listFirstParentShas(repoRoot);
54431
54537
  const config = yield* loadConfig(repoRoot);
54432
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));
54433
54540
  const unreachableShas = [];
54541
+ const offMainlineOutputs = [];
54434
54542
  const staleOutputs = [];
54435
54543
  const countsByCollector = /* @__PURE__ */ new Map();
54436
54544
  for (const sha of catalogShas) {
@@ -54441,6 +54549,10 @@ var buildPlan = (repoRoot) => gen(function* () {
54441
54549
  const collectorNames = yield* readdirIfExists(path.join(commitsPath, sha));
54442
54550
  for (const collectorName of collectorNames) {
54443
54551
  countsByCollector.set(collectorName, (countsByCollector.get(collectorName) ?? 0) + 1);
54552
+ if (!firstParentShas.has(sha) && snapshotCollectorNames.has(collectorName)) offMainlineOutputs.push({
54553
+ sha,
54554
+ collectorName
54555
+ });
54444
54556
  const currentCacheKey = currentCacheKeys.get(collectorName);
54445
54557
  if (currentCacheKey === void 0) {
54446
54558
  staleOutputs.push({
@@ -54461,12 +54573,18 @@ var buildPlan = (repoRoot) => gen(function* () {
54461
54573
  });
54462
54574
  }
54463
54575
  }
54576
+ const liveNamespaces = new Set([...currentCacheKeys].map(([collectorName, cacheKey]) => `${collectorName}:${cacheKey}`));
54464
54577
  return {
54465
54578
  catalogPath,
54466
54579
  commitsPath,
54467
54580
  unreachableShas,
54581
+ offMainlineOutputs,
54468
54582
  staleOutputs,
54469
- countsByCollector
54583
+ countsByCollector,
54584
+ staleCacheNamespaces: (yield* try_({
54585
+ try: () => listBlobCacheNamespaces(repoRoot),
54586
+ catch: toError$2
54587
+ })).filter((namespace) => !liveNamespaces.has(`${namespace.collector}:${namespace.cacheKey}`))
54470
54588
  };
54471
54589
  });
54472
54590
  var removePaths = (paths, dryRun) => dryRun ? void_$1 : forEach$1(paths, (target) => tryPromise({
@@ -54497,11 +54615,30 @@ var pruneEmptyCommitDirs = (commitsPath) => gen(function* () {
54497
54615
  }
54498
54616
  return pruned;
54499
54617
  });
54500
- var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes = false }) => gen(function* () {
54501
- 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);
54502
54638
  const requestedCollectors = (collectorNames ?? "").split(",").map((name) => name.trim()).filter(Boolean);
54503
- const anyFlagGiven = unreachable === true || stale === true || requestedCollectors.length > 0;
54639
+ const anyFlagGiven = unreachable === true || offMainline === true || stale === true || requestedCollectors.length > 0;
54504
54640
  let removeUnreachable = unreachable === true;
54641
+ let removeOffMainline = offMainline === true;
54505
54642
  let removeStale = stale === true;
54506
54643
  let collectorsToRemove = requestedCollectors;
54507
54644
  if (!anyFlagGiven) {
@@ -54510,10 +54647,17 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54510
54647
  title: `Data for ${plan.unreachableShas.length} commits no longer reachable from HEAD`,
54511
54648
  value: { kind: "unreachable" }
54512
54649
  });
54513
- if (plan.staleOutputs.length > 0) choices.push({
54514
- title: `${plan.staleOutputs.length} stale collector outputs (old versions or removed collectors)`,
54515
- 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" }
54516
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
+ }
54517
54661
  for (const [name, count] of [...plan.countsByCollector.entries()].toSorted(([left], [right]) => left.localeCompare(right))) choices.push({
54518
54662
  title: `All ${count} outputs of collector "${name}"`,
54519
54663
  value: {
@@ -54534,19 +54678,26 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54534
54678
  return;
54535
54679
  }
54536
54680
  removeUnreachable = selected.some((action) => action.kind === "unreachable");
54681
+ removeOffMainline = selected.some((action) => action.kind === "offMainline");
54537
54682
  removeStale = selected.some((action) => action.kind === "stale");
54538
54683
  collectorsToRemove = selected.flatMap((action) => action.kind === "collector" ? [action.name] : []);
54539
54684
  }
54540
- const targets = [];
54685
+ const targets = /* @__PURE__ */ new Set();
54541
54686
  const reportLines = [];
54542
54687
  if (removeUnreachable && plan.unreachableShas.length > 0) {
54543
- 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));
54544
54689
  reportLines.push(`${plan.unreachableShas.length} unreachable commit folders`);
54545
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
+ }
54546
54695
  if (removeStale && plan.staleOutputs.length > 0) {
54547
- 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));
54548
54697
  reportLines.push(`${plan.staleOutputs.length} stale collector outputs`);
54549
54698
  }
54699
+ const pruneCacheNamespaces = removeStale && plan.staleCacheNamespaces.length > 0;
54700
+ if (pruneCacheNamespaces) reportLines.push(`${staleCacheEntryCount(plan)} stale blob-cache entries`);
54550
54701
  for (const name of collectorsToRemove) {
54551
54702
  const count = plan.countsByCollector.get(name) ?? 0;
54552
54703
  if (count === 0) {
@@ -54554,10 +54705,10 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54554
54705
  continue;
54555
54706
  }
54556
54707
  const shas = yield* readdirIfExists(plan.commitsPath);
54557
- 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));
54558
54709
  reportLines.push(`${count} outputs of collector "${name}"`);
54559
54710
  }
54560
- if (targets.length === 0) {
54711
+ if (targets.size === 0 && !pruneCacheNamespaces) {
54561
54712
  yield* log("Nothing to garbage-collect.");
54562
54713
  return;
54563
54714
  }
@@ -54572,22 +54723,28 @@ var runGc = ({ repoPath, unreachable, stale, collectorNames, dryRun = false, yes
54572
54723
  return;
54573
54724
  }
54574
54725
  }
54575
- yield* removePaths(targets, false);
54726
+ yield* removePaths([...targets], false);
54576
54727
  const pruned = yield* pruneEmptyCommitDirs(plan.commitsPath);
54577
- 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.`);
54578
54733
  }).pipe(provide(layer$1));
54579
54734
  //#endregion
54580
54735
  //#region src/commands/gc.ts
54581
54736
  var gcCommand = make$3("gc", {
54582
54737
  repoPath: string("repo").pipe(withDefault("."), withDescription$1("Path to the git repository whose catalog to clean (defaults to the current directory)")),
54583
54738
  unreachable: boolean("unreachable").pipe(withDescription$1("Remove data for commits no longer reachable from HEAD")),
54584
- 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")),
54585
54741
  collectorNames: optional$1(string("collectors").pipe(withDescription$1("Comma-separated collector names whose outputs should be removed entirely"))),
54586
54742
  dryRun: boolean("dry-run").pipe(withDescription$1("Report what would be removed without removing it")),
54587
54743
  yes: boolean("yes").pipe(withAlias("y"), withDescription$1("Skip the confirmation prompt"))
54588
- }).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({
54589
54745
  repoPath: config.repoPath,
54590
54746
  unreachable: config.unreachable,
54747
+ offMainline: config.offMainline,
54591
54748
  stale: config.stale,
54592
54749
  collectorNames: getOrUndefined$1(config.collectorNames),
54593
54750
  dryRun: config.dryRun,
@@ -59199,6 +59356,7 @@ var exists = (filePath) => promise(() => access(filePath).then(() => true, () =>
59199
59356
  var runStatus = ({ repoPath }) => gen(function* () {
59200
59357
  const repoRoot = yield* resolveRepoRoot(repoPath);
59201
59358
  const commits = yield* listCommits(repoRoot);
59359
+ const firstParentShas = yield* listFirstParentShas(repoRoot);
59202
59360
  const config = yield* loadConfig(repoRoot);
59203
59361
  const catalogPath = path.join(repoRoot, catalogDirName);
59204
59362
  if (!(yield* exists(path.join(catalogPath, "catalog.json")))) {
@@ -59220,15 +59378,16 @@ var runStatus = ({ repoPath }) => gen(function* () {
59220
59378
  `Catalog: ${catalogPath}`
59221
59379
  ];
59222
59380
  for (const collector of builtInCollectors) {
59381
+ const target = sampleCommits(describesTreeState(collector) ? commits.filter((commit) => firstParentShas.has(commit.hash)) : commits, collector.defaultSampling);
59223
59382
  let collected = 0;
59224
59383
  const cacheKey = collectorCacheKey(collector, config);
59225
- yield* forEach$1(commits, (commit) => isCollected(catalog, commit.hash, collector, cacheKey).pipe(map$7((done) => {
59384
+ yield* forEach$1(target, (commit) => isCollected(catalog, commit.hash, collector, cacheKey).pipe(map$7((done) => {
59226
59385
  if (done) collected += 1;
59227
59386
  })), {
59228
59387
  concurrency: 16,
59229
59388
  discard: true
59230
59389
  });
59231
- lines.push(` ${collector.name}: ${collected}/${commits.length} commits collected`);
59390
+ lines.push(collector.defaultSampling === "all" ? ` ${collector.name}: ${collected}/${target.length} commits collected` : ` ${collector.name}: ${collected}/${target.length} commits collected (${samplingLabel(collector.defaultSampling)} sample of ${commits.length})`);
59232
59391
  }
59233
59392
  yield* log(lines.join("\n"));
59234
59393
  });
package/dist/config.d.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  * ["alice@work.example", "alice@personal.example"],
16
16
  * // Rich form: a display name, a profile link and a kind.
17
17
  * {
18
- * emails: ["bob@work.example", "bob@personal.example"],
19
18
  * displayName: "Bob",
19
+ * emails: ["bob@work.example", "bob@personal.example"],
20
20
  * url: "https://github.com/bob",
21
21
  * },
22
22
  * ],
package/dist/config.js CHANGED
@@ -15,8 +15,8 @@
15
15
  * ["alice@work.example", "alice@personal.example"],
16
16
  * // Rich form: a display name, a profile link and a kind.
17
17
  * {
18
- * emails: ["bob@work.example", "bob@personal.example"],
19
18
  * displayName: "Bob",
19
+ * emails: ["bob@work.example", "bob@personal.example"],
20
20
  * url: "https://github.com/bob",
21
21
  * },
22
22
  * ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-dive",
3
- "version": "0.4.0",
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",