balladeer 1.0.16 → 1.0.17

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.
@@ -1,7 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
- import { basename, dirname, isAbsolute, join } from "node:path";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { runCommand } from "./gh.js";
6
+ import { resolveCommit } from "./scratch-worktree.js";
7
+ import { configHome } from "./store.js";
8
+ export const JUDGE_HOOK_EVENTS = ["PreToolUse", "PostToolUse"];
9
+ /** A session id as the hook keeps it: a bounded name, never a path or a sentence. */
10
+ const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
5
11
  /** A coding host's hook payload, or nothing when the text is not one. */
6
12
  export function parseHookInput(text) {
7
13
  let value;
@@ -22,6 +28,9 @@ export function parseHookInput(text) {
22
28
  ...(command === undefined ? {} : { command }),
23
29
  ...(typeof input.cwd === "string" ? { cwd: input.cwd } : {}),
24
30
  ...(typeof input.model === "string" ? { model: input.model } : {}),
31
+ ...(typeof input.session_id === "string" && SESSION_ID.test(input.session_id)
32
+ ? { sessionId: input.session_id }
33
+ : {}),
25
34
  };
26
35
  }
27
36
  const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
@@ -49,16 +58,130 @@ export function parsePrePushInput(text) {
49
58
  }
50
59
  return refs;
51
60
  }
61
+ /** What ends a shell word outside quotes. */
62
+ const WORD_END = /[\s;&|()<>`]/;
52
63
  /**
53
- * The simple commands a shell line runs, as word lists.
64
+ * One word read the way a shell reads it, starting at `from`: quotes and
65
+ * backslashes honored and removed. `quoted` says whether any part was quoted
66
+ * or escaped, which is what makes a here-document's text literal.
67
+ */
68
+ function readWord(line, from) {
69
+ let text = "";
70
+ let quoted = false;
71
+ let at = from;
72
+ while (at < line.length && !WORD_END.test(line[at])) {
73
+ const char = line[at];
74
+ if (char === "'") {
75
+ quoted = true;
76
+ const close = line.indexOf("'", at + 1);
77
+ const end = close < 0 ? line.length : close;
78
+ text += line.slice(at + 1, end);
79
+ at = end + 1;
80
+ }
81
+ else if (char === '"') {
82
+ quoted = true;
83
+ at += 1;
84
+ while (at < line.length && line[at] !== '"') {
85
+ if (line[at] === "\\" && at + 1 < line.length)
86
+ at += 1;
87
+ text += line[at];
88
+ at += 1;
89
+ }
90
+ at += 1;
91
+ }
92
+ else if (char === "\\") {
93
+ quoted = true;
94
+ if (at + 1 < line.length && line[at + 1] !== "\n")
95
+ text += line[at + 1];
96
+ at += 2;
97
+ }
98
+ else {
99
+ text += char;
100
+ at += 1;
101
+ }
102
+ }
103
+ return { text, end: Math.min(at, line.length), quoted };
104
+ }
105
+ function skipBlanks(line, from) {
106
+ let at = from;
107
+ while (at < line.length && (line[at] === " " || line[at] === "\t"))
108
+ at += 1;
109
+ return at;
110
+ }
111
+ /**
112
+ * Whether a command reads a script from its input: a shell given no `-c`
113
+ * script and no script file, as in `bash <<EOF` or `... | sh`.
114
+ */
115
+ function readsScriptFromInput(words) {
116
+ const run = program(words);
117
+ if (run.length === 0 || !SHELLS.has(basename(run[0])))
118
+ return false;
119
+ const args = run.slice(1);
120
+ if (args.some((word) => /^-[a-z]*c[a-z]*$/.test(word)))
121
+ return false;
122
+ const operand = args.findIndex((word) => !word.startsWith("-"));
123
+ return operand < 0 || args.slice(0, operand).includes("-s");
124
+ }
125
+ /**
126
+ * The command substitutions in a here-document whose delimiter is not quoted,
127
+ * `$(...)` and backquotes, which the shell runs while it reads the text. The
128
+ * rest of the text is data.
129
+ */
130
+ function substitutions(body) {
131
+ const found = [];
132
+ for (let at = 0; at < body.length; at += 1) {
133
+ const char = body[at];
134
+ if (char === "\\") {
135
+ at += 1;
136
+ }
137
+ else if (char === "$" && body[at + 1] === "(" && body[at + 2] !== "(") {
138
+ let depth = 1;
139
+ let end = at + 2;
140
+ while (end < body.length && depth > 0) {
141
+ if (body[end] === "(")
142
+ depth += 1;
143
+ else if (body[end] === ")")
144
+ depth -= 1;
145
+ if (depth > 0)
146
+ end += 1;
147
+ }
148
+ found.push(body.slice(at + 2, end));
149
+ at = end;
150
+ }
151
+ else if (char === "`") {
152
+ let end = at + 1;
153
+ while (end < body.length && body[end] !== "`")
154
+ end += body[end] === "\\" ? 2 : 1;
155
+ found.push(body.slice(at + 1, end));
156
+ at = end;
157
+ }
158
+ }
159
+ return found;
160
+ }
161
+ /**
162
+ * A shell line in the order a shell reads it: simple commands and the
163
+ * operators between them.
54
164
  *
55
- * Quotes and backslashes are honored, so quoted text stays one word; `;`,
56
- * `&&`, `||`, `|`, `&`, newlines and parentheses end a command, so `npm test
57
- * && git push` is two commands and `$(git push)` is found. A comment runs to
58
- * the end of its line.
165
+ * Quotes and backslashes are honored, so quoted text stays one word. `;`,
166
+ * `&&`, `||`, `|`, `&`, newlines, parentheses and backquotes come between
167
+ * commands. A comment runs to the end of its line. A redirection (`>`, `2>&1`,
168
+ * `<file`, `&>log`) and its target are left out of the command's words.
169
+ *
170
+ * A here-document (`<<WORD`, `<<'WORD'`, `<<"WORD"`, `<<-WORD`) is data: the
171
+ * lines after its command, up to a line that is exactly WORD (leading tabs
172
+ * removed for `<<-`), are not commands. A here-string's word (`<<<`) is data
173
+ * too. Two exceptions keep a push inside one from going unseen: text fed to a
174
+ * shell that reads its script from input (`bash <<EOF`, `cat <<EOF | sh`) is
175
+ * read as that script, and the `$(...)` and backquotes in a here-document
176
+ * whose delimiter is not quoted are read as the commands the shell runs while
177
+ * expanding it. Either is read as a subshell, after the pipeline it feeds. A
178
+ * here-document that never reaches its delimiter line is not taken as one, so
179
+ * the lines after it are still read as commands.
59
180
  */
