@bookedsolid/rea 0.11.0 → 0.12.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.
@@ -46,6 +46,18 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
46
46
  * NOT a trust boundary. Do not key security decisions on the return value.
47
47
  */
48
48
  export declare function isGitRepo(baseDir: string): boolean;
49
+ /**
50
+ * Hard-fail when `policy.review.codex_required: true` but the `codex`
51
+ * binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
52
+ * push, by which point the consumer had cloned, run `pnpm install`,
53
+ * authored a commit, and tried to push — only then to learn that they
54
+ * needed a separate install. Fix C of 0.12.0 surfaces it during install.
55
+ *
56
+ * Returns `pass` when codex resolves; `fail` when missing. Operators who
57
+ * want to disable the gate can flip `policy.review.codex_required: false`
58
+ * (the doctor then short-circuits past every Codex check).
59
+ */
60
+ export declare function checkCodexBinaryOnPath(): CheckResult;
49
61
  /**
50
62
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
51
63
  * responsiveness (pass/warn) and one informational line about the last
@@ -423,6 +423,95 @@ function checkCodexCommand(baseDir) {
423
423
  detail: `missing: ${cmdPath}`,
424
424
  };
425
425
  }
426
+ /**
427
+ * Resolve the absolute path of `codex` on PATH (cross-platform). Returns
428
+ * `null` when codex is not installed. We walk `process.env.PATH`
429
+ * directly rather than shelling out — earlier iterations spawned
430
+ * `sh -c "command -v codex"` which gave false negatives in sanitized
431
+ * POSIX environments where `/bin` is omitted from PATH (CI runners,
432
+ * hardened dev shells) but the `codex` binary lives at a project-bin
433
+ * path that IS on PATH. Codex [P2] 2026-04-29.
434
+ *
435
+ * On Windows we iterate `PATHEXT` (default `.COM;.EXE;.BAT;.CMD`) so
436
+ * `codex.cmd` (the typical npm shim) is discovered. POSIX checks the
437
+ * bare name and accepts any file with an execute bit set.
438
+ */
439
+ function resolveCodexBinary() {
440
+ const isWindows = process.platform === 'win32';
441
+ const pathEnv = process.env.PATH ?? process.env.Path ?? '';
442
+ if (pathEnv.length === 0)
443
+ return null;
444
+ const sep = isWindows ? ';' : ':';
445
+ const entries = pathEnv.split(sep).filter((p) => p.length > 0);
446
+ if (isWindows) {
447
+ const pathExt = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
448
+ for (const dir of entries) {
449
+ for (const ext of pathExt) {
450
+ const candidate = path.join(dir, `codex${ext}`);
451
+ try {
452
+ const st = fs.statSync(candidate);
453
+ if (st.isFile())
454
+ return candidate;
455
+ }
456
+ catch {
457
+ // not present in this PATH entry — keep walking
458
+ }
459
+ }
460
+ // also check the bare name in case PATHEXT is unusual
461
+ const bare = path.join(dir, 'codex');
462
+ try {
463
+ const st = fs.statSync(bare);
464
+ if (st.isFile())
465
+ return bare;
466
+ }
467
+ catch {
468
+ // not present — keep walking
469
+ }
470
+ }
471
+ return null;
472
+ }
473
+ // POSIX: check executable bit on the file mode.
474
+ for (const dir of entries) {
475
+ const candidate = path.join(dir, 'codex');
476
+ try {
477
+ const st = fs.statSync(candidate);
478
+ if (st.isFile() && (st.mode & 0o111) !== 0)
479
+ return candidate;
480
+ }
481
+ catch {
482
+ // not present in this PATH entry — keep walking
483
+ }
484
+ }
485
+ return null;
486
+ }
487
+ /**
488
+ * Hard-fail when `policy.review.codex_required: true` but the `codex`
489
+ * binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
490
+ * push, by which point the consumer had cloned, run `pnpm install`,
491
+ * authored a commit, and tried to push — only then to learn that they
492
+ * needed a separate install. Fix C of 0.12.0 surfaces it during install.
493
+ *
494
+ * Returns `pass` when codex resolves; `fail` when missing. Operators who
495
+ * want to disable the gate can flip `policy.review.codex_required: false`
496
+ * (the doctor then short-circuits past every Codex check).
497
+ */
498
+ export function checkCodexBinaryOnPath() {
499
+ const resolved = resolveCodexBinary();
500
+ if (resolved !== null) {
501
+ return {
502
+ label: 'codex CLI on PATH',
503
+ status: 'pass',
504
+ detail: resolved,
505
+ };
506
+ }
507
+ return {
508
+ label: 'codex CLI on PATH',
509
+ status: 'fail',
510
+ detail: 'codex not found on PATH. policy.review.codex_required: true requires the codex binary. ' +
511
+ 'Install: https://github.com/openai/codex (e.g. `npm i -g @openai/codex`). ' +
512
+ 'To disable the push-gate instead, set policy.review.codex_required: false in .rea/policy.yaml.',
513
+ };
514
+ }
426
515
  /**
427
516
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
428
517
  * responsiveness (pass/warn) and one informational line about the last
@@ -521,7 +610,7 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
521
610
  });
522
611
  }
523
612
  if (codexRequiredFromPolicy(baseDir)) {
524
- checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir));
613
+ checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir), checkCodexBinaryOnPath());
525
614
  if (codexProbeState !== undefined) {
526
615
  checks.push(...checksFromProbeState(codexProbeState));
527
616
  }
@@ -31,6 +31,13 @@
31
31
  import type { Command } from 'commander';
32
32
  export interface HookPushGateOptions {
33
33
  base?: string;
34
+ /**
35
+ * Diff against `HEAD~N` instead of running the upstream ladder. Mirrors
36
+ * `policy.review.last_n_commits`; the CLI flag wins when both are set.
37
+ * `--base` always wins over both. Validated as a positive integer; the
38
+ * CLI rejects non-numeric input before reaching `runPushGate`.
39
+ */
40
+ lastNCommits?: number;
34
41
  }
