@ok-ggwp/git-gloss 0.4.0 → 0.6.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 +49 -3
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- package/dist/batch.js +129 -0
- package/dist/cli.js +2 -1
- package/dist/commands/ary.js +3 -3
- package/dist/commands/lint.js +96 -0
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +117 -6
- package/dist/commands/show.js +14 -6
- package/dist/commands/signals.js +9 -8
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +61 -5
- package/dist/findings.js +254 -0
- package/dist/floor.js +39 -12
- package/dist/git.js +18 -2
- package/dist/html/client.js +56 -11
- package/dist/html/data.js +20 -3
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +39 -1
- package/dist/loader.js +6 -2
- package/dist/plain.js +10 -0
- package/dist/render.js +6 -4
- package/dist/reviews.js +112 -4
- package/dist/safe-match.js +5 -3
- package/dist/schema.js +10 -3
- package/dist/selection.js +21 -0
- package/dist/signals.js +13 -3
- package/dist/validate.js +8 -3
- package/package.json +2 -2
- package/skills/gloss/SKILL.md +26 -3
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/dist/batch.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { GlossError } from './git.js';
|
|
3
|
+
import { checkDecision, MAX_DECISION_LINE } from './reviews.js';
|
|
4
|
+
import { findingTexts } from './findings.js';
|
|
5
|
+
import { plain } from './plain.js';
|
|
6
|
+
// Limits for `reviews --decide-file`, checked before anything is parsed or written.
|
|
7
|
+
export const MAX_BATCH_ENTRIES = 500;
|
|
8
|
+
export const MAX_BATCH_BYTES = 1024 * 1024;
|
|
9
|
+
export const TEMPLATE_TEXT = 80;
|
|
10
|
+
// Reads a file, or stdin for "-", never more than MAX_BATCH_BYTES: a read that
|
|
11
|
+
// goes past it is refused without holding the rest (also for a pipe or /dev/zero).
|
|
12
|
+
export function readBounded(src) {
|
|
13
|
+
const what = src === '-' ? 'stdin' : plain(src);
|
|
14
|
+
const fail = (e) => new GlossError(`--decide-file: can't read ${what}: ${e.code ?? 'error'}`);
|
|
15
|
+
const buf = Buffer.allocUnsafe(MAX_BATCH_BYTES + 1);
|
|
16
|
+
let fd = 0, total = 0;
|
|
17
|
+
try {
|
|
18
|
+
if (src !== '-')
|
|
19
|
+
fd = openSync(src, 'r');
|
|
20
|
+
// Until end of input; a full buffer means the input is over the limit.
|
|
21
|
+
while (total < buf.length) {
|
|
22
|
+
let n;
|
|
23
|
+
try {
|
|
24
|
+
n = readSync(fd, buf, total, buf.length - total, null);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// A non-blocking pipe with nothing yet: wait a little (no busy spin) and ask again.
|
|
28
|
+
if (e.code !== 'EAGAIN')
|
|
29
|
+
throw e;
|
|
30
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (n === 0)
|
|
34
|
+
break;
|
|
35
|
+
total += n;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
throw fail(e);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (fd !== 0)
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
}
|
|
45
|
+
if (total > MAX_BATCH_BYTES)
|
|
46
|
+
throw new GlossError(`--decide-file: the input is over ${MAX_BATCH_BYTES / 1024 / 1024} MiB; split the decisions into several files`);
|
|
47
|
+
return buf.toString('utf8', 0, total);
|
|
48
|
+
}
|
|
49
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
50
|
+
// Accepts a bare array of {finding, outcome, note?}, or {review?, complete?: boolean,
|
|
51
|
+
// decisions: [...]}. Unknown keys are ignored. Everything is checked here; nothing is written.
|
|
52
|
+
export function parseBatch(text, reviewId) {
|
|
53
|
+
let data;
|
|
54
|
+
try {
|
|
55
|
+
data = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw new GlossError(`--decide-file: not valid JSON (${e instanceof RangeError ? 'nested too deeply' : plain(e.message).slice(0, 120)})`);
|
|
59
|
+
}
|
|
60
|
+
let list;
|
|
61
|
+
let complete = false;
|
|
62
|
+
if (Array.isArray(data))
|
|
63
|
+
list = data;
|
|
64
|
+
else if (isObject(data)) {
|
|
65
|
+
const d = data.decisions;
|
|
66
|
+
const c = data.complete;
|
|
67
|
+
if (c !== undefined && typeof c !== 'boolean')
|
|
68
|
+
throw new GlossError('--decide-file: "complete" must be true or false');
|
|
69
|
+
complete = c === true;
|
|
70
|
+
const r = data.review;
|
|
71
|
+
if (r !== undefined && r !== '' && r !== reviewId)
|
|
72
|
+
throw new GlossError(`--decide-file: the file is for review ${plain(String(r)).slice(0, 80)}, not ${plain(reviewId)}`);
|
|
73
|
+
if (!Array.isArray(d))
|
|
74
|
+
throw new GlossError('--decide-file: the object needs a "decisions" array');
|
|
75
|
+
list = d;
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
throw new GlossError('--decide-file: expected an array of {finding, outcome, note?}, or an object with a "decisions" array');
|
|
79
|
+
if (list.length > MAX_BATCH_ENTRIES)
|
|
80
|
+
throw new GlossError(`--decide-file: ${list.length} entries; at most ${MAX_BATCH_ENTRIES} per file`);
|
|
81
|
+
if (!list.length && !complete)
|
|
82
|
+
throw new GlossError('--decide-file: no decisions in the file');
|
|
83
|
+
// addReviews keeps a repeated line once, so a repeated finding would be counted but not written.
|
|
84
|
+
const firstAt = new Map();
|
|
85
|
+
const entries = list.map((e, i) => {
|
|
86
|
+
const at = `--decide-file: entry ${i + 1}`;
|
|
87
|
+
if (!isObject(e))
|
|
88
|
+
throw new GlossError(`${at} is not an object`);
|
|
89
|
+
const finding = e.finding, outcome = e.outcome, note = e.note;
|
|
90
|
+
if (typeof finding !== 'string')
|
|
91
|
+
throw new GlossError(`${at} has no "finding" text`);
|
|
92
|
+
const label = `${at} ("${plain(finding).slice(0, 40)}")`;
|
|
93
|
+
if (outcome === undefined || outcome === '')
|
|
94
|
+
throw new GlossError(`${label} has an empty outcome; fill it in or delete the row`);
|
|
95
|
+
if (typeof outcome !== 'string')
|
|
96
|
+
throw new GlossError(`${label}: "outcome" must be a string`);
|
|
97
|
+
if (note !== undefined && typeof note !== 'string')
|
|
98
|
+
throw new GlossError(`${label}: "note" must be a string`);
|
|
99
|
+
try {
|
|
100
|
+
checkDecision({ finding, outcome, note });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof GlossError)
|
|
104
|
+
throw new GlossError(`${label}: ${err.message.replace(/^--/, '')}`);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const seen = firstAt.get(finding.trim());
|
|
108
|
+
if (seen !== undefined)
|
|
109
|
+
throw new GlossError(`${label} repeats entry ${seen}`);
|
|
110
|
+
firstAt.set(finding.trim(), i + 1);
|
|
111
|
+
return { finding: finding.trim(), outcome: outcome, ...(note ? { note } : {}) };
|
|
112
|
+
});
|
|
113
|
+
return { entries, complete };
|
|
114
|
+
}
|
|
115
|
+
// A record that, as written, is a line readers would skip (escapes make control characters longer).
|
|
116
|
+
export function checkLength(record, what) {
|
|
117
|
+
if (JSON.stringify(record).length > MAX_DECISION_LINE)
|
|
118
|
+
throw new GlossError(`${what} is too long to record: the finding and note, as written, exceed what readers accept; shorten them`);
|
|
119
|
+
}
|
|
120
|
+
// The skeleton `--decide-template` prints: one row per ref, outcome left empty.
|
|
121
|
+
export function template(reviewId, findings) {
|
|
122
|
+
const rows = findingTexts(findings);
|
|
123
|
+
if (rows.length > MAX_BATCH_ENTRIES)
|
|
124
|
+
throw new GlossError(`review ${plain(reviewId)} has ${rows.length} findings; --decide-file takes at most ${MAX_BATCH_ENTRIES}`);
|
|
125
|
+
return {
|
|
126
|
+
review: reviewId, complete: false,
|
|
127
|
+
decisions: rows.map(([ref, text]) => ({ finding: `${ref}${ref.includes('.') ? '' : ':'} ${[...plain(text).replace(/\s+/g, ' ').trim()].slice(0, TEMPLATE_TEXT).join('')}`.trim(), outcome: '', note: '' })),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as html from './commands/html.js';
|
|
|
7
7
|
import * as hunks from './commands/hunks.js';
|
|
8
8
|
import * as init from './commands/init.js';
|
|
9
9
|
import * as review from './commands/review.js';
|
|
10
|
+
import * as lint from './commands/lint.js';
|
|
10
11
|
import * as reviews from './commands/reviews.js';
|
|
11
12
|
import * as schema from './commands/schema.js';
|
|
12
13
|
import * as show from './commands/show.js';
|
|
@@ -15,7 +16,7 @@ import * as sync from './commands/sync.js';
|
|
|
15
16
|
import * as versions from './commands/versions.js';
|
|
16
17
|
import * as why from './commands/why.js';
|
|
17
18
|
const COMMANDS = {
|
|
18
|
-
init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
19
|
+
init, hunks, signals, lint, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
19
20
|
};
|
|
20
21
|
function help() {
|
|
21
22
|
return 'usage: git gloss <command> [options]\n\n' +
|
package/dist/commands/ary.js
CHANGED
|
@@ -2,10 +2,10 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { createAry } from '../ary/server.js';
|
|
3
3
|
import { GlossError, git } from '../git.js';
|
|
4
4
|
import { openInBrowser } from './html.js';
|
|
5
|
-
export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page';
|
|
5
|
+
export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page (the html view with branch pickers)';
|
|
6
6
|
// Starts a read-only server on this machine and keeps running until stopped.
|
|
7
|
-
// The browser page
|
|
8
|
-
//
|
|
7
|
+
// The browser page is the same view as `git gloss html`, with branch pickers
|
|
8
|
+
// and a revision box in its top bar; each choice loads a new page.
|
|
9
9
|
export function run(argv) {
|
|
10
10
|
const { values } = parseArgs({
|
|
11
11
|
args: argv,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { GlossError, resolveCommits } from '../git.js';
|
|
3
|
+
import { loadGloss } from '../loader.js';
|
|
4
|
+
import { ratingIssues, tryAnalyze, whyText } from '../floor.js';
|
|
5
|
+
import { attentionRank, impactRank } from '../attention.js';
|
|
6
|
+
import { servesOf } from '../schema.js';
|
|
7
|
+
import { unknownServes } from '../validate.js';
|
|
8
|
+
import { plain } from '../plain.js';
|
|
9
|
+
export const usage = 'lint [<commit> | wip] [--json] advisory checks of a gloss that a reviewer would make by eye (always exits 0 for findings)';
|
|
10
|
+
// What removal_impact says when it admits the group is not needed. Matched on the
|
|
11
|
+
// lowercased text, after plain(): BARE phrases only when they are the whole text
|
|
12
|
+
// ("Nothing." but not "Nothing else would work"); the others may start it.
|
|
13
|
+
const NOTHING_BREAKS_WHOLE = ['nothing', 'none', 'n/a'];
|
|
14
|
+
const NOTHING_BREAKS_START = ['nothing breaks', 'nothing would break', 'nothing is lost', 'nothing happens', 'no effect', 'no impact'];
|
|
15
|
+
export function saysNothingBreaks(text) {
|
|
16
|
+
const t = plain(text).toLowerCase().trim().replace(/[\s.!;:,]+$/, '');
|
|
17
|
+
if (NOTHING_BREAKS_WHOLE.includes(t))
|
|
18
|
+
return true;
|
|
19
|
+
return NOTHING_BREAKS_START.some((p) => t === p || (t.startsWith(p) && /^[\s.,;:!-]/.test(t.slice(p.length))));
|
|
20
|
+
}
|
|
21
|
+
export function run(argv) {
|
|
22
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { json: { type: 'boolean' } } });
|
|
23
|
+
const arg = positionals[0] ?? 'HEAD';
|
|
24
|
+
const { shas, range } = resolveCommits(arg);
|
|
25
|
+
if (range)
|
|
26
|
+
throw new GlossError(`${arg}: lint takes one commit or wip, not a range`);
|
|
27
|
+
const sha = shas[0];
|
|
28
|
+
const gloss = loadGloss(sha);
|
|
29
|
+
if (!gloss)
|
|
30
|
+
throw new GlossError(`${sha.slice(0, 10)} has no gloss (annotate it with \`git gloss add\`)`);
|
|
31
|
+
for (const w of gloss.warnings)
|
|
32
|
+
console.error(`warning: ${plain(w)}`);
|
|
33
|
+
const a = gloss.note.annotation;
|
|
34
|
+
const findings = [];
|
|
35
|
+
// Text from a note is sanitized here, once, for both outputs.
|
|
36
|
+
const add = (code, message, at = {}) => findings.push({ code, ...(at.group !== undefined && { group: plain(at.group) }), ...(at.hunk !== undefined && { hunk: plain(at.hunk) }), message: plain(message) });
|
|
37
|
+
const ids = new Set(a.groups.map((g) => g.id));
|
|
38
|
+
const an = tryAnalyze(gloss);
|
|
39
|
+
if (an) {
|
|
40
|
+
const issues = ratingIssues(a, an);
|
|
41
|
+
for (const u of issues.unrated)
|
|
42
|
+
add('no-impact', `no impact rated${u.signal ? ` (floor ${u.floor}: ${whyText(u.why)})` : ''}`, { group: u.id });
|
|
43
|
+
for (const b of issues.belowFloor)
|
|
44
|
+
add('impact-below-floor', `impact "${b.impact}" is below its floor "${b.floor}" (${whyText(b.why)})`, { group: b.id });
|
|
45
|
+
if (issues.attention)
|
|
46
|
+
add('attention-below-floor', `attention "${issues.attention.level}" is below the computed floor "${issues.attention.floor}" (${issues.attention.why}) and has no override_reason`);
|
|
47
|
+
const sigOf = new Map(an.signals.hunks.map((s) => [s.id, s]));
|
|
48
|
+
for (const g of gloss.groups) {
|
|
49
|
+
const f = an.groups.get(g.group.id);
|
|
50
|
+
// A mechanical group made only of lockfiles, docs, tests or generated files sits on a path-class floor honestly (the skill's lockfile example).
|
|
51
|
+
const real = g.hunks.some((h) => h.current && !sigOf.get(h.current.id)?.inert);
|
|
52
|
+
if (g.group.necessity === 'mechanical' && f && impactRank(f.floor) > impactRank('module') && real) {
|
|
53
|
+
add('mechanical-on-floor', `graded mechanical but its floor is "${f.floor}" (${whyText(f.why)})`, { group: g.group.id });
|
|
54
|
+
}
|
|
55
|
+
if (f?.testsOnly && !servesOf(g.group).some((t) => t !== 'task' && ids.has(t) && t !== g.group.id)) {
|
|
56
|
+
add('test-serves-nothing', 'a test group that serves no existing group (set serves to the groups it verifies)', { group: g.group.id });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const g of a.groups) {
|
|
61
|
+
const bad = unknownServes(g, ids);
|
|
62
|
+
if (bad.length)
|
|
63
|
+
add('unknown-serves', `serves names no such group: ${bad.map((t) => `"${t}"`).join(', ')}`, { group: g.id });
|
|
64
|
+
if (g.necessity === 'required' && saysNothingBreaks(g.removal_impact)) {
|
|
65
|
+
add('required-but-nothing-breaks', `graded required but removal_impact says: "${g.removal_impact}"`, { group: g.id });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const level = a.attention?.level ?? an?.derived.level;
|
|
69
|
+
if (level && attentionRank(level) >= attentionRank('careful') && !a.risks?.length) {
|
|
70
|
+
add('missing-risks', `attention is ${level} but no risks are listed`);
|
|
71
|
+
}
|
|
72
|
+
for (const u of gloss.unannotated) {
|
|
73
|
+
add('uncovered-hunks', `${u.hunk.file}: ${u.lines ? `changed lines ${[...u.lines].join(',')}` : 'the whole hunk'} not covered by any group`, { hunk: u.hunk.id });
|
|
74
|
+
}
|
|
75
|
+
if (values.json) {
|
|
76
|
+
console.log(JSON.stringify({ target: sha.slice(0, 10), findings, ...(an ? {} : { incomplete: true }) }, null, 2));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
const subjects = new Map();
|
|
80
|
+
for (const f of findings) {
|
|
81
|
+
const key = f.group !== undefined ? `group ${f.group}` : f.hunk !== undefined ? `hunk ${f.hunk}` : 'whole gloss';
|
|
82
|
+
subjects.set(key, [...(subjects.get(key) ?? []), f]);
|
|
83
|
+
}
|
|
84
|
+
for (const [key, fs] of subjects) {
|
|
85
|
+
console.log(key);
|
|
86
|
+
for (const f of fs)
|
|
87
|
+
console.log(` ${f.code.padEnd(28)} ${f.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (findings.length)
|
|
90
|
+
console.log(`${findings.length} finding${findings.length === 1 ? '' : 's'}`);
|
|
91
|
+
if (!an)
|
|
92
|
+
console.log('incomplete: the rating checks need the diff signals, which are unavailable (see the warning above)');
|
|
93
|
+
else if (!findings.length)
|
|
94
|
+
console.log('clean');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
package/dist/commands/review.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
+
import { plain } from '../plain.js';
|
|
2
3
|
import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
|
|
3
4
|
import { parseDiff } from '../diff.js';
|
|
4
5
|
import { refLabel, sliceText } from '../selection.js';
|
|
@@ -15,16 +16,16 @@ function fenced(lang, body) {
|
|
|
15
16
|
// Author text is JSON-quoted so it stays on one line and can't fake a heading
|
|
16
17
|
// or end the material section.
|
|
17
18
|
const q = (s) => JSON.stringify(s);
|
|
18
|
-
const hunkLine = (h) => `- ${h.id} ${h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ` :
|
|
19
|
+
const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file)} ` +
|
|
19
20
|
`(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
|
|
20
21
|
// The material for one commit: its gloss and the code under each group, or,
|
|
21
22
|
// for an unglossed commit in a range, its whole diff.
|
|
22
23
|
function commitMaterial(sha, gloss) {
|
|
23
24
|
if (!gloss) {
|
|
24
25
|
return [
|
|
25
|
-
`## Commit ${sha.slice(0, 10)}: ${commitSubject(sha)}`,
|
|
26
|
+
`## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
|
|
26
27
|
'This commit has no gloss. Review its changes as unexplained.',
|
|
27
|
-
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${h.file}\n${h.text}`).join('\n')),
|
|
28
|
+
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
|
|
28
29
|
];
|
|
29
30
|
}
|
|
30
31
|
const a = gloss.note.annotation;
|
|
@@ -47,7 +48,7 @@ function commitMaterial(sha, gloss) {
|
|
|
47
48
|
const sections = [
|
|
48
49
|
isWipSnapshot(sha)
|
|
49
50
|
? `## Uncommitted work (snapshot ${sha.slice(0, 10)})\n\nThese changes are not committed yet; the author glossed them before committing.`
|
|
50
|
-
: `## Commit ${sha.slice(0, 10)}: ${gloss.subject}`,
|
|
51
|
+
: `## Commit ${sha.slice(0, 10)}: ${plain(gloss.subject)}`,
|
|
51
52
|
...gloss.warnings.map((w) => `> **Warning from git-gloss:** ${w}`),
|
|
52
53
|
`**Task (author's statement):** ${q(a.task)}`,
|
|
53
54
|
...(a.summary ? [`**Summary (author's statement):** ${q(a.summary)}`] : []),
|
package/dist/commands/reviews.js
CHANGED
|
@@ -2,15 +2,34 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
|
|
5
|
-
import { addReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
5
|
+
import { addReviews, checkBy, checkDecision, isValidDecision, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
6
|
+
import { findingRefs, labelFindings, orderDecisions, refOf } from '../findings.js';
|
|
7
|
+
import { plain } from '../plain.js';
|
|
6
8
|
import { page, style } from '../render.js';
|
|
7
|
-
|
|
9
|
+
import { checkLength, parseBatch, readBounded, template } from '../batch.js';
|
|
10
|
+
export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings and what was decided about them\n reviews <target> --decide <review-id> --finding <text> --outcome <fixed|follow-up|rejected|accepted> [--note <text>] [--by <name>]\n reviews <target> --decide <review-id> --complete the arbiter is done\n reviews <target> --decide-template <review-id> print a JSON skeleton of the findings of a review to fill in\n reviews <target> --decide <review-id> --decide-file <file|-> record every decision of the filled-in skeleton at once, or none; --stamp, --record and --decide are for tools';
|
|
8
11
|
export function run(argv) {
|
|
9
12
|
const { values, positionals } = parseArgs({
|
|
10
13
|
args: argv,
|
|
11
14
|
allowPositionals: true,
|
|
12
|
-
options: {
|
|
15
|
+
options: {
|
|
16
|
+
stamp: { type: 'boolean' }, record: { type: 'boolean' }, decide: { type: 'string' }, finding: { type: 'string' },
|
|
17
|
+
outcome: { type: 'string' }, 'decide-file': { type: 'string' }, 'decide-template': { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
|
|
18
|
+
},
|
|
13
19
|
});
|
|
20
|
+
const batchFlag = values['decide-file'] !== undefined ? '--decide-file' : values['decide-template'] !== undefined ? '--decide-template' : null;
|
|
21
|
+
if (batchFlag) {
|
|
22
|
+
for (const k of ['finding', 'outcome', 'note', 'complete', 'stamp', 'record']) {
|
|
23
|
+
if (values[k] !== undefined)
|
|
24
|
+
throw new GlossError(`${batchFlag} cannot be combined with --${k}`);
|
|
25
|
+
}
|
|
26
|
+
if (values['decide-file'] !== undefined && values['decide-template'] !== undefined)
|
|
27
|
+
throw new GlossError('--decide-file and --decide-template cannot be combined');
|
|
28
|
+
if (batchFlag === '--decide-template' && (values.decide !== undefined || values.by !== undefined))
|
|
29
|
+
throw new GlossError('--decide-template takes the review id itself and no --decide or --by');
|
|
30
|
+
if (batchFlag === '--decide-file' && values.decide === undefined)
|
|
31
|
+
throw new GlossError('--decide-file goes with --decide <review-id>');
|
|
32
|
+
}
|
|
14
33
|
if (values.record) {
|
|
15
34
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
16
35
|
for (const k of ['stage', 'agent', 'target', 'findings']) {
|
|
@@ -28,6 +47,9 @@ export function run(argv) {
|
|
|
28
47
|
if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
|
|
29
48
|
throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
|
|
30
49
|
}
|
|
50
|
+
// Readers skip what is over these limits, so a record that is would vanish: refuse it here instead.
|
|
51
|
+
if (input.findings.length > MAX_FINDINGS)
|
|
52
|
+
throw new GlossError(`--record: the findings are too long (${input.findings.length} characters; readers skip reports over ${MAX_FINDINGS / 1024} KiB); shorten the report`);
|
|
31
53
|
const id = randomUUID();
|
|
32
54
|
const at = new Date().toISOString();
|
|
33
55
|
for (const [sha, seen] of Object.entries(input.stamp)) {
|
|
@@ -37,6 +59,8 @@ export function run(argv) {
|
|
|
37
59
|
target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
|
|
38
60
|
...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
|
|
39
61
|
};
|
|
62
|
+
if (JSON.stringify(record).length > MAX_REVIEW_LINE)
|
|
63
|
+
throw new GlossError(`--record: the record for ${sha.slice(0, 10)} is over ${MAX_REVIEW_LINE / 1024 / 1024} MiB with its stamp, which readers skip; review fewer changed lines at a time`);
|
|
40
64
|
addReviews(sha, [record]);
|
|
41
65
|
}
|
|
42
66
|
const n = Object.keys(input.stamp).length;
|
|
@@ -44,6 +68,85 @@ export function run(argv) {
|
|
|
44
68
|
return 0;
|
|
45
69
|
}
|
|
46
70
|
const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
71
|
+
const target = positionals[0] ?? 'HEAD';
|
|
72
|
+
// Every commit of the target holding that review gets the decision.
|
|
73
|
+
const holdersOf = (id) => {
|
|
74
|
+
const holders = shas.filter((sha) => readReviews(sha).some((r) => r.id === id));
|
|
75
|
+
if (!holders.length) {
|
|
76
|
+
const known = [...new Set(shas.flatMap(readReviews).map((r) => r.id))];
|
|
77
|
+
throw new GlossError(`no review ${id} on ${target}` + (known.length ? ` (reviews there: ${plain(known.join(', '))})` : ' (it has no reviews)'));
|
|
78
|
+
}
|
|
79
|
+
return holders;
|
|
80
|
+
};
|
|
81
|
+
const reportOf = (id, holders) => holders.flatMap(readReviews).find((r) => r.id === id);
|
|
82
|
+
// A finding that starts with a ref names a finding of the report: say so when there is none (never refuse).
|
|
83
|
+
const warnUnknownRef = (id, report, finding, hint = '') => {
|
|
84
|
+
const ref = refOf(finding);
|
|
85
|
+
if (ref !== null && report && !findingRefs(report.findings).has(ref))
|
|
86
|
+
console.error(`git-gloss: review ${plain(id)} has no finding ${ref} (${hint}see the refs in \`git gloss reviews ${target}\`); recording it anyway`);
|
|
87
|
+
};
|
|
88
|
+
if (values['decide-template'] !== undefined) {
|
|
89
|
+
const id = values['decide-template'];
|
|
90
|
+
const report = reportOf(id, holdersOf(id));
|
|
91
|
+
console.log(JSON.stringify(template(id, report.findings), null, 2));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (values['decide-file'] !== undefined) {
|
|
95
|
+
const id = values.decide;
|
|
96
|
+
// Everything is checked before the first write: one bad entry records nothing.
|
|
97
|
+
const batch = parseBatch(readBounded(values['decide-file']), id);
|
|
98
|
+
const holders = holdersOf(id);
|
|
99
|
+
const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
|
|
100
|
+
checkBy(by);
|
|
101
|
+
const at = new Date().toISOString();
|
|
102
|
+
const records = batch.entries.map((e, i) => {
|
|
103
|
+
const r = { decision: 1, review: id, at, by, finding: e.finding, outcome: e.outcome, ...(e.note ? { note: e.note } : {}) };
|
|
104
|
+
checkLength(r, `--decide-file: entry ${i + 1}`);
|
|
105
|
+
return r;
|
|
106
|
+
});
|
|
107
|
+
if (batch.complete)
|
|
108
|
+
records.push({ decision: 1, review: id, at, by, complete: true });
|
|
109
|
+
const report = reportOf(id, holders);
|
|
110
|
+
batch.entries.forEach((e, i) => warnUnknownRef(id, report, e.finding, `entry ${i + 1}; `));
|
|
111
|
+
// One compare-and-swap write per commit, whatever the number of records.
|
|
112
|
+
for (const sha of holders)
|
|
113
|
+
addReviews(sha, records);
|
|
114
|
+
const n = batch.entries.length;
|
|
115
|
+
console.log(`recorded ${n} decision${n === 1 ? '' : 's'} on ${holders.length} commit${holders.length === 1 ? '' : 's'}${batch.complete ? ' and marked the review complete' : ''}`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (values.decide !== undefined) {
|
|
119
|
+
const id = values.decide;
|
|
120
|
+
if (values.complete) {
|
|
121
|
+
if (!isValidDecision({ complete: true, finding: values.finding, outcome: values.outcome, note: values.note })) {
|
|
122
|
+
throw new GlossError('--complete marks the review as arbitrated; it takes no --finding, --outcome or --note');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
checkDecision(values);
|
|
127
|
+
const holders = holdersOf(id);
|
|
128
|
+
const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
|
|
129
|
+
checkBy(by);
|
|
130
|
+
const record = {
|
|
131
|
+
decision: 1, review: id, at: new Date().toISOString(), by,
|
|
132
|
+
...(values.complete ? { complete: true } : {
|
|
133
|
+
finding: values.finding.trim(), outcome: values.outcome,
|
|
134
|
+
...(values.note ? { note: values.note } : {}),
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
// Control characters are written as 6-byte escapes, so check the line itself against the reader's cap.
|
|
138
|
+
checkLength(record, 'that decision');
|
|
139
|
+
if (record.finding !== undefined)
|
|
140
|
+
warnUnknownRef(id, reportOf(id, holders), record.finding);
|
|
141
|
+
for (const sha of holders)
|
|
142
|
+
addReviews(sha, [record]);
|
|
143
|
+
console.log(`recorded ${values.complete ? 'the review as complete' : `"${record.finding}": ${record.outcome}`} on ${holders.length} commit${holders.length === 1 ? '' : 's'}`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
for (const k of ['finding', 'outcome', 'note', 'complete', 'by']) {
|
|
147
|
+
if (values[k] !== undefined)
|
|
148
|
+
throw new GlossError(`--${k} goes with --decide <review-id>`);
|
|
149
|
+
}
|
|
47
150
|
if (values.stamp) {
|
|
48
151
|
console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
|
|
49
152
|
return 0;
|
|
@@ -51,7 +154,8 @@ export function run(argv) {
|
|
|
51
154
|
const out = [];
|
|
52
155
|
for (const sha of shas) {
|
|
53
156
|
const statuses = reviewStatus(sha);
|
|
54
|
-
|
|
157
|
+
const decisions = readDecisions(sha);
|
|
158
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`);
|
|
55
159
|
if (!statuses.length) {
|
|
56
160
|
out.push(style('dim', ' no reviews recorded'), '');
|
|
57
161
|
continue;
|
|
@@ -59,8 +163,15 @@ export function run(argv) {
|
|
|
59
163
|
for (const s of statuses) {
|
|
60
164
|
out.push(` ${statusPhrase(s)}`);
|
|
61
165
|
for (const r of s.reviews) {
|
|
62
|
-
out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}`));
|
|
63
|
-
out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
|
|
166
|
+
out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}, id ${plain(r.id)}`));
|
|
167
|
+
out.push(...labelFindings(r.findings).trim().split('\n').map((l) => ` ${l}`));
|
|
168
|
+
const ds = decisions.filter((d) => d.review === r.id);
|
|
169
|
+
if (r.verdict !== 'clean' || ds.length) {
|
|
170
|
+
out.push('', ` decisions on this review: ${decisionSummary(ds)}`);
|
|
171
|
+
for (const d of orderDecisions(ds.filter((d) => d.outcome))) {
|
|
172
|
+
out.push(` ${d.outcome}: ${plain(d.finding)}${d.note ? ` — ${plain(d.note)}` : ''} ${style('dim', `(${plain(d.by)}, ${plain(d.at).slice(0, 10)})`)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
64
175
|
}
|
|
65
176
|
out.push('');
|
|
66
177
|
}
|
package/dist/commands/show.js
CHANGED
|
@@ -5,7 +5,8 @@ import { loadGloss } from '../loader.js';
|
|
|
5
5
|
import { impactRank } from '../attention.js';
|
|
6
6
|
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
7
|
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
8
|
-
import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
8
|
+
import { current, decisionSummary, readDecisions, readReviews, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
9
|
+
import { plain } from '../plain.js';
|
|
9
10
|
import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
10
11
|
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
|
|
11
12
|
// Counts over the lines each part selects, not whole hunks, so groups that
|
|
@@ -102,12 +103,12 @@ export function run(argv) {
|
|
|
102
103
|
out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
|
|
103
104
|
const gloss = glosses[i];
|
|
104
105
|
if (!gloss) {
|
|
105
|
-
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
|
|
106
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
|
|
106
107
|
return;
|
|
107
108
|
}
|
|
108
109
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
|
109
110
|
out.push(...(renderCommit(sha, gloss, values, only, false) ??
|
|
110
|
-
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
111
|
+
[`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
111
112
|
});
|
|
112
113
|
if (glossed)
|
|
113
114
|
out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
|
|
@@ -122,7 +123,14 @@ function reviewsLine(sha) {
|
|
|
122
123
|
if (!statuses.length)
|
|
123
124
|
return style('dim', 'reviews: none recorded');
|
|
124
125
|
const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
|
|
125
|
-
|
|
126
|
+
// What was decided about the reviews that found something (or didn't say);
|
|
127
|
+
// a commit with only clean reviews has nothing to decide.
|
|
128
|
+
const open = readReviews(sha).filter((r) => r.verdict !== 'clean');
|
|
129
|
+
const decisions = readDecisions(sha).filter((d) => open.some((r) => r.id === d.review));
|
|
130
|
+
const decided = open.length
|
|
131
|
+
? ` ${style('dim', '·')} decisions: ${decisionSummary(decisions, open.every((r) => decisions.some((d) => d.review === r.id && d.complete)))}`
|
|
132
|
+
: '';
|
|
133
|
+
return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}${decided}` +
|
|
126
134
|
style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
|
|
127
135
|
}
|
|
128
136
|
// One commit's view, or null if no group matches -g/--only. `hints` adds the
|
|
@@ -130,14 +138,14 @@ function reviewsLine(sha) {
|
|
|
130
138
|
function renderCommit(sha, gloss, values, only, hints) {
|
|
131
139
|
const a = gloss.note.annotation;
|
|
132
140
|
const short = sha.slice(0, 7);
|
|
133
|
-
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
|
|
141
|
+
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`];
|
|
134
142
|
out.push(...field('task:', a.task, 0));
|
|
135
143
|
if (a.summary)
|
|
136
144
|
out.push(...field('summary:', a.summary, 0));
|
|
137
145
|
for (const w of gloss.warnings)
|
|
138
146
|
out.push(...field('⚠', w, 0).map((l) => style('red', l)));
|
|
139
147
|
const an = tryAnalyze(gloss);
|
|
140
|
-
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
|
|
148
|
+
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived, timedOut: an.timedOut } : undefined), reviewsLine(sha));
|
|
141
149
|
const groups = gloss.groups
|
|
142
150
|
.filter((g) => matchesOnly(g.group, only))
|
|
143
151
|
.filter((g) => !values.group || values.group.includes(g.group.id));
|
package/dist/commands/signals.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { loadConfig } from '../config.js';
|
|
3
4
|
import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
|
|
@@ -17,11 +18,11 @@ export function summaryLines(sig, max = 6) {
|
|
|
17
18
|
const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
|
|
18
19
|
.sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
|
|
19
20
|
for (const f of files.slice(0, max))
|
|
20
|
-
out.push(` ${(f.floor + ':').padEnd(10)} ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
|
|
21
|
+
out.push(` ${(f.floor + ':').padEnd(10)} ${plain(f.file)}${f.why.length ? ` (${plain(f.why.join('; '))})` : ''}`);
|
|
21
22
|
if (files.length > max)
|
|
22
23
|
out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
|
|
23
24
|
for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
|
|
24
|
-
out.push(` large: ${r}`);
|
|
25
|
+
out.push(` large: ${plain(r)}`);
|
|
25
26
|
if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
26
27
|
out.push(` note: ${CONFIG_EDIT_NOTE}`);
|
|
27
28
|
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
@@ -31,16 +32,16 @@ function detailLines(sig, label) {
|
|
|
31
32
|
const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
|
|
32
33
|
style('dim', ` (floor impact: ${sig.floor.impact})`)];
|
|
33
34
|
for (const r of sig.floor.reasons)
|
|
34
|
-
out.push(` why: ${r}`);
|
|
35
|
+
out.push(` why: ${plain(r)}`);
|
|
35
36
|
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
36
37
|
const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
|
|
37
38
|
if (notable.length)
|
|
38
39
|
out.push('', style('bold', 'hunks with signals'));
|
|
39
40
|
for (const h of notable) {
|
|
40
|
-
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${h.file}` +
|
|
41
|
-
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + h.why.join('; ') : ''}`));
|
|
41
|
+
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${plain(h.file)}` +
|
|
42
|
+
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + plain(h.why.join('; ')) : ''}`));
|
|
42
43
|
for (const x of h.hits.slice(0, 8)) {
|
|
43
|
-
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', x.text)}`);
|
|
44
|
+
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.timedOut ? ' (pattern timed out: counted as matching)' : ''}${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', plain(x.text))}`);
|
|
44
45
|
}
|
|
45
46
|
if (h.hits.length > 8)
|
|
46
47
|
out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
|
|
@@ -51,9 +52,9 @@ function detailLines(sig, label) {
|
|
|
51
52
|
if (sig.dependencies.length)
|
|
52
53
|
out.push('', style('bold', 'dependency changes'));
|
|
53
54
|
for (const d of sig.dependencies) {
|
|
54
|
-
out.push(` ${d.file} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
55
|
+
out.push(` ${plain(d.file)} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
55
56
|
for (const b of d.bumps.slice(0, 20)) {
|
|
56
|
-
const what = b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}
|
|
57
|
+
const what = plain(b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`);
|
|
57
58
|
out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
|
|
58
59
|
}
|
|
59
60
|
if (d.bumps.length > 20)
|
package/dist/commands/sync.js
CHANGED
|
@@ -46,6 +46,18 @@ function shareable(remote, pushing) {
|
|
|
46
46
|
return new Set();
|
|
47
47
|
return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
|
|
48
48
|
}
|
|
49
|
+
// The fetched ref is the remote's to set: it must be a commit before notes
|
|
50
|
+
// code (or an update-ref onto our own notes ref) touches it.
|
|
51
|
+
function checkTracking(remote, ref, tracking) {
|
|
52
|
+
if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
|
|
53
|
+
gitTry(['update-ref', '-d', tracking]);
|
|
54
|
+
throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fetchTracking(remote, ref, tracking) {
|
|
58
|
+
git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
|
|
59
|
+
checkTracking(remote, ref, tracking);
|
|
60
|
+
}
|
|
49
61
|
const PUSH_REF = 'refs/notes/gloss-sync-tmp';
|
|
50
62
|
// One fetch → merge → push round. Returns null if the push lost a race with
|
|
51
63
|
// someone else's push and the round should be retried.
|
|
@@ -53,7 +65,7 @@ function round(remote, pushing, carried) {
|
|
|
53
65
|
const tracking = `refs/notes/remotes/${remote}/gloss`;
|
|
54
66
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
|
|
55
67
|
if (remoteHas)
|
|
56
|
-
|
|
68
|
+
fetchTracking(remote, NOTES_REF, tracking);
|
|
57
69
|
const local = noteList(NOTES_REF);
|
|
58
70
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
59
71
|
// The last state both sides shared. A gloss changed on one side only is
|
|
@@ -159,7 +171,7 @@ function syncReviews(remote, pushing) {
|
|
|
159
171
|
const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
|
|
160
172
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
|
|
161
173
|
if (remoteHas)
|
|
162
|
-
|
|
174
|
+
fetchTracking(remote, REVIEWS_REF, tracking);
|
|
163
175
|
const before = noteList(REVIEWS_REF);
|
|
164
176
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
165
177
|
if (remoteHas) {
|
package/dist/commands/why.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { blameRange, commitSubject, lineHistory } from '../git.js';
|
|
3
4
|
import { loadGloss } from '../loader.js';
|
|
@@ -158,8 +159,8 @@ export function run(argv) {
|
|
|
158
159
|
const renderStep = (s, label, older) => {
|
|
159
160
|
// Where the lines were in an older commit: after edits or a file rename
|
|
160
161
|
// that differs from the query.
|
|
161
|
-
const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
|
|
162
|
-
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
|
|
162
|
+
const then = older || s.file !== file ? style('dim', ` (${plain(s.file)}:${formatRanges(s.lines)} then)`) : '';
|
|
163
|
+
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${plain(s.subject)}${then}`);
|
|
163
164
|
if (!s.gloss) {
|
|
164
165
|
out.push(style('dim', ' no gloss for this commit'));
|
|
165
166
|
return;
|