@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.
- package/.husky/pre-push +44 -13
- package/README.md +834 -552
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +90 -1
- package/dist/cli/hook.d.ts +7 -0
- package/dist/cli/hook.js +12 -1
- package/dist/cli/install/pre-push.d.ts +21 -10
- package/dist/cli/install/pre-push.js +47 -27
- package/dist/hooks/push-gate/base.d.ts +48 -1
- package/dist/hooks/push-gate/base.js +121 -0
- package/dist/hooks/push-gate/index.d.ts +8 -0
- package/dist/hooks/push-gate/index.js +86 -21
- package/dist/hooks/push-gate/policy.d.ts +18 -4
- package/dist/hooks/push-gate/policy.js +13 -4
- package/dist/policy/loader.d.ts +5 -0
- package/dist/policy/loader.js +1 -0
- package/dist/policy/types.d.ts +44 -2
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -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
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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.
|
|
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
|
|
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-
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
110
|
-
* 0.
|
|
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
|
|
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.
|
|
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
|
|
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-
|
|
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 →
|
|
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
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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 ===
|
|
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
|
|
235
|
-
* 0.
|
|
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,
|
|
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
|