@afokapu/atdd-bun 0.10.111 → 0.10.113

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.111",
2
+ "version": "0.10.113",
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": "fce67a102fe61c15b4100daddf842ce7264d9e0d0e79ec89c4ed4a0b188967b7",
1272
+ "src/hooks.ts": "7e1310502c3f8959fcfaad9f4e5747d146f4f8bb91a14a8722fa207bd2a48629",
1273
1273
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1274
1274
  "src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
1275
1275
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.111",
3
+ "version": "0.10.113",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
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"]);
@@ -352,10 +476,10 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
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
478
  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); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); 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; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
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
 
@@ -411,14 +535,14 @@ async function branchCommit(root: string, branch: string) {
411
535
 
412
536
  // A delivery commit must have one unambiguous non-protected exported bookmark.
413
537
  // Ref ordering is never used as ownership authority.
414
- async function branchForCommit(root: string, commit: string, protectedBranches: string[]): Promise<{ error: string } | { branch: string }> {
538
+ async function branchForCommit(root: string, commit: string, protectedBranches: string[]): Promise<{ error: string; count?: number } | { branch: string }> {
415
539
  const refs = await git(root, ["for-each-ref", "--format=%(refname:short)", "--points-at", commit, "refs/heads/"]);
416
540
  if (refs.code) return { error: refs.err || refs.out };
417
541
  const candidates = refs.out.split("\n").map(line => line.trim()).filter(Boolean)
418
542
  .filter(name => !protectedBranches.includes(name));
419
543
  return candidates.length === 1
420
544
  ? { branch: candidates[0]! }
421
- : { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}` };
545
+ : { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}`, count: candidates.length };
422
546
  }
423
547
 
424
548
  // A JJ-colocated primary keeps Git HEAD detached at its working-copy parent, so JJ state decides:
@@ -482,13 +606,16 @@ async function createWorkspace(state: Layout, target: { leaf: string; target: st
482
606
  return bad(bookmark.err || bookmark.out || `bookmark ${branch} was not exported to the colocated Git repository`);
483
607
  }
484
608
 
485
- async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error: string } | { branch: string; tip: string }> {
609
+ // A branch of null means the delivery bookmark is already gone and the revision is merged.
610
+ type FinishCandidate = { branch: string | null; tip: string };
611
+ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error: string } | FinishCandidate> {
486
612
  if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return { error: `finish must run from a linked workspace beneath ${state.worktreeRoot}` };
487
613
  const conflict = await noConflicts(state.current);
488
614
  if (conflict.error !== undefined) return conflict;
489
615
  const current = await currentCommit(state.current);
490
616
  if (!current) return { error: "could not resolve the current Jujutsu revision" };
491
617
  let delivery = current, found = await branchForCommit(state.current, current, cfg.protected_branches);
618
+ if ("error" in found && found.count === 0 && await mergedIntoPrimary(state, current, cfg)) return { branch: null, tip: current };
492
619
  if ("error" in found) {
493
620
  const changed = await jj(state.current, ["diff", "-r", "@", "--name-only"]);
494
621
  const parent = await jj(state.current, ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
@@ -504,6 +631,11 @@ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error:
504
631
  return undelivered ? { error: undelivered } : { branch: found.branch, tip };
505
632
  }
506
633
 
634
+ // Zero-bookmark retirement is safe only for a revision already equal to or an ancestor of protected main.
635
+ async function mergedIntoPrimary(state: Layout, commit: string, cfg: HookPolicy) {
636
+ return !(await git(state.primary, ["merge-base", "--is-ancestor", commit, cfg.worktrees.primary_branch])).code;
637
+ }
638
+
507
639
  async function jjRevision(root: string, commit: string): Promise<{ error: string } | { empty: boolean; conflict: boolean; parents: string[] }> {
508
640
  const info = await jj(root, ["log", "-r", commit, "--no-graph", "-T", 'if(empty, "empty", "content") ++ " " ++ if(conflict, "conflict", "clean") ++ " " ++ parents.map(|p| p.commit_id()).join(" ")']);
509
641
  if (info.code || !info.out) return { error: `could not inspect Jujutsu revision ${commit}: ${info.err || info.out}` };
@@ -520,7 +652,7 @@ async function deliveryError(state: Layout, branch: string, tip: string, cfg: Ho
520
652
  const info = await jjRevision(state.primary, commit);
521
653
  if ("error" in info) return info.error;
522
654
  if (info.conflict) return `bookmark ${branch} has unresolved conflicts`;
523
- if (!(await git(state.primary, ["merge-base", "--is-ancestor", commit, cfg.worktrees.primary_branch])).code) return null;
655
+ if (await mergedIntoPrimary(state, commit, cfg)) return null;
524
656
  if (!info.empty || info.parents.length !== 1) return `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`;
525
657
  commit = info.parents[0]!;
526
658
  }
@@ -573,9 +705,10 @@ async function finishJjWorktree(root: string, deleteBranch: boolean, cfg: HookPo
573
705
  if ("error" in workspace) return bad(workspace.error);
574
706
  const removed = await jj(state.primary, ["workspace", "remove", workspace.name]);
575
707
  if (removed.code) return bad(removed.err || removed.out);
576
- if (!deleteBranch) return { ok: true, message: `removed merged Jujutsu workspace ${state.current}` };
577
- const deletionError = await deleteBookmark(state, candidate, cfg);
578
- return deletionError ? bad(deletionError) : { ok: true, message: `removed merged Jujutsu workspace ${state.current} and bookmark ${candidate.branch}` };
708
+ const { branch } = candidate;
709
+ if (!deleteBranch || branch === null) return { ok: true, message: `removed merged Jujutsu workspace ${state.current}` };
710
+ const deletionError = await deleteBookmark(state, { branch, tip: candidate.tip }, cfg);
711
+ return deletionError ? bad(deletionError) : { ok: true, message: `removed merged Jujutsu workspace ${state.current} and bookmark ${branch}` };
579
712
  }
580
713
 
581
714