canopycms 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +74 -48
  2. package/dist/api/admin.d.ts +11 -0
  3. package/dist/api/admin.js +2 -0
  4. package/dist/api/archived-branches.d.ts +70 -0
  5. package/dist/api/archived-branches.js +146 -0
  6. package/dist/api/branch-merge.js +16 -1
  7. package/dist/api/branch-status.d.ts +11 -0
  8. package/dist/api/branch-status.js +2 -0
  9. package/dist/api/branch-withdraw.js +1 -1
  10. package/dist/api/branch.js +46 -108
  11. package/dist/api/client.d.ts +25 -21
  12. package/dist/api/client.js +46 -38
  13. package/dist/branch-archive.d.ts +19 -0
  14. package/dist/branch-archive.js +27 -0
  15. package/dist/branch-delete.d.ts +45 -0
  16. package/dist/branch-delete.js +97 -0
  17. package/dist/branch-metadata-file.d.ts +3 -0
  18. package/dist/branch-metadata-file.js +1 -0
  19. package/dist/branch-metadata.d.ts +6 -1
  20. package/dist/branch-metadata.js +15 -2
  21. package/dist/cli/cli.js +2 -0
  22. package/dist/cli/generate-ai-content.js +1 -0
  23. package/dist/editor/BranchManager.d.ts +20 -0
  24. package/dist/editor/BranchManager.js +92 -26
  25. package/dist/editor/Editor.js +49 -25
  26. package/dist/editor/PreviewFrame.d.ts +8 -1
  27. package/dist/editor/PreviewFrame.js +15 -2
  28. package/dist/editor/PreviewPane.d.ts +7 -2
  29. package/dist/editor/PreviewPane.js +6 -5
  30. package/dist/editor/PreviewToolbarTools.d.ts +6 -0
  31. package/dist/editor/PreviewToolbarTools.js +6 -1
  32. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  33. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  34. package/dist/editor/admin/useSystemHealth.js +59 -0
  35. package/dist/editor/branch-status.js +9 -0
  36. package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
  37. package/dist/editor/comments/CommentThreadsPopover.js +9 -1
  38. package/dist/editor/comments/FieldWrapper.js +3 -1
  39. package/dist/editor/copy.d.ts +3 -0
  40. package/dist/editor/copy.js +3 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  42. package/dist/editor/hooks/useBranchManager.js +55 -4
  43. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  44. package/dist/editor/hooks/useBranchesData.js +3 -1
  45. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  46. package/dist/editor/hooks/useEditorLayout.js +5 -0
  47. package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
  48. package/dist/editor/hooks/usePreviewFollow.js +54 -0
  49. package/dist/editor/preview-bridge.d.ts +25 -0
  50. package/dist/editor/preview-bridge.js +175 -0
  51. package/dist/preview.d.ts +2 -2
  52. package/dist/preview.js +1 -1
  53. package/dist/types.d.ts +33 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/worker/archived-sweep.d.ts +23 -0
  57. package/dist/worker/archived-sweep.js +137 -0
  58. package/dist/worker/cms-worker.d.ts +6 -0
  59. package/dist/worker/cms-worker.js +7 -0
  60. package/dist/worker/git-sync.d.ts +4 -4
  61. package/dist/worker/git-sync.js +3 -0
  62. package/dist/worker/github-commits.d.ts +2 -2
  63. package/dist/worker/github-commits.js +5 -2
  64. package/dist/worker/github-gateway-wire.d.ts +2 -2
  65. package/dist/worker/rebase.js +6 -2
  66. package/dist/worker/task-runner.js +7 -4
  67. package/dist/worker/worker-context.d.ts +2 -0
  68. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ import { z } from 'zod';
