@zgeoff/chex 0.0.1 → 0.0.2

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.
Files changed (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,520 @@
1
+ import type { ShellEscapeParser } from './shell-escape-parsers.ts';
2
+ import { shellEscapeParsers } from './shell-escape-parsers.ts';
3
+
4
+ export interface ShellWord {
5
+ readonly text: string;
6
+ readonly isQuoted: boolean;
7
+ readonly isOperator: boolean;
8
+
9
+ // An unquoted NAME= prefix makes the word a variable assignment, whatever quotes its value holds.
10
+ readonly isAssignment: boolean;
11
+ }
12
+
13
+ type ShellContext =
14
+ | 'plain'
15
+ | 'substitution'
16
+ | 'group'
17
+ | 'backtick'
18
+ | 'quoted-backtick'
19
+ | 'single-quote'
20
+ | 'ansi-c-quote'
21
+ | 'double-quote'
22
+ | 'parameter'
23
+ | 'quoted-parameter'
24
+ | 'arithmetic'
25
+ | 'arithmetic-group';
26
+
27
+ interface ParserState {
28
+ readonly command: string;
29
+ readonly words: ShellWord[];
30
+ readonly contexts: ShellContext[];
31
+ index: number;
32
+ text: string;
33
+ isQuoted: boolean;
34
+ redirect: string;
35
+
36
+ // The word's text before its first quote, escape or expansion.
37
+ plainText: string;
38
+ isPlain: boolean;
39
+ }
40
+
41
+ interface ShellCursor {
42
+ readonly character: string;
43
+ readonly ahead: string;
44
+ readonly isWordStart: boolean;
45
+
46
+ // The unescaped `>` or `<` just before the character, or empty.
47
+ readonly redirect: string;
48
+ }
49
+
50
+ interface ShellActionFields {
51
+ readonly text: { readonly text: string };
52
+ readonly redirect: { readonly text: string };
53
+ readonly escape: object;
54
+ readonly open: { readonly context: ShellContext; readonly text: string; readonly length: number };
55
+ readonly close: { readonly text: string };
56
+ readonly break: object;
57
+ readonly operator: { readonly text: string };
58
+ readonly comment: object;
59
+ }
60
+
61
+ type ShellActionOf<Kind extends keyof ShellActionFields> = {
62
+ readonly kind: Kind;
63
+ } & ShellActionFields[Kind];
64
+
65
+ type ShellAction = {
66
+ [Kind in keyof ShellActionFields]: ShellActionOf<Kind>;
67
+ }[keyof ShellActionFields];
68
+
69
+ interface ShellContextRules {
70
+ readonly pickAction: (cursor: ShellCursor) => ShellAction;
71
+ readonly parseEscape: ShellEscapeParser;
72
+
73
+ // A quote or an escape here marks the word quoted, since the shell strips it from the command's
74
+ // own words.
75
+ readonly isTopLevel: boolean;
76
+ readonly isQuote: boolean;
77
+ }
78
+
79
+ // Every decision that depends on the context lives in this table, which is typed over every
80
+ // context, so a context without its rules fails the type check.
81
+ type ShellContextTable = Readonly<Record<ShellContext, ShellContextRules>>;
82
+
83
+ const contextTable: ShellContextTable = {
84
+ plain: {
85
+ pickAction: makeCodeReader({ closer: '', isGrouping: false, isCommenting: true }),
86
+ parseEscape: shellEscapeParsers.code,
87
+ isTopLevel: true,
88
+ isQuote: false,
89
+ },
90
+ substitution: {
91
+ pickAction: makeCodeReader({ closer: ')', isGrouping: true, isCommenting: false }),
92
+ parseEscape: shellEscapeParsers.code,
93
+ isTopLevel: false,
94
+ isQuote: false,
95
+ },
96
+ group: {
97
+ pickAction: makeCodeReader({ closer: ')', isGrouping: true, isCommenting: false }),
98
+ parseEscape: shellEscapeParsers.code,
99
+ isTopLevel: false,
100
+ isQuote: false,
101
+ },
102
+ backtick: {
103
+ pickAction: makeCodeReader({ closer: '`', isGrouping: false, isCommenting: false }),
104
+ parseEscape: shellEscapeParsers.backtick,
105
+ isTopLevel: false,
106
+ isQuote: false,
107
+ },
108
+ 'quoted-backtick': {
109
+ pickAction: makeCodeReader({ closer: '`', isGrouping: false, isCommenting: false }),
110
+ parseEscape: shellEscapeParsers['quoted-backtick'],
111
+ isTopLevel: false,
112
+ isQuote: false,
113
+ },
114
+ 'single-quote': {
115
+ pickAction: pickSingleQuotedAction,
116
+ parseEscape: shellEscapeParsers.literal,
117
+ isTopLevel: false,
118
+ isQuote: true,
119
+ },
120
+ 'ansi-c-quote': {
121
+ pickAction: pickANSICQuotedAction,
122
+ parseEscape: shellEscapeParsers['ansi-c'],
123
+ isTopLevel: false,
124
+ isQuote: true,
125
+ },
126
+ 'double-quote': {
127
+ pickAction: pickDoubleQuotedAction,
128
+ parseEscape: shellEscapeParsers['double-quote'],
129
+ isTopLevel: false,
130
+ isQuote: true,
131
+ },
132
+ parameter: {
133
+ pickAction: pickParameterAction,
134
+ parseEscape: shellEscapeParsers.code,
135
+ isTopLevel: false,
136
+ isQuote: false,
137
+ },
138
+ 'quoted-parameter': {
139
+ pickAction: pickQuotedParameterAction,
140
+ parseEscape: shellEscapeParsers['double-quote'],
141
+ isTopLevel: false,
142
+ isQuote: false,
143
+ },
144
+ arithmetic: {
145
+ pickAction: makeArithmeticReader('))'),
146
+ parseEscape: shellEscapeParsers['double-quote'],
147
+ isTopLevel: false,
148
+ isQuote: false,
149
+ },
150
+ 'arithmetic-group': {
151
+ pickAction: makeArithmeticReader(')'),
152
+ parseEscape: shellEscapeParsers['double-quote'],
153
+ isTopLevel: false,
154
+ isQuote: false,
155
+ },
156
+ };
157
+
158
+ // The parser keeps a stack of the quotes, substitutions and expansions it is inside, and each
159
+ // context reads the next character by the shell's rules for that context.
160
+ export function parseShellWords(command: string): ShellWord[] {
161
+ const state: ParserState = {
162
+ command,
163
+ words: [],
164
+ contexts: ['plain'],
165
+ index: 0,
166
+ text: '',
167
+ isQuoted: false,
168
+ redirect: '',
169
+ plainText: '',
170
+ isPlain: true,
171
+ };
172
+
173
+ while (state.index < command.length) {
174
+ const rules = contextTable[getContext(state)];
175
+
176
+ const action = rules.pickAction({
177
+ character: command.charAt(state.index),
178
+ ahead: command.slice(state.index + 1, state.index + 3),
179
+ isWordStart: state.text === '' && !state.isQuoted,
180
+ redirect: state.redirect,
181
+ });
182
+
183
+ advanceParser(state, action, rules.isTopLevel);
184
+ }
185
+
186
+ endWord(state);
187
+
188
+ return state.words;
189
+ }
190
+
191
+ interface ShellCodeRules {
192
+ // The text that closes the context, or empty when nothing closes it.
193
+ readonly closer: string;
194
+ readonly isGrouping: boolean;
195
+ readonly isCommenting: boolean;
196
+ }
197
+
198
+ function makeCodeReader(rules: ShellCodeRules): (cursor: ShellCursor) => ShellAction {
199
+ return (cursor) => {
200
+ const character = cursor.character;
201
+
202
+ // A closing backtick would read as an opening one, so the closer comes first.
203
+ if (character === rules.closer) {
204
+ return { kind: 'close', text: character };
205
+ }
206
+
207
+ const opening = pickOpening(cursor, false);
208
+
209
+ if (opening !== undefined) {
210
+ return opening;
211
+ }
212
+
213
+ if (character === "'") {
214
+ return { kind: 'open', context: 'single-quote', text: '', length: 1 };
215
+ }
216
+
217
+ if (character === '"') {
218
+ return { kind: 'open', context: 'double-quote', text: '', length: 1 };
219
+ }
220
+
221
+ if (character === '(' && rules.isGrouping) {
222
+ return { kind: 'open', context: 'group', text: '(', length: 1 };
223
+ }
224
+
225
+ if (character === '#' && cursor.isWordStart && rules.isCommenting) {
226
+ return { kind: 'comment' };
227
+ }
228
+
229
+ if (character === '\n') {
230
+ return { kind: 'operator', text: '\n' };
231
+ }
232
+
233
+ if (/\s/u.test(character)) {
234
+ return { kind: 'break' };
235
+ }
236
+
237
+ return pickOperator(cursor) ?? pickPlainAction(character);
238
+ };
239
+ }
240
+
241
+ // `${` opens a parameter expansion, `$((` an arithmetic expansion, and `$(` or a backtick a
242
+ // command substitution, in every context but a single quote. Outside double quotes, `$'` opens an
243
+ // ANSI-C quote, and `$"` opens a locale quote, which reads as a double quote.
244
+ function pickOpening(cursor: ShellCursor, isInsideQuotes: boolean): ShellAction | undefined {
245
+ const character = cursor.character;
246
+
247
+ if (character === '\\') {
248
+ return { kind: 'escape' };
249
+ }
250
+
251
+ if (character === '`') {
252
+ return isInsideQuotes
253
+ ? { kind: 'open', context: 'quoted-backtick', text: '`', length: 1 }
254
+ : { kind: 'open', context: 'backtick', text: '`', length: 1 };
255
+ }
256
+
257
+ if (character !== '$') {
258
+ return undefined;
259
+ }
260
+
261
+ if (cursor.ahead.startsWith("'") && !isInsideQuotes) {
262
+ return { kind: 'open', context: 'ansi-c-quote', text: '', length: 2 };
263
+ }
264
+
265
+ if (cursor.ahead.startsWith('"') && !isInsideQuotes) {
266
+ return { kind: 'open', context: 'double-quote', text: '', length: 2 };
267
+ }
268
+
269
+ if (cursor.ahead.startsWith('{')) {
270
+ return isInsideQuotes
271
+ ? { kind: 'open', context: 'quoted-parameter', text: '${', length: 2 }
272
+ : { kind: 'open', context: 'parameter', text: '${', length: 2 };
273
+ }
274
+
275
+ if (cursor.ahead === '((') {
276
+ return { kind: 'open', context: 'arithmetic', text: '$((', length: 3 };
277
+ }
278
+
279
+ if (cursor.ahead.startsWith('(')) {
280
+ return { kind: 'open', context: 'substitution', text: '$(', length: 2 };
281
+ }
282
+
283
+ return undefined;
284
+ }
285
+
286
+ // A redirection keeps its `|` and `&` in the word: `>|`, `>&`, `<&`, `&>` and `&>>`. `>>` and `<>`
287
+ // hold no operator character.
288
+ function pickOperator(cursor: ShellCursor): ShellAction | undefined {
289
+ const character = cursor.character;
290
+ const ahead = cursor.ahead;
291
+
292
+ if (character === ';') {
293
+ return { kind: 'operator', text: ';' };
294
+ }
295
+
296
+ if (character === '|' && cursor.redirect !== '>') {
297
+ return { kind: 'operator', text: ahead.startsWith('|') ? '||' : '|' };
298
+ }
299
+
300
+ if (character === '&' && ahead.startsWith('&')) {
301
+ return { kind: 'operator', text: '&&' };
302
+ }
303
+
304
+ if (character === '&' && cursor.redirect === '' && !ahead.startsWith('>')) {
305
+ return { kind: 'operator', text: '&' };
306
+ }
307
+
308
+ return undefined;
309
+ }
310
+
311
+ function pickPlainAction(character: string): ShellAction {
312
+ return character === '>' || character === '<'
313
+ ? { kind: 'redirect', text: character }
314
+ : { kind: 'text', text: character };
315
+ }
316
+
317
+ function pickSingleQuotedAction(cursor: ShellCursor): ShellAction {
318
+ if (cursor.character === '\\') {
319
+ return { kind: 'escape' };
320
+ }
321
+
322
+ return cursor.character === "'"
323
+ ? { kind: 'close', text: '' }
324
+ : { kind: 'text', text: cursor.character };
325
+ }
326
+
327
+ function pickANSICQuotedAction(cursor: ShellCursor): ShellAction {
328
+ if (cursor.character === '\\') {
329
+ return { kind: 'escape' };
330
+ }
331
+
332
+ return cursor.character === "'"
333
+ ? { kind: 'close', text: '' }
334
+ : { kind: 'text', text: cursor.character };
335
+ }
336
+
337
+ function pickDoubleQuotedAction(cursor: ShellCursor): ShellAction {
338
+ const opening = pickOpening(cursor, true);
339
+
340
+ if (opening !== undefined) {
341
+ return opening;
342
+ }
343
+
344
+ return cursor.character === '"'
345
+ ? { kind: 'close', text: '' }
346
+ : { kind: 'text', text: cursor.character };
347
+ }
348
+
349
+ // A parameter expansion reads its default or pattern as text, so `${VALUE:-a;b}` holds no operator,
350
+ // while quotes and expansions inside it keep their meaning.
351
+ function pickParameterAction(cursor: ShellCursor): ShellAction {
352
+ const opening = pickOpening(cursor, false);
353
+
354
+ if (opening !== undefined) {
355
+ return opening;
356
+ }
357
+
358
+ if (cursor.character === "'") {
359
+ return { kind: 'open', context: 'single-quote', text: '', length: 1 };
360
+ }
361
+
362
+ if (cursor.character === '"') {
363
+ return { kind: 'open', context: 'double-quote', text: '', length: 1 };
364
+ }
365
+
366
+ return cursor.character === '}'
367
+ ? { kind: 'close', text: '}' }
368
+ : { kind: 'text', text: cursor.character };
369
+ }
370
+
371
+ // Inside double quotes, a single quote in a parameter expansion is literal text.
372
+ function pickQuotedParameterAction(cursor: ShellCursor): ShellAction {
373
+ const opening = pickOpening(cursor, true);
374
+
375
+ if (opening !== undefined) {
376
+ return opening;
377
+ }
378
+
379
+ if (cursor.character === '"') {
380
+ return { kind: 'open', context: 'double-quote', text: '', length: 1 };
381
+ }
382
+
383
+ return cursor.character === '}'
384
+ ? { kind: 'close', text: '}' }
385
+ : { kind: 'text', text: cursor.character };
386
+ }
387
+
388
+ function makeArithmeticReader(closer: ')' | '))'): (cursor: ShellCursor) => ShellAction {
389
+ return (cursor) => {
390
+ const character = cursor.character;
391
+ const opening = pickOpening(cursor, true);
392
+
393
+ if (opening !== undefined) {
394
+ return opening;
395
+ }
396
+
397
+ if (character === '(') {
398
+ return { kind: 'open', context: 'arithmetic-group', text: '(', length: 1 };
399
+ }
400
+
401
+ if (`${character}${cursor.ahead}`.startsWith(closer)) {
402
+ return { kind: 'close', text: closer };
403
+ }
404
+
405
+ return { kind: 'text', text: character };
406
+ };
407
+ }
408
+
409
+ function getContext(state: ParserState): ShellContext {
410
+ const context = state.contexts.at(-1);
411
+
412
+ // Only a reader inside a nested context closes it, so the plain context never leaves the stack.
413
+ if (context === undefined) {
414
+ throw new Error('the shell parser closed its plain context');
415
+ }
416
+
417
+ return context;
418
+ }
419
+
420
+ // Each handler returns how many characters its action consumed. The table is typed over every
421
+ // action kind, so an action without a handler fails the type check.
422
+ type ShellActionHandlers = {
423
+ readonly [Kind in keyof ShellActionFields]: (
424
+ state: ParserState,
425
+ action: ShellActionOf<Kind>,
426
+ isTopLevel: boolean,
427
+ ) => number;
428
+ };
429
+
430
+ const actionHandlers: ShellActionHandlers = {
431
+ text: appendText,
432
+ redirect: (state, action) => {
433
+ state.redirect = action.text;
434
+
435
+ return appendText(state, action);
436
+ },
437
+ escape: (state, _action, isTopLevel) => {
438
+ const escape = contextTable[getContext(state)].parseEscape(state.command, state.index);
439
+
440
+ state.text += escape.text;
441
+
442
+ if (escape.isQuoting) {
443
+ state.isPlain = false;
444
+ state.isQuoted ||= isTopLevel;
445
+ }
446
+
447
+ return escape.length;
448
+ },
449
+ open: (state, action, isTopLevel) => {
450
+ state.isPlain = false;
451
+
452
+ state.contexts.push(action.context);
453
+
454
+ state.text += action.text;
455
+ state.isQuoted ||= isTopLevel && contextTable[action.context].isQuote;
456
+
457
+ return action.length;
458
+ },
459
+ close: (state, action) => {
460
+ state.contexts.pop();
461
+
462
+ state.text += action.text;
463
+
464
+ return Math.max(action.text.length, 1);
465
+ },
466
+ break: (state) => {
467
+ endWord(state);
468
+
469
+ return 1;
470
+ },
471
+ operator: (state, action) => {
472
+ endWord(state);
473
+
474
+ state.words.push({ text: action.text, isQuoted: false, isOperator: true, isAssignment: false });
475
+
476
+ return action.text.length;
477
+ },
478
+ comment: (state) => {
479
+ const end = state.command.indexOf('\n', state.index);
480
+
481
+ return (end === -1 ? state.command.length : end) - state.index;
482
+ },
483
+ };
484
+
485
+ // The handler table is generic over the action kind, so a call with the whole union still checks.
486
+ function advanceParser<Kind extends keyof ShellActionFields>(
487
+ state: ParserState,
488
+ action: ShellActionOf<Kind>,
489
+ isTopLevel: boolean,
490
+ ): void {
491
+ const handler: ShellActionHandlers[Kind] = actionHandlers[action.kind];
492
+
493
+ state.redirect = '';
494
+ state.index += handler(state, action, isTopLevel);
495
+ }
496
+
497
+ function appendText(state: ParserState, action: ShellActionFields['text']): number {
498
+ state.text += action.text;
499
+ state.plainText += state.isPlain ? action.text : '';
500
+
501
+ return 1;
502
+ }
503
+
504
+ const assignmentPattern = /^[A-Za-z_]\w*=/u;
505
+
506
+ function endWord(state: ParserState): void {
507
+ if (state.text !== '' || state.isQuoted) {
508
+ state.words.push({
509
+ text: state.text,
510
+ isQuoted: state.isQuoted,
511
+ isOperator: false,
512
+ isAssignment: assignmentPattern.test(state.plainText),
513
+ });
514
+ }
515
+
516
+ state.text = '';
517
+ state.isQuoted = false;
518
+ state.plainText = '';
519
+ state.isPlain = true;
520
+ }
@@ -0,0 +1,152 @@
1
+ import { formatCommitLines } from './format-commit-lines.ts';
2
+
3
+ export interface MergeBlobsReadings {
4
+ readonly commits: readonly string[];
5
+ readonly promisor?: string;
6
+ readonly merges?: string;
7
+ readonly bases: readonly string[];
8
+
9
+ readonly listing?: string;
10
+ readonly diff?: string;
11
+ readonly fetch?: string;
12
+ }
13
+
14
+ export interface MergeBlobsCommand {
15
+ readonly reading: 'promisor' | 'merges' | 'bases' | 'listing' | 'diff' | 'fetch';
16
+ readonly argv: readonly string[];
17
+ readonly stdin?: string;
18
+
19
+ // `git config --get` exits 1 for an unset key, and `git merge-base` exits 1 for parents that
20
+ // share no commit.
21
+ readonly exitCodes: 'any' | readonly number[];
22
+ }
23
+
24
+ // A blobless clone fetches a merge's missing blobs during the log, in one batch with the
25
+ // conflict-marked blob that the log builds in a temporary folder, and the server refuses the whole
26
+ // batch. Fetching the blobs first leaves the log nothing to ask for.
27
+ export function pickMergeBlobsCommand(readings: MergeBlobsReadings): MergeBlobsCommand | null {
28
+ if (readings.promisor === undefined) {
29
+ return {
30
+ reading: 'promisor',
31
+ argv: ['git', 'config', '--type=bool', '--get', 'remote.origin.promisor'],
32
+ exitCodes: 'any',
33
+ };
34
+ }
35
+
36
+ if (readings.promisor.trim() !== 'true') {
37
+ return null;
38
+ }
39
+
40
+ if (readings.merges === undefined) {
41
+ return {
42
+ reading: 'merges',
43
+ argv: ['git', 'rev-list', '--merges', '--parents', '--no-walk=unsorted', '--stdin'],
44
+ stdin: formatCommitLines(readings.commits),
45
+ exitCodes: [0],
46
+ };
47
+ }
48
+
49
+ const merges = splitLines(readings.merges).map((line) => line.split(' '));
50
+ const pending = merges[readings.bases.length];
51
+
52
+ if (pending !== undefined) {
53
+ return {
54
+ reading: 'bases',
55
+ argv: ['git', 'merge-base', '--all', ...pending.slice(1)],
56
+ exitCodes: [0, 1],
57
+ };
58
+ }
59
+
60
+ const pairs = merges.flatMap((mergeAndParents, index) =>
61
+ splitLines(readings.bases[index] ?? '').flatMap((base) =>
62
+ mergeAndParents.map((commit) => `${base} ${commit}\n`),
63
+ ),
64
+ );
65
+
66
+ if (pairs.length === 0) {
67
+ return null;
68
+ }
69
+
70
+ if (readings.listing === undefined) {
71
+ const pairCommits = new Set(pairs.flatMap((pair) => pair.trim().split(' ')));
72
+
73
+ // A blob ID on the command line makes git 2.43's rev-list die when the blob is missing, so the
74
+ // listing starts from the commits instead.
75
+ return {
76
+ reading: 'listing',
77
+ argv: ['git', 'rev-list', '--objects', '--missing=print', '--no-walk', '--stdin'],
78
+ stdin: formatCommitLines([...pairCommits]),
79
+ exitCodes: [0],
80
+ };
81
+ }
82
+
83
+ if (readings.diff === undefined) {
84
+ return {
85
+ reading: 'diff',
86
+ argv: ['git', 'diff-tree', '-r', '--no-abbrev', '--stdin'],
87
+ stdin: pairs.join(''),
88
+ exitCodes: [0],
89
+ };
90
+ }
91
+
92
+ const missing = new Set(
93
+ splitLines(readings.listing)
94
+ .filter((line) => line.startsWith('?'))
95
+ .map((line) => line.slice(1)),
96
+ );
97
+
98
+ // The server refuses the whole fetch when it asks for a blob that only this clone holds.
99
+ const blobs = collectBlobIDs(readings.diff).filter((blob) => missing.has(blob));
100
+
101
+ if (blobs.length === 0 || readings.fetch !== undefined) {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ reading: 'fetch',
107
+ argv: [
108
+ 'git',
109
+ '-c',
110
+ 'fetch.negotiationAlgorithm=noop',
111
+ 'fetch',
112
+ '--quiet',
113
+ '--no-tags',
114
+ '--no-write-fetch-head',
115
+ '--filter=blob:none',
116
+ '--stdin',
117
+ 'origin',
118
+ ],
119
+ stdin: blobs.map((blob) => `${blob}\n`).join(''),
120
+ exitCodes: [0],
121
+ };
122
+ }
123
+
124
+ function splitLines(text: string): string[] {
125
+ return text.split('\n').filter((line) => line !== '');
126
+ }
127
+
128
+ const blobModes = new Set(['100644', '100755', '120000']);
129
+
130
+ // Each raw line reads `:<old mode> <new mode> <old id> <new id> <status>\t<path>`.
131
+ function collectBlobIDs(rawDiff: string): string[] {
132
+ const blobs = new Set<string>();
133
+
134
+ for (const line of rawDiff.split('\n')) {
135
+ if (!line.startsWith(':')) {
136
+ continue;
137
+ }
138
+
139
+ const [oldMode, newMode, oldBlob, newBlob] = line.slice(1).split(' ');
140
+
141
+ for (const [mode, blob] of [
142
+ [oldMode, oldBlob],
143
+ [newMode, newBlob],
144
+ ]) {
145
+ if (mode !== undefined && blob !== undefined && blobModes.has(mode)) {
146
+ blobs.add(blob);
147
+ }
148
+ }
149
+ }
150
+
151
+ return [...blobs];
152
+ }
@@ -0,0 +1,31 @@
1
+ import type { RunProgram } from './run-program.ts';
2
+
3
+ export interface ReadBranchBaseOptions {
4
+ readonly cwd: string;
5
+ readonly env: Readonly<Record<string, string | undefined>>;
6
+ readonly runProgram: RunProgram;
7
+ }
8
+
9
+ export function readBranchBase(options: Readonly<ReadBranchBaseOptions>): string {
10
+ const defaultBranch = readDefaultBranch(options);
11
+ const base = runGit(options, ['merge-base', 'HEAD', defaultBranch]);
12
+
13
+ if (base.exitCode !== 0) {
14
+ throw new Error(
15
+ `cannot find the base of this branch: no merge base between HEAD and ${defaultBranch} (is the remote fetched?)`,
16
+ );
17
+ }
18
+
19
+ return base.stdout.trim();
20
+ }
21
+
22
+ export function readDefaultBranch(options: Readonly<ReadBranchBaseOptions>): string {
23
+ const head = runGit(options, ['rev-parse', '--abbrev-ref', 'origin/HEAD']);
24
+
25
+ // A clone made before origin/HEAD existed, and a CI checkout, have no symbolic ref.
26
+ return head.exitCode === 0 ? head.stdout.trim() : 'origin/main';
27
+ }
28
+
29
+ function runGit(options: Readonly<ReadBranchBaseOptions>, args: readonly string[]) {
30
+ return options.runProgram(['git', ...args], { cwd: options.cwd, env: options.env });
31
+ }