35
42
  /**
36
43
  * Public runner, exposed so integration tests and the commander binding can
package/dist/cli/hook.js CHANGED
@@ -55,6 +55,7 @@ export async function runHookPushGate(options) {
55
55
  stderr,
56
56
  refspecs,
57
57
  ...(options.base !== undefined && options.base.length > 0 ? { explicitBase: options.base } : {}),
58
+ ...(options.lastNCommits !== undefined ? { lastNCommits: options.lastNCommits } : {}),
58
59
  });
59
60
  process.exit(result.exitCode);
60
61
  }
@@ -121,7 +122,17 @@ export function registerHookCommand(program) {
121
122
  .argument('[gitArgs...]', 'positional args forwarded by git (remote name, URL); ignored')
122
123
  .description('Run `codex exec review` against the current diff and block on blocking findings. Exits 0/1/2: pass/HALT/blocked. No cache — every push runs Codex afresh.')
123
124
  .option('--base <ref>', 'explicit base ref to diff against (e.g. origin/main). Defaults to @{upstream} → origin/HEAD → main/master → empty-tree.')
125
+ .option('--last-n-commits <n>', 'narrow review to the last N commits (diff against HEAD~N). Useful for large feature branches. Loses to --base when both are set; mirrors policy.review.last_n_commits.', (raw) => {
126
+ const n = Number(raw);
127
+ if (!Number.isInteger(n) || n <= 0) {
128
+ throw new Error(`--last-n-commits must be a positive integer, got ${JSON.stringify(raw)}`);
129
+ }
130
+ return n;
131
+ })
124
132
  .action(async (_gitArgs, opts) => {
125
- await runHookPushGate({ ...(opts.base !== undefined ? { base: opts.base } : {}) });
133
+ await runHookPushGate({
134
+ ...(opts.base !== undefined ? { base: opts.base } : {}),
135
+ ...(opts.lastNCommits !== undefined ? { lastNCommits: opts.lastNCommits } : {}),
136
+ });
126
137
  });
127
138
  }
@@ -53,19 +53,26 @@
53
53
  * classification. Bump the version suffix whenever the body semantics
54
54
  * change so upgrades can migrate old installs cleanly.
55
55
  *
56
+ * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
57
+ * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
58
+ * that broke pushes from repo paths containing spaces.
56
59
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
57
60
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
58
61
  */
