@ok-ggwp/git-gloss 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/findings.js CHANGED
@@ -28,7 +28,20 @@
28
28
  // Items are indent-0 `-`, `*`, `+`, `1.` or `1)` lines; indented sub-items and
29
29
  // wrapped lines are left alone, as are fenced code blocks, rules and empty
30
30
  // items. A numbered heading with no list under it keeps its own number as its
31
- // ref (`1`) and gets no label. Running it on its own output changes nothing.
31
+ // ref (`1`, or `1/2` when `1` is taken) and its line is not rewritten. A section whose
32
+ // first ref is already taken (a second `1.` heading with items) is labelled `S/N`, see
33
+ // below. Running it on its own output changes nothing.
34
+ // A section label is the section number, `1`. A section is labelled `S/N` (`1/2`, items
35
+ // `1/2.1`, N from 2 to 999) only when a ref it would emit equals one already emitted: the
36
+ // heading ref `1` of a list-less heading, or the first item ref `1.1` of a section with
37
+ // items. Within a section a heading ref and an item ref never collide, so an empty
38
+ // numbered heading, an unnumbered heading or a bold line uses no label up, and no ref the
39
+ // old labeller produced changes. The one new ref: a list-less heading whose bare number
40
+ // equals a loose item's (`- x` then `## 1.`) was unreachable before and now gets `1/2`. Past 999 repeats the label stays `S/999`
41
+ // and the items keep counting on, so item refs stay distinct; only the heading row of such
42
+ // a section without items has no row of its own (its ref is already taken).
43
+ const MAX_REPEAT = 999;
44
+ const repeatLabel = (num, n) => (n === 1 ? String(num) : `${num}/${n}`);
32
45
  const isDigit = (c) => c >= '0' && c <= '9';
33
46
  const MAXDIG = 6;
34
47
  // The section number at the start of a heading's text: up to 3 digits, then `.` or
@@ -129,10 +142,13 @@ function itemText(line) {
129
142
  return null;
130
143
  return line.slice(j);
131
144
  }