2
+ import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from '../branch-archive.js';
3
+ import { deleteBranchWorkspace } from '../branch-delete.js';
4
+ import { buildUnmergedArchiveUpdate, getBranchMetadataFileManager } from '../branch-metadata.js';
5
+ import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
6
+ import { isSettingsBranchName } from '../paths/index.js';
7
+ import { getBranchProtection } from '../authorization/index.js';
8
+ import { getErrorMessage } from '../utils/error.js';
9
+ import { defineEndpoint } from './route-builder.js';
10
+ import { branchNameSchema } from './validators.js';
11
+ const archiveBranchParamsSchema = z.object({ branch: branchNameSchema });
12
+ const archiveBranchHandler = async (gc, _ctx) => {
13
+ const { branchContext } = gc;
14
+ const name = branchContext.branch.name;
15
+ const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
16
+ const archived = await meta.update((current) => current?.branch.name === name &&
17
+ current.branch.status !== 'archived' &&
18
+ current.branch.pullRequestState === 'closed'
19
+ ? { branch: buildUnmergedArchiveUpdate() }
20
+ : null);
21
+ if (archived === null) {
22
+ return {
23
+ ok: false,
24
+ status: 409,
25
+ error: 'Only a branch whose pull request closed without merging can be archived this way.',
26
+ };
27
+ }
28
+ return { ok: true, status: 200, data: { branch: { name, status: 'archived' } } };
29
+ };
30
+ /**
31
+ * Archive a branch whose PR closed without merging. A close never changes the status by itself,
32
+ * so this is an admin's decision that the work is abandoned. The handler's checks run on the
33
+ * branch.json read under its lock, where a reopened PR or a resubmit since the guard read refuses
34
+ * it. They also cover the base branch, which has no PR.
35
+ */
36
+ const archiveBranch = defineEndpoint({
37
+ namespace: 'workflow',
38
+ name: 'archive',
39
+ method: 'POST',
40
+ path: '/:branch/archive',
41
+ params: archiveBranchParamsSchema,
42
+ responseType: 'BranchMergeResponse',
43
+ response: {},
44
+ defaultMockData: { branch: { name: 'test-branch', status: 'archived' } },
45
+ guards: ['admin', 'branch'],
46
+ handler: archiveBranchHandler,
47
+ });
48
+ export const ARCHIVE_BRANCH_ROUTES = { archive: archiveBranch };
49
+ /**
50
+ * Wall-clock budget for starting deletes, well inside the CMS Lambda's default 60 s timeout
51
+ * (`DEFAULT_CMS_LAMBDA_TIMEOUT` in canopycms-cdk), so one slow `rm` can still finish. A Lambda
52
+ * killed in an `rm` costs nothing: the branch is already gone, and the worker sweeps what is left.
53
+ * @internal Exported for tests.
54
+ */
55
+ export const DELETE_ARCHIVED_BUDGET_MS = 25_000;
56
+ const deleteArchivedBranchesBodySchema = z.object({
57
+ olderThanDays: z.number().int().min(1),
58
+ branches: z.array(z.string()),
59
+ });
60
+ /** @internal Exported for tests. */
61
+ export const deleteArchivedBranchesHandler = async (_gc, ctx, _req, body) => {
62
+ const { config, registry } = ctx.services;
63
+ if (!clientOperatingStrategy(config.mode).supportsBranching()) {
64
+ return { ok: false, status: 400, error: 'Cannot delete branches in this operating mode' };
65
+ }
66
+ if (!registry) {
67
+ return {
68
+ ok: false,
69
+ status: 400,
70
+ error: 'Branch registry not initialized — ensure the workspace has been initialized',
71
+ };
72
+ }
73
+ const startedAt = Date.now();
74
+ const cutoff = archivedCutoff(body.olderThanDays, startedAt);
75
+ const settingsBranch = operatingStrategy(config.mode).getSettingsBranchName(config);
76
+ const old = (await registry.list()).filter(({ branch }) => archivedBefore(branch, cutoff) &&
77
+ !isSettingsBranchName(branch.name, settingsBranch) &&
78
+ !getBranchProtection(config, branch.name, branch.baseBranch).isProtected);
79
+ const confirmed = new Set(body.branches);
80
+ const due = old.filter(({ branch }) => deletableByAge(branch, cutoff) && confirmed.has(branch.name));
81
+ const heldFor = (hold) => old.filter(({ branch }) => ageDeletionHold(branch) === hold).map(({ branch }) => branch.name);
82
+ const data = {
83
+ deleted: [],
84
+ skipped: { notMerged: heldFor('not-merged'), unpublished: heldFor('unpublished') },
85
+ failed: [],
86
+ remaining: 0,
87
+ warnings: [],
88
+ };
89
+ for (const [index, context] of due.entries()) {
90
+ if (Date.now() - startedAt > DELETE_ARCHIVED_BUDGET_MS) {
91
+ data.remaining = due.length - index;
92
+ break;
93
+ }
94
+ const name = context.branch.name;
95
+ try {
96
+ const outcome = await deleteBranchWorkspace({
97
+ context,
98
+ registry,
99
+ stillWanted: (current) => current.name === name && deletableByAge(current, cutoff),
100
+ });
101
+ if (outcome.kind === 'deleted') {
102
+ data.deleted.push(name);
103
+ if (outcome.cleanupWarning)
104
+ data.warnings.push(`${name}: ${outcome.cleanupWarning}`);
105
+ }
106
+ else {
107
+ data.failed.push(name);
108
+ }
109
+ }
110
+ catch (err) {
111
+ console.error(`CanopyCMS: Failed to delete archived branch ${name}:`, getErrorMessage(err));
112
+ data.failed.push(name);
113
+ }
114
+ }
115
+ // Gone or changed since the admin confirmed them: reported, so nothing reads as deleted.
116
+ const listed = new Set(old.map(({ branch }) => branch.name));
117
+ data.failed.push(...[...confirmed].filter((name) => !listed.has(name)));
118
+ return { ok: true, status: 200, data };
119
+ };
120
+ /**
121
+ * Delete merged, archived branches older than a number of days, by the rule the retention sweep
122
+ * uses. Takes the editor's copy only (`deleteBranchWorkspace`'s `remoteCopies`).
123
+ * Here rather than queued for the worker so the admin gets the answer, and so it works where no
124
+ * worker runs.
125
+ */
126
+ const deleteArchivedBranches = defineEndpoint({
127
+ namespace: 'admin',
128
+ name: 'deleteArchivedBranches',
129
+ method: 'POST',
130
+ path: '/admin/archived-branches/delete',
131
+ body: deleteArchivedBranchesBodySchema,
132
+ bodyType: 'DeleteArchivedBranchesBody',
133
+ responseType: 'DeleteArchivedBranchesResponse',
134
+ response: {},
135
+ defaultMockData: {
136
+ deleted: [],
137
+ skipped: { notMerged: [], unpublished: [] },
138
+ failed: [],
139
+ remaining: 0,
140
+ warnings: [],
141
+ },
142
+ clientTimeout: 'long',
143
+ guards: ['admin'],
144
+ handler: deleteArchivedBranchesHandler,
145
+ });
146
+ export const ADMIN_ARCHIVED_BRANCH_ROUTES = { deleteArchivedBranches };
@@ -27,6 +27,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
27
27
  error: 'Cannot mark as merged: no pull request associated with this branch',