59
- export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v2";
62
+ export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v3";
63
+ /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
64
+ export declare const LEGACY_FALLBACK_MARKER_V2 = "# rea:pre-push-fallback v2";
60
65
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
61
66
  export declare const LEGACY_FALLBACK_MARKER_V1 = "# rea:pre-push-fallback v1";
62
67
  /**
63
68
  * Marker present in the shipped `.husky/pre-push` governance gate. The
64
69
  * second line of the shipped husky hook is this marker — rea upgrade
65
70
  * detects it to refresh in-place. Bump the suffix whenever the body
66
- * changes; pre-0.11 markers live in `LEGACY_HUSKY_GATE_MARKER_V1`.
71
+ * changes; pre-0.12 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2}`.
67
72
  */
68
- export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v2";
73
+ export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v3";
74
+ /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
75
+ export declare const LEGACY_HUSKY_GATE_MARKER_V2 = "# rea:husky-pre-push-gate v2";
69
76
  /** Legacy v1 husky marker for migration. */
70
77
  export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1";
71
78
  /**
@@ -73,7 +80,9 @@ export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1
73
80
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
74
81
  * A real rea hook always carries both markers.
75
82
  */
76
- export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v2";
83
+ export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v3";
84
+ /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
85
+ export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = "# rea:gate-body-v2";
77
86
  /** Legacy body marker — used by upgrade migration detection. */
78
87
  export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V1 = "# rea:gate-body-v1";
79
88
  /** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
@@ -87,10 +96,11 @@ export declare function huskyHookContent(): string;
87
96
  */
88
97
  export declare function isReaManagedFallback(content: string): boolean;
89
98
  /**
90
- * True when `content` is the legacy v1 fallback (`.git/hooks/pre-push`
91
- * that delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea
92
- * upgrade` to migrate we overwrite these unconditionally because we
93
- * control the entire body shape.
99
+ * True when `content` is a legacy fallback hook authored by an earlier rea
100
+ * release: v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x —
101
+ * delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade`
102
+ * to migrate we overwrite these unconditionally because we control the
103
+ * entire body shape.
94
104
  */
95
105
  export declare function isLegacyReaManagedFallback(content: string): boolean;
96
106
  /**
@@ -106,8 +116,9 @@ export declare function isLegacyReaManagedFallback(content: string): boolean;
106
116
  */
107
117
  export declare function isReaManagedHuskyGate(content: string): boolean;
108
118
  /**
109
- * True when `content` is the legacy v1 Husky gate (`.husky/pre-push` from
110
- * 0.10.x and earlier). Used to trigger the upgrade migration.
119
+ * True when `content` is a legacy Husky gate from an earlier rea release:
120
+ * v2 (0.11.x broken `exec $REA_BIN` body) or v1 (0.10.x bash core
121
+ * delegating). Used to trigger the upgrade migration.
111
122
  */
112
123
  export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
113
124
  /**
@@ -65,19 +65,26 @@ const execFileAsync = promisify(execFile);
65
65
  * classification. Bump the version suffix whenever the body semantics
66
66
  * change so upgrades can migrate old installs cleanly.
67
67
  *
68
+ * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
69
+ * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
70
+ * that broke pushes from repo paths containing spaces.
68
71
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
69
72
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
70
73
  */