132
- // Drop a label this function wrote before: `**[1.2]** `.
145
+ // Drop a label this function wrote before: `**[1.2]** ` or `**[1/2.2]** `.
133
146
  function stripLabel(t) {
134
147
  if (!t.startsWith('**['))
135
148
  return t;
149
+ const m = /^\*\*\[\d{1,3}\/\d{1,3}\.\d{1,6}\]\*\* */.exec(t.slice(0, 40));
150
+ if (m)
151
+ return t.slice(m[0].length);
136
152
  let i = 3;
137
153
  while (i < 20 && (isDigit(t[i] ?? '') || t[i] === '.'))
138
154
  i++;
@@ -148,9 +164,51 @@ function scan(text) {
148
164
  const lines = text.split('\n');
149
165
  // Each ref with its text (an item's, or a numbered heading's line); the first wins.
150
166
  const found = new Map();
151
- const add = (ref, t) => { if (!found.has(ref))
152
- found.set(ref, t); };
153
- let section = 0, count = 0;
167
+ // The rows of a decision template: one per ref that was added, marked heading or item.
168
+ const rows = [];
169
+ // Refs a heading row could not take because another row has them (a template row lost, or a
170
+ // heading whose ref a decision would give to the other finding).
171
+ const clashes = new Set();
172
+ // `lost` marks a heading row that would have been listed: only then is a lost ref a clash.
173
+ const add = (ref, t, heading, lost = false) => {
174
+ if (found.has(ref)) {
175
+ if (lost)
176
+ clashes.add(ref);
177
+ return null;
178
+ }
179
+ found.set(ref, t);
180
+ const row = { ref, text: t, heading, hasItems: false };
181
+ rows.push(row);
182
+ return row;
183
+ };
184
+ // How many items each label has.
185
+ const counts = new Map();
186
+ // The first label of `num` whose first ref is not taken: the heading's own ref, or `.1`.
187
+ // Refs only get added, so a label found taken stays taken: each (number, kind) resumes
188
+ // from where it stopped, which keeps this linear in the number of sections.
189
+ const resume = new Map();
190
+ const pick = (num, forHeading) => {
191
+ const key = `${num}:${forHeading}`;
192
+ let k = resume.get(key) ?? 1;
193
+ while (k < MAX_REPEAT && found.has(forHeading ? repeatLabel(num, k) : `${repeatLabel(num, k)}.1`))
194
+ k++;
195
+ resume.set(key, k);
196
+ return repeatLabel(num, k);
197
+ };
198
+ // A numbered heading waits for its first item: with items it only names the section
199
+ // (the items are the findings), without it is a row of its own.
200
+ let pending = null;
201
+ const flush = (withItems = false) => {
202
+ if (!pending)
203
+ return;
204
+ const lab = pick(pending.num, true);
205
+ const row = add(lab, pending.text, true, !withItems);
206
+ if (row && withItems)
207
+ row.hasItems = true;
208
+ pending = null;
209
+ };
210
+ let section = 0;
211
+ let label = null;
154
212
  let sawHeading = false, sawNumbered = false, sawItem = false;
155
213
  let fence = null;
156
214
  for (let n = 0; n < lines.length; n++) {
@@ -167,18 +225,19 @@ function scan(text) {
167
225
  continue;
168
226
  const h = headingText(line) ?? boldOnly(line) ?? (plainTitle(lines, n) ? line : null);
169
227
  if (h !== null) {
228
+ flush();
170
229
  const num = leadingNumber(h);
171
230
  if (num !== null) {
172
231
  section = num;
173
232
  sawNumbered = true;
174
- add(String(num), line.replace(/^[\s#*_]+/, '').replace(/^\d{1,3}[.)]?\s*/, '').replace(/[\s*_:]+$/, ''));
233
+ pending = { num, text: line.replace(/^[\s#*_]+/, '').replace(/^\d{1,3}[.)]?\s*/, '').replace(/[\s*_:]+$/, '') };
175
234
  }
176
235
  else if (!sawItem && !sawNumbered)
177
236
  section = 0;
178
237
  else
179
238
  section += 1;
239
+ label = null;
180
240
  sawHeading = true;
181
- count = 0;
182
241
  continue;
183
242
  }
184
243
  const item = itemText(line);
@@ -186,13 +245,28 @@ function scan(text) {
186
245
  continue;
187
246
  if (section === 0 && sawHeading)
188
247
  section = 1;
248
+ // A heading numbered 0 keeps its own ref: it is not the section its items fall in.
249
+ if (pending && pending.num !== section)
250
+ flush(true);
189
251
  sawItem = true;
190
- count++;
191
- const ref = sawHeading ? `${section}.${count}` : String(count);
192
- add(ref, stripLabel(item));
252
+ if (sawHeading && label === null) {
253
+ label = pick(section, false);
254
+ if (pending) {
255
+ const row = add(label, pending.text, true);
256
+ if (row)
257
+ row.hasItems = true;
258
+ pending = null;
259
+ }
260
+ }
261
+ const key = label ?? '';
262
+ const k = (counts.get(key) ?? 0) + 1;
263
+ counts.set(key, k);
264
+ const ref = sawHeading ? `${label}.${k}` : String(k);
265
+ add(ref, stripLabel(item), false);
193
266
  lines[n] = `- **[${ref}]** ${stripLabel(item)}`;
194
267
  }
195
- return { out: lines.join('\n'), found };
268
+ flush();
269
+ return { out: lines.join('\n'), found, rows, clashes: [...clashes] };
196
270
  }
197
271
  export function labelFindings(text) {
198
272
  return scan(text).out;
@@ -201,41 +275,62 @@ export function labelFindings(text) {
201
275
  export function findingRefs(text) {
202
276
  return new Set(scan(text).found.keys());
203
277
  }
204
- // Each ref a report carries, once, with the text of its finding (raw, for the caller to clean).
278
+ // What a decision template lists: each finding's ref once, with its text (raw, for the
279
+ // caller to clean). A numbered heading is listed only when no items sit under it; its
280
+ // ref stays valid for a decision (findingRefs has it) but is not suggested.
281
+ // Refs that more than one finding of a report claims, so a decision naming one is ambiguous.
282
+ export function findingClashes(text) {
283
+ return scan(text).clashes;
284
+ }
205
285
  export function findingTexts(text) {
206
- return [...scan(text).found];
286
+ return scan(text).rows.filter((r) => !(r.heading && r.hasItems)).map((r) => [r.ref, r.text]);
207
287
  }
208
288
  // A decision's finding names a finding by starting with its ref: `1.2 wording nit`,
209
289
  // `1.2: ...`, or a bare section number only with a delimiter (`3: ...`, `3) ...`,
210
290
  // `3. ...`). Numbers are up to 3 digits, so `2024 migration` and `3 files` are text.
211
291
  export function refOf(finding) {
212
- let i = 0;
213
- while (i < 4 && isDigit(finding[i] ?? ''))
214
- i++;
215
- if (i === 0 || i > 3)
292
+ // The end of the digit run at `from` (1 to `max` digits), or null if there is none or it is longer.
293
+ const digits = (from, max) => {
294
+ let e = from;
295
+ while (e < from + max && isDigit(finding[e] ?? ''))
296
+ e++;
297
+ return e === from || isDigit(finding[e] ?? '') ? null : e;
298
+ };
299
+ let s = digits(0, 3);
300
+ if (s === null)
216
301
  return null;
217
- if (finding[i] === '.') {
218
- let j = i + 1;
219
- while (j < i + 5 && isDigit(finding[j] ?? ''))
302
+ // The Nth occurrence of a section number: `1/2`, N from 2 to 999.
303
+ if (finding[s] === '/') {
304
+ const e = digits(s + 1, 3);
305
+ if (e === null || Number(finding.slice(s + 1, e)) < 2)
306
+ return null;
307
+ s = e;
308
+ }
309
+ if (finding[s] === '.') {
310
+ let j = s + 1;
311
+ while (j < s + 5 && isDigit(finding[j] ?? ''))
220
312
  j++;
221
- if (j > i + 1 && j <= i + 4) {
313
+ if (j > s + 1 && j <= s + 4) {
222
314
  const c = finding[j];
223
315
  if (c === undefined || c === ' ' || c === '\t' || c === ':')
224
316
  return finding.slice(0, j);
225
317
  }
226
318
  }
227
- const c = finding[i];
319
+ const c = finding[s];
228
320
  if (c === ':' || c === ')')
229
- return finding.slice(0, i);
230
- if (c === '.' && (finding[i + 1] === undefined || finding[i + 1] === ' '))
231
- return finding.slice(0, i);
321
+ return finding.slice(0, s);
322
+ if (c === '.' && (finding[s + 1] === undefined || finding[s + 1] === ' '))
323
+ return finding.slice(0, s);
232
324
  return null;
233
325
  }
234
- function compareRefs(a, b) {
235
- const [a1, a2 = '-1'] = a.split('.');
236
- const [b1, b2 = '-1'] = b.split('.');
237
- return Number(a1) - Number(b1) || Number(a2) - Number(b2);
238
- }
326
+ // `1` < `1.1` < `1.10` < `1/2` < `1/2.1` < `2`: section number, then repeat, then item.
327
+ // Only refs from refOf reach this.
328
+ const refKey = (r) => {
329
+ const [sec, item = '-1'] = r.split('.');
330
+ const [num, rep = '1'] = sec.split('/');
331
+ return { num: Number(num), rep: Number(rep), item: Number(item) };
332
+ };
333
+ const compareKeys = (x, y) => x.num - y.num || x.rep - y.rep || x.item - y.item;
239
334
  // Finding order: decisions whose finding starts with a ref, by ref (1.2 before
240
335
  // 1.10, ties by time); the rest after them in time order.
241
336
  export function orderDecisions(input) {
@@ -247,8 +342,8 @@ export function orderDecisions(input) {
247
342
  if (r === null)
248
343
  rest.push(d);
249
344
  else
250
- withRef.push([r, d]);
345
+ withRef.push([refKey(r), d]);
251
346
  }
252
- withRef.sort((a, b) => compareRefs(a[0], b[0]));
347
+ withRef.sort((a, b) => compareKeys(a[0], b[0]));
253
348
  return [...withRef.map((x) => x[1]), ...rest];
254
349
  }
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
@@ -492,8 +492,10 @@
492
492
  if (!multi && state.kind === 'group' && dir < 0 && !neighbor(-1)) { selectKind(0, 'overview'); return }
493
493
  if (multi && state.kind !== 'group') {
494
494
  var order = commitOrder()
495
- var next = order[order.indexOf(state.ci) + dir]
496
- 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
+ }
497
499
  return
498
500
  }
499
501
  var id = neighbor(dir)
@@ -653,6 +655,7 @@
653
655
  function unannotatedBox(c) {
654
656
  var box = add(el('div', 'unannotated'), el('p', null, c.glossed
655
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.'
656
659
  : 'This commit has no gloss, so nothing explains these changes.'))
657
660
  c.unannotated.forEach(function (h) { box.appendChild(hunkView(c, h, 'un')) })
658
661
  return box
@@ -714,9 +717,10 @@
714
717
  if (multi) add(pane, el('div', 'eyebrow', 'Commit ' + (ci + 1) + ' of ' + commits.length + ' · ' + new Date(c.date).toLocaleDateString()))
715
718
  add(pane, el('h1', 'title', c.subject), commitMeta(c))
716
719
  if (!c.glossed) {
717
- 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,
718
722
  el('p', null, 'Its whole diff is unannotated, so there is no attention level, no groups and no reasons to read.'),
719
- el('p', 'cmd', 'git gloss add ' + sha7(c)))
723
+ el('p', 'cmd', 'git gloss add ' + sha7(c) + (c.glossRefused ? ' --force' : '')))
720
724
  add(pane, w)
721
725
  add(pane, changesHead('Changes (' + c.unannotated.length + ')'), unannotatedBox(c))
722
726
  return pane
@@ -953,7 +957,7 @@
953
957
  })
954
958
  var box = add(el('div', 'sum'), el('div', 'eyebrow', 'Range at a glance'), strip, line)
955
959
  var top = commits[order[0]]
956
- if (levelOf(top).level) {
960
+ if (top && levelOf(top).level) {
957
961
  var link = el('a', null, sha7(top) + ' ' + top.subject)
958
962
  link.href = '#c-' + sha7(top)
959
963
  link.addEventListener('click', function (ev) { ev.preventDefault(); selectKind(order[0], 'overview') })
@@ -962,13 +966,51 @@
962
966
  return box
963
967
  }
964
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
+
965
1000
  function renderRail() {
966
1001
  railEl.textContent = ''
967
1002
  if (multi) {
968
1003
  add(railEl, railSummary(), add(el('div', 'railhead'), el('div', 'eyebrow', state.hot ? 'Hottest first, then newest' : 'Newest first')))
969
1004
  var list = el('div', 'commits')
970
- commitOrder().forEach(function (ci) { list.appendChild(commitRow(ci)) })
971
- 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()
972
1014
  if (data.merges.length) {
973
1015
  var ml = add(el('div', 'mergelist'), el('div', 'eyebrow', 'Merges, not reviewed'))
974
1016
  data.merges.forEach(function (m) { ml.appendChild(add(el('div'), el('span', 'mono', m.sha.slice(0, 7)), text(' ' + m.subject))) })