@jwilger/pi-development-system 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,19 @@ What the tiers do not cover, so you can decide what to trust:
124
124
  - **A departure is the agent's own call.** `devsys_record_departure` waives a soft gate (also with no
125
125
  UI) and is written to the decision log; it is never available for a hard stop.
126
126
  - **Gating starts at `devsys_intake`.** With no slice open, the review and red-first gates are off.
127
+ - **Command shape is read, not run.** The git guards classify the command text: aliases and
128
+ `git config` that redirect a later command, shells reading stdin, and `python -c` or `node -e`
129
+ that mention git are all treated as unclassifiable, so the user decides (headless: refused). A
130
+ command the classifier cannot see into at all (a script file, another tool) is not covered.
131
+ Commit messages are checked again at push time against what the remote lacks, so a commit made by
132
+ `-C`, `--fixup`, `commit-tree` or an alias cannot carry an AI trailer or skip the rationale; a push
133
+ in the same call as such a commit is refused, because the commit does not exist yet when it is checked.
134
+ - **A push source the shell builds is checked loosely.** `git push origin "$(cat sha)":refs/heads/x`
135
+ checks every local branch for AI trailers, not the unnamed commit the expression may name (one made
136
+ with `commit-tree` and on no branch). The unclassified-command prompt is the only stop there.
137
+ - **A push after a ref moves in the same call is checked against the refs as they were.**
138
+ `git merge --ff-only feature && git push origin main` publishes commits on `feature` that the
139
+ push check read before the call ran. Make the merge, switch or reset in one call and push in the next.
127
140
  - **Editing gate configuration is not guarded** (`biome.json`, hooks, CI files); review catches it.
128
141
 
129
142
  ## Decision log
@@ -17,6 +17,7 @@ import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
17
17
  import { defaultMatrix } from "../src/core/models.ts";
18
18
  import { createApprovalStore } from "../src/gates/approvals.ts";
19
19
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
20
+ import { createExcusedMessages } from "../src/gates/excused-messages.ts";
20
21
  import { declareWithoutCodemode } from "../src/gates/exposure.ts";
21
22
  import { registerGitGuard } from "../src/gates/git-guard.ts";
22
23
  import { registerLintSuppressionGuard } from "../src/gates/lint-suppression-guard.ts";
@@ -124,11 +125,13 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
124
125
  const exec: Exec = async (command, args, options) =>
125
126
  timeoutAsFailure(await pi.exec(command, args, options));
126
127
  registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
128
+ const excused = createExcusedMessages();
127
129
  registerCommitGuard({
128
130
  pi,
129
131
  state,
130
132
  jev: (ctx) => jevHolder.forContext(ctx),
131
133
  exec,
134
+ excused,
132
135
  });
133
136
  registerCiCommand({
134
137
  pi,
@@ -141,6 +144,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
141
144
  approvals,
142
145
  jev: (ctx) => jevHolder.forContext(ctx),
143
146
  exec,
147
+ excused,
144
148
  });
145
149
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
146
150
  registerTestEvidence({ pi, state });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -117,7 +117,7 @@ export const DATA_ONLY = new Set([
117
117
  "head",
118
118
  "tail",
119
119
  ]);
120
- export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
120
+ export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]);
121
121
  export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