71
- export const FALLBACK_MARKER = '# rea:pre-push-fallback v2';
74
+ export const FALLBACK_MARKER = '# rea:pre-push-fallback v3';
75
+ /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
76
+ export const LEGACY_FALLBACK_MARKER_V2 = '# rea:pre-push-fallback v2';
72
77
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
73
78
  export const LEGACY_FALLBACK_MARKER_V1 = '# rea:pre-push-fallback v1';
74
79
  /**
75
80
  * Marker present in the shipped `.husky/pre-push` governance gate. The
76
81
  * second line of the shipped husky hook is this marker — rea upgrade
77
82
  * detects it to refresh in-place. Bump the suffix whenever the body
78
- * changes; pre-0.11 markers live in `LEGACY_HUSKY_GATE_MARKER_V1`.
83
+ * changes; pre-0.12 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2}`.
79
84
  */
80
- export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v2';
85
+ export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v3';
86
+ /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
87
+ export const LEGACY_HUSKY_GATE_MARKER_V2 = '# rea:husky-pre-push-gate v2';
81
88
  /** Legacy v1 husky marker for migration. */
82
89
  export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
83
90
  /**
@@ -85,7 +92,9 @@ export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
85
92
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
86
93
  * A real rea hook always carries both markers.
87
94
  */
88
- export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v2';
95
+ export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v3';
96
+ /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
97
+ export const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = '# rea:gate-body-v2';
89
98
  /** Legacy body marker — used by upgrade migration detection. */
90
99
  export const LEGACY_HUSKY_GATE_BODY_MARKER_V1 = '# rea:gate-body-v1';
91
100
  // ---------------------------------------------------------------------------
@@ -107,7 +116,8 @@ const BODY_TEMPLATE = `set -eu
107
116
  # invocation, verdict inference, audit write — lives in
108
117
  # \`src/hooks/push-gate/\` and is invoked via \`rea hook push-gate\`.
109
118
  # This stub only short-circuits on the kill-switch and resolves the rea
110
- # binary (in priority: project node_modules/.bin/rea → PATH npx).
119
+ # binary (in priority: project node_modules/.bin/rea → dogfood dist
120
+ # PATH → npx).
111
121
  #
112
122
  # The 0.10.x hooks assumed rea was on PATH. Consumers who bootstrap via
113
123
  # \`npx @bookedsolid/rea init\` have no persistent global rea install, so
@@ -123,35 +133,39 @@ if [ -f "\${REA_ROOT}/.rea/HALT" ]; then
123
133
  exit 1
124
134
  fi
125
135
 
126
- # The pre-push stdin carries one line per refspec (local_ref local_sha
127
- # remote_ref remote_sha). Forward stdin verbatim via process substitution
128
- # the \`rea hook push-gate\` CLI reads it via process.stdin to pick up
129
- # the actual push base. Empty stdin (direct invocation, CI, etc.) is
130
- # handled by the CLI falling back to upstream origin/HEAD resolution.
136
+ # Dispatch the rea CLI as a positional-args list rather than a string
137
+ # (the 0.11.x \`exec \$REA_BIN ...\` pattern broke when REA_ROOT contained
138
+ # whitespace because the unquoted \$REA_BIN expansion underwent word
139
+ # splitting; \`/Users/jane/My Projects/repo\` produced four argv tokens
140
+ # instead of two). The \`set --\` form below preserves spaces verbatim.
141
+ #
142
+ # The pre-push stdin carries one line per refspec; \`exec\` inherits stdin
143
+ # unchanged. \$@ on entry carries git's <remote-name> <remote-url>; we
144
+ # preserve those by appending "\$@" inside each \`set --\` arm.
131
145
 
132
- REA_BIN=""
133
146
  if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
134
- REA_BIN="\${REA_ROOT}/node_modules/.bin/rea"
135
- elif [ -f "\${REA_ROOT}/dist/cli/index.js" ]; then
147
+ set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
148
+ elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
136
149
  # rea's own repo (dogfood) — the package is not installed under
137
150
  # node_modules here because we ARE the package. The built CLI
138
151
  # entry point lives at dist/cli/index.js; node runs it directly.
139
- REA_BIN="node \${REA_ROOT}/dist/cli/index.js"
152
+ # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
153
+ # consumer repo that happens to ship its own \`dist/cli/index.js\` does
154
+ # not get this hook executing the consumer's unrelated build.
155
+ set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
140
156
  elif command -v rea >/dev/null 2>&1; then
141
- REA_BIN="rea"
157
+ set -- rea hook push-gate "\$@"
142
158
  elif command -v npx >/dev/null 2>&1; then
143
159
  # Last resort: npx will resolve the package from npm or the cache.
144
160
  # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
145
161
  # error instead of silently downloading at push time.
146
- REA_BIN="npx --no-install @bookedsolid/rea"
162
+ set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
147
163
  else
148
164
  printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
149
165
  exit 2
150
166
  fi
151
167
 
152
- # \$@ carries the pre-push arguments (git passes <remote-name> <remote-url>).
153
- # Stdin is inherited by \`exec\` → the CLI sees it unchanged.
154
- exec \$REA_BIN hook push-gate "\$@"
168
+ exec "\$@"
155
169
  `;