60
- export function shellCommands(line) {
61
- const commands = [];
181
+ export function scanShell(line, depth = 0) {
182
+ const pieces = [];
183
+ const fed = [];
184
+ const pending = [];
62
185
  let words = [];
63
186
  let word = "";
64
187
  let inWord = false;
@@ -71,17 +194,62 @@ export function shellCommands(line) {
71
194
  const endCommand = () => {
72
195
  endWord();
73
196
  if (words.length > 0)
74
- commands.push(words);
197
+ pieces.push({ kind: "command", words });
75
198
  words = [];
76
199
  };
77
- for (let at = 0; at < line.length; at += 1) {
200
+ const operator = (value) => {
201
+ endCommand();
202
+ pieces.push({ kind: "operator", operator: value });
203
+ };
204
+ /** The bodies of the here-documents waiting on the line that just ended, from `from`. */
205
+ const hereDocuments = (from) => {
206
+ let at = from;
207
+ while (pending.length > 0) {
208
+ const document = pending[0];
209
+ const lines = [];
210
+ let cursor = at;
211
+ let after = -1;
212
+ for (;;) {
213
+ const newline = line.indexOf("\n", cursor);
214
+ const stop = newline < 0 ? line.length : newline;
215
+ const text = document.stripTabs
216
+ ? line.slice(cursor, stop).replace(/^\t+/, "")
217
+ : line.slice(cursor, stop);
218
+ if (text === document.delimiter) {
219
+ after = newline < 0 ? line.length : newline + 1;
220
+ break;
221
+ }
222
+ lines.push(text);
223
+ if (newline < 0)
224
+ break;
225
+ cursor = newline + 1;
226
+ }
227
+ if (after < 0) {
228
+ // Never closed: read what follows as commands rather than hide it.
229
+ pending.length = 0;
230
+ break;
231
+ }
232
+ pending.shift();
233
+ fed.push({
234
+ index: document.index,
235
+ body: lines.join("\n"),
236
+ expands: document.expands,
237
+ order: fed.length,
238
+ });
239
+ at = after;
240
+ }
241
+ return at;
242
+ };
243
+ let at = 0;
244
+ while (at < line.length) {
78
245
  const char = line[at];
246
+ const next = line[at + 1];
79
247
  if (char === "'") {
80
248
  const close = line.indexOf("'", at + 1);
81
249
  const end = close < 0 ? line.length : close;
82
250
  word += line.slice(at + 1, end);
83
251
  inWord = true;
84
- at = end;
252
+ at = end + 1;
85
253
  }
86
254
  else if (char === '"') {
87
255
  inWord = true;
@@ -92,33 +260,412 @@ export function shellCommands(line) {
92
260
  word += line[at];
93
261
  at += 1;
94
262
  }
263
+ at += 1;
95
264
  }
96
265
  else if (char === "\\") {
97
- if (at + 1 < line.length && line[at + 1] !== "\n")
98
- word += line[at + 1];
99
- inWord = true;
100
- at += 1;
266
+ // A backslash before a newline joins two lines and adds nothing.
267
+ if (next !== undefined && next !== "\n") {
268
+ word += next;
269
+ inWord = true;
270
+ }
271
+ at += 2;
101
272
  }
102
273
  else if (char === "#" && !inWord) {
103
274
  const end = line.indexOf("\n", at);
104
- at = end < 0 ? line.length : end - 1;
275
+ at = end < 0 ? line.length : end;
276
+ }
277
+ else if (char === "\n") {
278
+ operator("\n");
279
+ at = hereDocuments(at + 1);
105
280
  }
106
281
  else if (/\s/.test(char)) {
107
- if (char === "\n")
108
- endCommand();
282
+ endWord();
283
+ at += 1;
284
+ }
285
+ else if (char === "<" && next === "<" && line[at + 2] === "<") {
286
+ endWord();
287
+ const data = readWord(line, skipBlanks(line, at + 3));
288
+ fed.push({ index: pieces.length, body: data.text, expands: false, order: fed.length });
289
+ at = data.end;
290
+ }
291
+ else if (char === "<" && next === "<") {
292
+ endWord();
293
+ let from = at + 2;
294
+ const stripTabs = line[from] === "-";
295
+ if (stripTabs)
296
+ from += 1;
297
+ from = skipBlanks(line, from);
298
+ const delimiter = readWord(line, from);
299
+ if (delimiter.end > from)
300
+ pending.push({
301
+ delimiter: delimiter.text,
302
+ stripTabs,
303
+ expands: !delimiter.quoted,
304
+ index: pieces.length,
305
+ });
306
+ at = delimiter.end;
307
+ }
308
+ else if ((char === "<" || char === ">") && next === "(") {
309
+ // Process substitution: the parenthesis opens a command of its own.
310
+ endWord();
311
+ at += 1;
312
+ }
313
+ else if (char === "<" || char === ">" || (char === "&" && next === ">")) {
314
+ // A number written straight before the operator is the descriptor it redirects.
315
+ if (inWord && /^\d+$/.test(word)) {
316
+ word = "";
317
+ inWord = false;
318
+ }
109
319
  else
110
320
  endWord();
321
+ let from = at + (char === "&" ? 2 : 1);
322
+ if (line[from] === ">" || line[from] === "&" || line[from] === "|")
323
+ from += 1;
324
+ else if (char === "<" && line[from] === ">")
325
+ from += 1;
326
+ at = readWord(line, skipBlanks(line, from)).end;
327
+ }
328
+ else if (char === "&" && next === "&") {
329
+ operator("&&");
330
+ at += 2;
111
331
  }
112
- else if (";&|()`".includes(char)) {
113
- endCommand();
332
+ else if (char === "|" && next === "|") {
333
+ operator("||");
334
+ at += 2;
335
+ }
336
+ else if (char === "|") {
337
+ operator("|");
338
+ at += next === "&" ? 2 : 1;
339
+ }
340
+ else if (char === "&" || char === ";" || char === "(" || char === ")" || char === "`") {
341
+ operator(char);
342
+ at += 1;
114
343
  }
115
344
  else {
116
345
  word += char;
117
346
  inWord = true;
347
+ at += 1;
118
348
  }
119
349
  }
120
350
  endCommand();
121
- return commands;
351
+ if (depth >= 3)
352
+ return pieces;
353
+ // Text read as commands goes in as a subshell after the pipeline it feeds,
354
+ // latest first so the earlier positions stay where they are.
355
+ const isPipe = (piece) => piece?.kind === "operator" && piece.operator === "|";
356
+ const placed = fed
357
+ .filter((entry) => pieces[entry.index]?.kind === "command")
358
+ .map((entry) => {
359
+ let start = entry.index;
360
+ let end = entry.index;
361
+ while (start >= 2 && isPipe(pieces[start - 1]) && pieces[start - 2]?.kind === "command")
362
+ start -= 2;
363
+ while (isPipe(pieces[end + 1]) && pieces[end + 2]?.kind === "command")
364
+ end += 2;
365
+ const pipeline = pieces
366
+ .slice(start, end + 1)
367
+ .flatMap((piece) => (piece.kind === "command" ? [piece.words] : []));
368
+ const scripts = pipeline.some(readsScriptFromInput)
369
+ ? [entry.body]
370
+ : entry.expands
371
+ ? substitutions(entry.body)
372
+ : [];
373
+ return { end, order: entry.order, scripts };
374
+ })
375
+ .sort((a, b) => b.end - a.end || b.order - a.order);
376
+ for (const { end, scripts } of placed) {
377
+ const inserted = scripts.flatMap((script) => [
378
+ { kind: "operator", operator: "(" },
379
+ ...scanShell(script, depth + 1),
380
+ { kind: "operator", operator: ")" },
381
+ ]);
382
+ pieces.splice(end + 1, 0, ...inserted);
383
+ }
384
+ return pieces;
385
+ }
386
+ /**
387
+ * The simple commands a shell line runs, as word lists, in the order
388
+ * `scanShell` reads them: `npm test && git push` is two commands, `$(git
389
+ * push)` is found, and a here-document's text is not a command.
390
+ */
391
+ export function shellCommands(line) {
392
+ return scanShell(line).flatMap((piece) => (piece.kind === "command" ? [[...piece.words]] : []));
393
+ }
394
+ /** Words that group commands or negate one; the command is what follows them. */
395
+ const GROUPING = new Set(["{", "}", "!"]);
396
+ /** Words after which a command runs only sometimes, repeatedly, or later. */
397
+ const BRANCHING = new Set([
398
+ "if",
399
+ "then",
400
+ "elif",
401
+ "else",
402
+ "fi",
403
+ "for",
404
+ "while",
405
+ "until",
406
+ "do",
407
+ "done",
408
+ "case",
409
+ "esac",
410
+ "select",
411
+ "function",
412
+ ]);
413
+ /**
414
+ * A directory a command names, taken from `from`, or nothing when it cannot
415
+ * be told for certain: a variable, a pattern, another user's home, or a
416
+ * starting point that is itself unknown.
417
+ */
418
+ function directoryFrom(from, named) {
419
+ if (from === undefined || named === "" || /[$`*?[]/.test(named))
420
+ return undefined;
421
+ let path = named;
422
+ if (path === "~" || path.startsWith("~/")) {
423
+ const home = process.env.HOME;
424
+ if (!home || !isAbsolute(home))
425
+ return undefined;
426
+ path = join(home, path.slice(1));
427
+ }
428
+ else if (path.startsWith("~"))
429
+ return undefined;
430
+ const joined = isAbsolute(path) ? join(path) : join(from, path);
431
+ return joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
432
+ }
433
+ /** Where `cd` with these arguments goes. `cd`, `cd -` and `cd a b` cannot be told. */
434
+ function cdTarget(from, args) {
435
+ let at = 0;
436
+ while (at < args.length && /^-[LPe@]+$/.test(args[at]))
437
+ at += 1;
438
+ if (args[at] === "--")
439
+ at += 1;
440
+ const rest = args.slice(at);
441
+ if (rest.length !== 1 || rest[0] === "-")
442
+ return undefined;
443
+ return directoryFrom(from, rest[0]);
444
+ }
445
+ /** Where a git command runs after its `-C` options; `--git-dir` and `--work-tree` cannot be told. */
446
+ function gitDirectory(from, args) {
447
+ let here = from;
448
+ for (let at = 0; at < args.length; at += 1) {
449
+ const word = args[at];
450
+ if (/^--(git-dir|work-tree)(=|$)/.test(word))
451
+ return undefined;
452
+ if (word === "-C") {
453
+ const named = args[at + 1];
454
+ if (named === undefined)
455
+ return undefined;
456
+ if (named !== "")
457
+ here = directoryFrom(here, named);
458
+ at += 1;
459
+ }
460
+ else if (GIT_VALUE_OPTIONS.has(word))
461
+ at += 1;
462
+ else if (!word.startsWith("-"))
463
+ break;
464
+ }
465
+ return here;
466
+ }
467
+ /** `gh pr create` options that take the next word as their value. */
468
+ const GH_CREATE_VALUE_OPTIONS = new Set([
469
+ "-a",
470
+ "--assignee",
471
+ "-B",
472
+ "--base",
473
+ "-b",
474
+ "--body",
475
+ "-F",
476
+ "--body-file",
477
+ "-H",
478
+ "--head",
479
+ "-l",
480
+ "--label",
481
+ "-m",
482
+ "--milestone",
483
+ "-p",
484
+ "--project",
485
+ "-R",
486
+ "--repo",
487
+ "-r",
488
+ "--reviewer",
489
+ "-T",
490
+ "--template",
491
+ "-t",
492
+ "--title",
493
+ "--recover",
494
+ ]);
495
+ function createRepo(args) {
496
+ let repo;
497
+ for (let at = 0; at < args.length; at += 1) {
498
+ const word = args[at];
499
+ if (word === "--")
500
+ break;
501
+ if (word.startsWith("--repo="))
502
+ repo = word.slice("--repo=".length);
503
+ else if (GH_CREATE_VALUE_OPTIONS.has(word)) {
504
+ if (word === "-R" || word === "--repo")
505
+ repo = args[at + 1];
506
+ at += 1;
507
+ }
508
+ }
509
+ return repo;
510
+ }
511
+ /**
512
+ * Every push, pull request creation and merge in a shell line, with the
513
+ * directory it runs in when that can be told for certain.
514
+ *
515
+ * Starting from `cwd`, each `cd` moves the line along in order, and a git
516
+ * command's own `-C` options apply to that command alone. A `cd` inside
517
+ * parentheses, backquotes or `$(...)` ends with them. Anything that makes the
518
+ * directory uncertain makes it unknown from there on rather than guessed: `cd`
519
+ * with no directory, `cd -`, a variable or pattern, `pushd` or `popd`, a `cd`
520
+ * in a pipeline or in the background, a `cd` inside `if`, a loop, `case` or a
521
+ * function, and a `cd` that ran only when an earlier command succeeded or
522
+ * failed, once the line goes past its `&&` chain. A shell's `-c` script starts
523
+ * where the shell does and moves nothing outside it.
524
+ */
525
+ export function pushSites(line, cwd, depth = 0) {
526
+ const pieces = scanShell(line);
527
+ const sites = [];
528
+ const saved = [];
529
+ let here = cwd;
530
+ // Set after a `cd` that ran only when an earlier command succeeded or
531
+ // failed: it holds along its own `&&` chain and not past it.
532
+ let conditional = false;
533
+ let backquoted = false;
534
+ let branching = false;
535
+ const restore = () => {
536
+ const frame = saved.pop();
537
+ here = frame === undefined ? undefined : frame.here;
538
+ conditional = frame === undefined ? false : frame.conditional;
539
+ };
540
+ const operatorAt = (index) => {
541
+ const piece = pieces[index];
542
+ return piece?.kind === "operator" ? piece.operator : undefined;
543
+ };
544
+ for (let index = 0; index < pieces.length; index += 1) {
545
+ const piece = pieces[index];
546
+ if (piece.kind === "operator") {
547
+ const value = piece.operator;
548
+ if (value === "(") {
549
+ // `name()` defines a function, whose body runs later if at all.
550
+ if (operatorAt(index + 1) === ")")
551
+ branching = true;
552
+ saved.push({ here, conditional });
553
+ conditional = false;
554
+ }
555
+ else if (value === ")")
556
+ restore();
557
+ else if (value === "`") {
558
+ if (backquoted)
559
+ restore();
560
+ else {
561
+ saved.push({ here, conditional });
562
+ conditional = false;
563
+ }
564
+ backquoted = !backquoted;
565
+ }
566
+ else if (value !== "&&" && conditional) {
567
+ here = undefined;
568
+ conditional = false;
569
+ }
570
+ continue;
571
+ }
572
+ let start = 0;
573
+ while (start < piece.words.length) {
574
+ const word = piece.words[start];
575
+ if (BRANCHING.has(word))
576
+ branching = true;
577
+ else if (!GROUPING.has(word))
578
+ break;
579
+ start += 1;
580
+ }
581
+ const words = piece.words.slice(start);
582
+ const run = program(words);
583
+ if (run.length === 0)
584
+ continue;
585
+ const prefix = words.slice(0, words.length - run.length);
586
+ const name = basename(run[0]);
587
+ const before = operatorAt(index - 1);
588
+ const after = operatorAt(index + 1);
589
+ if (name === "cd") {
590
+ if (branching || before === "|" || after === "|" || after === "&")
591
+ here = undefined;
592
+ else {
593
+ here = cdTarget(here, run.slice(1));
594
+ if (before === "&&" || before === "||")
595
+ conditional = true;
596
+ }
597
+ }
598
+ else if (name === "pushd" || name === "popd")
599
+ here = undefined;
600
+ else if (name === "git" && gitSubcommand(run.slice(1)) === "push") {
601
+ const directory = prefix.some((word) => /^GIT_(DIR|WORK_TREE)=/.test(word))
602
+ ? undefined
603
+ : gitDirectory(here, run.slice(1));
604
+ sites.push({ kind: "push", ...(directory === undefined ? {} : { directory }) });
605
+ }
606
+ else if (name === "gh" && run[1] === "pr" && (run[2] === "create" || run[2] === "merge")) {
607
+ const named = (run[2] === "merge" ? mergeTarget(run.slice(3)).repo : createRepo(run.slice(3))) ??
608
+ prefix.find((word) => word.startsWith("GH_REPO="))?.slice("GH_REPO=".length);
609
+ const directory = named !== undefined && /[$`]/.test(named) ? undefined : here;
610
+ sites.push({
611
+ kind: run[2],
612
+ ...(directory === undefined ? {} : { directory }),
613
+ ...(named ? { repo: named } : {}),
614
+ });
615
+ }
616
+ else if (SHELLS.has(name) && depth < 3) {
617
+ const flag = run.findIndex((word, at) => at > 0 && /^-[a-z]*c[a-z]*$/.test(word));
618
+ const script = flag > 0 ? run[flag + 1] : undefined;
619
+ if (script !== undefined)
620
+ sites.push(...pushSites(script, here, depth + 1));
621
+ }
622
+ }
623
+ return sites;
624
+ }
625
+ /**
626
+ * The one directory a shell line's pushes run in, starting from `cwd`, or
627
+ * unknown when any of them cannot be told or they run in more than one.
628
+ */
629
+ export function pushPlace(line, cwd) {
630
+ const sites = pushSites(line, cwd);
631
+ if (sites.length === 0)
632
+ return { known: true, directory: cwd, sites };
633
+ const directories = new Set(sites.map((site) => site.directory));
634
+ const [directory] = directories;
635
+ if (directories.size !== 1 || directory === undefined)
636
+ return { known: false };
637
+ return { known: true, directory, sites };
638
+ }
639
+ /**
640
+ * A repository as `owner/name` in lower case, from a remote URL or a `--repo`
641
+ * value: `https://github.com/o/r.git`, `git@github.com:o/r.git`,
642
+ * `ssh://git@host/o/r`, `HOST/o/r` or `o/r`. Nothing when there is no owner.
643
+ */
644
+ export function repositoryName(reference) {
645
+ let path = reference.trim();
646
+ const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]*(\/.*)?$/i.exec(path);
647
+ const scp = url === null ? /^(?:[^/@:\s]+@)?[^/:\s]+:(.*)$/.exec(path) : null;
648
+ if (url !== null)
649
+ path = url[1] ?? "";
650
+ else if (scp !== null)
651
+ path = scp[1] ?? "";
652
+ const segments = path
653
+ .replace(/\/+$/, "")
654
+ .replace(/\.git$/i, "")
655
+ .split("/")
656
+ .filter(Boolean);
657
+ return segments.length < 2 ? undefined : segments.slice(-2).join("/").toLowerCase();
658
+ }
659
+ /** The repositories a checkout's remotes point at, from what `git remote -v` prints. */
660
+ export function remoteRepositoryNames(listing) {
661
+ const names = new Set();
662
+ for (const line of listing.split("\n")) {
663
+ const url = line.trim().split(/\s+/)[1];
664
+ const name = url === undefined ? undefined : repositoryName(url);
665
+ if (name !== undefined)
666
+ names.add(name);
667
+ }
668
+ return names;
122
669
  }
123
670
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
124
671
  const WRAPPERS = new Set(["env", "command", "exec", "time", "nohup", "nice", "sudo", "builtin"]);
@@ -162,36 +709,161 @@ function gitSubcommand(args) {
162
709
  }
163
710
  return undefined;
164
711
  }
165
- /** Whether a shell line pushes: `git push`, `gh pr create`, or `gh pr merge`, anywhere in it. */
166
- export function isPushCommand(line, depth = 0) {
712
+ /**
713
+ * The commands in a shell line that matter to the judge, in the order they
714
+ * run: pushes, pull requests created or merged, and commits. A shell's `-c`
715
+ * script is read in place, a few levels deep.
716
+ */
717
+ function steps(line, depth = 0) {
718
+ const found = [];
167
719
  for (const words of shellCommands(line)) {
168
720
  const run = program(words);
169
721
  if (run.length === 0)
170
722
  continue;
171
723
  const name = basename(run[0]);
172
- if (name === "git" && gitSubcommand(run.slice(1)) === "push")
173
- return true;
174
- if (name === "gh" && run[1] === "pr" && (run[2] === "create" || run[2] === "merge"))
175
- return true;
176
- if (SHELLS.has(name) && depth < 3) {
724
+ if (name === "git") {
725
+ const subcommand = gitSubcommand(run.slice(1));
726
+ if (subcommand === "push" || subcommand === "commit")
727
+ found.push({ kind: subcommand, args: [] });
728
+ }
729
+ else if (name === "gh" && run[1] === "pr" && (run[2] === "create" || run[2] === "merge"))
730
+ found.push({ kind: run[2], args: run.slice(3) });
731
+ else if (SHELLS.has(name) && depth < 3) {
177
732
  const flag = run.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/.test(word));
178
733
  const script = flag > 0 ? run[flag + 1] : undefined;
179
- if (script !== undefined && isPushCommand(script, depth + 1))
180
- return true;
734
+ if (script !== undefined)
735
+ found.push(...steps(script, depth + 1));
181
736
  }
182
737
  }
183
- return false;
738
+ return found;
739
+ }
740
+ /** Whether a shell line pushes: `git push`, `gh pr create`, or `gh pr merge`, anywhere in it. */
741
+ export function isPushCommand(line) {
742
+ return steps(line).some((step) => step.kind !== "commit");
743
+ }
744
+ /**
745
+ * Whether a shell line sends this checkout's commits: a `git push`, or a `gh pr
746
+ * create`, which pushes the branch it opens a pull request for. A line whose
747
+ * only push is `gh pr merge` sends none; that pull request's commits were on
748
+ * the remote before the line ran.
749
+ */
750
+ export function sendsCommits(line) {
751
+ return steps(line).some((step) => step.kind === "push" || step.kind === "create");
752
+ }
753
+ /**
754
+ * git commands that make a commit or move the branch to one: what a check run
755
+ * before the shell line cannot see, because it does not exist yet.
756
+ */
757
+ const COMMIT_MAKING = new Set(["commit", "merge", "cherry-pick", "revert", "am", "rebase", "pull"]);
758
+ /** Flags with which those commands make no commit: they stop, or only pretend. */
759
+ const MAKES_NO_COMMIT = new Set(["--abort", "--quit", "--dry-run"]);
760
+ /** The simple commands a shell line runs, in order, with those inside `bash -c` and the like in place. */
761
+ function commandsInOrder(line, depth = 0) {
762
+ const out = [];
763
+ for (const words of shellCommands(line)) {
764
+ const run = program(words);
765
+ if (run.length === 0)
766
+ continue;
767
+ if (SHELLS.has(basename(run[0])) && depth < 3) {
768
+ const flag = run.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/.test(word));
769
+ const script = flag > 0 ? run[flag + 1] : undefined;
770
+ if (script !== undefined) {
771
+ out.push(...commandsInOrder(script, depth + 1));
772
+ continue;
773
+ }
774
+ }
775
+ out.push(run);
776
+ }
777
+ return out;
778
+ }
779
+ /**
780
+ * The first command in the line that makes a commit before it pushes, as
781
+ * `git <subcommand>`, or nothing.
782
+ *
783
+ * A host's PreToolUse hook sees the checkout before the line runs, so in
784
+ * `git commit -am x && git push` the check judges the code as it was before
785
+ * the commit, and the commit the push carries is one it never saw. Only a
786
+ * commit made before the push matters: one made after it is not in what was
787
+ * pushed, and `gh pr merge` merges a pull request's head, not this checkout's.
788
+ */
789
+ export function commitBeforePush(line) {
790
+ let found;
791
+ for (const run of commandsInOrder(line)) {
792
+ const name = basename(run[0]);
793
+ if (name === "gh" && run[1] === "pr" && run[2] === "create")
794
+ return found;
795
+ if (name !== "git")
796
+ continue;
797
+ const subcommand = gitSubcommand(run.slice(1));
798
+ if (subcommand === "push")
799
+ return found;
800
+ if (found === undefined &&
801
+ subcommand !== undefined &&
802
+ COMMIT_MAKING.has(subcommand) &&
803
+ !run.some((word) => MAKES_NO_COMMIT.has(word)))
804
+ found = `git ${subcommand}`;
805
+ }
806
+ return undefined;
807
+ }
808
+ /** `gh pr merge` options that take the next word as their value. */
809
+ const GH_MERGE_VALUE_OPTIONS = new Set([
810
+ "-A",
811
+ "--author-email",
812
+ "-b",
813
+ "--body",
814
+ "-F",
815
+ "--body-file",
816
+ "--match-head-commit",
817
+ "-t",
818
+ "--subject",
819
+ "-R",
820
+ "--repo",
821
+ ]);
822
+ function mergeTarget(args) {
823
+ let selector;
824
+ let repo;
825
+ for (let at = 0; at < args.length; at += 1) {
826
+ const word = args[at];
827
+ if (word === "--") {
828
+ selector ??= args[at + 1];
829
+ break;
830
+ }
831
+ if (word.startsWith("--") && word.includes("=")) {
832
+ if (word.startsWith("--repo="))
833
+ repo = word.slice("--repo=".length);
834
+ continue;
835
+ }
836
+ if (GH_MERGE_VALUE_OPTIONS.has(word)) {
837
+ if (word === "-R" || word === "--repo")
838
+ repo = args[at + 1];
839
+ at += 1;
840
+ continue;
841
+ }
842
+ if (word.startsWith("-"))
843
+ continue;
844
+ selector ??= word;
845
+ }
846
+ return { ...(selector ? { selector } : {}), ...(repo ? { repo } : {}) };
847
+ }
848
+ /** Every `gh pr merge` in a shell line, with the pull request and repository it names. */
849
+ export function mergeTargets(line) {
850
+ return steps(line)
851
+ .filter((step) => step.kind === "merge")
852
+ .map((step) => mergeTarget(step.args));
184
853
  }
185
854
  /**
186
855
  * What the host is told. In report mode the lines reach the agent as context
187
856
  * and the push goes ahead; a block is exit 2 with the reason, which both hosts
188
857
  * treat as a refusal of that one command. Claude Code also gets the reason as
189
858
  * `permissionDecision: "deny"`, which it prefers to stderr; Codex is given the
190
- * stderr form its documentation names.
859
+ * stderr form its documentation names. After the command ran (PostToolUse)
860
+ * there is nothing left to hold, so the lines are only ever context, named for
861
+ * the event that asked.
191
862
  */
192
863
  export function hookResponse(host, outcome) {
193
864
  const text = outcome.lines.join("\n");
194
- if (outcome.block) {
865
+ const event = outcome.event ?? "PreToolUse";
866
+ if (outcome.block && event === "PreToolUse") {
195
867
  if (host === "claude")
196
868
  return {
197
869
  stdout: `${JSON.stringify({
@@ -212,7 +884,7 @@ export function hookResponse(host, outcome) {
212
884
  return { stdout: `${text}\n`, stderr: "", exitCode: 0 };
213
885
  return {
214
886
  stdout: `${JSON.stringify({
215
- hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: text },
887
+ hookSpecificOutput: { hookEventName: event, additionalContext: text },
216
888
  })}\n`,
217
889
  stderr: "",
218
890
  exitCode: 0,
@@ -296,17 +968,106 @@ function writeAtomically(path, content, mode) {
296
968
  throw error;
297
969
  }
298
970
  }
971
+ /** The hook file the installer writes for a host, relative to the repository root. */
972
+ export function judgeHookFile(host) {
973
+ return host === "claude" ? ".claude/settings.json" : ".codex/hooks.json";
974
+ }
975
+ /** Whether one row of a hook event's list is an entry this command owns: a handler carries the marker. */
976
+ function ownsRow(row) {
977
+ const handlers = row?.hooks;
978
+ return Array.isArray(handlers)
979
+ ? handlers.some((handler) => handler?.statusMessage === JUDGE_HOOK_MARKER)
980
+ : false;
981
+ }
982
+ /**
983
+ * Whether the hook file the installer writes has this command's entry for one
984
+ * event. It reads the file rather than guessing, so a checkout installed by an
985
+ * earlier release, which wrote only the PreToolUse entry, is told apart from a
986
+ * current one. Anything unreadable counts as no entry.
987
+ */
988
+ export function hasJudgeEntry(root, host, event) {
989
+ try {
990
+ const text = readOwnFile(join(root, judgeHookFile(host)));
991
+ if (text === undefined)
992
+ return false;
993
+ const rows = JSON.parse(text)?.hooks?.[event];
994
+ return Array.isArray(rows) && rows.some(ownsRow);
995
+ }
996
+ catch {
997
+ return false;
998
+ }
999
+ }
1000
+ /**
1001
+ * Whether this checkout has the push check's own project entry for a host,
1002
+ * under either event. Where it does, that entry owns the check in this
1003
+ * checkout and the one setup writes at the host's user scope stands aside, so
1004
+ * a push is judged once. Either event counts: a checkout an earlier release
1005
+ * installed has only the entry before the push, and it keeps judging there.
1006
+ */
1007
+ export function hasProjectJudgeHook(root, host) {
1008
+ return JUDGE_HOOK_EVENTS.some((event) => hasJudgeEntry(root, host, event));
1009
+ }
299
1010
  /**
300
- * The PreToolUse entry in a host's JSON hook file, added once.
1011
+ * The checkout that holds a folder, found by walking up to its `.git` entry (a
1012
+ * directory, or a file in a worktree). No process is started, so a folder that
1013
+ * is not in a checkout at all is known in well under a millisecond.
1014
+ */
1015
+ export function checkoutRoot(start) {
1016
+ let directory = resolve(start);
1017
+ for (;;) {
1018
+ if (existsSync(join(directory, ".git")))
1019
+ return directory;
1020
+ const parent = dirname(directory);
1021
+ if (parent === directory)
1022
+ return undefined;
1023
+ directory = parent;
1024
+ }
1025
+ }
1026
+ /**
1027
+ * The push check's switch for this laptop, in Balladeer's own config home.
1028
+ * Absent means on. Written only by `balladeer judge --push-check off`, and
1029
+ * read by the user-scope entry on each push, so turning it off takes effect in
1030
+ * sessions that are already open.
1031
+ */
1032
+ export const PUSH_CHECK_SETTING_FILE = "push-check.json";
1033
+ export function pushCheckSettingPath(environment) {
1034
+ return join(configHome(environment), PUSH_CHECK_SETTING_FILE);
1035
+ }
1036
+ /** True only when this laptop's switch says off; anything unreadable leaves it on. */
1037
+ export function pushCheckOff(environment) {
1038
+ try {
1039
+ const parsed = JSON.parse(readFileSync(pushCheckSettingPath(environment), "utf8"));
1040
+ return parsed?.on === false;
1041
+ }
1042
+ catch {
1043
+ return false;
1044
+ }
1045
+ }
1046
+ /** Turn the user-scope push check off or back on for this laptop. */
1047
+ export function setPushCheck(environment, on) {
1048
+ const path = pushCheckSettingPath(environment);
1049
+ if (on) {
1050
+ rmSync(path, { force: true });
1051
+ return;
1052
+ }
1053
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1054
+ writeFileSync(path, `${JSON.stringify({ on: false })}\n`, { mode: 0o600 });
1055
+ }
1056
+ /**
1057
+ * The PreToolUse and PostToolUse entries in a host's JSON hook file, each
1058
+ * added once. Both run the same command; the judge reads which event called it
1059
+ * from the host's input (`hook_event_name`).
301
1060
  *
302
1061
  * The entry this command owns is the one whose handler carries the marker. A
303
- * second run finds it and changes nothing, byte for byte. An owned entry that
304
- * somebody edited is left alone and reported, the same rule the guidance
305
- * installer follows: it is theirs now, and silently putting it back would undo
306
- * a decision somebody made on purpose.
1062
+ * second run finds both and changes nothing, byte for byte; a file written by
1063
+ * an earlier release, with only the PreToolUse entry, gains the PostToolUse
1064
+ * one. An owned entry that somebody edited is left alone and reported, and
1065
+ * nothing in the file is written, the same rule the guidance installer
1066
+ * follows: it is theirs now, and silently putting it back would undo a
1067
+ * decision somebody made on purpose.
307
1068
  */
308
1069
  function installJsonHook(root, host) {
309
- const relativePath = host === "claude" ? ".claude/settings.json" : ".codex/hooks.json";
1070
+ const relativePath = judgeHookFile(host);
310
1071
  const path = join(root, relativePath);
311
1072
  try {
312
1073
  const before = readOwnFile(path);
@@ -328,28 +1089,33 @@ function installJsonHook(root, host) {
328
1089
  reason: `hooks in ${relativePath} is not an object`,
329
1090
  };
330
1091
  const events = hooks;
331
- const rows = events.PreToolUse === undefined ? [] : events.PreToolUse;
332
- if (!Array.isArray(rows))
333
- return {
334
- target: host,
335
- path: relativePath,
336
- status: "refused",
337
- reason: `hooks.PreToolUse in ${relativePath} is not a list`,
338
- };
339
- const own = rows.filter((row) => Array.isArray(row?.hooks)
340
- ? row.hooks.some((handler) => handler?.statusMessage === JUDGE_HOOK_MARKER)
341
- : false);
342
1092
  const expected = expectedEntry(host);
343
- if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
1093
+ const missing = [];
1094
+ for (const event of JUDGE_HOOK_EVENTS) {
1095
+ const rows = events[event] === undefined ? [] : events[event];
1096
+ if (!Array.isArray(rows))
1097
+ return {
1098
+ target: host,
1099
+ path: relativePath,
1100
+ status: "refused",
1101
+ reason: `hooks.${event} in ${relativePath} is not a list`,
1102
+ };
1103
+ const own = rows.filter(ownsRow);
1104
+ if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
1105
+ continue;
1106
+ if (own.length > 0)
1107
+ return {
1108
+ target: host,
1109
+ path: relativePath,
1110
+ status: "refused",
1111
+ reason: `${relativePath} already has a Balladeer judge entry under ${event} that was edited by hand, so the file was left as it is. Remove it to have this command write the current ones.`,
1112
+ };
1113
+ missing.push(event);
1114
+ }
1115
+ if (missing.length === 0)
344
1116
  return { target: host, path: relativePath, status: "unchanged" };
345
- if (own.length > 0)
346
- return {
347
- target: host,
348
- path: relativePath,
349
- status: "refused",
350
- reason: `${relativePath} already has a Balladeer judge entry that was edited by hand, so it was left as it is. Remove it to have this command write the current one.`,
351
- };
352
- events.PreToolUse = [...rows, expected];
1117
+ for (const event of missing)
1118
+ events[event] = [...(events[event] ?? []), expected];
353
1119
  settings.hooks = events;
354
1120
  const mode = before === undefined ? 0o644 : lstatSync(path).mode & 0o777;
355
1121
  writeAtomically(path, `${JSON.stringify(settings, null, 2)}\n`, mode);
@@ -418,3 +1184,95 @@ function installGitHook(root) {
418
1184
  export function installJudgeHook(root, target) {
419
1185
  return target === "git" ? installGitHook(root) : installJsonHook(root, target);
420
1186
  }
1187
+ const SHA = /^[0-9a-f]{40}$/;
1188
+ /**
1189
+ * Whether a commit is on a remote now: reachable from a remote-tracking ref,
1190
+ * or else the tip of a branch some remote reports.
1191
+ *
1192
+ * `git push`, `git push origin HEAD:branch` and the push `gh pr create` makes
1193
+ * all move the remote-tracking ref of what they pushed, so the first check is
1194
+ * local and immediate. The second is there because a push can land without
1195
+ * that ref moving: Codex's workspace sandbox keeps `.git` read-only, so its
1196
+ * pushes reach the remote and then fail to update `refs/remotes/...` (hook
1197
+ * canary, 29 September 2026). Asking each remote for its branch tips settles
1198
+ * it without fetching anything, and a push that failed leaves its commit on
1199
+ * neither.
1200
+ */
1201
+ export async function pushLanded(root, sha) {
1202
+ const local = await runCommand("git", [
1203
+ "-C",
1204
+ root,
1205
+ "for-each-ref",
1206
+ "--contains",
1207
+ sha,
1208
+ "--count=1",
1209
+ "--format=%(refname)",
1210
+ "refs/remotes",
1211
+ ], { timeoutMs: 30_000 });
1212
+ if (local.ok && local.stdout.trim() !== "")
1213
+ return true;
1214
+ const remotes = await runCommand("git", ["-C", root, "remote"]);
1215
+ if (!remotes.ok)
1216
+ return false;
1217
+ const names = remotes.stdout
1218
+ .split("\n")
1219
+ .map((name) => name.trim())
1220
+ .filter(Boolean);
1221
+ for (const remote of names.slice(0, 5)) {
1222
+ const listed = await runCommand("git", ["-C", root, "ls-remote", "--heads", remote], {
1223
+ timeoutMs: 20_000,
1224
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
1225
+ });
1226
+ if (listed.ok && listed.stdout.split("\n").some((line) => line.startsWith(`${sha}\t`)))
1227
+ return true;
1228
+ }
1229
+ return false;
1230
+ }
1231
+ /**
1232
+ * Where the remote default branch stood before this push, when this push is
1233
+ * what moved it to `head`; otherwise nothing, and the ordinary fork-point rule
1234
+ * applies.
1235
+ *
1236
+ * Judged after a push straight to the default branch, the fork point with that
1237
+ * branch's remote-tracking ref is `head` itself, so the change would shrink to
1238
+ * its last commit. git records the push in the ref's reflog ("update by
1239
+ * push"), and the entry before it is where the branch stood.
1240
+ */
1241
+ export async function baseBeforePush(root, head) {
1242
+ const names = [];
1243
+ const remoteHead = await runCommand("git", [
1244
+ "-C",
1245
+ root,
1246
+ "rev-parse",
1247
+ "--abbrev-ref",
1248
+ "origin/HEAD",
1249
+ ]);
1250
+ const named = remoteHead.stdout.trim();
1251
+ if (remoteHead.ok && named && named !== "origin/HEAD")
1252
+ names.push(named);
1253
+ names.push("origin/main", "origin/master");
1254
+ for (const name of new Set(names)) {
1255
+ if ((await resolveCommit(root, name)) !== head)
1256
+ continue;
1257
+ const log = await runCommand("git", [
1258
+ "-C",
1259
+ root,
1260
+ "reflog",
1261
+ "show",
1262
+ "--format=%H %gs",
1263
+ "-n",
1264
+ "2",
1265
+ name,
1266
+ ]);
1267
+ const [latest, earlier] = log.ok ? log.stdout.trim().split("\n") : [];
1268
+ if (!latest?.startsWith(`${head} update by push`) || earlier === undefined)
1269
+ return undefined;
1270
+ const previous = earlier.split(" ")[0] ?? "";
1271
+ if (!SHA.test(previous) || previous === head)
1272
+ return undefined;
1273
+ const fork = await runCommand("git", ["-C", root, "merge-base", head, previous]);
1274
+ const sha = fork.stdout.trim();
1275
+ return fork.ok && SHA.test(sha) && sha !== head ? sha : undefined;
1276
+ }
1277
+ return undefined;
1278
+ }