122
122
  export const GLOBAL_WITH_VALUE = new Set([
123
123
  "-c",
@@ -128,7 +128,72 @@ export const GLOBAL_WITH_VALUE = new Set([
128
128
  "--config-env",
129
129
  "--super-prefix",
130
130
  ]);
131
- const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
131
+
132
+ /** Assignments that switch git hooks off (lefthook, husky), however the value is spelled. */
133
+ const skipsHooks = (word: string): boolean =>
134
+ /^(LEFTHOOK|HUSKY)=(0|false|no|off)$/i.test(word) ||
135
+ /^(LEFTHOOK_EXCLUDE|HUSKY_SKIP_HOOKS)=.+/i.test(word);
136
+ /** Assignments that feed git configuration (aliases, hooks path, push targets) from the environment. */
137
+ const setsGitConfig = (word: string): boolean =>
138
+ /^GIT_CONFIG_[A-Z_0-9]*=/.test(word) ||
139
+ /^(HOME|XDG_CONFIG_HOME)=/.test(word) || // git reads its global config from there
140
+ (/^(GIT_(SSH|SSH_COMMAND|EDITOR|SEQUENCE_EDITOR|PAGER|ASKPASS|EXTERNAL_DIFF|PROXY_COMMAND|EXEC_PATH)|EDITOR|VISUAL|PAGER)=/.test(
141
+ word,
142
+ ) &&
143
+ !isBenignProgram(word.slice(word.indexOf("=") + 1)));
144
+ /** A bare program name (`true`, `cat`, `less`) that is neither git, a shell nor an interpreter: it cannot run a push. */
145
+ const isBenignProgram = (value: string): boolean =>
146
+ /^[\w.+-]+$/.test(value) && !isCommandWord(value);
147
+ /** Builtins that set variables for the commands after them. */
148
+ const ENV_SETTERS = new Set(["export", "declare", "typeset", "readonly", "local"]);
149
+ /** Commands that run another command (or a shell script) given as their arguments. */
150
+ const RUNNERS = new Set([
151
+ "timeout",
152
+ "script",
153
+ "busybox",
154
+ "setsid",
155
+ "stdbuf",
156
+ "ionice",
157
+ "flock",
158
+ "doas",
159
+ "su",
160
+ "runuser",
161
+ "xvfb-run",
162
+ "unbuffer",
163
+ "strace",
164
+ "ltrace",
165
+ "taskset",
166
+ "chrt",
167
+ "nsenter",
168
+ "chroot",
169
+ "bwrap",
170
+ "firejail",
171
+ ]);
172
+ const INTERPRETERS = /^(python[\d.]*|node(js)?|ruby|perl|php|deno|bun|lua|tclsh|osascript)$/;
173
+ const INLINE_CODE_FLAG = /^(-[A-Za-z]*[cepEr]|--eval|--print|eval)$/;
174
+ /** Paths a program reads its standard input through. */
175
+ const STDIN_PATH = /^(-|\/dev\/stdin|\/dev\/fd\/\d+|\/proc\/(self|\d+)\/fd\/\d+)$/;
176
+ const INFO_FLAG = /^(-[Vh]|--version|--help)$/;
177
+ /** `node -v`, `ruby -v`, `perl -v` print a version; for python `-v` is verbose and still reads stdin. */
178
+ const VERSION_FLAG = /^-v$/;
179
+
180
+ /** Every subcommand git itself provides; any other word is an alias or an external `git-<word>`. */
181
+ export const GIT_SUBCOMMANDS = new Set(
182
+ `add am annotate apply archive bisect blame branch bundle cat-file check-attr check-ignore
183
+ check-ref-format checkout checkout-index cherry cherry-pick citool clean clone column commit
184
+ commit-graph commit-tree config count-objects credential describe diff diff-files diff-index
185
+ diff-tree difftool fast-export fast-import fetch fetch-pack filter-branch filter-repo
186
+ for-each-ref for-each-repo format-patch fsck gc gitk grep gui hash-object help hook index-pack
187
+ init instaweb interpret-trailers lfs log ls-files ls-remote ls-tree maintenance merge
188
+ merge-base merge-file merge-index merge-tree mergetool mktag mktree mv name-rev notes pack-objects
189
+ pack-refs patch-id prune prune-packed pull push range-diff read-tree rebase reflog remote repack
190
+ replace request-pull rerere reset restore rev-list rev-parse revert rm scalar send-email
191
+ shortlog show show-branch show-index show-ref sparse-checkout stash status stripspace submodule
192
+ switch symbolic-ref tag unpack-file unpack-objects update-index update-ref update-server-info
193
+ var verify-commit verify-pack verify-tag version whatchanged worktree write-tree bugreport diagnose
194
+ multi-pack-index refs repo mailinfo mailsplit upload-pack receive-pack upload-archive daemon
195
+ get-tar-commit-id difftool--helper maintenance backfill`.split(/\s+/),
196
+ );
132
197
 
133
198
  export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
134
199
  export const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
@@ -139,9 +204,76 @@ const isLong = (arg: string, ...names: string[]): boolean => {
139
204
  return key.length >= 4 && key.startsWith("--") && names.some((n) => n.startsWith(key));
140
205
  };
141
206
 
207
+ /** Keys whose value git runs as a command: setting one makes a later, harmless-looking command run anything. */
208
+ const RUNS_COMMAND =
209
+ /^(core\.(sshcommand|editor|pager|fsmonitor|askpass)|diff\.external|sequence\.editor|credential(\..+)?\.helper|gpg(\..+)?\.program|filter\..+\.(clean|smudge|process)|diff\..+\.(textconv|command)|merge\..+\.driver|(merge|diff)tool\..+\.cmd|pager\..+|uploadpack\.packobjectshook|url\..+\.(insteadof|pushinsteadof))$/;
210
+
211
+ /** What setting this configuration key can do to a later git command. */
212
+ function configKeyIntent(key: string, value?: string): GitIntent {
213
+ const k = key.toLowerCase();
214
+ if (k === "core.hookspath") return "no-verify";
215
+ const runsCommand = RUNS_COMMAND.test(k) && !(value !== undefined && isBenignProgram(value));
216
+ const redirects =
217
+ k.startsWith("alias.") ||
218
+ k.startsWith("include.") ||
219
+ k.startsWith("includeif.") ||
220
+ runsCommand ||
221
+ /^remote\..+\.(push|mirror|pushurl)$/.test(k) ||
222
+ k === "push.default";
223
+ return redirects ? "unknown" : "ordinary";
224
+ }
225
+
226
+ const READS_CONFIG = /^--(get|get-all|get-regexp|list|unset|unset-all)$|^-l$/;
227
+
228
+ /** `git config` options that take a value: `--comment --get` is a comment, not a lookup. */
229
+ const CONFIG_VALUE_OPTIONS = new Set([
230
+ "--comment",
231
+ "-f",
232
+ "--file",
233
+ "--blob",
234
+ "--type",
235
+ "--default",
236
+ "--value",
237
+ ]);
238
+
239
+ /** The words with each value-taking option's value removed. */
240
+ const withoutOptionValues = (args: string[]): string[] =>
241
+ args.filter((_, k) => !CONFIG_VALUE_OPTIONS.has(args[k - 1] ?? ""));
242
+
243
+ function classifyConfig(args: string[]): GitIntent {
244
+ // `git config get|list|unset ...` (newer spelling) never sets a value.
245
+ if (["get", "list", "unset"].includes(args[0] ?? "")) return "ordinary";
246
+ // git stops reading options at the first plain word: `config k v --get` still writes k.
247
+ const options = args.slice(
248
+ 0,
249
+ Math.max(
250
+ 0,
251
+ args.findIndex((a) => !a.startsWith("-")),
252
+ ),
253
+ );
254
+ const leading = args.findIndex((a) => !a.startsWith("-")) < 0 ? args : options;
255
+ if (withoutOptionValues(leading).some((a) => READS_CONFIG.test(a))) return "ordinary";
256
+ const plain = withoutOptionValues(args).filter((a) => !a.startsWith("-"));
257
+ // A key with no value is a lookup (`git config core.hooksPath`), not a write.
258
+ if (plain.length < 2) return "ordinary";
259
+ return plain.reduce<GitIntent>(
260
+ (acc, a, k) => worst(acc, configKeyIntent(a, plain[k + 1])),
261
+ "ordinary",
262
+ );
263
+ }
264
+
142
265
  function classifyGitArgs(globals: string[], sub: string | undefined, args: string[]): GitIntent {
143
266
  let intent: GitIntent = "ordinary";
144
- if (globals.some((g) => /^core\.hookspath=/i.test(g))) intent = worst(intent, "no-verify");
267
+ for (const g of globals) {
268
+ const eq = g.indexOf("=");
269
+ intent = worst(
270
+ intent,
271
+ configKeyIntent(eq < 0 ? g : g.slice(0, eq), eq < 0 ? undefined : g.slice(eq + 1)),
272
+ );
273
+ }
274
+ if (sub !== undefined && !sub.startsWith("$") && !GIT_SUBCOMMANDS.has(sub)) {
275
+ intent = worst(intent, "unknown");
276
+ }
145
277
  if (args.some((a) => isLong(a, "--no-verify"))) intent = worst(intent, "no-verify");
146
278
  switch (sub) {
147
279
  case "push": {
@@ -156,7 +288,7 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
156
288
  return worst(intent, "force-push");
157
289
  }
158
290
  if (
159
- args.some((a) => isLong(a, "--delete")) ||
291
+ args.some((a) => isLong(a, "--delete", "--prune")) ||
160
292
  shortFlags.includes("-d") ||
161
293
  args.some((a) => /^:\S/.test(a))
162
294
  ) {
@@ -172,6 +304,11 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
172
304
  return args.some((a) => ["--abort", "--continue", "--skip", "--quit"].includes(a))
173
305
  ? intent
174
306
  : worst(intent, "history-rewrite");
307
+ case "config":
308
+ return worst(intent, classifyConfig(args));
309
+ case "remote":
310
+ // `remote add --mirror=push` / `set-url --push` make a later plain push mirror or redirect.
311
+ return args.some((a) => /^--(mirror|push)/.test(a)) ? worst(intent, "unknown") : intent;
175
312
  case "filter-branch":
176
313
  case "filter-repo":
177
314
  return worst(intent, "history-rewrite");
@@ -183,21 +320,27 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
183
320
  }
184
321
 
185
322
  /** Skips leading `NAME=value` words, wrappers and shell keywords; `index` is the command word (-1: none). */
186
- function skipPrefix(tokens: string[]): { index: number; hookSkip: boolean } {
187
- let hookSkip = false;
323
+ function skipPrefix(tokens: string[]): { index: number; env: GitIntent } {
324
+ let env: GitIntent = "ordinary";
188
325
  let index = 0;
189
326
  for (;;) {
190
327
  const t = tokens[index];
191
- if (t === undefined) return { index: -1, hookSkip };
192
- if (isAssignment(t)) {
193
- if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
194
- } else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) {
195
- return { index, hookSkip };
196
- }
328
+ if (t === undefined) return { index: -1, env };
329
+ if (isAssignment(t)) env = worst(env, assignmentIntent(t));
330
+ else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) return { index, env };
197
331
  index++;
198
332
  }
199
333
  }
200
334
 
335
+ /** What one `NAME=value` word does to the git commands that run with it. */
336
+ function assignmentIntent(word: string): GitIntent {
337
+ if (skipsHooks(word)) return "no-verify";
338
+ return setsGitConfig(word) ? "unknown" : "ordinary";
339
+ }
340
+
341
+ /** `--config-env=key=VAR`: the value is a variable's name, whatever that variable holds; only the key is known. */
342
+ const envConfigKey = (pair: string): string => pair.split("=")[0] ?? "";
343
+
201
344
  /** Git's global options (`-c k=v`, `-C dir`, flags) before the subcommand. */
202
345
  function splitGlobals(rest: string[]): {
203
346
  globals: string[];
@@ -208,8 +351,12 @@ function splitGlobals(rest: string[]): {
208
351
  let j = 0;
209
352
  while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
210
353
  const opt = rest[j] ?? "";
211
- if (GLOBAL_WITH_VALUE.has(opt)) {
212
- globals.push(rest[j + 1] ?? "");
354
+ if (opt.startsWith("--config-env=")) {
355
+ globals.push(envConfigKey(opt.slice("--config-env=".length)));
356
+ j++;
357
+ } else if (GLOBAL_WITH_VALUE.has(opt)) {
358
+ const value = rest[j + 1] ?? "";
359
+ globals.push(opt === "--config-env" ? envConfigKey(value) : value);
213
360
  j += 2;
214
361
  } else {
215
362
  j++;
@@ -218,28 +365,132 @@ function splitGlobals(rest: string[]): {
218
365
  return { globals, sub: rest[j], args: rest.slice(j + 1) };
219
366
  }
220
367
 
368
+ /** A shell started without a script to run reads its commands from stdin, which cannot be seen. */
369
+ function classifyShell(rest: string[]): GitIntent {
370
+ const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
371
+ const script = c >= 0 ? rest[c + 1] : undefined;
372
+ if (script !== undefined) return classifyGitCommand(script);
373
+ return readsStdin(rest) ? "unknown" : "ordinary";
374
+ }
375
+
376
+ /** No script file among the arguments (or `-s`, or a stdin path): the program reads its code from stdin. */
377
+ const readsStdin = (rest: string[]): boolean =>
378
+ rest.includes("-s") ||
379
+ rest.some((t) => STDIN_PATH.test(t)) ||
380
+ rest.every((t) => t.startsWith("-"));
381
+
382
+ /** A word that starts a command worth reading: git, a shell, or an interpreter that may run either. */
383
+ const isCommandWord = (word: string): boolean => {
384
+ const base = basename(word);
385
+ return base === "git" || SHELLS.has(base) || INTERPRETERS.test(base);
386
+ };
387
+
388
+ /** `timeout 5 bash -c '…'`, `script -qc '…'`: the wrapped command is a later word or a script string. */
389
+ function classifyRunner(rest: string[]): GitIntent {
390
+ let intent: GitIntent = "ordinary";
391
+ for (const [k, word] of rest.entries()) {
392
+ // Everything after the command word is that command's own arguments, not more commands.
393
+ if (isCommandWord(word)) {
394
+ return worst(intent, classifySegment(rest.slice(k)));
395
+ }
396
+ if (isAssignment(word)) intent = worst(intent, assignmentIntent(word));
397
+ else if (/\s/.test(word)) intent = worst(intent, classifyGitCommand(word));
398
+ }
399
+ return intent;
400
+ }
401
+
402
+ /** `python3 -c '…git…'`: code that may run git by any means cannot be read. */
403
+ const interpreterRunsGit = (rest: string[], isPython: boolean): boolean => {
404
+ const inline = rest.some((t) => INLINE_CODE_FLAG.test(t));
405
+ // Inline code that mentions git, or a script read from stdin (pipe, heredoc): either can run any git.
406
+ if (inline) return rest.some((t) => /\bgit\b/.test(t) && !t.startsWith("-"));
407
+ if (rest.some((t) => INFO_FLAG.test(t) || (VERSION_FLAG.test(t) && !isPython))) return false;
408
+ // `node --test` is a runner, not stdin; flags alone (`python3 -u`) still read it, so does a stdin path.
409
+ const runs = rest.some((t) => /^--(test|run|check|watch)\b/.test(t));
410
+ return !runs && (rest.every((t) => t.startsWith("-")) || rest.some((t) => STDIN_PATH.test(t)));
411
+ };
412
+
413
+ /** `export LEFTHOOK=0`: the assignment outlives the command, so the commands after it skip hooks. */
414
+ const classifyExport = (rest: string[]): GitIntent =>
415
+ rest.reduce<GitIntent>(
416
+ (acc, t) => worst(acc, isAssignment(t) ? assignmentIntent(t) : "ordinary"),
417
+ "ordinary",
418
+ );
419
+
420
+ /** The command line `env -S` / `--split-string` carries, if there is one. */
421
+ function splitStringValue(rest: string[]): string | undefined {
422
+ for (const [k, t] of rest.entries()) {
423
+ if (t === "-S" || t === "--split-string") return rest[k + 1];
424
+ if (t.startsWith("--split-string=")) return t.slice("--split-string=".length);
425
+ if (/^-S./.test(t)) return t.slice(2);
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ /** A command that is not git, a shell or an assignment: it may still wrap or script a git command. */
431
+ function classifyOtherCommand(base: string, rest: string[]): GitIntent {
432
+ if (DATA_ONLY.has(base)) return "ordinary";
433
+ if (RUNNERS.has(base)) return classifyRunner(rest);
434
+ if (INTERPRETERS.test(base) && interpreterRunsGit(rest, base.startsWith("python")))
435
+ return "unknown";
436
+ // `env -S "git push -f"` splits its value into a command line.
437
+ const split = splitStringValue([base, ...rest]);
438
+ if (split !== undefined) return classifyGitCommand(split);
439
+ // Wrapper with options (`sudo -E bash -c ...`, `env -i git ...`): classify from the git or shell token.
440
+ // Only an option-shaped head means a skipped wrapper's options (`command -v node` is a lookup);
441
+ // otherwise just a literal `git` among the words counts (`brew install node` runs nothing).
442
+ const wrapperOptions = base.startsWith("-") && !/^-[vV]$/.test(base);
443
+ const at = wrapperOptions
444
+ ? rest.findIndex(isCommandWord)
445
+ : rest.findIndex((t) => basename(t) === "git");
446
+ if (at < 0) return "ordinary";
447
+ // `apt install git curl`, `brew install git gh`: a `git` argument followed by another package name is data.
448
+ const after = rest[at + 1];
449
+ if (!wrapperOptions && after !== undefined && !/^[-$]/.test(after) && !GIT_SUBCOMMANDS.has(after))
450
+ return "ordinary";
451
+ // `env -i LEFTHOOK=0 git commit`: assignments among the wrapper's own words reach the command too.
452
+ const env = rest
453
+ .slice(0, at)
454
+ .reduce<GitIntent>(
455
+ (acc, t) => worst(acc, isAssignment(t) ? assignmentIntent(t) : "ordinary"),
456
+ "ordinary",
457
+ );
458
+ return worst(env, classifySegment(rest.slice(at)));
459
+ }
460
+
461
+ /** Whether a command can start git itself (a runner, an interpreter, a wrapper's options, or a git/shell word). */
462
+ const runsCommands = (base: string, rest: string[]): boolean =>
463
+ RUNNERS.has(base) ||
464
+ INTERPRETERS.test(base) ||
465
+ base.startsWith("-") ||
466
+ rest.some((t) => basename(t) === "git" || SHELLS.has(basename(t)));
467
+
221
468
  // pi-lens-ignore: high-fan-out -- the classifier dispatches one git word to many small predicates; it is a table, not coordination
222
469
  function classifySegment(tokens: string[]): GitIntent {
223
- const { index: i, hookSkip } = skipPrefix(tokens);
224
- if (i < 0) return "ordinary";
470
+ const { index: i, env } = skipPrefix(tokens);
471
+ if (i < 0) return env;
225
472
  const head = tokens[i] ?? "";
226
473
  const rest = tokens.slice(i + 1);
227
474
  const base = basename(head);
228
- const withHookSkip = (found: GitIntent): GitIntent =>
229
- hookSkip ? worst(found, "no-verify") : found;
230
- if (head.startsWith("$") || base === "$GIT") return "unknown";
231
- if (SHELLS.has(base)) {
232
- const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
233
- const script = c >= 0 ? rest[c + 1] : undefined;
234
- return script !== undefined ? classifyGitCommand(script) : "ordinary";
475
+ const withEnv = (found: GitIntent): GitIntent => worst(found, env);
476
+ if (head.startsWith("$") || head.includes("`") || base === "$GIT") return "unknown";
477
+ if (SHELLS.has(base)) return withEnv(classifyShell(rest));
478
+ if (base === "eval") return withEnv(classifyGitCommand(rest.join(" ")));
479
+ if (ENV_SETTERS.has(base)) return withEnv(classifyExport(rest));
480
+ if (OPAQUE.has(base)) {
481
+ return rest.some((t) => isCommandWord(t) || (/\s/.test(t) && /\bgit\b/.test(t)))
482
+ ? "unknown"
483
+ : "ordinary";
484
+ }
485
+ // `source /dev/stdin <<< '…'`: sourcing standard input runs code that cannot be read.
486
+ if ((base === "source" || base === ".") && rest.some((t) => STDIN_PATH.test(t))) return "unknown";
487
+ if (base.startsWith("git-") && GIT_SUBCOMMANDS.has(base.slice(4))) {
488
+ return withEnv(classifyGitArgs([], base.slice(4), rest));
235
489
  }
236
- if (base === "eval") return classifyGitCommand(rest.join(" "));
237
- if (OPAQUE.has(base)) return rest.some((t) => basename(t) === "git") ? "unknown" : "ordinary";
238
490
  if (base !== "git") {
239
- if (DATA_ONLY.has(base)) return "ordinary";
240
- // Wrapper with options (`sudo -E git ...`, `timeout 5 git ...`): classify from the git token.
241
- const at = rest.findIndex((t) => basename(t) === "git");
242
- return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
491
+ const found = classifyOtherCommand(base, rest);
492
+ // `HUSKY=0 npm ci` skips a hook installer, not a commit hook: the assignment counts only for commands that can run git.
493
+ return runsCommands(base, rest) ? withEnv(found) : found;
243
494
  }
244
495
  const { globals, sub, args } = splitGlobals(rest);
245
496
  const dynamic =
@@ -247,7 +498,7 @@ function classifySegment(tokens: string[]): GitIntent {
247
498
  (["push", "reset", "rebase"].includes(sub ?? "") &&
248
499
  args.some((t) => t.startsWith("$") || t.includes("`")));
249
500
  const intent = classifyGitArgs(globals, sub, args);
250
- return withHookSkip(dynamic ? worst(intent, "unknown") : intent);
501
+ return withEnv(dynamic ? worst(intent, "unknown") : intent);
251
502
  }
252
503
 
253
504
  /**
@@ -316,19 +567,52 @@ export function heredocDelimiter(line: string): string | undefined {
316
567
  return undefined;
317
568
  }
318
569
 
319
- /** Classifies the most severe git intent in a shell command (deterministic fast path). */
570
+ /** The body of the heredoc that `delimiter` opened on line `from` of `lines` (empty when unterminated). */
571
+ function heredocBody(lines: readonly string[], from: number, delimiter: string): string {
572
+ const body: string[] = [];
573
+ for (const line of lines.slice(from + 1)) {
574
+ if (line.trim() === delimiter) break;
575
+ body.push(line);
576
+ }
577
+ return body.join("\n");
578
+ }
579
+
580
+ /**
581
+ * Whether a script read from a heredoc cannot run git. An interpreter's body only has to avoid the word;
582
+ * a shell's must also be plain words, because `g\it`, `gi""t` and `${G}t` build it without spelling it.
583
+ */
584
+ const scriptCannotRunGit = (body: string, shell: boolean): boolean =>
585
+ !/git/i.test(body) && (!shell || /^[\w\s./=:,@%+-]*$/.test(body));
586
+
587
+ /**
588
+ * Classifies the most severe git intent in a shell command (deterministic fast path).
589
+ * A script fed by a heredoc (`python3 - <<'EOF'`) is unreadable only if it may run git: when neither
590
+ * the command line nor the body mentions git at all, it is not held for the user.
591
+ */
320
592
  export function classifyGitCommand(command: string): GitIntent {
321
593
  let intent: GitIntent = "ordinary";
322
594
  let heredocEnd: string | undefined;
323
- for (const line of splitLines(command)) {
595
+ const lines = splitLines(command);
596
+ for (const [index, line] of lines.entries()) {
324
597
  if (heredocEnd !== undefined) {
325
598
  if (line.trim() === heredocEnd) heredocEnd = undefined;
326
599
  continue;
327
600
  }
328
- for (const segment of segments(line)) intent = worst(intent, classifySegment(segment));
601
+ const delimiter = heredocDelimiter(line);
602
+ let lineIntent: GitIntent = "ordinary";
603
+ for (const segment of segments(line)) lineIntent = worst(lineIntent, classifySegment(segment));
329
604
  for (const body of substitutions(live(line, false)))
330
- intent = worst(intent, classifyGitCommand(body));
331
- heredocEnd = heredocDelimiter(line) ?? heredocEnd;
605
+ lineIntent = worst(lineIntent, classifyGitCommand(body));
606
+ const gitFree =
607
+ delimiter !== undefined &&
608
+ !/git/i.test(line) &&
609
+ !/[$`]/.test(line) && // `$CMD <<EOF` runs whatever the variable holds
610
+ scriptCannotRunGit(
611
+ heredocBody(lines, index, delimiter),
612
+ segments(line).some((seg) => seg.some((t) => SHELLS.has(basename(t)))),
613
+ );
614
+ intent = worst(intent, lineIntent === "unknown" && gitFree ? "ordinary" : lineIntent);
615
+ heredocEnd = delimiter ?? heredocEnd;
332
616
  }
333
617
  return intent;
334
618
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  basename,
3
3
  DATA_ONLY,
4
+ GIT_SUBCOMMANDS,
4
5
  GLOBAL_WITH_VALUE,
5
6
  heredocDelimiter,
6
7
  isAssignment,
@@ -95,13 +96,33 @@ function visitSegment(tokens: readonly string[], into: Collector): void {
95
96
  else if (OPAQUE.has(base)) {
96
97
  if (gitIndex(rest) >= 0) into.opaque.push(...rest);
97
98
  } else if (base === "git") visitGit(rest, into);
99
+ else if (/^git-[a-z]/.test(base))
100
+ visitGit([base.slice(4), ...rest], into); // /usr/lib/git-core/git-push
98
101
  else visitWrapped(base, rest, into);
99
102
  }
100
103
 
101
- /** Wrapper with options (`sudo -E git …`, `timeout 5 git …`): resolve from the git token. */
104
+ /**
105
+ * Wrapper with options (`sudo -E git …`, `timeout 5 git …`, `timeout 5 bash -c '…'`): resolve from the
106
+ * git or shell token; `env -S 'git push'` carries its command line as one string and is read too.
107
+ */
102
108
  function visitWrapped(base: string, rest: readonly string[], into: Collector): void {
103
- const at = gitIndex(rest);
104
- if (!DATA_ONLY.has(base) && at >= 0) visitSegment(rest.slice(at), into);
109
+ if (DATA_ONLY.has(base)) return;
110
+ const at = rest.findIndex((t) => basename(t) === "git" || SHELLS.has(basename(t)));
111
+ if (at >= 0) visitSegment(rest.slice(at), into);
112
+ else {
113
+ const split = splitString([base, ...rest]);
114
+ if (split !== undefined) merge(into, resolveGit(split));
115
+ }
116
+ }
117
+
118
+ /** The command line `env -S 'git push'` / `--split-string` carries, if there is one. */
119
+ function splitString(rest: readonly string[]): string | undefined {
120
+ for (const [k, t] of rest.entries()) {
121
+ if (t === "-S" || t === "--split-string") return rest[k + 1];
122
+ if (t.startsWith("--split-string=")) return t.slice("--split-string=".length);
123
+ if (/^-S./.test(t)) return t.slice(2);
124
+ }
125
+ return undefined;
105
126
  }
106
127
 
107
128
  function merge(into: Collector, other: GitResolution): void {
@@ -131,6 +152,14 @@ export function resolveGit(command: string): GitResolution {
131
152
  return into;
132
153
  }
133
154
 
155
+ /**
156
+ * An alias defined in this very command (`git -c alias.p=push p`) and then run: it may be any command,
157
+ * a push or a commit included. An alias from the user's own configuration is not this command's doing.
158
+ */
159
+ export const runsDefinedAlias = (command: string, resolution: GitResolution): boolean =>
160
+ /\balias\./i.test(command) &&
161
+ resolution.invocations.some((g) => !(g.sub.startsWith("$") || GIT_SUBCOMMANDS.has(g.sub)));
162
+
134
163
  /** Whether something unreadable in the command may be running `git <subcommand>`. */
135
164
  export const opaqueMentions = (resolution: GitResolution, subcommand: string): boolean =>
136
165
  resolution.opaque.includes(subcommand);
@@ -0,0 +1,10 @@
1
+ import { hasRationaleBody, parseConventionalCommit } from "./commit-message.ts";
2
+
3
+ /** Why a commit message fails the repository's rule (Conventional subject, a body saying why), or undefined. */
4
+ export function messageProblem(message: string): string | undefined {
5
+ const parsed = parseConventionalCommit(message);
6
+ if (!parsed.ok) return parsed.error.message;
7
+ return hasRationaleBody(message)
8
+ ? undefined
9
+ : "the message has no body explaining why the change was made";
10
+ }
@@ -1,14 +1,20 @@
1
- import { opaqueMentions, resolveGit } from "./git-invocations.ts";
1
+ import { opaqueMentions, resolveGit, runsDefinedAlias } from "./git-invocations.ts";
2
2
 
3
3
  export type PushTarget = {
4
4
  readonly remote: string | undefined;
5
5
  /** Destination branch names named by refspecs; empty means "the current branch's default". */
6
6
  readonly branches: readonly string[];
7
+ /** Local refs the refspecs push (`HEAD` in `HEAD:feature`); empty means the current branch. */
8
+ readonly sources: readonly string[];
7
9
  readonly allBranches: boolean;
8
10
  /** The refspecs name `HEAD`, so the current branch is pushed too. */
9
11
  readonly usesHead: boolean;
10
12
  /** `--tags` with no refspec: only tags are pushed, no branch. */
11
13
  readonly tagsOnly: boolean;
14
+ /** `--tags` beside named refspecs: every tag goes too, and a tag may sit on a commit no named branch has. */
15
+ readonly withTags?: boolean;
16
+ /** Every refspec deletes a ref (`--delete`, `:branch`): nothing is sent, so no commit is published. */
17
+ readonly deleteOnly: boolean;
12
18
  /** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
13
19
  readonly dir?: string | undefined;
14
20
  };
@@ -20,6 +26,11 @@ const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
20
26
  const isDryRun = (args: readonly string[]): boolean =>
21
27
  args.some((a) => a === "--dry-run" || a === "-n" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a));
22
28
 
29
+ const source = (refspec: string): string =>
30
+ refspec.includes(":")
31
+ ? refspec.slice(0, refspec.indexOf(":")).replace(/^\+/, "")
32
+ : refspec.replace(/^\+/, "");
33
+
23
34
  const destination = (refspec: string): string => {
24
35
  const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
25
36
  return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
@@ -29,6 +40,7 @@ type ParsedArgs = {
29
40
  positional: string[];
30
41
  all: boolean;
31
42
  tags: boolean;
43
+ deleting: boolean;
32
44
  repo: string | undefined;
33
45
  };
34
46
 
@@ -38,11 +50,18 @@ function applyFlag(parsed: ParsedArgs, a: string): void {
38
50
  if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
39
51
  else if (a === "--all" || a === "--mirror") parsed.all = true;
40
52
  else if (a === "--tags") parsed.tags = true;
53
+ else if (a === "--delete" || a === "-d") parsed.deleting = true;
41
54
  else if (!a.startsWith("-")) parsed.positional.push(a);
42
55
  }
43
56
 
44
57
  function positionalArgs(args: readonly string[]): ParsedArgs {
45
- const parsed: ParsedArgs = { positional: [], all: false, tags: false, repo: undefined };
58
+ const parsed: ParsedArgs = {
59
+ positional: [],
60
+ all: false,
61
+ tags: false,
62
+ deleting: false,
63
+ repo: undefined,
64
+ };
46
65
  let valueFor: "repo" | "skip" | undefined;
47
66
  for (const a of args) {
48
67
  if (valueFor !== undefined) {
@@ -56,7 +75,7 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
56
75
  }
57
76
 
58
77
  const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
59
- const { positional, all, tags, repo } = positionalArgs(args);
78
+ const { positional, all, tags, deleting, repo } = positionalArgs(args);
60
79
  // With --repo, every positional argument is a refspec.
61
80
  const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
62
81
  const destinations = refspecs.map(destination);
@@ -64,9 +83,23 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
64
83
  return {
65
84
  remote,
66
85
  branches: destinations.filter(named),
67
- allBranches: all || destinations.some(isDynamic),
86
+ // A source that starts with "-" would be read as an option by `git log`; it is never a ref name.
87
+ sources: refspecs.map(source).filter((r) => r !== "" && !isDynamic(r) && !r.startsWith("-")),
88
+ // A destination or a source built by the shell (`$SHA:refs/heads/x`) could be any ref: check them all.
89
+ // `:` and `+:` push every branch that exists on both sides (the "matching" refspec).
90
+ allBranches:
91
+ all ||
92
+ destinations.some(isDynamic) ||
93
+ refspecs.map(source).some(isDynamic) ||
94
+ refspecs.some((r) => r.replace(/^\+/, "") === ":"),
68
95
  usesHead: destinations.includes("HEAD"),
69
96
  tagsOnly: tags && refspecs.length === 0 && !all,
97
+ // `--tags` sends every tag as well, so a push with it deletes nothing only.
98
+ deleteOnly:
99
+ refspecs.length > 0 &&
100
+ !tags &&
101
+ (deleting || refspecs.every((r) => r.startsWith(":") && r !== ":")),
102
+ ...(tags && refspecs.length > 0 && !all ? { withTags: true } : {}),
70
103
  ...(dir === undefined ? {} : { dir }),
71
104
  };
72
105
  };
@@ -77,17 +110,19 @@ export function pushTargets(command: string): PushTarget[] {
77
110
  const targets = resolution.invocations.flatMap((g) =>
78
111
  g.sub === "push" && !isDryRun(g.args) ? [toTarget(g.args, g.dir)] : [],
79
112
  );
80
- // git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
81
- const opaquePush = opaqueMentions(resolution, "push");
113
+ // git run through something opaque (xargs, `$CMD`, an alias): a push cannot be ruled out, so assume the worst.
114
+ const opaquePush = opaqueMentions(resolution, "push") || runsDefinedAlias(command, resolution);
82
115
  return opaquePush
83
116
  ? [
84
117
  ...targets,
85
118
  {
86
119
  remote: undefined,
87
120
  branches: [],
121
+ sources: [],
88
122
  allBranches: true,
89
123
  usesHead: false,
90
124
  tagsOnly: false,
125
+ deleteOnly: false,
91
126
  dir: undefined,
92
127
  },
93
128
  ]
@@ -6,14 +6,10 @@ import {
6
6
  isToolCallEventType,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { type CommitExtraction, extractCommits } from "../core/commit-command.ts";
9
- import {
10
- findForbiddenTrailerKeys,
11
- findForbiddenTrailers,
12
- hasRationaleBody,
13
- parseConventionalCommit,
14
- } from "../core/commit-message.ts";
9
+ import { findForbiddenTrailerKeys, findForbiddenTrailers } from "../core/commit-message.ts";
15
10
  import type { Exec } from "../core/exec.ts";
16
11
  import { resolveGit } from "../core/git-invocations.ts";
12
+ import { messageProblem } from "../core/message-problem.ts";
17
13
  import { DECISION_LOG, reviewGap } from "../core/review-flow.ts";
18
14
  import { type GateId, isParseError, parseGateId } from "../core/types.ts";
19
15
  import type { Jev } from "../jev/client.ts";
@@ -22,12 +18,15 @@ import { judgeCommit, MIX_THRESHOLD, RATIONALE_FLOOR } from "../jev/questions/co
22
18
  import { snapshotDiff } from "../review/digest.ts";
23
19
  import type { SessionState } from "../state/session-state.ts";
24
20
  import { departureUse } from "./departure-use.ts";
21
+ import type { ExcusedMessages } from "./excused-messages.ts";
25
22
 
26
23
  export type CommitGuardDeps = {
27
24
  pi: ExtensionAPI;
28
25
  state: SessionState;
29
26
  jev: (ctx: ExtensionContext) => Jev;
30
27
  exec: Exec;
28
+ /** Messages whose missing rationale a departure excused here, so the push does not ask again. */
29
+ excused?: ExcusedMessages | undefined;
31
30
  };
32
31
 
33
32
  type Need = { gate: GateId; why: string };
@@ -66,11 +65,8 @@ const messageOf = (extracted: CommitExtraction, cwd: string): string | undefined
66
65
 
67
66
  /** Deterministic checks: Conventional subject and a prose rationale body. */
68
67
  function messageNeeds(message: string): Need[] {
69
- const parsed = parseConventionalCommit(message);
70
- if (!parsed.ok) return [{ gate: RATIONALE, why: parsed.error.message }];
71
- return hasRationaleBody(message)
72
- ? []
73
- : [{ gate: RATIONALE, why: "the message has no body explaining why the change was made" }];
68
+ const problem = messageProblem(message);
69
+ return problem === undefined ? [] : [{ gate: RATIONALE, why: problem }];
74
70
  }
75
71
 
76
72
  /** `-a`/`--all`, or a `git add` in the same command, widen the commit beyond what is staged. */
@@ -361,6 +357,12 @@ async function needsOf(
361
357
  return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
362
358
  }
363
359
 
360
+ /** A message let through under a rationale departure is remembered, so its push is not asked again. */
361
+ function rememberExcused(deps: CommitGuardDeps, needs: readonly Need[], messages: string[]): void {
362
+ if (!needs.some((n) => n.gate === RATIONALE)) return;
363
+ for (const message of messages) deps.excused?.add(message);
364
+ }
365
+
364
366
  /** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
365
367
  export function registerCommitGuard(deps: CommitGuardDeps): void {
366
368
  deps.pi.on("tool_call", async (event, ctx) => {
@@ -397,6 +399,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
397
399
  const uncovered = uses.find(({ use }) => !use.hasOpen());
398
400
  if (uncovered !== undefined) return { block: true, reason: blockReason(uncovered.need) };
399
401
  for (const { use } of uses) use.consume();
402
+ rememberExcused(deps, all, known);
400
403
  return undefined;
401
404
  });
402
405
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Commit messages whose missing rationale a recorded departure already excused at `git commit`, so the
3
+ * push that publishes them does not ask for a second departure. In memory: a restart asks again, which
4
+ * only costs a repeat departure.
5
+ */
6
+ export type ExcusedMessages = {
7
+ add(message: string): void;
8
+ has(message: string): boolean;
9
+ };
10
+
11
+ const key = (message: string): string => message.trim();
12
+
13
+ export function createExcusedMessages(): ExcusedMessages {
14
+ const seen = new Set<string>();
15
+ return {
16
+ add: (message) => {
17
+ seen.add(key(message));
18
+ },
19
+ has: (message) => seen.has(key(message)),
20
+ };
21
+ }
@@ -8,6 +8,7 @@ import {
8
8
  import { extractCommits } from "../core/commit-command.ts";
9
9
  import { parseConventionalCommit } from "../core/commit-message.ts";
10
10
  import type { Exec } from "../core/exec.ts";
11
+ import { resolveGit, runsDefinedAlias } from "../core/git-invocations.ts";
11
12
  import { type PushTarget, pushTargets } from "../core/push-command.ts";
12
13
  import { type GateId, isParseError, parseGateId } from "../core/types.ts";
13
14
  import type { Jev } from "../jev/client.ts";
@@ -16,6 +17,9 @@ import { getFailureLog, getTrunkStatus } from "../state/ci.ts";
16
17
  import { type DevsysConfig, loadConfig } from "../state/config.ts";
17
18
  import type { SessionState } from "../state/session-state.ts";
18
19
  import { type ApprovalStore, requestHardStop } from "./approvals.ts";
20
+ import { departureUse } from "./departure-use.ts";
21
+ import type { ExcusedMessages } from "./excused-messages.ts";
22
+ import { type PushMessageFindings, pushMessageFindings } from "./push-messages.ts";
19
23
 
20
24
  export type PushGuardDeps = {
21
25
  pi: ExtensionAPI;
@@ -24,6 +28,8 @@ export type PushGuardDeps = {
24
28
  jev: (ctx: ExtensionContext) => Jev;
25
29
  exec: Exec;
26
30
  now?: () => Date;
31
+ /** Messages `git commit` already excused by a departure; shared with the commit guard. */
32
+ excused?: ExcusedMessages | undefined;
27
33
  };
28
34
 
29
35
  type Verdict = { readonly block: true; readonly reason: string } | undefined;
@@ -36,6 +42,75 @@ const gate = (id: string): GateId => {
36
42
  const FIX_DIFF_LIMIT = 8000;
37
43
  const RED_TRUNK = gate("push.red-trunk");
38
44
  const DELIVERY_MODE = gate("push.delivery-mode");
45
+ const RATIONALE = gate("commit.rationale");
46
+
47
+ const COMMIT_MAKERS = new Set(["cherry-pick", "merge", "revert", "am", "commit-tree"]);
48
+
49
+ /** Flags that stop a commit-maker from writing a commit: a fast-forward, a staged-only result, or an abandoned run. */
50
+ const NO_COMMIT_FLAGS: Readonly<Record<string, readonly string[]>> = {
51
+ merge: ["--ff-only", "--abort", "--quit", "--squash", "--no-commit"],
52
+ "cherry-pick": ["--abort", "--quit", "-n", "--no-commit"],
53
+ revert: ["--abort", "--quit", "-n", "--no-commit"],
54
+ am: ["--abort", "--quit"],
55
+ };
56
+ const makesNoCommit = (g: { readonly sub: string; readonly args: readonly string[] }): boolean =>
57
+ g.args.some((a) => NO_COMMIT_FLAGS[g.sub]?.includes(a) === true);
58
+
59
+ /** A push in the same call as commits whose messages the commit guard never reads (`commit -C`, `cherry-pick`). */
60
+ const createsUnseenCommits = (command: string): boolean => {
61
+ const { invocations } = resolveGit(command);
62
+ return (
63
+ invocations.some((g) => COMMIT_MAKERS.has(g.sub) && !makesNoCommit(g)) ||
64
+ runsDefinedAlias(command, resolveGit(command)) ||
65
+ invocations.some(
66
+ (g) =>
67
+ g.sub === "commit" &&
68
+ g.args.some((a) => expandsAtRunTime(a) && !command.includes(`'${a}'`)),
69
+ ) ||
70
+ extractCommits(command).some((e) => commitIsUnseen(command, e))
71
+ );
72
+ };
73
+
74
+ /** `$(cat <<'EOF' …)` is the message itself (its body is read); any other `$(…)`, backtick or variable is only known once the call runs. */
75
+ const expandsAtRunTime = (arg: string): boolean =>
76
+ /\$\(|`|\$\{?\w/.test(
77
+ arg.replace(/\$\(\s*cat\s+#HD\d+\s*\)/g, "").replace(/\\[\s\S]/g, ""), // an escaped `\`` or `\$` is literal text
78
+ );
79
+
80
+ /** A commit whose message the guard read before the call ran may still change: a `-F` file the same call writes. */
81
+ const commitIsUnseen = (command: string, e: ReturnType<typeof extractCommits>[number]): boolean => {
82
+ if (e.kind === "unknown") return true;
83
+ if (e.kind !== "file") return false;
84
+ // Stdin, or a path the shell expands (`~/msg`), is a message the commit guard never read.
85
+ if (e.path === "-" || /^~|[$`]/.test(e.path)) return true;
86
+ return command.split(e.path).length > 2;
87
+ };
88
+
89
+ const forbiddenVerdict = (found: PushMessageFindings): Verdict =>
90
+ found.forbidden.length === 0
91
+ ? undefined
92
+ : {
93
+ block: true,
94
+ reason:
95
+ `commit.forbidden-trailer: this push would publish a commit that carries an AI attribution (${found.forbidden.join("; ")}). ` +
96
+ "Commits in this repository have no Co-Authored-By or generated-by trailers and this is not something to depart from. " +
97
+ "Remove the trailer from the unpushed commit, then push again.",
98
+ };
99
+
100
+ /**
101
+ * A commit with no rationale needs the departure `git commit` would have asked for. Checked before any
102
+ * approval is asked (a refusal after the user approved would ask them again); spent only once the push
103
+ * is otherwise allowed.
104
+ */
105
+ const rationaleOwed = (deps: PushGuardDeps, found: PushMessageFindings): boolean =>
106
+ found.unexplained !== undefined && !departureUse(deps.state, RATIONALE).hasOpen();
107
+
108
+ const rationaleRefusal = (found: PushMessageFindings): Verdict => ({
109
+ block: true,
110
+ reason:
111
+ `commit.rationale: this push would publish a commit that fails the message rule (${found.unexplained}). ` +
112
+ "Improve the unpushed message, or record a departure with devsys_record_departure (gate commit.rationale) and push again.",
113
+ });
39
114
 
40
115
  async function currentBranch(exec: Exec, cwd: string): Promise<string | undefined> {
41
116
  try {
@@ -147,6 +222,48 @@ async function hardStop(deps: PushGuardDeps, stop: HardStop): Promise<Verdict> {
147
222
  };
148
223
  }
149
224
 
225
+ type PushCall = { readonly toolCallId: string; readonly input: { readonly command: string } };
226
+
227
+ /** Delivery-mode and red-trunk hard stops for a push onto the trunk. */
228
+ async function trunkVerdict(
229
+ deps: PushGuardDeps,
230
+ ctx: ExtensionContext,
231
+ event: PushCall,
232
+ config: DevsysConfig,
233
+ targets: readonly PushTarget[],
234
+ ): Promise<Verdict> {
235
+ const { mode, trunk } = config.delivery;
236
+ if (!(await pushesTrunk(deps.exec, ctx.cwd, targets, trunk))) return undefined;
237
+ const command = event.input.command;
238
+ if (mode === "pull-request") {
239
+ return hardStop(deps, {
240
+ ctx,
241
+ toolCallId: event.toolCallId,
242
+ command,
243
+ gate: DELIVERY_MODE,
244
+ why: `delivery.mode is "pull-request" but this pushes ${trunk}`,
245
+ costIfWrong: "unreviewed work lands directly on the trunk",
246
+ });
247
+ }
248
+ const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
249
+ if (status.status !== "red") return undefined;
250
+ if (extractCommits(command).length > 0) {
251
+ return {
252
+ block: true,
253
+ reason: `CI on ${trunk} is red and this call commits and pushes together, so the push cannot be judged as a fix for it. Commit first in one call, then push in the next.`,
254
+ };
255
+ }
256
+ if (await repairsRedTrunk(deps, ctx, config, status.runId)) return undefined;
257
+ return hardStop(deps, {
258
+ ctx,
259
+ toolCallId: event.toolCallId,
260
+ command,
261
+ gate: RED_TRUNK,
262
+ why: `CI on ${trunk} is red (${status.headSha?.slice(0, 7) ?? "unknown sha"}) and this push is not a recognised fix for it`,
263
+ costIfWrong: "unrelated work piles onto a broken build and hides the failure",
264
+ });
265
+ }
266
+
150
267
  /** Delivery-mode and red-trunk hard stops on `git push`; records when a push last succeeded. */
151
268
  export function registerPushGuard(deps: PushGuardDeps): void {
152
269
  deps.pi.on("tool_call", async (event, ctx) => {
@@ -157,42 +274,28 @@ export function registerPushGuard(deps: PushGuardDeps): void {
157
274
  if (!loaded.ok) {
158
275
  return { block: true, reason: `cannot read the delivery policy: ${loaded.error.message}` };
159
276
  }
160
- const { mode, trunk } = loaded.value.delivery;
161
- if (mode === "local-only") {
277
+ if (loaded.value.delivery.mode === "local-only") {
162
278
  return {
163
279
  block: true,
164
280
  reason: `delivery.mode is "local-only" in .development-system.toml: nothing is pushed. Change the mode with the user if this should be published.`,
165
281
  };
166
282
  }
167
- if (!(await pushesTrunk(deps.exec, ctx.cwd, targets, trunk))) return undefined;
168
- const command = event.input.command;
169
- if (mode === "pull-request") {
170
- return hardStop(deps, {
171
- ctx,
172
- toolCallId: event.toolCallId,
173
- command,
174
- gate: DELIVERY_MODE,
175
- why: `delivery.mode is "pull-request" but this pushes ${trunk}`,
176
- costIfWrong: "unreviewed work lands directly on the trunk",
177
- });
178
- }
179
- const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
180
- if (status.status !== "red") return undefined;
181
- if (extractCommits(command).length > 0) {
283
+ if (createsUnseenCommits(event.input.command)) {
182
284
  return {
183
285
  block: true,
184
- reason: `CI on ${trunk} is red and this call commits and pushes together, so the push cannot be judged as a fix for it. Commit first in one call, then push in the next.`,
286
+ reason:
287
+ "this call creates commits (cherry-pick, merge, revert, am, commit-tree or a commit with a message the guard cannot read) and pushes them together, so their messages cannot be checked first. Make the commits in one call, then push in the next.",
185
288
  };
186
289
  }
187
- if (await repairsRedTrunk(deps, ctx, loaded.value, status.runId)) return undefined;
188
- return hardStop(deps, {
189
- ctx,
190
- toolCallId: event.toolCallId,
191
- command,
192
- gate: RED_TRUNK,
193
- why: `CI on ${trunk} is red (${status.headSha?.slice(0, 7) ?? "unknown sha"}) and this push is not a recognised fix for it`,
194
- costIfWrong: "unrelated work piles onto a broken build and hides the failure",
195
- });
290
+ // Read-only first: an AI trailer blocks before any approval or departure is spent.
291
+ const found = await pushMessageFindings(deps.exec, ctx.cwd, targets, deps.excused);
292
+ const refused = found === undefined ? undefined : forbiddenVerdict(found);
293
+ if (refused !== undefined) return refused;
294
+ if (found !== undefined && rationaleOwed(deps, found)) return rationaleRefusal(found);
295
+ const stopped = await trunkVerdict(deps, ctx, event, loaded.value, targets);
296
+ if (stopped !== undefined) return stopped;
297
+ if (found?.unexplained !== undefined) departureUse(deps.state, RATIONALE).consume();
298
+ return undefined;
196
299
  });
197
300
 
198
301
  deps.pi.on("tool_result", (event) => {
@@ -0,0 +1,94 @@
1
+ import { resolve } from "node:path";
2
+ import { findForbiddenTrailerKeys, findForbiddenTrailers } from "../core/commit-message.ts";
3
+ import type { Exec } from "../core/exec.ts";
4
+ import { messageProblem } from "../core/message-problem.ts";
5
+ import type { PushTarget } from "../core/push-command.ts";
6
+ import type { ExcusedMessages } from "./excused-messages.ts";
7
+
8
+ type UnpushedCommit = { readonly merge: boolean; readonly message: string };
9
+
10
+ /** What is wrong with the messages of the commits a push would publish. */
11
+ export type PushMessageFindings = {
12
+ /** AI-attribution trailers (non-negotiable 8): never a departure. */
13
+ readonly forbidden: readonly string[];
14
+ /** Why the first non-merge commit needs a `commit.rationale` departure, if one does. */
15
+ readonly unexplained: string | undefined;
16
+ };
17
+
18
+ /** git prints these bytes for `%x1f` / `%x00`; the bytes themselves cannot be passed as an argument. */
19
+ const FIELD = "\u001f";
20
+ const RECORD = "\u0000";
21
+
22
+ /** `git log <ref> --not --remotes`: what that ref has that no remote-tracking branch has (upstream commits pulled into a fork are not this push's to judge). */
23
+ async function unpushedCommits(
24
+ exec: Exec,
25
+ cwd: string,
26
+ ref: string,
27
+ ): Promise<UnpushedCommit[] | undefined> {
28
+ const log = await exec("git", ["log", ref, "--not", "--remotes", "--format=%P%x1f%B%x00", "--"], {
29
+ cwd,
30
+ timeout: 15_000,
31
+ });
32
+ if (log.code !== 0) return undefined; // a ref git cannot resolve: the push itself will fail
33
+ return log.stdout
34
+ .split(RECORD)
35
+ .filter((record) => record.trim() !== "")
36
+ .map((record) => {
37
+ const [parents = "", ...message] = record.replace(/^\n/, "").split(FIELD);
38
+ return { merge: parents.trim().split(/\s+/).length > 1, message: message.join(FIELD) };
39
+ });
40
+ }
41
+
42
+ /** No remote-tracking branch at all (a first push): every commit looks unpushed, so only AI trailers are judged, never the rationale. */
43
+ async function knowsRemotes(exec: Exec, cwd: string): Promise<boolean> {
44
+ const refs = await exec("git", ["for-each-ref", "--count=1", "refs/remotes"], {
45
+ cwd,
46
+ timeout: 5000,
47
+ });
48
+ return refs.code === 0 && refs.stdout.trim() !== "";
49
+ }
50
+
51
+ /** The refs a push sends: the named branches, HEAD for a bare push, every branch for `--all`. */
52
+ const refsOf = (t: PushTarget): string[] => {
53
+ if (t.allBranches) return ["--branches"];
54
+ if (t.tagsOnly) return ["--tags"];
55
+ const named = t.sources.length === 0 ? ["HEAD"] : [...t.sources];
56
+ return t.withTags === true ? [...named, "--tags"] : named;
57
+ };
58
+
59
+ /**
60
+ * The messages of the commits the push would publish (what each target's refs have that its remote
61
+ * lacks), however each was made: `-C`, `--fixup`, `commit-tree`, `merge -m`, an alias. Read-only.
62
+ * Undefined when git cannot say (unknown ref). With no remote-tracking branch yet, only trailers are judged.
63
+ */
64
+ export async function pushMessageFindings(
65
+ exec: Exec,
66
+ cwd: string,
67
+ targets: readonly PushTarget[],
68
+ excused: ExcusedMessages | undefined,
69
+ ): Promise<PushMessageFindings | undefined> {
70
+ const commits: UnpushedCommit[] = [];
71
+ const unjudgedForRationale = new Set<UnpushedCommit>();
72
+ let judged = false;
73
+ for (const t of targets.filter((x) => !x.deleteOnly)) {
74
+ const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
75
+ const firstPush = !(await knowsRemotes(exec, where));
76
+ for (const ref of refsOf(t)) {
77
+ const found = await unpushedCommits(exec, where, ref);
78
+ if (found === undefined) continue;
79
+ judged = true;
80
+ commits.push(...found);
81
+ if (firstPush) for (const c of found) unjudgedForRationale.add(c);
82
+ }
83
+ }
84
+ if (!judged) return undefined;
85
+ const forbidden = commits.flatMap((c) => [
86
+ ...findForbiddenTrailers(c.message),
87
+ ...findForbiddenTrailerKeys(c.message),
88
+ ]);
89
+ const unexplained = commits
90
+ .filter((c) => !(c.merge || unjudgedForRationale.has(c)) && excused?.has(c.message) !== true)
91
+ .map((c) => messageProblem(c.message))
92
+ .find((problem) => problem !== undefined);
93
+ return { forbidden: [...new Set(forbidden)], unexplained };
94
+ }