@ok-ggwp/git-gloss 0.4.0 → 0.5.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.
@@ -5,7 +5,8 @@ import { loadGloss } from '../loader.js';
5
5
  import { impactRank } from '../attention.js';
6
6
  import { showsImpact, tryAnalyze } from '../floor.js';
7
7
  import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
8
- import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
8
+ import { current, decisionSummary, readDecisions, readReviews, reviewStatus, statusPhrase, verdict } from '../reviews.js';
9
+ import { plain } from '../plain.js';
9
10
  import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
10
11
  export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
11
12
  // Counts over the lines each part selects, not whole hunks, so groups that
@@ -102,12 +103,12 @@ export function run(argv) {
102
103
  out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
103
104
  const gloss = glosses[i];
104
105
  if (!gloss) {
105
- out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
106
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
106
107
  return;
107
108
  }
108
109
  // An --only filter can leave a commit with nothing to show; say so and move on.
109
110
  out.push(...(renderCommit(sha, gloss, values, only, false) ??
110
- [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
111
+ [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`, style('dim', `no ${only?.names.join('/')} groups`)]));
111
112
  });
112
113
  if (glossed)
113
114
  out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
@@ -122,7 +123,14 @@ function reviewsLine(sha) {
122
123
  if (!statuses.length)
123
124
  return style('dim', 'reviews: none recorded');
124
125
  const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
125
- return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
126
+ // What was decided about the reviews that found something (or didn't say);
127
+ // a commit with only clean reviews has nothing to decide.
128
+ const open = readReviews(sha).filter((r) => r.verdict !== 'clean');
129
+ const decisions = readDecisions(sha).filter((d) => open.some((r) => r.id === d.review));
130
+ const decided = open.length
131
+ ? ` ${style('dim', '·')} decisions: ${decisionSummary(decisions, open.every((r) => decisions.some((d) => d.review === r.id && d.complete)))}`
132
+ : '';
133
+ return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}${decided}` +
126
134
  style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
127
135
  }
128
136
  // One commit's view, or null if no group matches -g/--only. `hints` adds the
@@ -130,14 +138,14 @@ function reviewsLine(sha) {
130
138
  function renderCommit(sha, gloss, values, only, hints) {
131
139
  const a = gloss.note.annotation;
132
140
  const short = sha.slice(0, 7);
133
- const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
141
+ const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`];
134
142
  out.push(...field('task:', a.task, 0));
135
143
  if (a.summary)
136
144
  out.push(...field('summary:', a.summary, 0));
137
145
  for (const w of gloss.warnings)
138
146
  out.push(...field('⚠', w, 0).map((l) => style('red', l)));
139
147
  const an = tryAnalyze(gloss);
140
- out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
148
+ out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived, timedOut: an.timedOut } : undefined), reviewsLine(sha));
141
149
  const groups = gloss.groups
142
150
  .filter((g) => matchesOnly(g.group, only))
143
151
  .filter((g) => !values.group || values.group.includes(g.group.id));
@@ -1,3 +1,4 @@
1
+ import { plain } from '../plain.js';
1
2
  import { parseArgs } from 'node:util';
2
3
  import { loadConfig } from '../config.js';
3
4
  import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
@@ -17,11 +18,11 @@ export function summaryLines(sig, max = 6) {
17
18
  const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
18
19
  .sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
19
20
  for (const f of files.slice(0, max))
20
- out.push(` ${(f.floor + ':').padEnd(10)} ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
21
+ out.push(` ${(f.floor + ':').padEnd(10)} ${plain(f.file)}${f.why.length ? ` (${plain(f.why.join('; '))})` : ''}`);
21
22
  if (files.length > max)
22
23
  out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
23
24
  for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
24
- out.push(` large: ${r}`);
25
+ out.push(` large: ${plain(r)}`);
25
26
  if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
26
27
  out.push(` note: ${CONFIG_EDIT_NOTE}`);
27
28
  out.push(` stats: ${statsLine(sig.stats)}`);
@@ -31,16 +32,16 @@ function detailLines(sig, label) {
31
32
  const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
32
33
  style('dim', ` (floor impact: ${sig.floor.impact})`)];
33
34
  for (const r of sig.floor.reasons)
34
- out.push(` why: ${r}`);
35
+ out.push(` why: ${plain(r)}`);
35
36
  out.push(` stats: ${statsLine(sig.stats)}`);
36
37
  const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
37
38
  if (notable.length)
38
39
  out.push('', style('bold', 'hunks with signals'));
39
40
  for (const h of notable) {
40
- out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${h.file}` +
41
- style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + h.why.join('; ') : ''}`));
41
+ out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${plain(h.file)}` +
42
+ style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + plain(h.why.join('; ')) : ''}`));
42
43
  for (const x of h.hits.slice(0, 8)) {
43
- out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', x.text)}`);
44
+ out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.timedOut ? ' (pattern timed out: counted as matching)' : ''}${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', plain(x.text))}`);
44
45
  }
45
46
  if (h.hits.length > 8)
46
47
  out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
@@ -51,9 +52,9 @@ function detailLines(sig, label) {
51
52
  if (sig.dependencies.length)
52
53
  out.push('', style('bold', 'dependency changes'));
53
54
  for (const d of sig.dependencies) {
54
- out.push(` ${d.file} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
55
+ out.push(` ${plain(d.file)} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
55
56
  for (const b of d.bumps.slice(0, 20)) {
56
- const what = b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`;
57
+ const what = plain(b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`);
57
58
  out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
58
59
  }
59
60
  if (d.bumps.length > 20)
@@ -46,6 +46,18 @@ function shareable(remote, pushing) {
46
46
  return new Set();
47
47
  return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
48
48
  }
49
+ // The fetched ref is the remote's to set: it must be a commit before notes
50
+ // code (or an update-ref onto our own notes ref) touches it.
51
+ function checkTracking(remote, ref, tracking) {
52
+ if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
53
+ gitTry(['update-ref', '-d', tracking]);
54
+ throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
55
+ }
56
+ }
57
+ function fetchTracking(remote, ref, tracking) {
58
+ git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
59
+ checkTracking(remote, ref, tracking);
60
+ }
49
61
  const PUSH_REF = 'refs/notes/gloss-sync-tmp';
50
62
  // One fetch → merge → push round. Returns null if the push lost a race with
51
63
  // someone else's push and the round should be retried.
@@ -53,7 +65,7 @@ function round(remote, pushing, carried) {
53
65
  const tracking = `refs/notes/remotes/${remote}/gloss`;
54
66
  const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
55
67
  if (remoteHas)
56
- git(['fetch', '-q', remote, `+${NOTES_REF}:${tracking}`]);
68
+ fetchTracking(remote, NOTES_REF, tracking);
57
69
  const local = noteList(NOTES_REF);
58
70
  const theirs = remoteHas ? noteList(tracking) : new Map();
59
71
  // The last state both sides shared. A gloss changed on one side only is
@@ -159,7 +171,7 @@ function syncReviews(remote, pushing) {
159
171
  const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
160
172
  const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
161
173
  if (remoteHas)
162
- git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
174
+ fetchTracking(remote, REVIEWS_REF, tracking);
163
175
  const before = noteList(REVIEWS_REF);
164
176
  const theirs = remoteHas ? noteList(tracking) : new Map();
165
177
  if (remoteHas) {
@@ -1,3 +1,4 @@
1
+ import { plain } from '../plain.js';
1
2
  import { parseArgs } from 'node:util';
2
3
  import { blameRange, commitSubject, lineHistory } from '../git.js';
3
4
  import { loadGloss } from '../loader.js';
@@ -158,8 +159,8 @@ export function run(argv) {
158
159
  const renderStep = (s, label, older) => {
159
160
  // Where the lines were in an older commit: after edits or a file rename
160
161
  // that differs from the query.
161
- const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
162
- out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
162
+ const then = older || s.file !== file ? style('dim', ` (${plain(s.file)}:${formatRanges(s.lines)} then)`) : '';
163
+ out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${plain(s.subject)}${then}`);
163
164
  if (!s.gloss) {
164
165
  out.push(style('dim', ' no gloss for this commit'));
165
166
  return;
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { GlossError, gitTry } from './git.js';
2
+ import { git, GitError, GlossError, gitTry } 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):
@@ -135,6 +135,31 @@ function gitInt(key) {
135
135
  throw new ConfigError(`git config gloss.attention.${key} must be a positive integer, got "${v}"`);
136
136
  return Number(v);
137
137
  }
138
+ // Git failing (a broken repo, an unreadable object, no permission) is not the
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.
144
+ const warned = new Set();
145
+ function warnOnce(msg) {
146
+ if (warned.has(msg))
147
+ return;
148
+ warned.add(msg);
149
+ console.error(`warning: ${msg}`);
150
+ }
151
+ // git's output, or null when it exits 1 with nothing to say (its way of
152
+ // answering "no"); any other failure is thrown.
153
+ function gitAnswer(args) {
154
+ try {
155
+ return git(args);
156
+ }
157
+ catch (err) {
158
+ if (err instanceof GitError && err.status === 1 && /failed$/.test(err.message))
159
+ return null;
160
+ throw err;
161
+ }
162
+ }
138
163
  // The config is read from the base branch, not the working tree: a change under
139
164
  // review must not be able to reclassify its own paths or add patterns that
140
165
  // lower its own floor. The base is the merge base of the checked-out HEAD (not
@@ -145,18 +170,49 @@ function gitInt(key) {
145
170
  // the change's own config. Editing the file in a change is itself flagged: it
146
171
  // is always a `sensitive` path.
147
172
  function trustedRef() {
173
+ // No commits yet: no base, and nothing failed.
174
+ if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
175
+ return noBase();
176
+ let failed = false;
148
177
  // Full ref names, so a tag called "main" cannot stand in for the branch.
149
178
  for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
150
- const mb = gitTry(['merge-base', 'HEAD', b])?.trim();
151
- if (mb)
152
- return mb;
179
+ try {
180
+ if (gitAnswer(['rev-parse', '--verify', '-q', b]) === null)
181
+ continue;
182
+ const mb = gitAnswer(['merge-base', 'HEAD', b])?.trim();
183
+ if (mb)
184
+ return mb;
185
+ }
186
+ catch (err) {
187
+ // A broken candidate does not hide the ones after it.
188
+ failed = true;
189
+ warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
190
+ }
153
191
  }
192
+ // 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();
194
+ }
195
+ function noBase() {
154
196
  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');
155
197
  return null;
156
198
  }
199
+ // 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
201
+ // is asked of ls-tree (empty output: absent), not read off an error message.
202
+ function configAt(ref) {
203
+ try {
204
+ if (!git(['ls-tree', '--full-tree', '--name-only', ref, '--', CONFIG_FILE]).trim())
205
+ return null;
206
+ return git(['show', `${ref}:${CONFIG_FILE}`]);
207
+ }
208
+ 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;
211
+ }
212
+ }
157
213
  function readRepoConfig() {
158
214
  const ref = trustedRef();
159
- const text = ref ? gitTry(['show', `${ref}:${CONFIG_FILE}`]) : null;
215
+ const text = ref ? configAt(ref) : null;
160
216
  if (text === null)
161
217
  return undefined;
162
218
  let json;
package/dist/floor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ConfigError, loadConfig } from './config.js';
2
2
  import { attentionForImpact, attentionRank, impactRank, maxAttention, maxImpact, raise } from './attention.js';
3
3
  import { servesOf } from './schema.js';
4
- import { analyze, CONFIG_EDIT_NOTE, partFloor } from './signals.js';
4
+ import { analyze, CONFIG_EDIT_NOTE, partFloor, timedOutNotes } from './signals.js';
5
5
  import { changedCount, selections } from './selection.js';
6
6
  // The groups of a loaded gloss with their current hunks (a hunk that is gone is skipped).
7
7
  export function groupInputs(gloss) {
@@ -101,7 +101,7 @@ export function analyzeGroups(hunks, groups, cfg) {
101
101
  level = maxAttention(level, signals.floor.attention);
102
102
  reasons.push(`floor ${signals.floor.attention}: ${signals.floor.reasons[0] ?? signals.floor.impact}`);
103
103
  }
104
- return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons } };
104
+ return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons }, timedOut: timedOutNotes(signals.hunks) };
105
105
  }
106
106
  export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
107
107
  export function ratingProblems(a, an) {
package/dist/git.js CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { Annotation, NOTE_FORMAT } from './schema.js';
6
6
  import { parsePath, unquote } from './diff.js';
7
+ import { plain } from './plain.js';
7
8
  export const NOTES_REF = 'refs/notes/gloss';
8
9
  // Glosses of uncommitted work (wip snapshots) live in their own notes ref,
9
10
  // which sync never pushes, and the latest snapshot in a private ref that
@@ -18,6 +19,8 @@ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
18
19
  export class GlossError extends Error {
19
20
  }
20
21
  export class GitError extends GlossError {
22
+ // git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
23
+ status;
21
24
  }
22
25
  export function git(args, input, env = {}) {
23
26
  try {
@@ -32,7 +35,9 @@ export function git(args, input, env = {}) {
32
35
  }
33
36
  catch (err) {
34
37
  const stderr = err.stderr?.trim();
35
- throw new GitError(stderr || `git ${args.join(' ')} failed`);
38
+ const e = new GitError(stderr || `git ${args.join(' ')} failed`);
39
+ e.status = typeof err.status === 'number' ? err.status : undefined;
40
+ throw e;
36
41
  }
37
42
  }
38
43
  export function gitTry(args, input) {
@@ -224,6 +229,15 @@ function rawNote(ref, sha) {
224
229
  throw err;
225
230
  }
226
231
  }
232
+ function cleanStrings(v) {
233
+ if (typeof v === 'string')
234
+ return plain(v, true);
235
+ if (Array.isArray(v))
236
+ return v.map(cleanStrings);
237
+ if (v && typeof v === 'object')
238
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
239
+ return v;
240
+ }
227
241
  export function readNote(sha) {
228
242
  // A commit's gloss, or a wip snapshot's (they are never the same object).
229
243
  const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
@@ -249,7 +263,9 @@ export function readNote(sha) {
249
263
  if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
250
264
  throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
251
265
  }
252
- return { ...data, annotation: annotation.data };
266
+ // The gloss's text is shown in a terminal, and a note can come from anyone
267
+ // who can push to the remote: no escape sequences or control characters get through.
268
+ return { ...data, annotation: cleanStrings(annotation.data) };
253
269
  }
254
270
  export function writeNote(sha, note, ref = NOTES_REF) {
255
271
  git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
@@ -62,7 +62,7 @@
62
62
  return { level: null, source: 'unrated' }
63
63
  }
64
64
  function fileCount(c) {
65
- var seen = {}
65
+ var seen = Object.create(null)
66
66
  c.groups.forEach(function (g) { g.hunks.forEach(function (h) { seen[h.file] = true }) })
67
67
  c.unannotated.forEach(function (h) { seen[h.file] = true })
68
68
  return Object.keys(seen).length
@@ -72,7 +72,7 @@
72
72
  var readCache = {}
73
73
  function readSet(c) {
74
74
  if (!readCache[c.sha]) {
75
- var s = {}
75
+ var s = Object.create(null) // no prototype: a group id like "constructor" must not read as already read
76
76
  try {
77
77
  var raw = window.sessionStorage.getItem('git-gloss:read:' + c.sha)
78
78
  if (raw) JSON.parse(raw).forEach(function (id) { s[id] = true })
@@ -240,6 +240,11 @@
240
240
  reasons.forEach(function (r) { ul.appendChild(el('li', null, r)) })
241
241
  add(box, ul)
242
242
  }
243
+ if (k && k.timedOut && k.timedOut.length) {
244
+ var tl = el('ul', 'timedout')
245
+ k.timedOut.forEach(function (r) { tl.appendChild(el('li', null, r)) })
246
+ add(box, tl)
247
+ }
243
248
  if (c.risks.length) {
244
249
  var rb = button('linkbtn', plural(c.risks.length, 'risk') + ' flagged', function () { selectRisks(commits.indexOf(c)) })
245
250
  add(box, add(el('p', 'look'), rb))
@@ -278,6 +283,8 @@
278
283
 
279
284
  // ---- markdown: a small subset, built as DOM nodes (never parsed as HTML) ----
280
285
  var ALNUM = /[A-Za-z0-9]/
286
+ // Review text is untrusted and unbounded: no emphasis or link may reach further than this.
287
+ var MAX_SPAN = 500
281
288
  function inline(parent, s) {
282
289
  var i = 0, buf = ''
283
290
  function flush() { if (buf) { parent.appendChild(text(buf)); buf = '' } }
@@ -290,12 +297,13 @@
290
297
  j = s.indexOf('**', i + 2)
291
298
  if (j > i + 2) { flush(); parent.appendChild(inline(el('strong'), s.slice(i + 2, j))); i = j + 2; continue }
292
299
  } else if (ch === '*' && s.charAt(i + 1) && !/[\s*]/.test(s.charAt(i + 1)) && (i === 0 || !ALNUM.test(s.charAt(i - 1)))) {
293
- for (j = i + 2; j < s.length; j++) {
300
+ // Looks no further than MAX_SPAN: an unclosed marker would otherwise re-read the rest of the text each time (quadratic).
301
+ for (j = i + 2; j < s.length && j < i + MAX_SPAN; j++) {
294
302
  if (s.charAt(j) === '*' && !/\s/.test(s.charAt(j - 1)) && !ALNUM.test(s.charAt(j + 1)) && s.charAt(j + 1) !== '*') break
295
303
  }
296
- if (j < s.length) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
304
+ if (j < s.length && j < i + MAX_SPAN) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
297
305
  } else if (ch === '[') {
298
- m = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(s.slice(i))
306
+ m = /^\[([^\]]{1,200})\]\(([^)\s]{1,400})\)/.exec(s.slice(i, i + MAX_SPAN))
299
307
  if (m) { flush(); inline(parent, m[1]); parent.appendChild(el('span', 'muted', ' (' + m[2] + ')')); i += m[0].length; continue }
300
308
  }
301
309
  buf += ch
@@ -466,6 +474,10 @@
466
474
 
467
475
  // ---- the detail pane ----
468
476
  var topEl = el('header', 'top')
477
+ // Served by `git gloss ary`: the branch pickers (src/ary/picker.js, included
478
+ // ahead of this script only then) live in the top bar. Built once, since
479
+ // the bar is rebuilt on every change of filter.
480
+ var aryEl = data.ary ? aryBar(data.ary, el, add) : null
469
481
  var railEl = el('aside', 'rail')
470
482
  railEl.setAttribute('aria-label', multi ? 'Commits in the range' : 'Groups in review order')
471
483
  var mainEl = el('main', 'main')
@@ -562,7 +574,7 @@
562
574
  var order = c.order
563
575
  var hunkN = g.hunks.length
564
576
  var files = []
565
- var perFile = {}
577
+ var perFile = Object.create(null)
566
578
  g.hunks.forEach(function (h) {
567
579
  if (!perFile[h.file]) { perFile[h.file] = 0; files.push(h.file) }
568
580
  perFile[h.file]++
@@ -753,7 +765,7 @@
753
765
  function renderTop() {
754
766
  var c = commits[state.ci]
755
767
  topEl.textContent = ''
756
- add(topEl, el('span', 'brand', 'git gloss'))
768
+ add(topEl, el('span', 'brand', 'git gloss'), aryEl)
757
769
  if (multi) {
758
770
  var glossed = commits.filter(function (x) { return x.glossed }).length
759
771
  var vetted = commits.filter(function (x) {
@@ -1091,8 +1103,12 @@
1091
1103
 
1092
1104
  // ---- start ----
1093
1105
  var desk = add(el('div', 'desk'), topEl, body)
1106
+ // The rail sticks below the top bar, whose height varies (the pickers wrap).
1107
+ function measureTop() { desk.style.setProperty('--top-h', topEl.offsetHeight + 'px') }
1094
1108
  app.textContent = ''
1095
1109
  app.appendChild(desk)
1110
+ measureTop()
1111
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(measureTop).observe(topEl)
1096
1112
  if (multi) {
1097
1113
  var first = commitOrder()[0]
1098
1114
  state.ci = first
package/dist/html/data.js CHANGED
@@ -127,7 +127,7 @@ export function commitData(gloss) {
127
127
  task: a.task,
128
128
  summary: a.summary,
129
129
  attention: a.attention ?? null,
130
- computed: an ? { floor: an.floor, derived: an.derived, below: belowFloor(a.attention, an) } : null,
130
+ computed: an ? { floor: an.floor, derived: an.derived, below: belowFloor(a.attention, an), timedOut: an.timedOut } : null,
131
131
  warnings: gloss.warnings,
132
132
  groups,
133
133
  order: reviewOrder(groups, a.attention?.focus),
package/dist/html/page.js CHANGED
@@ -27,7 +27,7 @@ ${asset('./style.css')}
27
27
  <div id="app"><noscript>This page needs JavaScript to render the gloss.</noscript></div>
28
28
  <script type="application/json" id="gloss-data">${embedJson(data)}</script>
29
29
  <script nonce="${nonce}">
30
- ${asset('./client.js')}
30
+ ${data.ary ? asset('../ary/picker.js') + '\n' : ''}${asset('./client.js')}
31
31
  </script>
32
32
  </body>
33
33
  </html>
@@ -119,13 +119,38 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
119
119
  .track { width: 120px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
120
120
  .track i { display: block; height: 100%; width: 0; background: var(--accent); }
121
121
 
122
+ /* Branch pickers and revision box in the top bar, only when served by `git gloss ary` */
123
+ .ary-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; min-width: 0; }
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
+ .ary-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
126
+ .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
+ .combo { position: relative; }
128
+ .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; }
129
+ .combo-btn { position: relative; max-width: 220px; padding-right: 28px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12.5px; }
130
+ .combo-btn::after { content: "▾"; position: absolute; right: 10px; color: var(--muted); }
131
+ .combo-btn:hover { background: var(--accent-soft); }
132
+ .combo-panel { position: absolute; z-index: 10; left: 0; top: calc(100% + 4px); width: 300px; max-width: calc(100vw - 32px); display: grid; gap: 6px; padding: 6px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); }
133
+ .combo-panel[hidden] { display: none; }
134
+ .combo-panel input { width: 100%; }
135
+ .combo-list { max-height: min(50vh, 320px); overflow: auto; }
136
+ .combo-group { margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--line); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink); }
137
+ .combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
138
+ .combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
139
+ .combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 6px; padding: 5px 8px 5px 20px; font-family: var(--mono); font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
140
+ .combo-item:hover, .combo-item.active { background: var(--accent-soft); }
141
+ .combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
142
+ .combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
143
+ .ary-rev input { width: 220px; max-width: 100%; font-family: var(--mono); font-size: 12.5px; }
144
+
122
145
  /* Body: rail above the pane when narrow, beside it from 900px */
123
146
  .body { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; }
124
147
  @media (min-width: 900px) {
125
148
  /* the rail's panel colour runs the full height, however tall the top bar is */
126
149
  .body { grid-template-columns: 360px minmax(0, 1fr); background: linear-gradient(90deg, var(--panel) 359px, var(--line) 359px 360px, transparent 360px); }
127
150
  .body.range { grid-template-columns: 400px minmax(0, 1fr); background: linear-gradient(90deg, var(--panel) 399px, var(--line) 399px 400px, transparent 400px); }
128
- .rail { position: sticky; top: 0; max-height: 100vh; overflow: auto; overscroll-behavior: contain; border-bottom: 0; border-right: 1px solid var(--line); }
151
+ /* the bar stays put and the rail fits in what is left (--top-h is measured by the script) */
152
+ .top { position: sticky; top: 0; z-index: 5; }
153
+ .rail { position: sticky; top: var(--top-h, 0px); max-height: calc(100vh - var(--top-h, 0px)); overflow: auto; overscroll-behavior: contain; border-bottom: 0; border-right: 1px solid var(--line); }
129
154
  }
130
155
  .rail { background: var(--panel); border-bottom: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; }
131
156
  .main { min-width: 0; padding: 20px 16px 48px; }
@@ -139,6 +164,7 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
139
164
  .att li { margin-bottom: 4px; }
140
165
  .att .look { margin: 10px 0 0; font-size: 13px; }
141
166
  .att .override { margin: 10px 0 0; font-size: 13px; color: var(--warn-text); background: var(--warn-bg); border-radius: 6px; padding: 6px 10px; }
167
+ .att .timedout { margin: 10px 0 0; padding: 6px 10px 6px 28px; font-size: 13px; color: var(--warn-text); background: var(--warn-bg); border-radius: 6px; }
142
168
  .att .override.mismatch { font-weight: 600; border: 1px solid var(--warn-border); }
143
169
  .gauge { display: inline-flex; gap: 3px; }
144
170
  .gauge i { display: block; width: 22px; height: 8px; border-radius: 2px; background: var(--line); }
package/dist/loader.js CHANGED
@@ -1,6 +1,6 @@
1
- import { commitDiff, commitSubject, readNote } from './git.js';
1
+ import { commitDiff, commitSubject, GlossError, readNote } from './git.js';
2
2
  import { parseDiff } from './diff.js';
3
- import { changedCount, selections, uncovered } from './selection.js';
3
+ import { changedCount, claimsProblem, selections, uncovered } from './selection.js';
4
4
  const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
5
5
  // Pair each stored hunk with a current one: first by the hash of its changes
6
6
  // (it may have moved in a rebase), then by location (same place, different
@@ -49,6 +49,10 @@ export function loadGloss(sha) {
49
49
  })),
50
50
  }));
51
51
  const claims = groups.flatMap((g) => g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])));
52
+ // Readers refuse what `add` refuses (see claimsProblem).
53
+ const tooMany = claimsProblem(claims, current);
54
+ if (tooMany)
55
+ throw new GlossError(`the gloss on ${sha.slice(0, 10)} is refused: ${tooMany}`);
52
56
  const unannotated = uncovered(current, claims);
53
57
  const bad = [...resolved.values()].filter((h) => h.status !== 'ok').length;
54
58
  const matched = new Set([...resolved.values()].map((h) => h.current));
package/dist/plain.js ADDED
@@ -0,0 +1,9 @@
1
+ // Text from a note is shown in a terminal: no escape sequences or other
2
+ // control characters get through. Newlines and tabs stay when `multiline`.
3
+ export function plain(text, multiline = false) {
4
+ return text
5
+ // OSC (ended by BEL or ESC \) and CSI sequences, then any lone ESC and control characters (newline and tab kept when multiline).
6
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
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 : ' '));
9
+ }
package/dist/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { plain } from './plain.js';
1
2
  import { spawnSync } from 'node:child_process';
2
3
  import { styleText } from 'node:util';
3
4
  import { gitTry } from './git.js';
@@ -54,21 +55,22 @@ export function attentionBlock(a, view) {
54
55
  return [style('dim', 'attention: unrated')];
55
56
  const d = view.derived;
56
57
  return [`attention: ${level(d.level)} ${style('dim', '(derived)')}` + (d.level === view.floor ? '' : ` · floor: ${view.floor}`),
57
- ...d.reasons.flatMap((r) => field('•', r))];
58
+ ...d.reasons.flatMap((r) => field('•', r)), ...timedOutLines(view)];
58
59
  }
59
60
  const head = `attention: ${level(a.level)}${view ? ' ' + style('dim', '(declared)') + ` · floor: ${view.floor}` : ''}` +
60
61
  (a.focus?.length ? ` · focus: ${a.focus.join(', ')}` : '');
61
62
  const below = belowFloor(a, view);
62
63
  return [head, ...(a.reasons ?? []).flatMap((r) => field('•', r)),
63
64
  ...(below ? field('⚠', `declared ${a.level}, below the computed floor ${view.floor}` + (a.override_reason ? '' : ' (no override_reason)'), 2).map((l) => style('yellow', l)) : []),
64
- ...(a.override_reason ? field('override:', a.override_reason) : [])];
65
+ ...(a.override_reason ? field('override:', a.override_reason) : []), ...timedOutLines(view)];
65
66
  }
67
+ const timedOutLines = (view) => (view?.timedOut ?? []).flatMap((r) => field('⚠', r, 2).map((l) => style('yellow', l)));
66
68
  export const termWidth = () => Math.min(process.stdout.columns || 100, 120);
67
69
  // Word-wrap plain text to `width` columns.
68
70
  export function wrap(text, width) {
69
71
  const lines = [];
70
72
  let line = '';
71
- for (const word of text.split(/\s+/).filter(Boolean)) {
73
+ for (const word of plain(text).split(/\s+/).filter(Boolean)) {
72
74
  if (line && line.length + 1 + word.length > width) {
73
75
  lines.push(line);
74
76
  line = word;
@@ -90,7 +92,7 @@ export function hunkLabel(h, lines = null) {
90
92
  const range = h.newLines || h.oldLines
91
93
  ? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}`
92
94
  : 'file header';
93
- const file = h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file;
95
+ const file = plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file);
94
96
  const s = sliceStats(h, lines);
95
97
  return `${style('bold', refLabel(h.id, lines))} ${file} ${style('dim', range)} ${style('green', `+${s.added}`)} ${style('red', `-${s.removed}`)}`;
96
98
  }