@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.
@@ -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 ?? 0, 'utf8');
54
+ const raw = values.file ? readFileSync(values.file, 'utf8') : readStdin();
29
55
  let json;
30
56
  try {
31
57
  json = JSON.parse(raw);
@@ -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
  }
@@ -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+ /, '')})`);
@@ -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)
@@ -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
- const glosses = shas.map((sha) => loadGloss(sha));
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) {
@@ -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, 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';
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 { checkLength, parseBatch, readBounded, template } from '../batch.js';
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 = 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 });
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
- // 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' : ''}`);
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 !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
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 } : {
@@ -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 glosses = shas.map((sha) => loadGloss(sha));
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.
@@ -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
- gitTry(['update-ref', '-d', tracking]);
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
- git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
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
- gitTry(['update-ref', '-d', PUSH_REF]);
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
- gitTry(['update-ref', '-d', PUSH_REF]);
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
- gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
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
- gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
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
- return `${from}${a.groups.length} group${a.groups.length === 1 ? '' : 's'}: ${a.groups.map((g) => g.id).join(', ')}\n task: ${JSON.stringify(a.task)}`;
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
- process.stdout.write(git(['cat-file', 'blob', pick(values.show, '--show').v.blob]));
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
  });
@@ -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 { loadGloss } from '../loader.js';
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
- glossCache.set(commit, loadGloss(commit));
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. It is not fatal either (the built-in defaults
141
- // still give a floor, and refusing to rate anything would make every command
142
- // fail in a damaged repo), so it is warned about on stderr, once per message,
143
- // and the defaults are used.
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
- if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
175
- return noBase();
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
- return failed ? (console.error(`warning: no working base branch, so ${CONFIG_FILE} is not read; using the built-in defaults`), null) : noBase();
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 warning and null when git could not tell or could not read it. Presence
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
- warnOnce(`${CONFIG_FILE} could not be read from the base branch (${err.message}); using the built-in defaults, so the repo's own patterns do not apply`);
210
- return null;
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() {