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.
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +19 -6
- package/dist/commands/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +168 -9
- package/dist/commands/judge.js +594 -51
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +242 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +278 -94
- package/dist/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +15 -3
- package/dist/judge-brief.js +15 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +8 -0
- package/dist/promise-meaning.js +9 -1
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +3 -3
- package/dist/wire.js +2 -2
- package/package.json +1 -1
package/dist/judge-hook.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
61
|
-
const
|
|
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
|
-
|
|
197
|
+
pieces.push({ kind: "command", words });
|
|
75
198
|
words = [];
|
|
76
199
|
};
|
|
77
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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 ("
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
166
|
-
|
|
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"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
180
|
-
|
|
734
|
+
if (script !== undefined)
|
|
735
|
+
found.push(...steps(script, depth + 1));
|
|
181
736
|
}
|
|
182
737
|
}
|
|
183
|
-
return
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
+
}
|