canopycms 0.12.0 → 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 (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { readSchemaSnapshot } from '../branch-schema-cache.js';
4
5
  import { extractIdFromFilename } from '../content-id-index.js';
5
6
  import { normalizeFilesystemPath } from '../paths/normalize.js';
6
7
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
7
8
  import { isNonFastForwardRejection } from '../utils/git.js';
8
9
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
10
+ import { resolveFieldMerge } from './field-merge.js';
9
11
  import { runGit } from './git-process.js';
10
12
  import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './shared-repo-git.js';
11
13
  /**
@@ -16,10 +18,12 @@ import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './sh
16
18
  */
17
19
  const OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
18
20
  /**
19
- * The conflicts {@link buildMergeCommit} settles by keeping `head`'s side of one path. Every other
20
- * kind (a rename colliding with another, a file meeting a directory, a directory rename) moves or
21
- * joins paths, so no one side of one path is the branch's version, and settling it by rule loses a
22
- * file. Messages that are not `CONFLICT` are git's notes, such as `Auto-merging`.
21
+ * The conflicts {@link planMerge} settles by keeping `head`'s side of one path. Every other kind (a
22
+ * rename colliding with another, a file meeting a directory, a directory rename) moves or joins
23
+ * paths, so no one side of one path is the branch's version, and settling it by rule loses a file;
24
+ * only the field merge, which pairs entries by content ID, settles those, and only where it decides
25
+ * every path the conflict names. Messages that are not `CONFLICT` are git's notes, such as
26
+ * `Auto-merging`.
23
27
  */
24
28
  const SETTLED_BY_KEEPING_HEAD = new Set([
25
29
  'CONFLICT (contents)',
@@ -46,27 +50,44 @@ function parseMergeTree(output, head, other) {
46
50
  const [, mode, oid, stage, path] = match;
47
51
  stages.push({ mode, oid, stage, path });
48
52
  }
49
- const unsettled = new Set();
53
+ const unsettled = [];
50
54
  for (i++; i < fields.length && fields[i] !== '';) {
51
- const kind = fields[i + parseInt(fields[i], 10) + 1];
52
- if (kind?.startsWith('CONFLICT') && !SETTLED_BY_KEEPING_HEAD.has(kind))
53
- unsettled.add(kind);
54
- i += parseInt(fields[i], 10) + 3;
55
+ const count = parseInt(fields[i], 10);
56
+ const kind = fields[i + count + 1];
57
+ if (kind?.startsWith('CONFLICT') && !SETTLED_BY_KEEPING_HEAD.has(kind)) {
58
+ unsettled.push({ kind, paths: fields.slice(i + 1, i + 1 + count) });
59
+ }
60
+ i += count + 3;
55
61
  }
56
62
  // A path git could not decode as UTF-8 reads back as U+FFFD, which names no file.
57
- if (stages.some(({ path }) => path.includes('\ufffd')))
58
- unsettled.add('an unreadable file name');
59
- return { tree, stages, unsettled: [...unsettled] };
63
+ const undecodable = stages.filter(({ path }) => path.includes('\ufffd')).map(({ path }) => path);
64
+ if (undecodable.length > 0)
65
+ unsettled.push({ kind: 'an unreadable file name', paths: [] });
66
+ return { tree, stages, unsettled };
60
67
  }
68
+ const NO_FIELD_MERGE = {
69
+ updates: new Map(),
70
+ covered: new Set(),
71
+ conflicts: [],
72
+ keyRemaps: [],
73
+ paths: new Map(),
74
+ };
61
75
  /**
62
- * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
63
- * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
64
- * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
65
- * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
66
- * is provisioned.
76
+ * The merge of `other` into `head`, as a tree. Every entry and `.collection.json` both sides
77
+ * changed is merged field by field (field-merge.ts), which settles git's conflicts there, whatever
78
+ * their kind. A path git cannot merge that is no entry keeps `head`'s side, stage 2, at the path
79
+ * git reports, or stays deleted when `head` has no such stage: `head` has the editor's work. With
80
+ * no single merge base, git's merge stands as that rule leaves it, since a field merge needs one
81
+ * version both sides started from.
67
82
  */
68
- export async function buildMergeCommit(git, clonePath, args) {
69
- const { head, other, message, timeoutMs } = args;
83
+ export async function planMerge(git, clonePath, args) {
84
+ const { head, other, contentRoot, timeoutMs } = args;
85
+ const bases = (await git.raw(['merge-base', '--all', '--end-of-options', head, other]))
86
+ .split('\n')
87
+ .filter((line) => OBJECT_ID.test(line));
88
+ if (bases.length === 0) {
89
+ return { kind: 'unsettled', conflicts: ['no history in common with it'], paths: [] };
90
+ }
70
91
  const output = await git.raw([
71
92
  'merge-tree',
72
93
  '--write-tree',
@@ -75,16 +96,99 @@ export async function buildMergeCommit(git, clonePath, args) {
75
96
  head,
76
97
  other,
77
98
  ]);
78
- const { tree: mergedTree, stages, unsettled } = parseMergeTree(output, head, other);
79
- const conflicted = [...new Set(stages.map(({ path }) => path))];
80
- if (unsettled.length > 0)
81
- return { kind: 'unsettled', conflicts: unsettled, paths: conflicted };
82
- const tree = stages.length === 0
83
- ? mergedTree
84
- : await keepHeadStages(clonePath, { mergedTree, stages, conflicted, timeoutMs });
99
+ const { tree: gitTree, stages, unsettled } = parseMergeTree(output, head, other);
100
+ const gitConflicted = [...new Set(stages.map(({ path }) => path))];
101
+ const field = bases.length === 1
102
+ ? await resolveFieldMerge({
103
+ git,
104
+ clonePath,
105
+ ancestor: bases[0],
106
+ head,
107
+ other,
108
+ mergedTree: gitTree,
109
+ conflicted: new Set(gitConflicted),
110
+ contentRoot,
111
+ flatSchema: await readSchemaSnapshot(clonePath),
112
+ collectionIdOf: (file) => conflictFilesToContentIds([file], contentRoot)[0],
113
+ timeoutMs,
114
+ })
115
+ : NO_FIELD_MERGE;
116
+ const unsettledLeft = unsettled.filter(({ paths }) => paths.length === 0 || paths.some((p) => !field.covered.has(p)));
117
+ if (field.unsettled)
118
+ unsettledLeft.push({ kind: field.unsettled, paths: [] });
119
+ if (unsettledLeft.length > 0) {
120
+ return {
121
+ kind: 'unsettled',
122
+ conflicts: [...new Set(unsettledLeft.map(({ kind }) => kind))],
123
+ paths: gitConflicted,
124
+ };
125
+ }
126
+ const updates = new Map(field.updates);
127
+ const kept = gitConflicted.filter((file) => !field.covered.has(file));
128
+ const headStages = new Map(stages.filter((s) => s.stage === '2').map((s) => [s.path, s]));
129
+ for (const file of kept) {
130
+ const stage = headStages.get(file);
131
+ updates.set(file, stage ? { path: file, mode: stage.mode, oid: stage.oid } : null);
132
+ }
133
+ const tree = updates.size === 0 ? gitTree : await withUpdates(clonePath, { gitTree, updates, timeoutMs });
134
+ const conflicts = [
135
+ ...field.conflicts,
136
+ ...keptVersions(kept, stages, contentRoot, field.conflicts),
137
+ ];
138
+ return {
139
+ kind: 'planned',
140
+ tree,
141
+ gitTree,
142
+ gitConflicted,
143
+ conflicts,
144
+ keptPaths: [...new Set([...field.conflicts.map(({ path }) => path), ...kept])],
145
+ keyRemaps: field.keyRemaps,
146
+ paths: field.paths,
147
+ asGitDoes: gitConflicted.length === 0 &&
148
+ conflicts.length === 0 &&
149
+ field.keyRemaps.length === 0 &&
150
+ tree === gitTree,
151
+ };
152
+ }
153
+ /**
154
+ * The files the merge kept `head`'s side of as entry-level conflicts, by the content ID their path
155
+ * carries, one per ID and none for an ID the field merge already names. A path with none is
156
+ * dropped rather than guessed at.
157
+ */
158
+ function keptVersions(kept, stages, contentRoot, named) {
159
+ const seen = new Set(named.map(({ contentId }) => contentId));
160
+ const found = [];
161
+ for (const file of kept) {
162
+ const [contentId] = conflictFilesToContentIds([file], contentRoot);
163
+ if (!contentId || seen.has(contentId))
164
+ continue;
165
+ seen.add(contentId);
166
+ const oid = (stage) => stages.find((s) => s.path === file && s.stage === stage)?.oid;
167
+ const [ancestor, branch, base] = [oid('1'), oid('2'), oid('3')];
168
+ found.push({
169
+ contentId,
170
+ path: file,
171
+ kind: 'unreadable',
172
+ sides: {
173
+ ...(ancestor && { ancestor }),
174
+ ...(base && { base }),
175
+ ...(branch && { branch }),
176
+ },
177
+ });
178
+ }
179
+ return found;
180
+ }
181
+ /**
182
+ * {@link planMerge}'s merge as a commit no ref names, with `head` the first parent. The commit takes
183
+ * its identity from the clone's `user.*`, written when the clone is provisioned.
184
+ */
185
+ export async function commitMerge(git, plan, { head, other, message }) {
186
+ const { tree, gitTree: _gitTree, kind: _kind, ...rest } = plan;
85
187
  const sha = (await git.raw(['commit-tree', '-p', head, '-p', other, '-m', message, '--end-of-options', tree])).trim();
86
- return { kind: 'built', sha, conflicted };
188
+ return { kind: 'built', sha, ...rest };
87
189
  }
190
+ /** Whether `plan` is git's own merge with nothing for a person. */
191
+ export const mergesAsGitDoes = (plan) => plan.kind === 'planned' && plan.asGitDoes;
88
192
  /**
89
193
  * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
90
194
  * conflict counts as no: whoever settled it chose what `merged` holds.
@@ -103,9 +207,9 @@ export async function isCleanMergeOf(git, merged, from, other) {
103
207
  return false;
104
208
  return (await git.revparse(['--verify', '--end-of-options', `${merged}^{tree}`])).trim() === tree;
105
209
  }
106
- /** `mergedTree` with each of `conflicted` as `head` has it, through a throwaway index. */
107
- async function keepHeadStages(clonePath, args) {
108
- const { mergedTree, stages, conflicted, timeoutMs } = args;
210
+ /** `gitTree` with each of `updates` in place, through a throwaway index. */
211
+ async function withUpdates(clonePath, args) {
212
+ const { gitTree, updates, timeoutMs } = args;
109
213
  const shared = sharedRepoProcessOptions(clonePath, 'worktree');
110
214
  const indexFile = path.join(clonePath, '.git', `canopy-merge-index-${randomUUID()}`);
111
215
  const options = {
@@ -113,15 +217,11 @@ async function keepHeadStages(clonePath, args) {
113
217
  env: { ...shared.env, GIT_INDEX_FILE: indexFile },
114
218
  inactivityTimeoutMs: timeoutMs,
115
219
  };
116
- const kept = new Map(stages.filter((s) => s.stage === '2').map((s) => [s.path, s]));
117
- // Mode 0 removes a path: what is left when `head` has no side of it.
118
- const zeros = '0'.repeat(mergedTree.length);
119
- const entries = conflicted.map((file) => {
120
- const entry = kept.get(file);
121
- return `${entry?.mode ?? '0'} ${entry?.oid ?? zeros}\t${file}\0`;
122
- });
220
+ // Mode 0 removes a path.
221
+ const zeros = '0'.repeat(gitTree.length);
222
+ const entries = [...updates].map(([file, blob]) => `${blob?.mode ?? '0'} ${blob?.oid ?? zeros}\t${file}\0`);
123
223
  try {
124
- await runGit(['read-tree', '--end-of-options', mergedTree], options);
224
+ await runGit(['read-tree', '--end-of-options', gitTree], options);
125
225
  await runGit(['update-index', '-z', '--index-info'], { ...options, stdin: entries.join('') });
126
226
  return (await runGit(['write-tree'], options)).trim();
127
227
  }
@@ -170,7 +270,7 @@ export async function commitsApart(git, a, b) {
170
270
  *
171
271
  * Pure.
172
272
  */
173
- export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
273
+ function conflictFilesToContentIds(conflictedFiles, contentRoot) {
174
274
  // Entry files carry their ID in the filename. A `.collection.json` has none
175
275
  // of its own (extractIdFromFilename returns null for dot-prefixed files), so
176
276
  // the ID comes from the parent directory; the root content directory has no
@@ -211,18 +311,3 @@ export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
211
311
  .filter((id) => id !== null);
212
312
  return [...new Set(conflictIds)];
213
313
  }
214
- /**
215
- * The conflict record after a rebase or merge: the IDs already recorded plus
216
- * its own. A conflict means this branch's version replaced base's change to
217
- * that entry, which a later clean rebase does not undo, so the record only
218
- * grows until the branch merges (buildMergedBranchUpdate clears it).
219
- *
220
- * Pure.
221
- */
222
- export function recordConflicts(recorded, found) {
223
- const conflictFiles = [...new Set([...(recorded ?? []), ...found])];
224
- return {
225
- conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
226
- conflictFiles,
227
- };
228
- }
@@ -205,6 +205,9 @@ export declare class CmsWorker {
205
205
  private log;
206
206
  private statusReport?;
207
207
  private warnedDivergences;
208
+ private branchSyncRequests;
209
+ private gitSyncRequested;
210
+ private wakeGitSync;
208
211
  private lockLostMessage?;
209
212
  private statusWriterAtLock?;
210
213
  private settleSelfStopped;
@@ -333,8 +336,18 @@ export declare class CmsWorker {
333
336
  * Run `fn` repeatedly, the next invocation starting `interval` ms after the
334
337
  * previous one COMPLETES. setTimeout chaining rather than setInterval, so
335
338
  * executions cannot overlap when one runs longer than the interval.
339
+ *
340
+ * `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
341
+ * shorter: `setWake`
342
+ * receives a function that does so while the loop rests, and undefined while a run is in
343
+ * flight; a request made during a run shows in `requested()` when it ends. Any number of
344
+ * requests coalesce into one early run.
336
345
  */
337
346
  private scheduleLoop;
347
+ /** See {@link WorkerContext.requestBranchSync}. */
348
+ private requestBranchSync;
349
+ /** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
350
+ private takeBranchSyncRequests;
338
351
  private get baseBranch();
339
352
  private get sanitizedBaseBranch();
340
353
  private requireBaseBranch;
@@ -477,9 +490,9 @@ export declare class CmsWorker {
477
490
  */
478
491
  private branchWorkspacePath;
479
492
  /**
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.
493
+ * Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
494
+ * after `git rebase` reported conflicted files and BEFORE the rebase is
495
+ * aborted for a merge. No-op in production.
483
496
  *
484
497
  * A test subclass overrides this to land a real `ContentStore` write at
485
498
  * exactly the instant the rebase is mid-flight, with no sleeps or shell
@@ -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,10 @@ export class CmsWorker {
241
246
  this.releaseLockFn = null;
242
247
  this.log = cmsTaskQueueLogger;
243
248
  this.warnedDivergences = new Map();
249
+ // Branches asked for since the git sync last took them (requestBranchSync), by directory name.
250
+ this.branchSyncRequests = new Set();
251
+ // Set by any request; cleared by the next cycle, which takes it.
252
+ this.gitSyncRequested = false;
244
253
  /**
245
254
  * Settles once the worker has stopped for a reason it chose itself (its lock was
246
255
  * compromised), after the drain and its record in worker-status.json. Never settles for a
@@ -356,6 +365,8 @@ export class CmsWorker {
356
365
  ensureStatusReport: () => this.ensureStatusReport(),
357
366
  ensureSettingsBranch: () => this.ensureSettingsBranch(),
358
367
  warnedDivergences: () => this.warnedDivergences,
368
+ requestBranchSync: (branchRef) => this.requestBranchSync(branchRef),
369
+ takeBranchSyncRequests: () => this.takeBranchSyncRequests(),
359
370
  afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
360
371
  afterRebaseCompletedForTesting: () => this.afterRebaseCompletedForTesting(),
361
372
  };
@@ -487,7 +498,12 @@ export class CmsWorker {
487
498
  const taskInterval = this.config.taskPollInterval ?? 5_000;
488
499
  const gitInterval = this.gitSyncIntervalMs;
489
500
  this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
490
- this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
501
+ this.scheduleLoop('git sync', () => this.syncGit(), gitInterval, {
502
+ requested: () => this.gitSyncRequested,
503
+ setWake: (wake) => {
504
+ this.wakeGitSync = wake;
505
+ },
506
+ });
491
507
  if (this.config.refreshAuthCache) {
492
508
  const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
493
509
  this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
@@ -695,22 +711,53 @@ export class CmsWorker {
695
711
  * Run `fn` repeatedly, the next invocation starting `interval` ms after the
696
712
  * previous one COMPLETES. setTimeout chaining rather than setInterval, so
697
713
  * executions cannot overlap when one runs longer than the interval.
714
+ *
715
+ * `early` lets a request cut the rest short to {@link EARLY_RUN_MIN_REST_MS}, or `interval` if
716
+ * shorter: `setWake`
717
+ * receives a function that does so while the loop rests, and undefined while a run is in
718
+ * flight; a request made during a run shows in `requested()` when it ends. Any number of
719
+ * requests coalesce into one early run.
698
720
  */
699
- scheduleLoop(label, fn, interval) {
700
- const run = () => {
721
+ scheduleLoop(label, fn, interval, early) {
722
+ // `delay` counts from `restStartedAt`, so a later wake never pushes the next run out.
723
+ const run = (delay, restStartedAt = Date.now()) => {
701
724
  if (!this.running)
702
725
  return;
703
- const timeout = setTimeout(async () => {
726
+ const fire = async () => {
704
727
  this.activeTimeouts.delete(timeout);
728
+ early?.setWake(undefined);
705
729
  const operation = fn().catch((err) => {
706
730
  workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
707
731
  });
708
732
  await this.trackOperation(label, operation);
709
- run();
710
- }, interval);
733
+ run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
734
+ };
735
+ const timeout = setTimeout(fire, Math.max(0, delay - (Date.now() - restStartedAt)));
711
736
  this.activeTimeouts.add(timeout);
737
+ early?.setWake(() => {
738
+ const rested = Date.now() - restStartedAt;
739
+ const sooner = Math.max(0, Math.min(interval, EARLY_RUN_MIN_REST_MS) - rested);
740
+ if (sooner >= delay - rested || !this.activeTimeouts.delete(timeout))
741
+ return;
742
+ clearTimeout(timeout);
743
+ run(rested + sooner, restStartedAt);
744
+ });
712
745
  };
713
- run();
746
+ run(early?.requested() ? Math.min(interval, EARLY_RUN_MIN_REST_MS) : interval);
747
+ }
748
+ /** See {@link WorkerContext.requestBranchSync}. */
749
+ requestBranchSync(branchRef) {
750
+ if (branchRef !== undefined)
751
+ this.branchSyncRequests.add(sanitizeBranchName(branchRef));
752
+ this.gitSyncRequested = true;
753
+ this.wakeGitSync?.();
754
+ }
755
+ /** See {@link WorkerContext.takeBranchSyncRequests}; called once per git sync cycle. */
756
+ takeBranchSyncRequests() {
757
+ const taken = { branches: this.branchSyncRequests, requested: this.gitSyncRequested };
758
+ this.branchSyncRequests = new Set();
759
+ this.gitSyncRequested = false;
760
+ return taken;
714
761
  }
715
762
  get baseBranch() {
716
763
  return this.requireBaseBranch().name;
@@ -1232,9 +1279,9 @@ export class CmsWorker {
1232
1279
  return path.join(this.contentBranchesPath, sanitizeBranchName(branchRefName));
1233
1280
  }
1234
1281
  /**
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.
1282
+ * Test-only seam: awaited inside `rebaseActiveBranches()`'s rebase round,
1283
+ * after `git rebase` reported conflicted files and BEFORE the rebase is
1284
+ * aborted for a merge. No-op in production.
1238
1285
  *
1239
1286
  * A test subclass overrides this to land a real `ContentStore` write at
1240
1287
  * exactly the instant the rebase is mid-flight, with no sleeps or shell
@@ -1261,8 +1308,8 @@ export class CmsWorker {
1261
1308
  // `rebaseActiveBranches` also has a consumer outside the test files:
1262
1309
  // apps/test-app/app/api/e2e-test/rebase/route.ts drives it from an e2e
1263
1310
  // fixture route. Do not delete it as unused.
1264
- async rebaseActiveBranches(gatewayPolicy, gitHub) {
1265
- return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub);
1311
+ async rebaseActiveBranches(gatewayPolicy, gitHub, requested) {
1312
+ return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub, requested);
1266
1313
  }
1267
1314
  // --- Git-sync cluster (worker/git-sync.ts) -----------------------------
1268
1315
  //
@@ -0,0 +1,65 @@
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
+ 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
+ /**
61
+ * Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
62
+ * result says which blobs replace git's, and which conflicts a person must resolve.
63
+ */
64
+ export declare function resolveFieldMerge(args: FieldMergeArgs): Promise<FieldMergeResult>;
65
+ export {};