@bookedsolid/rea 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/review-gate/args.d.ts +126 -0
- package/dist/hooks/review-gate/args.js +315 -0
- package/dist/hooks/review-gate/banner.d.ts +97 -0
- package/dist/hooks/review-gate/banner.js +172 -0
- package/dist/hooks/review-gate/cache-key.d.ts +55 -0
- package/dist/hooks/review-gate/cache-key.js +41 -0
- package/dist/hooks/review-gate/constants.d.ts +26 -0
- package/dist/hooks/review-gate/constants.js +34 -0
- package/dist/hooks/review-gate/errors.d.ts +72 -0
- package/dist/hooks/review-gate/errors.js +100 -0
- package/dist/hooks/review-gate/hash.d.ts +43 -0
- package/dist/hooks/review-gate/hash.js +46 -0
- package/dist/hooks/review-gate/index.d.ts +21 -0
- package/dist/hooks/review-gate/index.js +21 -0
- package/dist/hooks/review-gate/metadata.d.ts +98 -0
- package/dist/hooks/review-gate/metadata.js +158 -0
- package/dist/hooks/review-gate/policy.d.ts +55 -0
- package/dist/hooks/review-gate/policy.js +71 -0
- package/dist/hooks/review-gate/protected-paths.d.ts +46 -0
- package/dist/hooks/review-gate/protected-paths.js +76 -0
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refspec parsing. Two input shapes the gate must accept:
|
|
3
|
+
*
|
|
4
|
+
* 1. Git pre-push hook stdin — one line per refspec, fields:
|
|
5
|
+
* `<local_ref> <local_sha> <remote_ref> <remote_sha>`
|
|
6
|
+
* (https://git-scm.com/docs/githooks#_pre_push)
|
|
7
|
+
*
|
|
8
|
+
* 2. Claude-Code `Bash`-PreToolUse command string — parse `git push [remote]
|
|
9
|
+
* [refspec...]` out of the command, synthesize refspec records against
|
|
10
|
+
* the caller's HEAD/@{upstream}.
|
|
11
|
+
*
|
|
12
|
+
* Shape 1 is authoritative when present; shape 2 is a fallback for the
|
|
13
|
+
* Claude-Code adapter (BUG-008 sniff). See design §3.2 for the adapter
|
|
14
|
+
* split and §5.1 for the scenarios covered by unit tests.
|
|
15
|
+
*
|
|
16
|
+
* ## Defect J — mixed-push deletion guard
|
|
17
|
+
*
|
|
18
|
+
* A push like `git push origin safe:safe :main` contains both a push refspec
|
|
19
|
+
* and a deletion refspec. The bash core has been burned twice by nesting the
|
|
20
|
+
* deletion check inside the "no SOURCE_SHA resolved" fallback branch, which
|
|
21
|
+
* lets the deletion slip through whenever a sibling refspec DID resolve.
|
|
22
|
+
* This module exposes `hasDeletion()` as a separate predicate so the caller
|
|
23
|
+
* can fail-closed on deletions up front, before any refspec-selection logic.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* One parsed refspec record. Either a push (local_sha != ZERO_SHA) or a
|
|
27
|
+
* deletion (local_sha === ZERO_SHA). `source_is_head` flags the
|
|
28
|
+
* argv-fallback case where no explicit source ref was named and the parser
|
|
29
|
+
* substituted HEAD.
|
|
30
|
+
*/
|
|
31
|
+
export interface RefspecRecord {
|
|
32
|
+
local_sha: string;
|
|
33
|
+
remote_sha: string;
|
|
34
|
+
local_ref: string;
|
|
35
|
+
remote_ref: string;
|
|
36
|
+
/** True when the parser had to fall back to HEAD for the source ref. */
|
|
37
|
+
source_is_head: boolean;
|
|
38
|
+
/** True when `local_sha === ZERO_SHA` (this refspec is a branch deletion). */
|
|
39
|
+
is_deletion: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface ParseStdinResult {
|
|
42
|
+
records: RefspecRecord[];
|
|
43
|
+
/** The parser accepted at least one well-formed line from stdin. */
|
|
44
|
+
matched: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse the git pre-push stdin contract.
|
|
48
|
+
*
|
|
49
|
+
* Returns `{ records, matched: true }` when at least one refspec line
|
|
50
|
+
* parsed cleanly; `{ records: [], matched: false }` otherwise.
|
|
51
|
+
*
|
|
52
|
+
* ## Bash-core parity (push-review-core.sh §45-69)
|
|
53
|
+
*
|
|
54
|
+
* The bash parser uses `read -r local_ref local_sha remote_ref remote_sha rest`
|
|
55
|
+
* against each line, so:
|
|
56
|
+
* - Lines with fewer than the required fields leave some vars empty and
|
|
57
|
+
* the loop `continue`s via the `-z` check (line 54-56). Parser does NOT
|
|
58
|
+
* abort the overall parse — subsequent lines still get a chance.
|
|
59
|
+
* - Extra whitespace-separated fields collapse into `rest` and are
|
|
60
|
+
* silently dropped (line 53's `rest` capture absorbs everything past
|
|
61
|
+
* field four).
|
|
62
|
+
* - Only a 40-hex SHA failure on either sha triggers `return 1` (line
|
|
63
|
+
* 57-59), aborting the whole parse — the caller falls through to argv.
|
|
64
|
+
* - If no lines accept (`accepted=0` at line 63-65), the parser also
|
|
65
|
+
* returns 1.
|
|
66
|
+
*
|
|
67
|
+
* We mirror that exactly. Codex pass-1 on phase 1 flagged an earlier
|
|
68
|
+
* too-strict version that aborted on short/long lines and would have
|
|
69
|
+
* starved the authoritative stdin path when consumer pre-push wrappers
|
|
70
|
+
* emit extra trailing whitespace columns (e.g. a comment or a trailing
|
|
71
|
+
* remote-url duplicate).
|
|
72
|
+
*
|
|
73
|
+
* Empty / whitespace-only lines are skipped silently.
|
|
74
|
+
*
|
|
75
|
+
* @param raw the full stdin bytes as a string
|
|
76
|
+
*/
|
|
77
|
+
export declare function parsePrepushStdin(raw: string): ParseStdinResult;
|
|
78
|
+
/**
|
|
79
|
+
* Return true iff any refspec in the list is a branch deletion (defect J).
|
|
80
|
+
* Callers must check this before any refspec-selection pass; the bash core
|
|
81
|
+
* pre-0.9.4 nested the check inside the "no SOURCE_SHA resolved" branch and
|
|
82
|
+
* let mixed pushes bypass the gate.
|
|
83
|
+
*/
|
|
84
|
+
export declare function hasDeletion(records: RefspecRecord[]): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* A `ResolveHead` callback returns the SHA of a source ref, or `null` when
|
|
87
|
+
* the ref is unknown. Injected here (rather than shelling out to `git`
|
|
88
|
+
* directly) so `args.ts` stays pure and unit-testable without a git repo.
|
|
89
|
+
* The real implementation lives in `diff.ts` / `base-resolve.ts`.
|
|
90
|
+
*/
|
|
91
|
+
export type ResolveHead = (ref: string) => string | null;
|
|
92
|
+
export interface ArgvFallbackDeps {
|
|
93
|
+
/** Resolve a source ref (e.g. `feature/foo`) to a commit SHA, or null. */
|
|
94
|
+
resolveHead: ResolveHead;
|
|
95
|
+
/** Current HEAD SHA for bare `git push` with no explicit refspec. */
|
|
96
|
+
headSha: string;
|
|
97
|
+
/** `@{upstream}` short name (e.g. `origin/main`) or null. */
|
|
98
|
+
upstream: string | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse refspecs out of a `git push [remote] [refspec...]` command string.
|
|
102
|
+
* Used only when stdin-parsing returned `matched: false` (Claude-Code
|
|
103
|
+
* adapter path).
|
|
104
|
+
*
|
|
105
|
+
* Behavior mirrors the bash core's `pr_resolve_argv_refspecs` exactly:
|
|
106
|
+
* - Bare `git push` with no explicit refspec → synthesize a single record
|
|
107
|
+
* against `@{upstream}` (or `main` when no upstream), local_sha = HEAD.
|
|
108
|
+
* - `git push origin foo` → source=foo, dest=foo.
|
|
109
|
+
* - `git push origin src:dst` → source=src, dest=dst.
|
|
110
|
+
* - `git push origin :main` → deletion record.
|
|
111
|
+
* - `git push origin --delete main` → deletion record.
|
|
112
|
+
* - `git push origin HEAD:main` → resolves via `resolveHead('HEAD')`;
|
|
113
|
+
* the bash core rejects HEAD only when it lands on the DESTINATION
|
|
114
|
+
* side of the refspec (dst == 'HEAD'), not the source side. We match.
|
|
115
|
+
* - `git push origin HEAD` → HeadRefspecBlockedError (dst resolves to
|
|
116
|
+
* HEAD because src==dst when no colon is present).
|
|
117
|
+
*
|
|
118
|
+
* Throws `BlockedError` subclasses for operator-error conditions so the
|
|
119
|
+
* caller can translate them to exit 2 + banner identical to the bash core.
|
|
120
|
+
*/
|
|
121
|
+
export declare function resolveArgvRefspecs(cmd: string, deps: ArgvFallbackDeps): RefspecRecord[];
|
|
122
|
+
/**
|
|
123
|
+
* Strip `refs/heads/` or `refs/for/` prefixes so caller-facing code sees a
|
|
124
|
+
* bare branch name. Exported for unit tests in `args.test.ts`.
|
|
125
|
+
*/
|
|
126
|
+
export declare function stripRefsPrefix(ref: string): string;
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refspec parsing. Two input shapes the gate must accept:
|
|
3
|
+
*
|
|
4
|
+
* 1. Git pre-push hook stdin — one line per refspec, fields:
|
|
5
|
+
* `<local_ref> <local_sha> <remote_ref> <remote_sha>`
|
|
6
|
+
* (https://git-scm.com/docs/githooks#_pre_push)
|
|
7
|
+
*
|
|
8
|
+
* 2. Claude-Code `Bash`-PreToolUse command string — parse `git push [remote]
|
|
9
|
+
* [refspec...]` out of the command, synthesize refspec records against
|
|
10
|
+
* the caller's HEAD/@{upstream}.
|
|
11
|
+
*
|
|
12
|
+
* Shape 1 is authoritative when present; shape 2 is a fallback for the
|
|
13
|
+
* Claude-Code adapter (BUG-008 sniff). See design §3.2 for the adapter
|
|
14
|
+
* split and §5.1 for the scenarios covered by unit tests.
|
|
15
|
+
*
|
|
16
|
+
* ## Defect J — mixed-push deletion guard
|
|
17
|
+
*
|
|
18
|
+
* A push like `git push origin safe:safe :main` contains both a push refspec
|
|
19
|
+
* and a deletion refspec. The bash core has been burned twice by nesting the
|
|
20
|
+
* deletion check inside the "no SOURCE_SHA resolved" fallback branch, which
|
|
21
|
+
* lets the deletion slip through whenever a sibling refspec DID resolve.
|
|
22
|
+
* This module exposes `hasDeletion()` as a separate predicate so the caller
|
|
23
|
+
* can fail-closed on deletions up front, before any refspec-selection logic.
|
|
24
|
+
*/
|
|
25
|
+
import { ZERO_SHA } from './constants.js';
|
|
26
|
+
import { BlockedError, DeletionBlockedError, HeadRefspecBlockedError, InvalidDeleteRefspecError, } from './errors.js';
|
|
27
|
+
const SHA_HEX_40 = /^[0-9a-f]{40}$/;
|
|
28
|
+
/**
|
|
29
|
+
* Parse the git pre-push stdin contract.
|
|
30
|
+
*
|
|
31
|
+
* Returns `{ records, matched: true }` when at least one refspec line
|
|
32
|
+
* parsed cleanly; `{ records: [], matched: false }` otherwise.
|
|
33
|
+
*
|
|
34
|
+
* ## Bash-core parity (push-review-core.sh §45-69)
|
|
35
|
+
*
|
|
36
|
+
* The bash parser uses `read -r local_ref local_sha remote_ref remote_sha rest`
|
|
37
|
+
* against each line, so:
|
|
38
|
+
* - Lines with fewer than the required fields leave some vars empty and
|
|
39
|
+
* the loop `continue`s via the `-z` check (line 54-56). Parser does NOT
|
|
40
|
+
* abort the overall parse — subsequent lines still get a chance.
|
|
41
|
+
* - Extra whitespace-separated fields collapse into `rest` and are
|
|
42
|
+
* silently dropped (line 53's `rest` capture absorbs everything past
|
|
43
|
+
* field four).
|
|
44
|
+
* - Only a 40-hex SHA failure on either sha triggers `return 1` (line
|
|
45
|
+
* 57-59), aborting the whole parse — the caller falls through to argv.
|
|
46
|
+
* - If no lines accept (`accepted=0` at line 63-65), the parser also
|
|
47
|
+
* returns 1.
|
|
48
|
+
*
|
|
49
|
+
* We mirror that exactly. Codex pass-1 on phase 1 flagged an earlier
|
|
50
|
+
* too-strict version that aborted on short/long lines and would have
|
|
51
|
+
* starved the authoritative stdin path when consumer pre-push wrappers
|
|
52
|
+
* emit extra trailing whitespace columns (e.g. a comment or a trailing
|
|
53
|
+
* remote-url duplicate).
|
|
54
|
+
*
|
|
55
|
+
* Empty / whitespace-only lines are skipped silently.
|
|
56
|
+
*
|
|
57
|
+
* @param raw the full stdin bytes as a string
|
|
58
|
+
*/
|
|
59
|
+
export function parsePrepushStdin(raw) {
|
|
60
|
+
const records = [];
|
|
61
|
+
if (raw.length === 0)
|
|
62
|
+
return { records, matched: false };
|
|
63
|
+
const lines = raw.split('\n');
|
|
64
|
+
let accepted = false;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (line.trim().length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
// bash `read -r a b c d rest` takes the first 4 whitespace-separated
|
|
69
|
+
// tokens and rolls everything else into `rest` (which we ignore).
|
|
70
|
+
const parts = line.trim().split(/\s+/);
|
|
71
|
+
const local_ref = parts[0] ?? '';
|
|
72
|
+
const local_sha = parts[1] ?? '';
|
|
73
|
+
const remote_ref = parts[2] ?? '';
|
|
74
|
+
const remote_sha = parts[3] ?? '';
|
|
75
|
+
// Missing required fields: bash `continue`s silently. Do the same —
|
|
76
|
+
// DO NOT abort the whole parse, so a later well-formed line can still
|
|
77
|
+
// be accepted.
|
|
78
|
+
if (local_ref.length === 0 ||
|
|
79
|
+
local_sha.length === 0 ||
|
|
80
|
+
remote_ref.length === 0 ||
|
|
81
|
+
remote_sha.length === 0) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Invalid SHA on a line that otherwise has all 4 fields: bash
|
|
85
|
+
// `return 1`s the whole parse so the caller falls through to the argv
|
|
86
|
+
// fallback. Match that — return matched:false with no records.
|
|
87
|
+
if (!SHA_HEX_40.test(local_sha) || !SHA_HEX_40.test(remote_sha)) {
|
|
88
|
+
return { records: [], matched: false };
|
|
89
|
+
}
|
|
90
|
+
records.push({
|
|
91
|
+
local_sha,
|
|
92
|
+
remote_sha,
|
|
93
|
+
local_ref,
|
|
94
|
+
remote_ref,
|
|
95
|
+
source_is_head: false,
|
|
96
|
+
is_deletion: local_sha === ZERO_SHA,
|
|
97
|
+
});
|
|
98
|
+
accepted = true;
|
|
99
|
+
}
|
|
100
|
+
return { records, matched: accepted };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Return true iff any refspec in the list is a branch deletion (defect J).
|
|
104
|
+
* Callers must check this before any refspec-selection pass; the bash core
|
|
105
|
+
* pre-0.9.4 nested the check inside the "no SOURCE_SHA resolved" branch and
|
|
106
|
+
* let mixed pushes bypass the gate.
|
|
107
|
+
*/
|
|
108
|
+
export function hasDeletion(records) {
|
|
109
|
+
return records.some((r) => r.is_deletion);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse refspecs out of a `git push [remote] [refspec...]` command string.
|
|
113
|
+
* Used only when stdin-parsing returned `matched: false` (Claude-Code
|
|
114
|
+
* adapter path).
|
|
115
|
+
*
|
|
116
|
+
* Behavior mirrors the bash core's `pr_resolve_argv_refspecs` exactly:
|
|
117
|
+
* - Bare `git push` with no explicit refspec → synthesize a single record
|
|
118
|
+
* against `@{upstream}` (or `main` when no upstream), local_sha = HEAD.
|
|
119
|
+
* - `git push origin foo` → source=foo, dest=foo.
|
|
120
|
+
* - `git push origin src:dst` → source=src, dest=dst.
|
|
121
|
+
* - `git push origin :main` → deletion record.
|
|
122
|
+
* - `git push origin --delete main` → deletion record.
|
|
123
|
+
* - `git push origin HEAD:main` → resolves via `resolveHead('HEAD')`;
|
|
124
|
+
* the bash core rejects HEAD only when it lands on the DESTINATION
|
|
125
|
+
* side of the refspec (dst == 'HEAD'), not the source side. We match.
|
|
126
|
+
* - `git push origin HEAD` → HeadRefspecBlockedError (dst resolves to
|
|
127
|
+
* HEAD because src==dst when no colon is present).
|
|
128
|
+
*
|
|
129
|
+
* Throws `BlockedError` subclasses for operator-error conditions so the
|
|
130
|
+
* caller can translate them to exit 2 + banner identical to the bash core.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveArgvRefspecs(cmd, deps) {
|
|
133
|
+
const segment = extractPushSegment(cmd);
|
|
134
|
+
const tokens = tokenizePushSegment(segment);
|
|
135
|
+
const specs = [];
|
|
136
|
+
let seenPush = false;
|
|
137
|
+
let remoteSeen = false;
|
|
138
|
+
let deleteMode = false;
|
|
139
|
+
for (const tok of tokens) {
|
|
140
|
+
if (tok === 'git' || tok === 'push') {
|
|
141
|
+
seenPush = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (tok === '--delete' || tok === '-d') {
|
|
145
|
+
deleteMode = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (tok.startsWith('--delete=')) {
|
|
149
|
+
// Bash-core parity (push-review-core.sh §108-112): `--delete=<ref>`
|
|
150
|
+
// sets delete_mode AND inlines the ref into specs WITHOUT the
|
|
151
|
+
// `__REA_DELETE__` sentinel. In the existing bash implementation
|
|
152
|
+
// this produces a non-deletion refspec record — the `delete_mode`
|
|
153
|
+
// flag only affects tokens that appear AFTER the flag, not the
|
|
154
|
+
// inlined ref. Documented upstream as a pre-existing bash quirk;
|
|
155
|
+
// phase 1's job is byte-for-byte bash parity, so we mirror it even
|
|
156
|
+
// though it looks counter-intuitive. A follow-up may harden both
|
|
157
|
+
// implementations together (design §11.1 phase 4 window).
|
|
158
|
+
deleteMode = true;
|
|
159
|
+
specs.push(tok.slice('--delete='.length));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (tok.startsWith('-'))
|
|
163
|
+
continue;
|
|
164
|
+
if (!seenPush)
|
|
165
|
+
continue;
|
|
166
|
+
if (!remoteSeen) {
|
|
167
|
+
remoteSeen = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (deleteMode) {
|
|
171
|
+
specs.push(`__REA_DELETE__${tok}`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
specs.push(tok);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (specs.length === 0) {
|
|
178
|
+
// Bare `git push` — one record against @{upstream} or main.
|
|
179
|
+
let dstRef = 'refs/heads/main';
|
|
180
|
+
if (deps.upstream && deps.upstream.includes('/')) {
|
|
181
|
+
const short = deps.upstream.slice(deps.upstream.indexOf('/') + 1);
|
|
182
|
+
dstRef = `refs/heads/${short}`;
|
|
183
|
+
}
|
|
184
|
+
if (deps.headSha.length === 0) {
|
|
185
|
+
throw new BlockedError('PUSH_BLOCKED_SOURCE_UNRESOLVABLE', 'could not resolve HEAD to a commit; aborting review-gate argv fallback.');
|
|
186
|
+
}
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
local_sha: deps.headSha,
|
|
190
|
+
remote_sha: ZERO_SHA,
|
|
191
|
+
local_ref: 'HEAD',
|
|
192
|
+
remote_ref: dstRef,
|
|
193
|
+
source_is_head: true,
|
|
194
|
+
is_deletion: false,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
const records = [];
|
|
199
|
+
for (const rawSpec of specs) {
|
|
200
|
+
let spec = rawSpec;
|
|
201
|
+
let isDelete = false;
|
|
202
|
+
if (spec.startsWith('__REA_DELETE__')) {
|
|
203
|
+
isDelete = true;
|
|
204
|
+
spec = spec.slice('__REA_DELETE__'.length);
|
|
205
|
+
}
|
|
206
|
+
if (spec.startsWith('+'))
|
|
207
|
+
spec = spec.slice(1);
|
|
208
|
+
let src;
|
|
209
|
+
let dst;
|
|
210
|
+
if (spec.includes(':')) {
|
|
211
|
+
src = spec.slice(0, spec.indexOf(':'));
|
|
212
|
+
dst = spec.slice(spec.lastIndexOf(':') + 1);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
src = spec;
|
|
216
|
+
dst = spec;
|
|
217
|
+
}
|
|
218
|
+
if (dst.length === 0) {
|
|
219
|
+
// `src:` with empty destination — the bash core treated this as a
|
|
220
|
+
// deletion with dst = last component of spec. For safety, reject.
|
|
221
|
+
dst = spec.split(':').pop() ?? '';
|
|
222
|
+
src = '';
|
|
223
|
+
}
|
|
224
|
+
dst = stripRefsPrefix(dst);
|
|
225
|
+
if (isDelete) {
|
|
226
|
+
if (dst.length === 0 || dst === 'HEAD') {
|
|
227
|
+
// Bash-core parity (push-review-core.sh §161-168): delete-mode
|
|
228
|
+
// HEAD/empty destination uses a distinct operator banner —
|
|
229
|
+
// "--delete refspec resolves to HEAD or empty" — rather than the
|
|
230
|
+
// general "refspec resolves to HEAD" message, because the
|
|
231
|
+
// remediation is different ("name the branch you meant to
|
|
232
|
+
// delete", not "name the destination explicitly").
|
|
233
|
+
throw new InvalidDeleteRefspecError(rawSpec);
|
|
234
|
+
}
|
|
235
|
+
records.push({
|
|
236
|
+
local_sha: ZERO_SHA,
|
|
237
|
+
remote_sha: ZERO_SHA,
|
|
238
|
+
local_ref: '(delete)',
|
|
239
|
+
remote_ref: `refs/heads/${dst}`,
|
|
240
|
+
source_is_head: false,
|
|
241
|
+
is_deletion: true,
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (dst === 'HEAD' || dst.length === 0) {
|
|
246
|
+
throw new HeadRefspecBlockedError(rawSpec);
|
|
247
|
+
}
|
|
248
|
+
if (src.length === 0) {
|
|
249
|
+
// `:main` — deletion.
|
|
250
|
+
records.push({
|
|
251
|
+
local_sha: ZERO_SHA,
|
|
252
|
+
remote_sha: ZERO_SHA,
|
|
253
|
+
local_ref: '(delete)',
|
|
254
|
+
remote_ref: `refs/heads/${dst}`,
|
|
255
|
+
source_is_head: false,
|
|
256
|
+
is_deletion: true,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const resolved = deps.resolveHead(src);
|
|
261
|
+
if (resolved === null || !SHA_HEX_40.test(resolved)) {
|
|
262
|
+
throw new BlockedError('PUSH_BLOCKED_SOURCE_UNRESOLVABLE', `could not resolve source ref ${JSON.stringify(src)} to a commit.`, { ref: src });
|
|
263
|
+
}
|
|
264
|
+
records.push({
|
|
265
|
+
local_sha: resolved,
|
|
266
|
+
remote_sha: ZERO_SHA,
|
|
267
|
+
local_ref: `refs/heads/${src}`,
|
|
268
|
+
remote_ref: `refs/heads/${dst}`,
|
|
269
|
+
source_is_head: false,
|
|
270
|
+
is_deletion: false,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// Deletion-first check (defect J): if ANY deletion resolved, the caller
|
|
274
|
+
// will re-check via hasDeletion(). We do NOT throw here because the caller
|
|
275
|
+
// may want to include push-side records in audit metadata before blocking.
|
|
276
|
+
void DeletionBlockedError; // pulled so tree-shaking keeps the export chain.
|
|
277
|
+
return records;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Extract the `git push ...` segment from a command string, stopping at the
|
|
281
|
+
* first shell separator (`;`, `&&`, `||`, `|`, `&`). Returns an empty
|
|
282
|
+
* string when no `git push` is present — the caller bails out upstream.
|
|
283
|
+
*/
|
|
284
|
+
function extractPushSegment(cmd) {
|
|
285
|
+
const pushMatch = cmd.match(/git\s+push(?:\s|$)/);
|
|
286
|
+
if (!pushMatch || pushMatch.index === undefined)
|
|
287
|
+
return '';
|
|
288
|
+
const tail = cmd.slice(pushMatch.index);
|
|
289
|
+
const sepMatch = tail.match(/;|\|{1,2}|&{1,2}/);
|
|
290
|
+
if (sepMatch && sepMatch.index !== undefined) {
|
|
291
|
+
return tail.slice(0, sepMatch.index);
|
|
292
|
+
}
|
|
293
|
+
return tail;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Split a `git push ...` segment into whitespace-separated tokens. This is
|
|
297
|
+
* intentionally naive (no quote handling) — the bash core does the same
|
|
298
|
+
* via `set -- $segment`, and preserving the bug-for-bug shape means we do
|
|
299
|
+
* not silently start accepting quoted refspecs the bash core would have
|
|
300
|
+
* rejected.
|
|
301
|
+
*/
|
|
302
|
+
function tokenizePushSegment(segment) {
|
|
303
|
+
return segment.split(/\s+/).filter((t) => t.length > 0);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Strip `refs/heads/` or `refs/for/` prefixes so caller-facing code sees a
|
|
307
|
+
* bare branch name. Exported for unit tests in `args.test.ts`.
|
|
308
|
+
*/
|
|
309
|
+
export function stripRefsPrefix(ref) {
|
|
310
|
+
if (ref.startsWith('refs/heads/'))
|
|
311
|
+
return ref.slice('refs/heads/'.length);
|
|
312
|
+
if (ref.startsWith('refs/for/'))
|
|
313
|
+
return ref.slice('refs/for/'.length);
|
|
314
|
+
return ref;
|
|
315
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-facing banner composition.
|
|
3
|
+
*
|
|
4
|
+
* The bash core builds its banners via `printf` inside a `{ ... } >&2`
|
|
5
|
+
* block, counting diff lines with `grep -cE ...` and file changes with
|
|
6
|
+
* `grep -c '^+++ '`. Defect K (rea#62) surfaced because `grep -c`
|
|
7
|
+
* emits `0` to stdout AND exits non-zero on no-match, and the bash author
|
|
8
|
+
* wrote `$(grep -c ... || echo 0)` — which emitted `0\n0` when the pipe
|
|
9
|
+
* produced no matches. The fix in the bash core was `|| true` + default
|
|
10
|
+
* via `${LINE_COUNT:-0}`.
|
|
11
|
+
*
|
|
12
|
+
* The TS port closes this entire class of bug: counting happens over an
|
|
13
|
+
* actual string in Node, not via a pipe-on-a-side-effect. The only way
|
|
14
|
+
* LINE_COUNT / FILE_COUNT can ever be wrong now is a test-missed edge in
|
|
15
|
+
* `countChangedLines` or `countChangedFiles` — unit tests in `banner.test.ts`
|
|
16
|
+
* cover the zero case, the empty-diff case, the unicode-filename case, and
|
|
17
|
+
* the `+++ b/-file` edge explicitly.
|
|
18
|
+
*
|
|
19
|
+
* ## Format parity
|
|
20
|
+
*
|
|
21
|
+
* `renderPushReviewRequiredBanner` reproduces the byte-exact output of the
|
|
22
|
+
* bash core's "PUSH REVIEW GATE: Review required..." block, including the
|
|
23
|
+
* cache-disabled fallback branch. A fixture test in `banner.test.ts` asserts
|
|
24
|
+
* the output against a snapshot captured from the 0.10.1 bash core.
|
|
25
|
+
*/
|
|
26
|
+
export interface DiffStats {
|
|
27
|
+
/** Number of `^\+[^+]|^-[^-]` lines in the unified diff. */
|
|
28
|
+
line_count: number;
|
|
29
|
+
/** Number of `^\+\+\+ ` lines (one per changed file). */
|
|
30
|
+
file_count: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Count lines that begin with `+` followed by a non-`+` character, OR `-`
|
|
34
|
+
* followed by a non-`-` character. Bash-core parity (push-review-core.sh
|
|
35
|
+
* §1082): `grep -cE '^\+[^+]|^-[^-]'`. This rejects every line whose
|
|
36
|
+
* SECOND character is the same as the first — not just `+++`/`---`
|
|
37
|
+
* headers, but also pathological `++foo` and `--bar` strings (which bash
|
|
38
|
+
* did not count). Codex pass-1 on phase 1 flagged the earlier too-lax
|
|
39
|
+
* char-1-only TS implementation that would have silently changed the
|
|
40
|
+
* Scope: banner line count vs. bash and broken phase-4 byte compatibility.
|
|
41
|
+
*
|
|
42
|
+
* Empty input → 0. Bare `+` or `-` (single char line) → 0, same as bash
|
|
43
|
+
* (the regex requires a second character).
|
|
44
|
+
*/
|
|
45
|
+
export declare function countChangedLines(diff: string): number;
|
|
46
|
+
/**
|
|
47
|
+
* Count `^\+\+\+ ` header lines (one per file in the diff). Parity with
|
|
48
|
+
* the bash core's `grep -c '^\+\+\+ '`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function countChangedFiles(diff: string): number;
|
|
51
|
+
/**
|
|
52
|
+
* Compute `{line_count, file_count}` over a diff string. Exposed separately
|
|
53
|
+
* so callers can use just the stats without generating the full banner.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeDiffStats(diff: string): DiffStats;
|
|
56
|
+
export interface PushReviewRequiredBannerInput {
|
|
57
|
+
/** The ref being pushed (e.g. `refs/heads/feature/foo` or `HEAD`). */
|
|
58
|
+
source_ref: string;
|
|
59
|
+
/** The source commit SHA (12 chars + rest; full sha expected). */
|
|
60
|
+
source_sha: string;
|
|
61
|
+
/** Target branch / base label (defect N completion surfaces here). */
|
|
62
|
+
target_branch: string;
|
|
63
|
+
/** Resolved merge-base SHA. */
|
|
64
|
+
merge_base: string;
|
|
65
|
+
/** Diff stats — pre-computed by `computeDiffStats`. */
|
|
66
|
+
stats: DiffStats;
|
|
67
|
+
/**
|
|
68
|
+
* The sha256-of-diff cache key. When empty, the banner emits the
|
|
69
|
+
* cache-disabled fallback branch (`Cache is DISABLED on this host`).
|
|
70
|
+
*/
|
|
71
|
+
push_sha: string;
|
|
72
|
+
/** Source branch name for the `rea cache set` hint. */
|
|
73
|
+
source_branch: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compose the "PUSH REVIEW GATE: Review required before pushing" banner.
|
|
77
|
+
* Output goes to stderr via the caller; this function is pure. Returns the
|
|
78
|
+
* exact text the bash core would have printed (including trailing blank
|
|
79
|
+
* line and spacing), so the fixture snapshot can be compared byte-exactly.
|
|
80
|
+
*/
|
|
81
|
+
export declare function renderPushReviewRequiredBanner(input: PushReviewRequiredBannerInput): string;
|
|
82
|
+
export interface ProtectedPathsBlockedBannerInput {
|
|
83
|
+
source_ref: string;
|
|
84
|
+
source_sha: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Compose the "PUSH BLOCKED: protected paths changed — /codex-review
|
|
88
|
+
* required" banner. Pure; exit-2 translation happens in the CLI shim.
|
|
89
|
+
*/
|
|
90
|
+
export declare function renderProtectedPathsBlockedBanner(input: ProtectedPathsBlockedBannerInput): string;
|
|
91
|
+
/**
|
|
92
|
+
* Strip C0 control characters (0x00-0x1F, 0x7F) and C1 (0x80-0x9F) from a
|
|
93
|
+
* string. Used when a banner embeds text from a subprocess's stderr (e.g.
|
|
94
|
+
* the cache-check failure case). Mirrors the `LC_ALL=C tr -d` invocation
|
|
95
|
+
* in the bash core's cache-error path. Codex LOW 5 on the 0.9.4 pass.
|
|
96
|
+
*/
|
|
97
|
+
export declare function stripControlChars(input: string): string;
|