canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How long a create of a branch name its own creator just made answers with that branch rather
3
+ * than a 409, so a request killed between publishing the branch and responding is safe to retry.
4
+ * Dependency-free: the editor applies the same rule when it settles an unanswered create.
5
+ * @internal Exported for tests.
6
+ */
7
+ export const IDEMPOTENT_CREATE_WINDOW_MS = 5 * 60_000;
8
+ /** Whether `branch` can be the result of `userId`'s create: theirs, and inside the window. */
9
+ export function isCreatorsRecentBranch(branch, userId) {
10
+ return (userId !== undefined &&
11
+ branch.createdBy === userId &&
12
+ Date.now() - Date.parse(branch.createdAt) < IDEMPOTENT_CREATE_WINDOW_MS);
13
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { BranchResponse } from './branch.js';
2
+ import { type BranchListItemResponse } from './branch.js';
3
3
  export declare const requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
4
4
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
5
5
  }, "strip", z.ZodTypeAny, {
@@ -10,7 +10,7 @@ export declare const requestChanges: import("./route-builder.js").RouteDefinitio
10
10
  branch: string & {
11
11
  readonly __brand: "BranchName";
12
12
  };
13
- }>, undefined, BranchResponse>;
13
+ }>, undefined, BranchListItemResponse>;
14
14
  export declare const approveBranch: import("./route-builder.js").RouteDefinition<z.ZodObject<{
15
15
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
16
16
  }, "strip", z.ZodTypeAny, {
@@ -21,4 +21,4 @@ export declare const approveBranch: import("./route-builder.js").RouteDefinition
21
21
  branch: string & {
22
22
  readonly __brand: "BranchName";
23
23
  };
24
- }>, undefined, BranchResponse>;
24
+ }>, undefined, BranchListItemResponse>;
@@ -1,4 +1,5 @@
1
1
  import { branchParamSchema } from './validators.js';
2
+ import { toBranchListItem } from './branch.js';
2
3
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
3
4
  import { defineEndpoint } from './route-builder.js';
4
5
  import { syncConvertToDraft } from './github-sync.js';
@@ -17,9 +18,13 @@ const requestChangesHandler = async (gc, ctx, _req, _params) => {
17
18
  branch: { name: branchContext.branch.name, status: 'editing' },
18
19
  });
19
20
  // TODO: Optionally record comment in .canopycms/comments.json when comment system is implemented
20
- return { ok: true, status: 200, data: { branch: updated.branch } };
21
+ return {
22
+ ok: true,
23
+ status: 200,
24
+ data: { branch: toBranchListItem(ctx.services.config, updated.branch) },
25
+ };
21
26
  };
