canopycms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { type GitHubAuthConfig } from './github-auth.js';
2
2
  import { type GitHubGateway } from './github-gateway.js';
3
3
  import type { WorkerStatusReport } from '../types.js';
4
+ import { type RebaseSummary } from './rebase.js';
4
5
  export { PermanentTaskError, isPermanentTaskFailure } from './task-runner.js';
5
6
  export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from './log.js';
6
7
  export { resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, type GitHubAppAuth, type GitHubAuthConfig, } from './github-auth.js';
@@ -205,6 +206,10 @@ export declare class CmsWorker {
205
206
  private log;
206
207
  private statusReport?;
207
208
  private warnedDivergences;
209
+ private conflictsStatusesSet;
210
+ private branchSyncRequests;
211
+ private gitSyncRequested;
212
+ private wakeGitSync;
208
213
  private lockLostMessage?;
209
214
  private statusWriterAtLock?;
210
215
  private settleSelfStopped;
@@ -333,8 +338,18 @@ export declare class CmsWorker {
333
338
  * Run `fn` repeatedly, the next invocation starting `interval` ms after the
334
339
  * previous one COMPLETES. setTimeout chaining rather than setInterval, so
335
340
  * executions cannot overlap when one runs longer than the interval.
341
+ *
342
+ * `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
343
+ * shorter: `setWake`
344
+ * receives a function that does so while the loop rests, and undefined while a run is in
345
+ * flight; a request made during a run shows in `requested()` when it ends. Any number of
346
+ * requests coalesce into one early run.
336
347
  */
337
348
  private scheduleLoop;
349
+ /** See {@link WorkerContext.requestBranchSync}. */
350
+ private requestBranchSync;
351
+ /** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
352
+ private takeBranchSyncRequests;
338
353
  private get baseBranch();
339
354
  private get sanitizedBaseBranch();
340
355
  private requireBaseBranch;
@@ -477,9 +492,9 @@ export declare class CmsWorker {
477
492
  */
478
493
  private branchWorkspacePath;
479
494
  /**
480
- * Test-only seam: awaited inside `rebaseActiveBranches()`'s conflict round,
481
- * after `git rebase` reported conflicted files and BEFORE the
482
- * `checkout --theirs` resolution loop overwrites them. No-op in production.
495
+ * Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
496
+ * after `git rebase` reported conflicted files and BEFORE the rebase is
497
+ * aborted for a merge. No-op in production.
483
498
  *
484
499
  * A test subclass overrides this to land a real `ContentStore` write at
485
500
  * exactly the instant the rebase is mid-flight, with no sleeps or shell
@@ -497,6 +512,13 @@ export declare class CmsWorker {
497
512
  */
498
513
  protected afterRebaseCompletedForTesting(): Promise<void>;
499
514
  private rebaseActiveBranches;
515
+ /**
516
+ * Bring the base into one branch now and answer its request for the latest changes
517
+ * (rebase.ts `syncBranchNow`).
518
+ */
519
+ syncBranchNow(branchName: string): Promise<'answered' | 'busy'>;
520
+ /** Bring the base into every branch now, answering their requests from this run's start. */
521
+ syncBranchesNow(): Promise<RebaseSummary>;
500
522
  syncGit(): Promise<void>;
501
523
  private pushSettingsBranches;
502
524
  private refreshBaseBranchWorkspace;
@@ -504,3 +526,15 @@ export declare class CmsWorker {
504
526
  private pollMergeState;
505
527
  refreshAuthCache(): Promise<void>;
506
528
  }
529
+ /**
530
+ * A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
531
+ * "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
532
+ * never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
533
+ * GitHub only about a branch's pull request, which dev never opens.
534
+ */
535
+ export declare function createLocalWorker(options: {
536
+ workspacePath: string;
537
+ baseBranch: string;
538
+ contentRoot: string;
539
+ settingsBranch?: string;
540
+ }): CmsWorker;
@@ -20,7 +20,7 @@ import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.j
20
20
  import { readCarriedGatewayPolicy, readCarriedUnpublishableBranches, } from './unpublishable-branches.js';
21
21
  import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
22
22
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
23
- import { pollMergeState, runRebaseCycle } from './rebase.js';
23
+ import { pollMergeState, runRebaseCycle, syncBranchNow } from './rebase.js';
24
24
  import { cleanupTrashedBranchDirs, pushSettingsBranches, refreshBaseBranchWorkspace, syncGit, } from './git-sync.js';
25
25
  // Re-exported because this module is the package's advertised worker
26
26
  // entrypoint (`canopycms/worker/cms-worker`).
@@ -227,6 +227,11 @@ export async function recordWorkerStartupFailure(options) {
227
227
  await release?.().catch(() => { });
228
228
  }
229
229
  }
230
+ /**
231
+ * The shortest rest before a run `scheduleLoop`'s `early` starts, so a request repeated while a
232
+ * branch stays locked cannot keep the git sync, and its GitHub fetch, running back to back.
233
+ */
234
+ const EARLY_RUN_MIN_REST_MS = 10_000;
230
235
  export class CmsWorker {
231
236
  constructor(config) {
232
237
  this.config = config;
@@ -241,6 +246,11 @@ export class CmsWorker {
241
246
  this.releaseLockFn = null;
242
247
  this.log = cmsTaskQueueLogger;
243
248
  this.warnedDivergences = new Map();
249
+ this.conflictsStatusesSet = new Map();
250
+ // Branches asked for since the git sync last took them (requestBranchSync), by directory name.
251
+ this.branchSyncRequests = new Set();
252
+ // Set by any request; cleared by the next cycle, which takes it.
253
+ this.gitSyncRequested = false;
244
254
  /**
245
255
  * Settles once the worker has stopped for a reason it chose itself (its lock was
246
256
  * compromised), after the drain and its record in worker-status.json. Never settles for a
@@ -356,6 +366,9 @@ export class CmsWorker {
356
366
  ensureStatusReport: () => this.ensureStatusReport(),
357
367
  ensureSettingsBranch: () => this.ensureSettingsBranch(),
358
368
  warnedDivergences: () => this.warnedDivergences,
369
+ conflictsStatusesSet: () => this.conflictsStatusesSet,
370
+ requestBranchSync: (branchRef) => this.requestBranchSync(branchRef),
371
+ takeBranchSyncRequests: () => this.takeBranchSyncRequests(),
359
372
  afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
360
373
  afterRebaseCompletedForTesting: () => this.afterRebaseCompletedForTesting(),
361
374
  };
@@ -487,7 +500,12 @@ export class CmsWorker {
487
500
  const taskInterval = this.config.taskPollInterval ?? 5_000;
488
501
  const gitInterval = this.gitSyncIntervalMs;
489
502
  this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
490
- this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
503
+ this.scheduleLoop('git sync', () => this.syncGit(), gitInterval, {
504
+ requested: () => this.gitSyncRequested,
505
+ setWake: (wake) => {
506
+ this.wakeGitSync = wake;
507
+ },
508
+ });
491
509
  if (this.config.refreshAuthCache) {
492
510
  const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
493
511
  this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
@@ -695,22 +713,53 @@ export class CmsWorker {
695
713
  * Run `fn` repeatedly, the next invocation starting `interval` ms after the
696
714
  * previous one COMPLETES. setTimeout chaining rather than setInterval, so
697
715
  * executions cannot overlap when one runs longer than the interval.
716
+ *
717
+ * `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
718
+ * shorter: `setWake`
719
+ * receives a function that does so while the loop rests, and undefined while a run is in
720
+ * flight; a request made during a run shows in `requested()` when it ends. Any number of
721
+ * requests coalesce into one early run.
698
722
  */
699
- scheduleLoop(label, fn, interval) {
700
- const run = () => {
723
+ scheduleLoop(label, fn, interval, early) {
724
+ // `delay` counts from `restStartedAt`, so a later wake never pushes the next run out.
725
+ const run = (delay, restStartedAt = Date.now()) => {
701
726
  if (!this.running)
702
727
  return;
703
- const timeout = setTimeout(async () => {
728
+ const fire = async () => {
704
729
  this.activeTimeouts.delete(timeout);
730
+ early?.setWake(undefined);
705
731
  const operation = fn().catch((err) => {
706
732
  workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
707
733
  });
708
734
  await this.trackOperation(label, operation);
709
- run();
710
- }, interval);
735
+ run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
736
+ };
737
+ const timeout = setTimeout(fire, Math.max(0, delay - (Date.now() - restStartedAt)));
711
738
  this.activeTimeouts.add(timeout);
739
+ early?.setWake(() => {
740
+ const rested = Date.now() - restStartedAt;
741
+ const sooner = Math.max(0, Math.min(interval, EARLY_RUN_MIN_REST_MS) - rested);
742
+ if (sooner >= delay - rested || !this.activeTimeouts.delete(timeout))
743
+ return;
744
+ clearTimeout(timeout);
745
+ run(rested + sooner, restStartedAt);
746
+ });
712
747
  };
713
- run();
748
+ run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
749
+ }
750
+ /** See {@link WorkerContext.requestBranchSync}. */
751
+ requestBranchSync(branchRef) {
752
+ if (branchRef !== undefined)
753
+ this.branchSyncRequests.add(sanitizeBranchName(branchRef));
754
+ this.gitSyncRequested = true;
755
+ this.wakeGitSync?.();
756
+ }
757
+ /** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
758
+ takeBranchSyncRequests() {
759
+ const taken = { branches: this.branchSyncRequests, requested: this.gitSyncRequested };
760
+ this.branchSyncRequests = new Set();
761
+ this.gitSyncRequested = false;
762
+ return taken;
714
763
  }
715
764
  get baseBranch() {
716
765
  return this.requireBaseBranch().name;
@@ -1232,9 +1281,9 @@ export class CmsWorker {
1232
1281
  return path.join(this.contentBranchesPath, sanitizeBranchName(branchRefName));
1233
1282
  }
1234
1283
  /**
1235
- * Test-only seam: awaited inside `rebaseActiveBranches()`'s conflict round,
1236
- * after `git rebase` reported conflicted files and BEFORE the
1237
- * `checkout --theirs` resolution loop overwrites them. No-op in production.
1284
+ * Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
1285
+ * after `git rebase` reported conflicted files and BEFORE the rebase is
1286
+ * aborted for a merge. No-op in production.
1238
1287
  *
1239
1288
  * A test subclass overrides this to land a real `ContentStore` write at
1240
1289
  * exactly the instant the rebase is mid-flight, with no sleeps or shell
@@ -1258,11 +1307,20 @@ export class CmsWorker {
1258
1307
  // directly and that calls `pollMergeState` directly. Neither is on
1259
1308
  // WorkerContext, so a stub installed on either is a no-op for production.
1260
1309
  //
1261
- // `rebaseActiveBranches` also has a consumer outside the test files:
1262
- // apps/test-app/app/api/e2e-test/rebase/route.ts drives it from an e2e
1263
- // fixture route. Do not delete it as unused.
1264
- async rebaseActiveBranches(gatewayPolicy, gitHub) {
1265
- return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub);
1310
+ async rebaseActiveBranches(gatewayPolicy, gitHub, requested) {
1311
+ return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub, requested);
1312
+ }
1313
+ // --- Runs with no loop ({@link createLocalWorker}) -----------------------
1314
+ /**
1315
+ * Bring the base into one branch now and answer its request for the latest changes
1316
+ * (rebase.ts `syncBranchNow`).
1317
+ */
1318
+ async syncBranchNow(branchName) {
1319
+ return syncBranchNow(this.ctx(), branchName);
1320
+ }
1321
+ /** Bring the base into every branch now, answering their requests from this run's start. */
1322
+ async syncBranchesNow() {
1323
+ return runRebaseCycle(this.ctx(), undefined, undefined, new Set(), { fetchedAt: Date.now() }, true);
1266
1324
  }
1267
1325
  // --- Git-sync cluster (worker/git-sync.ts) -----------------------------
1268
1326
  //
@@ -1302,3 +1360,17 @@ export class CmsWorker {
1302
1360
  }
1303
1361
  }
1304
1362
  }
1363
+ /**
1364
+ * A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
1365
+ * "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
1366
+ * never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
1367
+ * GitHub only about a branch's pull request, which dev never opens.
1368
+ */
1369
+ export function createLocalWorker(options) {
1370
+ return new CmsWorker({
1371
+ ...options,
1372
+ githubOwner: 'local',
1373
+ githubRepo: 'local',
1374
+ githubToken: 'local',
1375
+ });
1376
+ }
@@ -0,0 +1,20 @@
1
+ import type { ConflictsStatusFailure } from '../types.js';
2
+ import type { GitHubConflictsStatus } from './github-gateway.js';
3
+ import type { WorkerContext } from './worker-context.js';
4
+ export type ConflictsStatusContext = Pick<WorkerContext, 'contentBranchesPath' | 'sanitizedBaseBranch' | 'github' | 'conflictsStatusesSet' | 'shutdownSignal' | 'taskTimeoutMs' | 'requestBranchSync'>;
5
+ /** The state and description a branch's conflict record calls for. */
6
+ type ConflictsStatusText = Pick<GitHubConflictsStatus, 'state' | 'description'>;
7
+ /**
8
+ * What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
9
+ * the record is held after a damaged one was set aside, or does not parse; success otherwise.
10
+ * @internal Exported for tests.
11
+ */
12
+ export declare function conflictsStatusOf(branchRoot: string): Promise<ConflictsStatusText>;
13
+ /**
14
+ * Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
15
+ * fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
16
+ * when every status was set, or null when the stage could not say: the branches could not be
17
+ * listed, or a drain cut it off. Never throws.
18
+ */
19
+ export declare function setConflictsStatuses(ctx: ConflictsStatusContext, githubTips: ReadonlyMap<string, string>): Promise<ConflictsStatusFailure | undefined | null>;
20
+ export {};
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { BranchMetadataFileManager } from '../branch-metadata.js';
4
+ import { BranchMetadataCorruptError } from '../branch-metadata-file.js';
5
+ import { editorDeepLink, resolveEditorUrl } from '../editor-url.js';
6
+ import { MergeConflictStore, MergeConflictsFileFormatError, MergeConflictsFileMalformedError, openConflictCount, } from '../merge-conflicts.js';
7
+ import { getErrorMessage, isNodeError, sanitizeErrorMessage } from '../utils/error.js';
8
+ import { escapeForLog } from '../utils/escape-text.js';
9
+ import { GatewayRefusalError, MAX_COMMIT_STATUS_URL_LENGTH } from './branch-policy.js';
10
+ import { getHttpStatus, isPermissionDenial } from './github-errors.js';
11
+ import { workerLog, workerLogWarn } from './log.js';
12
+ /** The most branch names a failure report lists. */
13
+ const MAX_REPORTED_BRANCHES = 50;
14
+ /**
15
+ * What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
16
+ * the record is held after a damaged one was set aside, or does not parse; success otherwise.
17
+ * @internal Exported for tests.
18
+ */
19
+ export async function conflictsStatusOf(branchRoot) {
20
+ try {
21
+ const record = await new MergeConflictStore(branchRoot).read();
22
+ if (record?.held) {
23
+ return {
24
+ state: 'failure',
25
+ description: 'Conflict record set aside: an admin must release the branch in the editor',
26
+ };
27
+ }
28
+ const open = record ? openConflictCount(record.entries) : 0;
29
+ if (open === 0)
30
+ return { state: 'success', description: 'No conflicting changes to resolve' };
31
+ return {
32
+ state: 'failure',
33
+ description: open === 1
34
+ ? '1 conflicting change to resolve in the editor'
35
+ : `${open} conflicting changes to resolve in the editor`,
36
+ };
37
+ }
38
+ catch (err) {
39
+ if (err instanceof MergeConflictsFileMalformedError ||
40
+ err instanceof MergeConflictsFileFormatError) {
41
+ return { state: 'failure', description: "Couldn't read this branch's conflicts" };
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+ /**
47
+ * Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
48
+ * fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
49
+ * when every status was set, or null when the stage could not say: the branches could not be
50
+ * listed, or a drain cut it off. Never throws.
51
+ */
52
+ export async function setConflictsStatuses(ctx, githubTips) {
53
+ let due;
54
+ let unreadable;
55
+ try {
56
+ ;
57
+ ({ due, unreadable } = await dueStatuses(ctx, githubTips));
58
+ }
59
+ catch (err) {
60
+ workerLogWarn(`Could not read the branches' conflicts for their status: ${getErrorMessage(err)}`);
61
+ return null;
62
+ }
63
+ const set = ctx.conflictsStatusesSet();
64
+ const failed = [];
65
+ let failure;
66
+ for (const [index, { branch, status, key }] of due.entries()) {
67
+ if (ctx.shutdownSignal().aborted)
68
+ return null;
69
+ try {
70
+ // Bounded, so a GitHub that stalls holds the sync for one timeout, not one per branch.
71
+ await ctx
72
+ .github()
73
+ .setConflictsStatus(status, AbortSignal.any([ctx.shutdownSignal(), AbortSignal.timeout(ctx.taskTimeoutMs)]));
74
+ set.set(branch, key);
75
+ workerLog(`Set the conflicts status of PR #${status.pullRequest} (${escapeForLog(branch)}) at ` +
76
+ `${status.sha.slice(0, 7)}: ${status.state}`);
77
+ }
78
+ catch (err) {
79
+ if (ctx.shutdownSignal().aborted)
80
+ return null;
81
+ if (err instanceof GatewayRefusalError && err.reason === 'not-head') {
82
+ // GitHub moves a pull request's head a moment after the push, so this cycle's tip can be
83
+ // ahead of it: one more sync for that tip, and no loop if the two never agree.
84
+ const asked = `not-head:${branch}`;
85
+ if (set.get(asked) !== status.sha) {
86
+ set.set(asked, status.sha);
87
+ ctx.requestBranchSync(branch);
88
+ }
89
+ continue;
90
+ }
91
+ const message = sanitizeErrorMessage(getErrorMessage(err));
92
+ failed.push(branch);
93
+ workerLogWarn(`Could not set the conflicts status of ${escapeForLog(branch)}: ${message}`);
94
+ // The gateway's refusals for the pull request itself report as `other`, retried each cycle.
95
+ const reason = err instanceof GatewayRefusalError
96
+ ? 'other'
97
+ : isPermissionDenial(err)
98
+ ? 'permission'
99
+ : isUnreachable(err)
100
+ ? 'unreachable'
101
+ : 'other';
102
+ // A refused credential names its remedy over any other failure.
103
+ if (reason === 'permission' || !failure)
104
+ failure = { reason, message };
105
+ // Every later write would meet the same GitHub; the next cycle tries again.
106
+ if (reason !== 'other') {
107
+ failed.push(...due.slice(index + 1).map((d) => d.branch));
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ // After GitHub's answers, whose reason and remedy come first.
113
+ for (const u of unreadable)
114
+ failed.push(u.branch);
115
+ failure ??= unreadable[0] && { reason: 'other', message: unreadable[0].message };
116
+ if (!failure)
117
+ return undefined;
118
+ return {
119
+ ...failure,
120
+ branches: failed.slice(0, MAX_REPORTED_BRANCHES),
121
+ count: failed.length,
122
+ at: new Date().toISOString(),
123
+ };
124
+ }
125
+ /**
126
+ * A failure to reach GitHub or the gateway, or GitHub busy: no status at all, a timeout, a rate
127
+ * limit (a 403 that is not a permission denial), or a server error.
128
+ */
129
+ function isUnreachable(err) {
130
+ const status = getHttpStatus(err);
131
+ return status === null || status === 403 || status === 408 || status === 429 || status >= 500;
132
+ }
133
+ /**
134
+ * Each branch with a pull request whose head lacks the status it calls for, and each whose conflict
135
+ * record could not be read, which costs only its own status. A branch.json that cannot be read says
136
+ * nothing of a pull request, so it is logged and skipped. Throws only when the branches directory
137
+ * cannot be listed.
138
+ */
139
+ async function dueStatuses(ctx, githubTips) {
140
+ let entries;
141
+ try {
142
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
143
+ }
144
+ catch (err) {
145
+ if (isNodeError(err) && err.code === 'ENOENT')
146
+ return { due: [], unreadable: [] };
147
+ throw err;
148
+ }
149
+ const set = ctx.conflictsStatusesSet();
150
+ const due = [];
151
+ const unreadable = [];
152
+ const cannotRead = (branch, err) => {
153
+ const message = sanitizeErrorMessage(getErrorMessage(err));
154
+ workerLogWarn(`Could not read ${escapeForLog(branch)} for its conflicts status: ${message}`);
155
+ unreadable.push({ branch, message });
156
+ };
157
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
158
+ const dir = entry.name;
159
+ // The base branch has no pull request; `.trash-*` directories hold purged branches.
160
+ if (!entry.isDirectory() || dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
161
+ continue;
162
+ const branchRoot = path.join(ctx.contentBranchesPath, dir);
163
+ let meta;
164
+ try {
165
+ meta = await BranchMetadataFileManager.loadOnly(branchRoot);
166
+ }
167
+ catch (err) {
168
+ if (!(err instanceof BranchMetadataCorruptError) &&
169
+ !(isNodeError(err) && err.code === 'ENOENT'))
170
+ workerLogWarn(`Could not read ${escapeForLog(dir)}'s branch.json for its conflicts status: ` +
171
+ sanitizeErrorMessage(getErrorMessage(err)));
172
+ continue;
173
+ }
174
+ const branch = meta?.branch;
175
+ if (!branch || branch.status === 'archived' || branch.pullRequestNumber === undefined)
176
+ continue;
177
+ if (branch.pullRequestState === 'merged' || branch.pullRequestState === 'closed')
178
+ continue;
179
+ const sha = githubTips.get(branch.name);
180
+ if (sha === undefined)
181
+ continue;
182
+ let text;
183
+ try {
184
+ text = await conflictsStatusOf(branchRoot);
185
+ }
186
+ catch (err) {
187
+ cannotRead(branch.name, err);
188
+ continue;
189
+ }
190
+ const editorUrl = branch.editorUrl && resolveEditorUrl({ configured: branch.editorUrl });
191
+ const link = editorUrl && editorDeepLink(editorUrl, branch.name);
192
+ const status = {
193
+ pullRequest: branch.pullRequestNumber,
194
+ sha,
195
+ ...text,
196
+ // A link the gateway would refuse is left off, so it never costs the status.
197
+ ...(link && link.length <= MAX_COMMIT_STATUS_URL_LENGTH && { targetUrl: link }),
198
+ };
199
+ const key = JSON.stringify(status);
200
+ if (set.get(branch.name) !== key)
201
+ due.push({ branch: branch.name, status, key });
202
+ }
203
+ return { due, unreadable };
204
+ }
@@ -0,0 +1,80 @@
1
+ import type { SimpleGit } from 'simple-git';
2
+ import type { FlatSchemaItem } from '../config/types.js';
3
+ import { type KeyRemap } from '../merge/entry-merge.js';
4
+ import { type FoundEntryConflict } from '../merge-conflicts.js';
5
+ import type { ContentId } from '../paths/types.js';
6
+ /**
7
+ * The worker's side of the field merge: which files of a merge the engine (`merge/entry-merge.ts`)
8
+ * decides, and the blobs it decides them with. A merge's three versions are the merge base
9
+ * (ancestor), the commit coming in (base) and the branch's own (branch). Every entry file and
10
+ * `.collection.json` changed on both sides goes through the engine, whether or not git found a
11
+ * conflict in it: a merge git calls clean can still be wrong, such as a duplicate frontmatter key.
12
+ *
13
+ * Entries are paired by the content ID in their file names, and `.collection.json` files by their
14
+ * collection's, not by path, so a rename on one side meets an edit on the other whatever git's
15
+ * rename detection makes of it. A collection directory one side moved moves the other side's files
16
+ * in it too. An entry changed on one side only takes that side's version: where git's rename
17
+ * detection put something else there, the pairing by ID puts it right.
18
+ */
19
+ /** A file in one version of the tree. */
20
+ export interface TreeFile {
21
+ path: string;
22
+ oid: string;
23
+ mode: string;
24
+ }
25
+ /** What the resolver makes of a merge, for `planMerge` to stage. */
26
+ export interface FieldMergeResult {
27
+ /** Paths whose blob in the merge replaces git's: a file, or null for none. */
28
+ updates: Map<string, TreeFile | null>;
29
+ /** Every path of the units the resolver decided: git's conflicts on them are settled. */
30
+ covered: Set<string>;
31
+ conflicts: FoundEntryConflict[];
32
+ /** Item keys the merge changed, by the logical path the branch's comment threads name. */
33
+ keyRemaps: Array<{
34
+ entryPath: string;
35
+ contentId?: string;
36
+ remaps: KeyRemap[];
37
+ }>;
38
+ /** Where each entry or collection with a conflict on record may now be: its path in the merge. */
39
+ paths: Map<string, string>;
40
+ /** Why git's merge can't be settled by rule, where it can't. */
41
+ unsettled?: string;
42
+ }
43
+ export interface FieldMergeArgs {
44
+ git: SimpleGit;
45
+ clonePath: string;
46
+ ancestor: string;
47
+ head: string;
48
+ other: string;
49
+ /** The tree git's merge wrote. */
50
+ mergedTree: string;
51
+ /** Paths git left conflicted. */
52
+ conflicted: ReadonlySet<string>;
53
+ contentRoot: string;
54
+ /** The branch's entry types, which name conflicts as the "Show changes" drawer does. */
55
+ flatSchema: readonly FlatSchemaItem[] | undefined;
56
+ /** The content ID a `.collection.json` path belongs to. */
57
+ collectionIdOf: (file: string) => ContentId | undefined;
58
+ timeoutMs: number;
59
+ /**
60
+ * Git's merge stands, and only the item keys of an entry both sides changed change: to the field
61
+ * merge's, where it holds the same content with no conflict ({@link rekeyAsMerged}).
62
+ */
63
+ rekeyOnly?: boolean;
64
+ }
65
+ /** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
66
+ export declare function changedFiles(git: SimpleGit, from: string, to: string): Promise<Array<{
67
+ path: string;
68
+ before?: TreeFile;
69
+ after?: TreeFile;
70
+ }>>;
71
+ /**
72
+ * Whether two versions of one entry file under `contentRoot` differ in item keys alone: what a
73
+ * re-key changes. False for anything else, a file's mode or path included.
74
+ */
75
+ export declare function differsInItemKeysOnly(git: SimpleGit, x: TreeFile, y: TreeFile, contentRoot: string): Promise<boolean>;
76
+ /**
77
+ * Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
78
+ * result says which blobs replace git's, and which conflicts a person must resolve.
79
+ */
80
+ export declare function resolveFieldMerge(args: FieldMergeArgs): Promise<FieldMergeResult>;