@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5
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/package.json +4 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +549 -44
- package/src/config/extension-defaults.yaml +38 -5
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +77 -5
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/copilot-ci-status.mjs +46 -18
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +28 -1
- package/src/loop/gate-fanin.mjs +723 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +194 -13
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +1 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A shell working directory can silently reset to the primary checkout — after a
|
|
6
|
+
* subprocess run, or when a `cd` inside a compound command does not persist. An
|
|
7
|
+
* agent that then runs a relative-path `git commit && git push` executes it in
|
|
8
|
+
* the PRIMARY checkout on the DEFAULT branch, so the change lands straight on
|
|
9
|
+
* the remote's default branch and skips the PR flow entirely. Prose in a
|
|
10
|
+
* contract cannot stop that; the shell never read it.
|
|
11
|
+
*
|
|
12
|
+
* These hooks make the dangerous operation itself fail. Linked worktrees DO run
|
|
13
|
+
* them — git resolves hooks from the common directory, which every worktree
|
|
14
|
+
* shares — so what spares the loop's own work is the branch/ref check, not the
|
|
15
|
+
* hook's absence. A sanctioned release or reconcile sets DEVLOOPS_ALLOW_MAIN=1
|
|
16
|
+
* to proceed deliberately.
|
|
17
|
+
*/
|
|
18
|
+
export const GUARD_MARKER = "dev-loops:default-branch-guard";
|
|
19
|
+
export const GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
|
|
20
|
+
// pre-merge-commit, not pre-commit, is what git runs for `git merge` (a plain
|
|
21
|
+
// `git commit` never fires during a merge); omitting it let a merge onto a
|
|
22
|
+
// guarded branch land while a plain commit on the same branch was refused.
|
|
23
|
+
export const GUARDED_HOOKS = Object.freeze(["pre-commit", "pre-merge-commit", "pre-push"]);
|
|
24
|
+
|
|
25
|
+
const REFUSAL_BODY = (what, branchExpr) => ` echo "dev-loops: refusing to ${what} ($${branchExpr}) from this checkout." >&2
|
|
26
|
+
echo " The dev-loop works in a linked worktree; a cwd that silently reset to the" >&2
|
|
27
|
+
echo " primary checkout is the usual cause. Re-run from the worktree, addressing it" >&2
|
|
28
|
+
echo " explicitly (git -C <absolute-worktree-path> ...)." >&2
|
|
29
|
+
echo " For a sanctioned release or reconcile: ${GUARD_OVERRIDE_ENV}=1 <command>" >&2`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Git's ref rules forbid spaces and control characters but permit `$`, backtick,
|
|
33
|
+
* quotes, `;`, `&`, `|` and parentheses — every one of which sh expands inside
|
|
34
|
+
* the double-quoted assignment below. A branch named `main$(id)` would execute
|
|
35
|
+
* on every commit, and `main$HOME` would expand to something that never matches
|
|
36
|
+
* the real branch, leaving the default silently unguarded. Only names matching
|
|
37
|
+
* this are baked in. An unsafe DEFAULT branch refuses the install; an unsafe EXPLICIT base is dropped (recorded in droppedExplicitBranches) while the default guard installs.
|
|
38
|
+
*/
|
|
39
|
+
const SHELL_SAFE_BRANCH = /^[A-Za-z0-9._/-]+$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a `defaultBranches` argument (a single branch name, an array of
|
|
43
|
+
* them, or nothing) to a deduped array of trimmed, non-empty strings. More
|
|
44
|
+
* than one branch is guarded when a caller's own default (git's advertised
|
|
45
|
+
* `<remote>/HEAD`) and its resolved working base genuinely differ — e.g. a
|
|
46
|
+
* `.devloops` `workflow.baseBranch` of `develop` in a repo whose real default
|
|
47
|
+
* is still `main`: a stray commit on EITHER must be refused.
|
|
48
|
+
*/
|
|
49
|
+
function normalizeBranchList(branches) {
|
|
50
|
+
const list = branches == null ? [] : Array.isArray(branches) ? branches : [branches];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const raw of list) {
|
|
54
|
+
if (typeof raw !== "string") continue;
|
|
55
|
+
const trimmed = raw.trim();
|
|
56
|
+
if (trimmed.length === 0 || seen.has(trimmed)) continue;
|
|
57
|
+
seen.add(trimmed);
|
|
58
|
+
out.push(trimmed);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {(typeof GUARDED_HOOKS)[number]} hookName one of GUARDED_HOOKS —
|
|
65
|
+
* "pre-commit", "pre-merge-commit", or "pre-push".
|
|
66
|
+
* @param {string|string[]|null} defaultBranches the STICKY set, resolved at
|
|
67
|
+
* INSTALL time and unioned across installs by installDefaultBranchGuard.
|
|
68
|
+
* Baking them in beats re-deriving in shell: `origin/HEAD` is often absent,
|
|
69
|
+
* and a main-or-master guess picks a stale local `main` in a `master` repo,
|
|
70
|
+
* which would guard the wrong branch and leave the real default open.
|
|
71
|
+
* @param {string|string[]|null} explicitBranches an operator-scoped set
|
|
72
|
+
* (e.g. an explicit `--base`) that REPLACES rather than unions across
|
|
73
|
+
* installs — see installDefaultBranchGuard's explicitBaseBranches.
|
|
74
|
+
* @throws {Error} when hookName is not a guarded hook, or any branch is
|
|
75
|
+
* non-empty and not shell-safe. `hookName` and every branch name are
|
|
76
|
+
* interpolated straight into the generated script, so THIS function — not
|
|
77
|
+
* just its installDefaultBranchGuard caller — is the trust boundary and
|
|
78
|
+
* must refuse on its own rather than rely on every caller re-checking first.
|
|
79
|
+
*/
|
|
80
|
+
export function renderGuardHook(hookName, defaultBranches = null, explicitBranches = null) {
|
|
81
|
+
if (!GUARDED_HOOKS.includes(hookName)) {
|
|
82
|
+
throw new Error(`renderGuardHook: unknown hook ${JSON.stringify(hookName)}; expected one of ${GUARDED_HOOKS.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
const branches = normalizeBranchList(defaultBranches);
|
|
85
|
+
const explicits = normalizeBranchList(explicitBranches);
|
|
86
|
+
const unsafe = [...branches, ...explicits].find((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
87
|
+
if (unsafe) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`default branch ${JSON.stringify(unsafe)} contains characters the generated hook's shell would expand; refusing to render a hook that could execute it`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const defaults = branches.join(" ");
|
|
93
|
+
const explicitDefaults = explicits.join(" ");
|
|
94
|
+
const header = `#!/bin/sh
|
|
95
|
+
# ${GUARD_MARKER}
|
|
96
|
+
# Refuses a ${hookName} that would land on a guarded default branch. Installed
|
|
97
|
+
# in the common hook directory, so linked worktrees run it too — their branch
|
|
98
|
+
# is not one of the guarded ones, which is what lets their work through.
|
|
99
|
+
if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
|
|
100
|
+
exit 0
|
|
101
|
+
fi
|
|
102
|
+
defaults="${defaults}"
|
|
103
|
+
explicit_defaults="${explicitDefaults}"
|
|
104
|
+
if [ -z "$defaults" ] && [ -z "$explicit_defaults" ]; then
|
|
105
|
+
# Not resolvable at install time; fail OPEN rather than guess a branch and
|
|
106
|
+
# protect the wrong one. The install reports this so it is not silent.
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
// pre-commit and pre-merge-commit both fire with HEAD already on the branch
|
|
112
|
+
// the commit would land on (a plain commit vs. finishing a merge) — same
|
|
113
|
+
// check, just under the name git actually invokes for each operation.
|
|
114
|
+
if (hookName !== "pre-push") {
|
|
115
|
+
return `${header}
|
|
116
|
+
# Full ref, not --short: git DISAMBIGUATES a --short symbolic-ref to
|
|
117
|
+
# "heads/main" when a tag also named "main" exists, so comparing the short
|
|
118
|
+
# form against a bare branch name would never match and let the commit land.
|
|
119
|
+
branch=$(git symbolic-ref --quiet HEAD 2>/dev/null) || exit 0
|
|
120
|
+
for default in $defaults $explicit_defaults; do
|
|
121
|
+
if [ "$branch" = "refs/heads/$default" ]; then
|
|
122
|
+
${REFUSAL_BODY("commit on the default branch", "default")}
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
done
|
|
126
|
+
exit 0
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// pre-push receives "<local ref> <local sha> <remote ref> <remote sha>" per
|
|
131
|
+
// line on stdin. Checking the CURRENT branch instead would miss every
|
|
132
|
+
// explicit refspec — `git push origin HEAD:main` from a feature branch is the
|
|
133
|
+
// exact shape that moved a remote default in testing.
|
|
134
|
+
return `${header}
|
|
135
|
+
blocked=0
|
|
136
|
+
blocked_default=""
|
|
137
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
138
|
+
[ -n "$remote_ref" ] || continue
|
|
139
|
+
for default in $defaults $explicit_defaults; do
|
|
140
|
+
if [ "$remote_ref" = "refs/heads/$default" ]; then
|
|
141
|
+
blocked=1
|
|
142
|
+
blocked_default="$default"
|
|
143
|
+
fi
|
|
144
|
+
done
|
|
145
|
+
done
|
|
146
|
+
if [ "$blocked" = "1" ]; then
|
|
147
|
+
${REFUSAL_BODY("push to the default branch", "blocked_default")}
|
|
148
|
+
exit 1
|
|
149
|
+
fi
|
|
150
|
+
exit 0
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Ownership is decided by the marker on a line of its OWN, exactly as the
|
|
155
|
+
// renderer emits it. A bare substring test would claim any file that merely
|
|
156
|
+
// mentions the sentinel — a user wrapper commented "# chains to
|
|
157
|
+
// dev-loops:default-branch-guard" reads as ours and gets overwritten, which is
|
|
158
|
+
// the one thing installDefaultBranchGuard promises never to do.
|
|
159
|
+
const GUARD_MARKER_LINE = new RegExp(`^# ${GUARD_MARKER}$`, "mu");
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pull a baked-in branch list back out of a line this guard itself wrote
|
|
163
|
+
* (`defaults="..."` or `explicit_defaults="..."`), re-validating every entry
|
|
164
|
+
* against SHELL_SAFE_BRANCH rather than trusting the file. A hand-edited (or
|
|
165
|
+
* otherwise corrupted) baked value would otherwise make renderGuardHook THROW
|
|
166
|
+
* on re-install — after earlier hook slots may already have been renamed into
|
|
167
|
+
* place — which breaks the documented "guard.ok: false means nothing was
|
|
168
|
+
* written" invariant. Dropping the unsafe entry here keeps the contract:
|
|
169
|
+
* install refuses on genuinely new bad input, and self-heals a tampered file.
|
|
170
|
+
*/
|
|
171
|
+
function extractBakedBranches(contents, varName) {
|
|
172
|
+
const match = contents.match(new RegExp(`^${varName}="([^"]*)"$`, "mu"));
|
|
173
|
+
if (!match) return [];
|
|
174
|
+
return match[1].split(/\s+/u).filter((branch) => branch.length > 0 && SHELL_SAFE_BRANCH.test(branch));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readHookState(hookPath) {
|
|
178
|
+
if (!fs.existsSync(hookPath)) return { ours: true, absent: true, existingBranches: [], existingExplicitBranches: [] };
|
|
179
|
+
const contents = fs.readFileSync(hookPath, "utf8");
|
|
180
|
+
const ours = GUARD_MARKER_LINE.test(contents);
|
|
181
|
+
return {
|
|
182
|
+
ours,
|
|
183
|
+
absent: false,
|
|
184
|
+
existingBranches: ours ? extractBakedBranches(contents, "defaults") : [],
|
|
185
|
+
existingExplicitBranches: ours ? extractBakedBranches(contents, "explicit_defaults") : [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Install the guard hooks into a repository's hook directory.
|
|
191
|
+
*
|
|
192
|
+
* Idempotent: re-installing rewrites only hooks this guard authored. A hook the
|
|
193
|
+
* user (or another tool) wrote is NEVER clobbered — that file is left exactly as
|
|
194
|
+
* found and reported as `skipped`, because silently replacing someone's hook is
|
|
195
|
+
* a worse failure than not installing ours.
|
|
196
|
+
*
|
|
197
|
+
* `defaultBranches` and `explicitBaseBranches` are tracked as two SEPARATE
|
|
198
|
+
* slots baked into every hook, because they need opposite persistence rules:
|
|
199
|
+
* - `defaultBranches` (a repo's own default) is UNIONED across installs — a
|
|
200
|
+
* later call resolving fewer/none (a transient fetch hiccup) must never
|
|
201
|
+
* un-guard a branch an earlier install already protected.
|
|
202
|
+
* - `explicitBaseBranches` (an operator's `--base`, or a configured
|
|
203
|
+
* `workflow.baseBranch`) is REPLACED wholesale by whatever this call
|
|
204
|
+
* passes — a later call with a different (or no) explicit base must be
|
|
205
|
+
* able to replace or drop it, never stack it forever. Unioning this slot
|
|
206
|
+
* too would permanently guard every branch anyone ever stacked a worktree
|
|
207
|
+
* off, refusing that branch's OWN commits with no way to undo it.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ gitDir: string, defaultBranches?: string|string[]|null, explicitBaseBranches?: string|string[]|null, hooksPathOverride?: string|null }} target
|
|
210
|
+
* `hooksPathOverride` is the repo's `core.hooksPath` when set. Installing into
|
|
211
|
+
* `$GIT_DIR/hooks` while git reads elsewhere would report success for a guard
|
|
212
|
+
* that can never fire, so that case refuses instead.
|
|
213
|
+
*/
|
|
214
|
+
export function installDefaultBranchGuard({
|
|
215
|
+
gitDir,
|
|
216
|
+
defaultBranches = null,
|
|
217
|
+
explicitBaseBranches = null,
|
|
218
|
+
hooksPathOverride = null,
|
|
219
|
+
}) {
|
|
220
|
+
const refuse = (reason, skipReason) => ({
|
|
221
|
+
ok: false,
|
|
222
|
+
installed: [],
|
|
223
|
+
refreshed: [],
|
|
224
|
+
skipped: GUARDED_HOOKS.map((hook) => ({ hook, reason: skipReason })),
|
|
225
|
+
reason,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Any STRING means core.hooksPath is set (`git config --get` exits 0), even
|
|
229
|
+
// to "" — which git treats as "run no hooks at all", not "unset". A caller
|
|
230
|
+
// that collapsed exit-0-empty and exit-1-unset to the same null would make
|
|
231
|
+
// this refusal unreachable for exactly the config value it exists to catch.
|
|
232
|
+
if (typeof hooksPathOverride === "string") {
|
|
233
|
+
const configured = hooksPathOverride.trim();
|
|
234
|
+
return configured.length > 0
|
|
235
|
+
? refuse(
|
|
236
|
+
`core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
|
|
237
|
+
`core.hooksPath is set to ${JSON.stringify(configured)}, so hooks in $GIT_DIR/hooks would never run`,
|
|
238
|
+
)
|
|
239
|
+
: refuse(
|
|
240
|
+
`core.hooksPath is set to an empty string — git runs no hooks at all; unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
|
|
241
|
+
"core.hooksPath is set to an empty string, so git runs no hooks at all",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A relative or empty gitDir resolves against the caller's cwd, which writes a
|
|
246
|
+
// stray hooks/ directory into the working tree and reports success for a guard
|
|
247
|
+
// git will never read.
|
|
248
|
+
if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
|
|
249
|
+
return refuse(
|
|
250
|
+
`gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`,
|
|
251
|
+
"no absolute git directory to install into",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Absoluteness alone does not make it a GIT dir: a caller slip (the worktree
|
|
256
|
+
// root instead of its .git) would otherwise pass, then mkdirSync a stray
|
|
257
|
+
// hooks/ tree there and report success for a guard git will never read.
|
|
258
|
+
// Every real git dir — bare, a main checkout, or a linked worktree's own
|
|
259
|
+
// gitdir — has a HEAD file; that is the cheapest reliable probe.
|
|
260
|
+
if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
|
|
261
|
+
return refuse(
|
|
262
|
+
`gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`,
|
|
263
|
+
"gitDir does not look like a git directory",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A linked worktree's OWN per-worktree gitdir (`.git/worktrees/<name>`) also
|
|
268
|
+
// has a HEAD file, so the probe above alone lets it through — hooks written
|
|
269
|
+
// there are never resolved by git (hooks always come from the COMMON dir),
|
|
270
|
+
// so this would report ok:true for a guard that can never fire. `commondir`
|
|
271
|
+
// exists only in a linked worktree's own gitdir, never in the common one:
|
|
272
|
+
// a one-line, same-cost discriminator against exactly that gitdir.
|
|
273
|
+
if (fs.existsSync(path.join(gitDir, "commondir"))) {
|
|
274
|
+
return refuse(
|
|
275
|
+
`gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`,
|
|
276
|
+
"gitDir is a linked worktree's own git directory (has a commondir file), not the common one hooks are resolved from",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const branches = normalizeBranchList(defaultBranches);
|
|
281
|
+
const allExplicitBranches = normalizeBranchList(explicitBaseBranches);
|
|
282
|
+
const unsafeBranch = branches.find((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
283
|
+
|
|
284
|
+
if (unsafeBranch) {
|
|
285
|
+
return refuse(
|
|
286
|
+
`default branch ${JSON.stringify(unsafeBranch)} contains characters the generated hook's shell would expand; refusing rather than installing a hook that could execute it or silently guard the wrong branch`,
|
|
287
|
+
"the resolved default branch name is not shell-safe",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A shell-unsafe EXPLICIT base (git-legal names like "feat(auth)" or
|
|
292
|
+
// "fix#123") must not kill the whole install — that would leave the real
|
|
293
|
+
// default branch unguarded while reporting the worktree ok. Drop only the
|
|
294
|
+
// unsafe explicit entries, install the default guard regardless, and record
|
|
295
|
+
// what was dropped so the caller can surface it.
|
|
296
|
+
const droppedExplicitBranches = allExplicitBranches.filter((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
297
|
+
const explicitBranches = allExplicitBranches.filter((branch) => SHELL_SAFE_BRANCH.test(branch));
|
|
298
|
+
|
|
299
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
300
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
301
|
+
|
|
302
|
+
// Read every guarded slot's on-disk state ONCE, up front — not inside the
|
|
303
|
+
// write loop — so the union below is computed repo-wide. Computing it
|
|
304
|
+
// per-hook instead let a slot that just became free (a foreign hook
|
|
305
|
+
// removed) miss what its siblings already had baked in, e.g. a hook
|
|
306
|
+
// re-installed after its foreign occupant is gone could get written INERT
|
|
307
|
+
// while its siblings still enforced the real default.
|
|
308
|
+
const hookStates = GUARDED_HOOKS.map((hook) => ({ hook, hookPath: path.join(hooksDir, hook), ...readHookState(path.join(hooksDir, hook)) }));
|
|
309
|
+
|
|
310
|
+
// Sticky slot: union across whatever this call resolved (`branches`) with
|
|
311
|
+
// what ANY hook of ours already had baked in — never a straight overwrite.
|
|
312
|
+
// A caller's resolution can legitimately come back empty or narrower on a
|
|
313
|
+
// later call (a transient fetch/network hiccup, a remote HEAD that briefly
|
|
314
|
+
// stopped advertising); rewriting to that smaller set would silently
|
|
315
|
+
// un-guard a branch an earlier install already protected.
|
|
316
|
+
const stickyBranches = new Set(branches);
|
|
317
|
+
for (const state of hookStates) {
|
|
318
|
+
if (!state.ours) continue;
|
|
319
|
+
for (const branch of state.existingBranches) stickyBranches.add(branch);
|
|
320
|
+
}
|
|
321
|
+
const finalStickyBranches = [...stickyBranches];
|
|
322
|
+
|
|
323
|
+
// Explicit-base slot: REPLACED wholesale by whatever this call passed, never
|
|
324
|
+
// unioned with what a PRIOR call baked in — that is what makes the slot
|
|
325
|
+
// droppable (a later call with a different, or no, explicit base) instead
|
|
326
|
+
// of accumulating every branch anyone ever stacked a worktree off.
|
|
327
|
+
const finalExplicitBranches = explicitBranches;
|
|
328
|
+
|
|
329
|
+
const installed = [];
|
|
330
|
+
const refreshed = [];
|
|
331
|
+
const skipped = [];
|
|
332
|
+
let anyWritten = false;
|
|
333
|
+
|
|
334
|
+
for (const state of hookStates) {
|
|
335
|
+
const { hook, hookPath, ours, absent } = state;
|
|
336
|
+
if (!ours) {
|
|
337
|
+
skipped.push({ hook, reason: "a pre-existing hook is present and was left untouched" });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// Write + chmod a temp file in the SAME directory, then rename into place.
|
|
341
|
+
// A direct writeFileSync is visible to git mid-write: the common hooks dir
|
|
342
|
+
// is shared, so a concurrent ensureWorktree call (or a real commit racing
|
|
343
|
+
// an install) can exec the file while it is still header-only, falling
|
|
344
|
+
// through to `exit 0` and letting a default-branch commit land. A same-dir
|
|
345
|
+
// rename is atomic, so any reader sees either the old hook or the new one,
|
|
346
|
+
// never a partial one.
|
|
347
|
+
const tmpPath = path.join(hooksDir, `.${hook}.tmp-${process.pid}-${Date.now()}`);
|
|
348
|
+
fs.writeFileSync(tmpPath, renderGuardHook(hook, finalStickyBranches, finalExplicitBranches), { mode: 0o755 });
|
|
349
|
+
fs.chmodSync(tmpPath, 0o755); // mode above is umask-limited; force it
|
|
350
|
+
fs.renameSync(tmpPath, hookPath);
|
|
351
|
+
anyWritten = true;
|
|
352
|
+
(absent ? installed : refreshed).push(hook);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Report only what a hook actually enforces: a branch reads as guarded when
|
|
356
|
+
// at least one slot was WRITTEN with it, never merely requested. Seeding
|
|
357
|
+
// this from `branches` before the loop (the prior bug) claimed enforcement
|
|
358
|
+
// — ok: true, defaultBranches: ["main"] — even when every slot was foreign
|
|
359
|
+
// and nothing was written.
|
|
360
|
+
const reportedBranches = anyWritten ? [...new Set([...finalStickyBranches, ...finalExplicitBranches])] : [];
|
|
361
|
+
let reason;
|
|
362
|
+
if (reportedBranches.length === 0) {
|
|
363
|
+
reason = anyWritten
|
|
364
|
+
? "default branch could not be resolved at install time; the hooks are inert rather than guessing which branch to protect"
|
|
365
|
+
: "every guarded hook slot is already occupied by a foreign hook; nothing was written, so nothing is enforced";
|
|
366
|
+
}
|
|
367
|
+
if (droppedExplicitBranches.length > 0) {
|
|
368
|
+
const dropNote = `explicit base ${droppedExplicitBranches.map((branch) => JSON.stringify(branch)).join(", ")} contains characters the generated hook's shell would expand; the default guard was installed without it`;
|
|
369
|
+
reason = reason ? `${reason}; ${dropNote}` : dropNote;
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
installed,
|
|
374
|
+
refreshed,
|
|
375
|
+
skipped,
|
|
376
|
+
defaultBranches: reportedBranches,
|
|
377
|
+
...(droppedExplicitBranches.length > 0 ? { droppedExplicitBranches } : {}),
|
|
378
|
+
...(reason ? { reason } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -125,7 +125,13 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
125
125
|
const name = typeof angle === "string" ? angle.trim() : "";
|
|
126
126
|
if (name.length === 0) return { kind: "unknown" };
|
|
127
127
|
if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
|
|
128
|
-
|
|
128
|
+
// Case-insensitive: callers key angles as base+lowercase while configs may
|
|
129
|
+
// carry case drift ("Correctness"); normalizing HERE keeps producer and
|
|
130
|
+
// consumer on one predicate instead of each caller pre-lowercasing.
|
|
131
|
+
if (alwaysRerun) {
|
|
132
|
+
const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
|
|
133
|
+
if (normalized.has(name.toLowerCase())) return { kind: "always" };
|
|
134
|
+
}
|
|
129
135
|
const kinds = ANGLE_SURFACE_KINDS.get(name);
|
|
130
136
|
if (!kinds || kinds.size === 0) return { kind: "unknown" };
|
|
131
137
|
return { kind: "kinds", kinds: new Set(kinds) };
|
|
@@ -149,6 +155,24 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
149
155
|
* is carry-forward-eligible.
|
|
150
156
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
151
157
|
*/
|
|
158
|
+
|
|
159
|
+
// A path whose change rewrites the dev-loop review system itself — the angle
|
|
160
|
+
// pool, mandatory floor, and reviewer personas/prompts — rather than a
|
|
161
|
+
// reviewed surface. A clean verdict produced under the OLD config cannot
|
|
162
|
+
// carry across such a delta, and a converged Copilot round cannot be treated
|
|
163
|
+
// as still-converged either. classifyFile correctly reports these as
|
|
164
|
+
// "config"; this predicate is the carry-forward-specific override. The
|
|
165
|
+
// shipped defaults file is in this class too: it is the layer that ships the
|
|
166
|
+
// angle pool and reviewer prompts, so a delta touching it must never be
|
|
167
|
+
// carried across or reviewed under a reduced diff-class tier.
|
|
168
|
+
const DEV_LOOP_CONFIG_SOURCE_RE = /(^|\/)(\.devloops(\.(ya?ml|json))?|\.pi\/dev-loop\/(settings|defaults)\.[^/]+|packages\/core\/src\/config\/extension-defaults\.yaml)$/;
|
|
169
|
+
export function isDevLoopConfigSourcePath(filePath) {
|
|
170
|
+
if (typeof filePath !== "string") return false;
|
|
171
|
+
// Normalize Windows separators like classifyFile does, so a
|
|
172
|
+
// ".pi\\dev-loop\\settings.yaml" delta is still detected on Windows.
|
|
173
|
+
return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
153
177
|
if (prevVerdict !== "clean") {
|
|
154
178
|
return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
|
|
@@ -164,6 +188,9 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
|
|
|
164
188
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
165
189
|
}
|
|
166
190
|
for (const file of changedFiles) {
|
|
191
|
+
if (isDevLoopConfigSourcePath(file)) {
|
|
192
|
+
return { carryForward: false, reason: `delta rewrites the dev-loop config source (reviewer pool/prompts): ${file}` };
|
|
193
|
+
}
|
|
167
194
|
const kind = classifyFile(file);
|
|
168
195
|
if (kind === "unknown") {
|
|
169
196
|
return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
|