156
170
  /** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
157
171
  export function fallbackHookContent() {
@@ -202,10 +216,11 @@ export function isReaManagedFallback(content) {
202
216
  return secondLine === FALLBACK_MARKER;
203
217
  }
204
218
  /**
205
- * True when `content` is the legacy v1 fallback (`.git/hooks/pre-push`
206
- * that delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea
207
- * upgrade` to migrate we overwrite these unconditionally because we
208
- * control the entire body shape.
219
+ * True when `content` is a legacy fallback hook authored by an earlier rea
220
+ * release: v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x —
221
+ * delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade`
222
+ * to migrate we overwrite these unconditionally because we control the
223
+ * entire body shape.
209
224
  */
210
225
  export function isLegacyReaManagedFallback(content) {
211
226
  if (!content.startsWith('#!/bin/sh\n'))
@@ -214,7 +229,8 @@ export function isLegacyReaManagedFallback(content) {
214
229
  if (secondLineEnd < 0)
215
230
  return false;
216
231
  const secondLine = content.slice(10, secondLineEnd);
217
- return secondLine === LEGACY_FALLBACK_MARKER_V1;
232
+ return (secondLine === LEGACY_FALLBACK_MARKER_V2 ||
233
+ secondLine === LEGACY_FALLBACK_MARKER_V1);
218
234
  }
219
235
  /**
220
236
  * True when `content` carries the rea Husky gate markers in the canonical
@@ -231,11 +247,13 @@ export function isReaManagedHuskyGate(content) {
231
247
  return hasHeaderMarkers(content, HUSKY_GATE_MARKER, HUSKY_GATE_BODY_MARKER);
232
248
  }
233
249
  /**
234
- * True when `content` is the legacy v1 Husky gate (`.husky/pre-push` from
235
- * 0.10.x and earlier). Used to trigger the upgrade migration.
250
+ * True when `content` is a legacy Husky gate from an earlier rea release:
251
+ * v2 (0.11.x broken `exec $REA_BIN` body) or v1 (0.10.x bash core
252
+ * delegating). Used to trigger the upgrade migration.
236
253
  */
237
254
  export function isLegacyReaManagedHuskyGate(content) {
238
- return hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1);
255
+ return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
256
+ hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1));
239
257
  }