22
- const approveBranchHandler = async (gc, _ctx, _req, _params) => {
27
+ const approveBranchHandler = async (gc, ctx, _req, _params) => {
23
28
  const { branchContext } = gc;
24
29
  if (branchContext.branch.status !== 'submitted') {
25
30
  return {
@@ -33,7 +38,11 @@ const approveBranchHandler = async (gc, _ctx, _req, _params) => {
33
38
  branch: { name: branchContext.branch.name, status: 'approved' },
34
39
  });
35
40
  // TODO: Optionally call githubService.approvePullRequest() when GitHub integration is needed
36
- return { ok: true, status: 200, data: { branch: updated.branch } };
41
+ return {
42
+ ok: true,
43
+ status: 200,
44
+ data: { branch: toBranchListItem(ctx.services.config, updated.branch) },
45
+ };
37
46
  };
38
47
  export const requestChanges = defineEndpoint({
39
48
  namespace: 'workflow',
@@ -43,7 +52,7 @@ export const requestChanges = defineEndpoint({
43
52
  params: branchParamSchema,
44
53
  // body/bodyType removed: comment field was declared but never stored.
45
54
  // Re-add when comment storage is implemented (see TODO in handler).
46
- responseType: 'BranchResponse',
55
+ responseType: 'BranchListItemResponse',
47
56
  response: {},
48
57
  defaultMockData: {
49
58
  branch: {
@@ -64,7 +73,7 @@ export const approveBranch = defineEndpoint({
64
73
  method: 'POST',
65
74
  path: '/:branch/approve',
66
75
  params: branchParamSchema,
67
- responseType: 'BranchResponse',
76
+ responseType: 'BranchListItemResponse',
68
77
  response: {},
69
78
  defaultMockData: {
70
79
  branch: {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { BranchResponse } from './branch.js';
2
+ import { type BranchListItemResponse, type BranchResponse } from './branch.js';
3
3
  export type { BranchMergeResponse } from './branch-merge.js';
4
4
  export declare const WORKFLOW_ROUTES: {
5
5
  readonly getStatus: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -23,7 +23,7 @@ export declare const WORKFLOW_ROUTES: {
23
23
  branch: string & {
24
24
  readonly __brand: "BranchName";
25
25
  };
26
- }>, undefined, BranchResponse>;
26
+ }>, undefined, BranchListItemResponse>;
27
27
  readonly withdraw: import("./route-builder.js").RouteDefinition<z.ZodObject<{
28
28
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
29
29
  }, "strip", z.ZodTypeAny, {
@@ -34,7 +34,7 @@ export declare const WORKFLOW_ROUTES: {
34
34
  branch: string & {
35
35
  readonly __brand: "BranchName";
36
36
  };
37
- }>, undefined, BranchResponse>;
37
+ }>, undefined, BranchListItemResponse>;
38
38
  readonly requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
39
39
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
40
40
  }, "strip", z.ZodTypeAny, {
@@ -45,7 +45,7 @@ export declare const WORKFLOW_ROUTES: {
45
45
  branch: string & {
46
46
  readonly __brand: "BranchName";
47
47
  };
48
- }>, undefined, BranchResponse>;
48
+ }>, undefined, BranchListItemResponse>;
49
49
  readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
50
50
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
51
51
  }, "strip", z.ZodTypeAny, {
@@ -56,7 +56,7 @@ export declare const WORKFLOW_ROUTES: {
56
56
  branch: string & {
57
57
  readonly __brand: "BranchName";
58
58
  };
59
- }>, undefined, BranchResponse>;
59
+ }>, undefined, BranchListItemResponse>;
60
60
  readonly markMerged: import("./route-builder.js").RouteDefinition<z.ZodObject<{
61
61
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
62
62
  }, "strip", z.ZodTypeAny, {
@@ -1,4 +1,5 @@
1
1
  import { branchParamSchema } from './validators.js';
2
+ import { toBranchListItem } from './branch.js';
2
3
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
3
4
  import { withdrawBranch } from './branch-withdraw.js';
4
5
  import { requestChanges, approveBranch } from './branch-review.js';
@@ -8,6 +9,8 @@ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/
8
9
  import { syncSubmitPr } from './github-sync.js';
9
10
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
10
11
  import { isNonFastForwardRejection } from '../utils/git.js';
12
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
13
+ import { submissionEditorFromUser } from '../submission-attribution.js';
11
14
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
12
15
  const { branchContext } = gc;
13
16
  return { ok: true, status: 200, data: { branch: branchContext.branch } };
@@ -49,10 +52,24 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
49
52
  };
50
53
  }
51
54
  // Commit and push changes
55
+ const submitter = submissionEditorFromUser(req.user);
56
+ let changedPaths;
52
57
  try {
53
- await ctx.services.submitBranch({ context: branchContext });
58
+ ;
59
+ ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
54
60
  }
55
61
  catch (err) {
62
+ // Retriable either way: a resubmit commits nothing new and pushes only
63
+ // what has not reached the remote.
64
+ if (err instanceof ContentWriteLockBusyError) {
65
+ return {
66
+ ok: false,
67
+ status: 409,
68
+ error: err.outcome === 'not-run'
69
+ ? `Could not submit "${branchContext.branch.name}" right now: it is being synced with its base branch, or a save is in flight. Nothing was submitted; try again in a moment.`
70
+ : `"${branchContext.branch.name}" was being synced while it was submitted, so the submit may not have completed. Try submitting again.`,
71
+ };
72
+ }
56
73
  const message = getErrorMessage(err);
57
74
  // Full path detail (including branchRoot, an absolute path) to server logs
58
75
  // only; the client only ever sees the sanitized form (API-H2). Credentials
@@ -62,8 +79,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
62
79
  // A non-fast-forward rejection means this branch and the deployment's
63
80
  // local repository have diverged. Retrying the identical push can never
64
81
  // succeed (see isNonFastForwardRejection), so surface 409 instead of the
65
- // generic 500 below. Everything else (network, auth, lock contention)
66
- // keeps the existing 500 path unchanged.
82
+ // generic 500 below. Everything else (network, auth) keeps the 500 path.
67
83
  //
68
84
  // This push targets the deployment's OWN local origin (remote.git), not
69
85
  // GitHub, so the message deliberately states only the observable fact and
@@ -91,7 +107,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
91
107
  };
92
108
  }
93
109
  // Create or update PR (sync via githubService, or async via task queue)
94
- const prResult = await syncSubmitPr(ctx, branchContext);
110
+ const prResult = await syncSubmitPr(ctx, branchContext, { submitter, changedPaths });
95
111
  // Update metadata with status and PR info
96
112
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
97
113
  const updated = await meta.save({
@@ -107,7 +123,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
107
123
  rebaseFailure: undefined,
108
124
  },
109
125
  });
110
- return { ok: true, status: 200, data: { branch: updated.branch } };
126
+ return {
127
+ ok: true,
128
+ status: 200,
129
+ data: { branch: toBranchListItem(ctx.services.config, updated.branch) },
130
+ };
111
131
  };
112
132
  const getBranchStatus = defineEndpoint({
113
133
  namespace: 'workflow',
@@ -136,7 +156,7 @@ const submitBranchForMerge = defineEndpoint({
136
156
  method: 'POST',
137
157
  path: '/:branch/submit',
138
158
  params: branchParamSchema,
139
- responseType: 'BranchResponse',
159
+ responseType: 'BranchListItemResponse',
140
160
  response: {},
141
161
  defaultMockData: {
142
162
  branch: {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { BranchResponse } from './branch.js';
2
+ import { type BranchListItemResponse } from './branch.js';
3
3
  /**
4
4
  * Withdraw a submitted branch, converting the PR to draft and unlocking editing
5
5
  *
@@ -19,4 +19,4 @@ export declare const withdrawBranch: import("./route-builder.js").RouteDefinitio
19
19
  branch: string & {
20
20
  readonly __brand: "BranchName";
21
21
  };
22
- }>, undefined, BranchResponse>;
22
+ }>, undefined, BranchListItemResponse>;
@@ -1,4 +1,5 @@
1
1
  import { branchParamSchema } from './validators.js';
2
+ import { toBranchListItem } from './branch.js';
2
3
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
3
4
  import { defineEndpoint } from './route-builder.js';
4
5
  import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
@@ -60,7 +61,11 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
60
61
  : {}),
61
62
  },
62
63
  });
63
- return { ok: true, status: 200, data: { branch: updated.branch } };
64
+ return {
65
+ ok: true,
66
+ status: 200,
67
+ data: { branch: toBranchListItem(ctx.services.config, updated.branch) },
68
+ };
64
69
  };
65
70
  /**
66
71
  * Withdraw a submitted branch, converting the PR to draft and unlocking editing
@@ -77,7 +82,7 @@ export const withdrawBranch = defineEndpoint({
77
82
  method: 'POST',
78
83
  path: '/:branch/withdraw',
79
84
  params: branchParamSchema,
80
- responseType: 'BranchResponse',
85
+ responseType: 'BranchListItemResponse',
81
86
  response: {},
82
87
  defaultMockData: {
83
88
  branch: {
@@ -45,6 +45,14 @@ export interface BranchListItem extends BranchMetadata {
45
45
  */
46
46
  submitBlocked?: boolean;
47
47
  }
48
+ /**
49
+ * Response type for branch creation and the workflow transitions. Carries the
50
+ * list-item shape (server-computed flags included) so the editor can show the
51
+ * result without waiting for a listing that may lag behind it.
52
+ */
53
+ export type BranchListItemResponse = ApiResponse<{
54
+ branch: BranchListItem;
55
+ }>;
48
56
  /** Response type for listing branches */
49
57
  export type BranchListResponse = ApiResponse<{
50
58
  branches: BranchListItem[];
@@ -129,8 +137,13 @@ export interface CreateBranchBody {
129
137
  description?: string;
130
138
  access?: BranchMetadata['access'];
131
139
  }
140
+ /**
141
+ * Attach server-computed protected-base-branch flags to a branch. Reads config
142
+ * per call so dev-mode refreshActiveBranch() updates are reflected.
143
+ */
144
+ export declare const toBranchListItem: (config: ApiContext["services"]["config"], branch: BranchMetadata) => BranchListItem;
132
145
  /** @internal Exported for tests. */
133
- export declare const createBranchHandler: (ctx: ApiContext, req: ApiRequest, body: z.infer<typeof createBranchBodySchema>) => Promise<BranchResponse>;
146
+ export declare const createBranchHandler: (ctx: ApiContext, req: ApiRequest, body: z.infer<typeof createBranchBodySchema>) => Promise<BranchListItemResponse>;
134
147
  /** @internal Exported for tests. */
135
148
  export declare const listBranchesHandler: (ctx: ApiContext, req: ApiRequest) => Promise<BranchListResponse>;
136
149
  /** @internal Exported for tests. */
@@ -187,7 +200,7 @@ export declare const BRANCH_ROUTES: {
187
200
  allowedUsers?: string[] | undefined;
188
201
  allowedGroups?: string[] | undefined;
189
202
  } | undefined;
190
- }>, BranchResponse>;
203
+ }>, BranchListItemResponse>;
191
204
  readonly delete: import("./route-builder.js").RouteDefinition<z.ZodObject<{
192
205
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
193
206
  }, "strip", z.ZodTypeAny, {
@@ -2,16 +2,19 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { BranchWorkspaceManager } from '../branch-workspace.js';
5
+ import { BranchDirOccupiedError, BranchProvisioningBusyError, deletingDirName, removeLeftoverDir, } from '../branch-provisioning.js';
5
6
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
6
7
  import { withOccFileLock } from '../utils/occ-json-write.js';
8
+ import { isCreatorsRecentBranch } from './branch-create-window.js';
7
9
  import { defineEndpoint } from './route-builder.js';
8
10
  import { createDebugLogger } from '../utils/debug.js';
9
11
  import { clientOperatingStrategy } from '../operating-mode/index.js';
10
12
  import { isNotFoundError, getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
11
13
  import { filePathExists } from '../utils/fs.js';
12
14
  import { isNetworkRemoteUrl } from '../utils/git.js';
13
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from '../paths/index.js';
15
+ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from '../paths/index.js';
14
16
  import { GitManager } from '../git-manager.js';
17
+ import { syncDeleteRemoteBranch } from './github-sync.js';
15
18
  import { branchNameSchema, branchParamSchema } from './validators.js';
16
19
  const log = createDebugLogger({ prefix: 'BranchAPI' });
17
20
  const createBranchBodySchema = z.object({
@@ -87,6 +90,20 @@ const resolveReadOnlyMirrorPath = (ctx, strategy) => {
87
90
  process.env[remoteUrlConfig.envVarName] ??
88
91
  remoteUrlConfig.autoDetectRemotePath);
89
92
  };
93
+ /**
94
+ * Attach server-computed protected-base-branch flags to a branch. Reads config
95
+ * per call so dev-mode refreshActiveBranch() updates are reflected.
96
+ */
97
+ export const toBranchListItem = (config, branch) => {
98
+ const protection = getBranchWriteProtection(config, branch.name, branch.baseBranch, branch.status);
99
+ return {
100
+ ...branch,
101
+ isProtected: protection.isProtected,
102
+ readOnly: protection.readOnly,
103
+ writeBlocked: protection.writeBlocked,
104
+ submitBlocked: protection.submitBlockedIncludingStatus,
105
+ };
106
+ };
90
107
  /** @internal Exported for tests. */
91
108
  export const createBranchHandler = async (ctx, req, body) => {
92
109
  return log.timed('api', 'createBranch', async () => {
@@ -95,15 +112,12 @@ export const createBranchHandler = async (ctx, req, body) => {
95
112
  branchName,
96
113
  userId: req.user.userId,
97
114
  });
98
- // Scope note: the collision guards below (settings-branch collision,
99
- // reserved canopycms-settings- prefix, and the remote-mirror check
100
- // further down) apply ONLY to this user-facing creation path.
101
- // http/handler.ts's auto-create (base/active/settings branches) and
102
- // branch-workspace.ts's loadOrCreateBranchContext (reached from run-time
103
- // content reads and the AI pipeline; build-time reads return the checkout
104
- // and never provision) provision system/known branch names, not
105
- // user-chosen ones, and deliberately stay uncovered -- intentional, not
106
- // an oversight.
115
+ // Scope note: the remote-mirror check further down applies ONLY to this
116
+ // user-facing creation path; http/handler.ts's auto-create (base/active
117
+ // branches) and loadOrCreateBranchContext provision known names. The two
118
+ // settings-branch checks below give this path a specific 400; every
119
+ // provisioning path, this one included, also refuses a settings branch in
120
+ // BranchWorkspaceManager.openOrCreateBranch.
107
121
  // Prevent git branch name collision with the settings branch. Settings
108
122
  // live in a separate directory but share the same git remote, and
109
123
  // openOrCreateBranch (branch-workspace.ts) uses the SANITIZED name as the
@@ -154,13 +168,9 @@ export const createBranchHandler = async (ctx, req, body) => {
154
168
  error: `Branch name "${branchName}" is reserved: it collides with the /${branchName} API route namespace`,
155
169
  };
156
170
  }
157
- // Reject the base branch name outright. openOrCreateBranch's save() (see
158
- // branch-metadata.ts) field-merges the caller-supplied `access` over an
159
- // EXISTING branch's metadata rather than replacing it, so a request
160
- // naming the base branch would let the caller inject themselves into the
161
- // protected base branch's ACL (gaining e.g. withdraw rights via the
162
- // allowed_by_acl path). No recorded fork point exists yet for a
163
- // not-yet-created branch, so this checks config protection only.
171
+ // Reject the base branch name outright, even before the base branch is
172
+ // provisioned. No recorded fork point exists yet for a not-yet-created
173
+ // branch, so this checks config protection only.
164
174
  const { isProtected } = getBranchProtection(ctx.services.config, branchName);
165
175
  if (isProtected) {
166
176
  return {
@@ -169,14 +179,10 @@ export const createBranchHandler = async (ctx, req, body) => {
169
179
  error: 'Cannot create a branch with the base branch name',
170
180
  };
171
181
  }
172
- // Reject a name collision with ANY existing branch for the same
173
- // field-merge reason: creating over an existing branch name would let
174
- // the caller's `access` ACL overwrite that branch's real ACL instead of
175
- // creating a new, separate branch. Comparison uses the sanitized name
176
- // since that's what's persisted in branch.json (see
177
- // BranchWorkspaceManager.openOrCreateBranch). System branches
178
- // auto-provisioned via http/handler.ts's getBranchContext don't go
179
- // through this handler, so rejecting collisions here doesn't affect them.
182
+ // A name collision with ANY existing branch answers before a clone is paid
183
+ // for. Provisioning never merges into an existing branch either: it reports
184
+ // `exists`, mapped the same way below. Compared sanitized, the form
185
+ // branch.json records.
180
186
  if (!ctx.services.registry) {
181
187
  return {
182
188
  ok: false,
@@ -186,11 +192,7 @@ export const createBranchHandler = async (ctx, req, body) => {
186
192
  }
187
193
  const existingBranch = await ctx.services.registry.get(sanitizeBranchName(branchName));
188
194
  if (existingBranch) {
189
- return {
190
- ok: false,
191
- status: 409,
192
- error: 'A branch with this name already exists',
193
- };
195
+ return existingBranchResponse(ctx, req, existingBranch);
194
196
  }
195
197
  // L2: create-time collision check against this deployment's local
196
198
  // GitHub mirror (`remote.git`). The CMS Lambda has no internet access
@@ -315,19 +317,47 @@ export const createBranchHandler = async (ctx, req, body) => {
315
317
  };
316
318
  }
317
319
  const manager = new BranchWorkspaceManager(ctx.services.config);
318
- const operatingMode = ctx.services.config.mode;
319
- const context = await manager.openOrCreateBranch({
320
- branchName,
321
- mode: operatingMode,
322
- createdBy: req.user.userId,
323
- title: body.title,
324
- description: body.description,
325
- access: body.access,
326
- });
327
- log.debug('api', 'Branch created', { branchName: context.branch.name });
328
- return { ok: true, status: 200, data: { branch: context.branch } };
320
+ let outcome;
321
+ try {
322
+ outcome = await manager.provisionBranch({
323
+ branchName,
324
+ mode: ctx.services.config.mode,
325
+ createdBy: req.user.userId,
326
+ title: body.title,
327
+ description: body.description,
328
+ access: body.access,
329
+ });
330
+ }
331
+ catch (err) {
332
+ if (err instanceof BranchProvisioningBusyError) {
333
+ return { ok: false, status: 503, error: err.message };
334
+ }
335
+ if (err instanceof BranchDirOccupiedError) {
336
+ return { ok: false, status: 409, error: err.message };
337
+ }
338
+ throw err;
339
+ }
340
+ if (outcome.kind === 'exists')
341
+ return existingBranchResponse(ctx, req, outcome.context);
342
+ log.debug('api', 'Branch created', { branchName: outcome.context.branch.name });
343
+ return {
344
+ ok: true,
345
+ status: 200,
346
+ data: { branch: toBranchListItem(ctx.services.config, outcome.context.branch) },
347
+ };
329
348
  });
330
349
  };
350
+ /** A create that names an existing branch: 409, unless it is that creator's recent retry. */
351
+ const existingBranchResponse = (ctx, req, existing) => {
352
+ if (isCreatorsRecentBranch(existing.branch, req.user.userId)) {
353
+ return {
354
+ ok: true,
355
+ status: 200,
356
+ data: { branch: toBranchListItem(ctx.services.config, existing.branch) },
357
+ };
358
+ }
359
+ return { ok: false, status: 409, error: 'A branch with this name already exists' };
360
+ };
331
361
  /** @internal Exported for tests. */
332
362
  export const listBranchesHandler = async (ctx, req) => {
333
363
  if (!ctx.services.registry) {
@@ -337,7 +367,10 @@ export const listBranchesHandler = async (ctx, req) => {
337
367
  error: 'Branch registry not initialized — ensure the workspace has been initialized',
338
368
  };
339
369
  }
340
- const allBranches = await ctx.services.registry.list();
370
+ // A settings-branch workspace is never resolvable as a content branch (see
371
+ // http/handler.ts), so one left on disk is hidden rather than listed unopenable.
372
+ const settingsBranch = operatingStrategy(ctx.services.config.mode).getSettingsBranchName(ctx.services.config);
373
+ const allBranches = (await ctx.services.registry.list()).filter((context) => !isSettingsBranchName(context.branch.name, settingsBranch));
341
374
  // The branch the editor should open when none is pinned via URL/config.
342
375
  // Read per-request so dev-mode refreshActiveBranch() updates are reflected.
343
376
  // Sanitized: dev mode detects the RAW git HEAD name (e.g. 'claude/foo'),
@@ -345,24 +378,15 @@ export const listBranchesHandler = async (ctx, req) => {
345
378
  // editor matches defaultBranch against registry names, so return the form
346
379
  // that can actually be found there.
347
380
  const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? ctx.services.config.defaultBaseBranch ?? 'main');
348
- // Attach server-computed protected-base-branch flags; read config per-request
349
- // so dev-mode refreshActiveBranch() updates are reflected here too.
350
- const toListItem = (context) => {
351
- const protection = getBranchWriteProtection(ctx.services.config, context.branch.name, context.branch.baseBranch, context.branch.status);
352
- return {
353
- ...context.branch,
354
- isProtected: protection.isProtected,
355
- readOnly: protection.readOnly,
356
- writeBlocked: protection.writeBlocked,
357
- submitBlocked: protection.submitBlockedIncludingStatus,
358
- };
359
- };
360
381
  // Admins and Reviewers see all branches
361
382
  if (isPrivileged(req.user.groups)) {
362
383
  return {
363
384
  ok: true,
364
385
  status: 200,
365
- data: { branches: allBranches.map(toListItem), defaultBranch },
386
+ data: {
387
+ branches: allBranches.map((context) => toBranchListItem(ctx.services.config, context.branch)),
388
+ defaultBranch,
389
+ },
366
390
  };
367
391
  }
368
392
  // Regular users only see branches they created or have explicit access to
@@ -390,7 +414,10 @@ export const listBranchesHandler = async (ctx, req) => {
390
414
  return {
391
415
  ok: true,
392
416
  status: 200,
393
- data: { branches: visibleBranches.map(toListItem), defaultBranch },
417
+ data: {
418
+ branches: visibleBranches.map((context) => toBranchListItem(ctx.services.config, context.branch)),
419
+ defaultBranch,
420
+ },
394
421
  };
395
422
  };
396
423
  /** @internal Exported for tests. */
@@ -456,48 +483,39 @@ export const deleteBranchHandler = async (ctx, req, params) => {
456
483
  error: 'Cannot delete branch with open pull request',
457
484
  };
458
485
  }
459
- // Delete branch metadata file so it disappears from registry scans.
460
- // Hold the same server-enforced lockfile branch-metadata saves hold
461
- // (see utils/occ-json-write.ts): an unguarded unlink racing a concurrent
462
- // save() would let the save's create path resurrect a phantom branch.json
463
- // inside a deleted branch, which the registry's next scan would list as a
464
- // live branch with no clone. The directory removal happens inside the
465
- // same hold so a racing save cannot slip between unlink and rm either.
486
+ // The branch directory leaves its name by one rename, under the same
487
+ // server-enforced lockfile branch-metadata saves hold (see
488
+ // utils/occ-json-write.ts), so a save() that reaches that lock after the
489
+ // rename fails rather than recreating the tree, and a process killed during
490
+ // the `rm` leaves
491
+ // only a `.deleting-*` directory the worker sweeps, never residue under a
492
+ // name that may be created again.
466
493
  const metadataFile = path.join(branchContext.branchRoot, '.canopy-meta', 'branch.json');
467
494
  let cleanupWarning;
495
+ let deletingPath;
468
496
  try {
469
497
  await withOccFileLock(metadataFile, async () => {
498
+ if (branchContext.branchRoot === branchContext.baseRoot) {
499
+ await fs.unlink(metadataFile).catch((err) => {
500
+ if (!isNotFoundError(err))
501
+ throw err;
502
+ });
503
+ return;
504
+ }
505
+ const target = path.join(branchContext.baseRoot, deletingDirName(path.basename(branchContext.branchRoot)));
470
506
  try {
471
- await fs.unlink(metadataFile);
507
+ await fs.rename(branchContext.branchRoot, target);
508
+ deletingPath = target;
472
509
  }
473
510
  catch (err) {
474
- if (!isNotFoundError(err)) {
475
- console.error(`CanopyCMS: Failed to delete branch metadata for ${branchName}:`, getErrorMessage(err));
476
- }
477
- }
478
- // In multi-branch modes, also delete the entire branch directory.
479
- // Retry transient EFS/NFS errors (ENOTEMPTY from a concurrent writer,
480
- // EBUSY) a few times before giving up -- rm's failure must never be
481
- // swallowed: metadata is gone either way (the branch is logically
482
- // deleted and will no longer appear in listings), but silently
483
- // succeeding here would leave a full orphan clone on disk with
484
- // nothing in the API surfacing its existence.
485
- if (branchContext.branchRoot !== branchContext.baseRoot) {
486
- try {
487
- await fs.rm(branchContext.branchRoot, {
488
- recursive: true,
489
- force: true,
490
- maxRetries: 3,
491
- retryDelay: 100,
492
- });
493
- }
494
- catch (err) {
495
- // sanitizeErrorMessage: this string goes back to the browser in the
496
- // delete response (API-H2 — no absolute EFS paths to clients);
497
- // the console line below keeps the raw detail for server logs.
498
- cleanupWarning = `Failed to fully remove branch directory: ${sanitizeErrorMessage(getErrorMessage(err))}`;
499
- console.error(`CanopyCMS: Failed to delete branch directory for ${branchName}:`, getErrorMessage(err));
500
- }
511
+ if (isNotFoundError(err))
512
+ return;
513
+ // Still deleted logically: without branch.json the branch is unlisted,
514
+ // and the worker quarantines what is left.
515
+ await fs.unlink(metadataFile).catch(() => { });
516
+ // [REDACT] Returned to the browser; the console line keeps the path.
517
+ cleanupWarning = `Failed to remove branch directory: ${sanitizeErrorMessage(getErrorMessage(err))}`;
518
+ console.error(`CanopyCMS: Failed to move branch directory for ${branchName} aside:`, getErrorMessage(err));
501
519
  }
502
520
  });
503
521
  }
@@ -510,6 +528,8 @@ export const deleteBranchHandler = async (ctx, req, params) => {
510
528
  error: `Branch is busy, try again: ${getErrorMessage(err)}`,
511
529
  };
512
530
  }
531
+ if (deletingPath)
532
+ await removeLeftoverDir(deletingPath);
513
533
  // Also delete the branch's local head from the remote.git mirror (the
514
534
  // deployment's local origin). The sync loop deliberately never deletes a
515
535
  // local head (see reconcileTrackedBranches), so THIS is the one explicit
@@ -549,6 +569,12 @@ export const deleteBranchHandler = async (ctx, req, params) => {
549
569
  console.warn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${sanitizedDeleted}, mirror ${mirrorPath}): ${getErrorMessage(err)}`);
550
570
  }
551
571
  }
572
+ // `branchContext` was read before the delete, so it still carries the PR number this gates on.
573
+ if (deletableHead) {
574
+ const warning = await syncDeleteRemoteBranch(ctx, branchContext);
575
+ if (warning)
576
+ cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
577
+ }
552
578
  // Invalidate registry cache so next list() will regenerate without this branch
553
579
  if (!ctx.services.registry) {
554
580
  return {
@@ -646,7 +672,7 @@ const createBranch = defineEndpoint({
646
672
  path: '/branches',
647
673
  body: createBranchBodySchema,
648
674
  bodyType: 'CreateBranchBody',
649
- responseType: 'BranchResponse',
675
+ responseType: 'BranchListItemResponse',
650
676
  response: {},
651
677
  defaultMockData: {
652
678
  branch: {