whygit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +275 -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/cli.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** whygit command line. */
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { c, width } from './util.js';
|
|
4
|
+
import { cmdWhy } from './cmd-why.js';
|
|
5
|
+
import { cmdStory } from './cmd-story.js';
|
|
6
|
+
import { cmdCursed } from './cmd-cursed.js';
|
|
7
|
+
import { cmdCheck } from './cmd-check.js';
|
|
8
|
+
import { cmdInit, cmdDoctor, cmdStatus } from './cmd-init.js';
|
|
9
|
+
import { cmdMcp } from './mcp.js';
|
|
10
|
+
import { cmdInstall } from './install.js';
|
|
11
|
+
import { loadContext, repoLabel } from './context.js';
|
|
12
|
+
import { buildWhylog, writeWhylog } from './whylog.js';
|
|
13
|
+
import { header } from './ui.js';
|
|
14
|
+
|
|
15
|
+
const VERSION = '0.1.0';
|
|
16
|
+
|
|
17
|
+
const HELP = [
|
|
18
|
+
'',
|
|
19
|
+
' ' + c.bold(c.cyan('whygit')) + c.gray(' git blame tells you who. whygit tells you why.'),
|
|
20
|
+
'',
|
|
21
|
+
c.bold(' USAGE'),
|
|
22
|
+
' whygit <command> [options]',
|
|
23
|
+
'',
|
|
24
|
+
c.bold(' QUESTIONS'),
|
|
25
|
+
' whygit why <words...> search history for the reason behind a decision',
|
|
26
|
+
' whygit why <file>:<line> explain one line: who wrote it and why',
|
|
27
|
+
' whygit story <file> narrate one file from birth to HEAD',
|
|
28
|
+
' whygit cursed rank files by churn x fixes x authors',
|
|
29
|
+
'',
|
|
30
|
+
c.bold(' ARTIFACTS'),
|
|
31
|
+
' whygit whylog [--write] print or write WHYLOG.md',
|
|
32
|
+
' whygit check [--base ref] explanation coverage for a change set (CI gate)',
|
|
33
|
+
'',
|
|
34
|
+
c.bold(' AGENTS'),
|
|
35
|
+
' whygit mcp run the MCP server on stdio',
|
|
36
|
+
' whygit install [agent] wire into claude / cursor / vscode / agents',
|
|
37
|
+
'',
|
|
38
|
+
c.bold(' HOUSEKEEPING'),
|
|
39
|
+
' whygit init [--full] build or refresh the index',
|
|
40
|
+
' whygit doctor diagnose the setup',
|
|
41
|
+
' whygit status what is indexed',
|
|
42
|
+
'',
|
|
43
|
+
c.bold(' OPTIONS'),
|
|
44
|
+
' --json machine-readable output',
|
|
45
|
+
' -n, --limit <n> number of results',
|
|
46
|
+
' --cwd <dir> run against another repository',
|
|
47
|
+
' --base <ref> base for check (default origin/HEAD)',
|
|
48
|
+
' --min <0..1> check threshold (default 0.5)',
|
|
49
|
+
' --full reindex everything',
|
|
50
|
+
' --local install: use the whygit binary, not npx',
|
|
51
|
+
' -v, --version print version',
|
|
52
|
+
'',
|
|
53
|
+
c.gray(' Every command accepts --json. Nothing leaves your machine.'),
|
|
54
|
+
'',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
export function parseArgs(argv) {
|
|
58
|
+
const flags = {};
|
|
59
|
+
const args = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < argv.length) {
|
|
62
|
+
const a = argv[i];
|
|
63
|
+
if (a === '--') { args.push.apply(args, argv.slice(i + 1)); break; }
|
|
64
|
+
if (a.startsWith('--')) {
|
|
65
|
+
const eq = a.indexOf('=');
|
|
66
|
+
const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
67
|
+
let val = eq > 0 ? a.slice(eq + 1) : null;
|
|
68
|
+
if (val == null && i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
|
|
69
|
+
const maybe = argv[i + 1];
|
|
70
|
+
if (['json','full','local','quiet','write','help','version','no-color','all'].includes(key)) val = true;
|
|
71
|
+
else { val = maybe; i += 1; }
|
|
72
|
+
}
|
|
73
|
+
flags[key] = val == null ? true : val;
|
|
74
|
+
i += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (a === '-n' || a === '-q' || a === '-v' || a === '-h') {
|
|
78
|
+
if (a === '-n') { flags.limit = argv[i + 1]; i += 2; continue; }
|
|
79
|
+
if (a === '-q') { flags.quiet = true; i += 1; continue; }
|
|
80
|
+
if (a === '-v') { flags.version = true; i += 1; continue; }
|
|
81
|
+
if (a === '-h') { flags.help = true; i += 1; continue; }
|
|
82
|
+
}
|
|
83
|
+
args.push(a);
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
return { args: args, flags: flags };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cmdWhylog(args, flags) {
|
|
90
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
91
|
+
const label = repoLabel(ctx);
|
|
92
|
+
if (flags.write) {
|
|
93
|
+
const res = writeWhylog(ctx, { label: label, limit: Number(flags.limit) || 40 });
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(' ' + c.green('\u2713') + ' wrote ' + c.bold(res.file) + c.gray(' (' + res.decisions + ' decisions, ' + Math.round(res.bytes / 1024) + ' KB)'));
|
|
96
|
+
console.log('');
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(buildWhylog(ctx, { label: label, limit: Number(flags.limit) || 40 }));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function main(argv) {
|
|
104
|
+
const parsed = parseArgs(argv);
|
|
105
|
+
const flags = parsed.flags;
|
|
106
|
+
const args = parsed.args;
|
|
107
|
+
if (flags.version) { console.log(VERSION); return 0; }
|
|
108
|
+
if (flags.help) { console.log(HELP); return 0; }
|
|
109
|
+
const cmd = args[0];
|
|
110
|
+
const rest = args.slice(1);
|
|
111
|
+
if (flags['no-color']) process.env.NO_COLOR = '1';
|
|
112
|
+
switch (cmd) {
|
|
113
|
+
case undefined:
|
|
114
|
+
try { return cmdStatus([], flags); } catch (err) { console.log(HELP); return 0; }
|
|
115
|
+
case 'why': return cmdWhy(rest, flags);
|
|
116
|
+
case 'story': return cmdStory(rest, flags);
|
|
117
|
+
case 'cursed': return cmdCursed(rest, flags);
|
|
118
|
+
case 'whylog': return cmdWhylog(rest, flags);
|
|
119
|
+
case 'check': return cmdCheck(rest, flags);
|
|
120
|
+
case 'init': return cmdInit(rest, flags);
|
|
121
|
+
case 'doctor': return cmdDoctor(rest, flags);
|
|
122
|
+
case 'status': return cmdStatus(rest, flags);
|
|
123
|
+
case 'mcp': return cmdMcp(rest, flags);
|
|
124
|
+
case 'install': return cmdInstall(rest, flags);
|
|
125
|
+
case 'help': console.log(HELP); return 0;
|
|
126
|
+
case 'version': console.log(VERSION); return 0;
|
|
127
|
+
default:
|
|
128
|
+
console.error('whygit: unknown command ' + JSON.stringify(cmd));
|
|
129
|
+
console.log(HELP);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/cmd-check.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** whygit check - explanation coverage for a change set (CI gate). */
|
|
2
|
+
import { c, plural, pct } from './util.js';
|
|
3
|
+
import { loadContext, repoLabel } from './context.js';
|
|
4
|
+
import { diffHunks, blamePorcelain, mergeBase, revParse } from './git.js';
|
|
5
|
+
import { decisionFor } from './search.js';
|
|
6
|
+
import { header, renderNote } from './ui.js';
|
|
7
|
+
|
|
8
|
+
export function cmdCheck(args, flags) {
|
|
9
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
10
|
+
const base = flags.base || 'origin/HEAD';
|
|
11
|
+
let baseSha = revParse(ctx.cwd, base);
|
|
12
|
+
if (!baseSha) baseSha = mergeBase(ctx.cwd, 'HEAD', base);
|
|
13
|
+
if (!baseSha) {
|
|
14
|
+
console.log(renderNote('Cannot resolve base ' + base + '. Pass --base <ref>.', 'warn'));
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
const files = diffHunks(ctx.cwd, baseSha, 'HEAD');
|
|
18
|
+
const blameCache = new Map();
|
|
19
|
+
const results = [];
|
|
20
|
+
let total = 0;
|
|
21
|
+
let explained = 0;
|
|
22
|
+
let newTodos = 0;
|
|
23
|
+
for (const f of files) {
|
|
24
|
+
if (f.isBinary) continue;
|
|
25
|
+
if (!blameCache.has(f.path)) blameCache.set(f.path, blamePorcelain(ctx.cwd, f.path, null));
|
|
26
|
+
const blame = blameCache.get(f.path);
|
|
27
|
+
let lines = 0;
|
|
28
|
+
let ok = 0;
|
|
29
|
+
for (const h of f.hunks) {
|
|
30
|
+
for (let i = 0; i < h.added.length; i++) {
|
|
31
|
+
const lineNo = h.newStart + i;
|
|
32
|
+
const b = blame[lineNo - 1];
|
|
33
|
+
lines += 1;
|
|
34
|
+
if (/\b(TODO|FIXME|HACK|XXX)\b/.test(h.added[i])) newTodos += 1;
|
|
35
|
+
if (!b) continue;
|
|
36
|
+
const d = decisionFor(ctx.db, b.sha);
|
|
37
|
+
if (d && d.confidence >= 0.5 && d.rationale) ok += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!lines) continue;
|
|
41
|
+
total += lines;
|
|
42
|
+
explained += ok;
|
|
43
|
+
results.push({ path: f.path, lines: lines, explained: ok, ratio: ok / lines });
|
|
44
|
+
}
|
|
45
|
+
results.sort(function (a, b) { return a.ratio - b.ratio; });
|
|
46
|
+
const coverage = total ? explained / total : 1;
|
|
47
|
+
if (flags.json) {
|
|
48
|
+
console.log(JSON.stringify({ base: baseSha, coverage: coverage, lines: total, explained: explained, newTodos: newTodos, files: results }, null, 2));
|
|
49
|
+
return coverage >= (flags.min == null ? 0.5 : flags.min) ? 0 : 1;
|
|
50
|
+
}
|
|
51
|
+
console.log(header('check', repoLabel(ctx) + ' ' + base + '..HEAD'));
|
|
52
|
+
console.log('');
|
|
53
|
+
console.log(' ' + c.bold('explanation coverage') + ' ' + colorPct(coverage) + c.gray(' (' + explained + '/' + total + ' changed lines trace to a commit that said why)'));
|
|
54
|
+
console.log('');
|
|
55
|
+
const worst = results.filter(function (r) { return r.ratio < 0.5; }).slice(0, 8);
|
|
56
|
+
if (worst.length) {
|
|
57
|
+
console.log(c.gray(' thin explanations:'));
|
|
58
|
+
for (const r of worst) {
|
|
59
|
+
console.log(' ' + c.red((' ' + Math.round(r.ratio * 100) + '%').padStart(4)) + ' ' + r.path + c.gray(' ' + plural(r.lines, 'line')));
|
|
60
|
+
}
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(c.gray(' these lines trace back to commits that never said why.'));
|
|
63
|
+
console.log(c.gray(' fix: amend the message, or add a WHY: <reason> line to the commit body.'));
|
|
64
|
+
} else {
|
|
65
|
+
console.log(renderNote('Every changed line traces back to a stated reason. Nicely done.', 'ok'));
|
|
66
|
+
}
|
|
67
|
+
if (newTodos) {
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(renderNote(newTodos + ' new TODO/FIXME marker(s) added - they will outlive you.', 'warn'));
|
|
70
|
+
}
|
|
71
|
+
console.log('');
|
|
72
|
+
const min = flags.min == null ? 0.5 : flags.min;
|
|
73
|
+
return coverage >= min ? 0 : 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function colorPct(v) {
|
|
77
|
+
const s = Math.round(v * 100) + '%';
|
|
78
|
+
if (v >= 0.8) return c.green(s);
|
|
79
|
+
if (v >= 0.5) return c.yellow(s);
|
|
80
|
+
return c.red(s);
|
|
81
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** whygit cursed - the files that keep coming back. */
|
|
2
|
+
import { c, isoDate, truncate, plural } from './util.js';
|
|
3
|
+
import { loadContext, repoLabel } from './context.js';
|
|
4
|
+
import { topRisk } from './risk.js';
|
|
5
|
+
import { header, bar, termWidth, renderNote } from './ui.js';
|
|
6
|
+
import { lsFiles } from './git.js';
|
|
7
|
+
|
|
8
|
+
export function cmdCursed(args, flags) {
|
|
9
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
10
|
+
const n = Number(flags.limit || flags.n) || 15;
|
|
11
|
+
const alive = new Set(lsFiles(ctx.cwd));
|
|
12
|
+
const scored = topRisk(ctx.db, Math.max(n * 6, 120));
|
|
13
|
+
const rows = scored.filter(function (r) { return alive.has(r.path); }).slice(0, n);
|
|
14
|
+
if (flags.json) {
|
|
15
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
const max = Math.max.apply(null, rows.map(function (r) { return r.score; }).concat([1]));
|
|
19
|
+
console.log(header('cursed files', repoLabel(ctx) + ' ' + plural(ctx.stats.files, 'file') + ' tracked'));
|
|
20
|
+
console.log('');
|
|
21
|
+
const width = Math.max.apply(null, rows.map(function (r) { return r.path.length; }).concat([20]));
|
|
22
|
+
rows.forEach(function (r, i) {
|
|
23
|
+
const flame = '\ud83d\udd25'.repeat(Math.max(1, Math.min(5, Math.round((r.score / max) * 5))));
|
|
24
|
+
console.log(
|
|
25
|
+
' ' + c.gray(String(i + 1).padStart(2)) + ' ' +
|
|
26
|
+
c.bold(truncate(r.path, Math.min(width, termWidth() - 46)).padEnd(Math.min(width, termWidth() - 46))) + ' ' +
|
|
27
|
+
bar(r.score, max, 14) + ' ' +
|
|
28
|
+
c.gray(String(Math.round(r.score * 10) / 10).padStart(5)) + ' ' +
|
|
29
|
+
c.gray(plural(r.commits, 'commit')) + c.gray(' \u00b7 ') + c.gray(r.authors + ' authors') + c.gray(' \u00b7 ') +
|
|
30
|
+
(r.fixes ? c.red(r.fixes + ' fixes') : c.gray('0 fixes'))
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log(c.gray(' score = churn \u00d7 fix-density \u00d7 author-spread \u00d7 freshness. Not a judgement, a map.'));
|
|
35
|
+
console.log(c.gray(' Start here: ') + c.bold('whygit story <file>'));
|
|
36
|
+
console.log('');
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
package/src/cmd-init.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** whygit init / doctor / status. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { c } from './util.js';
|
|
5
|
+
import { loadContext, repoLabel, resolveRepo } from './context.js';
|
|
6
|
+
import { gitDir, git, headSha, isShallow, revParse } from './git.js';
|
|
7
|
+
import { buildIndex, computeRisk, scanMarkers, indexPathFor } from './indexer.js';
|
|
8
|
+
import { getMeta, dbStats } from './db.js';
|
|
9
|
+
import { header, kv, renderNote } from './ui.js';
|
|
10
|
+
|
|
11
|
+
export function cmdInit(args, flags) {
|
|
12
|
+
const cwd = resolveRepo(flags.cwd);
|
|
13
|
+
const full = Boolean(flags.full) || !fs.existsSync(indexPathFor(gitDir(cwd)));
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
const res = buildIndex({ cwd: cwd, full: full });
|
|
16
|
+
const ctx = loadContext({ cwd: cwd });
|
|
17
|
+
const files = computeRisk(ctx.db);
|
|
18
|
+
const markers = scanMarkers(ctx.db, cwd);
|
|
19
|
+
const stats = dbStats(ctx.db);
|
|
20
|
+
if (flags.json) {
|
|
21
|
+
console.log(JSON.stringify({ indexed: res, stats: stats, files: files, markers: markers }, null, 2));
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
console.log(header('index', repoLabel(ctx)));
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(kv([
|
|
27
|
+
['commits', stats.commits.toLocaleString('en-US') + c.gray(' (' + res.commits + ' new this run)')],
|
|
28
|
+
['decisions', stats.decisions.toLocaleString('en-US')],
|
|
29
|
+
['files', stats.files.toLocaleString('en-US')],
|
|
30
|
+
['markers', markers.markers + c.gray(' TODO/FIXME in ' + markers.files + ' files')],
|
|
31
|
+
['time', ((Date.now() - t0) / 1000).toFixed(2) + 's'],
|
|
32
|
+
]));
|
|
33
|
+
console.log('');
|
|
34
|
+
if (isShallow(cwd)) console.log(renderNote('Shallow clone: history is partial. git fetch --unshallow for the full story.', 'warn'));
|
|
35
|
+
if (!stats.commits) console.log(renderNote('No commits indexed - is this a fresh repo?', 'warn'));
|
|
36
|
+
console.log(renderNote('Next: ' + c.bold('whygit why "timeout"') + ' or ' + c.bold('whygit cursed'), 'ok'));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cmdDoctor(args, flags) {
|
|
41
|
+
const cwd = resolveRepo(flags.cwd);
|
|
42
|
+
const gd = gitDir(cwd);
|
|
43
|
+
const checks = [];
|
|
44
|
+
const add = function (name, ok, detail) { checks.push({ name: name, ok: ok, detail: detail }); };
|
|
45
|
+
add('git', Boolean(headSha(cwd)), (function () { try { return git(['--version'], { cwd: cwd }).trim(); } catch { return 'not found'; } })());
|
|
46
|
+
add('repository', true, cwd);
|
|
47
|
+
const idx = indexPathFor(gd);
|
|
48
|
+
const has = fs.existsSync(idx);
|
|
49
|
+
add('index', has, has ? idx + ' (' + Math.round(fs.statSync(idx).size / 1024) + ' KB)' : 'missing - run whygit init');
|
|
50
|
+
if (has) {
|
|
51
|
+
const ctx = loadContext({ cwd: cwd });
|
|
52
|
+
const head = headSha(cwd);
|
|
53
|
+
const meta = getMeta(ctx.db, 'head');
|
|
54
|
+
add('up to date', meta === head, meta === head ? 'indexed at ' + String(meta).slice(0, 8) : 'stale: indexed ' + String(meta).slice(0, 8) + ' vs HEAD ' + String(head).slice(0, 8));
|
|
55
|
+
add('shallow', !isShallow(cwd), isShallow(cwd) ? 'partial history' : 'full history');
|
|
56
|
+
const wl = path.join(cwd, 'WHYLOG.md');
|
|
57
|
+
add('whylog', fs.existsSync(wl), fs.existsSync(wl) ? 'WHYLOG.md present' : 'not written yet - whygit whylog --write');
|
|
58
|
+
}
|
|
59
|
+
if (flags.json) {
|
|
60
|
+
console.log(JSON.stringify(checks, null, 2));
|
|
61
|
+
return checks.every(function (x) { return x.ok; }) ? 0 : 1;
|
|
62
|
+
}
|
|
63
|
+
console.log(header('doctor', repoLabel({ cwd: cwd, webUrl: null })));
|
|
64
|
+
console.log('');
|
|
65
|
+
for (const ch of checks) {
|
|
66
|
+
console.log(' ' + (ch.ok ? c.green('\u2713') : c.red('\u2717')) + ' ' + ch.name.padEnd(12) + ' ' + c.gray(ch.detail));
|
|
67
|
+
}
|
|
68
|
+
console.log('');
|
|
69
|
+
return checks.every(function (x) { return x.ok; }) ? 0 : 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function cmdStatus(args, flags) {
|
|
73
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
74
|
+
if (flags.json) {
|
|
75
|
+
console.log(JSON.stringify({ repo: repoLabel(ctx), stats: ctx.stats, head: ctx.head, branch: ctx.branch }, null, 2));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
console.log(header('whygit', repoLabel(ctx)));
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(kv([
|
|
81
|
+
['commits', ctx.stats.commits.toLocaleString('en-US')],
|
|
82
|
+
['decisions', ctx.stats.decisions.toLocaleString('en-US')],
|
|
83
|
+
['files', ctx.stats.files.toLocaleString('en-US')],
|
|
84
|
+
['branch', ctx.branch || '(detached)'],
|
|
85
|
+
['head', String(ctx.head || '').slice(0, 8)],
|
|
86
|
+
['whylog', fs.existsSync(path.join(ctx.cwd, 'WHYLOG.md')) ? c.green('yes') : c.gray('no')],
|
|
87
|
+
]));
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(c.gray(' whygit why <question> \u00b7 whygit story <file> \u00b7 whygit cursed'));
|
|
90
|
+
console.log('');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
package/src/cmd-story.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** whygit story - narrate one file's life. */
|
|
2
|
+
import { c, isoDate, truncate, wrapText, plural, clamp } from './util.js';
|
|
3
|
+
import { loadContext, repoLabel } from './context.js';
|
|
4
|
+
import { decisionsForFile, fileRow, authorsForFile, birthOf, yearHistogram, chapterCandidates } from './risk.js';
|
|
5
|
+
import { header, bar, termWidth, renderNote } from './ui.js';
|
|
6
|
+
import { lsFiles } from './git.js';
|
|
7
|
+
|
|
8
|
+
export function cmdStory(args, flags) {
|
|
9
|
+
const file = String(args[0] || '').replace(/\\/g, '/');
|
|
10
|
+
if (!file) {
|
|
11
|
+
console.error('usage: whygit story <path>');
|
|
12
|
+
return 2;
|
|
13
|
+
}
|
|
14
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
15
|
+
let target = file;
|
|
16
|
+
if (!ctx.db.prepare('SELECT 1 FROM commit_files WHERE path = ? LIMIT 1').get(target)) {
|
|
17
|
+
const matches = lsFiles(ctx.cwd).filter(function (p) { return p.toLowerCase().includes(file.toLowerCase()); });
|
|
18
|
+
if (matches.length === 1) target = matches[0];
|
|
19
|
+
else if (matches.length > 1) {
|
|
20
|
+
console.log(renderNote('Multiple files match: ' + matches.slice(0, 6).join(', '), 'warn'));
|
|
21
|
+
return 0;
|
|
22
|
+
} else {
|
|
23
|
+
console.log(renderNote('No history for ' + file + ' - it may be untracked or never committed.', 'warn'));
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const rows = decisionsForFile(ctx.db, target, 4000);
|
|
28
|
+
const stats = fileRow(ctx.db, target);
|
|
29
|
+
const authors = authorsForFile(ctx.db, target);
|
|
30
|
+
const birth = birthOf(ctx.db, target) || rows[0];
|
|
31
|
+
if (flags.json) {
|
|
32
|
+
console.log(JSON.stringify({ file: target, stats: stats, birth: birth, authors: authors, decisions: rows }, null, 2));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
console.log(header('story', repoLabel(ctx) + ' ' + target));
|
|
36
|
+
if (birth) {
|
|
37
|
+
console.log(c.gray(' born ') + isoDate(birth.ts) + c.gray(' \u00b7 ') + c.bold(truncate(birth.title, 70)));
|
|
38
|
+
}
|
|
39
|
+
const hist = yearHistogram(ctx.db, target);
|
|
40
|
+
const years = new Map();
|
|
41
|
+
for (const h of hist) years.set(h.year, h.n);
|
|
42
|
+
const yKeys = Array.from(years.keys()).sort();
|
|
43
|
+
if (yKeys.length) {
|
|
44
|
+
const counts = yKeys.map(function (y) { return years.get(y); });
|
|
45
|
+
const max = Math.max.apply(null, counts);
|
|
46
|
+
console.log('');
|
|
47
|
+
const labelW = 6;
|
|
48
|
+
for (let i = 0; i < yKeys.length; i++) {
|
|
49
|
+
const s = String(yKeys[i]);
|
|
50
|
+
console.log(' ' + c.gray(s.padStart(labelW)) + ' ' + bar(counts[i], max, Math.min(40, termWidth() - 16)) + ' ' + c.gray(counts[i] + ''));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(c.gray(' ' + (stats ? plural(stats.commits, 'commit') + ' \u00b7 ' + stats.authors + ' authors \u00b7 ' + (stats.fixes || 0) + ' fixes \u00b7 ' + (stats.todos || 0) + ' TODO markers' : '')));
|
|
55
|
+
const scored = chapterCandidates(ctx.db, target).map(function (r) {
|
|
56
|
+
const churn = Math.log10(2 + (r.added || 0) + (r.removed || 0));
|
|
57
|
+
const s = (0.25 + (r.confidence || 0)) * churn * (r.kind === 'fix' ? 1.4 : 1);
|
|
58
|
+
return Object.assign({}, r, { weight: s });
|
|
59
|
+
});
|
|
60
|
+
console.log(c.gray(' \u2500\u2500\u2500 the plot ' + '\u2500'.repeat(Math.max(0, Math.min(50, termWidth() - 20)))));
|
|
61
|
+
const top = scored.slice().sort(function (a, b) { return b.weight - a.weight; }).slice(0, 24);
|
|
62
|
+
const ordered = top.slice().sort(function (a, b) { return a.ts - b.ts; });
|
|
63
|
+
const pick = ordered.length > 8 ? sample(ordered, 8) : ordered;
|
|
64
|
+
for (const r of pick) {
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log(' ' + c.gray(isoDate(r.ts)) + ' ' + c.bold(truncate(r.title, termWidth() - 16)));
|
|
67
|
+
if (r.rationale) {
|
|
68
|
+
const lines = wrapText(r.rationale, termWidth() - 10, ' ');
|
|
69
|
+
for (const l of lines) console.log(c.white(l));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (authors.length) {
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(c.gray(' who kept it alive: ') + authors.slice(0, 5).map(function (a) { return a.author + ' (' + a.n + ')'; }).join(', '));
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sample(arr, n) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const step = Math.max(1, Math.floor(arr.length / n));
|
|
83
|
+
for (let i = 0; i < arr.length && out.length < n; i += step) out.push(arr[i]);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
package/src/cmd-why.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** whygit why - the main question. */
|
|
2
|
+
import { c, isoDate, truncate, wrapText, plural } from './util.js';
|
|
3
|
+
import { parsePathLine } from './util.js';
|
|
4
|
+
import { loadContext, repoLabel } from './context.js';
|
|
5
|
+
import { search, decisionFor, decisionsForPath } from './search.js';
|
|
6
|
+
import { renderDecision, header, termWidth, renderNote } from './ui.js';
|
|
7
|
+
import { blamePorcelain, commitUrl, blameUrl, lsFiles } from './git.js';
|
|
8
|
+
|
|
9
|
+
export function cmdWhy(args, flags) {
|
|
10
|
+
const query = args.join(' ').trim();
|
|
11
|
+
if (!query) {
|
|
12
|
+
console.error('usage: whygit why <words...> | <file>:<line>');
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
15
|
+
const ctx = loadContext({ cwd: flags.cwd });
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
const pl = parsePathLine(query);
|
|
18
|
+
const isLine = pl.line != null && /[./\\]/.test(pl.path);
|
|
19
|
+
let results = [];
|
|
20
|
+
let alias = null;
|
|
21
|
+
if (isLine) {
|
|
22
|
+
const target = resolvePath(ctx, pl.path);
|
|
23
|
+
if (!target) {
|
|
24
|
+
console.log(header('whygit', repoLabel(ctx)));
|
|
25
|
+
console.log(renderNote('No file named ' + c.bold(pl.path) + ' at HEAD. History may know it under another path.', 'warn'));
|
|
26
|
+
console.log(renderNote('Tip: ' + c.bold('whygit story <file>') + ' narrates one file from birth to HEAD.'));
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
if (target !== pl.path) alias = target;
|
|
30
|
+
results = whyLine(ctx, target, pl.line);
|
|
31
|
+
} else {
|
|
32
|
+
results = whyQuery(ctx, query, flags);
|
|
33
|
+
}
|
|
34
|
+
if (flags.json) {
|
|
35
|
+
console.log(JSON.stringify(results, null, 2));
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
if (!results.length) {
|
|
39
|
+
console.log(header('whygit', repoLabel(ctx)));
|
|
40
|
+
console.log(renderNote('No decision matched. Try fewer words, or a file path.', 'warn'));
|
|
41
|
+
console.log(renderNote('Tip: ' + c.bold('whygit story <file>') + ' narrates one file from birth to HEAD.'));
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
console.log(header('whygit', repoLabel(ctx) + ' ' + ctx.stats.commits.toLocaleString('en-US') + ' commits'));
|
|
45
|
+
const max = flags.n || flags.limit || (isLine ? 4 : 5);
|
|
46
|
+
results.slice(0, max).forEach(function (d, i) {
|
|
47
|
+
const extra = [];
|
|
48
|
+
if (alias && i === 0) extra.push(c.gray('resolved ') + c.bold(alias) + c.gray(' (path moved; history kept)'));
|
|
49
|
+
if (d.alive != null) {
|
|
50
|
+
extra.push(c.gray('line ' + pl.line + ' is ') + c.cyan(d.alive + ' lines') + c.gray(' of this change, still in HEAD'));
|
|
51
|
+
}
|
|
52
|
+
if (ctx.webUrl && d.sha) {
|
|
53
|
+
const url = commitUrl(ctx.webUrl, d.sha);
|
|
54
|
+
if (url) extra.push(c.gray('\u2192 ') + c.underline(c.dim(url)));
|
|
55
|
+
}
|
|
56
|
+
console.log(renderDecision(d, { index: i + 1, extra: extra }));
|
|
57
|
+
});
|
|
58
|
+
const ms = Date.now() - started;
|
|
59
|
+
console.log('');
|
|
60
|
+
const shown = Math.min(results.length, max);
|
|
61
|
+
const label = results.length > shown ? shown + ' of ' + results.length + ' decisions' : plural(results.length, 'decision');
|
|
62
|
+
console.log(c.gray(' ' + label + ' \u00b7 ' + ms + 'ms \u00b7 whygit why "' + truncate(query, 40) + '"'));
|
|
63
|
+
console.log('');
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function whyQuery(ctx, query, flags) {
|
|
68
|
+
const rows = search(ctx.db, query, { limit: (flags.n || 5) * 3 });
|
|
69
|
+
return rows.map(function (r) {
|
|
70
|
+
return Object.assign({}, r, { ts: r.ts });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolvePath(ctx, p) {
|
|
75
|
+
const files = lsFiles(ctx.cwd);
|
|
76
|
+
const set = new Set(files);
|
|
77
|
+
if (set.has(p)) return p;
|
|
78
|
+
const bySuffix = files.filter(function (f) { return f.endsWith('/' + p) || f.endsWith(p); });
|
|
79
|
+
return bySuffix.length === 1 ? bySuffix[0] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function whyLine(ctx, file, line) {
|
|
83
|
+
const blame = blamePorcelain(ctx.cwd, file, null);
|
|
84
|
+
const entry = blame[line - 1];
|
|
85
|
+
if (!entry) return [];
|
|
86
|
+
const d = decisionFor(ctx.db, entry.sha);
|
|
87
|
+
const out = [];
|
|
88
|
+
if (d) {
|
|
89
|
+
const same = blame.filter(function (b) { return b && b.sha === entry.sha; }).length;
|
|
90
|
+
out.push(Object.assign({}, d, { alive: same, lineText: entry.text }));
|
|
91
|
+
} else {
|
|
92
|
+
out.push({
|
|
93
|
+
sha: entry.sha,
|
|
94
|
+
ts: entry.ts,
|
|
95
|
+
title: '(commit not in index - run whygit init --full)',
|
|
96
|
+
rationale: '',
|
|
97
|
+
kind: 'change',
|
|
98
|
+
paths: file,
|
|
99
|
+
alive: 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const history = decisionsForPath(ctx.db, file, 6).filter(function (x) { return x.sha !== entry.sha; });
|
|
103
|
+
for (const h of history) out.push(Object.assign({}, h, { kind: h.kind }));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Shared command context: locate repo, open index, auto-build when missing. */
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { isRepo, repoRoot, gitDir, headSha, branchName, remoteWebUrl } from './git.js';
|
|
4
|
+
import { openIndex, dbStats, getMeta } from './db.js';
|
|
5
|
+
import { buildIndex, computeRisk } from './indexer.js';
|
|
6
|
+
import { c, warn, die } from './util.js';
|
|
7
|
+
|
|
8
|
+
export function resolveRepo(cwd) {
|
|
9
|
+
const start = cwd || process.cwd();
|
|
10
|
+
if (!isRepo(start)) die('not inside a git repository (run this from your project, or pass --cwd)');
|
|
11
|
+
return repoRoot(start);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadContext(opts) {
|
|
15
|
+
const o = opts || {};
|
|
16
|
+
const cwd = resolveRepo(o.cwd);
|
|
17
|
+
const gd = gitDir(cwd);
|
|
18
|
+
let db = openIndex(gd, { create: false });
|
|
19
|
+
if (!db) {
|
|
20
|
+
if (o.noAuto) die('no index yet - run ' + c.bold('whygit init'));
|
|
21
|
+
process.stderr.write(c.dim('whygit: building index (first run, one pass over history)...\n'));
|
|
22
|
+
const res = buildIndex({ cwd: cwd, quiet: true });
|
|
23
|
+
process.stderr.write(c.dim('whygit: indexed ' + res.commits + ' commits in ' + (res.elapsed / 1000).toFixed(2) + 's\n'));
|
|
24
|
+
db = openIndex(gd, { create: false });
|
|
25
|
+
}
|
|
26
|
+
const head = headSha(cwd);
|
|
27
|
+
const indexedHead = getMeta(db, 'head');
|
|
28
|
+
const empty = dbStats(db).commits === 0;
|
|
29
|
+
if ((empty || (head && indexedHead && head !== indexedHead)) && o.refresh !== false) {
|
|
30
|
+
try {
|
|
31
|
+
buildIndex({ cwd: cwd, quiet: true });
|
|
32
|
+
db.close();
|
|
33
|
+
db = openIndex(gd, { create: false });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
warn('could not refresh index: ' + err.message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
cwd: cwd,
|
|
40
|
+
gitDir: gd,
|
|
41
|
+
db: db,
|
|
42
|
+
head: head,
|
|
43
|
+
branch: branchName(cwd),
|
|
44
|
+
webUrl: remoteWebUrl(cwd),
|
|
45
|
+
stats: dbStats(db),
|
|
46
|
+
root: getMeta(db, 'root') || cwd,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function repoLabel(ctx) {
|
|
51
|
+
const web = ctx.webUrl;
|
|
52
|
+
if (web) {
|
|
53
|
+
const m = web.match(/^https?:\/\/[^/]+\/(.+)$/);
|
|
54
|
+
if (m) return m[1];
|
|
55
|
+
}
|
|
56
|
+
return ctx.cwd.split(/[\\/]/).pop();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ensureIndex(cwd, full) {
|
|
60
|
+
return buildIndex({ cwd: cwd, full: full });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { computeRisk };
|
package/src/db.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** SQLite storage for the whygit index. */
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const SCHEMA_VERSION = '2';
|
|
7
|
+
|
|
8
|
+
const SCHEMA = [
|
|
9
|
+
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);",
|
|
10
|
+
"CREATE TABLE IF NOT EXISTS commits (sha TEXT PRIMARY KEY, ts INTEGER, iso TEXT, author TEXT, email TEXT, subject TEXT, body TEXT, parents TEXT, is_bot INTEGER DEFAULT 0, files_changed INTEGER DEFAULT 0, added INTEGER DEFAULT 0, removed INTEGER DEFAULT 0, paths TEXT DEFAULT '');",
|
|
11
|
+
"CREATE TABLE IF NOT EXISTS commit_files (sha TEXT, path TEXT, added INTEGER, removed INTEGER, status TEXT, PRIMARY KEY (sha, path));",
|
|
12
|
+
"CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);",
|
|
13
|
+
"CREATE TABLE IF NOT EXISTS decisions (sha TEXT PRIMARY KEY, ts INTEGER, title TEXT, rationale TEXT, paths TEXT, kind TEXT, confidence REAL, evidence TEXT);",
|
|
14
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(title, rationale, paths, subject, tokenize='porter unicode61');",
|
|
15
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS decisions_tri USING fts5(title, rationale, paths, tokenize='trigram');",
|
|
16
|
+
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, line INTEGER, sha TEXT, body TEXT, ts INTEGER);",
|
|
17
|
+
"CREATE TABLE IF NOT EXISTS risk (path TEXT PRIMARY KEY, score REAL, commits INTEGER, authors INTEGER, fixes INTEGER, todos INTEGER, last_ts INTEGER, added INTEGER, removed INTEGER);",
|
|
18
|
+
"CREATE TABLE IF NOT EXISTS blame_cache (path TEXT, head TEXT, data TEXT, PRIMARY KEY (path, head));",
|
|
19
|
+
].join(' ');
|
|
20
|
+
|
|
21
|
+
export function indexPath(gitDirPath) {
|
|
22
|
+
return path.join(gitDirPath, 'whygit', 'index.db');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function openIndex(gitDirPath, opts) {
|
|
26
|
+
const o = opts || {};
|
|
27
|
+
const file = indexPath(gitDirPath);
|
|
28
|
+
if (!o.create && !fs.existsSync(file)) return null;
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
const dbc = new DatabaseSync(file);
|
|
31
|
+
dbc.exec('PRAGMA journal_mode = WAL;');
|
|
32
|
+
dbc.exec('PRAGMA synchronous = NORMAL;');
|
|
33
|
+
dbc.exec('PRAGMA temp_store = MEMORY;');
|
|
34
|
+
dbc.exec(SCHEMA);
|
|
35
|
+
const v = getMeta(dbc, 'schema');
|
|
36
|
+
if (v && v !== SCHEMA_VERSION) {
|
|
37
|
+
for (const t of ['decisions_fts', 'decisions_tri', 'decisions', 'commits', 'commit_files', 'risk', 'blame_cache']) {
|
|
38
|
+
dbc.exec('DROP TABLE IF EXISTS ' + t + ';');
|
|
39
|
+
}
|
|
40
|
+
dbc.exec('DELETE FROM meta;');
|
|
41
|
+
dbc.exec(SCHEMA);
|
|
42
|
+
}
|
|
43
|
+
setMeta(dbc, 'schema', SCHEMA_VERSION);
|
|
44
|
+
return dbc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getMeta(dbc, key) {
|
|
48
|
+
const row = dbc.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
49
|
+
return row ? row.value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setMeta(dbc, key, value) {
|
|
53
|
+
dbc.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function tx(dbc, fn) {
|
|
57
|
+
dbc.exec('BEGIN');
|
|
58
|
+
try {
|
|
59
|
+
const out = fn();
|
|
60
|
+
dbc.exec('COMMIT');
|
|
61
|
+
return out;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
dbc.exec('ROLLBACK');
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function dbStats(dbc) {
|
|
69
|
+
const one = function (sql) { const r = dbc.prepare(sql).get(); return r ? Object.values(r)[0] : 0; };
|
|
70
|
+
return {
|
|
71
|
+
commits: one('SELECT COUNT(*) FROM commits'),
|
|
72
|
+
decisions: one('SELECT COUNT(*) FROM decisions'),
|
|
73
|
+
files: one('SELECT COUNT(DISTINCT path) FROM commit_files'),
|
|
74
|
+
notes: one('SELECT COUNT(*) FROM notes'),
|
|
75
|
+
};
|
|
76
|
+
}
|