@ok-ggwp/git-gloss 0.6.0 → 0.7.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 +12 -9
- package/dist/ary/desk.js +112 -0
- package/dist/ary/jobs.js +132 -0
- package/dist/ary/page-job.js +23 -0
- package/dist/ary/picker.js +8 -0
- package/dist/ary/server.js +41 -107
- package/dist/batch.js +56 -1
- package/dist/commands/add.js +28 -2
- package/dist/commands/ary.js +3 -0
- package/dist/commands/carry.js +3 -1
- package/dist/commands/lint.js +3 -0
- package/dist/commands/review.js +10 -5
- package/dist/commands/reviews.js +13 -23
- package/dist/commands/show.js +11 -5
- package/dist/commands/sync.js +35 -12
- package/dist/commands/versions.js +52 -8
- package/dist/commands/why.js +8 -3
- package/dist/config.js +22 -11
- package/dist/findings.js +127 -32
- package/dist/git.js +151 -27
- package/dist/html/client.js +49 -7
- package/dist/html/data.js +20 -10
- package/dist/html/style.css +4 -0
- package/dist/loader.js +22 -3
- package/dist/reviews.js +12 -4
- package/dist/schema.js +2 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +5 -5
package/dist/commands/add.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, readSync } from 'node:fs';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTES_REF, writeNote } from '../git.js';
|
|
@@ -7,6 +7,32 @@ import { Annotation, noteFormat } from '../schema.js';
|
|
|
7
7
|
import { validate } from '../validate.js';
|
|
8
8
|
import { loadConfig } from '../config.js';
|
|
9
9
|
import { analyzeGroups, groupInputsFor, ratingProblems } from '../floor.js';
|
|
10
|
+
// readFileSync(0) throws EAGAIN when the pipe is non-blocking and momentarily empty
|
|
11
|
+
// (a big gloss from a slower writer): wait a moment and read on.
|
|
12
|
+
function readStdin() {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
const buf = Buffer.alloc(1 << 20);
|
|
15
|
+
for (;;) {
|
|
16
|
+
let n;
|
|
17
|
+
try {
|
|
18
|
+
n = readSync(0, buf);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const code = err.code;
|
|
22
|
+
if (code === 'EAGAIN') {
|
|
23
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (code === 'EOF')
|
|
27
|
+
break;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
if (n === 0)
|
|
31
|
+
break;
|
|
32
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
35
|
+
}
|
|
10
36
|
export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--allow-underrate] [--dry-run] attach an annotation (JSON from file or stdin)';
|
|
11
37
|
export function run(argv) {
|
|
12
38
|
const { values, positionals } = parseArgs({
|
|
@@ -25,7 +51,7 @@ export function run(argv) {
|
|
|
25
51
|
console.error('error: pipe annotation JSON on stdin or pass -f <file> (see `git gloss schema`)');
|
|
26
52
|
return 2;
|
|
27
53
|
}
|
|
28
|
-
const raw = readFileSync(values.file
|
|
54
|
+
const raw = values.file ? readFileSync(values.file, 'utf8') : readStdin();
|
|
29
55
|
let json;
|
|
30
56
|
try {
|
|
31
57
|
json = JSON.parse(raw);
|
package/dist/commands/ary.js
CHANGED
|
@@ -31,5 +31,8 @@ export function run(argv) {
|
|
|
31
31
|
if (!values['no-open'])
|
|
32
32
|
openInBrowser(url);
|
|
33
33
|
});
|
|
34
|
+
// The default action of a signal would skip the cleanup that kills running page builds.
|
|
35
|
+
for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGHUP', 129], ['SIGQUIT', 131]])
|
|
36
|
+
process.on(sig, () => { server.close(); process.exit(code); });
|
|
34
37
|
return 0;
|
|
35
38
|
}
|
package/dist/commands/carry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { commitDiff, git, GlossError, readNote, resolveCommit, resolveCommits, writeNote } from '../git.js';
|
|
3
|
+
import { commitDiff, git, GlossError, readNote, rethrowTimeout, resolveCommit, resolveCommits, writeNote } from '../git.js';
|
|
4
4
|
import { matchHunks } from '../loader.js';
|
|
5
5
|
import { findOrigins } from '../origins.js';
|
|
6
6
|
import { location, parseDiff } from '../diff.js';
|
|
@@ -20,6 +20,7 @@ export function targetState(sha) {
|
|
|
20
20
|
return note ? (note.sources || note.carried_from ? 'carried' : 'glossed') : 'none';
|
|
21
21
|
}
|
|
22
22
|
catch (err) {
|
|
23
|
+
rethrowTimeout(err);
|
|
23
24
|
if (err instanceof GlossError)
|
|
24
25
|
return 'broken';
|
|
25
26
|
throw err;
|
|
@@ -41,6 +42,7 @@ export function carry(sourceShas, target) {
|
|
|
41
42
|
note = readNote(sha);
|
|
42
43
|
}
|
|
43
44
|
catch (err) {
|
|
45
|
+
rethrowTimeout(err);
|
|
44
46
|
if (!(err instanceof GlossError))
|
|
45
47
|
throw err;
|
|
46
48
|
unreadable.push(`${sha.slice(0, 7)} (${err.message.replace(/^the gloss on \S+ /, '')})`);
|
package/dist/commands/lint.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { GlossError, resolveCommits } from '../git.js';
|
|
3
3
|
import { loadGloss } from '../loader.js';
|
|
4
|
+
import { CONFIG_EDIT_NOTE } from '../signals.js';
|
|
4
5
|
import { ratingIssues, tryAnalyze, whyText } from '../floor.js';
|
|
5
6
|
import { attentionRank, impactRank } from '../attention.js';
|
|
6
7
|
import { servesOf } from '../schema.js';
|
|
@@ -38,6 +39,8 @@ export function run(argv) {
|
|
|
38
39
|
const an = tryAnalyze(gloss);
|
|
39
40
|
if (an) {
|
|
40
41
|
const issues = ratingIssues(a, an);
|
|
42
|
+
if (issues.configEdit)
|
|
43
|
+
add('config-edit', CONFIG_EDIT_NOTE);
|
|
41
44
|
for (const u of issues.unrated)
|
|
42
45
|
add('no-impact', `no impact rated${u.signal ? ` (floor ${u.floor}: ${whyText(u.why)})` : ''}`, { group: u.id });
|
|
43
46
|
for (const b of issues.belowFloor)
|
package/dist/commands/review.js
CHANGED
|
@@ -3,7 +3,7 @@ import { plain } from '../plain.js';
|
|
|
3
3
|
import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
|
|
4
4
|
import { parseDiff } from '../diff.js';
|
|
5
5
|
import { refLabel, sliceText } from '../selection.js';
|
|
6
|
-
import { loadGloss } from '../loader.js';
|
|
6
|
+
import { loadGloss, loadGlossInRange, refusedWarning } from '../loader.js';
|
|
7
7
|
import { NECESSITY_HELP, servesOf } from '../schema.js';
|
|
8
8
|
export const usage = 'review [<commit> | <range>] [--material] print a review prompt for a commit or a branch (pipe it to an agent, e.g. `| claude -p`); --material prints only the gloss and code, for other review prompts';
|
|
9
9
|
// A fence longer than any backtick run in the content, so a markdown file in
|
|
@@ -20,11 +20,12 @@ const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `
|
|
|
20
20
|
`(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
|
|
21
21
|
// The material for one commit: its gloss and the code under each group, or,
|
|
22
22
|
// for an unglossed commit in a range, its whole diff.
|
|
23
|
-
function commitMaterial(sha, gloss) {
|
|
23
|
+
function commitMaterial(sha, gloss, refused) {
|
|
24
24
|
if (!gloss) {
|
|
25
25
|
return [
|
|
26
26
|
`## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
|
|
27
|
-
'This commit has no gloss. Review its changes as unexplained.',
|
|
27
|
+
refused ? "This commit's gloss was refused (reason below), so it is not shown. Review its changes as unexplained." : 'This commit has no gloss. Review its changes as unexplained.',
|
|
28
|
+
...(refused ? [`Reason: ${q(plain(refused).slice(0, 300))}`] : []),
|
|
28
29
|
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
|
|
29
30
|
];
|
|
30
31
|
}
|
|
@@ -69,7 +70,11 @@ function commitMaterial(sha, gloss) {
|
|
|
69
70
|
export function run(argv) {
|
|
70
71
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { material: { type: 'boolean' } } });
|
|
71
72
|
const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
72
|
-
|
|
73
|
+
// In a range a refused gloss is skipped with a warning; a single commit fails with the error.
|
|
74
|
+
const loaded = shas.map((sha) => (range ? loadGlossInRange(sha) : { gloss: loadGloss(sha), refused: undefined }));
|
|
75
|
+
loaded.forEach((l, i) => { if (l.refused)
|
|
76
|
+
console.error(refusedWarning(shas[i], l.refused)); });
|
|
77
|
+
const glosses = loaded.map((l) => l.gloss);
|
|
73
78
|
if (!range && !glosses[0]) {
|
|
74
79
|
console.error(`${shas[0].slice(0, 10)} has no gloss to review`);
|
|
75
80
|
return 1;
|
|
@@ -82,7 +87,7 @@ export function run(argv) {
|
|
|
82
87
|
? `\n\nMerge commits in the range are not included below. A merge can still carry conflict resolutions or edits of its own; check them with \`git show <sha>\`:\n\n${list(merges)}`
|
|
83
88
|
: ''));
|
|
84
89
|
}
|
|
85
|
-
shas.forEach((sha, i) => material.push(...commitMaterial(sha, glosses[i])));
|
|
90
|
+
shas.forEach((sha, i) => material.push(...commitMaterial(sha, glosses[i], loaded[i].refused)));
|
|
86
91
|
// Just the material, for a reviewer that brings its own instructions. Its
|
|
87
92
|
// author text is quoted but not framed as untrusted; the caller must say so.
|
|
88
93
|
if (values.material) {
|
package/dist/commands/reviews.js
CHANGED
|
@@ -2,11 +2,11 @@ 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,
|
|
6
|
-
import { findingRefs, labelFindings, orderDecisions, refOf } from '../findings.js';
|
|
5
|
+
import { addReviews, checkDecision, resolveBy, isValidDecision, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
6
|
+
import { findingClashes, findingRefs, labelFindings, orderDecisions, refOf } from '../findings.js';
|
|
7
7
|
import { plain } from '../plain.js';
|
|
8
8
|
import { page, style } from '../render.js';
|
|
9
|
-
import {
|
|
9
|
+
import { applyBatch, checkLength, prepareBatch, template } from '../batch.js';
|
|
10
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';
|
|
11
11
|
export function run(argv) {
|
|
12
12
|
const { values, positionals } = parseArgs({
|
|
@@ -80,39 +80,30 @@ export function run(argv) {
|
|
|
80
80
|
};
|
|
81
81
|
const reportOf = (id, holders) => holders.flatMap(readReviews).find((r) => r.id === id);
|
|
82
82
|
// A finding that starts with a ref names a finding of the report: say so when there is none (never refuse).
|
|
83
|
+
// The refs of a report are computed once, not once per entry.
|
|
84
|
+
const refsOfReport = new Map();
|
|
83
85
|
const warnUnknownRef = (id, report, finding, hint = '') => {
|
|
84
86
|
const ref = refOf(finding);
|
|
85
|
-
if (ref !== null && report && !findingRefs(report.findings).has(ref))
|
|
87
|
+
if (ref !== null && report && !(refsOfReport.get(report.findings) ?? refsOfReport.set(report.findings, findingRefs(report.findings)).get(report.findings)).has(ref))
|
|
86
88
|
console.error(`git-gloss: review ${plain(id)} has no finding ${ref} (${hint}see the refs in \`git gloss reviews ${target}\`); recording it anyway`);
|
|
87
89
|
};
|
|
88
90
|
if (values['decide-template'] !== undefined) {
|
|
89
91
|
const id = values['decide-template'];
|
|
90
92
|
const report = reportOf(id, holdersOf(id));
|
|
93
|
+
const clash = findingClashes(report.findings);
|
|
94
|
+
if (clash.length)
|
|
95
|
+
console.error(`git-gloss: review ${plain(id)}: these refs are ambiguous, so the template cannot list every finding under its own: ${clash.join(', ')}; the findings are in \`git gloss reviews ${target}\``);
|
|
91
96
|
console.log(JSON.stringify(template(id, report.findings), null, 2));
|
|
92
97
|
return 0;
|
|
93
98
|
}
|
|
94
99
|
if (values['decide-file'] !== undefined) {
|
|
95
100
|
const id = values.decide;
|
|
96
101
|
// Everything is checked before the first write: one bad entry records nothing.
|
|
97
|
-
const batch =
|
|
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 });
|
|
102
|
+
const { batch, holders, records } = prepareBatch(values['decide-file'], id, values.by, holdersOf);
|
|
109
103
|
const report = reportOf(id, holders);
|
|
110
104
|
batch.entries.forEach((e, i) => warnUnknownRef(id, report, e.finding, `entry ${i + 1}; `));
|
|
111
|
-
|
|
112
|
-
|
|
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' : ''}`);
|
|
105
|
+
const { decisions: n, marked, written, skipped } = applyBatch(holders, records);
|
|
106
|
+
console.log(`recorded ${n} decision${n === 1 ? '' : 's'} on ${written} commit${written === 1 ? '' : 's'}${marked ? ' and marked the review complete' : ''}${skipped ? ` (${skipped} already recorded)` : ''}`);
|
|
116
107
|
return 0;
|
|
117
108
|
}
|
|
118
109
|
if (values.decide !== undefined) {
|
|
@@ -125,8 +116,7 @@ export function run(argv) {
|
|
|
125
116
|
else
|
|
126
117
|
checkDecision(values);
|
|
127
118
|
const holders = holdersOf(id);
|
|
128
|
-
const by = values.by
|
|
129
|
-
checkBy(by);
|
|
119
|
+
const by = resolveBy(values.by);
|
|
130
120
|
const record = {
|
|
131
121
|
decision: 1, review: id, at: new Date().toISOString(), by,
|
|
132
122
|
...(values.complete ? { complete: true } : {
|
package/dist/commands/show.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
-
import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
|
|
2
|
+
import { commitSubject, gitTry, readNote, resolveCommits, rethrowTimeout } from '../git.js';
|
|
3
3
|
import { refLabel, sliceStats, sliceText } from '../selection.js';
|
|
4
|
-
import { loadGloss } from '../loader.js';
|
|
4
|
+
import { loadGloss, loadGlossInRange, refusedWarning } 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';
|
|
@@ -61,7 +61,8 @@ export function run(argv) {
|
|
|
61
61
|
const groups = readNote(c)?.annotation.groups.map((g) => g.id) ?? [];
|
|
62
62
|
return missing.every((id) => groups.includes(id));
|
|
63
63
|
}
|
|
64
|
-
catch {
|
|
64
|
+
catch (err) {
|
|
65
|
+
rethrowTimeout(err);
|
|
65
66
|
return false;
|
|
66
67
|
}
|
|
67
68
|
});
|
|
@@ -84,7 +85,11 @@ export function run(argv) {
|
|
|
84
85
|
console.error('error: -g needs a single commit; pick one from the range, e.g. git gloss show <commit> -g <group>');
|
|
85
86
|
return 2;
|
|
86
87
|
}
|
|
87
|
-
const
|
|
88
|
+
const loaded = shas.map((sha) => loadGlossInRange(sha));
|
|
89
|
+
const glosses = loaded.map((l) => l.gloss);
|
|
90
|
+
const refused = loaded.filter((l) => l.refused).length;
|
|
91
|
+
loaded.forEach((l, i) => { if (l.refused)
|
|
92
|
+
console.error(refusedWarning(shas[i], l.refused)); });
|
|
88
93
|
const glossed = glosses.filter(Boolean).length;
|
|
89
94
|
const stale = glosses.filter((g) => g?.stale).length;
|
|
90
95
|
// How many glossed commits had their gloss checked: an honesty review that
|
|
@@ -97,13 +102,14 @@ export function run(argv) {
|
|
|
97
102
|
(glossed ? style(vetted === glossed ? 'green' : 'yellow', `, ${vetted} honesty-reviewed`) : '') +
|
|
98
103
|
(flagged ? style('yellow', ` (${flagged} with issues)`) : '') +
|
|
99
104
|
(stale ? style('red', ` (${stale} stale)`) : '') +
|
|
105
|
+
(refused ? style('red', ` (${refused} refused)`) : '') +
|
|
100
106
|
(merges.length ? style('dim', ` (${merges.length} merge${merges.length === 1 ? '' : 's'} not shown)`) : ''),
|
|
101
107
|
];
|
|
102
108
|
shas.forEach((sha, i) => {
|
|
103
109
|
out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
|
|
104
110
|
const gloss = glosses[i];
|
|
105
111
|
if (!gloss) {
|
|
106
|
-
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
|
|
112
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', loaded[i].refused ? 'gloss refused (see the warning)' : 'no gloss for this commit'));
|
|
107
113
|
return;
|
|
108
114
|
}
|
|
109
115
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import { git, gitTry, GitError, NOTES_REF } from '../git.js';
|
|
3
|
+
import { git, gitTry, GitError, gitCleanup, NETWORK_TIMEOUT, NOTES_REF, rethrowTimeout } from '../git.js';
|
|
4
4
|
import { carryNew } from '../origins.js';
|
|
5
5
|
import { REVIEWS_REF } from '../reviews.js';
|
|
6
6
|
import { carry } from './carry.js';
|
|
@@ -22,7 +22,8 @@ function createdAt(blob) {
|
|
|
22
22
|
const value = JSON.parse(git(['cat-file', 'blob', blob]))?.created_at;
|
|
23
23
|
return typeof value === 'string' ? value : null;
|
|
24
24
|
}
|
|
25
|
-
catch {
|
|
25
|
+
catch (err) {
|
|
26
|
+
rethrowTimeout(err);
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -50,12 +51,34 @@ function shareable(remote, pushing) {
|
|
|
50
51
|
// code (or an update-ref onto our own notes ref) touches it.
|
|
51
52
|
function checkTracking(remote, ref, tracking) {
|
|
52
53
|
if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
|
|
53
|
-
|
|
54
|
+
gitCleanup(['update-ref', '-d', tracking]);
|
|
54
55
|
throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
// `init`'s refspec is a glob (refs/notes/gloss*), so a remote that once published
|
|
59
|
+
// refs/notes/gloss/x leaves refs/notes/remotes/<remote>/gloss/x behind, and git
|
|
60
|
+
// then cannot create refs/notes/remotes/<remote>/gloss once the remote has that
|
|
61
|
+
// ref (a ref cannot be both a file and a directory): every fetch fails until
|
|
62
|
+
// `git remote prune`. We are about to fetch the ref into `tracking`, so
|
|
63
|
+
// whatever sits below it is stale and cannot be valid together with it. Only
|
|
64
|
+
// that namespace is touched, in one batched update-ref.
|
|
65
|
+
function clearBlockingTracking(tracking) {
|
|
66
|
+
const stale = (gitTry(['for-each-ref', '--format=%(refname)', `${tracking}/`]) ?? '').split('\n').filter((r) => r.startsWith(`${tracking}/`));
|
|
67
|
+
if (stale.length)
|
|
68
|
+
git(['update-ref', '--stdin'], stale.map((r) => `delete ${r}\n`).join(''));
|
|
69
|
+
}
|
|
57
70
|
function fetchTracking(remote, ref, tracking) {
|
|
58
|
-
|
|
71
|
+
try {
|
|
72
|
+
clearBlockingTracking(tracking);
|
|
73
|
+
git(['fetch', '-q', remote, `+${ref}:${tracking}`], undefined, {}, NETWORK_TIMEOUT);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
rethrowTimeout(err);
|
|
77
|
+
if (err instanceof GitError && /cannot lock ref|Unable to create .*\.lock|some local refs could not be updated/.test(err.message)) {
|
|
78
|
+
throw new GitError(`${err.message}\n a stale remote-tracking ref may be in the way; try: git remote prune ${remote}`);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
59
82
|
checkTracking(remote, ref, tracking);
|
|
60
83
|
}
|
|
61
84
|
const PUSH_REF = 'refs/notes/gloss-sync-tmp';
|
|
@@ -63,7 +86,7 @@ const PUSH_REF = 'refs/notes/gloss-sync-tmp';
|
|
|
63
86
|
// someone else's push and the round should be retried.
|
|
64
87
|
function round(remote, pushing, carried) {
|
|
65
88
|
const tracking = `refs/notes/remotes/${remote}/gloss`;
|
|
66
|
-
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
|
|
89
|
+
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF], undefined, NETWORK_TIMEOUT) !== null;
|
|
67
90
|
if (remoteHas)
|
|
68
91
|
fetchTracking(remote, NOTES_REF, tracking);
|
|
69
92
|
const local = noteList(NOTES_REF);
|
|
@@ -142,13 +165,13 @@ function round(remote, pushing, carried) {
|
|
|
142
165
|
if (remoteHas)
|
|
143
166
|
git(['update-ref', PUSH_REF, tracking]);
|
|
144
167
|
else
|
|
145
|
-
|
|
168
|
+
gitCleanup(['update-ref', '-d', PUSH_REF]);
|
|
146
169
|
for (const [object, blob] of toPush)
|
|
147
170
|
git(['notes', '--ref', PUSH_REF, 'add', '-f', '-C', blob, object]);
|
|
148
171
|
try {
|
|
149
172
|
// --no-verify: don't run the pre-push hook for the notes push itself,
|
|
150
173
|
// which would call sync again.
|
|
151
|
-
git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`]);
|
|
174
|
+
git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`], undefined, {}, NETWORK_TIMEOUT);
|
|
152
175
|
}
|
|
153
176
|
catch (err) {
|
|
154
177
|
if (/\(fetch first\)|non-fast-forward/.test(err.message))
|
|
@@ -159,7 +182,7 @@ function round(remote, pushing, carried) {
|
|
|
159
182
|
git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', PUSH_REF]);
|
|
160
183
|
}
|
|
161
184
|
finally {
|
|
162
|
-
|
|
185
|
+
gitCleanup(['update-ref', '-d', PUSH_REF]);
|
|
163
186
|
}
|
|
164
187
|
return { fetched, pushed: toPush.length, withheld, conflicts };
|
|
165
188
|
}
|
|
@@ -169,7 +192,7 @@ const REVIEWS_PUSH_REF = 'refs/notes/gloss-reviews-sync-tmp';
|
|
|
169
192
|
// commits the remote may see are sent. Returns null if the push lost a race.
|
|
170
193
|
function syncReviews(remote, pushing) {
|
|
171
194
|
const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
|
|
172
|
-
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
|
|
195
|
+
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF], undefined, NETWORK_TIMEOUT) !== null;
|
|
173
196
|
if (remoteHas)
|
|
174
197
|
fetchTracking(remote, REVIEWS_REF, tracking);
|
|
175
198
|
const before = noteList(REVIEWS_REF);
|
|
@@ -190,11 +213,11 @@ function syncReviews(remote, pushing) {
|
|
|
190
213
|
if (remoteHas)
|
|
191
214
|
git(['update-ref', REVIEWS_PUSH_REF, tracking]);
|
|
192
215
|
else
|
|
193
|
-
|
|
216
|
+
gitCleanup(['update-ref', '-d', REVIEWS_PUSH_REF]);
|
|
194
217
|
for (const [object, blob] of toPush)
|
|
195
218
|
git(['notes', '--ref', REVIEWS_PUSH_REF, 'add', '-f', '-C', blob, object]);
|
|
196
219
|
try {
|
|
197
|
-
git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`]);
|
|
220
|
+
git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`], undefined, {}, NETWORK_TIMEOUT);
|
|
198
221
|
}
|
|
199
222
|
catch (err) {
|
|
200
223
|
if (/\(fetch first\)|non-fast-forward/.test(err.message))
|
|
@@ -204,7 +227,7 @@ function syncReviews(remote, pushing) {
|
|
|
204
227
|
git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', REVIEWS_PUSH_REF]);
|
|
205
228
|
}
|
|
206
229
|
finally {
|
|
207
|
-
|
|
230
|
+
gitCleanup(['update-ref', '-d', REVIEWS_PUSH_REF]);
|
|
208
231
|
}
|
|
209
232
|
return { fetched, pushed: toPush.length };
|
|
210
233
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
-
import { commitDiff, git, gitTry, GlossError, NOTES_REF, resolveCommit, WIP_NOTES_REF, WIP_REF, writeNote } from '../git.js';
|
|
2
|
+
import { commitDiff, git, GitError, gitTry, GlossError, noteSizeProblem, NOTES_REF, parseNote, resolveCommit, WIP_NOTES_REF, WIP_REF, rethrowTimeout, writeNote } from '../git.js';
|
|
3
|
+
import { plain } from '../plain.js';
|
|
3
4
|
import { location, parseDiff } from '../diff.js';
|
|
5
|
+
import { loadGlossOf } from '../loader.js';
|
|
6
|
+
import { carriedErrors } from '../validate.js';
|
|
4
7
|
import { mergeGlosses } from '../merge.js';
|
|
5
8
|
import { noteFormat } from '../schema.js';
|
|
6
9
|
import { refLabel } from '../selection.js';
|
|
@@ -51,18 +54,29 @@ function history(ref, only) {
|
|
|
51
54
|
flush();
|
|
52
55
|
return out;
|
|
53
56
|
}
|
|
57
|
+
// How many group ids the listing names for one version.
|
|
58
|
+
const MAX_LISTED = 20;
|
|
59
|
+
// Everything a version of the history prints comes from a note that may have been
|
|
60
|
+
// pushed by anyone: it is size-checked before it is read, cleaned, and bounded.
|
|
54
61
|
function summary(blob) {
|
|
62
|
+
const size = Number(gitTry(['cat-file', '-s', blob]));
|
|
63
|
+
const big = noteSizeProblem(size);
|
|
64
|
+
if (big)
|
|
65
|
+
return `(not read: ${big})`;
|
|
55
66
|
try {
|
|
56
67
|
const note = JSON.parse(git(['cat-file', 'blob', blob]));
|
|
57
68
|
const a = note.annotation;
|
|
58
|
-
const from = note.sources ? `carried from ${note.sources.map((s) => s.slice(0, 7)).join(', ')}; ` : '';
|
|
59
|
-
|
|
69
|
+
const from = Array.isArray(note.sources) ? `carried from ${note.sources.slice(0, 5).map((s) => plain(String(s)).slice(0, 7)).join(', ')}; ` : '';
|
|
70
|
+
const ids = a.groups.slice(0, MAX_LISTED).map((g) => plain(String(g.id)).slice(0, 40));
|
|
71
|
+
const n = a.groups.length;
|
|
72
|
+
return `${from}${n} group${n === 1 ? '' : 's'}: ${ids.join(', ')}${n > MAX_LISTED ? `, and ${n - MAX_LISTED} more` : ''}\n task: ${JSON.stringify(plain(String(a.task)).slice(0, 200))}`;
|
|
60
73
|
}
|
|
61
|
-
catch {
|
|
74
|
+
catch (err) {
|
|
75
|
+
rethrowTimeout(err);
|
|
62
76
|
return '(not a readable gloss: joined notes from a squash, or not JSON)';
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
|
-
const when = (iso) => iso.slice(0, 16).replace('T', ' ');
|
|
79
|
+
const when = (iso) => plain(iso).slice(0, 16).replace('T', ' ');
|
|
66
80
|
export function run(argv) {
|
|
67
81
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { show: { type: 'string' }, restore: { type: 'string' } } });
|
|
68
82
|
const wip = positionals[0] === 'wip';
|
|
@@ -84,12 +98,37 @@ export function run(argv) {
|
|
|
84
98
|
return { n, v: v };
|
|
85
99
|
};
|
|
86
100
|
if (values.show !== undefined) {
|
|
87
|
-
|
|
101
|
+
// Cleaned like everything else printed (control characters, escape sequences, bidi and
|
|
102
|
+
// zero-width characters become spaces), so not byte-identical to the stored blob.
|
|
103
|
+
process.stdout.write(plain(git(['cat-file', 'blob', pick(values.show, '--show').v.blob]), true));
|
|
88
104
|
return 0;
|
|
89
105
|
}
|
|
90
106
|
if (values.restore !== undefined) {
|
|
91
107
|
const { n, v } = pick(values.restore, '--restore');
|
|
108
|
+
// Only a gloss that readers accept is brought back (a restore must not plant what they refuse).
|
|
109
|
+
const text = git(['cat-file', 'blob', v.blob]);
|
|
110
|
+
let note;
|
|
111
|
+
try {
|
|
112
|
+
note = parseNote(text, `version ${n}`);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (!(err instanceof GlossError) || err instanceof GitError)
|
|
116
|
+
throw err;
|
|
117
|
+
throw new GlossError(`${err.message.replace(/; (replace it|re-gloss it|upgrade).*$/, '')}, so it is not restored (read it with --show ${n})`);
|
|
118
|
+
}
|
|
119
|
+
// What readers refuse for the commit it would go on (too many claimed lines).
|
|
120
|
+
const checkOn = (target) => {
|
|
121
|
+
try {
|
|
122
|
+
loadGlossOf(target, note);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (!(err instanceof GlossError) || err instanceof GitError)
|
|
126
|
+
throw err;
|
|
127
|
+
throw new GlossError(`version ${n} would be refused on ${target.slice(0, 7)}: ${err.message.replace(/^the gloss on \S+ is refused: /, '')} (not restored; read it with --show ${n})`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
92
130
|
if (!wip) {
|
|
131
|
+
checkOn(sha);
|
|
93
132
|
git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, sha]);
|
|
94
133
|
console.log(`restored version ${n} as the gloss of ${label}`);
|
|
95
134
|
return 0;
|
|
@@ -100,8 +139,8 @@ export function run(argv) {
|
|
|
100
139
|
const onto = gitTry(['rev-parse', '-q', '--verify', WIP_REF])?.trim();
|
|
101
140
|
if (!onto)
|
|
102
141
|
throw new GlossError('there is no snapshot of uncommitted work to restore onto; run git gloss hunks wip first');
|
|
103
|
-
const note = JSON.parse(git(['cat-file', 'blob', v.blob]));
|
|
104
142
|
if (v.object === onto || gitTry(['cat-file', '-e', `${v.object}^{commit}`]) === null) {
|
|
143
|
+
checkOn(onto);
|
|
105
144
|
git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, onto]);
|
|
106
145
|
console.log(`restored version ${n} onto the wip snapshot ${onto.slice(0, 7)}` +
|
|
107
146
|
(v.object === onto ? '' : `; its snapshot ${v.object.slice(0, 7)} is gone, so hunk references may not match: check with git gloss show wip`));
|
|
@@ -109,6 +148,11 @@ export function run(argv) {
|
|
|
109
148
|
}
|
|
110
149
|
const current = parseDiff(commitDiff(onto));
|
|
111
150
|
const merged = mergeGlosses([{ sha: v.object, note, hunks: parseDiff(commitDiff(v.object)) }], current);
|
|
151
|
+
if (merged.annotation) {
|
|
152
|
+
const errors = carriedErrors(merged.annotation, current);
|
|
153
|
+
if (errors.length)
|
|
154
|
+
throw new GlossError(`version ${n} doesn't fit the uncommitted work: ${errors.join('; ')} (not restored)`);
|
|
155
|
+
}
|
|
112
156
|
if (!merged.annotation)
|
|
113
157
|
throw new GlossError(`none of version ${n}'s glossed changes are in the uncommitted work any more; read it with --show ${n}`);
|
|
114
158
|
writeNote(onto, {
|
|
@@ -122,7 +166,7 @@ export function run(argv) {
|
|
|
122
166
|
}
|
|
123
167
|
console.log(`glosses of ${label}, newest first:`);
|
|
124
168
|
versions.forEach((v, i) => {
|
|
125
|
-
const head = `${String(i + 1).padStart(3)} ${when(v.at)} ${v.how}${wip ? ` (snapshot ${v.object.slice(0, 7)})` : ''}`;
|
|
169
|
+
const head = `${String(i + 1).padStart(3)} ${when(v.at)} ${plain(v.how)}${wip ? ` (snapshot ${v.object.slice(0, 7)})` : ''}`;
|
|
126
170
|
console.log(`${head}${i === 0 && v.blob && !wip ? ' [current]' : ''}`);
|
|
127
171
|
console.log(` ${v.blob ? summary(v.blob) : 'removed'}`);
|
|
128
172
|
});
|
package/dist/commands/why.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { plain } from '../plain.js';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { blameRange, commitSubject, lineHistory } from '../git.js';
|
|
4
|
-
import {
|
|
4
|
+
import { loadGlossInRange, refusedWarning } from '../loader.js';
|
|
5
5
|
import { servesOf } from '../schema.js';
|
|
6
6
|
import { field, groupHeader, style } from '../render.js';
|
|
7
7
|
import { bodyLines, formatRanges } from '../selection.js';
|
|
@@ -10,8 +10,13 @@ export const usage = 'why <file>:<line>[-<end>] [--history] [--rev <commit>] [--
|
|
|
10
10
|
// refer to it; each load diffs the commit.
|
|
11
11
|
const glossCache = new Map();
|
|
12
12
|
function glossFor(commit) {
|
|
13
|
-
if (!glossCache.has(commit))
|
|
14
|
-
|
|
13
|
+
if (!glossCache.has(commit)) {
|
|
14
|
+
// A refused gloss (hostile or broken) is skipped with a warning, once, not a failed command.
|
|
15
|
+
const { gloss, refused } = loadGlossInRange(commit);
|
|
16
|
+
if (refused)
|
|
17
|
+
console.error(refusedWarning(commit, refused));
|
|
18
|
+
glossCache.set(commit, gloss);
|
|
19
|
+
}
|
|
15
20
|
return glossCache.get(commit);
|
|
16
21
|
}
|
|
17
22
|
// Groups in a gloss that own `line` of `file` on the new side of the commit's
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { git, GitError, GlossError, gitTry } from './git.js';
|
|
2
|
+
import { git, GitError, GlossError, gitTry, rethrowTimeout } from './git.js';
|
|
3
3
|
import { globToRegExp } from './glob.js';
|
|
4
4
|
// Settings for the computed signals, in three layers (later wins for scalars,
|
|
5
5
|
// repo config extends the lists of the one before):
|
|
@@ -137,10 +137,12 @@ function gitInt(key) {
|
|
|
137
137
|
}
|
|
138
138
|
// Git failing (a broken repo, an unreadable object, no permission) is not the
|
|
139
139
|
// same as "no base" or "no file": the repo's own, perhaps stricter, config
|
|
140
|
-
// would silently not apply
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// and the
|
|
140
|
+
// would silently not apply, and a floor computed from the defaults alone could
|
|
141
|
+
// be lower than the real one. So the ratings are unverifiable: loadConfig
|
|
142
|
+
// throws a ConfigError, which `add` and `signals` report as an error, and
|
|
143
|
+
// `show`, `lint`, `html` and `hunks` meet through withSignals (the signals are
|
|
144
|
+
// unavailable, `lint` says "incomplete"). A broken base candidate is only
|
|
145
|
+
// warned about (once per message) while a later one still works.
|
|
144
146
|
const warned = new Set();
|
|
145
147
|
function warnOnce(msg) {
|
|
146
148
|
if (warned.has(msg))
|
|
@@ -171,8 +173,14 @@ function gitAnswer(args) {
|
|
|
171
173
|
// is always a `sensitive` path.
|
|
172
174
|
function trustedRef() {
|
|
173
175
|
// No commits yet: no base, and nothing failed.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
try {
|
|
177
|
+
if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
|
|
178
|
+
return noBase();
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
rethrowTimeout(err);
|
|
182
|
+
throw new ConfigError(`git failed finding HEAD (${err.message}), so ${CONFIG_FILE} cannot be read and the ratings cannot be verified`);
|
|
183
|
+
}
|
|
176
184
|
let failed = false;
|
|
177
185
|
// Full ref names, so a tag called "main" cannot stand in for the branch.
|
|
178
186
|
for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
|
|
@@ -184,20 +192,23 @@ function trustedRef() {
|
|
|
184
192
|
return mb;
|
|
185
193
|
}
|
|
186
194
|
catch (err) {
|
|
195
|
+
rethrowTimeout(err);
|
|
187
196
|
// A broken candidate does not hide the ones after it.
|
|
188
197
|
failed = true;
|
|
189
198
|
warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
// Some candidate failed: git's warning above is the reason, not an absent branch.
|
|
193
|
-
|
|
202
|
+
if (failed)
|
|
203
|
+
throw new ConfigError(`git failed on every base branch candidate, so ${CONFIG_FILE} cannot be read and the ratings cannot be verified (the repo's own patterns would not apply); fix the repository, or see the warnings above`);
|
|
204
|
+
return noBase();
|
|
194
205
|
}
|
|
195
206
|
function noBase() {
|
|
196
207
|
console.error('warning: no base branch (origin/main, main, origin/master, master) found, so .gloss/config.json is not read; using the built-in defaults');
|
|
197
208
|
return null;
|
|
198
209
|
}
|
|
199
210
|
// The config file at `ref`: null when the file is not there (the usual case),
|
|
200
|
-
// a
|
|
211
|
+
// a ConfigError when git could not tell or could not read it. Presence
|
|
201
212
|
// is asked of ls-tree (empty output: absent), not read off an error message.
|
|
202
213
|
function configAt(ref) {
|
|
203
214
|
try {
|
|
@@ -206,8 +217,8 @@ function configAt(ref) {
|
|
|
206
217
|
return git(['show', `${ref}:${CONFIG_FILE}`]);
|
|
207
218
|
}
|
|
208
219
|
catch (err) {
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
rethrowTimeout(err);
|
|
221
|
+
throw new ConfigError(`${CONFIG_FILE} could not be read from the base branch (${err.message}); the repo's own patterns cannot be applied, so the ratings cannot be verified`);
|
|
211
222
|
}
|
|
212
223
|
}
|
|
213
224
|
function readRepoConfig() {
|