@ok-ggwp/git-gloss 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/dist/ary/desk.js +112 -0
- package/dist/ary/jobs.js +132 -0
- package/dist/ary/page-job.js +23 -0
- package/dist/ary/picker.js +8 -0
- package/dist/ary/server.js +41 -107
- package/dist/batch.js +56 -1
- package/dist/commands/add.js +28 -2
- package/dist/commands/ary.js +3 -0
- package/dist/commands/carry.js +3 -1
- package/dist/commands/lint.js +3 -0
- package/dist/commands/review.js +10 -5
- package/dist/commands/reviews.js +13 -23
- package/dist/commands/show.js +11 -5
- package/dist/commands/sync.js +35 -12
- package/dist/commands/versions.js +52 -8
- package/dist/commands/why.js +8 -3
- package/dist/config.js +22 -11
- package/dist/findings.js +127 -32
- package/dist/git.js +151 -27
- package/dist/html/client.js +49 -7
- package/dist/html/data.js +20 -10
- package/dist/html/style.css +4 -0
- package/dist/loader.js +22 -3
- package/dist/reviews.js +12 -4
- package/dist/schema.js +2 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +5 -5
package/dist/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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 >
|
|
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[
|
|
319
|
+
const c = finding[s];
|
|
228
320
|
if (c === ':' || c === ')')
|
|
229
|
-
return finding.slice(0,
|
|
230
|
-
if (c === '.' && (finding[
|
|
231
|
-
return finding.slice(0,
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
146
|
+
const untracked = run(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
|
|
77
147
|
const excluded = untracked.filter(looksSensitive);
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
return { tree:
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/html/client.js
CHANGED
|
@@ -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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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))) })
|