canopycms 0.7.1 → 0.8.1

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 (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -12,11 +12,15 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
12
12
  import { isNotFoundError, getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
13
13
  import { filePathExists } from '../utils/fs.js';
14
14
  import { isNetworkRemoteUrl } from '../utils/git.js';
15
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from '../paths/index.js';
15
+ import { parseCreatedBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, 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';
19
19
  import { baseBranchOf } from '../utils/base-branch.js';
20
+ import { shortNameOf } from '../branch-param.js';
21
+ import { namespaceAncestors } from '../operating-mode/branch-namespace.js';
22
+ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
23
+ import { readWorkerStatus } from '../task-queue/worker-status.js';
20
24
  import { lookupUserProfiles } from '../auth/user-metadata-lookup.js';
21
25
  const log = createDebugLogger({ prefix: 'BranchAPI' });
22
26
  const createBranchBodySchema = z.object({
@@ -122,8 +126,11 @@ const accessForViewer = (access, viewer) => {
122
126
  */
123
127
  export const toBranchListItem = (config, branch, viewer) => {
124
128
  const protection = getBranchWriteProtection(config, branch.name, branch.baseBranch, branch.status);
129
+ // `review` names the reviewer, whom the editor has no use for.
130
+ const { review: _review, ...listed } = branch;
125
131
  return {
126
- ...branch,
132
+ ...listed,
133
+ shortName: shortNameOf(config, branch.name),
127
134
  ...accessForViewer(branch.access ?? {}, viewer),
128
135
  isProtected: protection.isProtected,
129
136
  readOnly: protection.readOnly,
@@ -131,85 +138,116 @@ export const toBranchListItem = (config, branch, viewer) => {
131
138
  submitBlocked: protection.submitBlockedIncludingStatus,
132
139
  };
133
140
  };
141
+ /**
142
+ * The API's static top-level route segments, which the router ranks above `/:branch`, so a branch
143
+ * whose short name is one would be shadowed. `http/router.test.ts` holds this list to the router.
144
+ * @internal Exported for tests.
145
+ */
146
+ export const TOP_LEVEL_ROUTE_SEGMENTS = [
147
+ 'admin',
148
+ 'assets',
149
+ 'branches',
150
+ 'groups',
151
+ 'permissions',
152
+ 'users',
153
+ 'whoami',
154
+ ];
155
+ /**
156
+ * The names of the branches outside `namespace` a URL can reach, which neither a new branch's
157
+ * short name nor its ref may take, since the base and active branches resolve first and a short
158
+ * name would shadow the rest (branch-param.ts): the base, the configured active branch, every
159
+ * workspace outside the namespace, and the exact names the GitHub gateway protects as the last git
160
+ * sync recorded them in worker-status.json. Namespace
161
+ * workspaces are left out, so a creator's retry of a create that already landed reaches the key
162
+ * check's idempotent answer instead of a refusal.
163
+ */
164
+ const outsideBranchNames = (config, namespace, listed, gatewayProtected) => {
165
+ const base = baseBranchOf(config);
166
+ return new Set([
167
+ base,
168
+ config.defaultActiveBranch ?? base,
169
+ ...listed.map((context) => context.branch.name).filter((name) => !name.startsWith(namespace)),
170
+ ...gatewayProtected,
171
+ ]);
172
+ };
134
173
  /** @internal Exported for tests. */
135
174
  export const createBranchHandler = async (ctx, req, body) => {
136
175
  return log.timed('api', 'createBranch', async () => {
137
- const branchName = body.branch;
176
+ const { config } = ctx.services;
177
+ const strategy = operatingStrategy(config.mode);
178
+ // Every branch an editor creates is in the namespace the GitHub gateway writes
179
+ // (worker/branch-policy.ts): its ref is the namespace followed by the typed name, sanitized.
180
+ let namespace;
181
+ try {
182
+ namespace = strategy.getBranchNamespace(config);
183
+ }
184
+ catch (err) {
185
+ return {
186
+ ok: false,
187
+ status: 500,
188
+ error: `Branches cannot be created until an administrator fixes the branch namespace: ${getErrorMessage(err)}`,
189
+ };
190
+ }
191
+ const { workerStatus } = await readWorkerStatus(getTaskQueueDir(config));
192
+ const gatewayNamespace = workerStatus?.gatewayBranchNamespace;
193
+ // The worker reports the namespace once a git sync reads one from the gateway's health
194
+ // (worker/git-sync.ts). A worker that predates the namespace never reports one, and its rebase
195
+ // loop renames a namespace branch to its directory, which no request then resolves. Where
196
+ // branches publish through the worker, creation waits for the report.
197
+ if (gatewayNamespace === undefined && strategy.supportsPullRequests()) {
198
+ return {
199
+ ok: false,
200
+ status: 503,
201
+ error: "Branches cannot be created yet: the CMS worker has not reported the GitHub gateway's " +
202
+ 'branch namespace. Ask an administrator to check that the worker (canopycms-cdk) runs ' +
203
+ 'this release and that its git sync reaches the gateway, then try again.',
204
+ };
205
+ }
206
+ if (gatewayNamespace !== undefined && gatewayNamespace !== namespace) {
207
+ return {
208
+ ok: false,
209
+ status: 409,
210
+ error: `Branches cannot be created: this CMS creates them in "${namespace}", but the GitHub ` +
211
+ `gateway publishes only branches in "${gatewayNamespace}", so none could be published. ` +
212
+ 'Ask an administrator to make the two agree.',
213
+ };
214
+ }
215
+ const named = parseCreatedBranchName(body.branch, namespace);
216
+ if (!named.ok)
217
+ return nameRefused(400, named.error);
218
+ const { name: branchName, shortName } = named;
219
+ const key = sanitizeBranchName(branchName);
138
220
  log.debug('api', 'Create branch request', {
139
221
  branchName,
140
222
  userId: req.user.userId,
141
223
  });
142
224
  // Scope note: the remote-mirror check further down applies ONLY to this
143
225
  // user-facing creation path; http/handler.ts's auto-create (base/active
144
- // branches) and loadOrCreateBranchContext provision known names. The two
145
- // settings-branch checks below give this path a specific 400; every
226
+ // branches) and loadOrCreateBranchContext provision known names. Every
146
227
  // provisioning path, this one included, also refuses a settings branch in
147
228
  // BranchWorkspaceManager.openOrCreateBranch.
148
- // Prevent git branch name collision with the settings branch. Settings
149
- // live in a separate directory but share the same git remote, and
150
- // openOrCreateBranch (branch-workspace.ts) uses the SANITIZED name as the
151
- // actual git branch name. parseBranchName (via branchNameSchema) permits
152
- // '/', so a raw-string comparison here let a request for
153
- // "canopycms/settings-prod" sail past this check while
154
- // sanitizeBranchName() collapsed it to "canopycms-settings-prod" --
155
- // creating a content branch whose real git ref WAS the settings branch.
156
- // Comparing sanitized forms on both sides closes that bypass.
157
- const strategy = operatingStrategy(ctx.services.config.mode);
158
- const sanitizedRequested = sanitizeBranchName(branchName);
229
+ // Settings live in a separate directory but share the git remote, so a content branch may not
230
+ // take the settings branch's ref. Compared sanitized, the form git and disk use.
159
231
  if (strategy.usesSeparateSettingsBranch()) {
160
- const settingsBranchName = strategy.getSettingsBranchName(ctx.services.config);
161
- if (sanitizedRequested === sanitizeBranchName(settingsBranchName)) {
162
- return {
163
- ok: false,
164
- status: 400,
165
- error: 'Cannot create content branch with settings branch name (git branch name collision)',
166
- };
232
+ const settingsBranchName = strategy.getSettingsBranchName(config);
233
+ if (key === sanitizeBranchName(settingsBranchName)) {
234
+ return nameRefused(400, 'Cannot create content branch with settings branch name (git branch name collision)');
167
235
  }
168
236
  }
169
- // Reserve the WHOLE canopycms-settings- namespace, not just this
170
- // deployment's own settings branch name. Two CanopyCMS deployments can
171
- // share one GitHub repo, each with its own settings branch under this
172
- // prefix; the worker (worker/git-sync.ts) treats any
173
- // `canopycms-settings-*` ref specially (orphan-branch reconcile/push
174
- // logic), so another deployment's settings branch is a real name that
175
- // must not be claimable as a content branch here either.
176
- if (sanitizedRequested.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
177
- return {
178
- ok: false,
179
- status: 400,
180
- error: `Branch names starting with "${RESERVED_SETTINGS_BRANCH_PREFIX}" are reserved for CanopyCMS settings branches`,
181
- };
182
- }
183
- // Reject names that collide with a static top-level API route namespace --
184
- // the router prefers a literal segment over `:branch`, so such a branch
185
- // would be unreachable through its own routes (see
186
- // RESERVED_ROUTE_BRANCH_NAMES). Checked on both the raw and sanitized form
187
- // for the same reason as the settings-branch guard above: the raw name is
188
- // what lands in the `/:branch` URL segment, the sanitized name is what
189
- // becomes the actual git ref.
190
- if (RESERVED_ROUTE_BRANCH_NAMES.includes(branchName) ||
191
- RESERVED_ROUTE_BRANCH_NAMES.includes(sanitizedRequested)) {
192
- return {
193
- ok: false,
194
- status: 400,
195
- error: `Branch name "${branchName}" is reserved: it collides with the /${branchName} API route namespace`,
196
- };
237
+ // The handler answers any `/:branch` segment in the settings range as not found
238
+ // (http/handler.ts), so a branch with such a short name could never be opened.
239
+ if (isSettingsBranchName(shortName, strategy.getSettingsBranchName(config))) {
240
+ return nameRefused(400, `"${shortName}" is a name CanopyCMS keeps for its settings. Choose a different name.`);
197
241
  }
198
242
  // Reject the base branch name outright, even before the base branch is
199
243
  // provisioned. No recorded fork point exists yet for a not-yet-created
200
244
  // branch, so this checks config protection only.
201
- const { isProtected } = getBranchProtection(ctx.services.config, branchName);
202
- if (isProtected) {
203
- return {
204
- ok: false,
205
- status: 400,
206
- error: 'Cannot create a branch with the base branch name',
207
- };
245
+ const { isProtected } = getBranchProtection(config, branchName);
246
+ if (isProtected)
247
+ return nameRefused(400, 'Cannot create a branch with the base branch name');
248
+ if (TOP_LEVEL_ROUTE_SEGMENTS.includes(shortName)) {
249
+ return nameRefused(400, `"${shortName}" is a name CanopyCMS uses for itself. Choose a different name.`);
208
250
  }
209
- // A name collision with ANY existing branch answers before a clone is paid
210
- // for. Provisioning never merges into an existing branch either: it reports
211
- // `exists`, mapped the same way below. Compared sanitized, the form
212
- // branch.json records.
213
251
  if (!ctx.services.registry) {
214
252
  return {
215
253
  ok: false,
@@ -217,9 +255,17 @@ export const createBranchHandler = async (ctx, req, body) => {
217
255
  error: 'Branch registry not initialized — ensure the workspace has been initialized',
218
256
  };
219
257
  }
220
- const existingBranch = await ctx.services.registry.get(sanitizeBranchName(branchName));
258
+ const listed = await ctx.services.registry.list();
259
+ const outside = outsideBranchNames(config, namespace, listed, workerStatus?.gatewayProtectedBranches ?? []);
260
+ if (outside.has(shortName) || outside.has(branchName)) {
261
+ return nameRefused(409, `"${shortName}" is already the name of another branch. Choose a different name.`);
262
+ }
263
+ // A name collision with ANY existing branch answers before a clone is paid
264
+ // for. Provisioning never merges into an existing branch either: it reports
265
+ // `exists`, mapped the same way below. Compared by key, the workspace directory.
266
+ const existingBranch = await ctx.services.registry.getByKey(key);
221
267
  if (existingBranch) {
222
- return existingBranchResponse(ctx, req, existingBranch);
268
+ return existingBranchResponse(ctx, req, existingBranch, named);
223
269
  }
224
270
  // L2: create-time collision check against this deployment's local
225
271
  // GitHub mirror (`remote.git`). The CMS Lambda has no internet access
@@ -231,10 +277,11 @@ export const createBranchHandler = async (ctx, req, body) => {
231
277
  // then reconciles refs/heads/* without touching editor work, so it carries both
232
278
  // this deployment's local heads and GitHub's view -- readable offline by
233
279
  // this same Lambda, since it resolves to the same EFS inode the worker
234
- // uses. Reading it here catches a sanitized-name collision with a branch
235
- // another CanopyCMS deployment sharing this repo (or a direct push to
236
- // GitHub) already created -- something the local registry check above
237
- // cannot see.
280
+ // uses. Reading it here catches a collision with a branch another
281
+ // CanopyCMS deployment sharing this repo (or a direct push to GitHub)
282
+ // already created -- something the local registry check above cannot
283
+ // see -- and a branch named after part of the namespace, which git
284
+ // cannot hold beside any branch in it.
238
285
  //
239
286
  const resolvedMirrorPath = resolveReadOnlyMirrorPath(ctx, strategy);
240
287
  if (!resolvedMirrorPath) {
@@ -266,7 +313,24 @@ export const createBranchHandler = async (ctx, req, body) => {
266
313
  }
267
314
  else {
268
315
  try {
269
- const collision = await GitManager.bareRemoteHasBranch(resolvedMirrorPath, sanitizedRequested,
316
+ for (const ancestor of namespaceAncestors(namespace)) {
317
+ // Local heads too: remote.git cannot take the new branch beside one either.
318
+ if (await GitManager.bareRemoteHasBranch(resolvedMirrorPath, ancestor)) {
319
+ return {
320
+ ok: false,
321
+ status: 409,
322
+ error: `Branches cannot be created: a branch named "${ancestor}" exists on the remote, ` +
323
+ 'and git cannot hold a branch inside another. Ask an administrator to rename or ' +
324
+ 'delete it.',
325
+ };
326
+ }
327
+ }
328
+ const [inside] = await GitManager.bareRemoteBranchesInside(resolvedMirrorPath, branchName);
329
+ if (inside !== undefined) {
330
+ return nameRefused(409, `"${shortName}" cannot be created: the remote has a branch named "${inside}" inside ` +
331
+ 'it, and git cannot hold both. Choose a different name.');
332
+ }
333
+ const collision = await GitManager.bareRemoteHasBranch(resolvedMirrorPath, branchName,
270
334
  // GitHub's view only -- see bareRemoteHasBranch. A local head in
271
335
  // remote.git survives an editor-side branch delete forever, so
272
336
  // including refs/heads/* here would make the ordinary create ->
@@ -275,14 +339,10 @@ export const createBranchHandler = async (ctx, req, body) => {
275
339
  // by the registry check above.
276
340
  { namespaces: 'tracking' });
277
341
  if (collision) {
278
- return {
279
- ok: false,
280
- status: 409,
281
- error: `A branch named "${sanitizedRequested}" already exists on the remote. ` +
282
- `It may have been created by another CanopyCMS deployment sharing this ` +
283
- `repository, pushed directly to GitHub, or left behind by an earlier branch ` +
284
- `of the same name. Choose a different name.`,
285
- };
342
+ return nameRefused(409, `A branch named "${shortName}" already exists on the remote. ` +
343
+ `It may have been created by another CanopyCMS deployment sharing this ` +
344
+ `repository, pushed directly to GitHub, or left behind by an earlier branch ` +
345
+ `of the same name. Choose a different name.`);
286
346
  }
287
347
  // No collision: GitHub does not have this name. Clear any STALE local
288
348
  // head the mirror still carries for it, so this new branch's first
@@ -302,11 +362,11 @@ export const createBranchHandler = async (ctx, req, body) => {
302
362
  // that is today's status quo, and the publish-time 409 message names
303
363
  // this cause.
304
364
  try {
305
- await GitManager.deleteBareRemoteHead(resolvedMirrorPath, sanitizedRequested);
365
+ await GitManager.deleteBareRemoteHead(resolvedMirrorPath, branchName);
306
366
  }
307
367
  catch (err) {
308
368
  log.warn('api', 'Failed to clear stale mirror head at branch reuse', {
309
- branch: sanitizedRequested,
369
+ branch: branchName,
310
370
  path: resolvedMirrorPath,
311
371
  error: getErrorMessage(err),
312
372
  });
@@ -327,11 +387,11 @@ export const createBranchHandler = async (ctx, req, body) => {
327
387
  // Load path permissions from the base branch's JSON file (the resolved
328
388
  // fork point — baked into config at service creation; dev-mode git HEAD
329
389
  // when not explicitly configured)
330
- const baseBranch = baseBranchOf(ctx.services.config);
390
+ const baseBranch = baseBranchOf(config);
331
391
  const baseBranchContext = await ctx.getBranchContext(baseBranch);
332
392
  let pathPermissions = [];
333
393
  if (baseBranchContext) {
334
- const operatingMode = ctx.services.config.mode;
394
+ const operatingMode = config.mode;
335
395
  pathPermissions = await loadPathPermissions(baseBranchContext.branchRoot, operatingMode);
336
396
  }
337
397
  const canCreate = canCreateBranch(req.user, pathPermissions);
@@ -359,13 +419,12 @@ export const createBranchHandler = async (ctx, req, body) => {
359
419
  if (err instanceof BranchProvisioningBusyError) {
360
420
  return { ok: false, status: 503, error: err.message };
361
421
  }
362
- if (err instanceof BranchDirOccupiedError) {
363
- return { ok: false, status: 409, error: err.message };
364
- }
422
+ if (err instanceof BranchDirOccupiedError)
423
+ return nameRefused(409, err.message);
365
424
  throw err;
366
425
  }
367
426
  if (outcome.kind === 'exists')
368
- return existingBranchResponse(ctx, req, outcome.context);
427
+ return existingBranchResponse(ctx, req, outcome.context, named);
369
428
  log.debug('api', 'Branch created', { branchName: outcome.context.branch.name });
370
429
  return {
371
430
  ok: true,
@@ -374,8 +433,16 @@ export const createBranchHandler = async (ctx, req, body) => {
374
433
  };
375
434
  });
376
435
  };
377
- /** A create that names an existing branch: 409, unless it is that creator's recent retry. */
378
- const existingBranchResponse = (ctx, req, existing) => {
436
+ /**
437
+ * A create whose key an existing workspace holds: 409, unless that workspace is the branch this
438
+ * create names (`named.name`) and the create is its creator's recent retry. A workspace with
439
+ * another stored name only shares the key, so answering with it would hand back another branch.
440
+ */
441
+ const existingBranchResponse = (ctx, req, existing, named) => {
442
+ if (existing.branch.name !== named.name) {
443
+ return nameRefused(409, `"${named.shortName}" would clash with the existing branch ` +
444
+ `"${shortNameOf(ctx.services.config, existing.branch.name)}". Choose a different name.`);
445
+ }
379
446
  if (isCreatorsRecentBranch(existing.branch, req.user.userId)) {
380
447
  return {
381
448
  ok: true,
@@ -383,8 +450,15 @@ const existingBranchResponse = (ctx, req, existing) => {
383
450
  data: { branch: toBranchListItem(ctx.services.config, existing.branch, req.user) },
384
451
  };
385
452
  }
386
- return { ok: false, status: 409, error: 'A branch with this name already exists' };
453
+ return nameRefused(409, `A branch named "${named.shortName}" already exists`);
387
454
  };
455
+ /** A create refused for the name it was given, which the editor shows under its name field. */
456
+ const nameRefused = (status, error) => ({
457
+ ok: false,
458
+ status,
459
+ error,
460
+ code: 'BRANCH_NAME_REFUSED',
461
+ });
388
462
  /** @internal Exported for tests. */
389
463
  export const listBranchesHandler = async (ctx, req) => {
390
464
  if (!ctx.services.registry) {
@@ -398,13 +472,13 @@ export const listBranchesHandler = async (ctx, req) => {
398
472
  // http/handler.ts), so one left on disk is hidden rather than listed unopenable.
399
473
  const settingsBranch = operatingStrategy(ctx.services.config.mode).getSettingsBranchName(ctx.services.config);
400
474
  const allBranches = (await ctx.services.registry.list()).filter((context) => !isSettingsBranchName(context.branch.name, settingsBranch));
401
- // The branch the editor should open when none is pinned via URL/config.
402
- // Read per-request so dev-mode refreshActiveBranch() updates are reflected.
403
- // Sanitized: dev mode detects the RAW git HEAD name (e.g. 'claude/foo'),
404
- // but registry branch names are filesystem-sanitized ('claude-foo') — the
405
- // editor matches defaultBranch against registry names, so return the form
406
- // that can actually be found there.
407
- const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? baseBranchOf(ctx.services.config));
475
+ // The branch the editor should open when none is pinned via URL/config: the name of the
476
+ // workspace the active branch resolves to, which the editor finds in the list. Read
477
+ // per-request so dev-mode refreshActiveBranch() updates are reflected.
478
+ const activeBranch = ctx.services.config.defaultActiveBranch ?? baseBranchOf(ctx.services.config);
479
+ const activeKey = sanitizeBranchName(activeBranch);
480
+ const defaultBranch = allBranches.find((context) => path.basename(context.branchRoot) === activeKey)?.branch.name ??
481
+ activeBranch;
408
482
  // The access check is the rule: the list shows exactly the branches it grants, so the editor
409
483
  // never lists a branch it cannot open, nor leaves out one it can.
410
484
  const visibleBranches = allBranches.filter((context) => ctx.services.checkBranchAccess(context, req.user).allowed);
@@ -574,14 +648,14 @@ export const deleteBranchHandler = async (ctx, req, params) => {
574
648
  !isNetworkRemoteUrl(mirrorPath) &&
575
649
  (await filePathExists(mirrorPath))) {
576
650
  try {
577
- await GitManager.deleteBareRemoteHead(mirrorPath, sanitizedDeleted);
651
+ await GitManager.deleteBareRemoteHead(mirrorPath, branchContext.branch.name);
578
652
  }
579
653
  catch (err) {
580
654
  // Client-facing copy is sanitized (API-H2 — no absolute EFS paths);
581
655
  // the console line keeps the raw detail for server logs.
582
656
  const warning = `Failed to remove the deleted branch's head from the local mirror: ${sanitizeErrorMessage(getErrorMessage(err))}`;
583
657
  cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
584
- console.warn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${sanitizedDeleted}, mirror ${mirrorPath}): ${getErrorMessage(err)}`);
658
+ console.warn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${branchContext.branch.name}, mirror ${mirrorPath}): ${getErrorMessage(err)}`);
585
659
  }
586
660
  }
587
661
  // `branchContext` was read before the delete, so it still carries the PR number this gates on.
@@ -9,6 +9,8 @@ import { type RequestOptions } from './request-deadline.js';
9
9
  export { isApiTimeoutError, WRITE_TIMED_OUT_MESSAGE, type RequestOptions } from './request-deadline.js';
10
10
  import type { OperatingMode } from '../operating-mode/types.js';
11
11
  import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
12
+ import type { BranchChangesResponse } from './branch-changes.js';
13
+ import type { BranchEntryChangeResponse } from './branch-entry-changes.js';
12
14
  import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
13
15
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
14
16
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -49,6 +51,12 @@ export interface ApiClientOptions {
49
51
  editorMode?: OperatingMode;
50
52
  /** Called whenever the server refuses `editorMode` (code `EDITOR_MODE_MISMATCH`). @internal */
51
53
  onEditorModeMismatch?: () => void;
54
+ /**
55
+ * Maps a branch name to the `/:branch` segment its requests carry. The editor maps each
56
+ * listed branch to its exact form (`~canopycms~prod~spring`, `exactBranchSegment`), which the
57
+ * server resolves to that branch alone, whatever the base or active branch is. @internal
58
+ */
59
+ branchSegment?: (branch: string) => string;
52
60
  }
53
61
  /**
54
62
  * Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
@@ -68,6 +76,7 @@ export declare class CanopyApiClient {
68
76
  private onUnauthorized;
69
77
  private editorMode;
70
78
  private onEditorModeMismatch;
79
+ private branchSegment;
71
80
  readonly branches: {
72
81
  /** GET /branches */
73
82
  list: (options?: RequestOptions) => Promise<BranchListResponse>;
@@ -78,6 +87,12 @@ export declare class CanopyApiClient {
78
87
  /** PATCH /:branch/access */
79
88
  updateAccess: (params: Record<string, string>, body: UpdateBranchAccessBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
80
89
  };
90
+ readonly changes: {
91
+ /** GET /:branch/changes */
92
+ list: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchChangesResponse>;
93
+ /** GET /:branch/changes/...entryPath */
94
+ entry: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchEntryChangeResponse>;
95
+ };
81
96
  readonly workflow: {
82
97
  /** POST /:branch/withdraw */
83
98
  withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
@@ -41,6 +41,16 @@ export class CanopyApiClient {
41
41
  return this.request('PATCH', this.buildPath('/:branch/access', params), body, 'standard', options);
42
42
  },
43
43
  };
44
+ this.changes = {
45
+ /** GET /:branch/changes */
46
+ list: (params, options) => {
47
+ return this.request('GET', this.buildPath('/:branch/changes', params), undefined, 'standard', options);
48
+ },
49
+ /** GET /:branch/changes/...entryPath */
50
+ entry: (params, options) => {
51
+ return this.request('GET', this.buildPath('/:branch/changes/...entryPath', params), undefined, 'standard', options);
52
+ },
53
+ };
44
54
  this.workflow = {
45
55
  /** POST /:branch/withdraw */
46
56
  withdraw: (params, options) => {
@@ -277,6 +287,7 @@ export class CanopyApiClient {
277
287
  this.onUnauthorized = options.onUnauthorized;
278
288
  this.editorMode = options.editorMode;
279
289
  this.onEditorModeMismatch = options.onEditorModeMismatch;
290
+ this.branchSegment = options.branchSegment;
280
291
  }
281
292
  buildPath(template, params) {
282
293
  let result = template;
@@ -285,7 +296,8 @@ export class CanopyApiClient {
285
296
  const pathParamPattern = `:${key}`;
286
297
  const restParamPattern = `...${key}`;
287
298
  if (result.includes(pathParamPattern)) {
288
- result = result.replace(pathParamPattern, encodeURIComponent(String(value)));
299
+ const segment = key === 'branch' && this.branchSegment ? this.branchSegment(value) : value;
300
+ result = result.replace(pathParamPattern, encodeURIComponent(String(segment)));
289
301
  }
290
302
  else if (result.includes(restParamPattern)) {
291
303
  // For rest parameters, encode each segment separately to preserve slashes
@@ -63,39 +63,39 @@ export declare const CONTENT_ROUTES: {
63
63
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
64
64
  path: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
65
65
  }, "strip", z.ZodTypeAny, {
66
- path: string & {
67
- readonly __brand: "LogicalPath";
68
- };
69
66
  branch: string & {
70
67
  readonly __brand: "BranchName";
71
68
  };
72
- }, {
73
69
  path: string & {
74
70
  readonly __brand: "LogicalPath";
75
71
  };
72
+ }, {
76
73
  branch: string & {
77
74
  readonly __brand: "BranchName";
78
75
  };
76
+ path: string & {
77
+ readonly __brand: "LogicalPath";
78
+ };
79
79
  }>, undefined, ContentReadResponse>;
80
80
  readonly write: import("./route-builder.js").RouteDefinition<z.ZodObject<{
81
81
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
82
82
  path: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
83
83
  entryType: z.ZodOptional<z.ZodString>;
84
84
  }, "strip", z.ZodTypeAny, {
85
- path: string & {
86
- readonly __brand: "LogicalPath";
87
- };
88
85
  branch: string & {
89
86
  readonly __brand: "BranchName";
90
87
  };
91
- entryType?: string | undefined;
92
- }, {
93
88
  path: string & {
94
89
  readonly __brand: "LogicalPath";
95
90
  };
91
+ entryType?: string | undefined;
92
+ }, {
96
93
  branch: string & {
97
94
  readonly __brand: "BranchName";
98
95
  };
96
+ path: string & {
97
+ readonly __brand: "LogicalPath";
98
+ };
99
99
  entryType?: string | undefined;
100
100
  }>, z.ZodObject<{
101
101
  format: z.ZodEnum<["json", "md", "mdx", "yaml"]>;
@@ -118,20 +118,20 @@ export declare const CONTENT_ROUTES: {
118
118
  path: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
119
119
  entryType: z.ZodOptional<z.ZodString>;
120
120
  }, "strip", z.ZodTypeAny, {
121
- path: string & {
122
- readonly __brand: "LogicalPath";
123
- };
124
121
  branch: string & {
125
122
  readonly __brand: "BranchName";
126
123
  };
127
- entryType?: string | undefined;
128
- }, {
129
124
  path: string & {
130
125
  readonly __brand: "LogicalPath";
131
126
  };
127
+ entryType?: string | undefined;
128
+ }, {
132
129
  branch: string & {
133
130
  readonly __brand: "BranchName";
134
131
  };
132
+ path: string & {
133
+ readonly __brand: "LogicalPath";
134
+ };
135
135
  entryType?: string | undefined;
136
136
  }>, z.ZodObject<{
137
137
  data: z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodUnknown>, Record<string, unknown>, Record<string, unknown>>;
@@ -144,19 +144,19 @@ export declare const CONTENT_ROUTES: {
144
144
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
145
145
  path: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
146
146
  }, "strip", z.ZodTypeAny, {
147
- path: string & {
148
- readonly __brand: "LogicalPath";
149
- };
150
147
  branch: string & {
151
148
  readonly __brand: "BranchName";
152
149
  };
153
- }, {
154
150
  path: string & {
155
151
  readonly __brand: "LogicalPath";
156
152
  };
153
+ }, {
157
154
  branch: string & {
158
155
  readonly __brand: "BranchName";
159
156
  };
157
+ path: string & {
158
+ readonly __brand: "LogicalPath";
159
+ };
160
160
  }>, z.ZodObject<{
161
161
  newSlug: z.ZodType<Slug, z.ZodTypeDef, Slug>;
162
162
  }, "strip", z.ZodTypeAny, {
@@ -429,7 +429,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
429
429
  try {
430
430
  issues = await validateEntry({
431
431
  entryPath: logicalPathSegments.join('/'),
432
- branch: params.branch,
432
+ branch: branchContext.branch.name,
433
433
  // The type the store writes with, so a request that omits `entryType` can't skip a
434
434
  // type-gated rule.
435
435
  ...(entryTypeName ? { entryType: entryTypeName } : {}),
@@ -6,6 +6,7 @@ import { sanitizeBranchName } from '../paths/branch-name.js';
6
6
  import { buildPrSection, mergePrSection } from '../submission-attribution.js';
7
7
  import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
8
8
  import { baseBranchOf } from '../utils/base-branch.js';
9
+ import { shortNameOf } from '../branch-param.js';
9
10
  import { CommentStore } from '../comment-store.js';
10
11
  import { prBodyLockKey, refreshCommentsBlock } from '../pr-comments.js';
11
12
  import { withLock } from '../utils/async-mutex.js';
@@ -28,14 +29,16 @@ async function readThreadsOrWarn(context) {
28
29
  export async function syncSubmitPr(ctx, context, submission) {
29
30
  const { githubService } = ctx.services;
30
31
  const mode = ctx.services.config.mode;
31
- const prTitle = context.branch.title || `Submit ${context.branch.name}`;
32
+ // Links and the title carry the short name, which `?branch=` resolves.
33
+ const shortName = shortNameOf(ctx.services.config, context.branch.name);
34
+ const prTitle = context.branch.title || `Submit ${shortName}`;
32
35
  const threads = await readThreadsOrWarn(context);
33
36
  const prSection = buildPrSection({
34
37
  description: context.branch.description,
35
38
  submitter: submission.submitter,
36
39
  editors: submission.editors,
37
40
  changedPaths: submission.changedPaths,
38
- branch: context.branch.name,
41
+ branch: shortName,
39
42
  editorUrl: submission.editorUrl,
40
43
  entryPaths: submission.entryPaths,
41
44
  threads,
@@ -66,7 +69,7 @@ export async function syncSubmitPr(ctx, context, submission) {
66
69
  // Re-read under the lock: a comment synced since the read above has the newer block.
67
70
  const section = await refreshCommentsBlock({
68
71
  branchRoot: context.branchRoot,
69
- branch: context.branch.name,
72
+ branch: shortName,
70
73
  editorUrl: submission.editorUrl,
71
74
  section: prSection,
72
75
  warn: (message) => console.warn(`CanopyCMS: ${message}`),
@@ -159,6 +162,7 @@ export async function syncSubmitPr(ctx, context, submission) {
159
162
  mergeSectionIntoBody: true,
160
163
  // The worker re-renders the comment block from comments.json when the task runs.
161
164
  ...(submission.editorUrl ? { editorUrl: submission.editorUrl } : {}),
165
+ editorBranch: shortName,
162
166
  baseBranch,
163
167
  pullRequestNumber: context.branch.pullRequestNumber,
164
168
  // A content submit is an explicit "ready for review" action: convert a