@afokapu/atdd-bun 0.10.112 → 0.10.114
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/integrity.json +2 -2
- package/package.json +1 -1
- package/src/hooks.ts +141 -4
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.114",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"README.md": "8aefceaa24bee498f39081b2aaf4c0d5c54bc1ed9849c20e1d98d1427df56eab",
|
|
@@ -1269,7 +1269,7 @@
|
|
|
1269
1269
|
"src/cli.ts": "395638ab87565bb10c2beb35988ff1acf686924d13515795156a2e8893b3da88",
|
|
1270
1270
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1271
1271
|
"src/enforce.ts": "c4811abe63efe4568f3cf26e21a152d1537e120cb3ce471daca32367cf6e2cda",
|
|
1272
|
-
"src/hooks.ts": "
|
|
1272
|
+
"src/hooks.ts": "afda4062002846e56d4c3ed0e7d4e18d9dfd59666a25d55eee4b68221f3411ef",
|
|
1273
1273
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1274
1274
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1275
1275
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -259,6 +259,130 @@ const cachedEntry = (text: string, key: string): FindingIdentity[] | null => {
|
|
|
259
259
|
const valid = entry.findings.every(finding => finding && typeof finding.rule_id === "string" && typeof finding.file === "string" && findingFingerprint(finding));
|
|
260
260
|
return valid ? entry.findings : null;
|
|
261
261
|
};
|
|
262
|
+
// Owner-authorized, single-use relaxation of the staged-file-count limit for one atomic delivery (and only that limit).
|
|
263
|
+
// Authorization M-20261104T155526Z-owner-authorization_3fd46f1c; evidence M-20261104T120517Z-atomic-decision-evidence_a871b6f0.
|
|
264
|
+
// The pin lives in reviewed package source, so a consumer-side activation record can only confirm it, never widen it.
|
|
265
|
+
const exceptionSchema = "atdd-bun.staged-file-limit-exception/v1";
|
|
266
|
+
const exceptionPin: Record<string, string> = { project: "score-os", task: "wagon-control-implementation-v2", scope: "staged-file-count", owner_authorization: "M-20261104T155526Z-owner-authorization_3fd46f1c", decision_evidence: "M-20261104T120517Z-atomic-decision-evidence_a871b6f0" };
|
|
267
|
+
const exceptionInventory = [
|
|
268
|
+
"contracts/score-provider/v1/inspection/descriptor.json",
|
|
269
|
+
"contracts/score-provider/v1/inspection/fixture.json",
|
|
270
|
+
"contracts/score-provider/v1/inspection/integrity.json",
|
|
271
|
+
"contracts/score-provider/v1/manifest.json",
|
|
272
|
+
"contracts/score-provider/v1/schemas.json",
|
|
273
|
+
"docs/purpose/journeys/index.adoc",
|
|
274
|
+
"docs/purpose/journeys/svg/journey-control-assessment.svg",
|
|
275
|
+
"docs/purpose/journeys/svg/path-control-assessment-nominal.svg",
|
|
276
|
+
"docs/purpose/journeys/svg/train-score-evaluate-control-refused.svg",
|
|
277
|
+
"docs/purpose/journeys/svg/train-score-evaluate-control-technical-failure.svg",
|
|
278
|
+
"docs/purpose/journeys/svg/train-score-evaluate-control.svg",
|
|
279
|
+
"e2e/interlockings/control-assessment/control-nominal.routes.test.ts",
|
|
280
|
+
"e2e/interlockings/control-assessment/control-refused.routes.test.ts",
|
|
281
|
+
"e2e/interlockings/control-assessment/control-technical-failure.routes.test.ts",
|
|
282
|
+
"plan/_journeys/control-assessment.yaml",
|
|
283
|
+
"plan/_themes.yaml",
|
|
284
|
+
"plan/_trains.yaml",
|
|
285
|
+
"plan/_trains/_interlockings/control-assessment.yaml",
|
|
286
|
+
"plan/_trains/train:score:evaluate-control-refused.yaml",
|
|
287
|
+
"plan/_trains/train:score:evaluate-control-technical-failure.yaml",
|
|
288
|
+
"plan/_trains/train:score:evaluate-control.yaml",
|
|
289
|
+
"plan/control/_control.yaml",
|
|
290
|
+
"plan/control/assess-joint-feasibility.yaml",
|
|
291
|
+
"plan/control/C001.yaml",
|
|
292
|
+
"plan/control/D001.yaml",
|
|
293
|
+
"plan/control/E001.yaml",
|
|
294
|
+
"plan/control/estimate-respite.yaml",
|
|
295
|
+
"plan/control/estimate-response-time.yaml",
|
|
296
|
+
"plan/control/evaluate-required-predicates.yaml",
|
|
297
|
+
"plan/control/P001.yaml",
|
|
298
|
+
"plan/control/publish-control-diagnostics.yaml",
|
|
299
|
+
"plan/control/R001.yaml",
|
|
300
|
+
"plan/control/Y001.yaml",
|
|
301
|
+
"src/composition.ts",
|
|
302
|
+
"src/wagons/control/features/assess-joint-feasibility/domain/canonical-joint-feasibility.ts",
|
|
303
|
+
"src/wagons/control/features/estimate-respite/domain/canonical-respite.ts",
|
|
304
|
+
"src/wagons/control/features/estimate-response-time/domain/canonical-response-time.ts",
|
|
305
|
+
"src/wagons/control/features/evaluate-required-predicates/application/evaluate-control.ts",
|
|
306
|
+
"src/wagons/control/features/evaluate-required-predicates/domain/canonical-vector.ts",
|
|
307
|
+
"src/wagons/control/features/publish-control-diagnostics/application/publish-control-diagnostics.ts",
|
|
308
|
+
"tests/fixtures/decision-control-evaluator/v1/canonical-vector.json",
|
|
309
|
+
"tests/trains/score:evaluate-control/canonical-routes.test.ts",
|
|
310
|
+
"tests/wagons/commons/features/measured-aspect/unit/provider-inspection-descriptor.contract.test.ts",
|
|
311
|
+
"tests/wagons/commons/features/measured-aspect/unit/provider-manifest.contract.test.ts",
|
|
312
|
+
"tests/wagons/control/features/assess-joint-feasibility/unit/canonical-joint-feasibility.test.ts",
|
|
313
|
+
"tests/wagons/control/features/estimate-respite/unit/canonical-timeliness.test.ts",
|
|
314
|
+
"tests/wagons/control/features/estimate-response-time/unit/canonical-response-time.test.ts",
|
|
315
|
+
"tests/wagons/control/features/evaluate-required-predicates/unit/canonical-vector.test.ts",
|
|
316
|
+
"tests/wagons/control/features/evaluate-required-predicates/unit/noncanonical.test.ts",
|
|
317
|
+
"tests/wagons/control/features/publish-control-diagnostics/integration/publish-control-diagnostics.test.ts",
|
|
318
|
+
];
|
|
319
|
+
const exceptionFields = ["schema", "inventory", "inventory_sha256", ...Object.keys(exceptionPin)];
|
|
320
|
+
const inventoryHash = (paths: string[]) => new Bun.CryptoHasher("sha256").update([...paths].sort().join("\n")).digest("hex");
|
|
321
|
+
const exceptionInventoryHash = inventoryHash(exceptionInventory);
|
|
322
|
+
/** `reason: null` means no activation record exists: the exception is not in play and the ordinary rejection stands unchanged. */
|
|
323
|
+
type StagedFileExceptionGrant = { ok: true; owner_authorization: string; inventory_sha256: string };
|
|
324
|
+
type StagedFileExceptionDecision = StagedFileExceptionGrant | { ok: false; reason: string | null };
|
|
325
|
+
const refuseException = (reason: string | null): StagedFileExceptionDecision => ({ ok: false, reason });
|
|
326
|
+
type ExceptionRecord = Record<string, unknown> | "missing" | "malformed";
|
|
327
|
+
|
|
328
|
+
async function readExceptionRecord(file: string): Promise<ExceptionRecord> {
|
|
329
|
+
const text = await readFile(file, "utf8").catch(() => null);
|
|
330
|
+
if (text === null) return "missing";
|
|
331
|
+
try { const data = Bun.YAML.parse(text) as unknown; return data && typeof data === "object" && !Array.isArray(data) ? data as Record<string, unknown> : "malformed"; } catch { return "malformed"; }
|
|
332
|
+
}
|
|
333
|
+
const wellFormedExceptionRecord = (record: Record<string, unknown>) => Object.keys(record).length === exceptionFields.length && exceptionFields.every(key => key in record)
|
|
334
|
+
&& record.schema === exceptionSchema && exceptionFields.filter(key => key !== "inventory").every(key => typeof record[key] === "string")
|
|
335
|
+
&& Array.isArray(record.inventory) && record.inventory.every(path => typeof path === "string");
|
|
336
|
+
function exceptionRecordRefusal(record: Record<string, unknown> | "malformed"): string | null {
|
|
337
|
+
if (record === "malformed" || !wellFormedExceptionRecord(record)) return "authorization record is malformed";
|
|
338
|
+
if (Object.entries(exceptionPin).some(([key, value]) => record[key] !== value)) return "authorization record does not match the pinned authorization";
|
|
339
|
+
if (record.inventory_sha256 !== inventoryHash(record.inventory as string[])) return "authorization record inventory hash does not match its inventory";
|
|
340
|
+
return record.inventory_sha256 === exceptionInventoryHash ? null : "authorization record does not match the pinned authorization";
|
|
341
|
+
}
|
|
342
|
+
/** Whether the exact staged path set may exceed the staged-file-count limit. Any doubt refuses. */
|
|
343
|
+
async function authorizeStagedFileException(root: string, staged: string[]): Promise<StagedFileExceptionDecision> {
|
|
344
|
+
const directory = await stagedExceptionDirectory(root), record = directory ? await readExceptionRecord(join(directory, "authorization.yaml")) : "missing";
|
|
345
|
+
if (!directory || record === "missing") return refuseException(null);
|
|
346
|
+
const refusal = exceptionRecordRefusal(record);
|
|
347
|
+
if (refusal) return refuseException(refusal);
|
|
348
|
+
if ((await readExceptionRecord(join(directory, "consumed.yaml"))) !== "missing") return refuseException("authorization was already consumed");
|
|
349
|
+
const outside = staged.filter(path => !exceptionInventory.includes(path)), absent = exceptionInventory.filter(path => !staged.includes(path));
|
|
350
|
+
if (outside.length) return refuseException(`staged paths outside the authorized inventory: ${outside.join(", ")}`);
|
|
351
|
+
if (absent.length) return refuseException(`authorized inventory paths missing from the staged set: ${absent.join(", ")}`);
|
|
352
|
+
return { ok: true, owner_authorization: exceptionPin.owner_authorization!, inventory_sha256: exceptionInventoryHash };
|
|
353
|
+
}
|
|
354
|
+
/** Remember that the exception admitted a commit, so post-commit can consume it only if that exact inventory landed. */
|
|
355
|
+
async function recordPendingException(root: string, grant: StagedFileExceptionGrant) {
|
|
356
|
+
const directory = await stagedExceptionDirectory(root);
|
|
357
|
+
if (!directory) return;
|
|
358
|
+
await mkdir(directory, { recursive: true });
|
|
359
|
+
await writeFile(join(directory, "pending.yaml"), JSON.stringify({ owner_authorization: grant.owner_authorization, inventory_sha256: grant.inventory_sha256 }));
|
|
360
|
+
}
|
|
361
|
+
/** Consume the authorization when HEAD carries exactly the pinned inventory; any pending marker is discarded either way. */
|
|
362
|
+
async function consumePendingException(root: string) {
|
|
363
|
+
const directory = await stagedExceptionDirectory(root);
|
|
364
|
+
if (!directory) return;
|
|
365
|
+
const pending = join(directory, "pending.yaml"), marker = await readExceptionRecord(pending);
|
|
366
|
+
if (marker === "missing") return;
|
|
367
|
+
await rm(pending, { force: true });
|
|
368
|
+
const head = (await git(root, ["rev-parse", "HEAD"])).out, committed = await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]);
|
|
369
|
+
if (marker === "malformed" || marker.owner_authorization !== exceptionPin.owner_authorization || marker.inventory_sha256 !== exceptionInventoryHash || inventoryHash(committed) !== exceptionInventoryHash) return;
|
|
370
|
+
const temporary = join(directory, `consumed.yaml.${process.pid}.tmp`);
|
|
371
|
+
await writeFile(temporary, JSON.stringify({ schema: exceptionSchema, owner_authorization: exceptionPin.owner_authorization, commit: head, inventory_sha256: exceptionInventoryHash, consumed_at: new Date().toISOString() }));
|
|
372
|
+
await rename(temporary, join(directory, "consumed.yaml"));
|
|
373
|
+
}
|
|
374
|
+
/** The staged-file-count gate: the ordinary rejection, unless the pinned exception admits exactly this staged set. */
|
|
375
|
+
async function stagedCountGate(root: string, staged: string[], counted: number, limit: number): Promise<{ rejection: string | null; grant: StagedFileExceptionGrant | null }> {
|
|
376
|
+
if (counted <= limit) return { rejection: null, grant: null };
|
|
377
|
+
const decision = await authorizeStagedFileException(root, staged);
|
|
378
|
+
if (decision.ok) return { rejection: null, grant: decision };
|
|
379
|
+
return { rejection: `staged files ${counted} exceed ${limit}${decision.reason ? `; staged-file exception refused: ${decision.reason}` : ""}`, grant: null };
|
|
380
|
+
}
|
|
381
|
+
/** Where the owner-authorized staged-file exception keeps its record and single-use ledger: the Git common directory, shared by every worktree. */
|
|
382
|
+
async function stagedExceptionDirectory(root: string): Promise<string | null> {
|
|
383
|
+
const common = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
384
|
+
return common.code || !common.out ? null : join(resolve(root, common.out), "atdd-bun", "staged-file-limit-exception");
|
|
385
|
+
}
|
|
262
386
|
/** Findings for one exact tree: reuse a valid entry, otherwise compute and record the result for the next push. */
|
|
263
387
|
async function findingsCacheDirectory(root: string): Promise<string | null> {
|
|
264
388
|
const common = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
@@ -346,16 +470,16 @@ async function primaryPolicy(root: string): Promise<PrimaryPolicy> { const found
|
|
|
346
470
|
const disabledWorktreePolicy = (source: PrimaryPolicy["source"]) => bad(source ? `worktree policy is disabled in primary checkout ${source.path} at ${source.commit}; update the primary checkout and try again` : "worktree policy is disabled");
|
|
347
471
|
export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
|
|
348
472
|
async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch && !(await jjPrimaryOnBranch(primary, cfg.primary_branch))) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
|
|
349
|
-
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
473
|
+
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy, protectedBranches: string[]): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch && !(await jjDeliveryBookmarkAtWorkingCopy(state.current, protectedBranches))) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
350
474
|
|
|
351
475
|
export async function runHook(event: HookEvent, root = process.cwd(), args: string[] = [], stdin = "") {
|
|
352
476
|
const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
|
|
353
477
|
if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
|
|
354
|
-
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
|
|
355
|
-
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0);
|
|
478
|
+
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees, cfg.protected_branches); if (violation) return bad(violation); }
|
|
479
|
+
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); const count = await stagedCountGate(root, staged, counted, cfg.max_staged_files); if (count.rejection) return bad(count.rejection); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const ratchet = await ratchetCommitGate(root, staged, registries.length > 0); if (ratchet) return ratchet; const checked = cfg.require_traceability || registries.length || generated.length ? await validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; if (checked.ok && count.grant) await recordPendingException(root, count.grant); return checked; }
|
|
356
480
|
if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
|
|
357
481
|
if (event === "pre-push") { const reports: string[] = []; let exclusions: Awaited<ReturnType<typeof countExclusionTips>> | undefined; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const isNew = !remoteSha || /^0+$/.test(remoteSha), range = isNew ? `${local}^..${local}` : `${remoteSha}..${local}`, shared = exclusions ??= await countExclusionTips(root, cfg.protected_branches, args[0]); if ("error" in shared) return bad(`could not list remote branch tips for the pushed range ${range}: ${shared.error}`); const counted = await git(root, ["rev-list", "--count", ...(shared.tips.length ? [isNew ? local : range, "--not", ...shared.tips] : [range])]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = remote?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha ?? "", local, cfg.protected_branches, args[0]) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
|
|
358
|
-
if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }
|
|
482
|
+
if (event === "post-commit") { await consumePendingException(root); const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }
|
|
359
483
|
return { ok: true, message: "ok" };
|
|
360
484
|
}
|
|
361
485
|
|
|
@@ -432,6 +556,19 @@ async function jjPrimaryOnBranch(primary: string, branch: string): Promise<boole
|
|
|
432
556
|
return (await lines("@-")).length === 1 && (await lines(`@- & (bookmarks(exact:${name}) | remote_bookmarks(exact:${name}, exact:"origin"))`)).length === 1;
|
|
433
557
|
}
|
|
434
558
|
|
|
559
|
+
// A colocated JJ workspace keeps Git HEAD detached, so the named-branch condition is read from JJ:
|
|
560
|
+
// @ must carry exactly one local, non-conflicted bookmark, and that same name must be the only
|
|
561
|
+
// Git-exported branch at @ and not protected. `--ignore-working-copy` keeps the hook from snapshotting.
|
|
562
|
+
async function jjDeliveryBookmarkAtWorkingCopy(root: string, protectedBranches: string[]): Promise<boolean> {
|
|
563
|
+
if (!existsSync(join(root, ".jj"))) return false;
|
|
564
|
+
const at = await jj(root, ["log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", 'commit_id ++ "\\n" ++ local_bookmarks.map(|b| if(b.conflict(), "conflict:", "") ++ b.name()).join("\\n")']).catch(() => null);
|
|
565
|
+
if (!at || at.code) return false;
|
|
566
|
+
const [commit, ...bookmarks] = at.out.split("\n");
|
|
567
|
+
if (!commit || bookmarks.length !== 1) return false;
|
|
568
|
+
const exported = await branchForCommit(root, commit, []);
|
|
569
|
+
return !("error" in exported) && exported.branch === bookmarks[0] && !protectedBranches.includes(exported.branch);
|
|
570
|
+
}
|
|
571
|
+
|
|
435
572
|
type Layout = { primary: string; worktreeRoot: string; current: string };
|
|
436
573
|
async function jjLayout(root: string, cfg: HookPolicy): Promise<Layout | { error: string }> {
|
|
437
574
|
const current = await realpath(resolve(root));
|