whygit 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +267 -2
- package/README.zh-CN.md +264 -0
- package/bin/whygit.js +11 -0
- package/docs/demo.svg +21 -0
- package/package.json +50 -4
- package/src/cli.js +132 -0
- package/src/cmd-check.js +81 -0
- package/src/cmd-cursed.js +38 -0
- package/src/cmd-init.js +92 -0
- package/src/cmd-story.js +85 -0
- package/src/cmd-why.js +105 -0
- package/src/context.js +63 -0
- package/src/db.js +76 -0
- package/src/git.js +311 -0
- package/src/indexer.js +150 -0
- package/src/install.js +95 -0
- package/src/mcp.js +171 -0
- package/src/risk.js +44 -0
- package/src/search.js +161 -0
- package/src/signals.js +160 -0
- package/src/ui.js +103 -0
- package/src/util.js +200 -0
- package/src/whylog.js +84 -0
package/src/git.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/** Thin, fast wrappers around the git CLI. No shell, no dependencies. */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_BUFFER = 1024 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export class GitError extends Error {
|
|
8
|
+
constructor(args, stderr, code) {
|
|
9
|
+
super('git ' + args.join(' ') + ' failed: ' + String(stderr || '').trim().slice(0, 500));
|
|
10
|
+
this.name = 'GitError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.args = args;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Run git and return stdout as utf8. Throws GitError on failure. */
|
|
17
|
+
export function git(args, opts) {
|
|
18
|
+
const o = opts || {};
|
|
19
|
+
try {
|
|
20
|
+
return execFileSync('git', args, {
|
|
21
|
+
cwd: o.cwd,
|
|
22
|
+
input: o.input,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
maxBuffer: o.maxBuffer || MAX_BUFFER,
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
+
env: Object.assign({}, process.env, { GIT_PAGER: 'cat', GIT_OPTIONAL_LOCKS: '0' }),
|
|
27
|
+
});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new GitError(args, err.stderr || err.message, err.status);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run git, returning null instead of throwing. */
|
|
34
|
+
export function tryGit(args, opts) {
|
|
35
|
+
try {
|
|
36
|
+
return git(args, opts);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isRepo(cwd) {
|
|
43
|
+
return tryGit(['rev-parse', '--is-inside-work-tree'], { cwd }) === 'true\n';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function repoRoot(cwd) {
|
|
47
|
+
return git(['rev-parse', '--show-toplevel'], { cwd }).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function gitDir(cwd) {
|
|
51
|
+
return path.resolve(cwd, git(['rev-parse', '--git-dir'], { cwd }).trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function headSha(cwd) {
|
|
55
|
+
return (tryGit(['rev-parse', 'HEAD'], { cwd }) || '').trim() || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isShallow(cwd) {
|
|
59
|
+
return String(tryGit(['rev-parse', '--is-shallow-repository'], { cwd }) || '').trim() === 'true';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function branchName(cwd) {
|
|
63
|
+
const name = String(tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) || '').trim();
|
|
64
|
+
return name && name !== 'HEAD' ? name : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Normalise the origin remote into a browsable https URL, or null. */
|
|
68
|
+
export function remoteWebUrl(cwd) {
|
|
69
|
+
const raw = (tryGit(['config', '--get', 'remote.origin.url'], { cwd }) || '').trim().replace(/\.git$/, '');
|
|
70
|
+
if (!raw) return null;
|
|
71
|
+
if (/^https?:\/\//.test(raw)) return raw.replace(/\/$/, '');
|
|
72
|
+
if (/^git@[^:]+:/.test(raw)) {
|
|
73
|
+
const m = raw.match(/^git@([^:]+):(.+)$/);
|
|
74
|
+
return 'https://' + m[1] + '/' + m[2];
|
|
75
|
+
}
|
|
76
|
+
if (/^ssh:\/\//.test(raw)) {
|
|
77
|
+
const m = raw.match(/^ssh:\/\/(?:git@)?([^/]+)\/(.+)$/);
|
|
78
|
+
if (m) return 'https://' + m[1] + '/' + m[2];
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function commitUrl(webUrl, sha) {
|
|
84
|
+
return webUrl ? webUrl + '/commit/' + sha : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function blameUrl(webUrl, branch, file, line) {
|
|
88
|
+
if (!webUrl || !branch) return null;
|
|
89
|
+
const enc = file.split('/').map(encodeURIComponent).join('/');
|
|
90
|
+
return webUrl + '/blame/' + encodeURIComponent(branch) + '/' + enc + '#L' + line;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** All tracked files. */
|
|
94
|
+
export function lsFiles(cwd) {
|
|
95
|
+
const out = tryGit(['ls-files', '-z'], { cwd });
|
|
96
|
+
if (!out) return [];
|
|
97
|
+
return out.split('\0').filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function revParse(cwd, rev) {
|
|
101
|
+
const out = tryGit(['rev-parse', '--verify', '--quiet', rev + '^{commit}'], { cwd });
|
|
102
|
+
return out ? out.trim() : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function mergeBase(cwd, a, b) {
|
|
106
|
+
const out = tryGit(['merge-base', a, b], { cwd });
|
|
107
|
+
return out ? out.trim() : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function currentUser(cwd) {
|
|
111
|
+
return {
|
|
112
|
+
name: (tryGit(['config', 'user.name'], { cwd }) || '').trim(),
|
|
113
|
+
email: (tryGit(['config', 'user.email'], { cwd }) || '').trim(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function shortSha(sha) {
|
|
118
|
+
return String(sha || '').slice(0, 8);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function diffNameStatus(cwd, base, head) {
|
|
122
|
+
const out = tryGit(['diff', '--name-status', '-M', base, head], { cwd });
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const line of String(out || '').split('\n')) {
|
|
125
|
+
if (!line.trim()) continue;
|
|
126
|
+
const parts = line.split('\t');
|
|
127
|
+
const status = parts[0][0];
|
|
128
|
+
if (status === 'R' || status === 'C') {
|
|
129
|
+
rows.push({ status: status, oldPath: parts[1], path: parts[2] });
|
|
130
|
+
} else {
|
|
131
|
+
rows.push({ status: status, oldPath: null, path: parts[1] });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return rows;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function diffHunks(cwd, base, head) {
|
|
138
|
+
const out = tryGit(['diff', '--unified=0', '--no-color', '--no-ext-diff', '-M', base, head], { cwd });
|
|
139
|
+
return parseUnifiedDiff(String(out || ''));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Parse a unified diff (as produced by -U0) into files with hunk ranges. */
|
|
143
|
+
export function parseUnifiedDiff(text) {
|
|
144
|
+
const files = [];
|
|
145
|
+
let cur = null;
|
|
146
|
+
let hunk = null;
|
|
147
|
+
for (const line of String(text).split('\n')) {
|
|
148
|
+
if (line.startsWith('diff --git ')) {
|
|
149
|
+
cur = { path: null, oldPath: null, hunks: [], added: 0, removed: 0, isBinary: false, status: null };
|
|
150
|
+
files.push(cur);
|
|
151
|
+
hunk = null;
|
|
152
|
+
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
153
|
+
if (m) { cur.oldPath = m[1]; cur.path = m[2]; }
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!cur) continue;
|
|
157
|
+
if (line.startsWith('new file mode')) { cur.status = 'A'; continue; }
|
|
158
|
+
if (line.startsWith('deleted file mode')) { cur.status = 'D'; continue; }
|
|
159
|
+
if (line.startsWith('rename from ')) { cur.oldPath = line.slice(11).trim(); continue; }
|
|
160
|
+
if (line.startsWith('rename to ')) { cur.path = line.slice(9).trim(); continue; }
|
|
161
|
+
if (line.startsWith('Binary files ')) { cur.isBinary = true; continue; }
|
|
162
|
+
if (line.startsWith('+++ ')) {
|
|
163
|
+
const p = line.slice(4).trim();
|
|
164
|
+
if (p !== '/dev/null') cur.path = p.replace(/^b\//, '');
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (line.startsWith('--- ')) {
|
|
168
|
+
const p = line.slice(4).trim();
|
|
169
|
+
if (p !== '/dev/null') cur.oldPath = p.replace(/^a\//, '');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (line.startsWith('@@ ')) {
|
|
173
|
+
const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
174
|
+
if (m) {
|
|
175
|
+
hunk = {
|
|
176
|
+
oldStart: Number(m[1]),
|
|
177
|
+
oldLines: m[2] === undefined ? 1 : Number(m[2]),
|
|
178
|
+
newStart: Number(m[3]),
|
|
179
|
+
newLines: m[4] === undefined ? 1 : Number(m[4]),
|
|
180
|
+
added: [],
|
|
181
|
+
removed: [],
|
|
182
|
+
};
|
|
183
|
+
cur.hunks.push(hunk);
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
188
|
+
if (hunk) hunk.added.push(line.slice(1));
|
|
189
|
+
cur.added += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
193
|
+
if (hunk) hunk.removed.push(line.slice(1));
|
|
194
|
+
cur.removed += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return files.filter(function (f) { return f.path; });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function showCommit(cwd, sha, paths) {
|
|
202
|
+
const args = ['show', '--no-color', '--no-ext-diff', '--unified=2', '--format=%H%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b', sha];
|
|
203
|
+
if (paths && paths.length) args.push('--', ...paths);
|
|
204
|
+
const out = tryGit(args, { cwd });
|
|
205
|
+
if (!out) return null;
|
|
206
|
+
const sep = out.indexOf('\n');
|
|
207
|
+
const header = out.slice(0, sep < 0 ? out.length : sep);
|
|
208
|
+
const fields = header.split('\x1f');
|
|
209
|
+
const diffText = out.slice(sep + 1);
|
|
210
|
+
return {
|
|
211
|
+
sha: fields[0],
|
|
212
|
+
author: fields[1],
|
|
213
|
+
email: fields[2],
|
|
214
|
+
ts: Number(fields[3]),
|
|
215
|
+
subject: fields[4],
|
|
216
|
+
body: (fields[5] || '').trim(),
|
|
217
|
+
diff: parseUnifiedDiff(diffText),
|
|
218
|
+
raw: out,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Line-level blame for one file. Index 0 is line 1. */
|
|
223
|
+
export function blamePorcelain(cwd, file, rev) {
|
|
224
|
+
const args = ['blame', '--porcelain'];
|
|
225
|
+
if (rev) args.push(rev);
|
|
226
|
+
args.push('--', file);
|
|
227
|
+
const out = tryGit(args, { cwd });
|
|
228
|
+
if (!out) return [];
|
|
229
|
+
const lines = [];
|
|
230
|
+
let cur = null;
|
|
231
|
+
for (const raw of out.split('\n')) {
|
|
232
|
+
if (!raw) continue;
|
|
233
|
+
const head = raw.match(/^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/);
|
|
234
|
+
if (head) {
|
|
235
|
+
cur = { sha: head[1], line: Number(head[3]), author: '', ts: 0, text: '' };
|
|
236
|
+
lines[cur.line - 1] = cur;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!cur) continue;
|
|
240
|
+
if (raw.startsWith('author ')) cur.author = raw.slice(7);
|
|
241
|
+
else if (raw.startsWith('author-time ')) cur.ts = Number(raw.slice(12));
|
|
242
|
+
else if (raw.startsWith('\t')) cur.text = raw.slice(1);
|
|
243
|
+
}
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function fileAt(cwd, rev, file) {
|
|
248
|
+
const out = tryGit(['show', rev + ':' + file], { cwd });
|
|
249
|
+
return out == null ? null : out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function lsTree(cwd, rev) {
|
|
253
|
+
const out = tryGit(['ls-tree', '-r', '--name-only', '-z', rev], { cwd });
|
|
254
|
+
return String(out || '').split('\0').filter(Boolean);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Full commit metadata (no merges) in one pass. */
|
|
258
|
+
export function logMeta(cwd, opts) {
|
|
259
|
+
const o = opts || {};
|
|
260
|
+
const args = ['log', '--no-merges', '--format=%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cI%x1f%s%x1f%B'];
|
|
261
|
+
if (o.since) args.push(o.since + '..HEAD');
|
|
262
|
+
else if (o.rev) args.push(o.rev);
|
|
263
|
+
args.push('--');
|
|
264
|
+
const out = git(args, { cwd });
|
|
265
|
+
const rows = [];
|
|
266
|
+
for (const rec of out.split('\x1e')) {
|
|
267
|
+
if (!rec) continue;
|
|
268
|
+
const f = rec.split('\x1f');
|
|
269
|
+
if (f.length < 8) continue;
|
|
270
|
+
rows.push({
|
|
271
|
+
sha: f[0],
|
|
272
|
+
parents: f[1].trim() ? f[1].trim().split(' ') : [],
|
|
273
|
+
author: f[2],
|
|
274
|
+
email: f[3],
|
|
275
|
+
ts: Number(f[4]),
|
|
276
|
+
iso: f[5],
|
|
277
|
+
subject: f[6],
|
|
278
|
+
body: (f[7] || '').trim(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return rows;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Per-commit file stats in one pass, keyed by sha. */
|
|
285
|
+
export function logNumstat(cwd, opts) {
|
|
286
|
+
const o = opts || {};
|
|
287
|
+
const args = ['log', '--no-merges', '--format=%x1e%H', '--numstat', '-M'];
|
|
288
|
+
if (o.since) args.push(o.since + '..HEAD');
|
|
289
|
+
else if (o.rev) args.push(o.rev);
|
|
290
|
+
args.push('--');
|
|
291
|
+
const out = git(args, { cwd });
|
|
292
|
+
const map = new Map();
|
|
293
|
+
for (const rec of out.split('\x1e')) {
|
|
294
|
+
if (!rec) continue;
|
|
295
|
+
const lines = rec.split('\n').filter(function (l) { return l.trim().length > 0; });
|
|
296
|
+
if (!lines.length) continue;
|
|
297
|
+
const sha = lines[0].trim();
|
|
298
|
+
const files = [];
|
|
299
|
+
for (const line of lines.slice(1)) {
|
|
300
|
+
const parts = line.split('\t');
|
|
301
|
+
if (parts.length < 3) continue;
|
|
302
|
+
files.push({
|
|
303
|
+
added: parts[0] === '-' ? 0 : Number(parts[0]) || 0,
|
|
304
|
+
removed: parts[1] === '-' ? 0 : Number(parts[1]) || 0,
|
|
305
|
+
path: parts[2],
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
map.set(sha, files);
|
|
309
|
+
}
|
|
310
|
+
return map;
|
|
311
|
+
}
|
package/src/indexer.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Full and incremental indexing of a repository's history. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { git, gitDir, headSha, logMeta, logNumstat, isShallow, lsFiles } from './git.js';
|
|
5
|
+
import { openIndex, setMeta, getMeta, tx, dbStats } from './db.js';
|
|
6
|
+
import { extractRationale, isBot, isTrivial, cleanSubject, hasFixWord } from './signals.js';
|
|
7
|
+
|
|
8
|
+
export function indexPathFor(gitDirPath) {
|
|
9
|
+
return path.join(gitDirPath, 'whygit', 'index.db');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function indexExists(gitDirPath) {
|
|
13
|
+
return fs.existsSync(indexPathFor(gitDirPath));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function chunk(arr, n) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build or refresh the index.
|
|
24
|
+
* opts: { cwd, full, quiet, onProgress(step, done, total) }
|
|
25
|
+
*/
|
|
26
|
+
export function buildIndex(opts) {
|
|
27
|
+
const o = opts || {};
|
|
28
|
+
const cwd = o.cwd || process.cwd();
|
|
29
|
+
const t0 = Date.now();
|
|
30
|
+
const gd = gitDir(cwd);
|
|
31
|
+
const head = headSha(cwd);
|
|
32
|
+
if (!head) throw new Error('repository has no commits yet');
|
|
33
|
+
const dbc = openIndex(gd, { create: true });
|
|
34
|
+
const prevHead = getMeta(dbc, 'head');
|
|
35
|
+
let since = null;
|
|
36
|
+
if (!o.full && prevHead && prevHead !== head) {
|
|
37
|
+
const ok = git(['rev-parse', '--verify', '--quiet', prevHead + '^{commit}'], { cwd: cwd, maxBuffer: 1e7 });
|
|
38
|
+
if (ok) since = prevHead;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const metas = logMeta(cwd, { since: since });
|
|
42
|
+
const stats = logNumstat(cwd, { since: since });
|
|
43
|
+
|
|
44
|
+
const insertCommit = dbc.prepare(
|
|
45
|
+
'INSERT OR REPLACE INTO commits (sha, ts, iso, author, email, subject, body, parents, is_bot, files_changed, added, removed, paths) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
|
46
|
+
);
|
|
47
|
+
const insertFile = dbc.prepare('INSERT OR REPLACE INTO commit_files (sha, path, added, removed, status) VALUES (?,?,?,?,?)');
|
|
48
|
+
const insertDecision = dbc.prepare('INSERT OR REPLACE INTO decisions (sha, ts, title, rationale, paths, kind, confidence, evidence) VALUES (?,?,?,?,?,?,?,?)');
|
|
49
|
+
const deleteFts = dbc.prepare('DELETE FROM decisions_fts WHERE rowid = (SELECT rowid FROM decisions WHERE sha = ?)');
|
|
50
|
+
const insertFts = dbc.prepare('INSERT INTO decisions_fts (rowid, title, rationale, paths, subject) VALUES (?,?,?,?,?)');
|
|
51
|
+
const deleteTri = dbc.prepare('DELETE FROM decisions_tri WHERE rowid = (SELECT rowid FROM decisions WHERE sha = ?)');
|
|
52
|
+
const insertTri = dbc.prepare('INSERT INTO decisions_tri (rowid, title, rationale, paths) VALUES (?,?,?,?)');
|
|
53
|
+
const getRowid = dbc.prepare('SELECT rowid AS rid FROM decisions WHERE sha = ?');
|
|
54
|
+
|
|
55
|
+
let decisions = 0;
|
|
56
|
+
const batches = chunk(metas, 400);
|
|
57
|
+
for (let bi = 0; bi < batches.length; bi++) {
|
|
58
|
+
const batch = batches[bi];
|
|
59
|
+
tx(dbc, function () {
|
|
60
|
+
for (const com of batch) {
|
|
61
|
+
const files = stats.get(com.sha) || [];
|
|
62
|
+
const paths = files.map(function (f) { return f.path; });
|
|
63
|
+
const added = files.reduce(function (a, f) { return a + f.added; }, 0);
|
|
64
|
+
const removed = files.reduce(function (a, f) { return a + f.removed; }, 0);
|
|
65
|
+
const bot = isBot(com.author, com.email) ? 1 : 0;
|
|
66
|
+
insertCommit.run(com.sha, com.ts, com.iso, com.author, com.email, com.subject, com.body, com.parents.join(' '), bot, files.length, added, removed, paths.slice(0, 40).join(' '));
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
const status = f.added > 0 && f.removed > 0 ? 'M' : f.added > 0 ? 'A' : 'D';
|
|
69
|
+
insertFile.run(com.sha, f.path, f.added, f.removed, status);
|
|
70
|
+
}
|
|
71
|
+
const rat = extractRationale(com.subject, com.body);
|
|
72
|
+
const title = cleanSubject(com.subject);
|
|
73
|
+
const kind = hasFixWord(title) || hasFixWord(com.body.slice(0, 400)) ? 'fix' : isTrivial(title) ? 'chore' : 'change';
|
|
74
|
+
let confidence = rat.confidence;
|
|
75
|
+
if (bot) confidence = Math.min(confidence, 0.2);
|
|
76
|
+
if (isTrivial(title)) confidence = Math.min(confidence, 0.25);
|
|
77
|
+
if (files.length > 60) confidence = Math.min(confidence, 0.5);
|
|
78
|
+
deleteFts.run(com.sha);
|
|
79
|
+
deleteTri.run(com.sha);
|
|
80
|
+
insertDecision.run(com.sha, com.ts, title, rat.rationale, paths.slice(0, 24).join(' '), kind, confidence, rat.source);
|
|
81
|
+
const rid = getRowid.get(com.sha);
|
|
82
|
+
if (rid) {
|
|
83
|
+
insertFts.run(rid.rid, title, rat.rationale, paths.slice(0, 24).join(' '), title);
|
|
84
|
+
insertTri.run(rid.rid, title, rat.rationale, paths.slice(0, 24).join(' '));
|
|
85
|
+
}
|
|
86
|
+
decisions += 1;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (o.onProgress) o.onProgress('index', Math.min((bi + 1) * 400, metas.length), metas.length);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setMeta(dbc, 'head', head);
|
|
93
|
+
setMeta(dbc, 'indexed_at', String(Date.now()));
|
|
94
|
+
setMeta(dbc, 'commits_total', String(Number(getMeta(dbc, 'commits_total') || 0) + metas.length));
|
|
95
|
+
setMeta(dbc, 'shallow', isShallow(cwd) ? '1' : '0');
|
|
96
|
+
setMeta(dbc, 'root', String(cwd));
|
|
97
|
+
const elapsed = Date.now() - t0;
|
|
98
|
+
setMeta(dbc, 'last_seconds', String(elapsed));
|
|
99
|
+
const st = dbStats(dbc);
|
|
100
|
+
dbc.close();
|
|
101
|
+
return { commits: metas.length, decisions: decisions, elapsed: elapsed, since: since, files: st.files, total: st.commits };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compute per-file risk metrics from commit_files. */
|
|
105
|
+
export function computeRisk(dbc, opts) {
|
|
106
|
+
const o = opts || {};
|
|
107
|
+
const rows = dbc.prepare(
|
|
108
|
+
'SELECT cf.path AS path, COUNT(*) AS commits, COUNT(DISTINCT c.email) AS authors, SUM(cf.added) AS added, SUM(cf.removed) AS removed, MAX(c.ts) AS last_ts, SUM(CASE WHEN c.is_bot = 0 AND (LOWER(c.subject) LIKE \'%fix%\' OR LOWER(c.subject) LIKE \'%revert%\' OR LOWER(c.subject) LIKE \'%bug%\' OR LOWER(c.subject) LIKE \'%hotfix%\' OR LOWER(c.subject) LIKE \'%patch%\') THEN 1 ELSE 0 END) AS fixes FROM commit_files cf JOIN commits c ON c.sha = cf.sha GROUP BY cf.path'
|
|
109
|
+
).all();
|
|
110
|
+
const del = dbc.prepare('DELETE FROM risk');
|
|
111
|
+
const ins = dbc.prepare('INSERT INTO risk (path, score, commits, authors, fixes, todos, last_ts, added, removed) VALUES (?,?,?,?,?,?,?,?,?)');
|
|
112
|
+
const now = Date.now() / 1000;
|
|
113
|
+
tx(dbc, function () {
|
|
114
|
+
del.run();
|
|
115
|
+
for (const r of rows) {
|
|
116
|
+
const churn = (r.added || 0) + (r.removed || 0);
|
|
117
|
+
const fixRatio = r.commits ? r.fixes / r.commits : 0;
|
|
118
|
+
const ageDays = Math.max(0, (now - (r.last_ts || now)) / 86400);
|
|
119
|
+
const freshness = 1 / (1 + ageDays / 180);
|
|
120
|
+
let score =
|
|
121
|
+
Math.log10(1 + churn) * (1 + fixRatio * 1.5) * (1 + Math.min(r.authors, 8) / 6) * (0.6 + 0.4 * freshness);
|
|
122
|
+
if (/(^|\/)(tests?|__tests__|specs?|fixtures?)\//.test(r.path)) score *= 0.55;
|
|
123
|
+
if (/\.(rst|md|mdx|txt)$/.test(r.path)) score *= 0.5;
|
|
124
|
+
if (/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|go\.sum|Cargo\.lock)$/.test(r.path)) score *= 0.2;
|
|
125
|
+
ins.run(r.path, score, r.commits, r.authors, r.fixes, 0, r.last_ts, r.added, r.removed);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return rows.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Count TODO/FIXME-ish markers in the working tree, one pass, cheap. */
|
|
132
|
+
export function scanMarkers(dbc, cwd) {
|
|
133
|
+
const files = lsFiles(cwd).filter(function (p) {
|
|
134
|
+
return /\.(js|jsx|mjs|cjs|ts|tsx|py|rb|go|rs|java|kt|c|h|cc|cpp|hpp|cs|php|swift|sh|ps1|lua|ex|exs|vue|svelte|sql|md|css|scss)$/i.test(p);
|
|
135
|
+
});
|
|
136
|
+
const upd = dbc.prepare('UPDATE risk SET todos = ? WHERE path = ?');
|
|
137
|
+
const RE = /\b(TODO|FIXME|HACK|XXX|BUG)\b/g;
|
|
138
|
+
let total = 0;
|
|
139
|
+
tx(dbc, function () {
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
let text = '';
|
|
142
|
+
try { text = fs.readFileSync(path.join(cwd, f), 'utf8'); } catch { continue; }
|
|
143
|
+
if (text.length > 400000) continue;
|
|
144
|
+
const m = text.match(RE);
|
|
145
|
+
const n = m ? m.length : 0;
|
|
146
|
+
if (n) { upd.run(n, f); total += n; }
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return { files: files.length, markers: total };
|
|
150
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** One-command wiring into agent tools. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { c } from './util.js';
|
|
5
|
+
import { header, renderNote } from './ui.js';
|
|
6
|
+
|
|
7
|
+
const START = '<!-- whygit:start -->';
|
|
8
|
+
const END = '<!-- whygit:end -->';
|
|
9
|
+
|
|
10
|
+
function agentsBlock() {
|
|
11
|
+
return [
|
|
12
|
+
START,
|
|
13
|
+
'## Why the code is the way it is',
|
|
14
|
+
'',
|
|
15
|
+
'This repository keeps a generated WHYLOG.md: every non-obvious decision, with the commit and date behind it.',
|
|
16
|
+
'',
|
|
17
|
+
'Before changing code whose intent is not obvious:',
|
|
18
|
+
'',
|
|
19
|
+
'1. Search WHYLOG.md for the file you are about to touch.',
|
|
20
|
+
'2. If a line there contradicts your plan, trust it until you can explain why it no longer applies.',
|
|
21
|
+
'3. When you make a non-obvious change, put a WHY: <reason> line in the commit body.',
|
|
22
|
+
'',
|
|
23
|
+
'Prefer the MCP tools when available: why_search, why_line, why_file, why_risk, whylog_read.',
|
|
24
|
+
END,
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeAgentsFile(cwd) {
|
|
29
|
+
const file = path.join(cwd, 'AGENTS.md');
|
|
30
|
+
let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
31
|
+
const block = agentsBlock();
|
|
32
|
+
const re = new RegExp(START + '[\\s\\S]*?' + END);
|
|
33
|
+
if (re.test(text)) text = text.replace(re, block);
|
|
34
|
+
else text = (text.trim() ? text.trim() + '\n\n' : '') + block + '\n';
|
|
35
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function serverEntry(command, args) {
|
|
40
|
+
return { command: command, args: args };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mergeJson(file, key, entry) {
|
|
44
|
+
let cur = {};
|
|
45
|
+
if (fs.existsSync(file)) {
|
|
46
|
+
try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { cur = {}; }
|
|
47
|
+
}
|
|
48
|
+
cur[key] = Object.assign({}, cur[key], { whygit: entry });
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(cur, null, 2) + '\n', 'utf8');
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function installFor(agent, cwd, opts) {
|
|
54
|
+
const o = opts || {};
|
|
55
|
+
const cmd = o.command || 'npx';
|
|
56
|
+
const args = o.command ? ['mcp'] : ['-y', 'whygit@latest', 'mcp'];
|
|
57
|
+
const done = [];
|
|
58
|
+
if (agent === 'claude' || agent === 'all') {
|
|
59
|
+
done.push(mergeJson(path.join(cwd, '.mcp.json'), 'mcpServers', serverEntry(cmd, args)));
|
|
60
|
+
}
|
|
61
|
+
if (agent === 'cursor' || agent === 'all') {
|
|
62
|
+
fs.mkdirSync(path.join(cwd, '.cursor'), { recursive: true });
|
|
63
|
+
done.push(mergeJson(path.join(cwd, '.cursor', 'mcp.json'), 'mcpServers', serverEntry(cmd, args)));
|
|
64
|
+
}
|
|
65
|
+
if (agent === 'vscode' || agent === 'all') {
|
|
66
|
+
fs.mkdirSync(path.join(cwd, '.vscode'), { recursive: true });
|
|
67
|
+
const f = path.join(cwd, '.vscode', 'mcp.json');
|
|
68
|
+
let cur = {};
|
|
69
|
+
if (fs.existsSync(f)) { try { cur = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { cur = {}; } }
|
|
70
|
+
cur.servers = Object.assign({}, cur.servers, { whygit: { type: 'stdio', command: cmd, args: args } });
|
|
71
|
+
fs.writeFileSync(f, JSON.stringify(cur, null, 2) + '\n', 'utf8');
|
|
72
|
+
done.push(f);
|
|
73
|
+
}
|
|
74
|
+
if (agent === 'agents' || agent === 'all') done.push(writeAgentsFile(cwd));
|
|
75
|
+
return done;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function cmdInstall(args, flags) {
|
|
79
|
+
const cwd = flags.cwd || process.cwd();
|
|
80
|
+
const agent = flags.agent || args[0] || 'all';
|
|
81
|
+
const known = ['claude', 'cursor', 'vscode', 'agents', 'all'];
|
|
82
|
+
if (!known.includes(agent)) {
|
|
83
|
+
console.log(renderNote('Unknown agent: ' + agent + '. Use one of ' + known.join(', '), 'warn'));
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
const written = installFor(agent, cwd, { command: flags.local ? 'whygit' : null });
|
|
87
|
+
console.log(header('install', agent));
|
|
88
|
+
console.log('');
|
|
89
|
+
for (const f of written) console.log(' ' + c.green('\u2713') + ' ' + path.relative(cwd, f));
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(c.gray(' Restart your editor / agent session to pick up the MCP server.'));
|
|
92
|
+
if (!flags.local) console.log(c.gray(' Using npx whygit. Add --local to point at an installed binary instead.'));
|
|
93
|
+
console.log('');
|
|
94
|
+
return 0;
|
|
95
|
+
}
|