28
28
  };
29
29
  }
30
+ let verifiedMerged = false;
30
31
  if (ctx.services.githubService) {
31
32
  try {
32
33
  const pr = await ctx.services.githubService.getPullRequest(branchContext.branch.pullRequestNumber);
@@ -37,6 +38,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
37
38
  error: `Cannot mark as merged: PR #${branchContext.branch.pullRequestNumber} is not merged on GitHub`,
38
39
  };
39
40
  }
41
+ verifiedMerged = true;
40
42
  }
41
43
  catch (err) {
42
44
  console.error(`CanopyCMS: Failed to verify PR merge status for ${branchName}:`, err);
@@ -45,7 +47,20 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
45
47
  }
46
48
  // Update branch status to 'archived'
47
49
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
48
- await meta.update((current) => current === null ? null : { branch: buildMergedBranchUpdate(current.branch) });
50
+ const saved = await meta.update((current) => current === null ||
51
+ (current.branch.pullRequestState === 'closed' && !verifiedMerged) ||
52
+ (current.branch.status !== 'submitted' && current.branch.status !== 'approved')
53
+ ? null
54
+ : { branch: buildMergedBranchUpdate(current.branch) });
55
+ // `mergedAt` is what lets a branch be deleted by age (branch-archive.ts), so a branch GitHub
56
+ // reported closed unmerged never takes one on an admin's word.
57
+ if (saved === null) {
58
+ return {
59
+ ok: false,
60
+ status: 409,
61
+ error: 'Cannot mark as merged: its pull request was closed without merging, or its status changed. Archive the branch instead.',
62
+ };
63
+ }
49
64
  // Optionally delete remote branch (disabled by default for safety)
50
65
  // if (ctx.services.githubService && req.body?.deleteRemoteBranch) {
51
66
  // try {