240
258
  function hasHeaderMarkers(content, header, body) {
241
259
  if (!content.startsWith('#!/bin/sh\n'))
@@ -523,6 +541,8 @@ async function cleanupStaleTempFiles(dst) {
523
541
  return;
524
542
  if (!body.includes(FALLBACK_MARKER) &&
525
543
  !body.includes(HUSKY_GATE_MARKER) &&
544
+ !body.includes(LEGACY_FALLBACK_MARKER_V2) &&
545
+ !body.includes(LEGACY_HUSKY_GATE_MARKER_V2) &&
526
546
  !body.includes(LEGACY_FALLBACK_MARKER_V1) &&
527
547
  !body.includes(LEGACY_HUSKY_GATE_MARKER_V1)) {
528
548
  return;
@@ -31,7 +31,27 @@ export interface BaseResolution {
31
31
  */
32
32
  ref: string;
33
33
  /** Where the ref came from — surfaces in audit records and stderr. */
34
- source: 'explicit' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
34
+ source: 'explicit' | 'last-n-commits' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
35
+ /**
36
+ * Set when `last-n-commits` was requested but `<headRef>~N` did not
37
+ * resolve at the requested depth (shallower-than-N clone, or N larger
38
+ * than the branch history). The resolver clamps to the deepest
39
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
40
+ * resolve) and surfaces both numbers so the caller can emit a stderr
41
+ * warning ("requested N=50; clamped to K=12 (oldest reachable)").
42
+ * Present on both `last-n-commits` results (when clamped) and
43
+ * `empty-tree` results (when even `~1` was unreachable — orphan or
44
+ * single-commit branch).
45
+ */
46
+ lastNCommitsRequested?: number;
47
+ /**
48
+ * The N value actually used. When source is `last-n-commits`, this is
49
+ * the depth that resolved (equals `lastNCommitsRequested` on full
50
+ * resolution; smaller when clamped to a shallow clone). Surfaces in
51
+ * audit metadata so operators can grep their audit log for narrowed
52
+ * reviews.
53
+ */
54
+ lastNCommits?: number;
35
55
  }
36
56
  /**
37
57
  * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
@@ -48,6 +68,33 @@ export interface ResolveBaseOptions {
48
68
  * that's Codex's job (it'll error clearly if the ref is bad).
49
69
  */
50
70
  explicit?: string;
71
+ /**
72
+ * Resolve the base to `HEAD~N` instead of running the upstream ladder.
73
+ * `--last-n-commits N` flag and `policy.review.last_n_commits` map
74
+ * here. Ignored when `explicit` is set (explicit ref wins). When
75
+ * `<headRef>~N` does not resolve (shallower-than-N clone, or branch
76
+ * history shorter than N), the resolver CLAMPS to the deepest
77
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
78
+ * resolve) — i.e. it diffs against the oldest commit on the branch.
79
+ * It does NOT fall back to the empty-tree sentinel; that would
80
+ * silently expand "the last N commits" to "the entire repository
81
+ * snapshot" on a normal repo with a short feature branch, flooding
82
+ * Codex with unchanged base-branch files. The resolver only emits
83
+ * `source: 'empty-tree'` when even `<headRef>~1` cannot be resolved
84
+ * (orphan branch, single-commit history); in that case
85
+ * `lastNCommitsRequested: N` is set so the caller can warn.
86
+ */
87
+ lastNCommits?: number;
88
+ /**
89
+ * The head ref the gate is reviewing. Defaults to literal "HEAD" — i.e.
90
+ * the local checkout's tip. When the gate is invoked via pre-push and
91
+ * the pushed ref is not the current branch (e.g.
92
+ * `git push origin some-other-branch`), the caller passes the pushed
93
+ * `<sha>` here so `last-n-commits` resolves `<sha>~N` rather than
94
+ * `HEAD~N`. Without this thread-through the review walks back N commits
95
+ * from the local checkout, which can be a different branch entirely.
96
+ */
97
+ headRef?: string;
51
98
  }
52
99
  /**
53
100
  * Resolve the base ref using the configured priority order. Never throws —
@@ -39,6 +39,127 @@ export function resolveBaseRef(git, options = {}) {
39
39
  if (options.explicit !== undefined && options.explicit.length > 0) {
40
40
  return { ref: options.explicit, source: 'explicit' };
41
41
  }
42
+ // 0. Last-N-commits override. Caller (CLI flag or policy key) requested
43
+ // diffing against `HEAD~N` directly. Resolves to a 40-char SHA via
44
+ // `git rev-parse HEAD~N`; on failure (shallower-than-N clone, or N
45
+ // larger than the branch's history depth) we fall back to the
46
+ // empty-tree sentinel and surface `lastNCommitsRequested` so the
47
+ // caller can emit a stderr warning. We deliberately resolve to a
48
+ // SHA rather than passing `HEAD~N` through to Codex — Codex shells
49
+ // out to `git diff` itself, but a SHA is unambiguous regardless of
50
+ // intermediate ref churn.
51
+ if (options.lastNCommits !== undefined && options.lastNCommits > 0) {
52
+ // Walk back N commits from the actual head being reviewed (defaults to
53
+ // local HEAD when the caller didn't thread a pushed ref through). Using
54
+ // a literal "HEAD" here would be wrong for `git push origin
55
+ // some-other-branch` invocations, where the local checkout's HEAD is a
56
+ // different branch entirely and the resulting diff would compare the
57
+ // wrong commits.
58
+ const headRef = options.headRef !== undefined && options.headRef.length > 0
59
+ ? options.headRef
60
+ : 'HEAD';
61
+ const requested = options.lastNCommits;
62
+ const tryDepth = (k) => git.tryRevParse(['--verify', '--quiet', `${headRef}~${k}^{commit}`]).trim();
63
+ // Fast path: requested depth resolves directly.
64
+ const direct = tryDepth(requested);
65
+ if (direct.length > 0) {
66
+ return {
67
+ ref: direct,
68
+ source: 'last-n-commits',
69
+ lastNCommits: requested,
70
+ };
71
+ }
72
+ // Clamp: `<headRef>~N` did not resolve. Two distinct causes need
73
+ // different handling — and the difference matters because the wrong
74
+ // choice silently inflates the review:
75
+ //
76
+ // (i) Branch is genuinely shorter than N (full clone). The
77
+ // deepest resolvable ancestor `<headRef>~K` IS the root
78
+ // commit (parent-less). Diffing against `<headRef>~K` would
79
+ // EXCLUDE the root commit's changes (`git diff base..head`
80
+ // excludes `base`), so we diff against EMPTY_TREE_SHA to
81
+ // include them. Report lastNCommits = K + 1 (every commit on
82
+ // the branch was reviewed).
83
+ //
84
+ // (ii) Repo is a shallow clone — `<headRef>~K` resolves but
85
+ // `<headRef>~K`'s parent simply isn't fetched locally. The
86
+ // commit isn't actually the root; older history exists on
87
+ // the remote. Diffing against EMPTY_TREE_SHA would balloon
88
+ // the review to "every tracked file in the checkout"
89
+ // (including all unchanged base-branch files), defeating
90
+ // the entire point of last-n-commits. So in the shallow
91
+ // case we diff against `<headRef>~K` itself, accepting that
92
+ // the K-th commit's changes are excluded — the operator
93
+ // chose a shallow clone and the deepest reachable commit is
94
+ // the best base we have. Report lastNCommits = K (the K
95
+ // ancestors we DID reach).
96
+ //
97
+ // `git rev-parse --is-shallow-repository` distinguishes the two
98
+ // cases (returns "true" / "false"). On unknown / errored output we
99
+ // assume FULL (the safer default for case (i): we'd rather review
100
+ // the root commit and risk a slightly larger diff than silently
101
+ // drop changes).
102
+ //
103
+ // Both Codex [P1] findings 2026-04-29 (initial empty-tree-on-clamp
104
+ // dropping root commit, then shallow-clone empty-tree expanding to
105
+ // full repo) drove this two-branch design.
106
+ const oneSha = tryDepth(1);
107
+ if (oneSha.length === 0) {
108
+ // Even `<headRef>~1` does not resolve — single-commit history
109
+ // (full clone with one commit) OR a shallow clone fetched at
110
+ // depth=1. In both cases the only locally-resolvable commit is
111
+ // headRef itself; there's no useful intermediate base. Fall back
112
+ // to empty-tree (matches case (i) of single commit review) and
113
+ // report lastNCommits = 1.
114
+ return {
115
+ ref: EMPTY_TREE_SHA,
116
+ source: 'empty-tree',
117
+ lastNCommits: 1,
118
+ lastNCommitsRequested: requested,
119
+ };
120
+ }
121
+ // Binary search for the deepest K < N where `<headRef>~K` resolves.
122
+ // Invariant: tryDepth(lo) resolves; tryDepth(hi+1) does not. We
123
+ // narrow until lo > hi; bestDepth carries the highest K seen.
124
+ let lo = 1;
125
+ let hi = requested - 1;
126
+ let bestDepth = 1;
127
+ while (lo <= hi) {
128
+ const mid = lo + Math.floor((hi - lo) / 2);
129
+ const sha = tryDepth(mid);
130
+ if (sha.length > 0) {
131
+ bestDepth = mid;
132
+ lo = mid + 1;
133
+ }
134
+ else {
135
+ hi = mid - 1;
136
+ }
137
+ }
138
+ const shallowFlag = git.tryRevParse(['--is-shallow-repository']).trim();
139
+ if (shallowFlag === 'true') {
140
+ // Case (ii): shallow clone. Diff against the deepest reachable
141
+ // ancestor SHA — its parent exists on the remote but isn't
142
+ // locally available, so empty-tree would over-review. Accept that
143
+ // the K-th commit's content is excluded; that's the cost of the
144
+ // shallow clone the operator chose.
145
+ const bestSha = tryDepth(bestDepth);
146
+ return {
147
+ ref: bestSha,
148
+ source: 'last-n-commits',
149
+ lastNCommits: bestDepth,
150
+ lastNCommitsRequested: requested,
151
+ };
152
+ }
153
+ // Case (i): full clone, branch genuinely shorter than N. The
154
+ // deepest resolvable ancestor IS the root. Diff against empty-tree
155
+ // to include the root commit's changes; reviewed count = K + 1.
156
+ return {
157
+ ref: EMPTY_TREE_SHA,
158
+ source: 'last-n-commits',
159
+ lastNCommits: bestDepth + 1,
160
+ lastNCommitsRequested: requested,
161
+ };
162
+ }
42
163
  // 1. Upstream of current branch. `@{upstream}` resolves to the configured
43
164
  // tracking ref (typically `refs/remotes/origin/<branch>`). Returns
44
165
  // empty on branches without an upstream — which is normal for a brand
@@ -63,6 +63,14 @@ export interface PushGateDeps {
63
63
  stderr: (line: string) => void;
64
64
  /** Override via `--base <ref>`. Absent → auto-resolve. */
65
65
  explicitBase?: string;
66
+ /**
67
+ * Override from the `--last-n-commits N` CLI flag. When set, the gate
68
+ * diffs against `HEAD~N` instead of running the upstream ladder. Wins
69
+ * over `policy.review.last_n_commits` but loses to `explicitBase`. When
70
+ * both `explicitBase` and this are set, `explicitBase` is used and a
71
+ * stderr warning is emitted noting the conflict.
72
+ */
73
+ lastNCommits?: number;
66
74
  /**
67
75
  * Pre-push refspecs from git's stdin. Empty when invoked outside a
68
76
  * pre-push context (manual `rea hook push-gate` from the CLI). When