canopycms 0.11.1 → 0.12.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 (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. package/package.json +1 -1
package/dist/cli/init.js CHANGED
@@ -1558,6 +1558,9 @@ var ProdClientSafeStrategy = class {
1558
1558
  supportsPullRequests() {
1559
1559
  return true;
1560
1560
  }
1561
+ syncsBranchesWithBase() {
1562
+ return true;
1563
+ }
1561
1564
  getPermissionsFileName() {
1562
1565
  return "permissions.json";
1563
1566
  }
@@ -1584,6 +1587,9 @@ var DevClientSafeStrategy = class {
1584
1587
  supportsPullRequests() {
1585
1588
  return false;
1586
1589
  }
1590
+ syncsBranchesWithBase() {
1591
+ return false;
1592
+ }
1587
1593
  getPermissionsFileName() {
1588
1594
  return "permissions.json";
1589
1595
  }
@@ -1,9 +1,21 @@
1
1
  /**
2
- * Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
3
- * `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
2
+ * Imports a site's own TypeScript modules for CLI commands that need them: `canopycms.config.ts`
3
+ * (`sync`, `generate-ai-content`, `backfill-item-keys`, `configured-asset-store.ts`) and the
4
+ * entry schema registry (`generate-ai-content`, `backfill-item-keys`). jiti lets plain node
5
+ * import them without tsx. Importing runs the module and loads its own dependencies.
4
6
  */
7
+ /** A site module's exports, by name. */
8
+ export declare function importSiteModule(filePath: string): Promise<Record<string, unknown>>;
5
9
  /** The site's server config export (`server` of a split config, else the export itself). */
6
10
  export declare function importSiteServerConfig(projectDir: string): Promise<{
7
11
  configPath: string;
8
12
  server: unknown;
9
13
  }>;
14
+ /**
15
+ * The site's entry schema registry: `{appDir}/schemas.ts`'s `entrySchemaRegistry` export, else
16
+ * the module's exports. Throws when the file is missing or fails to import.
17
+ */
18
+ export declare function importSiteEntrySchemaRegistry(projectDir: string, appDir: string): Promise<{
19
+ schemasPath: string;
20
+ registry: Record<string, unknown>;
21
+ }>;
@@ -1,17 +1,33 @@
1
1
  /**
2
- * Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
3
- * `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
2
+ * Imports a site's own TypeScript modules for CLI commands that need them: `canopycms.config.ts`
3
+ * (`sync`, `generate-ai-content`, `backfill-item-keys`, `configured-asset-store.ts`) and the
4
+ * entry schema registry (`generate-ai-content`, `backfill-item-keys`). jiti lets plain node
5
+ * import them without tsx. Importing runs the module and loads its own dependencies.
4
6
  */
5
7
  import path from 'node:path';
6
8
  import { createJiti } from 'jiti';
9
+ const jiti = createJiti(import.meta.url);
10
+ /** A site module's exports, by name. */
11
+ export async function importSiteModule(filePath) {
12
+ return (await jiti.import(filePath));
13
+ }
7
14
  /** The site's server config export (`server` of a split config, else the export itself). */
8
15
  export async function importSiteServerConfig(projectDir) {
9
16
  const configPath = path.join(projectDir, 'canopycms.config.ts');
10
- const jiti = createJiti(import.meta.url);
11
- const mod = (await jiti.import(configPath));
17
+ const mod = await importSiteModule(configPath);
12
18
  const configExport = mod.default ?? mod.config ?? mod;
13
19
  const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
14
20
  ? configExport.server
15
21
  : configExport;
16
22
  return { configPath, server };
17
23
  }
24
+ /**
25
+ * The site's entry schema registry: `{appDir}/schemas.ts`'s `entrySchemaRegistry` export, else
26
+ * the module's exports. Throws when the file is missing or fails to import.
27
+ */
28
+ export async function importSiteEntrySchemaRegistry(projectDir, appDir) {
29
+ const schemasPath = path.join(projectDir, appDir, 'schemas.ts');
30
+ const mod = await importSiteModule(schemasPath);
31
+ const registry = mod.entrySchemaRegistry ?? mod;
32
+ return { schemasPath, registry };
33
+ }
@@ -1,3 +1,4 @@
1
+ import type { KeyRemap } from './merge/entry-merge.js';
1
2
  export type CommentType = 'field' | 'entry' | 'branch';
2
3
  interface Comment {
3
4
  id: string;
@@ -94,6 +95,13 @@ export declare class CommentStore {
94
95
  * `CommentStoreConflictError`.
95
96
  */
96
97
  private withMutation;
98
+ /**
99
+ * Point the threads on `entryPath` at the item keys a merge gave their items. A remap names its
100
+ * item by the keys of the items holding it, outermost first, as the threads held them before the
101
+ * merge, so every remap is matched against a thread's keys before any of them is applied.
102
+ * Resolves how many threads moved.
103
+ */
104
+ remapItemKeys(entryPath: string, remaps: readonly KeyRemap[]): Promise<number>;
97
105
  addComment(options: {
98
106
  userId: string;
99
107
  text: string;
@@ -138,6 +138,34 @@ export class CommentStore {
138
138
  throw err;
139
139
  }
140
140
  }
141
+ /**
142
+ * Point the threads on `entryPath` at the item keys a merge gave their items. A remap names its
143
+ * item by the keys of the items holding it, outermost first, as the threads held them before the
144
+ * merge, so every remap is matched against a thread's keys before any of them is applied.
145
+ * Resolves how many threads moved.
146
+ */
147
+ async remapItemKeys(entryPath, remaps) {
148
+ if (remaps.length === 0)
149
+ return 0;
150
+ return this.withMutation(async (data, version) => {
151
+ let moved = 0;
152
+ for (const thread of Object.values(data.threads)) {
153
+ const keys = thread.itemKeys;
154
+ if (thread.entryPath !== entryPath || !keys)
155
+ continue;
156
+ const remapped = keys.map((key, depth) => remaps.find((r) => r.from === key &&
157
+ r.within.length === depth &&
158
+ r.within.every((outer, i) => outer === keys[i]))?.to ?? key);
159
+ if (remapped.every((key, i) => key === keys[i]))
160
+ continue;
161
+ thread.itemKeys = remapped;
162
+ moved++;
163
+ }
164
+ if (moved > 0)
165
+ await this.writeData(data, version);
166
+ return moved;
167
+ });
168
+ }
141
169
  async addComment(options) {
142
170
  // Generate IDs outside the mutation so they stay stable across retries
143
171
  const threadId = options.threadId || randomUUID();
@@ -1,7 +1,4 @@
1
- /**
2
- * Absolute path of the generation marker for a branch-clone root.
3
- * @internal No importer; deletion candidate in safeinsights/canopycms#725.
4
- */
1
+ /** Absolute path of the generation marker under `root`. */
5
2
  export declare function contentIndexGenerationPath(root: string): string;
6
3
  /**
7
4
  * Record on disk that indexed files under `root` changed, so ContentStores in
@@ -18,10 +18,7 @@ import { SCHEMA_GENERATION_RESOURCE } from './branch-schema-cache.js';
18
18
  * before recreating a missing expected file.
19
19
  */
20
20
  const RESOURCE = 'content-index';
21
- /**
22
- * Absolute path of the generation marker for a branch-clone root.
23
- * @internal No importer; deletion candidate in safeinsights/canopycms#725.
24
- */
21
+ /** Absolute path of the generation marker under `root`. */
25
22
  export function contentIndexGenerationPath(root) {
26
23
  return resourceGenerationPath(root, RESOURCE);
27
24
  }
@@ -43,6 +43,10 @@ export declare class ContentStoreError extends Error {
43
43
  code: ContentStoreErrorCode;
44
44
  constructor(message: string, code: ContentStoreErrorCode);
45
45
  }
46
+ /** Why a restore refuses: another entry took the slug after this one was deleted. */
47
+ export declare const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
48
+ /** Why a restore refuses: the entry, by its content ID, is on the branch already. */
49
+ export declare const RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
46
50
  /**
47
51
  * Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
48
52
  * Indicates a cross-process concurrent write — the caller should reload and retry.
@@ -415,7 +419,17 @@ export declare class ContentStore {
415
419
  * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
416
420
  */
417
421
  read(collectionPath: LogicalPath, slug?: Slug | '', options?: ReadOptions): Promise<ContentDocument>;
418
- write(collectionPath: LogicalPath, slug: (Slug | "") | undefined, input: WriteInput, entryTypeName?: string, existingId?: ContentId): Promise<ContentDocument>;
422
+ write(collectionPath: LogicalPath, slug: (Slug | "") | undefined, input: WriteInput, entryTypeName?: string, existingId?: ContentId, options?: {
423
+ /**
424
+ * Runs once the file is written, under both of the write's locks, so what it changes about
425
+ * the entry no other writer or rebase sees before it does. A throw rejects the write, though
426
+ * the file is already written.
427
+ */
428
+ afterWrite?: (written: {
429
+ id?: string;
430
+ relativePath: PhysicalPath;
431
+ }) => Promise<void>;
432
+ }): Promise<ContentDocument>;
419
433
  /**
420
434
  * Set top-level `field` of the entry file at `physicalPath` to the string `value` when the
421
435
  * file holds it empty (absent, null or ''), keeping every other byte. Resolves to the file's
@@ -478,8 +492,56 @@ export declare class ContentStore {
478
492
  * pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
479
493
  */
480
494
  protected afterPrePassForTesting(): Promise<void>;
481
- /** Delete an entry and remove it from the index. */
482
- delete(collectionPath: LogicalPath, slug: Slug): Promise<void>;
495
+ /**
496
+ * Delete an entry and remove it from the index. With `expectId`, only the entry of that content
497
+ * ID: another at the slug, or none, is `EntryGoneError`. `beforeDelete` runs under both of the
498
+ * delete's locks before anything changes, and throwing refuses; `afterDelete` runs once it is
499
+ * gone, under them too.
500
+ */
501
+ delete(collectionPath: LogicalPath, slug: Slug, options?: {
502
+ expectId?: string;
503
+ beforeDelete?: () => Promise<void>;
504
+ afterDelete?: (deleted: {
505
+ id?: string;
506
+ }) => Promise<void>;
507
+ }): Promise<void>;
508
+ /**
509
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
510
+ * under its old slug, entry type and content ID, so references and comment threads find it
511
+ * again and the branch no longer differs from the base there.
512
+ *
513
+ * A create under write()'s locks and create-only guards that never replaces or relocates
514
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
515
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
516
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
517
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
518
+ * generation marker says another host changed the tree.
519
+ */
520
+ restore(collectionPath: LogicalPath, slug: Slug, entry: {
521
+ id: ContentId;
522
+ entryTypeName: string;
523
+ raw: string;
524
+ }, options?: {
525
+ /** Runs under the content-write lock before anything is written; throwing refuses. */
526
+ beforeWrite?: () => Promise<void>;
527
+ /** Runs once the entry is back, under the restore's locks. */
528
+ afterWrite?: () => Promise<void>;
529
+ }): Promise<{
530
+ relativePath: PhysicalPath;
531
+ }>;
532
+ /**
533
+ * Replace the file of entry `id`, at `slug` in `collectionPath`, with `raw`, verbatim, under
534
+ * write()'s locks; `beforeWrite` runs under them first, and throwing refuses, and `afterWrite`
535
+ * after. For a file no parse can be trusted with, so nothing is re-serialised. `EntryGoneError`
536
+ * when no file of that id is there.
537
+ */
538
+ replaceRaw(collectionPath: LogicalPath, slug: Slug, entry: {
539
+ id: ContentId;
540
+ raw: string;
541
+ }, options?: {
542
+ beforeWrite?: () => Promise<void>;
543
+ afterWrite?: () => Promise<void>;
544
+ }): Promise<void>;
483
545
  /**
484
546
  * Rename an entry by changing its slug (middle segment of filename).
485
547
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
@@ -16,7 +16,7 @@ import path from 'node:path';
16
16
  import matter from 'gray-matter';
17
17
  import { parse as yamlParse } from 'yaml';
18
18
  import { atomicWriteFile } from './utils/atomic-write.js';
19
- import { serializeFrontmatter, serializeYaml } from './utils/content-serialize.js';
19
+ import { serializeEntry, serializeFrontmatter, serializeYaml } from './utils/content-serialize.js';
20
20
  import { serializeJson } from './utils/json-source-splice.js';
21
21
  import { withLock } from './utils/async-mutex.js';
22
22
  import { assertContentWriteLockHeld, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
@@ -56,6 +56,10 @@ export class ContentStoreError extends Error {
56
56
  this.code = code;
57
57
  }
58
58
  }
59
+ /** Why a restore refuses: another entry took the slug after this one was deleted. */
60
+ export const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
61
+ /** Why a restore refuses: the entry, by its content ID, is on the branch already. */
62
+ export const RESTORE_ID_TAKEN_MESSAGE = 'This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.';
59
63
  /**
60
64
  * Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
61
65
  * Indicates a cross-process concurrent write — the caller should reload and retry.
@@ -798,7 +802,7 @@ export class ContentStore {
798
802
  doc.version = stat.mtimeMs;
799
803
  return doc;
800
804
  }
801
- async write(collectionPath, slug = '', input, entryTypeName, existingId) {
805
+ async write(collectionPath, slug = '', input, entryTypeName, existingId, options = {}) {
802
806
  // Warm the index outside the lock (a full scan must not hold the entry lock)
803
807
  await this.idIndex();
804
808
  const schemaItem = this.assertSchemaItem(collectionPath);
@@ -1023,16 +1027,7 @@ export class ContentStore {
1023
1027
  // intact, and no caller passes `existingId`. If a relocating write becomes
1024
1028
  // reachable, read the ID's current path instead of `absolutePath`.
1025
1029
  const existingRaw = current?.content;
1026
- let content;
1027
- if (input.format === 'json') {
1028
- content = serializeJson(input.data ?? {}, existingRaw);
1029
- }
1030
- else if (input.format === 'yaml') {
1031
- content = serializeYaml(input.data ?? {}, existingRaw);
1032
- }
1033
- else {
1034
- content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw, input.format, fields);
1035
- }
1030
+ const content = serializeEntry(input, existingRaw, fields);
1036
1031
  await atomicWriteFile(absolutePath, content);
1037
1032
  // Update the ID index after a successful write. Look up and mutate the LIVE index
1038
1033
  // in one synchronous window (no awaits in between): a concurrent rebuild may have
@@ -1069,6 +1064,7 @@ export class ContentStore {
1069
1064
  });
1070
1065
  }
1071
1066
  await this.recordOwnMutation(liveIndex);
1067
+ await options.afterWrite?.({ id, relativePath });
1072
1068
  const afterStat = await fs.stat(absolutePath);
1073
1069
  const base = {
1074
1070
  collection: schemaItem.logicalPath,
@@ -1228,8 +1224,13 @@ export class ContentStore {
1228
1224
  * pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
1229
1225
  */
1230
1226
  async afterPrePassForTesting() { }
1231
- /** Delete an entry and remove it from the index. */
1232
- async delete(collectionPath, slug) {
1227
+ /**
1228
+ * Delete an entry and remove it from the index. With `expectId`, only the entry of that content
1229
+ * ID: another at the slug, or none, is `EntryGoneError`. `beforeDelete` runs under both of the
1230
+ * delete's locks before anything changes, and throwing refuses; `afterDelete` runs once it is
1231
+ * gone, under them too.
1232
+ */
1233
+ async delete(collectionPath, slug, options = {}) {
1233
1234
  // Warm the index outside the lock (a full scan must not hold the entry lock)
1234
1235
  await this.idIndex();
1235
1236
  const collection = this.assertCollection(collectionPath);
@@ -1238,7 +1239,9 @@ export class ContentStore {
1238
1239
  const prePass = await this.buildPaths(collection, slug);
1239
1240
  if (prePass.existed)
1240
1241
  this.assertEntryTypeAvailable(collection, prePass.entryTypeName);
1241
- if (!prePass.existed) {
1242
+ if (!prePass.existed || (options.expectId !== undefined && prePass.id !== options.expectId)) {
1243
+ if (options.expectId !== undefined)
1244
+ throw new EntryGoneError();
1242
1245
  // Nothing on disk for this slug -- no shared resource to lock on, and fs.unlink on the
1243
1246
  // freshly generated (necessarily nonexistent) path throws ENOENT, as callers expect.
1244
1247
  await fs.unlink(prePass.absolutePath);
@@ -1264,6 +1267,11 @@ export class ContentStore {
1264
1267
  return RETRY_KEY;
1265
1268
  }
1266
1269
  const { absolutePath, relativePath } = inLock;
1270
+ if (options.expectId !== undefined &&
1271
+ (!inLock.existed || inLock.id !== options.expectId)) {
1272
+ throw new EntryGoneError();
1273
+ }
1274
+ await options.beforeDelete?.();
1267
1275
  await fs.unlink(absolutePath);
1268
1276
  // Remove from the LIVE index — lookup and mutation in one synchronous
1269
1277
  // window, since a concurrent rebuild may swap instances across awaits.
@@ -1273,6 +1281,7 @@ export class ContentStore {
1273
1281
  liveIndex.remove(id);
1274
1282
  }
1275
1283
  await this.recordOwnMutation(liveIndex);
1284
+ await options.afterDelete?.({ id: id ?? inLock.id });
1276
1285
  return undefined;
1277
1286
  });
1278
1287
  if (outcome !== RETRY_KEY)
@@ -1283,6 +1292,103 @@ export class ContentStore {
1283
1292
  throw new ContentConflictError();
1284
1293
  });
1285
1294
  }
1295
+ /**
1296
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
1297
+ * under its old slug, entry type and content ID, so references and comment threads find it
1298
+ * again and the branch no longer differs from the base there.
1299
+ *
1300
+ * A create under write()'s locks and create-only guards that never replaces or relocates
1301
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
1302
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
1303
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
1304
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
1305
+ * generation marker says another host changed the tree.
1306
+ */
1307
+ async restore(collectionPath, slug, entry, options = {}) {
1308
+ await this.idIndex();
1309
+ const collection = this.assertCollection(collectionPath);
1310
+ if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
1311
+ throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, 'NO_SCHEMA_ITEM');
1312
+ }
1313
+ this.assertEntryTypeAvailable(collection, entry.entryTypeName);
1314
+ return this.withContentWriteExclusion(async () => {
1315
+ await options.beforeWrite?.();
1316
+ // Every content write and every git or sync change to the tree bumps the marker
1317
+ // (content-index-generation.ts), so a marker that still matches the index's means the
1318
+ // index is current. Read here unthrottled, unlike idIndex()'s own probe; a rescan holds no
1319
+ // entry lock, which it must not.
1320
+ if ((await readContentIndexGeneration(this.root)) !== this.loadedDiskGeneration) {
1321
+ this.invalidateIndex();
1322
+ }
1323
+ await this.idIndex();
1324
+ return withLock(this.createLockKey(collection, slug), async () => {
1325
+ // The ID first: an entry already back at its own slug is this one, not another.
1326
+ const target = await this.buildPaths(collection, slug, {
1327
+ existingId: entry.id,
1328
+ entryTypeName: entry.entryTypeName,
1329
+ });
1330
+ const dir = path.dirname(target.absolutePath);
1331
+ const liveIndex = this._idIndex;
1332
+ if (liveIndex.findById(entry.id) ||
1333
+ liveIndex.getDuplicateFor(entry.id) ||
1334
+ (await this.findEntryPathsById(dir, entry.id)).length > 0) {
1335
+ throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
1336
+ }
1337
+ if ((await this.buildPaths(collection, slug)).existed) {
1338
+ throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
1339
+ }
1340
+ // [SLUG] and [URL], as write() checks a create.
1341
+ const routable = parseSlug(target.slug);
1342
+ if (!routable.ok) {
1343
+ throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, 'VALIDATION');
1344
+ }
1345
+ await this.assertUrlPathAvailable(dir, target.slug);
1346
+ await fs.mkdir(dir, { recursive: true });
1347
+ await atomicWriteFile(target.absolutePath, entry.raw);
1348
+ // The live index, looked up and changed with no await between: a rebuild since the check
1349
+ // above may have swapped in one that already scanned the restored file.
1350
+ const index = this._idIndex;
1351
+ if (!index.findById(entry.id)) {
1352
+ index.add({
1353
+ type: 'entry',
1354
+ relativePath: target.relativePath,
1355
+ collection: collectionPath,
1356
+ slug,
1357
+ });
1358
+ }
1359
+ await this.recordOwnMutation(index);
1360
+ await options.afterWrite?.();
1361
+ return { relativePath: target.relativePath };
1362
+ });
1363
+ });
1364
+ }
1365
+ /**
1366
+ * Replace the file of entry `id`, at `slug` in `collectionPath`, with `raw`, verbatim, under
1367
+ * write()'s locks; `beforeWrite` runs under them first, and throwing refuses, and `afterWrite`
1368
+ * after. For a file no parse can be trusted with, so nothing is re-serialised. `EntryGoneError`
1369
+ * when no file of that id is there.
1370
+ */
1371
+ async replaceRaw(collectionPath, slug, entry, options = {}) {
1372
+ await this.idIndex();
1373
+ const collection = this.assertCollection(collectionPath);
1374
+ const prePass = await this.buildPaths(collection, slug);
1375
+ if (!prePass.existed || prePass.id !== entry.id)
1376
+ throw new EntryGoneError();
1377
+ this.assertEntryTypeAvailable(collection, prePass.entryTypeName);
1378
+ const lockKey = this.entryLockKey(collection, slug, prePass);
1379
+ return this.withContentWriteExclusion(() => withLock(lockKey, async () => {
1380
+ const inLock = await this.buildPaths(collection, slug);
1381
+ if (!inLock.existed || inLock.id !== entry.id)
1382
+ throw new EntryGoneError();
1383
+ if (this.entryLockKey(collection, slug, inLock) !== lockKey) {
1384
+ throw new ContentConflictError();
1385
+ }
1386
+ await options.beforeWrite?.();
1387
+ await atomicWriteFile(inLock.absolutePath, entry.raw);
1388
+ await this.recordOwnMutation(this._idIndex);
1389
+ await options.afterWrite?.();
1390
+ }));
1391
+ }
1286
1392
  /**
1287
1393
  * Rename an entry by changing its slug (middle segment of filename).
1288
1394
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
@@ -4,6 +4,7 @@ import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '.
4
4
  import type { CommentThread } from '../comment-store.js';
5
5
  import type { UserSearchResult } from '../auth/types.js';
6
6
  import type { CreateRefusal } from './hooks/create-branch-request.js';
7
+ import type { BaseUpdateControl } from './base-update-wait.js';
7
8
  /** @internal Exported for tests. */
8
9
  export interface BranchSummary {
9
10
  name: string;
@@ -33,7 +34,7 @@ export interface BranchSummary {
33
34
  syncFailureReason?: string;
34
35
  /** Whether this branch has unresolved merge conflicts with the base branch */
35
36
  conflictStatus?: ConflictStatus;
36
- /** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
37
+ /** ContentIds of entries with a merge conflict a person has yet to resolve */
37
38
  conflictFiles?: string[];
38
39
  /** What is stuck and needs a person (api/branch.ts). */
39
40
  hold?: BranchHold;
@@ -82,6 +83,8 @@ export interface BranchManagerProps {
82
83
  currentBranchName?: string;
83
84
  /** Why the open branch's Submit or Withdraw must wait, such as a Save all still writing it. */
84
85
  currentWorkflowBlockedReason?: string;
86
+ /** "Get latest changes" for the open branch, when it applies (hooks/useBaseUpdate.tsx). */
87
+ currentBaseUpdate?: BaseUpdateControl;
85
88
  /** Opens on the new-branch form, its name field focused, as New branch… in the branch bar asks. */
86
89
  startCreating?: boolean;
87
90
  onSelect?: (name: string) => void;
@@ -17,6 +17,7 @@ import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence,
17
17
  import { TooltipDetail } from './components/TooltipDetail.js';
18
18
  import { branchAddress, displayBranchName } from './branch-names.js';
19
19
  import { CLOSED_PULL_REQUEST_REASON, DELETE_NEEDS_ADMIN_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
20
+ import { GetLatestChangesButton } from './components/GetLatestChangesButton.js';
20
21
  import { isReviewerOfOthersReview } from '../authorization/review-role.js';
21
22
  import { SYSTEM_USER_ID } from '../system-users.js';
22
23
  /**
@@ -149,7 +150,7 @@ const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
149
150
  * reviewer Review, which opens it; the other cards show Open, which the current one never needs.
150
151
  * No action is shown twice.
151
152
  */
152
- const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
153
+ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, baseUpdate, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
153
154
  const statusBadge = branchStatusPresentation(b.status, b.isProtected);
154
155
  const perms = getBranchPermissions(b, user);
155
156
  const userIsAdmin = isAdmin(user?.groups);
@@ -194,7 +195,10 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
194
195
  // Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
195
196
  // crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
196
197
  // the title.
197
- const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
198
+ const actionRow = !!secondary ||
199
+ !!baseUpdate ||
200
+ primary?.label === EDITOR_ACTIONS.withdrawFromReview ||
201
+ (!!primary && !!b.hold);
198
202
  const archive = heldBack(userIsAdmin && b.status !== 'archived' && b.pullRequestState === 'closed'
199
203
  ? {
200
204
  label: EDITOR_ACTIONS.archiveBranch,
@@ -227,7 +231,7 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
227
231
  b.hold?.kind === 'rewritten-at-merge') &&
228
232
  !userIsAdmin
229
233
  ? DELETE_NEEDS_ADMIN_REASON
230
- : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
234
+ : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [baseUpdate && (_jsx(GetLatestChangesButton, { control: baseUpdate, testId: `get-latest-changes-button-${b.name}` })), secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
231
235
  };
232
236
  const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
233
237
  if (!action.blockedReason)
@@ -281,7 +285,7 @@ const ArchivedSection = ({ count, open, onToggle, forcedOpen, children }) => {
281
285
  // Inset and content-width, or the ScrollArea clips the focus ring.
282
286
  style: { alignSelf: 'flex-start', outlineOffset: -2 }, "data-testid": "archived-branches-toggle", children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Chevron, { size: 16, "aria-hidden": true }), _jsxs(Text, { size: "sm", fw: 600, children: ["Archived (", count, ")"] })] }) })), open && children] }));
283
287
  };
284
- export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
288
+ export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, currentBaseUpdate, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
285
289
  const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
286
290
  const [showCreateForm, setShowCreateForm] = useState(startCreating);
287
291
  const [newBranchName, setNewBranchName] = useState('');
@@ -359,7 +363,7 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
359
363
  // short name it is, so a card's actions name its branch by `branchAddress`.
360
364
  const address = branchAddress(branches, b);
361
365
  const act = (run) => run && (() => run(address));
362
- return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
366
+ return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, baseUpdate: b.name === currentBranchName ? currentBaseUpdate : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
363
367
  };
364
368
  const hasBranchThreads = comments.some((t) => t.type === 'branch');
365
369
  return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => {