scopecairn 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,7 +149,8 @@ scopecairn agents list
149
149
  | `init [path]` | Initial indexing + Skill, Workflow, detected agent skills (`--agents …\|all`; never writes `AGENTS.md`) |
150
150
  | `scan` / `rebuild` / `clean` | Manual index management (`scan` incremental; `rebuild` from scratch; `clean` removes `.scopecairn/`) |
151
151
  | `status` / `doctor` | Index status (incl. adapters, invocations) and health checks |
152
- | `context "<task>"` | Main entry point: context + scope (`--escalate`, `--no-refresh`, `--mode NORMAL|FAST|SAFE|AUDIT`, `--max-tokens N`); a path task returns an orientation map |
152
+ | `context "<task>"` | Main entry point: context + scope (`--escalate`, `--no-refresh`, `--mode NORMAL|FAST|SAFE|AUDIT`, `--max-tokens N`, `--no-history`, `--with-diff`); a path task returns an orientation map |
153
+ | `history "<task>" [--limit N] [--json] [--with-diff]` | Analog commit lama paling mirip (BM25 + recency half-life 180 hari + co-change); co-change set ikut ke Optional di `context` |
153
154
  | `impact <path\|symbol>` | Change impact: direct, indirect, tests (via TESTS edges + static CALLS backward reachability), UI, queries, routes. Edge evidence tagged EXTRACTED/INFERRED/AMBIGUOUS |
154
155
  | `graph <symbol>` | Symbol relations (+ PageRank, community, cycle status) |
155
156
  | `path <from> <to>` | Shortest multi-hop call/dependency path between two symbols |
@@ -205,6 +206,16 @@ prompt → scopecairn context (refresh at start) → agent edits code
205
206
 
206
207
  Measure retrieval quality anytime: `scopecairn benchmark [--tune]`.
207
208
 
209
+ ## History privacy
210
+
211
+ `scan` mengindeks pesan commit git lokal ke tabel `commits`/`commit_files` di
212
+ `.scopecairn/scopecairn.db` (dilewati commit >40 file, lockfile, dan generated).
213
+ Isi commit **tidak** masuk `GRAPH.md`/`GRAPH.html`/`export` (sering di-commit dan
214
+ dibagikan) — riwayat hanya hidup di database lokal dan output `context`/`history`.
215
+ Pesan commit bisa memuat info sensitif; karena tidak keluar dari mesin, risikonya
216
+ kecil. Repo tanpa git atau clone dangkal tetap didukung (riwayat parsial/absen,
217
+ dilaporkan `doctor`).
218
+
208
219
  ## License
209
220
 
210
221
  MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ logError
4
+ } from "./chunk-OPA4TMAT.js";
5
+
6
+ // src/retrieval/tokenize.ts
7
+ var STOP = /* @__PURE__ */ new Set([
8
+ "the",
9
+ "a",
10
+ "an",
11
+ "of",
12
+ "to",
13
+ "in",
14
+ "for",
15
+ "on",
16
+ "with",
17
+ "and",
18
+ "or",
19
+ "src",
20
+ "lib",
21
+ "app",
22
+ "test",
23
+ "tests",
24
+ "spec"
25
+ ]);
26
+ function stemVariants(t) {
27
+ const out = /* @__PURE__ */ new Set([t]);
28
+ if (t.endsWith("ies") && t.length > 4) out.add(t.slice(0, -3) + "y");
29
+ else if (t.endsWith("es") && t.length > 4 && /(s|x|z|ch|sh)es$/.test(t)) out.add(t.slice(0, -2));
30
+ else if (t.endsWith("s") && t.length > 3 && !t.endsWith("ss")) out.add(t.slice(0, -1));
31
+ if (t.endsWith("ing") && t.length > 5) {
32
+ const base = t.slice(0, -3);
33
+ out.add(base);
34
+ out.add(base + "e");
35
+ }
36
+ if (t.endsWith("ation") && t.length > 7) {
37
+ const base = t.slice(0, -5);
38
+ out.add(base);
39
+ }
40
+ return [...out];
41
+ }
42
+ var EXPLICIT_STEM = /* @__PURE__ */ new Map([
43
+ ["configuration", "config"],
44
+ ["configurations", "config"],
45
+ ["configured", "config"],
46
+ ["ranking", "rank"],
47
+ ["ranked", "rank"],
48
+ ["weights", "weight"],
49
+ ["dependencies", "dependency"],
50
+ ["dependency", "depend"],
51
+ ["extraction", "extract"],
52
+ ["extracted", "extract"]
53
+ ]);
54
+ function tokenizeIdentifier(raw) {
55
+ const parts = raw.replace(/[\\/]/g, " ").replace(/[.:\-_"']/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/g).filter((t) => t.length >= 2 && !STOP.has(t));
56
+ const out = /* @__PURE__ */ new Set();
57
+ for (const t of parts) {
58
+ out.add(t);
59
+ for (const v of stemVariants(t)) {
60
+ if (v.length >= 2 && !STOP.has(v)) out.add(v);
61
+ }
62
+ const e = EXPLICIT_STEM.get(t);
63
+ if (e && !STOP.has(e)) out.add(e);
64
+ }
65
+ return [...out];
66
+ }
67
+ function tokenizeTask(text) {
68
+ return tokenizeIdentifier(text);
69
+ }
70
+
71
+ // src/retrieval/history/index.ts
72
+ function stripConventionalPrefix(subject) {
73
+ return subject.replace(/^[a-z]+(\([^)]*\))?(!)?:\s*/i, "").replace(/^\[\w+\]\s*/, "").trim();
74
+ }
75
+ function isPoorSubject(subject) {
76
+ const clean = stripConventionalPrefix(subject);
77
+ if (/^merge\s+(pr|pull request)\s*#?\d*/i.test(clean)) return true;
78
+ if (/^(update|updates|wip|tmp|test|fix|patch|changes?)$/i.test(clean)) return true;
79
+ return tokenizeIdentifier(clean).length < 2;
80
+ }
81
+ function commitTokens(subject, body, paths) {
82
+ const out = /* @__PURE__ */ new Set();
83
+ const cleanSubject = stripConventionalPrefix(subject);
84
+ for (const t of tokenizeIdentifier(cleanSubject)) out.add(t);
85
+ if (!isPoorSubject(subject)) {
86
+ for (const t of tokenizeIdentifier(body.slice(0, 2e3))) out.add(t);
87
+ }
88
+ for (const p of paths) {
89
+ for (const t of tokenizeIdentifier(p)) out.add(t);
90
+ }
91
+ return [...out];
92
+ }
93
+ function syncCommitFts(db, shas) {
94
+ let n = 0;
95
+ try {
96
+ const del = db.prepare(`DELETE FROM commit_fts WHERE sha = ?`);
97
+ const ins = db.prepare(`INSERT INTO commit_fts(sha, subject, body, paths) VALUES (?, ?, ?, ?)`);
98
+ const getCommit = db.prepare(`SELECT subject, body FROM commits WHERE sha = ?`);
99
+ const getPaths = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
100
+ for (const sha of shas) {
101
+ const c = getCommit.get(sha);
102
+ if (!c) continue;
103
+ const paths = getPaths.all(sha).map((r) => r.path);
104
+ del.run(sha);
105
+ ins.run(sha, c.subject, c.body.slice(0, 4e3), paths.join(" "));
106
+ n++;
107
+ }
108
+ } catch (err) {
109
+ logError("history", err);
110
+ }
111
+ return n;
112
+ }
113
+
114
+ // src/retrieval/history/search.ts
115
+ var HISTORY_HALF_LIFE_DAYS = 180;
116
+ var HISTORY_MIN_SCORE = 0.05;
117
+ var HISTORY_MAX_FILES = 40;
118
+ var BM25_LIMIT = 20;
119
+ function recencyScore(ts, nowSec, halfLifeDays) {
120
+ if (ts <= 0) return 0.5;
121
+ const ageDays = Math.max(0, (nowSec - ts) / 86400);
122
+ return Math.pow(0.5, ageDays / Math.max(1, halfLifeDays));
123
+ }
124
+ function sizePenalty(nFiles) {
125
+ return 1 / Math.log(2 + Math.max(0, nFiles));
126
+ }
127
+ function jaccard(a, b) {
128
+ if (a.size === 0 || b.size === 0) return 0;
129
+ let inter = 0;
130
+ for (const x of a) if (b.has(x)) inter++;
131
+ const union = a.size + b.size - inter;
132
+ return union === 0 ? 0 : inter / union;
133
+ }
134
+ function resolveCurrentPath(db, path) {
135
+ let cur = path;
136
+ try {
137
+ const stmt = db.prepare(`SELECT path FROM commit_files WHERE old_path = ? ORDER BY rowid DESC LIMIT 1`);
138
+ for (let i = 0; i < 5; i++) {
139
+ const row = stmt.get(cur);
140
+ if (!row || row.path === cur) break;
141
+ cur = row.path;
142
+ }
143
+ } catch {
144
+ }
145
+ return cur;
146
+ }
147
+ function candidates(db, tokens, asOfTs, excludeShas) {
148
+ const excluded = new Set(excludeShas ?? []);
149
+ if (tokens.length === 0) return [];
150
+ const q = tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
151
+ try {
152
+ const rows = db.prepare(
153
+ `SELECT sha, bm25(commit_fts) AS rank FROM commit_fts
154
+ WHERE commit_fts MATCH ? ORDER BY rank LIMIT ${BM25_LIMIT}`
155
+ ).all(q);
156
+ if (rows.length === 0) return [];
157
+ const filt = rows.filter((r) => !excluded.has(r.sha));
158
+ if (filt.length === 0) return [];
159
+ const worst = Math.min(...filt.map((r) => r.rank));
160
+ const range = Math.max(1e-9, 0 - worst + 1);
161
+ void asOfTs;
162
+ return filt.map((r) => ({
163
+ sha: r.sha,
164
+ bm25norm: Math.max(0, Math.min(1, (0 - r.rank + 0.5) / range))
165
+ }));
166
+ } catch (err) {
167
+ logError("history", err);
168
+ }
169
+ try {
170
+ let rows = db.prepare(`SELECT sha, subject, ts FROM commits`).all();
171
+ if (asOfTs !== void 0) rows = rows.filter((r) => r.ts <= asOfTs);
172
+ rows = rows.filter((r) => !excluded.has(r.sha));
173
+ const tset = new Set(tokens);
174
+ const scored = rows.map((r) => {
175
+ let paths = [];
176
+ try {
177
+ paths = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`).all(r.sha).map((x) => x.path);
178
+ } catch {
179
+ }
180
+ const doc = new Set(commitTokens(r.subject, "", paths));
181
+ let hit = 0;
182
+ for (const t of tset) if (doc.has(t)) hit++;
183
+ return { sha: r.sha, bm25norm: tset.size === 0 ? 0 : hit / tset.size };
184
+ }).filter((r) => r.bm25norm > 0);
185
+ scored.sort((a, b) => b.bm25norm - a.bm25norm || (a.sha < b.sha ? -1 : 1));
186
+ return scored.slice(0, BM25_LIMIT);
187
+ } catch (err) {
188
+ logError("history", err);
189
+ return [];
190
+ }
191
+ }
192
+ function historySearch(db, task, opts = {}) {
193
+ const seedFiles = new Set(opts.seedFiles ?? []);
194
+ const limit = Math.max(1, Math.min(10, opts.limit ?? 3));
195
+ const halfLife = opts.halfLifeDays ?? HISTORY_HALF_LIFE_DAYS;
196
+ const minScore = opts.minScore ?? HISTORY_MIN_SCORE;
197
+ const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1e3);
198
+ const tokens = tokenizeTask(task);
199
+ if (tokens.length === 0) return [];
200
+ let cands;
201
+ try {
202
+ cands = candidates(db, tokens, opts.asOfTs, opts.excludeShas);
203
+ } catch (err) {
204
+ logError("history", err);
205
+ return [];
206
+ }
207
+ if (cands.length === 0) return [];
208
+ const out = [];
209
+ try {
210
+ const getCommit = db.prepare(`SELECT ts, subject FROM commits WHERE sha = ?`);
211
+ const getFiles = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
212
+ const fileExists = db.prepare(`SELECT 1 AS ok FROM files WHERE path = ? LIMIT 1`);
213
+ for (const c of cands) {
214
+ const commit = getCommit.get(c.sha);
215
+ if (!commit) continue;
216
+ if (opts.asOfTs !== void 0 && commit.ts > opts.asOfTs) continue;
217
+ const rawFiles = getFiles.all(c.sha).map((r) => r.path);
218
+ if (rawFiles.length > HISTORY_MAX_FILES) continue;
219
+ const live = [];
220
+ for (const p of rawFiles) {
221
+ const cur = resolveCurrentPath(db, p);
222
+ try {
223
+ const hit = fileExists.get(cur);
224
+ if (hit) {
225
+ if (!live.includes(cur)) live.push(cur);
226
+ }
227
+ } catch {
228
+ }
229
+ }
230
+ if (live.length === 0) continue;
231
+ const rec = recencyScore(commit.ts, nowSec, halfLife);
232
+ const pen = sizePenalty(rawFiles.length);
233
+ const ov = jaccard(new Set(live), seedFiles);
234
+ const score = c.bm25norm * rec * pen * (1 + ov);
235
+ if (score < minScore) continue;
236
+ const d = new Date(commit.ts * 1e3);
237
+ out.push({
238
+ sha: c.sha,
239
+ shortSha: c.sha.slice(0, 7),
240
+ ts: commit.ts,
241
+ date: Number.isNaN(d.getTime()) ? "-" : d.toISOString().slice(0, 10),
242
+ subject: commit.subject,
243
+ files: [...live].sort(),
244
+ score,
245
+ sortTs: commit.ts
246
+ });
247
+ }
248
+ } catch (err) {
249
+ logError("history", err);
250
+ return [];
251
+ }
252
+ out.sort((a, b) => b.score - a.score || b.sortTs - a.sortTs || (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
253
+ return out.slice(0, limit).map(({ sortTs: _s, ...rest }) => rest);
254
+ }
255
+ function coChangeSet(analogs) {
256
+ const counts = /* @__PURE__ */ new Map();
257
+ for (const a of analogs) {
258
+ for (const f of new Set(a.files)) counts.set(f, (counts.get(f) ?? 0) + 1);
259
+ }
260
+ return [...counts.entries()].filter(([, n]) => n >= 2).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || (a.path < b.path ? -1 : 1));
261
+ }
262
+ function groupHistoryFiles(files) {
263
+ const source = [];
264
+ const test = [];
265
+ const config = [];
266
+ const docs = [];
267
+ for (const f of files) {
268
+ const base = f.split("/").pop() ?? f;
269
+ const lower = f.toLowerCase();
270
+ if (f.includes(".test.") || f.includes(".spec.") || f.includes("__tests__") || base.startsWith("test_") || base.endsWith("_test.py") || lower.startsWith("tests/")) {
271
+ test.push(f);
272
+ } else if (/\.(json|ya?ml|toml|ini|cfg)$/.test(lower) || ["package.json", "dockerfile", "makefile"].includes(base.toLowerCase())) {
273
+ config.push(f);
274
+ } else if (/\.mdx?$/.test(lower) || lower.startsWith("docs/")) {
275
+ docs.push(f);
276
+ } else {
277
+ source.push(f);
278
+ }
279
+ }
280
+ return { source, test, config, docs };
281
+ }
282
+ function leaveOneOut(db, shas, opts = {}) {
283
+ const out = [];
284
+ try {
285
+ const getCommit = db.prepare(`SELECT ts, subject FROM commits WHERE sha = ?`);
286
+ const getFiles = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
287
+ for (const sha of shas) {
288
+ const commit = getCommit.get(sha);
289
+ if (!commit) continue;
290
+ const expected = getFiles.all(sha).map((r) => r.path);
291
+ const seeds = opts.seedFilesOf ? opts.seedFilesOf(sha) : [];
292
+ const analogs = historySearch(db, commit.subject, {
293
+ seedFiles: seeds,
294
+ limit: opts.limit ?? 3,
295
+ asOfTs: commit.ts - 1,
296
+ excludeShas: [sha],
297
+ nowSec: opts.nowSec
298
+ });
299
+ const recalledFiles = /* @__PURE__ */ new Set();
300
+ for (const a of analogs) for (const f of a.files) recalledFiles.add(f);
301
+ for (const c of coChangeSet(analogs)) recalledFiles.add(c.path);
302
+ const recalled = expected.filter((f) => recalledFiles.has(f));
303
+ out.push({ sha, subject: commit.subject, recalled, expected, recall: expected.length === 0 ? 1 : recalled.length / expected.length });
304
+ }
305
+ } catch (err) {
306
+ logError("history", err);
307
+ }
308
+ return out;
309
+ }
310
+
311
+ export {
312
+ tokenizeIdentifier,
313
+ tokenizeTask,
314
+ syncCommitFts,
315
+ HISTORY_HALF_LIFE_DAYS,
316
+ HISTORY_MIN_SCORE,
317
+ HISTORY_MAX_FILES,
318
+ recencyScore,
319
+ sizePenalty,
320
+ jaccard,
321
+ resolveCurrentPath,
322
+ historySearch,
323
+ coChangeSet,
324
+ groupHistoryFiles,
325
+ leaveOneOut
326
+ };
@@ -17,7 +17,7 @@ function loadDatabaseSync() {
17
17
  }
18
18
  var DATA_DIR_NAME = ".scopecairn";
19
19
  var DB_FILE_NAME = "scopecairn.db";
20
- var SCHEMA_VERSION = 6;
20
+ var SCHEMA_VERSION = 7;
21
21
  function dataDir(repoRoot) {
22
22
  return path.join(repoRoot, DATA_DIR_NAME);
23
23
  }
@@ -118,6 +118,29 @@ function openDb(repoRoot) {
118
118
  nodes_json TEXT NOT NULL DEFAULT '[]',
119
119
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
120
  );
121
+ -- Analogi riwayat: commit git lokal untuk history search (hanya di DB
122
+ -- lokal .scopecairn/, tidak pernah masuk GRAPH.md/export).
123
+ CREATE TABLE IF NOT EXISTS commits (
124
+ sha TEXT PRIMARY KEY,
125
+ ts INTEGER NOT NULL DEFAULT 0,
126
+ subject TEXT NOT NULL DEFAULT '',
127
+ body TEXT NOT NULL DEFAULT '',
128
+ author TEXT NOT NULL DEFAULT ''
129
+ );
130
+ CREATE INDEX IF NOT EXISTS idx_commits_ts ON commits(ts);
131
+ CREATE TABLE IF NOT EXISTS commit_files (
132
+ sha TEXT NOT NULL REFERENCES commits(sha) ON DELETE CASCADE,
133
+ path TEXT NOT NULL,
134
+ status TEXT NOT NULL DEFAULT 'M',
135
+ old_path TEXT,
136
+ additions INTEGER NOT NULL DEFAULT 0,
137
+ deletions INTEGER NOT NULL DEFAULT 0
138
+ );
139
+ CREATE INDEX IF NOT EXISTS idx_commit_files_sha ON commit_files(sha);
140
+ CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);
141
+ CREATE VIRTUAL TABLE IF NOT EXISTS commit_fts USING fts5(
142
+ sha UNINDEXED, subject, body, paths
143
+ );
121
144
  `);
122
145
  db.prepare(
123
146
  `INSERT INTO meta(key, value) VALUES ('schema_version', ?)
@@ -273,7 +296,7 @@ function ensureProtectedFile(repoRoot) {
273
296
  }
274
297
 
275
298
  // src/scope/scope.ts
276
- function computeScope(highFiles, mediumFiles, testFiles, patterns) {
299
+ function computeScope(highFiles, mediumFiles, testFiles, patterns, historyOptional) {
277
300
  const required = [];
278
301
  const optional = [];
279
302
  const protectedFiles = [];
@@ -293,6 +316,12 @@ function computeScope(highFiles, mediumFiles, testFiles, patterns) {
293
316
  if (isProtected(t, patterns)) protectedFiles.push(t);
294
317
  else optional.push(t);
295
318
  }
319
+ for (const h of historyOptional ?? []) {
320
+ if (seen.has(h)) continue;
321
+ seen.add(h);
322
+ if (isProtected(h, patterns)) protectedFiles.push(h);
323
+ else optional.push(h);
324
+ }
296
325
  return { required, optional, protected: protectedFiles };
297
326
  }
298
327
  function scopeBlock(scope) {
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/log.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ function lastRunLogPath(repoRoot = process.cwd()) {
7
+ return path.join(repoRoot, ".scopecairn", "last-run.log");
8
+ }
9
+ function append(repoRoot, scope, text) {
10
+ const root = repoRoot ?? process.cwd();
11
+ try {
12
+ fs.mkdirSync(path.dirname(lastRunLogPath(root)), { recursive: true });
13
+ fs.appendFileSync(lastRunLogPath(root), text + "\n", "utf8");
14
+ } catch {
15
+ }
16
+ }
17
+ function logError(scope, err, repoRoot) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ append(repoRoot, scope, `${(/* @__PURE__ */ new Date()).toISOString()} [${scope}] ${message}`);
20
+ }
21
+
22
+ export {
23
+ logError
24
+ };
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ logError
4
+ } from "./chunk-OPA4TMAT.js";
5
+
6
+ // src/retrieval/history/extractor.ts
7
+ import { execFileSync } from "child_process";
8
+ var HISTORY_MAX_COMMITS = 2e3;
9
+ var HISTORY_MAX_FILES_PER_COMMIT = 40;
10
+ function shouldSkipCommit(fileCount) {
11
+ return fileCount > HISTORY_MAX_FILES_PER_COMMIT;
12
+ }
13
+ var HISTORY_SKIP_BASENAMES = /* @__PURE__ */ new Set([
14
+ "package-lock.json",
15
+ "pnpm-lock.yaml",
16
+ "yarn.lock",
17
+ "bun.lockb",
18
+ "cargo.lock",
19
+ "go.sum",
20
+ "gemfile.lock",
21
+ "poetry.lock"
22
+ ]);
23
+ var HISTORY_SKIP_SUFFIXES = [".min.js", ".map", ".lock"];
24
+ var HISTORY_SKIP_DIRS = ["dist/", "build/", "coverage/", "node_modules/", ".next/"];
25
+ function isHistorySkipped(posixPath) {
26
+ const base = posixPath.split("/").pop() ?? posixPath;
27
+ if (HISTORY_SKIP_BASENAMES.has(base)) return true;
28
+ for (const s of HISTORY_SKIP_SUFFIXES) if (base.endsWith(s)) return true;
29
+ for (const d of HISTORY_SKIP_DIRS) if (posixPath.startsWith(d)) return true;
30
+ return false;
31
+ }
32
+ function runGit(repoRoot, args, timeout = 3e4) {
33
+ try {
34
+ return execFileSync("git", args, {
35
+ cwd: repoRoot,
36
+ encoding: "utf8",
37
+ timeout,
38
+ maxBuffer: 64 * 1024 * 1024,
39
+ stdio: ["ignore", "pipe", "ignore"]
40
+ });
41
+ } catch (err) {
42
+ logError("history", err, repoRoot);
43
+ return null;
44
+ }
45
+ }
46
+ function isShallowRepo(repoRoot) {
47
+ try {
48
+ const out = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
49
+ cwd: repoRoot,
50
+ encoding: "utf8",
51
+ timeout: 8e3,
52
+ stdio: ["ignore", "pipe", "ignore"]
53
+ }).trim();
54
+ return out === "true";
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ var FS = String.fromCharCode(1);
60
+ var RS = String.fromCharCode(30);
61
+ var SB = String.fromCharCode(31);
62
+ function parseGitLog(out) {
63
+ const commits = [];
64
+ let cur = null;
65
+ let numstat = /* @__PURE__ */ new Map();
66
+ let statuses = [];
67
+ const flush = () => {
68
+ if (!cur) return;
69
+ for (const s of statuses) {
70
+ const n = numstat.get(s.p) ?? { a: 0, d: 0 };
71
+ cur.files.push({ status: s.st, path: s.p, oldPath: s.old, additions: n.a, deletions: n.d });
72
+ }
73
+ commits.push(cur);
74
+ };
75
+ for (const rawLine of out.replace(new RegExp(RS, "g"), "").split("\n")) {
76
+ const line = rawLine.trimEnd();
77
+ if (!line.trim()) continue;
78
+ if (line.includes(FS)) {
79
+ flush();
80
+ const [sha, tsRaw, author, subjectBody = ""] = line.split(FS);
81
+ if (!sha || !/^[0-9a-f]{4,}$/.test(sha.trim())) {
82
+ cur = null;
83
+ numstat = /* @__PURE__ */ new Map();
84
+ statuses = [];
85
+ continue;
86
+ }
87
+ const sep = subjectBody.indexOf(SB);
88
+ cur = {
89
+ sha: sha.trim(),
90
+ ts: Number((tsRaw ?? "").trim()) || 0,
91
+ author: (author ?? "").trim(),
92
+ subject: (sep === -1 ? subjectBody : subjectBody.slice(0, sep)).trim(),
93
+ body: (sep === -1 ? "" : subjectBody.slice(sep + 1)).trim(),
94
+ files: []
95
+ };
96
+ numstat = /* @__PURE__ */ new Map();
97
+ statuses = [];
98
+ continue;
99
+ }
100
+ if (!cur) continue;
101
+ const t = line.trim();
102
+ const num = t.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
103
+ if (num) {
104
+ numstat.set(num[3].trim(), {
105
+ a: num[1] === "-" ? 0 : Number(num[1]),
106
+ d: num[2] === "-" ? 0 : Number(num[2])
107
+ });
108
+ continue;
109
+ }
110
+ const m = t.match(/^([AMDRCTUXB])(\d*)\t(.+)$/);
111
+ if (!m) continue;
112
+ const parts = m[3].split(" ");
113
+ if (m[1] === "R" && parts.length >= 2) {
114
+ statuses.push({ st: "R", p: parts[1].trim(), old: parts[0].trim() });
115
+ } else {
116
+ statuses.push({ st: m[1], p: parts[0].trim(), old: null });
117
+ }
118
+ }
119
+ flush();
120
+ return commits;
121
+ }
122
+ function ensureHistoryIndex(db, repoRoot, opts) {
123
+ const maxCommits = Math.max(1, Math.min(5e3, opts?.maxCommits ?? HISTORY_MAX_COMMITS));
124
+ const fail = (shallow2 = false) => ({ available: false, indexed: 0, skipped: 0, shallow: shallow2, shas: [] });
125
+ let before = 0;
126
+ try {
127
+ before = db.prepare(`SELECT COUNT(*) AS n FROM commits`).get().n;
128
+ } catch (err) {
129
+ logError("history", err, repoRoot);
130
+ return fail();
131
+ }
132
+ const lastRow = (() => {
133
+ try {
134
+ return db.prepare(`SELECT value FROM meta WHERE key = 'history_last_sha'`).get();
135
+ } catch {
136
+ return void 0;
137
+ }
138
+ })();
139
+ const lastSha = lastRow?.value;
140
+ const headList = runGit(repoRoot, ["log", "--no-merges", `--max-count=${maxCommits * 2}`, "--format=%H"]);
141
+ if (!headList) return fail(isShallowRepo(repoRoot));
142
+ const headShas = headList.split("\n").map((s) => s.trim()).filter(Boolean);
143
+ if (headShas.length === 0) return { available: true, indexed: 0, skipped: 0, shallow: isShallowRepo(repoRoot), shas: [] };
144
+ const shallow = isShallowRepo(repoRoot);
145
+ let range;
146
+ if (lastSha && headShas.includes(lastSha)) {
147
+ range = headShas.slice(0, Math.max(0, headShas.indexOf(lastSha)));
148
+ } else {
149
+ range = headShas.slice(0, maxCommits);
150
+ }
151
+ if (range.length === 0) {
152
+ return { available: true, indexed: 0, skipped: 0, shallow, shas: [] };
153
+ }
154
+ const oldest = range[range.length - 1];
155
+ const newest = range[0];
156
+ const detailRange = lastSha && headShas.includes(lastSha) ? `${oldest}..${newest}` : `--max-count=${Math.min(maxCommits, range.length)}`;
157
+ const format = `%H${FS}%ct${FS}%an${FS}%s${SB}%b${RS}`;
158
+ const detailArgs = detailRange.startsWith("--max-count") ? ["log", "--no-merges", detailRange, `--format=${format}`, "--name-status", "-M", "--numstat", "--", "."] : ["log", "--no-merges", `--format=${format}`, "--name-status", "-M", "--numstat", detailRange, "--", "."];
159
+ const detail = runGit(repoRoot, detailArgs);
160
+ if (!detail) return fail(shallow);
161
+ let parsed;
162
+ try {
163
+ parsed = parseGitLog(detail);
164
+ } catch (err) {
165
+ logError("history", err, repoRoot);
166
+ return fail(shallow);
167
+ }
168
+ let indexed = 0;
169
+ let skipped = 0;
170
+ const shas = [];
171
+ try {
172
+ const insCommit = db.prepare(
173
+ `INSERT OR IGNORE INTO commits(sha, ts, subject, body, author) VALUES (?, ?, ?, ?, ?)`
174
+ );
175
+ const insFile = db.prepare(
176
+ `INSERT INTO commit_files(sha, path, status, old_path, additions, deletions) VALUES (?, ?, ?, ?, ?, ?)`
177
+ );
178
+ const delFiles = db.prepare(`DELETE FROM commit_files WHERE sha = ?`);
179
+ for (const c of parsed.slice(0, maxCommits)) {
180
+ const kept = c.files.filter((f) => !isHistorySkipped(f.path));
181
+ if (shouldSkipCommit(c.files.length)) {
182
+ skipped++;
183
+ continue;
184
+ }
185
+ const r = insCommit.run(c.sha, c.ts, c.subject, c.body, c.author);
186
+ if (Number(r.changes) === 0) continue;
187
+ delFiles.run(c.sha);
188
+ for (const f of kept) {
189
+ insFile.run(c.sha, f.path, f.status, f.oldPath, f.additions, f.deletions);
190
+ }
191
+ indexed++;
192
+ shas.push(c.sha);
193
+ }
194
+ } catch (err) {
195
+ logError("history", err, repoRoot);
196
+ }
197
+ try {
198
+ db.prepare(
199
+ `INSERT INTO meta(key, value) VALUES ('history_last_sha', ?)
200
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
201
+ ).run(headShas[0]);
202
+ } catch (err) {
203
+ logError("history", err, repoRoot);
204
+ }
205
+ void before;
206
+ return { available: true, indexed, skipped, shallow, shas };
207
+ }
208
+
209
+ export {
210
+ HISTORY_MAX_COMMITS,
211
+ HISTORY_MAX_FILES_PER_COMMIT,
212
+ shouldSkipCommit,
213
+ isHistorySkipped,
214
+ isShallowRepo,
215
+ parseGitLog,
216
+ ensureHistoryIndex
217
+ };