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 +33 -1
- package/dist/cli.js +233 -74
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/package.json +2 -1
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.
|
|
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
|
-
|
|
52721
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
54501
|
-
|
|
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.
|
|
54514
|
-
title: `${plan.
|
|
54515
|
-
value: { kind: "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|