canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -69,6 +69,17 @@ export declare class GitRemoteRefMissingError extends Error {
69
69
  */
70
70
  gitError?: unknown | undefined);
71
71
  }
72
+ /**
73
+ * No git remote is configured and the one the strategy auto-detects does not
74
+ * exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
75
+ * its first boot, so until then every request that needs a workspace fails
76
+ * here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
77
+ * The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
78
+ */
79
+ export declare class RemoteNotReadyError extends Error {
80
+ readonly expectedRemotePath: string;
81
+ constructor(expectedRemotePath: string);
82
+ }
72
83
  export interface ResolveRemoteUrlOptions {
73
84
  mode: OperatingMode;
74
85
  remoteUrl?: string;
@@ -173,7 +184,7 @@ export declare class GitManager {
173
184
  * deleteBranchHandler: a head left in `remote.git` forever makes the
174
185
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
175
186
  * the reused branch's first publish non-fast-forward against the stale head
176
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
187
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
177
188
  * not an ancestor of the new branch), and a retried submit then skips the
178
189
  * local push on a clean tree and enqueues the worker push of the STALE head,
179
190
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -223,6 +234,8 @@ export declare class GitManager {
223
234
  * @param source - Where `url` came from, for the thrown error message only.
224
235
  */
225
236
  private static assertRemoteUrlAllowedInMode;
237
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
238
+ private static remoteNotReadyError;
226
239
  /**
227
240
  * Resolves the remote URL for git operations following the priority:
228
241
  * 1. Explicit remoteUrl parameter
@@ -283,8 +296,15 @@ export declare class GitManager {
283
296
  rebaseOntoBase(): Promise<void>;
284
297
  private rebaseOntoBaseInner;
285
298
  add(files: string | string[]): Promise<void>;
299
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
300
+ addAllExceptCanopyState(): Promise<void>;
286
301
  commit(message: string): Promise<void>;
287
302
  push(branch?: string): Promise<void>;
303
+ /**
304
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
305
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
306
+ */
307
+ private currentBranchName;
288
308
  /**
289
309
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
290
310
  * whether push() would move the remote ref forward.
@@ -303,6 +323,13 @@ export declare class GitManager {
303
323
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
304
324
  */
305
325
  hasUnpushedCommits(branch?: string): Promise<boolean>;
326
+ /**
327
+ * Repo-relative paths that differ between HEAD and its merge base with the
328
+ * base branch: everything the branch changes, across all of its commits.
329
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
330
+ * constraint.
331
+ */
332
+ listChangedPathsSinceBase(): Promise<string[]>;
306
333
  ensureAuthor(author: {
307
334
  name: string;
308
335
  email: string;
@@ -317,11 +344,58 @@ export declare class GitManager {
317
344
  */
318
345
  ensureGitExclude(pattern: string): Promise<void>;
319
346
  /**
320
- * Create an orphan branch (no shared history) for settings, so
321
- * deployment-specific settings never pollute content history. It holds only
322
- * settings files committed by explicit path (permissions.json, groups.json at
323
- * the workspace root).
347
+ * Put the workspace on the settings branch: an orphan (no shared history),
348
+ * so deployment-specific settings never pollute content history. It holds
349
+ * only settings files committed by explicit path (permissions.json,
350
+ * groups.json at the workspace root).
351
+ *
352
+ * The remote is the settings branch's durable copy, and the workspace clone
353
+ * is `--single-branch` at the base branch, so the remote is asked first:
354
+ * - no local branch: check out the remote's, or create an empty orphan only
355
+ * when the remote has none. An unreadable remote throws
356
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
357
+ * - local branch present: a history unrelated to the remote's is repaired
358
+ * onto the remote's when the local branch is still its empty initial
359
+ * commit with a clean working tree, and otherwise throws
360
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
361
+ * An unreadable remote throws only for that empty branch, since one that
362
+ * holds settings can serve them and the next save surfaces the remote error.
363
+ * Either way a remote branch holding content is never adopted:
364
+ * {@link SettingsBranchHasContentHistoryError}.
324
365
  */
325
366
  createOrphanSettingsBranch(branchName: string, initialFiles: Record<string, string>): Promise<void>;
326
367
  private createOrphanSettingsBranchInner;
368
+ /**
369
+ * Compares the checked-out local settings branch with the remote's tip.
370
+ * Related histories are left for the next settings pull to reconcile; only
371
+ * an unrelated one is acted on here.
372
+ */
373
+ private reconcileLocalSettingsBranch;
374
+ /**
375
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
376
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
377
+ * output only for a reachable remote without the ref.
378
+ */
379
+ private remoteBranchTip;
380
+ /**
381
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
382
+ * pullBaseInner explains. The full ref: a bare name resolves to a
383
+ * same-named tag first.
384
+ */
385
+ private fetchBranchTip;
386
+ /**
387
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
388
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
389
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
390
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
391
+ */
392
+ private assertNotContentHistory;
393
+ private baseBranchRev;
394
+ private rootCommits;
395
+ /**
396
+ * Whether `branch` is a single parentless commit with an empty tree, as
397
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
398
+ * same-named tag first, and clones fetch tags.
399
+ */
400
+ private isEmptyInitialBranch;
327
401
  }
@@ -20,7 +20,7 @@ import { invalidateContentIndexesForRoot } from './content-index-registry.js';
20
20
  import { invalidateBranchContentCaches } from './content-index-generation.js';
21
21
  import { createDebugLogger } from './utils/debug.js';
22
22
  import { getErrorMessage, isNotFoundError } from './utils/error.js';
23
- import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
23
+ import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
24
24
  import { acquireProvisioningLock } from './utils/provisioning-lock.js';
25
25
  const log = createDebugLogger({ prefix: 'GitManager' });
26
26
  /**
@@ -166,6 +166,78 @@ export class GitRemoteRefMissingError extends Error {
166
166
  this.name = 'GitRemoteRefMissingError';
167
167
  }
168
168
  }
169
+ /**
170
+ * No git remote is configured and the one the strategy auto-detects does not
171
+ * exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
172
+ * its first boot, so until then every request that needs a workspace fails
173
+ * here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
174
+ * The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
175
+ */
176
+ export class RemoteNotReadyError extends Error {
177
+ constructor(expectedRemotePath) {
178
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
179
+ `not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
180
+ this.expectedRemotePath = expectedRemotePath;
181
+ this.name = 'RemoteNotReadyError';
182
+ }
183
+ }
184
+ /**
185
+ * Settings provisioning could not read the remote's settings branch, so it cannot tell a remote
186
+ * that has none from one it failed to reach. It stops instead of starting an empty settings
187
+ * branch: settings that silently vanish drop restrictive path rules, so content they hid
188
+ * becomes readable under `defaultPathAccess: { read: 'allow' }`.
189
+ */
190
+ class SettingsRemoteUnreadableError extends Error {
191
+ constructor(branch, remote,
192
+ /** The underlying git failure; see {@link GitRemoteRefMissingError.gitError}. */
193
+ gitError) {
194
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so ` +
195
+ `the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
196
+ this.branch = branch;
197
+ this.remote = remote;
198
+ this.gitError = gitError;
199
+ this.name = 'SettingsRemoteUnreadableError';
200
+ }
201
+ }
202
+ /**
203
+ * The workspace's settings branch shares no history with the remote's and holds work of its
204
+ * own, so neither copy can be chosen automatically, and a settings save could never pull.
205
+ */
206
+ class SettingsBranchDivergedError extends Error {
207
+ constructor(branch, workspacePath,
208
+ /** What the workspace holds beyond an empty initial commit. */
209
+ localWork) {
210
+ super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the ` +
211
+ `remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy ` +
212
+ `is chosen. To keep the remote's settings: copy anything you need out of ` +
213
+ `${workspacePath}, move it aside, and restart; the next start checks out the remote's ` +
214
+ `'${branch}', and copied changes can then be re-applied in the editor.`);
215
+ this.branch = branch;
216
+ this.workspacePath = workspacePath;
217
+ this.localWork = localWork;
218
+ this.name = 'SettingsBranchDivergedError';
219
+ }
220
+ }
221
+ /** The only files a settings branch ever commits (explicit paths at its root). */
222
+ const SETTINGS_BRANCH_FILES = new Set(['permissions.json', 'groups.json']);
223
+ /**
224
+ * The remote's settings branch holds content rather than settings. Adopting it would load no
225
+ * groups or path rules, and settings saves would then commit onto content history.
226
+ */
227
+ class SettingsBranchHasContentHistoryError extends Error {
228
+ constructor(branch, remote,
229
+ /** What shows it is content. */
230
+ evidence) {
231
+ super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not ` +
232
+ `settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote ` +
233
+ `from a copy of the settings (it may hold only permissions.json and groups.json), ` +
234
+ `then restart.`);
235
+ this.branch = branch;
236
+ this.remote = remote;
237
+ this.evidence = evidence;
238
+ this.name = 'SettingsBranchHasContentHistoryError';
239
+ }
240
+ }
169
241
  export class GitManager {
170
242
  constructor(options, gitOptions) {
171
243
  this.repoPath = path.resolve(options.repoPath);
@@ -407,7 +479,7 @@ export class GitManager {
407
479
  * deleteBranchHandler: a head left in `remote.git` forever makes the
408
480
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
409
481
  * the reused branch's first publish non-fast-forward against the stale head
410
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
482
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
411
483
  * not an ancestor of the new branch), and a retried submit then skips the
412
484
  * local push on a clean tree and enqueues the worker push of the STALE head,
413
485
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -562,6 +634,12 @@ export class GitManager {
562
634
  `intentionally runs git against a network remote (e.g. a single-VM deployment), set ` +
563
635
  `config.allowNetworkRemoteInProd: true to acknowledge this.`);
564
636
  }
637
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
638
+ static async remoteNotReadyError(mode) {
639
+ const { operatingStrategy } = await import('./operating-mode/index.js');
640
+ const { autoDetectRemotePath } = operatingStrategy(mode).getRemoteUrlConfig();
641
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : undefined;
642
+ }
565
643
  /**
566
644
  * Resolves the remote URL for git operations following the priority:
567
645
  * 1. Explicit remoteUrl parameter
@@ -686,6 +764,7 @@ export class GitManager {
686
764
  }
687
765
  }
688
766
  let justCloned = false;
767
+ let resolvedRemoteUrl;
689
768
  if (!repoExists) {
690
769
  const remoteUrl = await GitManager.resolveRemoteUrl({
691
770
  mode: options.mode,
@@ -696,6 +775,9 @@ export class GitManager {
696
775
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
697
776
  });
698
777
  if (!remoteUrl) {
778
+ const notReady = await GitManager.remoteNotReadyError(options.mode);
779
+ if (notReady)
780
+ throw notReady;
699
781
  throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
700
782
  }
701
783
  try {
@@ -708,6 +790,7 @@ export class GitManager {
708
790
  `from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
709
791
  }
710
792
  justCloned = true;
793
+ resolvedRemoteUrl = remoteUrl;
711
794
  // Mark as managed immediately after clone so ensureRemote's guard works,
712
795
  // and set a fallback author identity: GIT_CEILING_DIRECTORIES blocks
713
796
  // global gitconfig, and internal commits (e.g. orphan branch init) need
@@ -748,9 +831,20 @@ export class GitManager {
748
831
  if (remoteUrl) {
749
832
  await git.ensureRemote(remoteUrl);
750
833
  }
834
+ resolvedRemoteUrl = remoteUrl;
751
835
  }
752
836
  if (options.branchType === 'orphan') {
753
- await git.createOrphanSettingsBranch(options.branchName, {});
837
+ try {
838
+ await git.createOrphanSettingsBranch(options.branchName, {});
839
+ }
840
+ catch (err) {
841
+ // A remote that is unreadable because the worker has not (re)created
842
+ // it yet is the transient not-ready case, not a broken workspace.
843
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
844
+ throw (await GitManager.remoteNotReadyError(options.mode)) ?? err;
845
+ }
846
+ throw err;
847
+ }
754
848
  // Settings mutations hold an OCC lockfile (<file>.lock, see
755
849
  // authorization/settings-file-store.ts) inside this git-committed
756
850
  // workspace. Commits here stage explicit paths, but a crash-orphaned lock
@@ -893,7 +987,8 @@ export class GitManager {
893
987
  const branches = await this.git.branch();
894
988
  const currentBranch = branches.current;
895
989
  try {
896
- await this.git.fetch(this.remote, currentBranch);
990
+ // The full ref: a bare name resolves to a same-named tag first.
991
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
897
992
  }
898
993
  catch (err) {
899
994
  // The only benign failure here: the branch has never been pushed, so the
@@ -970,13 +1065,19 @@ export class GitManager {
970
1065
  const fileArray = Array.isArray(files) ? files : [files];
971
1066
  await this.git.add(fileArray);
972
1067
  }
1068
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
1069
+ async addAllExceptCanopyState() {
1070
+ await stageAllExceptCanopyState(this.git);
1071
+ }
973
1072
  async commit(message) {
974
1073
  await this.git.commit(message);
975
1074
  }
976
1075
  async push(branch) {
977
- const target = branch ?? (await this.git.revparse(['--abbrev-ref', 'HEAD']));
978
- // Explicit refspec (local:remote) so push works for branches not yet in the
979
- // remote (e.g. orphan settings branches). Built via raw() rather than the
1076
+ const target = branch ?? (await this.currentBranchName());
1077
+ // Explicit full-ref refspec (local:remote) so push works for branches not
1078
+ // yet in the remote (e.g. orphan settings branches), and a same-named tag
1079
+ // cannot redirect it: a short name for a new remote branch would create
1080
+ // `refs/heads/heads/<name>`. Built via raw() rather than the
980
1081
  // push() wrapper so `--end-of-options` sits immediately before the
981
1082
  // positional remote/refspec, guarding against a refspec starting with '-'
982
1083
  // being parsed as a git option (e.g. --receive-pack=...). Real flags must
@@ -986,9 +1087,20 @@ export class GitManager {
986
1087
  '--set-upstream',
987
1088
  '--end-of-options',
988
1089
  this.remote,
989
- `${target}:${target}`,
1090
+ `refs/heads/${target}:refs/heads/${target}`,
990
1091
  ]);
991
1092
  }
1093
+ /**
1094
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
1095
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
1096
+ */
1097
+ async currentBranchName() {
1098
+ const branches = await this.git.branch();
1099
+ if (branches.detached || !branches.current) {
1100
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
1101
+ }
1102
+ return branches.current;
1103
+ }
992
1104
  /**
993
1105
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
994
1106
  * whether push() would move the remote ref forward.
@@ -1010,12 +1122,14 @@ export class GitManager {
1010
1122
  // `--end-of-options` before every caller-influenced ref name, as in push()
1011
1123
  // above: names are sanitized upstream, but this file's rule is that a
1012
1124
  // positional is guarded where it is passed, not where it was validated.
1013
- const target = branch ?? (await this.git.revparse(['--abbrev-ref', '--end-of-options', 'HEAD']));
1014
- const localSha = (await this.git.revparse(['--end-of-options', target])).trim();
1125
+ // Each rev-parse needs `--verify`: without it, rev-parse echoes
1126
+ // `--end-of-options` as its own output line ahead of the result.
1127
+ const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
1128
+ const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
1015
1129
  let fetchedTip;
1016
1130
  try {
1017
1131
  await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
1018
- fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
1132
+ fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1019
1133
  }
1020
1134
  catch {
1021
1135
  // No ref on the remote yet -- the branch has never been pushed.
@@ -1028,6 +1142,27 @@ export class GitManager {
1028
1142
  const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
1029
1143
  return aheadCount !== '0';
1030
1144
  }
1145
+ /**
1146
+ * Repo-relative paths that differ between HEAD and its merge base with the
1147
+ * base branch: everything the branch changes, across all of its commits.
1148
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
1149
+ * constraint.
1150
+ */
1151
+ async listChangedPathsSinceBase() {
1152
+ await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
1153
+ // `--verify`: without it rev-parse echoes `--end-of-options` back as output.
1154
+ const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1155
+ // -z: NUL-separated and unquoted, so no path needs unescaping.
1156
+ const output = await this.git.raw([
1157
+ 'diff',
1158
+ '--name-only',
1159
+ '-z',
1160
+ '--no-renames',
1161
+ `${baseTip}...HEAD`,
1162
+ '--',
1163
+ ]);
1164
+ return output.split('\0').filter((p) => p.length > 0);
1165
+ }
1031
1166
  async ensureAuthor(author) {
1032
1167
  const config = (await this.git.listConfig());
1033
1168
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -1089,10 +1224,24 @@ export class GitManager {
1089
1224
  await ensureGitExcludePattern(this.repoPath, pattern);
1090
1225
  }
1091
1226
  /**
1092
- * Create an orphan branch (no shared history) for settings, so
1093
- * deployment-specific settings never pollute content history. It holds only
1094
- * settings files committed by explicit path (permissions.json, groups.json at
1095
- * the workspace root).
1227
+ * Put the workspace on the settings branch: an orphan (no shared history),
1228
+ * so deployment-specific settings never pollute content history. It holds
1229
+ * only settings files committed by explicit path (permissions.json,
1230
+ * groups.json at the workspace root).
1231
+ *
1232
+ * The remote is the settings branch's durable copy, and the workspace clone
1233
+ * is `--single-branch` at the base branch, so the remote is asked first:
1234
+ * - no local branch: check out the remote's, or create an empty orphan only
1235
+ * when the remote has none. An unreadable remote throws
1236
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
1237
+ * - local branch present: a history unrelated to the remote's is repaired
1238
+ * onto the remote's when the local branch is still its empty initial
1239
+ * commit with a clean working tree, and otherwise throws
1240
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
1241
+ * An unreadable remote throws only for that empty branch, since one that
1242
+ * holds settings can serve them and the next save surfaces the remote error.
1243
+ * Either way a remote branch holding content is never adopted:
1244
+ * {@link SettingsBranchHasContentHistoryError}.
1096
1245
  */
1097
1246
  async createOrphanSettingsBranch(branchName, initialFiles) {
1098
1247
  try {
@@ -1104,14 +1253,36 @@ export class GitManager {
1104
1253
  }
1105
1254
  }
1106
1255
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
1107
- log.debug('git', 'Creating orphan settings branch', { branchName });
1256
+ log.debug('git', 'Ensuring settings branch', { branchName });
1108
1257
  const branches = await this.git.branch();
1109
- if (branches.all.includes(branchName)) {
1110
- log.debug('git', 'Orphan branch already exists', { branchName });
1258
+ const localExists = branches.all.includes(branchName);
1259
+ if (localExists) {
1111
1260
  // No separator here — see checkoutBranch() above for why plain
1112
1261
  // `git checkout <branch>` can't safely take one. branchName is always an
1113
1262
  // internal/config-derived settings-branch name, never user input.
1114
1263
  await this.git.checkout(branchName);
1264
+ }
1265
+ let remoteTip;
1266
+ try {
1267
+ remoteTip = await this.remoteBranchTip(branchName);
1268
+ }
1269
+ catch (err) {
1270
+ if (localExists && !(await this.isEmptyInitialBranch(branchName))) {
1271
+ log.debug('git', 'Remote unreadable; serving the local settings branch', { branchName });
1272
+ return;
1273
+ }
1274
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
1275
+ }
1276
+ if (localExists) {
1277
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
1278
+ return;
1279
+ }
1280
+ if (remoteTip) {
1281
+ const fetchedTip = await this.fetchBranchTip(branchName);
1282
+ await this.assertNotContentHistory(branchName, fetchedTip);
1283
+ // `-b` consumes branchName as its literal value; see checkoutBranchInner.
1284
+ await this.git.raw(['checkout', '-b', branchName, fetchedTip]);
1285
+ log.debug('git', 'Checked out the remote settings branch', { branchName });
1115
1286
  return;
1116
1287
  }
1117
1288
  // branchName is consumed as --orphan's literal argument value (like -b/-B
@@ -1134,4 +1305,123 @@ export class GitManager {
1134
1305
  await this.git.commit('Initialize settings branch', ['--allow-empty']);
1135
1306
  log.debug('git', 'Orphan settings branch created', { branchName });
1136
1307
  }
1308
+ /**
1309
+ * Compares the checked-out local settings branch with the remote's tip.
1310
+ * Related histories are left for the next settings pull to reconcile; only
1311
+ * an unrelated one is acted on here.
1312
+ */
1313
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
1314
+ if (!remoteTip)
1315
+ return;
1316
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
1317
+ if (localTip === remoteTip)
1318
+ return;
1319
+ const fetchedTip = await this.fetchBranchTip(branchName);
1320
+ const [localRoots, remoteRoots] = await Promise.all([
1321
+ this.rootCommits(localTip),
1322
+ this.rootCommits(fetchedTip),
1323
+ ]);
1324
+ if (localRoots.some((root) => remoteRoots.includes(root)))
1325
+ return;
1326
+ const localWork = [];
1327
+ if (!(await this.isEmptyInitialBranch(branchName))) {
1328
+ localWork.push('commits beyond its empty initial commit');
1329
+ }
1330
+ const uncommitted = await this.getUncommittedFiles();
1331
+ if (uncommitted.length > 0) {
1332
+ localWork.push(`uncommitted changes to ${uncommitted.join(', ')}`);
1333
+ }
1334
+ if (localWork.length > 0) {
1335
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(' and '));
1336
+ }
1337
+ await this.assertNotContentHistory(branchName, fetchedTip);
1338
+ // `checkout -B` resets the current branch onto the fetched tip, and
1339
+ // refuses rather than overwrite a file written since the check above.
1340
+ await this.git.raw(['checkout', '-B', branchName, fetchedTip]);
1341
+ log.debug('git', 'Replaced an empty settings branch with the remote one', { branchName });
1342
+ }
1343
+ /**
1344
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
1345
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
1346
+ * output only for a reachable remote without the ref.
1347
+ */
1348
+ async remoteBranchTip(branch) {
1349
+ const ref = `refs/heads/${branch}`;
1350
+ const output = await this.git.raw(['ls-remote', '--heads', this.remote, ref]);
1351
+ for (const line of output.split('\n')) {
1352
+ const [sha, name] = line.trim().split('\t');
1353
+ if (sha && name === ref)
1354
+ return sha;
1355
+ }
1356
+ return undefined;
1357
+ }
1358
+ /**
1359
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
1360
+ * pullBaseInner explains. The full ref: a bare name resolves to a
1361
+ * same-named tag first.
1362
+ */
1363
+ async fetchBranchTip(branch) {
1364
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
1365
+ return (await this.git.revparse(['FETCH_HEAD'])).trim();
1366
+ }
1367
+ /**
1368
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
1369
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
1370
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
1371
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
1372
+ */
1373
+ async assertNotContentHistory(branchName, tip) {
1374
+ const tree = await this.git.raw(['ls-tree', '-r', '--name-only', tip]);
1375
+ const strays = tree.split('\n').filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
1376
+ if (strays.length > 0) {
1377
+ const shown = strays.slice(0, 3).join(', ') + (strays.length > 3 ? ', …' : '');
1378
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
1379
+ }
1380
+ const [tipRoots, baseRoots] = await Promise.all([
1381
+ this.rootCommits(tip),
1382
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev)),
1383
+ ]);
1384
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
1385
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
1386
+ }
1387
+ }
1388
+ async baseBranchRev(settingsBranch) {
1389
+ for (const ref of [
1390
+ `refs/heads/${this.baseBranch}`,
1391
+ `refs/remotes/${this.remote}/${this.baseBranch}`,
1392
+ ]) {
1393
+ // `--quiet` makes a missing ref a silent non-zero exit, which simple-git resolves
1394
+ // with empty output rather than rejecting.
1395
+ const sha = await this.git
1396
+ .raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
1397
+ .catch(() => '');
1398
+ if (sha.trim())
1399
+ return sha.trim();
1400
+ }
1401
+ try {
1402
+ return await this.fetchBranchTip(this.baseBranch);
1403
+ }
1404
+ catch (err) {
1405
+ throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote ` +
1406
+ `'${this.remote}', so settings branch '${settingsBranch}' was not checked out: ` +
1407
+ `${getErrorMessage(err)}`);
1408
+ }
1409
+ }
1410
+ async rootCommits(rev) {
1411
+ const output = await this.git.raw(['rev-list', '--max-parents=0', rev]);
1412
+ return output.split('\n').filter(Boolean);
1413
+ }
1414
+ /**
1415
+ * Whether `branch` is a single parentless commit with an empty tree, as
1416
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
1417
+ * same-named tag first, and clones fetch tags.
1418
+ */
1419
+ async isEmptyInitialBranch(branch) {
1420
+ const ref = `refs/heads/${branch}`;
1421
+ const commits = await this.git.raw(['rev-list', '--max-count=2', ref]);
1422
+ if (commits.split('\n').filter(Boolean).length !== 1)
1423
+ return false;
1424
+ const tree = await this.git.raw(['ls-tree', ref]);
1425
+ return tree.trim() === '';
1426
+ }
1137
1427
  }
@@ -76,6 +76,7 @@ export interface PullRequestDetails {
76
76
  state: 'open' | 'closed';
77
77
  merged: boolean;
78
78
  draft: boolean;
79
+ body: string;
79
80
  }
80
81
  export interface CreateOrUpdatePullRequestParams {
81
82
  octokit: Octokit;
@@ -87,6 +88,11 @@ export interface CreateOrUpdatePullRequestParams {
87
88
  body: string;
88
89
  /** Convert a pre-existing draft PR to ready-for-review after updating it. */
89
90
  markReadyIfDraft?: boolean;
91
+ /**
92
+ * `body` is the canopycms PR section: an existing PR keeps the text outside
93
+ * its section (see mergePrSection) instead of having its body replaced.
94
+ */
95
+ mergeSectionIntoBody?: boolean;
90
96
  /** Forwarded to all GitHub requests (worker task-timeout abort). */
91
97
  signal?: AbortSignal;
92
98
  }
@@ -128,6 +134,8 @@ export declare class GitHubService {
128
134
  body: string;
129
135
  /** Convert a pre-existing draft PR to ready-for-review after updating it. */
130
136
  markReadyIfDraft?: boolean;
137
+ /** See {@link CreateOrUpdatePullRequestParams.mergeSectionIntoBody}. */
138
+ mergeSectionIntoBody?: boolean;
131
139
  }): Promise<{
132
140
  number: number;
133
141
  url: string;
@@ -7,6 +7,7 @@ import { getErrorMessage } from './utils/error.js';
7
7
  // or CloudWatch folds it into the previous event. Under Lambda/dev the helper
8
8
  // is plain console. See utils/logger.ts.
9
9
  import { canopyLogWarn } from './utils/logger.js';
10
+ import { mergePrSection } from './submission-attribution.js';
10
11
  const ThrottledOctokit = Octokit.plugin(throttling);
11
12
  /**
12
13
  * Retry primary rate limits at most twice and only for short waits; beyond
@@ -67,6 +68,7 @@ export function createCanopyOctokit(options) {
67
68
  */
68
69
  export async function createOrUpdatePullRequest(params) {
69
70
  const { octokit, owner, repo, head, base, title, body, markReadyIfDraft, signal } = params;
71
+ const mergeSection = params.mergeSectionIntoBody === true;
70
72
  // CONDITIONAL spread: with no signal, the request objects below carry no
71
73
  // `request` key at all (github-service.test.ts asserts on their exact shape).
72
74
  const requestOption = signal ? { request: { signal } } : {};
@@ -92,7 +94,7 @@ export async function createOrUpdatePullRequest(params) {
92
94
  repo,
93
95
  pull_number: existing.number,
94
96
  title,
95
- body,
97
+ body: mergeSection ? mergePrSection(existing.body, body) : body,
96
98
  ...requestOption,
97
99
  });
98
100
  // Only a pre-existing PR can be a draft on this path — pulls.create
@@ -185,6 +187,7 @@ export class GitHubService {
185
187
  title: options.title,
186
188
  body: options.body,
187
189
  markReadyIfDraft: options.markReadyIfDraft,
190
+ mergeSectionIntoBody: options.mergeSectionIntoBody,
188
191
  });
189
192
  return { number: result.number, url: result.url };
190
193
  }
@@ -200,6 +203,7 @@ export class GitHubService {
200
203
  state: response.data.state,
201
204
  merged: response.data.merged ?? false,
202
205
  draft: response.data.draft ?? false,
206
+ body: response.data.body ?? '',
203
207
  };
204
208
  }
205
209
  async convertToDraft(prNumber) {