@bookedsolid/rea 0.11.0 → 0.13.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/commit-msg +32 -0
- package/.husky/pre-push +88 -13
- package/README.md +914 -550
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +152 -1
- package/dist/cli/hook.d.ts +7 -0
- package/dist/cli/hook.js +12 -1
- package/dist/cli/install/commit-msg.d.ts +34 -0
- package/dist/cli/install/commit-msg.js +60 -0
- package/dist/cli/install/pre-push.d.ts +32 -10
- package/dist/cli/install/pre-push.js +106 -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/codex-runner.d.ts +8 -0
- package/dist/hooks/push-gate/codex-runner.js +13 -0
- package/dist/hooks/push-gate/index.d.ts +8 -0
- package/dist/hooks/push-gate/index.js +162 -22
- package/dist/hooks/push-gate/policy.d.ts +39 -4
- package/dist/hooks/push-gate/policy.js +29 -4
- package/dist/policy/loader.d.ts +19 -0
- package/dist/policy/loader.js +11 -0
- package/dist/policy/types.d.ts +72 -2
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
|
@@ -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
|
|
@@ -51,6 +51,14 @@ export interface GitExecutor {
|
|
|
51
51
|
headSha(): string;
|
|
52
52
|
/** `git diff --name-only <base> <head>`. Returns path list (possibly empty). */
|
|
53
53
|
diffNames(base: string, head: string): string[];
|
|
54
|
+
/**
|
|
55
|
+
* `git rev-list --count <base>..<head>`. Returns the integer commit count
|
|
56
|
+
* or `null` when the range cannot be resolved (unreachable base, shallow
|
|
57
|
+
* clone, etc.) — null lets the caller treat divergence-counting as
|
|
58
|
+
* best-effort without breaking the gate. Used by the auto-narrow probe
|
|
59
|
+
* (J / 0.13.0).
|
|
60
|
+
*/
|
|
61
|
+
revListCount(base: string, head: string): number | null;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* Real git implementation using `spawnSync`. Each call is independent (no
|
|
@@ -95,6 +95,19 @@ export function createRealGitExecutor(cwd) {
|
|
|
95
95
|
return [];
|
|
96
96
|
return r.stdout.split(/\r?\n/).filter((l) => l.length > 0);
|
|
97
97
|
},
|
|
98
|
+
revListCount(base, head) {
|
|
99
|
+
// `git rev-list --count base..head` — number of commits reachable
|
|
100
|
+
// from head but not base. Returns null on any failure so the caller
|
|
101
|
+
// can treat divergence-counting as best-effort (auto-narrow probe).
|
|
102
|
+
const r = run(['rev-list', '--count', `${base}..${head}`]);
|
|
103
|
+
if (r.code !== 0)
|
|
104
|
+
return null;
|
|
105
|
+
const trimmed = r.stdout.trim();
|
|
106
|
+
if (trimmed.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
const n = Number(trimmed);
|
|
109
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
110
|
+
},
|
|
98
111
|
};
|
|
99
112
|
}
|
|
100
113
|
/**
|
|
@@ -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
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { appendAuditRecord } from '../../audit/append.js';
|
|
26
26
|
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
27
|
-
import { resolvePushGatePolicy, } from './policy.js';
|
|
27
|
+
import { resolvePushGatePolicy, PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK, } from './policy.js';
|
|
28
28
|
import { readHalt } from './halt.js';
|
|
29
29
|
import { resolveBaseRef } from './base.js';
|
|
30
30
|
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
|
|
@@ -124,38 +124,109 @@ export async function runPushGate(deps) {
|
|
|
124
124
|
summary: 'review.codex_required is false — push-gate skipped',
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
|
-
// 3.
|
|
128
|
-
//
|
|
127
|
+
// 3. Value-carrying skip waivers. HALT-wins ordering means these are
|
|
128
|
+
// checked AFTER halt (step 1) and AFTER codex_required=false
|
|
129
129
|
// short-circuit (step 2). Both of those should hold anyway; this is
|
|
130
|
-
// for the case where codex
|
|
130
|
+
// for the case where codex IS required but the operator wants to
|
|
131
131
|
// skip for a narrow, documented reason.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
//
|
|
133
|
+
// Two equivalent env vars are honored — gate behavior is identical;
|
|
134
|
+
// only the audit metadata's `skip_var` differs so operators can grep
|
|
135
|
+
// their audit log to see which variant agents used:
|
|
136
|
+
//
|
|
137
|
+
// - REA_SKIP_PUSH_GATE — the original 0.11.0 var
|
|
138
|
+
// - REA_SKIP_CODEX_REVIEW — added in 0.12.0 to match the variant
|
|
139
|
+
// documented elsewhere in the codebase
|
|
140
|
+
// (gateway/reviewers, codex-probe). Prior
|
|
141
|
+
// to 0.12.0 this string only worked at
|
|
142
|
+
// the gateway tier; agents who set it on
|
|
143
|
+
// a `git push` got no skip and codex still
|
|
144
|
+
// ran. The mismatch surfaced during the
|
|
145
|
+
// helixir migration session 2026-04-26.
|
|
146
|
+
//
|
|
147
|
+
// Precedence on simultaneous set: REA_SKIP_PUSH_GATE wins (it was the
|
|
148
|
+
// canonical name) and REA_SKIP_CODEX_REVIEW is logged but not used.
|
|
149
|
+
// Either var alone with non-empty reason short-circuits.
|
|
150
|
+
const skipPush = (env.REA_SKIP_PUSH_GATE ?? '').trim();
|
|
151
|
+
const skipCodex = (env.REA_SKIP_CODEX_REVIEW ?? '').trim();
|
|
152
|
+
if (skipPush.length > 0 || skipCodex.length > 0) {
|
|
153
|
+
const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
|
|
154
|
+
const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
|
|
155
|
+
stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
|
|
135
156
|
await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
|
|
136
157
|
reason: skipReason,
|
|
158
|
+
skip_var: skipVar,
|
|
137
159
|
});
|
|
138
160
|
return {
|
|
139
161
|
status: 'skipped',
|
|
140
162
|
exitCode: 0,
|
|
141
|
-
summary:
|
|
163
|
+
summary: `${skipVar} waiver: ${skipReason}`,
|
|
142
164
|
};
|
|
143
165
|
}
|
|
144
166
|
// 4. Resolve (base_ref, head_sha) for the actual review.
|
|
145
167
|
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
168
|
+
// Precedence (highest first):
|
|
169
|
+
// a) `--base <ref>` CLI flag (deps.explicitBase) — explicit ref the
|
|
170
|
+
// operator named; we trust it.
|
|
171
|
+
// b) `--last-n-commits N` CLI flag (deps.lastNCommits) — diff
|
|
172
|
+
// against HEAD~N. Wins over the policy key.
|
|
173
|
+
// c) `policy.review.last_n_commits` — same effect as (b), but
|
|
174
|
+
// configured in `.rea/policy.yaml`. Persistent narrow-window.
|
|
175
|
+
// d) Active refspec from pre-push stdin — what git is about to
|
|
176
|
+
// push. Critical for `git push origin HEAD:release/1.0`.
|
|
177
|
+
// e) Upstream → origin/HEAD → main/master ladder.
|
|
152
178
|
//
|
|
153
|
-
// When
|
|
154
|
-
//
|
|
179
|
+
// When (a) collides with (b) or (c), (a) wins and we warn — explicit
|
|
180
|
+
// ref beats relative count.
|
|
181
|
+
const policyLastN = policy.last_n_commits;
|
|
182
|
+
const explicitBaseSet = deps.explicitBase !== undefined && deps.explicitBase.length > 0;
|
|
183
|
+
const lastNFromFlag = deps.lastNCommits;
|
|
184
|
+
const effectiveLastN = lastNFromFlag !== undefined ? lastNFromFlag : policyLastN;
|
|
185
|
+
if (explicitBaseSet && effectiveLastN !== undefined) {
|
|
186
|
+
const source = lastNFromFlag !== undefined ? '--last-n-commits' : 'policy.review.last_n_commits';
|
|
187
|
+
stderr(`rea: --base ${deps.explicitBase} overrides ${source}=${effectiveLastN}; using explicit ref.\n`);
|
|
188
|
+
}
|
|
155
189
|
const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
|
|
156
190
|
let base;
|
|
157
191
|
let headSha;
|
|
158
|
-
|
|
192
|
+
// Tracks whether the base was resolved from the active refspec's
|
|
193
|
+
// remoteSha — i.e. "the tip of this branch as the remote currently sees
|
|
194
|
+
// it". Only that case represents commits Codex has already reviewed in
|
|
195
|
+
// a prior push; auto-narrow is only safe there (J / 0.13.0). Initial
|
|
196
|
+
// pushes against `origin/main`-shaped bases must NOT auto-narrow,
|
|
197
|
+
// because earlier commits on the branch may never have been reviewed.
|
|
198
|
+
let baseFromPushedRemoteTip = false;
|
|
199
|
+
if (explicitBaseSet) {
|
|
200
|
+
// (a) explicit base wins absolutely.
|
|
201
|
+
base = resolveBaseRef(git, { explicit: deps.explicitBase });
|
|
202
|
+
headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
|
|
203
|
+
}
|
|
204
|
+
else if (effectiveLastN !== undefined && effectiveLastN > 0) {
|
|
205
|
+
// (b) / (c) last-n-commits. Resolves to a SHA via `git rev-parse
|
|
206
|
+
// <headRef>~N`. Compute headSha FIRST so the resolver walks back N
|
|
207
|
+
// commits from the pushed ref rather than the local HEAD — critical
|
|
208
|
+
// for `git push origin some-other-branch` where the active refspec's
|
|
209
|
+
// localSha is a different branch entirely from the checkout's HEAD.
|
|
210
|
+
headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
|
|
211
|
+
base = resolveBaseRef(git, {
|
|
212
|
+
lastNCommits: effectiveLastN,
|
|
213
|
+
headRef: headSha,
|
|
214
|
+
});
|
|
215
|
+
if (base.lastNCommitsRequested !== undefined &&
|
|
216
|
+
base.lastNCommits !== undefined &&
|
|
217
|
+
base.lastNCommits < base.lastNCommitsRequested) {
|
|
218
|
+
// Clamp warning: the resolver couldn't go back N commits, so it
|
|
219
|
+
// clamped to the entire branch history (diff vs empty-tree, K+1
|
|
220
|
+
// commits reviewed) — `base.lastNCommits` carries the actual K+1.
|
|
221
|
+
// This warning fires both when source is 'last-n-commits' (clamped
|
|
222
|
+
// mid-branch, root commit included via empty-tree) and when source
|
|
223
|
+
// is 'empty-tree' (single-commit branch). The user-facing message
|
|
224
|
+
// is identical: we wanted N, got K, here's what we reviewed.
|
|
225
|
+
stderr(`rea: ${headSha.slice(0, 12)}~${base.lastNCommitsRequested} not reachable; reviewing all ${base.lastNCommits} commits on this branch instead.\n`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (activeRefspec !== undefined) {
|
|
229
|
+
// (d) refspec-aware base — use what git is about to push.
|
|
159
230
|
headSha = activeRefspec.localSha;
|
|
160
231
|
if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
|
|
161
232
|
// New remote ref — no existing commits to diff against. Fall back to
|
|
@@ -165,14 +236,14 @@ export async function runPushGate(deps) {
|
|
|
165
236
|
}
|
|
166
237
|
else {
|
|
167
238
|
base = { ref: activeRefspec.remoteSha, source: 'explicit' };
|
|
239
|
+
// ONLY this path produces a base that represents the previously-
|
|
240
|
+
// reviewed remote tip of THIS branch. Auto-narrow is safe here.
|
|
241
|
+
baseFromPushedRemoteTip = true;
|
|
168
242
|
}
|
|
169
243
|
}
|
|
170
244
|
else {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
? { explicit: deps.explicitBase }
|
|
174
|
-
: {}),
|
|
175
|
-
});
|
|
245
|
+
// (e) upstream ladder.
|
|
246
|
+
base = resolveBaseRef(git);
|
|
176
247
|
headSha = git.headSha();
|
|
177
248
|
}
|
|
178
249
|
if (headSha.length === 0) {
|
|
@@ -180,6 +251,67 @@ export async function runPushGate(deps) {
|
|
|
180
251
|
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
|
|
181
252
|
return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
|
|
182
253
|
}
|
|
254
|
+
// 4b. Auto-narrow probe (J / 0.13.0). When the resolved base is far
|
|
255
|
+
// behind HEAD AND the operator has not already pinned an explicit
|
|
256
|
+
// window, scope the review down to the recent commits and warn.
|
|
257
|
+
//
|
|
258
|
+
// CRITICAL safety rule: auto-narrow ONLY fires when the base was
|
|
259
|
+
// resolved from the active refspec's remoteSha — i.e. "the tip of
|
|
260
|
+
// this branch as the remote currently sees it". Only that case
|
|
261
|
+
// represents commits Codex already reviewed in a prior push, so
|
|
262
|
+
// skipping older commits on the branch is safe.
|
|
263
|
+
//
|
|
264
|
+
// For initial pushes (or any base resolved via the upstream /
|
|
265
|
+
// origin-head / origin-main ladder), the diff target is a trunk-
|
|
266
|
+
// like ref where commits earlier in the branch may never have been
|
|
267
|
+
// reviewed. Auto-narrowing past them would silently bypass the
|
|
268
|
+
// advertised pre-push review for a hook/policy/security change
|
|
269
|
+
// made early in the branch (codex-review 0.13.0 [P1]).
|
|
270
|
+
//
|
|
271
|
+
// Suppression rules (any one prevents auto-narrow from firing):
|
|
272
|
+
//
|
|
273
|
+
// - `--base` flag set (operator picked an explicit ref)
|
|
274
|
+
// - `--last-n-commits` flag set (operator picked an explicit
|
|
275
|
+
// window)
|
|
276
|
+
// - `policy.review.last_n_commits` set (persistent narrow window)
|
|
277
|
+
// - `policy.review.auto_narrow_threshold: 0` (disabled)
|
|
278
|
+
// - resolver already produced a `last-n-commits` source (we got
|
|
279
|
+
// here via the policyLastN branch above)
|
|
280
|
+
// - resolver fell back to `empty-tree` (single-commit branch /
|
|
281
|
+
// orphan; no usable upstream — narrowing would be silly)
|
|
282
|
+
// - base was NOT derived from the active refspec's remoteSha
|
|
283
|
+
// (initial push, no upstream, fallback to origin/main, etc.)
|
|
284
|
+
//
|
|
285
|
+
// The probe uses `git rev-list --count base..HEAD` rather than
|
|
286
|
+
// `diffNames().length` — line-counting commits is far cheaper than
|
|
287
|
+
// listing every changed path on a 50+ commit branch. A null result
|
|
288
|
+
// (range unresolvable) suppresses auto-narrow entirely; we'd
|
|
289
|
+
// rather err on the side of reviewing more than tripping a
|
|
290
|
+
// half-baked auto-narrow on a degenerate ref.
|
|
291
|
+
let autoNarrowed = false;
|
|
292
|
+
let originalCommitCount = null;
|
|
293
|
+
const autoNarrowEligible = !explicitBaseSet &&
|
|
294
|
+
lastNFromFlag === undefined &&
|
|
295
|
+
policyLastN === undefined &&
|
|
296
|
+
policy.auto_narrow_threshold > 0 &&
|
|
297
|
+
base.source !== 'last-n-commits' &&
|
|
298
|
+
base.source !== 'empty-tree' &&
|
|
299
|
+
baseFromPushedRemoteTip;
|
|
300
|
+
if (autoNarrowEligible) {
|
|
301
|
+
originalCommitCount = git.revListCount(base.ref, headSha);
|
|
302
|
+
if (originalCommitCount !== null &&
|
|
303
|
+
originalCommitCount > policy.auto_narrow_threshold) {
|
|
304
|
+
const fallbackWindow = PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK;
|
|
305
|
+
const narrowed = resolveBaseRef(git, {
|
|
306
|
+
lastNCommits: fallbackWindow,
|
|
307
|
+
headRef: headSha,
|
|
308
|
+
});
|
|
309
|
+
stderr(`rea: auto-narrow — ${originalCommitCount} commits behind ${base.ref} (threshold ${policy.auto_narrow_threshold}); reviewing the last ${fallbackWindow} commits instead.\n` +
|
|
310
|
+
` Override: pass \`--last-n-commits N\` or \`--base <ref>\`, set \`review.last_n_commits\` in .rea/policy.yaml, or disable with \`review.auto_narrow_threshold: 0\`.\n`);
|
|
311
|
+
base = narrowed;
|
|
312
|
+
autoNarrowed = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
183
315
|
// 5. Empty-diff short-circuit. An initial push against the empty-tree
|
|
184
316
|
// sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
|
|
185
317
|
// short-circuit only fires when the feature branch really is a
|
|
@@ -190,6 +322,10 @@ export async function runPushGate(deps) {
|
|
|
190
322
|
base_ref: base.ref,
|
|
191
323
|
base_source: base.source,
|
|
192
324
|
head_sha: headSha,
|
|
325
|
+
last_n_commits: base.lastNCommits,
|
|
326
|
+
last_n_commits_requested: base.lastNCommitsRequested,
|
|
327
|
+
auto_narrowed: autoNarrowed ? true : undefined,
|
|
328
|
+
original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
|
|
193
329
|
});
|
|
194
330
|
return {
|
|
195
331
|
status: 'empty-diff',
|
|
@@ -238,6 +374,10 @@ export async function runPushGate(deps) {
|
|
|
238
374
|
duration_seconds: codexResult.durationSeconds,
|
|
239
375
|
event_count: codexResult.eventCount,
|
|
240
376
|
concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
|
|
377
|
+
last_n_commits: base.lastNCommits,
|
|
378
|
+
last_n_commits_requested: base.lastNCommitsRequested,
|
|
379
|
+
auto_narrowed: autoNarrowed ? true : undefined,
|
|
380
|
+
original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
|
|
241
381
|
});
|
|
242
382
|
if (blocked) {
|
|
243
383
|
return {
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
* skip". This module is pure policy.
|
|
10
10
|
*
|
|
11
11
|
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
-
* - `codex_required` → `true`
|
|
13
|
-
* - `concerns_blocks` → `true`
|
|
14
|
-
* - `timeout_ms` →
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
|
|
15
|
+
* previous 10-minute default after the
|
|
16
|
+
* helixir migration session 2026-04-26
|
|
17
|
+
* showed realistic feature-branch
|
|
18
|
+
* reviews routinely exceeded 10 minutes
|
|
19
|
+
* on large diffs. Operators who pin
|
|
20
|
+
* `timeout_ms:` in policy.yaml are
|
|
21
|
+
* unaffected by this change.)
|
|
15
22
|
*
|
|
16
23
|
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
17
24
|
* operator may not have run `rea init` yet, and the gate's behavior
|
|
@@ -22,12 +29,40 @@ export interface ResolvedReviewPolicy {
|
|
|
22
29
|
codex_required: boolean;
|
|
23
30
|
concerns_blocks: boolean;
|
|
24
31
|
timeout_ms: number;
|
|
32
|
+
/**
|
|
33
|
+
* When set, the gate resolves the diff base to `HEAD~N` (see Fix D in
|
|
34
|
+
* 0.12.0). The CLI flag `--last-n-commits N` overrides this; the
|
|
35
|
+
* policy key surfaces here as a runtime knob with the same effect.
|
|
36
|
+
* `undefined` when unset (default-untouched behavior).
|
|
37
|
+
*/
|
|
38
|
+
last_n_commits: number | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Auto-narrow threshold (J / 0.13.0). When the resolved diff base is more
|
|
41
|
+
* than N commits behind HEAD AND no explicit narrowing was pinned, the
|
|
42
|
+
* gate scopes to `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` (10) and
|
|
43
|
+
* emits a stderr warning. Defaults to 30 when unset; 0 disables.
|
|
44
|
+
*/
|
|
45
|
+
auto_narrow_threshold: number;
|
|
25
46
|
/** `true` when `.rea/policy.yaml` was absent; defaults apply. */
|
|
26
47
|
policyMissing: boolean;
|
|
27
48
|
}
|
|
28
|
-
export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS =
|
|
49
|
+
export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
|
|
29
50
|
export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
30
51
|
export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
52
|
+
/**
|
|
53
|
+
* Default auto-narrow threshold (J / 0.13.0). When the divergence between
|
|
54
|
+
* the resolved base and HEAD exceeds this count and the operator has not
|
|
55
|
+
* pinned an explicit window, the gate auto-narrows to
|
|
56
|
+
* `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` commits.
|
|
57
|
+
*/
|
|
58
|
+
export declare const PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD = 30;
|
|
59
|
+
/**
|
|
60
|
+
* Window the gate auto-narrows to when the threshold trips and the operator
|
|
61
|
+
* has not pinned `policy.review.last_n_commits`. Conservative — small
|
|
62
|
+
* enough that Codex review stays fast, large enough to capture meaningful
|
|
63
|
+
* recent work.
|
|
64
|
+
*/
|
|
65
|
+
export declare const PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK = 10;
|
|
31
66
|
/**
|
|
32
67
|
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
33
68
|
* policy file surfaces as a typed error via the underlying zod validator,
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
* skip". This module is pure policy.
|
|
10
10
|
*
|
|
11
11
|
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
-
* - `codex_required` → `true`
|
|
13
|
-
* - `concerns_blocks` → `true`
|
|
14
|
-
* - `timeout_ms` →
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
|
|
15
|
+
* previous 10-minute default after the
|
|
16
|
+
* helixir migration session 2026-04-26
|
|
17
|
+
* showed realistic feature-branch
|
|
18
|
+
* reviews routinely exceeded 10 minutes
|
|
19
|
+
* on large diffs. Operators who pin
|
|
20
|
+
* `timeout_ms:` in policy.yaml are
|
|
21
|
+
* unaffected by this change.)
|
|
15
22
|
*
|
|
16
23
|
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
17
24
|
* operator may not have run `rea init` yet, and the gate's behavior
|
|
@@ -21,9 +28,23 @@
|
|
|
21
28
|
import fs from 'node:fs';
|
|
22
29
|
import path from 'node:path';
|
|
23
30
|
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
24
|
-
export const PUSH_GATE_DEFAULT_TIMEOUT_MS =
|
|
31
|
+
export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
|
|
25
32
|
export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
26
33
|
export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
34
|
+
/**
|
|
35
|
+
* Default auto-narrow threshold (J / 0.13.0). When the divergence between
|
|
36
|
+
* the resolved base and HEAD exceeds this count and the operator has not
|
|
37
|
+
* pinned an explicit window, the gate auto-narrows to
|
|
38
|
+
* `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` commits.
|
|
39
|
+
*/
|
|
40
|
+
export const PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD = 30;
|
|
41
|
+
/**
|
|
42
|
+
* Window the gate auto-narrows to when the threshold trips and the operator
|
|
43
|
+
* has not pinned `policy.review.last_n_commits`. Conservative — small
|
|
44
|
+
* enough that Codex review stays fast, large enough to capture meaningful
|
|
45
|
+
* recent work.
|
|
46
|
+
*/
|
|
47
|
+
export const PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK = 10;
|
|
27
48
|
/**
|
|
28
49
|
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
29
50
|
* policy file surfaces as a typed error via the underlying zod validator,
|
|
@@ -41,6 +62,8 @@ export async function resolvePushGatePolicy(baseDir) {
|
|
|
41
62
|
codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
42
63
|
concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
43
64
|
timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
65
|
+
last_n_commits: undefined,
|
|
66
|
+
auto_narrow_threshold: PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
|
|
44
67
|
policyMissing: true,
|
|
45
68
|
};
|
|
46
69
|
}
|
|
@@ -50,6 +73,8 @@ export async function resolvePushGatePolicy(baseDir) {
|
|
|
50
73
|
codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
51
74
|
concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
52
75
|
timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
76
|
+
last_n_commits: review.last_n_commits,
|
|
77
|
+
auto_narrow_threshold: review.auto_narrow_threshold ?? PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
|
|
53
78
|
policyMissing: false,
|
|
54
79
|
};
|
|
55
80
|
}
|