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
@@ -0,0 +1,636 @@
1
+ import path from 'node:path';
2
+ import { parse as yamlParse } from 'yaml';
3
+ import { entryLogicalPathOfFile, extractIdFromFilename } from '../content-id-index.js';
4
+ import { parseEntryParts } from '../content-listing.js';
5
+ import { mergeCollectionMeta, mergeEntry, } from '../merge/entry-merge.js';
6
+ import { exactToken } from '../merge/value-equality.js';
7
+ import { LOCATION_CONFLICT_KEY, } from '../merge-conflicts.js';
8
+ import { normalizeFilesystemPath } from '../paths/normalize.js';
9
+ import { serializeFrontmatter, serializeYaml } from '../utils/content-serialize.js';
10
+ import { getErrorMessage } from '../utils/error.js';
11
+ import { escapeForLog } from '../utils/escape-text.js';
12
+ import { isPlainRecord } from '../validation/field-traversal.js';
13
+ import { serializeJson } from '../utils/json-source-splice.js';
14
+ import { parseTypedFilename } from '../utils/typed-filename.js';
15
+ import { runGit } from './git-process.js';
16
+ import { workerLogWarn } from './log.js';
17
+ import { sharedRepoProcessOptions } from './shared-repo-git.js';
18
+ /** The size cap "Show changes" reads with: a bigger version is not merged field by field. */
19
+ const MAX_MERGE_BYTES = 1024 * 1024;
20
+ const REGULAR_FILE = /^100(644|755)$/;
21
+ const FORMATS = ['md', 'mdx', 'json', 'yaml'];
22
+ const formatOf = (file) => {
23
+ const ext = path.posix.extname(file);
24
+ return FORMATS.find((f) => (f === 'yaml' ? ext === '.yaml' : ext === `.${f}`));
25
+ };
26
+ /** The unit a path belongs to, by its key, or null for a path the engine never decides. */
27
+ function unitKeyOf(file, root, collectionIdOf) {
28
+ if (entryLogicalPathOfFile(file, root) !== null) {
29
+ const parsed = parseTypedFilename(path.posix.basename(file));
30
+ return parsed ? { key: `id:${parsed.id}`, id: parsed.id } : null;
31
+ }
32
+ const below = file.startsWith(`${root}/`) ? file.slice(root.length + 1).split('/') : null;
33
+ if (!below || below.at(-1) !== '.collection.json')
34
+ return null;
35
+ if (below.slice(0, -1).some((s) => s.startsWith('.') || s === '_ids_'))
36
+ return null;
37
+ const collection = collectionIdOf(file);
38
+ return { key: collection ? `meta:${collection}` : `meta-at:${file}` };
39
+ }
40
+ /** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
41
+ async function changedFiles(git, from, to) {
42
+ const out = await git.raw([
43
+ 'diff-tree',
44
+ '-r',
45
+ '-z',
46
+ '--no-renames',
47
+ '--raw',
48
+ '--end-of-options',
49
+ from,
50
+ to,
51
+ ]);
52
+ const fields = out.split('\0');
53
+ const changes = [];
54
+ for (let i = 0; i + 1 < fields.length; i += 2) {
55
+ const match = /^:(\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])\d*$/.exec(fields[i]);
56
+ if (!match)
57
+ throw new Error(`Unreadable git diff-tree entry: ${JSON.stringify(fields[i])}`);
58
+ const [, beforeMode, afterMode, beforeOid, afterOid, status] = match;
59
+ const file = fields[i + 1];
60
+ changes.push({
61
+ path: file,
62
+ ...(status !== 'A' && { before: { path: file, oid: beforeOid, mode: beforeMode } }),
63
+ ...(status !== 'D' && { after: { path: file, oid: afterOid, mode: afterMode } }),
64
+ });
65
+ }
66
+ return changes;
67
+ }
68
+ /**
69
+ * Where each collection directory a merge touches ends up, by its collection ID. A directory's
70
+ * parent and its own name are settled apart, each as a field is: the branch's change, else the
71
+ * base's. So a parent one side renamed and a child the other side renamed both come through, and
72
+ * only both sides changing the same one differently is a move apart. A directory one version
73
+ * holds at two places is left where each file has it.
74
+ */
75
+ class CollectionMoves {
76
+ constructor() {
77
+ this.at = {
78
+ ancestor: new Map(),
79
+ base: new Map(),
80
+ branch: new Map(),
81
+ };
82
+ /** Collection directories each side moved into the other: no tree holds both moves. */
83
+ this.cyclic = false;
84
+ }
85
+ note(version, file) {
86
+ const segments = file.split('/');
87
+ let parent = '';
88
+ for (let k = 0; k < segments.length - 1; k++) {
89
+ const id = extractIdFromFilename(segments[k]);
90
+ if (!id) {
91
+ parent = parent.startsWith('id:')
92
+ ? `${parent}/${segments[k]}`
93
+ : segments.slice(0, k + 1).join('/');
94
+ continue;
95
+ }
96
+ const here = { parent, name: segments[k] };
97
+ const known = this.at[version].get(id);
98
+ const same = known && known.parent === here.parent && known.name === here.name;
99
+ this.at[version].set(id, known === undefined || same ? here : null);
100
+ parent = `id:${id}`;
101
+ }
102
+ }
103
+ /** Each side's place for `id`'s directory, when every version that has one has just one. */
104
+ versions(id) {
105
+ const [o, a, b] = ['ancestor', 'base', 'branch'].map((v) => this.at[v].get(id));
106
+ if (o === null || a === null || b === null)
107
+ return undefined;
108
+ return { o, a, b };
109
+ }
110
+ settle(id) {
111
+ const v = this.versions(id);
112
+ if (!v)
113
+ return undefined;
114
+ const pick = (part) => {
115
+ const [o, a, b] = [v.o?.[part], v.a?.[part], v.b?.[part]];
116
+ if (b !== undefined && b !== o)
117
+ return b;
118
+ if (a !== undefined && a !== o)
119
+ return a;
120
+ return o ?? b ?? a;
121
+ };
122
+ const [parent, name] = [pick('parent'), pick('name')];
123
+ return parent !== undefined && name !== undefined ? { parent, name } : undefined;
124
+ }
125
+ /** The path `id`'s directory ends up at, or undefined where that can't be told. */
126
+ final(id, seen = new Set()) {
127
+ if (seen.has(id)) {
128
+ this.cyclic = true;
129
+ return undefined;
130
+ }
131
+ const dir = this.settle(id);
132
+ if (!dir)
133
+ return undefined;
134
+ if (!dir.parent.startsWith('id:'))
135
+ return dir.parent ? `${dir.parent}/${dir.name}` : dir.name;
136
+ const [parentId, ...below] = dir.parent.slice(3).split('/');
137
+ const parent = this.final(parentId, new Set([...seen, id]));
138
+ return parent === undefined ? undefined : [parent, ...below, dir.name].join('/');
139
+ }
140
+ /** Whether both sides moved `id`'s directory, changing its parent or its name differently. */
141
+ movedApart(id) {
142
+ const v = this.versions(id);
143
+ if (!v?.o || !v.a || !v.b)
144
+ return false;
145
+ const { o, a, b } = v;
146
+ return ['parent', 'name'].some((part) => a[part] !== o[part] && b[part] !== o[part] && a[part] !== b[part]);
147
+ }
148
+ /** `file` in the collection directories it is in, each where it ends up. */
149
+ place(file) {
150
+ const segments = file.split('/');
151
+ for (let k = segments.length - 2; k >= 0; k--) {
152
+ const id = extractIdFromFilename(segments[k]);
153
+ const dir = id ? this.final(id) : undefined;
154
+ if (dir)
155
+ return [dir, ...segments.slice(k + 1)].join('/');
156
+ }
157
+ return file;
158
+ }
159
+ }
160
+ /** The units both sides' changes touch, keyed as {@link unitKeyOf} keys them. */
161
+ async function collectUnits(args, root, moves) {
162
+ const { git, ancestor, head, other } = args;
163
+ const units = new Map();
164
+ for (const [side, to] of [
165
+ ['base', other],
166
+ ['branch', head],
167
+ ]) {
168
+ for (const change of await changedFiles(git, ancestor, to)) {
169
+ const { before, after } = change;
170
+ if (before)
171
+ moves.note('ancestor', change.path);
172
+ if (after)
173
+ moves.note(side, change.path);
174
+ const at = unitKeyOf(change.path, root, args.collectionIdOf);
175
+ if (!at)
176
+ continue;
177
+ let unit = units.get(at.key);
178
+ if (!unit) {
179
+ unit = {
180
+ kind: at.key.startsWith('id:') ? 'entry' : 'meta',
181
+ ...(at.id && { id: at.id }),
182
+ changed: {},
183
+ touched: { base: false, branch: false },
184
+ removed: { base: new Set(), branch: new Set() },
185
+ ambiguous: false,
186
+ };
187
+ units.set(at.key, unit);
188
+ }
189
+ if (before) {
190
+ if (unit.ancestor && unit.ancestor.path !== before.path)
191
+ unit.ambiguous = true;
192
+ unit.ancestor = before;
193
+ if (!after)
194
+ unit.removed[side].add(change.path);
195
+ }
196
+ if (after) {
197
+ if (unit.changed[side])
198
+ unit.ambiguous = true;
199
+ unit.changed[side] = after;
200
+ }
201
+ unit.touched[side] = true;
202
+ for (const file of [before, after]) {
203
+ if (file && !REGULAR_FILE.test(file.mode))
204
+ unit.ambiguous = true;
205
+ }
206
+ if (change.path.includes('\uFFFD'))
207
+ unit.ambiguous = true;
208
+ }
209
+ }
210
+ // A side with a file for the unit at a new path, which kept the ancestor's, copied it.
211
+ for (const unit of units.values()) {
212
+ for (const side of ['base', 'branch']) {
213
+ const file = unit.changed[side];
214
+ const kept = unit.ancestor && !unit.removed[side].has(unit.ancestor.path);
215
+ if (file && unit.ancestor && file.path !== unit.ancestor.path && kept)
216
+ unit.ambiguous = true;
217
+ }
218
+ }
219
+ return units;
220
+ }
221
+ /** One side's file, as it stands after that side's changes. */
222
+ const sideFile = (unit, side) => unit.touched[side] ? unit.changed[side] : unit.ancestor;
223
+ /** What git's merge holds for each unit: path to file. */
224
+ async function gitsFiles(args, root, units) {
225
+ const held = new Map();
226
+ for (const [key, unit] of units) {
227
+ held.set(key, new Map(unit.ancestor ? [[unit.ancestor.path, unit.ancestor]] : []));
228
+ }
229
+ for (const change of await changedFiles(args.git, args.ancestor, args.mergedTree)) {
230
+ const at = unitKeyOf(change.path, root, args.collectionIdOf);
231
+ const files = at && held.get(at.key);
232
+ if (!files)
233
+ continue;
234
+ if (change.before)
235
+ files.delete(change.path);
236
+ if (change.after)
237
+ files.set(change.path, change.after);
238
+ }
239
+ return held;
240
+ }
241
+ /** A blob's text, or undefined when it is too large or not UTF-8. */
242
+ async function readText(git, oid) {
243
+ const size = parseInt((await git.raw(['cat-file', '-s', '--end-of-options', oid])).trim(), 10);
244
+ if (!Number.isInteger(size) || size > MAX_MERGE_BYTES)
245
+ return undefined;
246
+ const text = await git.raw(['cat-file', 'blob', '--end-of-options', oid]);
247
+ return Buffer.byteLength(text, 'utf8') === size && !text.includes('\uFFFD') ? text : undefined;
248
+ }
249
+ /**
250
+ * Whether `text` holds a record at its top level, as an entry's data must: the store reads any
251
+ * other JSON or YAML as no data at all, which would make every version alike.
252
+ */
253
+ function holdsRecord(text, format) {
254
+ if (format === 'json')
255
+ return isPlainRecord(JSON.parse(text));
256
+ if (format !== 'yaml')
257
+ return true;
258
+ const value = yamlParse(text);
259
+ return value === null || value === undefined || isPlainRecord(value);
260
+ }
261
+ /** One version of an entry, parsed as `ContentStore.read` parses it, or undefined if it can't be. */
262
+ async function readEntry(git, file, format) {
263
+ const text = await readText(git, file.oid);
264
+ if (text === undefined)
265
+ return undefined;
266
+ try {
267
+ if (!holdsRecord(text, format))
268
+ return undefined;
269
+ const { data, body } = parseEntryParts(text, format);
270
+ // A copy: gray-matter's cache hands every reader of the same text one object.
271
+ const side = { data: structuredClone(data) };
272
+ if (body !== undefined)
273
+ side.body = body;
274
+ const type = parseTypedFilename(path.posix.basename(file.path))?.type;
275
+ if (type !== undefined)
276
+ side.entryType = type;
277
+ return { file, text, side };
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ /** The branch's fields for the entry type a file stores, when the schema snapshot has them. */
284
+ function fieldsFor(flatSchema, file, root) {
285
+ const logical = entryLogicalPathOfFile(file, root);
286
+ const type = parseTypedFilename(path.posix.basename(file))?.type;
287
+ if (!flatSchema || logical === null || type === undefined)
288
+ return undefined;
289
+ const collection = logical.slice(0, logical.lastIndexOf('/'));
290
+ const item = flatSchema.find((i) => i.type === 'entry-type' && i.parentPath === collection && i.name === type);
291
+ return item?.type === 'entry-type' ? item.schema : undefined;
292
+ }
293
+ const sameContent = (x, y) => exactToken(x.data) === exactToken(y.data) && x.body === y.body;
294
+ /** Text for merged entry data, written onto `onto` (the base's file) to keep its formatting. */
295
+ function serializeEntry(format, merged, onto) {
296
+ if (format === 'json')
297
+ return serializeJson(merged.data, onto);
298
+ if (format === 'yaml')
299
+ return serializeYaml(merged.data, onto);
300
+ return serializeFrontmatter(merged.body ?? '', merged.data, onto, format);
301
+ }
302
+ const blobSides = (unit) => {
303
+ const [ancestor, base, branch] = [unit.ancestor, sideFile(unit, 'base'), sideFile(unit, 'branch')];
304
+ return {
305
+ ...(ancestor && { ancestor: ancestor.oid }),
306
+ ...(base && { base: base.oid }),
307
+ ...(branch && { branch: branch.oid }),
308
+ };
309
+ };
310
+ const recordFields = (conflicts) => conflicts.map(({ key, dataPath, sidePaths, kind, item, regions, unparseable }) => ({
311
+ key,
312
+ dataPath,
313
+ sidePaths,
314
+ kind,
315
+ ...(item && { item }),
316
+ ...((regions !== undefined || unparseable) && { text: true }),
317
+ }));
318
+ /** The entry's place, which no version holds a value for. */
319
+ const LOCATION = {
320
+ key: LOCATION_CONFLICT_KEY,
321
+ dataPath: [],
322
+ sidePaths: {},
323
+ kind: 'changed',
324
+ };
325
+ class Resolver {
326
+ constructor(args, moves) {
327
+ this.args = args;
328
+ this.moves = moves;
329
+ this.root = normalizeFilesystemPath(args.contentRoot);
330
+ this.options = {
331
+ ...sharedRepoProcessOptions(args.clonePath, 'worktree'),
332
+ inactivityTimeoutMs: args.timeoutMs,
333
+ };
334
+ }
335
+ async write(text, like, at) {
336
+ // `--no-filters`: no clean filter from the clone's attributes runs.
337
+ const oid = (await runGit(['hash-object', '-w', '--no-filters', '--stdin'], {
338
+ ...this.options,
339
+ stdin: text,
340
+ })).trim();
341
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(oid))
342
+ throw new Error('git hash-object printed no id');
343
+ return { path: at, oid, mode: like.mode };
344
+ }
345
+ /**
346
+ * The file for a merged value at `at`, keeping the most of what each side wrote that the value
347
+ * does not carry, such as a developer's comments and formatting: git's clean merge, else the
348
+ * base's file, else the value written onto the base's file, else the branch's file, else the
349
+ * value written plain. A text must read back as the value. Undefined when none does.
350
+ */
351
+ async bytesFor(c, at) {
352
+ const mode = c.branch.mode;
353
+ if (c.git?.path === at) {
354
+ const text = await readText(this.args.git, c.git.oid);
355
+ if (text !== undefined && c.reads(text))
356
+ return c.git;
357
+ }
358
+ if (c.isBase)
359
+ return { ...c.base.file, path: at, mode };
360
+ const serialized = c.serialize(c.base.text);
361
+ // The serializer writes some lines with `\n` alone: a base written with `\r\n` keeps them.
362
+ const crlf = /\r\n/.test(c.base.text) && !/(^|[^\r])\n/.test(c.base.text);
363
+ for (const onBase of crlf ? [serialized.replace(/\r?\n/g, '\r\n'), serialized] : [serialized]) {
364
+ if (c.reads(onBase))
365
+ return this.write(onBase, c.branch, at);
366
+ }
367
+ if (c.isBranch)
368
+ return { ...c.branch, path: at };
369
+ const plain = c.serialize(undefined);
370
+ return c.reads(plain) ? this.write(plain, c.branch, at) : undefined;
371
+ }
372
+ /**
373
+ * Where both sides' versions of a unit go: each side's path with every collection directory
374
+ * either side moved moved too. Where only one side moved the file, there; else the branch's
375
+ * place. Both moving it apart, or both adding it apart, is a location conflict.
376
+ */
377
+ placeOf(o, a, b) {
378
+ const [oAt, aAt, bAt] = [
379
+ o && this.moves.place(o.path),
380
+ this.moves.place(a.path),
381
+ this.moves.place(b.path),
382
+ ];
383
+ const moved = {
384
+ base: oAt !== undefined && aAt !== oAt,
385
+ branch: oAt !== undefined && bAt !== oAt,
386
+ };
387
+ return {
388
+ at: moved.base && !moved.branch ? aAt : bAt,
389
+ apart: aAt !== bAt && (oAt === undefined || (moved.base && moved.branch)),
390
+ };
391
+ }
392
+ /**
393
+ * An entry both sides changed. A version that can't be read or doesn't match the others' format
394
+ * leaves git's merge where git merged it, and the branch's file where git could not. An entry
395
+ * type changed on one side, a merge that throws, or merged text that doesn't read back keeps the
396
+ * branch's file, an entry-level conflict, whatever git made of it.
397
+ */
398
+ async entry(unit, gitClean, gitFile) {
399
+ const { git } = this.args;
400
+ const [o, a, b] = [unit.ancestor, sideFile(unit, 'base'), sideFile(unit, 'branch')];
401
+ const sides = blobSides(unit);
402
+ if (!a && !b)
403
+ return {};
404
+ if (!a && b) {
405
+ const at = this.moves.place(b.path);
406
+ return { file: { ...b, path: at }, conflict: { path: at, kind: 'deleted-on-base', sides } };
407
+ }
408
+ if (a && !b) {
409
+ return { conflict: { path: this.moves.place(a.path), kind: 'deleted-on-branch', sides } };
410
+ }
411
+ if (!a || !b)
412
+ return null;
413
+ const { at, apart } = this.placeOf(o, a, b);
414
+ // The branch's whole file is the branch's version of the entry, so it keeps the branch's name.
415
+ const keepBranch = () => {
416
+ const own = this.moves.place(b.path);
417
+ return { file: { ...b, path: own }, conflict: { path: own, kind: 'unreadable', sides } };
418
+ };
419
+ if (a.oid === b.oid && !apart)
420
+ return { file: { ...b, path: at } };
421
+ const format = formatOf(b.path);
422
+ if (!format || [o, a].some((f) => f && formatOf(f.path) !== format)) {
423
+ return gitClean ? null : keepBranch();
424
+ }
425
+ const [oSide, aSide, bSide] = await Promise.all([o, a, b].map((f) => (f ? readEntry(git, f, format) : undefined)));
426
+ if (!aSide || !bSide || (o && !oSide))
427
+ return gitClean ? null : keepBranch();
428
+ const fields = fieldsFor(this.args.flatSchema, b.path, this.root);
429
+ let result;
430
+ try {
431
+ result = mergeEntry({
432
+ format,
433
+ ancestor: oSide?.side ?? { data: {} },
434
+ base: aSide.side,
435
+ branch: bSide.side,
436
+ ...(fields && { fields }),
437
+ });
438
+ }
439
+ catch (err) {
440
+ workerLogWarn(` could not merge ${escapeForLog(b.path)} field by field: ${getErrorMessage(err)}`);
441
+ return keepBranch();
442
+ }
443
+ if (result.kind === 'entry-conflict')
444
+ return keepBranch();
445
+ const found = [...recordFields(result.conflicts), ...(apart ? [LOCATION] : [])];
446
+ const merged = result;
447
+ const file = await this.bytesFor({
448
+ ...(gitClean && gitFile && { git: gitFile }),
449
+ base: { file: a, text: aSide.text },
450
+ branch: b,
451
+ isBase: sameContent(aSide.side, merged),
452
+ isBranch: sameContent(bSide.side, merged),
453
+ reads: (text) => {
454
+ try {
455
+ if (!holdsRecord(text, format))
456
+ return false;
457
+ const back = parseEntryParts(text, format);
458
+ return sameContent({ data: back.data, ...(back.body !== undefined && { body: back.body }) }, merged);
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ },
464
+ serialize: (onto) => serializeEntry(format, merged, onto),
465
+ }, at);
466
+ if (!file) {
467
+ workerLogWarn(` the merge of ${escapeForLog(b.path)} does not read back as merged`);
468
+ return keepBranch();
469
+ }
470
+ return {
471
+ file,
472
+ ...(found.length > 0 && { conflict: { path: at, kind: 'fields', sides, fields: found } }),
473
+ ...(merged.keyRemaps.length > 0 && { remaps: merged.keyRemaps }),
474
+ };
475
+ }
476
+ /** A `.collection.json` both sides changed and both still have; else git's rule. */
477
+ async collectionMeta(unit, gitClean, gitFile) {
478
+ const { git } = this.args;
479
+ const [o, a, b] = [unit.ancestor, sideFile(unit, 'base'), sideFile(unit, 'branch')];
480
+ const sides = blobSides(unit);
481
+ // Deleted on one side and changed on the other, as for an entry: the branch's side stands.
482
+ if (o && (!a || !b)) {
483
+ if (!a && !b)
484
+ return {};
485
+ const contentId = this.args.collectionIdOf((b ?? a ?? o).path);
486
+ if (!contentId)
487
+ return null;
488
+ if (b) {
489
+ const at = this.moves.place(b.path);
490
+ return { file: { ...b, path: at }, conflict: { path: at, kind: 'deleted-on-base', sides } };
491
+ }
492
+ return {
493
+ conflict: { path: this.moves.place((a ?? o).path), kind: 'deleted-on-branch', sides },
494
+ };
495
+ }
496
+ if (!o || !a || !b)
497
+ return null;
498
+ const parse = async (file) => {
499
+ const text = await readText(git, file.oid);
500
+ if (text === undefined)
501
+ return undefined;
502
+ try {
503
+ const json = JSON.parse(text);
504
+ return isPlainRecord(json) ? { text, json } : undefined;
505
+ }
506
+ catch {
507
+ return undefined;
508
+ }
509
+ };
510
+ const [oJson, aJson, bJson] = await Promise.all([o, a, b].map(parse));
511
+ if (!oJson || !aJson || !bJson)
512
+ return null;
513
+ const merged = mergeCollectionMeta(oJson.json, aJson.json, bJson.json);
514
+ // The directory moved apart on both sides: it goes where the branch put it.
515
+ const dirId = extractIdFromFilename(path.posix.basename(path.posix.dirname(b.path)));
516
+ const apart = dirId !== null && this.moves.movedApart(dirId);
517
+ const at = this.moves.place(b.path);
518
+ const fields = [...recordFields(merged.conflicts), ...(apart ? [LOCATION] : [])];
519
+ const contentId = this.args.collectionIdOf(at);
520
+ if (fields.length > 0 && !contentId) {
521
+ workerLogWarn(` ${escapeForLog(at)} keeps this branch's values where both changed them`);
522
+ }
523
+ const token = exactToken(merged.data);
524
+ const file = await this.bytesFor({
525
+ ...(gitClean && gitFile && { git: gitFile }),
526
+ base: { file: a, text: aJson.text },
527
+ branch: b,
528
+ isBase: exactToken(aJson.json) === token,
529
+ isBranch: exactToken(bJson.json) === token,
530
+ reads: (text) => {
531
+ try {
532
+ return exactToken(JSON.parse(text)) === token;
533
+ }
534
+ catch {
535
+ return false;
536
+ }
537
+ },
538
+ serialize: (onto) => serializeJson(merged.data, onto),
539
+ }, at);
540
+ if (!file)
541
+ return null;
542
+ return {
543
+ file,
544
+ ...(fields.length > 0 &&
545
+ contentId && { conflict: { path: at, kind: 'fields', sides, fields } }),
546
+ };
547
+ }
548
+ }
549
+ /** Two sets of a unit's files hold the same thing. */
550
+ function sameFiles(held, wanted) {
551
+ if (!wanted)
552
+ return held.size === 0;
553
+ const at = held.get(wanted.path);
554
+ return held.size === 1 && at?.oid === wanted.oid && at.mode === wanted.mode;
555
+ }
556
+ /**
557
+ * Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
558
+ * result says which blobs replace git's, and which conflicts a person must resolve.
559
+ */
560
+ export async function resolveFieldMerge(args) {
561
+ const moves = new CollectionMoves();
562
+ const resolver = new Resolver(args, moves);
563
+ const root = normalizeFilesystemPath(args.contentRoot);
564
+ const units = await collectUnits(args, root, moves);
565
+ const held = await gitsFiles(args, root, units);
566
+ const result = {
567
+ updates: new Map(),
568
+ covered: new Set(),
569
+ conflicts: [],
570
+ keyRemaps: [],
571
+ paths: new Map(),
572
+ };
573
+ for (const [key, unit] of units) {
574
+ if (unit.ambiguous)
575
+ continue;
576
+ const gitHeld = held.get(key) ?? new Map();
577
+ const paths = new Set([
578
+ ...[unit.ancestor, sideFile(unit, 'base'), sideFile(unit, 'branch')].flatMap((f) => f ? [f.path] : []),
579
+ ...gitHeld.keys(),
580
+ ]);
581
+ const gitClean = ![...paths].some((p) => args.conflicted.has(p));
582
+ const only = gitHeld.size === 1 ? [...gitHeld.values()][0] : undefined;
583
+ let resolution;
584
+ if (unit.touched.base && unit.touched.branch) {
585
+ resolution =
586
+ unit.kind === 'entry'
587
+ ? await resolver.entry(unit, gitClean, only)
588
+ : await resolver.collectionMeta(unit, gitClean, only);
589
+ if (resolution === null)
590
+ continue;
591
+ }
592
+ else {
593
+ const file = sideFile(unit, unit.touched.base ? 'base' : 'branch');
594
+ // The one side's file, where git put it if git kept it whole, else where the collection
595
+ // directories it is in ended up.
596
+ const kept = file && only?.oid === file.oid && only.mode === file.mode;
597
+ const from = kept ? only : file;
598
+ resolution = { file: from && { ...from, path: moves.place(from.path) } };
599
+ }
600
+ for (const p of paths)
601
+ result.covered.add(p);
602
+ if (!sameFiles(gitHeld, resolution.file)) {
603
+ for (const p of gitHeld.keys())
604
+ result.updates.set(p, null);
605
+ for (const p of paths) {
606
+ if (args.conflicted.has(p))
607
+ result.updates.set(p, null);
608
+ }
609
+ if (resolution.file)
610
+ result.updates.set(resolution.file.path, resolution.file);
611
+ }
612
+ const recordId = unit.id ?? (resolution.file && args.collectionIdOf(resolution.file.path));
613
+ if (recordId && resolution.file)
614
+ result.paths.set(recordId, resolution.file.path);
615
+ if (unit.id && resolution.conflict) {
616
+ result.conflicts.push({ contentId: unit.id, ...resolution.conflict });
617
+ }
618
+ else if (unit.kind === 'meta' && resolution.conflict) {
619
+ const contentId = args.collectionIdOf(resolution.conflict.path);
620
+ if (contentId)
621
+ result.conflicts.push({ contentId, ...resolution.conflict });
622
+ }
623
+ const branchFile = sideFile(unit, 'branch');
624
+ const entryPath = branchFile && entryLogicalPathOfFile(branchFile.path, root);
625
+ if (resolution.remaps && entryPath) {
626
+ result.keyRemaps.push({
627
+ entryPath,
628
+ ...(unit.id && { contentId: unit.id }),
629
+ remaps: resolution.remaps,
630
+ });
631
+ }
632
+ }
633
+ if (moves.cyclic)
634
+ result.unsettled = 'collection directories moved into each other';
635
+ return result;
636
+ }
@@ -32,7 +32,7 @@ import type { WorkerContext } from './worker-context.js';
32
32
  * The whole cycle is wrapped so both outcomes record a worker-status.json
33
33
  * snapshot, since an operator's only view of this loop is the admin panel.
34
34
  */
35
- export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
35
+ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'takeBranchSyncRequests' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
36
36
  /**
37
37
  * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
38
38
  * remote.git to GitHub. Non-fatal. Skipped when `trackedTips`, GitHub's tips
@@ -418,6 +418,9 @@ function warnOnce(warned, lines) {
418
418
  export async function syncGit(ctx) {
419
419
  if (!ctx.isRunning())
420
420
  return;
421
+ // Taken first, so a cycle that fails before its fetch can't leave the loop waking early for good.
422
+ // A request made later in the cycle waits for the next one, whose fetch begins after it.
423
+ const requested = ctx.takeBranchSyncRequests();
421
424
  // The CDK worker-down alarm counts this line (WORKER_SYNC_LOG_PHRASE in
422
425
  // canopycms-cdk worker-lifecycle.ts); changing its text breaks the alarm.
423
426
  workerLog('Syncing git...');
@@ -468,8 +471,9 @@ export async function syncGit(ctx) {
468
471
  // deadline: the wait for the gateway's GitHub fetch, which runs on detached, then the gateway's
469
472
  // bundle, the unbundle and the ref update; a killed ref update leaves each ref as it was or
470
473
  // fully moved.
471
- const fetchedAt = Date.now();
472
- const { refs: githubTips, needsOperator } = await fetchGitHubIntoRemoteGit(ctx, ctx.shutdownSignal());
474
+ // A woken cycle takes no GitHub fetch the gateway began earlier: answers to a request for the
475
+ // latest changes are timed by this fetch's start (worker/rebase.ts, answerBaseUpdate).
476
+ const { refs: githubTips, fetchedAt, needsOperator, } = await fetchGitHubIntoRemoteGit(ctx, ctx.shutdownSignal(), requested.requested);
473
477
  workerLog('Fetched from GitHub');
474
478
  if (needsOperator !== null)
475
479
  workerLogError(needsOperator);
@@ -512,10 +516,7 @@ export async function syncGit(ctx) {
512
516
  report.gatewayProtectedBranches = branchPolicy.configured.filter((n) => !n.includes('*'));
513
517
  await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
514
518
  }
515
- const rebaseSummary = await runRebaseCycle(ctx, branchPolicy, {
516
- tips: githubTips,
517
- fetchedAt,
518
- });
519
+ const rebaseSummary = await runRebaseCycle(ctx, branchPolicy, { tips: githubTips, fetchedAt }, requested.branches);
519
520
  if (stoppedForDrain(ctx, 'the cleanup sweeps'))
520
521
  return;
521
522
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);