@ok-ggwp/git-gloss 0.5.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/dist/git.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { Annotation, NOTE_FORMAT } from './schema.js';
5
+ import { Annotation, MAX_NOTE_BYTES, NOTE_FORMAT } from './schema.js';
6
6
  import { parsePath, unquote } from './diff.js';
7
7
  import { plain } from './plain.js';
8
8
  export const NOTES_REF = 'refs/notes/gloss';
@@ -21,10 +21,33 @@ export class GlossError extends Error {
21
21
  export class GitError extends GlossError {
22
22
  // git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
23
23
  status;
24
+ // The call was killed at GIT_GLOSS_GIT_TIMEOUT: unlike a failure, not an answer.
25
+ timedOut = false;
24
26
  }
25
- export function git(args, input, env = {}) {
27
+ // How long one git call may run before it is killed (ms; 0 = no limit). A
28
+ // hung git (a stuck filesystem, a credential helper, a lock) would otherwise
29
+ // hang git-gloss for good. Read once at load: GIT_GLOSS_GIT_TIMEOUT overrides;
30
+ // a value that is not a non-negative number is ignored.
31
+ // A timed-out git gets SIGTERM, not SIGKILL: git removes its lock files
32
+ // (index.lock, a ref's .lock) on SIGTERM, while SIGKILL would leave one that
33
+ // fails every later command. A git that ignores SIGTERM is waited for, with
34
+ // no second-stage kill (execFileSync only returns when the child ends), so
35
+ // such a git still hangs git-gloss.
36
+ // Network calls (fetch, push, ls-remote) get NETWORK_TIMEOUT (600 s) instead:
37
+ // a slow link would otherwise fail at 120 s and every retry would fail again.
38
+ // GIT_GLOSS_NETWORK_TIMEOUT (ms, 0 = none) sets that limit alone; failing it,
39
+ // GIT_GLOSS_GIT_TIMEOUT, when set, applies to both.
40
+ export function parseGitTimeout(raw, fallback = 120_000) {
41
+ const n = raw === undefined || raw.trim() === '' ? NaN : Number(raw);
42
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
43
+ }
44
+ const GIT_TIMEOUT = parseGitTimeout(process.env.GIT_GLOSS_GIT_TIMEOUT);
45
+ export const NETWORK_TIMEOUT = parseGitTimeout(process.env.GIT_GLOSS_NETWORK_TIMEOUT, parseGitTimeout(process.env.GIT_GLOSS_GIT_TIMEOUT, 600_000));
46
+ export function git(args, input, env = {}, limit = GIT_TIMEOUT) {
26
47
  try {
27
48
  return execFileSync('git', args, {
49
+ timeout: limit,
50
+ killSignal: 'SIGTERM',
28
51
  encoding: 'utf8',
29
52
  input,
30
53
  // Untranslated messages: readNote recognises "no note found" by its text.
@@ -34,17 +57,45 @@ export function git(args, input, env = {}) {
34
57
  });
35
58
  }
36
59
  catch (err) {
60
+ if (err.code === 'ETIMEDOUT') {
61
+ const what = plain(args.slice(0, 3).join(' ')).slice(0, 80);
62
+ const t = new GitError(`git ${what} timed out after ${limit / 1000} s (set GIT_GLOSS_GIT_TIMEOUT to change the limit)`);
63
+ t.timedOut = true;
64
+ throw t;
65
+ }
37
66
  const stderr = err.stderr?.trim();
38
67
  const e = new GitError(stderr || `git ${args.join(' ')} failed`);
39
68
  e.status = typeof err.status === 'number' ? err.status : undefined;
40
69
  throw e;
41
70
  }
42
71
  }
43
- export function gitTry(args, input) {
72
+ // For a catch that turns a git failure into a value ("no gloss", "unreadable",
73
+ // "use the defaults"): a git killed at the time limit gave no answer, so the
74
+ // timeout goes on to the caller rather than become that value.
75
+ export function rethrowTimeout(err) {
76
+ if (err instanceof GitError && err.timedOut)
77
+ throw err;
78
+ }
79
+ // Best-effort cleanup (a temporary ref in a `finally`, or after success): never
80
+ // throws, not even on a timeout, so it can't fail a finished job or replace the
81
+ // first error. A timeout leaves one warning line.
82
+ export function gitCleanup(args) {
44
83
  try {
45
- return git(args, input);
84
+ git(args);
46
85
  }
47
- catch {
86
+ catch (err) {
87
+ if (err instanceof GitError && err.timedOut)
88
+ process.stderr.write(`warning: cleanup timed out, a temporary ref may be left (git ${args.join(' ')})\n`);
89
+ }
90
+ }
91
+ export function gitTry(args, input, limit = GIT_TIMEOUT) {
92
+ try {
93
+ return git(args, input, {}, limit);
94
+ }
95
+ catch (err) {
96
+ // Any failure is "no" (callers probe with this), but a git killed at the
97
+ // time limit gave no answer: say so rather than let it read as "no".
98
+ rethrowTimeout(err);
48
99
  return null;
49
100
  }
50
101
  }
@@ -59,11 +110,30 @@ const looksSensitive = (path) => SENSITIVE.some((re) => re.test(path.split('/').
59
110
  // untracked files except ignored ones and ones that look like secrets. Built
60
111
  // in a temporary index so the real index is untouched. `excluded` lists the
61
112
  // untracked files left out as possible secrets.
62
- function workingTree() {
113
+ const READ_ONLY_MAX_UNTRACKED = 5000;
114
+ // One deadline for the whole read-only check, every git call in it included.
115
+ const READ_ONLY_TOTAL_MS = 10_000;
116
+ class TooBig extends Error {
117
+ }
118
+ // A git runner for the read-only check: every call gets what is left of one total deadline
119
+ // (none left: TooBig), and never more than the usual limit.
120
+ function deadlineRun(totalMs) {
121
+ const end = Date.now() + totalMs;
122
+ return (args, env = {}) => {
123
+ const left = end - Date.now();
124
+ if (left <= 0)
125
+ throw new TooBig();
126
+ return git(args, undefined, env, GIT_TIMEOUT ? Math.min(left, GIT_TIMEOUT) : left);
127
+ };
128
+ }
129
+ // `bounded`: the read-only check (see wipStaleness): its runner, and nothing is written to the repo.
130
+ function workingTree(bounded) {
63
131
  const dir = mkdtempSync(join(tmpdir(), 'git-gloss-index-'));
132
+ const readOnly = !!bounded;
133
+ const run = bounded ?? ((args, env = {}) => git(args, undefined, env));
64
134
  try {
65
135
  const index = join(dir, 'index');
66
- const real = git(['rev-parse', '--path-format=absolute', '--git-path', 'index']).trim();
136
+ const real = run(['rev-parse', '--path-format=absolute', '--git-path', 'index']).trim();
67
137
  if (existsSync(real)) {
68
138
  copyFileSync(real, index);
69
139
  // Keep the index's timestamps: git compares them with file mtimes to
@@ -73,12 +143,20 @@ function workingTree() {
73
143
  utimesSync(index, atime, mtime);
74
144
  }
75
145
  const env = { GIT_INDEX_FILE: index };
76
- const untracked = git(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
146
+ const untracked = run(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
77
147
  const excluded = untracked.filter(looksSensitive);
78
- git(['add', '-A', '--', ':/'], undefined, env);
148
+ if (readOnly) {
149
+ if (untracked.length > READ_ONLY_MAX_UNTRACKED)
150
+ throw new TooBig();
151
+ // New objects go to the temporary directory (removed below); the repo's own are only read.
152
+ mkdirSync(join(dir, 'objects'));
153
+ env.GIT_OBJECT_DIRECTORY = join(dir, 'objects');
154
+ env.GIT_ALTERNATE_OBJECT_DIRECTORIES = run(['rev-parse', '--path-format=absolute', '--git-path', 'objects']).trim();
155
+ }
156
+ run(['add', '-A', '--', ':/'], env);
79
157
  if (excluded.length)
80
- git(['rm', '-q', '--cached', '--', ...excluded.map((p) => `:/${p}`)], undefined, env);
81
- return { tree: git(['write-tree'], undefined, env).trim(), excluded };
158
+ run(['rm', '-q', '--cached', '--', ...excluded.map((p) => `:/${p}`)], env);
159
+ return { tree: run(['write-tree'], env).trim(), excluded };
82
160
  }
83
161
  finally {
84
162
  rmSync(dir, { recursive: true, force: true });
@@ -139,13 +217,35 @@ export function archiveSnapshot(sha) {
139
217
  export const dropWipNote = (sha) => gitTry(['notes', '--ref', WIP_NOTES_REF, 'remove', '--ignore-missing', sha]);
140
218
  export const isWipSnapshot = (sha) => gitTry(['log', '-1', '--format=%s', sha])?.trim() === WIP_MESSAGE;
141
219
  // Why the latest snapshot no longer matches the working tree, or null.
142
- export function wipStaleness(sha) {
143
- const s = treeAndParent(sha);
144
- if (s.parent !== headSha())
145
- return 'HEAD has moved since this snapshot was taken';
146
- if (workingTree().tree !== s.tree)
147
- return 'the working tree has changed since this snapshot was taken';
148
- return null;
220
+ // `readOnly` (the ary server): writes nothing to the repository (the check's objects go to a
221
+ // temporary directory), and ONE total deadline covers every git call in it. If that runs out, or
222
+ // git fails, or the tree is too big, the answer is undefined ("could not check"): it is advisory.
223
+ export function wipStaleness(sha, readOnly = false) {
224
+ if (!readOnly) {
225
+ const s = treeAndParent(sha);
226
+ if (s.parent !== headSha())
227
+ return 'HEAD has moved since this snapshot was taken';
228
+ return workingTree().tree !== s.tree ? 'the working tree has changed since this snapshot was taken' : null;
229
+ }
230
+ try {
231
+ const run = deadlineRun(READ_ONLY_TOTAL_MS);
232
+ const [tree, parent] = run(['log', '-1', '--format=%T %P', sha]).trim().split(' ');
233
+ let head = null; // no commits yet: no HEAD
234
+ try {
235
+ head = run(['rev-parse', '--verify', '--quiet', 'HEAD']).trim();
236
+ }
237
+ catch (err) {
238
+ rethrowTimeout(err);
239
+ if (err instanceof TooBig)
240
+ throw err;
241
+ }
242
+ if ((parent || null) !== (head || null))
243
+ return 'HEAD has moved since this snapshot was taken';
244
+ return workingTree(run).tree !== tree ? 'the working tree has changed since this snapshot was taken' : null;
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
149
249
  }
150
250
  // The diff of uncommitted work against HEAD, as a snapshot would have it,
151
251
  // without taking a snapshot: no ref is written (only objects). Hunk ids match
@@ -185,19 +285,28 @@ export function resolveCommit(rev) {
185
285
  // Merges are not reviewed (their first-parent diff repeats the merged
186
286
  // commits) but are returned so views can say they were skipped: a merge can
187
287
  // still carry conflict resolutions or edits of its own.
188
- export function resolveCommits(arg) {
288
+ // `newest`: keep only the newest that many non-merge commits (and the merges since the oldest of
289
+ // them); `total` is how many there were. The rev-list still walks the whole range.
290
+ export function resolveCommits(arg, newest = Infinity) {
189
291
  if (!arg.includes('..'))
190
- return { shas: [resolveCommit(arg)], merges: [], range: null };
292
+ return { shas: [resolveCommit(arg)], merges: [], range: null, total: 1 };
191
293
  if (arg.includes('...')) {
192
294
  throw new GitError(`${arg}: "..." includes commits from both sides; use A..B for the commits on B since A`);
193
295
  }
194
296
  // --end-of-options: the argument is a revision range, never an option.
195
297
  const list = (...flags) => git(['rev-list', '--reverse', ...flags, '--end-of-options', arg, '--']).split('\n').filter(Boolean);
196
- const shas = list('--no-merges');
197
- const merges = list('--merges');
198
- if (!shas.length && !merges.length)
298
+ const plain = list('--no-merges');
299
+ let merges = list('--merges');
300
+ if (!plain.length && !merges.length)
199
301
  throw new GitError(`no commits in ${arg}`);
200
- return { shas, merges, range: arg };
302
+ const shas = plain.slice(-newest);
303
+ if (shas.length < plain.length) {
304
+ // Keep the merges after the oldest shown commit, in the order of the whole range.
305
+ const all = list();
306
+ const keep = new Set(all.slice(all.indexOf(shas[0])));
307
+ merges = merges.filter((c) => keep.has(c));
308
+ }
309
+ return { shas, merges, range: arg, total: plain.length };
201
310
  }
202
311
  export function commitSubject(sha) {
203
312
  return git(['log', '-1', '--format=%s', sha]).trim();
@@ -238,12 +347,22 @@ function cleanStrings(v) {
238
347
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
239
348
  return v;
240
349
  }
350
+ // A note's total size, which no other cap bounds (each cost is linear in it, but
351
+ // 60 MB took seconds, and the hunk records are not capped by count).
352
+ export const noteSizeProblem = (bytes) => bytes > MAX_NOTE_BYTES ? `${(bytes / 1024 / 1024).toFixed(1)} MiB, over the size limit of ${MAX_NOTE_BYTES / 1024 / 1024} MiB (MAX_NOTE_BYTES in src/schema.ts)` : null;
241
353
  export function readNote(sha) {
242
354
  // A commit's gloss, or a wip snapshot's (they are never the same object).
243
355
  const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
244
356
  if (raw === null)
245
357
  return null;
246
- const at = `the gloss on ${sha.slice(0, 10)}`;
358
+ return parseNote(raw, `the gloss on ${sha.slice(0, 10)}`);
359
+ }
360
+ // A stored gloss's text as a note, or a GlossError saying why it can't be one
361
+ // (`at` names it in the message). Also used for glosses read from the notes history.
362
+ export function parseNote(raw, at) {
363
+ const big = noteSizeProblem(Buffer.byteLength(raw));
364
+ if (big)
365
+ throw new GlossError(`${at} is ${big}; replace it with \`git gloss add --force\``);
247
366
  let data;
248
367
  try {
249
368
  data = JSON.parse(raw);
@@ -268,7 +387,12 @@ export function readNote(sha) {
268
387
  return { ...data, annotation: cleanStrings(annotation.data) };
269
388
  }
270
389
  export function writeNote(sha, note, ref = NOTES_REF) {
271
- git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
390
+ const text = JSON.stringify(note, null, 2) + '\n';
391
+ // Readers refuse a bigger note, so it is not written.
392
+ const big = noteSizeProblem(Buffer.byteLength(text));
393
+ if (big)
394
+ throw new GlossError(`the gloss is ${big}; write less text, or fewer or smaller groups`);
395
+ git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], text);
272
396
  }
273
397
  // The commits that changed lines start..end of `file`, newest first, starting
274
398
  // from `commit`, following them through edits, moves within the file and
@@ -264,6 +264,13 @@
264
264
  var f = r.state === 'seen' ? 'current' : r.state === 'code-changed' ? 'code changed since review' : 'gloss changed since review'
265
265
  return v + ' · ' + f
266
266
  }
267
+ // How far the arbiter got with a stage's latest review, for a rail row: nothing unless it found issues.
268
+ function decidedShort(r) {
269
+ // reviewStatus() in src/reviews.ts sorts each stage newest-first, and the page data keeps that order.
270
+ var latest = r.entries[0]
271
+ if (!latest || !latest.decisions || latest.verdict === 'clean') return ''
272
+ return latest.complete ? ' \u00b7 complete' : latest.decisionCount ? ' \u00b7 ' + latest.decisionCount + ' decided' : ' \u00b7 undecided'
273
+ }
267
274
  // One compact row per stage; each opens the Reviews view in the main pane.
268
275
  function reviewStatus(c) {
269
276
  var ci = commits.indexOf(c)
@@ -274,7 +281,7 @@
274
281
  var b = button('rvrow', null, function () { selectReviews(ci, r.stage) })
275
282
  b.setAttribute('data-fid', 'rv:' + ci + ':' + r.stage)
276
283
  b.title = r.phrase
277
- add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r)),
284
+ add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r) + decidedShort(r)),
278
285
  el('span', 'rvn', r.entries.length))
279
286
  box.appendChild(b)
280
287
  })
@@ -342,6 +349,9 @@
342
349
  if (!top || top.indent < indent || top.list.tagName.toLowerCase() !== tag) {
343
350
  if (top && top.indent === indent) stack.pop()
344
351
  var list = el(tag)
352
+ // A numbered list starts at its first item's number, so "5. 6." is not shown as "1. 2.".
353
+ var first = tag === 'ol' ? parseInt(m[2], 10) : 0
354
+ if (first > 0 && first < 100000 && first !== 1) list.start = first
345
355
  if (stack.length && stack[stack.length - 1].li) stack[stack.length - 1].li.appendChild(list); else root.appendChild(list)
346
356
  top = { indent: indent, list: list, li: null }
347
357
  stack.push(top)
@@ -376,6 +386,25 @@
376
386
  if (v) add(head, el('span', 'chip ' + (v === 'ISSUES' ? 'bad' : 'good'), v === 'ISSUES' ? 'issues found' : 'no issues'))
377
387
  return head
378
388
  }
389
+ // What was decided about one review: the summary, then a row per decision. Text only, never markup.
390
+ function decisionsBlock(e) {
391
+ if (!e.decisions) return null
392
+ var box = el('div', 'decisions')
393
+ add(box, el('h3', 'dhead', 'Decisions'), el('p', 'dsum', e.summary))
394
+ if (e.decisionCount > e.decisions.length) add(box, el('p', 'muted dhidden', 'showing the latest ' + e.decisions.length + ' of ' + e.decisionCount))
395
+ if (e.decisions.length) {
396
+ var ul = el('ul', 'dlist')
397
+ e.decisions.forEach(function (d) {
398
+ var row = el('li', 'drow')
399
+ add(row, el('span', 'chip dchip ' + d.outcome, d.outcome), text(' '), el('span', 'dfinding', d.finding))
400
+ if (d.note) add(row, text(' \u2014 '), el('span', 'dnote', d.note))
401
+ add(row, text(' '), el('span', 'muted dby', d.by + ', ' + d.at.slice(0, 10)))
402
+ ul.appendChild(row)
403
+ })
404
+ add(box, ul)
405
+ }
406
+ return box
407
+ }
379
408
  function reviewsPane(c, stage) {
380
409
  var pane = el('div')
381
410
  add(pane, button('btn small back', '‹ Back', goBack))
@@ -387,10 +416,10 @@
387
416
  add(sec, el('h2', null, r.stage), add(el('p', 'rvsum'), el('span', reviewClass(r), r.phrase)))
388
417
  var entries = r.entries.slice().sort(function (a, b) { return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 })
389
418
  entries.forEach(function (e, i) {
390
- if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), markdown(e.findings)))
419
+ if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), decisionsBlock(e), markdown(e.findings)))
391
420
  else {
392
421
  var d = el('details', 'entry old')
393
- add(d, add(el('summary'), entryHead(e)), markdown(e.findings))
422
+ add(d, add(el('summary'), entryHead(e)), decisionsBlock(e), markdown(e.findings))
394
423
  add(sec, d)
395
424
  }
396
425
  })
@@ -463,8 +492,10 @@
463
492
  if (!multi && state.kind === 'group' && dir < 0 && !neighbor(-1)) { selectKind(0, 'overview'); return }
464
493
  if (multi && state.kind !== 'group') {
465
494
  var order = commitOrder()
466
- var next = order[order.indexOf(state.ci) + dir]
467
- if (next !== undefined) selectKind(next, 'overview')
495
+ // The nearest commit the filter shows, not one it hides.
496
+ for (var j = order.indexOf(state.ci) + dir; j >= 0 && j < order.length; j += dir) {
497
+ if (!commitHidden(order[j])) { selectKind(order[j], 'overview'); break }
498
+ }
468
499
  return
469
500
  }
470
501
  var id = neighbor(dir)
@@ -624,6 +655,7 @@
624
655
  function unannotatedBox(c) {
625
656
  var box = add(el('div', 'unannotated'), el('p', null, c.glossed
626
657
  ? 'These changes are in the commit but no group explains them.'
658
+ : c.glossRefused ? 'This commit\'s gloss was refused, so nothing explains these changes.'
627
659
  : 'This commit has no gloss, so nothing explains these changes.'))
628
660
  c.unannotated.forEach(function (h) { box.appendChild(hunkView(c, h, 'un')) })
629
661
  return box
@@ -685,17 +717,18 @@
685
717
  if (multi) add(pane, el('div', 'eyebrow', 'Commit ' + (ci + 1) + ' of ' + commits.length + ' · ' + new Date(c.date).toLocaleDateString()))
686
718
  add(pane, el('h1', 'title', c.subject), commitMeta(c))
687
719
  if (!c.glossed) {
688
- var w = add(el('section', 'warnbox'), el('div', 't', 'No gloss on this commit'),
720
+ var w = add(el('section', 'warnbox'), el('div', 't', c.glossRefused ? 'Gloss refused on this commit' : 'No gloss on this commit'),
721
+ c.glossRefused ? el('p', null, 'The gloss on this commit was refused: ' + c.glossRefused) : null,
689
722
  el('p', null, 'Its whole diff is unannotated, so there is no attention level, no groups and no reasons to read.'),
690
- el('p', 'cmd', 'git gloss add ' + sha7(c)))
723
+ el('p', 'cmd', 'git gloss add ' + sha7(c) + (c.glossRefused ? ' --force' : '')))
691
724
  add(pane, w)
692
725
  add(pane, changesHead('Changes (' + c.unannotated.length + ')'), unannotatedBox(c))
693
726
  return pane
694
727
  }
695
728
  warnings(c, pane)
696
- add(pane, attentionCard(c))
697
729
  statement(pane, 'Task', c.task)
698
730
  statement(pane, 'Summary', c.summary)
731
+ add(pane, attentionCard(c))
699
732
  focusSection(c, ci, pane)
700
733
  if (c.groups.length) {
701
734
  var nb = necessityBar(c)
@@ -924,7 +957,7 @@
924
957
  })
925
958
  var box = add(el('div', 'sum'), el('div', 'eyebrow', 'Range at a glance'), strip, line)
926
959
  var top = commits[order[0]]
927
- if (levelOf(top).level) {
960
+ if (top && levelOf(top).level) {
928
961
  var link = el('a', null, sha7(top) + ' ' + top.subject)
929
962
  link.href = '#c-' + sha7(top)
930
963
  link.addEventListener('click', function (ev) { ev.preventDefault(); selectKind(order[0], 'overview') })
@@ -933,13 +966,51 @@
933
966
  return box
934
967
  }
935
968
 
969
+ // The box above a range's commit list. Matching is plain text (lower-cased `includes` on the
970
+ // short sha, subject and author), never a pattern or markup. The one input is kept across
971
+ // re-renders so what was typed stays; it is not saved anywhere, so a reload clears it.
972
+ var railRows = []
973
+ var filterBox = null
974
+ var noMatch = el('div', 'emptyrows', 'No commits match.')
975
+ function commitHidden(ci) {
976
+ return railRows.some(function (r) { return r.ci === ci && r.node.hidden })
977
+ }
978
+ function applyCommitFilter() {
979
+ var q = filterBox.value.trim().toLowerCase()
980
+ var shown = 0
981
+ railRows.forEach(function (r) {
982
+ var hit = !q || r.text.indexOf(q) >= 0
983
+ r.node.hidden = !hit
984
+ if (hit) shown++
985
+ })
986
+ noMatch.hidden = !q || !!shown
987
+ }
988
+ function commitFilter() {
989
+ if (!filterBox) {
990
+ filterBox = el('input')
991
+ filterBox.type = 'search'
992
+ filterBox.spellcheck = false
993
+ filterBox.placeholder = 'Filter commits'
994
+ filterBox.setAttribute('aria-label', 'Filter commits by message, author or sha')
995
+ filterBox.addEventListener('input', applyCommitFilter)
996
+ }
997
+ return add(el('div', 'cfilter'), filterBox, noMatch)
998
+ }
999
+
936
1000
  function renderRail() {
937
1001
  railEl.textContent = ''
938
1002
  if (multi) {
939
1003
  add(railEl, railSummary(), add(el('div', 'railhead'), el('div', 'eyebrow', state.hot ? 'Hottest first, then newest' : 'Newest first')))
940
1004
  var list = el('div', 'commits')
941
- commitOrder().forEach(function (ci) { list.appendChild(commitRow(ci)) })
942
- add(railEl, list)
1005
+ railRows = []
1006
+ commitOrder().forEach(function (ci) {
1007
+ var row = commitRow(ci)
1008
+ var c = commits[ci]
1009
+ railRows.push({ ci: ci, node: row, text: (sha7(c) + ' ' + c.subject + ' ' + c.author).toLowerCase() })
1010
+ list.appendChild(row)
1011
+ })
1012
+ add(railEl, commitFilter(), list)
1013
+ applyCommitFilter()
943
1014
  if (data.merges.length) {
944
1015
  var ml = add(el('div', 'mergelist'), el('div', 'eyebrow', 'Merges, not reviewed'))
945
1016
  data.merges.forEach(function (m) { ml.appendChild(add(el('div'), el('span', 'mono', m.sha.slice(0, 7)), text(' ' + m.subject))) })
package/dist/html/data.js CHANGED
@@ -1,19 +1,42 @@
1
1
  import { commitDiff, commitSubject, git } from '../git.js';
2
- import { reviewStatus, statusPhrase, verdict } from '../reviews.js';
2
+ import { labelFindings, orderDecisions } from '../findings.js';
3
+ import { decisionSummary, readDecisions, reviewStatus, statusPhrase, verdict } from '../reviews.js';
3
4
  import { parseDiff } from '../diff.js';
4
- import { loadGloss } from '../loader.js';
5
+ import { loadGlossInRange, loadGloss, refusedWarning } from '../loader.js';
6
+ import { plain } from '../plain.js';
5
7
  import { belowFloor } from '../render.js';
6
8
  import { showsImpact, tryAnalyze } from '../floor.js';
7
9
  import { bodyLines, changedCount, gapText, isGap, refLabel, slice, sliceStats } from '../selection.js';
8
10
  import { IMPACT, IMPACT_HELP, NECESSITY, NECESSITY_HELP, servesOf } from '../schema.js';
11
+ // The most decisions listed per review (the newest); the summary counts all of them.
12
+ export const MAX_PAGE_DECISIONS = 200;
13
+ // The page shows commit text as it shows a terminal's: without escape sequences or characters that reorder or hide text.
14
+ const authorAndDate = (sha) => {
15
+ const [author, date] = git(['log', '-1', '--format=%an%x00%aI', sha]).trim().split('\0');
16
+ return [plain(author), plain(date)];
17
+ };
9
18
  function pageReviews(sha) {
19
+ const all = readDecisions(sha);
10
20
  return reviewStatus(sha).map((s) => ({
11
21
  stage: s.stage,
12
22
  state: s.code === 'changed' ? 'code-changed'
13
23
  : (s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed' ? 'gloss-changed' : 'seen',
14
24
  verdict: verdict(s),
15
25
  phrase: statusPhrase(s),
16
- entries: s.reviews.map((r) => ({ agent: r.agent, model: r.model, mode: r.mode, at: r.at, target: r.target, findings: r.findings })),
26
+ entries: s.reviews.map((r) => {
27
+ const entry = { id: r.id, agent: r.agent, model: r.model, mode: r.mode, at: r.at, target: r.target, findings: labelFindings(r.findings) };
28
+ const ds = all.filter((d) => d.review === r.id);
29
+ if (r.verdict === 'clean' && !ds.length)
30
+ return entry;
31
+ // Sorted here only to pick the newest MAX_PAGE_DECISIONS; orderDecisions sets the order shown.
32
+ const done = ds.filter((d) => d.outcome).sort((a, b) => a.at.localeCompare(b.at));
33
+ entry.decisions = orderDecisions(done.slice(-MAX_PAGE_DECISIONS)).map((d) => ({ outcome: d.outcome, finding: plain(d.finding), ...(d.note ? { note: plain(d.note) } : {}), by: plain(d.by), at: plain(d.at) }));
34
+ entry.complete = ds.some((d) => d.complete);
35
+ entry.summary = decisionSummary(ds);
36
+ entry.verdict = r.verdict;
37
+ entry.decisionCount = done.length;
38
+ return entry;
39
+ }),
17
40
  }));
18
41
  }
19
42
  // The lines to show for a selection: the @@ header, then the slice, with
@@ -71,7 +94,7 @@ export function reviewOrder(groups, focus = []) {
71
94
  }
72
95
  const allLines = (h) => Array.from({ length: changedCount(h) }, (_, i) => i + 1);
73
96
  export function commitData(gloss) {
74
- const [author, date] = git(['log', '-1', '--format=%an%x00%aI', gloss.sha]).trim().split('\0');
97
+ const [author, date] = authorAndDate(gloss.sha);
75
98
  const a = gloss.note.annotation;
76
99
  const an = tryAnalyze(gloss);
77
100
  // Same threshold as `show`: derived impact badges only where the diff puts a group above module.
@@ -120,7 +143,7 @@ export function commitData(gloss) {
120
143
  .sort((x, y) => NECESSITY.indexOf(x.necessity) - NECESSITY.indexOf(y.necessity));
121
144
  return {
122
145
  sha: gloss.sha,
123
- subject: gloss.subject,
146
+ subject: plain(gloss.subject),
124
147
  author,
125
148
  date,
126
149
  glossed: true,
@@ -137,14 +160,15 @@ export function commitData(gloss) {
137
160
  reviews: pageReviews(gloss.sha),
138
161
  };
139
162
  }
140
- function unglossedData(sha) {
141
- const [author, date] = git(['log', '-1', '--format=%an%x00%aI', sha]).trim().split('\0');
163
+ function unglossedData(sha, refused) {
164
+ const [author, date] = authorAndDate(sha);
142
165
  return {
143
166
  sha,
144
- subject: commitSubject(sha),
167
+ subject: plain(commitSubject(sha)),
145
168
  author,
146
169
  date,
147
170
  glossed: false,
171
+ ...(refused ? { glossRefused: plain(refused).slice(0, 300) } : {}),
148
172
  task: '',
149
173
  attention: null,
150
174
  computed: null,
@@ -160,12 +184,15 @@ function unglossedData(sha) {
160
184
  export function buildPageData(shas, range = null, merges = []) {
161
185
  return {
162
186
  range,
163
- merges: merges.map((sha) => ({ sha, subject: commitSubject(sha) })),
187
+ merges: merges.map((sha) => ({ sha, subject: plain(commitSubject(sha)) })),
164
188
  necessity: NECESSITY.map((name) => ({ name, help: NECESSITY_HELP[name] })),
165
189
  impact: IMPACT.map((name) => ({ name, help: IMPACT_HELP[name] })),
166
190
  commits: shas.map((sha) => {
167
- const gloss = loadGloss(sha);
168
- return gloss ? commitData(gloss) : unglossedData(sha);
191
+ // In a range, a refused gloss is skipped (with a warning) like an unglossed commit.
192
+ const { gloss, refused } = range ? loadGlossInRange(sha) : { gloss: loadGloss(sha), refused: undefined };
193
+ if (refused)
194
+ console.error(refusedWarning(sha, refused));
195
+ return gloss ? commitData(gloss) : unglossedData(sha, refused);
169
196
  }),
170
197
  generated: new Date().toISOString(),
171
198
  };
@@ -123,6 +123,7 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
123
123
  .ary-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; min-width: 0; }
124
124
  .ary-repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
125
125
  .ary-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
126
+ .ary-wip { font-size: 13px; }
126
127
  .ary-notice { flex-basis: 100%; margin: 0; padding: 6px 10px; border-radius: 8px; border: 1px dashed var(--warn); font-size: 13px; color: var(--ink); }
127
128
  .combo { position: relative; }
128
129
  .combo-btn, .combo-panel input, .ary-rev input { border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 8px; min-height: 36px; padding: 4px 12px; font-size: 13px; }
@@ -220,6 +221,9 @@ button.chip { min-height: 32px; }
220
221
  .strip i.none { background: transparent; border: 1.5px dashed var(--attention-none); }
221
222
  .commits { display: flex; flex-direction: column; padding: 0 8px 16px; }
222
223
  .crow { margin-bottom: 2px; }
224
+ .crow[hidden] { display: none; }
225
+ .cfilter { padding: 0 8px 8px; }
226
+ .cfilter input { width: 100%; box-sizing: border-box; border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 8px; min-height: 36px; padding: 4px 12px; font-size: 13px; }
223
227
  .crow > .pick { display: block; width: 100%; border: 1px solid transparent; border-radius: 8px; padding: 10px 12px; min-height: 64px; }
224
228
  .crow.sel > .pick { background: var(--accent-soft); border-color: var(--accent); }
225
229
  .rtop { display: flex; align-items: center; gap: 10px; }
@@ -340,6 +344,18 @@ tr.gap td { background: var(--bg); color: var(--muted); font-style: italic; font
340
344
  .chip.good { color: var(--ok); border-color: var(--ok); }
341
345
  .chip.bad { color: var(--warn); border-color: var(--warn); }
342
346
 
347
+ /* Decisions on a review: the outcome differs by its word and its border, not by colour alone */
348
+ .decisions { margin: 0 0 12px; }
349
+ .dhead { margin: 0 0 2px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
350
+ .dsum { margin: 0 0 6px; }
351
+ .dlist { list-style: none; margin: 0; padding: 0; }
352
+ .drow { padding: 4px 0; overflow-wrap: anywhere; }
353
+ .dchip { font-size: 12px; padding: 0 8px; }
354
+ .dchip.fixed { color: var(--ok); border-color: var(--ok); border-style: solid; }
355
+ .dchip.follow-up { border-style: dashed; }
356
+ .dchip.rejected { border-style: dotted; text-decoration: line-through; }
357
+ .dchip.accepted { border-style: double; border-width: 3px; }
358
+
343
359
  /* Rendered markdown */
344
360
  .md { max-width: 75ch; overflow-wrap: anywhere; }
345
361
  .md p { margin: 0 0 10px; }
package/dist/loader.js CHANGED
@@ -1,4 +1,5 @@
1
- import { commitDiff, commitSubject, GlossError, readNote } from './git.js';
1
+ import { commitDiff, commitSubject, GitError, GlossError, readNote } from './git.js';
2
+ import { plain } from './plain.js';
2
3
  import { parseDiff } from './diff.js';
3
4
  import { changedCount, claimsProblem, selections, uncovered } from './selection.js';
4
5
  const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
@@ -37,8 +38,11 @@ export function matchHunks(stored, current) {
37
38
  // the wrong code under a group without anyone noticing.
38
39
  export function loadGloss(sha) {
39
40
  const note = readNote(sha);
40
- if (!note)
41
- return null;
41
+ return note && loadGlossOf(sha, note);
42
+ }
43
+ // The same, for a note already read: what `versions --restore` checks a version
44
+ // with before it becomes the gloss, so it can never restore what readers refuse.
45
+ export function loadGlossOf(sha, note) {
42
46
  const current = parseDiff(commitDiff(sha));
43
47
  const resolved = matchHunks(note.hunks, current);
44
48
  const groups = note.annotation.groups.map((group) => ({
@@ -64,3 +68,18 @@ export function loadGloss(sha) {
64
68
  }
65
69
  return { sha, subject: commitSubject(sha), note, hunks: current, groups, unannotated, stale: warnings.length > 0, warnings };
66
70
  }
71
+ // For a range: a commit whose gloss is refused (too big, malformed, over the
72
+ // caps, claims too many lines) is skipped like an unglossed one, and the
73
+ // reason comes back for the caller to warn about, so one hostile note does not
74
+ // fail the whole range. A git failure or timeout is not a refusal and still throws.
75
+ export function loadGlossInRange(sha) {
76
+ try {
77
+ return { gloss: loadGloss(sha) };
78
+ }
79
+ catch (err) {
80
+ if (!(err instanceof GlossError) || err instanceof GitError)
81
+ throw err;
82
+ return { gloss: null, refused: err.message };
83
+ }
84
+ }
85
+ export const refusedWarning = (sha, why) => `warning: skipping the gloss on ${sha.slice(0, 10)}, which ${plain(why).replace(/^the gloss on \S+ /, '')}`;
package/dist/plain.js CHANGED
@@ -5,5 +5,6 @@ export function plain(text, multiline = false) {
5
5
  // OSC (ended by BEL or ESC \) and CSI sequences, then any lone ESC and control characters (newline and tab kept when multiline).
6
6
  .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
7
7
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]?/g, '')
8
- .replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, (c) => (multiline && (c === '\n' || c === '\t') ? c : ' '));
8
+ // Also the invisible format characters that reorder or hide text: LRM, RLM, ALM (U+200E, U+200F, U+061C), zero-width space (U+200B), word joiner and invisible operators (U+2060-U+2064), BOM (U+FEFF), the Mongolian vowel separator (U+180E) and tag characters (U+E0000-U+E007F). U+200C (ZWNJ) and U+200D (ZWJ) are kept: stripping them would mangle valid text (Persian words, emoji sequences), and they reorder or hide nothing. The soft hyphen (U+00AD) is kept too: it hides no text. The html page does not use plain().
9
+ .replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069\u200b\u200e\u200f\u061c\u180e\u2060-\u2064\ufeff\u{e0000}-\u{e007f}]/gu, (c) => (multiline && (c === '\n' || c === '\t') ? c : ' '));
9
10
  }