@@ -23,6 +23,17 @@ declare const submitBranchBodySchema: z.ZodOptional<z.ZodObject<{
23
23
  /** The submit request body. */
24
24
  export type SubmitBranchBody = z.infer<typeof submitBranchBodySchema>;
25
25
  export declare const WORKFLOW_ROUTES: {
26
+ readonly archive: import("./route-builder.js").RouteDefinition<z.ZodObject<{
27
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
28
+ }, "strip", z.ZodTypeAny, {
29
+ branch: string & {
30
+ readonly __brand: "BranchName";
31
+ };
32
+ }, {
33
+ branch: string & {
34
+ readonly __brand: "BranchName";
35
+ };
36
+ }>, undefined, import("./branch-merge.js").BranchMergeResponse>;
26
37
  readonly getStatus: import("./route-builder.js").RouteDefinition<z.ZodObject<{
27
38
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
28
39
  }, "strip", z.ZodTypeAny, {
@@ -5,6 +5,7 @@ import { getBranchMetadataFileManager } from '../branch-metadata.js';
5
5
  import { withdrawBranch } from './branch-withdraw.js';
6
6
  import { requestChanges, approveBranch } from './branch-review.js';
7
7
  import { markAsMerged } from './branch-merge.js';
8
+ import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
8
9
  import { defineEndpoint } from './route-builder.js';
9
10
  import { canPerformWorkflowAction, getBranchProtection, isAdmin } from '../authorization/index.js';
10
11
  import { syncSubmitPr } from './github-sync.js';
@@ -277,4 +278,5 @@ export const WORKFLOW_ROUTES = {
277
278
  requestChanges: requestChanges,
278
279
  approve: approveBranch,
279
280
  markMerged: markAsMerged,
281
+ ...ARCHIVE_BRANCH_ROUTES,
280
282
  };
@@ -28,7 +28,7 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
28
28
  // non-'editing' status blocks content writes, and a raw workflow.submit would
29
29
  // discard the approval, which the submit status gate refuses (api/branch-status.ts).
30
30
  //
31
- // 'archived' stays refused: the PR is merged and there is nothing to return to.
31
+ // 'archived' stays refused: an archive is the end of the branch, merged or not.
32
32
  const status = branchContext.branch.status;
33
33
  if (status !== 'submitted' && status !== 'approved') {
34
34
  return {
@@ -1,18 +1,18 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
2
  import { z } from 'zod';
4
3
  import { BranchWorkspaceManager } from '../branch-workspace.js';
5
- import { BranchDirOccupiedError, BranchProvisioningBusyError, deletingDirName, removeLeftoverDir, } from '../branch-provisioning.js';
4
+ import { BranchDirOccupiedError, BranchProvisioningBusyError } from '../branch-provisioning.js';
5
+ import { deleteBranchWorkspace } from '../branch-delete.js';
6
6
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
7
- import { withOccFileLock } from '../utils/occ-json-write.js';
8
7
  import { isCreatorsRecentBranch } from './branch-create-window.js';
9
8
  import { defineEndpoint } from './route-builder.js';
10
9
  import { createDebugLogger } from '../utils/debug.js';
11
10
  import { clientOperatingStrategy } from '../operating-mode/index.js';
12
- import { isNotFoundError, getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
11
+ import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
12
+ import { getErrorMessage } from '../utils/error.js';
13
13
  import { filePathExists } from '../utils/fs.js';
14
14
  import { isNetworkRemoteUrl } from '../utils/git.js';
15
- import { parseCreatedBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, isSettingsBranchName, } from '../paths/index.js';
15
+ import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
16
16
  import { GitManager } from '../git-manager.js';
17
17
  import { syncDeleteRemoteBranch } from './github-sync.js';
18
18
  import { branchNameSchema, branchParamSchema } from './validators.js';
@@ -86,7 +86,7 @@ export const canCreateBranch = (user, pathPermissions) => {
86
86
  * being avoided, so it has no read-only equivalent here.
87
87
  *
88
88
  * Shared by createBranchHandler (create-time collision check against the
89
- * mirror) and deleteBranchHandler (removing the deleted branch's stale local
89
+ * mirror) and remoteCopiesOf (removing the deleted branch's stale local
90
90
  * head from the mirror) -- the two halves of the same lifecycle: what create
91
91
  * checks, delete must clean up.
92
92
  */
@@ -137,7 +137,9 @@ export const toBranchListItem = (config, branch, viewer) => {
137
137
  : !inReview
138
138
  ? branch.unpublishedAtMerge
139
139
  ? { kind: 'unpublished-at-merge' }
140
- : undefined
140
+ : branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined
141
+ ? { kind: 'rewritten-at-merge' }
142
+ : undefined
141
143
  : branch.submitUnfinished
142
144
  ? {
143
145
  kind: 'submit-unfinished',
@@ -367,7 +369,7 @@ export const createBranchHandler = async (ctx, req, body) => {
367
369
  // No collision: GitHub does not have this name. Clear any STALE local
368
370
  // head the mirror still carries for it, so this new branch's first
369
371
  // publish can't be rejected non-fast-forward against a leftover tip.
370
- // deleteBranchHandler's cleanup (below) handles the common case, but
372
+ // remoteCopiesOf's cleanup (below) handles the common case, but
371
373
  // cannot cover every ordering: when the branch still existed on
372
374
  // GitHub at editor-delete time, the sync loop's reconcile re-creates
373
375
  // the local head from GitHub's branch within a cycle, and once the
@@ -529,6 +531,9 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
529
531
  },
530
532
  };
531
533
  };
534
+ /** A person's delete, as deletion by age never does, can take commits only the branch still has. */
535
+ const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
536
+ (branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
532
537
  /** @internal Exported for tests. */
533
538
  export const canDeleteBranch = (user, branchContext) => {
534
539
  if (isAdmin(user.groups)) {
@@ -594,106 +599,9 @@ export const deleteBranchHandler = async (ctx, req, params) => {
594
599
  }
595
600
  // It may hold the only copy of commits the merge left out, and deleting it would drop the alert
596
601
  // that says so.
597
- if (branchContext.branch.unpublishedAtMerge && !isAdmin(req.user.groups)) {
598
- return {
599
- ok: false,
600
- status: 409,
601
- error: 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.',
602
- };
603
- }
604
- // The branch directory leaves its name by one rename, under the same
605
- // server-enforced lockfile branch-metadata saves hold (see
606
- // utils/occ-json-write.ts), so a save() that reaches that lock after the
607
- // rename fails rather than recreating the tree, and a process killed during
608
- // the `rm` leaves
609
- // only a `.deleting-*` directory the worker sweeps, never residue under a
610
- // name that may be created again.
611
- const metadataFile = path.join(branchContext.branchRoot, '.canopy-meta', 'branch.json');
612
- let cleanupWarning;
613
- let deletingPath;
614
- try {
615
- await withOccFileLock(metadataFile, async () => {
616
- if (branchContext.branchRoot === branchContext.baseRoot) {
617
- await fs.unlink(metadataFile).catch((err) => {
618
- if (!isNotFoundError(err))
619
- throw err;
620
- });
621
- return;
622
- }
623
- const target = path.join(branchContext.baseRoot, deletingDirName(path.basename(branchContext.branchRoot)));
624
- try {
625
- await fs.rename(branchContext.branchRoot, target);
626
- deletingPath = target;
627
- }
628
- catch (err) {
629
- if (isNotFoundError(err))
630
- return;
631
- // Still deleted logically: without branch.json the branch is unlisted,
632
- // and the worker quarantines what is left.
633
- await fs.unlink(metadataFile).catch(() => { });
634
- // [REDACT] Returned to the browser; the console line keeps the path.
635
- cleanupWarning = `Failed to remove branch directory: ${sanitizeErrorMessage(getErrorMessage(err))}`;
636
- console.error(`CanopyCMS: Failed to move branch directory for ${branchName} aside:`, getErrorMessage(err));
637
- }
638
- });
639
- }
640
- catch (err) {
641
- // Lock acquisition failed (e.g. contention past the retry budget) —
642
- // surface as an error rather than silently skipping the delete.
643
- return {
644
- ok: false,
645
- status: 409,
646
- error: `Branch is busy, try again: ${getErrorMessage(err)}`,
647
- };
602
+ if (mayHoldLostCommits(branchContext.branch) && !isAdmin(req.user.groups)) {
603
+ return { ok: false, status: 409, error: DELETE_NEEDS_ADMIN_REASON };
648
604
  }
649
- if (deletingPath)
650
- await removeLeftoverDir(deletingPath);
651
- // Also delete the branch's local head from the remote.git mirror (the
652
- // deployment's local origin). The sync loop's reconcileTrackedBranches
653
- // removes a head only once GitHub no longer has its branch, so while GitHub
654
- // does, THIS is the path that removes it -- without this, the head outlives
655
- // the branch, and reusing the name after the GitHub side is gone (e.g. a
656
- // squash-merged PR with auto-delete) has the reused branch's first publish
657
- // rejected non-fast-forward against the stale old tip: a permanent,
658
- // misleading 409 -- and a retried submit would then ship the STALE head to
659
- // GitHub as an apparent success (see GitManager.deleteBareRemoteHead's doc
660
- // comment for the full trace). The tracking ref is deliberately left
661
- // alone: while the branch still exists on GitHub, the create-time
662
- // collision check SHOULD keep matching it.
663
- //
664
- // Best-effort, same as the directory removal above: the branch is already
665
- // logically deleted; a mirror we can't reach/write just leaves today's
666
- // status quo behind (with a warning), it must not fail the delete.
667
- const strategy = operatingStrategy(operatingMode);
668
- const mirrorPath = resolveReadOnlyMirrorPath(ctx, strategy);
669
- const sanitizedDeleted = sanitizeBranchName(branchContext.branch.name);
670
- // Defense-in-depth only -- isProtected above already rejects the base
671
- // branch, and settings branches never resolve through getBranchContext.
672
- const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config));
673
- const deletableHead = sanitizedDeleted !== sanitizedBase &&
674
- !sanitizedDeleted.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
675
- if (deletableHead &&
676
- mirrorPath &&
677
- !isNetworkRemoteUrl(mirrorPath) &&
678
- (await filePathExists(mirrorPath))) {
679
- try {
680
- await GitManager.deleteBareRemoteHead(mirrorPath, branchContext.branch.name);
681
- }
682
- catch (err) {
683
- // Client-facing copy is sanitized (API-H2 — no absolute EFS paths);
684
- // the console line keeps the raw detail for server logs.
685
- const warning = `Failed to remove the deleted branch's head from the local mirror: ${sanitizeErrorMessage(getErrorMessage(err))}`;
686
- cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
687
- console.warn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${branchContext.branch.name}, mirror ${mirrorPath}): ${getErrorMessage(err)}`);
688
- }
689
- }
690
- // `branchContext` was read before the delete, so it still carries the PR number this gates on.
691
- if (deletableHead) {
692
- const warning = await syncDeleteRemoteBranch(ctx, branchContext);
693
- if (warning)
694
- cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
695
- }
696
- // Invalidate registry cache so next list() will regenerate without this branch
697
605
  if (!ctx.services.registry) {
698
606
  return {
699
607
  ok: false,
@@ -701,13 +609,43 @@ export const deleteBranchHandler = async (ctx, req, params) => {
701
609
  error: 'Branch registry not initialized — ensure the workspace has been initialized',
702
610
  };
703
611
  }
704
- await ctx.services.registry.invalidate();
612
+ const outcome = await deleteBranchWorkspace({
613
+ context: branchContext,
614
+ registry: ctx.services.registry,
615
+ remoteCopies: remoteCopiesOf(ctx, branchContext),
616
+ });
617
+ if (outcome.kind !== 'deleted') {
618
+ const detail = outcome.kind === 'busy' ? outcome.error : 'it is already being deleted';
619
+ return { ok: false, status: 409, error: `Branch is busy, try again: ${detail}` };
620
+ }
621
+ const { cleanupWarning } = outcome;
705
622
  return {
706
623
  ok: true,
707
624
  status: 200,
708
625
  data: { deleted: true, ...(cleanupWarning && { cleanupWarning }) },
709
626
  };
710
627
  };
628
+ /**
629
+ * A person's delete also takes the branch off GitHub and the mirror: the sync loop's
630
+ * reconcileTrackedBranches removes a head only once GitHub no longer has its branch, so while
631
+ * GitHub does, this is the path that removes it. Without it the head outlives the branch, and
632
+ * reusing the name after the GitHub side is gone has the reused branch's first publish rejected
633
+ * non-fast-forward against the stale tip, and a retried submit ship that stale head to GitHub as
634
+ * an apparent success (see GitManager.deleteBareRemoteHead). The tracking ref stays: while the
635
+ * branch still exists on GitHub, the create-time collision check should keep matching it.
636
+ * `branchContext` was read before the delete, so it still carries the PR number GitHub's delete
637
+ * gates on.
638
+ */
639
+ const remoteCopiesOf = (ctx, branchContext) => ({
640
+ baseBranch: branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config),
641
+ deleteMirrorHead: async () => {
642
+ const mirrorPath = resolveReadOnlyMirrorPath(ctx, operatingStrategy(ctx.services.config.mode));
643
+ if (mirrorPath && !isNetworkRemoteUrl(mirrorPath) && (await filePathExists(mirrorPath))) {
644
+ await GitManager.deleteBareRemoteHead(mirrorPath, branchContext.branch.name);
645
+ }
646
+ },
647
+ deleteOnGitHub: () => syncDeleteRemoteBranch(ctx, branchContext),
648
+ });
711
649
  /** @internal Exported for tests. */
712
650
  export const canModifyBranchAccess = (user, branchContext) => {
713
651
  if (isAdmin(user.groups)) {
@@ -12,6 +12,7 @@ import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse,
12
12
  import type { BranchChangesResponse } from './branch-changes.js';
13
13
  import type { BranchEntryChangeResponse } from './branch-entry-changes.js';
14
14
  import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
15
+ import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
15
16
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
16
17
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
17
18
  import type { ReferenceOptionsResponse } from './reference-options.js';
@@ -23,7 +24,6 @@ import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGrou
23
24
  import type { UserInfoResponse } from './user.js';
24
25
  import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
25
26
  import type { CreateCollectionInput, CreateEntryTypeInput, UpdateCollectionInput, UpdateEntryTypeInput } from './../schema/schema-store-types.js';
26
- import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
27
27
  /** Options for {@link createApiClient}. */
28
28
  export interface ApiClientOptions {
29
29
  /** Base URL for API requests. Defaults to '/api/canopycms'. */
@@ -102,11 +102,35 @@ export declare class CanopyApiClient {
102
102
  approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
103
103
  /** POST /:branch/mark-merged */
104
104
  markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
105
+ /** POST /:branch/archive */
106
+ archive: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
105
107
  /** GET /:branch/status */
106
108
  getStatus: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
107
109
  /** POST /:branch/submit */
108
110
  submit: (params: Record<string, string>, body: SubmitBranchBody, options?: RequestOptions) => Promise<SubmitBranchResponse>;
109
111
  };
112
+ readonly admin: {
113
+ /** POST /admin/archived-branches/delete */
114
+ deleteArchivedBranches: (body: DeleteArchivedBranchesBody, options?: RequestOptions) => Promise<DeleteArchivedBranchesResponse>;
115
+ /** GET /admin/branch-health */
116
+ branchHealth: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchHealthResponse>;
117
+ /** POST /admin/branch-dirs/:dirName/purge */
118
+ purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
119
+ /** POST /admin/branch-dirs/:dirName/repair-metadata */
120
+ repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
121
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
122
+ dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
123
+ /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
124
+ repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
125
+ /** GET /admin/status */
126
+ status: (options?: RequestOptions) => Promise<AdminStatusResponse>;
127
+ /** GET /admin/tasks/:status */
128
+ listTasks: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminTasksResponse>;
129
+ /** POST /admin/tasks/:taskId/retry */
130
+ retryTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminRetryTaskResponse>;
131
+ /** DELETE /admin/tasks/:status/:fileName */
132
+ deleteTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminDeleteTaskResponse>;
133
+ };
110
134
  readonly comments: {
111
135
  /** GET /:branch/comments */
112
136
  list: (params: Record<string, string>, options?: RequestOptions) => Promise<CommentsResponse>;
@@ -199,26 +223,6 @@ export declare class CanopyApiClient {
199
223
  /** POST /:branch/schema/invalidate-cache */
200
224
  invalidateSchemaCache: (params: Record<string, string>, options?: RequestOptions) => Promise<InvalidateSchemaCacheApiResponse>;
201
225
  };
202
- readonly admin: {
203
- /** GET /admin/branch-health */
204
- branchHealth: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchHealthResponse>;
205
- /** POST /admin/branch-dirs/:dirName/purge */
206
- purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
207
- /** POST /admin/branch-dirs/:dirName/repair-metadata */
208
- repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
209
- /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
210
- dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
211
- /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
212
- repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
213
- /** GET /admin/status */
214
- status: (options?: RequestOptions) => Promise<AdminStatusResponse>;
215
- /** GET /admin/tasks/:status */
216
- listTasks: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminTasksResponse>;
217
- /** POST /admin/tasks/:taskId/retry */
218
- retryTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminRetryTaskResponse>;
219
- /** DELETE /admin/tasks/:status/:fileName */
220
- deleteTask: (params: Record<string, string>, options?: RequestOptions) => Promise<AdminDeleteTaskResponse>;
221
- };
222
226
  constructor(options?: ApiClientOptions);
223
227
  private buildPath;
224
228
  /** One API call, 429 resends included, under the deadline `timeout` and `options` give it. */
@@ -68,6 +68,10 @@ export class CanopyApiClient {
68
68
  markMerged: (params, options) => {
69
69
  return this.request('POST', this.buildPath('/:branch/mark-merged', params), undefined, 'standard', options);
70
70
  },
71
+ /** POST /:branch/archive */
72
+ archive: (params, options) => {
73
+ return this.request('POST', this.buildPath('/:branch/archive', params), undefined, 'standard', options);
74
+ },
71
75
  /** GET /:branch/status */
72
76
  getStatus: (params, options) => {
73
77
  return this.request('GET', this.buildPath('/:branch/status', params), undefined, 'standard', options);
@@ -77,6 +81,48 @@ export class CanopyApiClient {
77
81
  return this.request('POST', this.buildPath('/:branch/submit', params), body, 'long', options);
78
82
  },
79
83
  };
84
+ this.admin = {
85
+ /** POST /admin/archived-branches/delete */
86
+ deleteArchivedBranches: (body, options) => {
87
+ return this.request('POST', '/admin/archived-branches/delete', body, 'long', options);
88
+ },
89
+ /** GET /admin/branch-health */
90
+ branchHealth: (params, options) => {
91
+ return this.request('GET', this.buildPath('/admin/branch-health', params), undefined, 'long', options);
92
+ },
93
+ /** POST /admin/branch-dirs/:dirName/purge */
94
+ purgeBranchDir: (params, options) => {
95
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/purge', params), undefined, 'long', options);
96
+ },
97
+ /** POST /admin/branch-dirs/:dirName/repair-metadata */
98
+ repairBranchDir: (params, options) => {
99
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
100
+ },
101
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
102
+ dismissUnpublished: (params, options) => {
103
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
104
+ },
105
+ /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
106
+ repairContentDuplicates: (params, options) => {
107
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
108
+ },
109
+ /** GET /admin/status */
110
+ status: (options) => {
111
+ return this.request('GET', '/admin/status', undefined, 'standard', options);
112
+ },
113
+ /** GET /admin/tasks/:status */
114
+ listTasks: (params, options) => {
115
+ return this.request('GET', this.buildPath('/admin/tasks/:status', params), undefined, 'standard', options);
116
+ },
117
+ /** POST /admin/tasks/:taskId/retry */
118
+ retryTask: (params, options) => {
119
+ return this.request('POST', this.buildPath('/admin/tasks/:taskId/retry', params), undefined, 'standard', options);
120
+ },
121
+ /** DELETE /admin/tasks/:status/:fileName */
122
+ deleteTask: (params, options) => {
123
+ return this.request('DELETE', this.buildPath('/admin/tasks/:status/:fileName', params), undefined, 'standard', options);
124
+ },
125
+ };
80
126
  this.comments = {
81
127
  /** GET /:branch/comments */
82
128
  list: (params, options) => {
@@ -246,44 +292,6 @@ export class CanopyApiClient {
246
292
  return this.request('POST', this.buildPath('/:branch/schema/invalidate-cache', params), undefined, 'standard', options);
247
293
  },
248
294
  };
249
- this.admin = {
250
- /** GET /admin/branch-health */
251
- branchHealth: (params, options) => {
252
- return this.request('GET', this.buildPath('/admin/branch-health', params), undefined, 'long', options);
253
- },
254
- /** POST /admin/branch-dirs/:dirName/purge */
255
- purgeBranchDir: (params, options) => {
256
- return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/purge', params), undefined, 'long', options);
257
- },
258
- /** POST /admin/branch-dirs/:dirName/repair-metadata */
259
- repairBranchDir: (params, options) => {
260
- return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
261
- },
262
- /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
263
- dismissUnpublished: (params, options) => {
264
- return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
265
- },
266
- /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
267
- repairContentDuplicates: (params, options) => {
268
- return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
269
- },
270
- /** GET /admin/status */
271
- status: (options) => {
272
- return this.request('GET', '/admin/status', undefined, 'standard', options);
273
- },
274
- /** GET /admin/tasks/:status */
275
- listTasks: (params, options) => {
276
- return this.request('GET', this.buildPath('/admin/tasks/:status', params), undefined, 'standard', options);
277
- },
278
- /** POST /admin/tasks/:taskId/retry */
279
- retryTask: (params, options) => {
280
- return this.request('POST', this.buildPath('/admin/tasks/:taskId/retry', params), undefined, 'standard', options);
281
- },
282
- /** DELETE /admin/tasks/:status/:fileName */
283
- deleteTask: (params, options) => {
284
- return this.request('DELETE', this.buildPath('/admin/tasks/:status/:fileName', params), undefined, 'standard', options);
285
- },
286
- };
287
295
  this.baseUrl = options.baseUrl ?? '/api/canopycms';
288
296
  // An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
289
297
  this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
@@ -0,0 +1,19 @@
1
+ import type { BranchMetadata } from './types.js';
2
+ type ArchiveFields = Pick<BranchMetadata, 'status' | 'archivedAt' | 'mergedAt' | 'unpublishedAtMerge' | 'rewrittenOnGitHub'>;
3
+ /** When the branch was archived, or undefined for one that records neither time. */
4
+ export declare const archivedTime: (branch: ArchiveFields) => number | undefined;
5
+ /** Archived before `cutoffMs`. `updatedAt` is no clock: any metadata write moves it. */
6
+ export declare const archivedBefore: (branch: ArchiveFields, cutoffMs: number) => boolean;
7
+ /** Why deletion by age leaves a branch for a person to delete. */
8
+ export type AgeDeletionHold = 'not-merged' | 'unpublished';
9
+ /**
10
+ * What keeps a branch from deletion by age, past any age: one archived without merging holds work
11
+ * that reached no base branch, and one merged with an unfinished submit, or rewritten on GitHub,
12
+ * may hold the only copy of commits the merge left out.
13
+ */
14
+ export declare const ageDeletionHold: (branch: ArchiveFields) => AgeDeletionHold | undefined;
15
+ /** The one rule the retention sweep and the admin bulk delete both select by. */
16
+ export declare const deletableByAge: (branch: ArchiveFields, cutoffMs: number) => boolean;
17
+ /** `now` less `days`, as the cutoff the functions above take. */
18
+ export declare const archivedCutoff: (days: number, now: number) => number;
19
+ export {};