canopycms 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -33,6 +33,13 @@ class StaleStatusError extends Error {
33
33
  this.name = 'StaleStatusError';
34
34
  }
35
35
  }
36
+ /** The API's `BRANCH_MOVED`: the branch's head isn't the one the reviewer's page was pinned to. */
37
+ class BranchMovedError extends Error {
38
+ constructor() {
39
+ super(...arguments);
40
+ this.name = 'BranchMovedError';
41
+ }
42
+ }
36
43
  /** An answer that refused the action, with its HTTP status: a 4xx is a refusal no retry passes. */
37
44
  class RefusedError extends Error {
38
45
  constructor(message, status) {
@@ -51,6 +58,8 @@ function actionError(result, fallback, staleIsConflict = false) {
51
58
  const message = result.error || fallback;
52
59
  if (result.ok)
53
60
  return new Error(message);
61
+ if (result.code === 'BRANCH_MOVED')
62
+ return new BranchMovedError(message);
54
63
  // Open conflicts refuse the action where the branch still is, so they are no stale status.
55
64
  return staleIsConflict &&
56
65
  result.status === 409 &&
@@ -478,6 +487,52 @@ export function useBranchManager(options) {
478
487
  // these at confirm time rather than from that render's closure.
479
488
  const latest = useRef({ branchName, resolved, branches, branchesData });
480
489
  latest.current = { branchName, resolved, branches, branchesData };
490
+ // The head a review sends, never newer than what the page shows of the branch. One not shown
491
+ // follows each listing; opening one, a head listed for the open one while its pin is null, and a
492
+ // refusal's recovery pin it from a listing already fetched once its content is read again.
493
+ const reviewPins = useRef(new Map());
494
+ const shown = useRef(new Set());
495
+ const pinning = useRef(new Map());
496
+ const lastOpen = useRef(undefined);
497
+ const pinThenReread = async (branch, data) => {
498
+ const headSha = data?.branches.find((b) => b.name === branch)?.headSha;
499
+ const claim = Symbol(branch);
500
+ pinning.current.set(branch, claim);
501
+ reviewPins.current.set(branch, null);
502
+ try {
503
+ if (headSha)
504
+ await options.onReviewPinned?.(branch);
505
+ // A failed read leaves it null, for the next listing to try again.
506
+ if (pinning.current.get(branch) === claim)
507
+ reviewPins.current.set(branch, headSha ?? null);
508
+ }
509
+ finally {
510
+ if (pinning.current.get(branch) === claim)
511
+ pinning.current.delete(branch);
512
+ }
513
+ };
514
+ useEffect(() => {
515
+ const listed = branchesData?.branches ?? [];
516
+ for (const b of listed) {
517
+ if (!shown.current.has(b.name))
518
+ reviewPins.current.set(b.name, b.headSha ?? null);
519
+ }
520
+ const open = currentBranchName && listed.find((b) => b.name === currentBranchName);
521
+ if (!open)
522
+ return;
523
+ const firstHead = open.headSha && reviewPins.current.get(open.name) === null && !pinning.current.has(open.name);
524
+ // Coming back to a branch opens it again: its entries are read afresh, so its pin is too.
525
+ const reopened = lastOpen.current !== open.name;
526
+ lastOpen.current = open.name;
527
+ if (!shown.current.has(open.name) || firstHead || reopened) {
528
+ shown.current.add(open.name);
529
+ pinThenReread(open.name, branchesData).catch(console.error);
530
+ }
531
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `pinThenReread` reads only refs and options
532
+ }, [branchesData, currentBranchName]);
533
+ const reviewedHead = (branch) => ({
534
+ headSha: reviewPins.current.get(branch) ?? undefined,
535
+ });
481
536
  // A card, deep link or URL can carry a short name, an exact form or the raw form of a listed
482
537
  // name; the actions resolve it once, the way an arriving name resolves, then key, look up and
483
538
  // send the listed name.
@@ -588,6 +643,11 @@ export function useBranchManager(options) {
588
643
  }
589
644
  };
590
645
  const reviewFailureId = (kind, branch) => `canopy-${kind}-failed-${branch}`;
646
+ /** A verdict that went through answers either kind's refusal. */
647
+ const hideReviewFailures = (branch) => {
648
+ notifications.hide(reviewFailureId('approve', branch));
649
+ notifications.hide(reviewFailureId('request-changes', branch));
650
+ };
591
651
  const submitRefusedId = (branch) => `canopy-submit-refused-${branch}`;
592
652
  const deleteRefusedId = (branch) => `canopy-delete-refused-${branch}`;
593
653
  /**
@@ -597,6 +657,22 @@ export function useBranchManager(options) {
597
657
  * submit didn't finish (see `showLeftReview`, which shows the server's reason either way).
598
658
  */
599
659
  const reportReviewFailure = async (err, kind, branch, details) => {
660
+ if (err instanceof BranchMovedError) {
661
+ // A listing that fails, or leaves the branch out, pins none for now.
662
+ void pinThenReread(branch, await reloadBranches()).catch(console.error);
663
+ const id = reviewFailureId(kind, branch);
664
+ notifications.hide(id);
665
+ notifications.show({
666
+ id,
667
+ message: details.note
668
+ ? `${err.message} Your note is kept for when you request changes again.`
669
+ : err.message,
670
+ color: 'yellow',
671
+ autoClose: false,
672
+ withCloseButton: true,
673
+ });
674
+ return;
675
+ }
600
676
  if (err instanceof StaleStatusError) {
601
677
  showLeftReview(branch, err.message, details.note);
602
678
  // What the branch is now decides whether a Request changes note is still wanted.
@@ -808,12 +884,10 @@ export function useBranchManager(options) {
808
884
  showApproveConfirmation(shownName(branchNameToApprove), pullRequestOf(branchNameToApprove), async () => {
809
885
  options.setBusy(true);
810
886
  try {
811
- const result = await apiClient.workflow.approve({
812
- branch: sendName(branchNameToApprove),
813
- });
887
+ const result = await apiClient.workflow.approve({ branch: sendName(branchNameToApprove) }, reviewedHead(branchNameToApprove));
814
888
  if (!result.ok)
815
889
  throw actionError(result, 'Failed to approve branch', true);
816
- notifications.hide(reviewFailureId('approve', branchNameToApprove));
890
+ hideReviewFailures(branchNameToApprove);
817
891
  notifications.show({ message: 'Branch approved', color: 'green' });
818
892
  updateCreatedBranch(branchNameToApprove, result.data?.branch);
819
893
  await loadBranches();
@@ -846,11 +920,11 @@ export function useBranchManager(options) {
846
920
  }, async (note) => {
847
921
  options.setBusy(true);
848
922
  try {
849
- const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined });
923
+ const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined, ...reviewedHead(branchNameForChanges) });
850
924
  if (!result.ok)
851
925
  throw actionError(result, 'Failed to request changes', true);
852
926
  noteDrafts.current.delete(branchNameForChanges);
853
- notifications.hide(reviewFailureId('request-changes', branchNameForChanges));
927
+ hideReviewFailures(branchNameForChanges);
854
928
  notifications.show({ message: 'Changes requested', color: 'blue' });
855
929
  // The note is saved apart from the status change, so it can fail on its own;
856
930
  // the warning stays up, with the note to copy, because the words are not saved.
@@ -25,7 +25,7 @@ export interface UseDraftManagerOptions {
25
25
  currentEntry: EditorEntry | undefined;
26
26
  entries: EditorEntry[];
27
27
  initialValues?: Record<string, FormValue>;
28
- loadEntry: (entry: EditorEntry) => Promise<FormValue>;
28
+ loadEntry: (entry: EditorEntry, isCurrent?: () => boolean) => Promise<FormValue>;
29
29
  /**
30
30
  * Server read that records no OCC token (useEntryManager's `readEntryValue`),
31
31
  * for checking restored drafts of unopened entries. Without it they stay
@@ -79,6 +79,13 @@ export interface UseDraftManagerReturn {
79
79
  setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
80
80
  loadedValues: Record<string, FormValue>;
81
81
  setLoadedValues: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
82
+ /**
83
+ * Drops the saved copies `keep` doesn't name, so each is read again; a read already in flight
84
+ * then lands nothing, as it can predate the head the branch is pinned to.
85
+ */
86
+ forgetLoadedValues: (keep: (contentId: string) => boolean) => void;
87
+ /** Bumped by {@link forgetLoadedValues}: a read whose generation is gone writes nothing. */
88
+ readGeneration: React.MutableRefObject<number>;
82
89
  selectedValue: FormValue | undefined;
83
90
  loadedValue: FormValue | undefined;
84
91
  effectiveValue: FormValue | undefined;
@@ -100,6 +100,11 @@ const VERIFY_WAIT_CAP_MS = 3000;
100
100
  export function useDraftManager(options) {
101
101
  const [drafts, setDrafts] = useState(() => options.initialValues ?? {});
102
102
  const [loadedValues, setLoadedValues] = useState({});
103
+ const readGeneration = useRef(0);
104
+ const forgetLoadedValues = (keep) => {
105
+ readGeneration.current += 1;
106
+ setLoadedValues((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => keep(id))));
107
+ };
103
108
  // Per-field validation errors from a blocked/rejected save (ED-H1), keyed by
104
109
  // the entry they belong to. Keying by entry id — and deriving the exposed
105
110
  // `fieldErrors` map below instead of clearing it in a separate effect after
@@ -1275,9 +1280,17 @@ export function useDraftManager(options) {
1275
1280
  if (!options.currentEntry || !currentId)
1276
1281
  return;
1277
1282
  const branch = options.branchName;
1283
+ const entry = options.currentEntry;
1278
1284
  options.setBusy(true);
1279
1285
  try {
1280
- const loaded = await options.loadEntry(options.currentEntry);
1286
+ // A read the branch's re-read overtakes is read again, as the reload was asked for.
1287
+ let loaded;
1288
+ let generation;
1289
+ do {
1290
+ generation = readGeneration.current;
1291
+ const read = generation;
1292
+ loaded = await options.loadEntry(entry, () => readGeneration.current === read);
1293
+ } while (readGeneration.current !== generation);
1281
1294
  // Content ids repeat across branches; the next branch's state is not this read's.
1282
1295
  if (latestOptionsRef.current.branchName !== branch)
1283
1296
  return;
@@ -1454,6 +1467,8 @@ export function useDraftManager(options) {
1454
1467
  setDrafts,
1455
1468
  loadedValues,
1456
1469
  setLoadedValues,
1470
+ forgetLoadedValues,
1471
+ readGeneration,
1457
1472
  selectedValue,
1458
1473
  loadedValue,
1459
1474
  effectiveValue,
@@ -72,6 +72,10 @@ export interface UseEntryManagerOptions {
72
72
  onSaved: (branch: string, contentId: string, sent: ResolvedFieldConflict[], cleared: ResolvedFieldConflict[]) => void;
73
73
  };
74
74
  }
75
+ /** `withChanges: false` leaves the branch's changes to the caller, who reads them itself. */
76
+ interface RefreshOptions {
77
+ withChanges?: boolean;
78
+ }
75
79
  export interface UseEntryManagerReturn {
76
80
  selectedPath: string;
77
81
  /** Opens an entry the user chose, adding a history entry, so Back returns to the one before. */
@@ -95,15 +99,16 @@ export interface UseEntryManagerReturn {
95
99
  entriesLoadFailed: boolean;
96
100
  navigatorOpen: boolean;
97
101
  setNavigatorOpen: (open: boolean) => void;
98
- refreshEntries: (branch?: string) => Promise<EditorEntry[]>;
102
+ refreshEntries: (branch?: string, refresh?: RefreshOptions) => Promise<EditorEntry[]>;
99
103
  /** `refreshEntries`, resolving to the collections it read too. */
100
- refreshContent: (branch?: string) => Promise<{
104
+ refreshContent: (branch?: string, refresh?: RefreshOptions) => Promise<{
101
105
  entries: EditorEntry[];
102
106
  collections: EditorCollection[];
103
107
  }>;
104
108
  handleCreateEntry: (collectionPath: LogicalPath, entryTypeName?: string) => Promise<void>;
105
109
  renameEntry: (path: string, newSlug: string) => Promise<void>;
106
- loadEntry: (entry: EditorEntry) => Promise<FormValue>;
110
+ /** `isCurrent`, asked once the read lands, says whether to record its OCC token. */
111
+ loadEntry: (entry: EditorEntry, isCurrent?: () => boolean) => Promise<FormValue>;
107
112
  /** Reads an entry's server value without recording the OCC token that `loadEntry` records. */
108
113
  readEntryValue: (entry: EditorEntry) => Promise<FormValue>;
109
114
  saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
@@ -293,14 +293,14 @@ export function useEntryManager(options) {
293
293
  }
294
294
  return result.data;
295
295
  };
296
- const loadEntry = async (entry) => {
296
+ const loadEntry = async (entry, isCurrent = () => true) => {
297
297
  // Pin the branch this request targets: if the user switches branches
298
298
  // while the read is in flight, the token must be recorded under the
299
299
  // branch that actually served it, not the current one.
300
300
  const requestBranch = options.branchName;
301
301
  const data = await readEntryPayload(entry, requestBranch);
302
302
  // Capture OCC version token for next save
303
- if (entry.contentId && typeof data?.version === 'number') {
303
+ if (entry.contentId && typeof data?.version === 'number' && isCurrent()) {
304
304
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), data.version);
305
305
  }
306
306
  return normalizeContentPayload(data);
@@ -459,10 +459,11 @@ export function useEntryManager(options) {
459
459
  // refreshSeqRef doc comment above; also warms the SWR cache for `branch`
460
460
  // so a later automatic re-fetch of the same key (e.g. switching away and
461
461
  // back) can reuse it instead of refetching.
462
- const refreshContent = async (branch = options.branchName) => {
462
+ const refreshContent = async (branch = options.branchName, { withChanges = true } = {}) => {
463
463
  if (!branch)
464
464
  return { entries: [], collections: [] };
465
- options.onContentRefresh?.(branch);
465
+ if (withChanges)
466
+ options.onContentRefresh?.(branch);
466
467
  const seq = claimRefreshSeq(branch);
467
468
  const fetched = await fetchEntriesAndSchema(apiClient, branch, {
468
469
  resolvePreviewSrc: options.resolvePreviewSrc,
@@ -482,7 +483,7 @@ export function useEntryManager(options) {
482
483
  }
483
484
  return fetched;
484
485
  };
485
- const refreshEntries = async (branch = options.branchName) => (await refreshContent(branch)).entries;
486
+ const refreshEntries = async (branch = options.branchName, refresh) => (await refreshContent(branch, refresh)).entries;
486
487
  const handleCreateEntry = async (collectionPath, _) => {
487
488
  const col = collectionByPath.get(collectionPath);
488
489
  if (!col || col.type === 'entry') {
@@ -574,6 +574,7 @@ export declare class GitManager {
574
574
  ensureRemote(remoteUrl: string): Promise<void>;
575
575
  hasUncommittedChanges(): Promise<boolean>;
576
576
  getUncommittedFiles(): Promise<string[]>;
577
+ /** The remote's URL as configured; `git remote -v`'s listing would split a path with a space. */
577
578
  getRemoteUrl(): Promise<string | undefined>;
578
579
  /**
579
580
  * Excludes `pattern` (e.g. `.canopy-meta/`) from this workspace's git, so it
@@ -1849,10 +1849,15 @@ export class GitManager {
1849
1849
  const status = await this.status();
1850
1850
  return status.files.map((f) => f.path);
1851
1851
  }
1852
+ /** The remote's URL as configured; `git remote -v`'s listing would split a path with a space. */
1852
1853
  async getRemoteUrl() {
1853
- const remotes = await this.git.getRemotes(true);
1854
- const remote = remotes.find((r) => r.name === this.remote);
1855
- return remote?.refs.push || remote?.refs.fetch;
1854
+ try {
1855
+ const url = (await this.git.raw(['remote', 'get-url', this.remote])).trim();
1856
+ return url === '' ? undefined : url;
1857
+ }
1858
+ catch {
1859
+ return undefined;
1860
+ }
1856
1861
  }
1857
1862
  /**
1858
1863
  * Excludes `pattern` (e.g. `.canopy-meta/`) from this workspace's git, so it
@@ -400,11 +400,12 @@ function mergeList(o, a, b, at, walk, namedBy) {
400
400
  }
401
401
  function identifyByKey(O, a, b, inEntry, moved) {
402
402
  const oKeys = itemKeysAsRead(O).map((key, i) => key ?? `${i}`);
403
- const aligned = alignToAncestor(a, O, oKeys, 'a', inEntry, moved.a);
404
- const bAligned = alignToAncestor(b, O, oKeys, 'b', inEntry, moved.b);
403
+ const aligned = alignToAncestor(a, O, oKeys, 'a', inEntry, moved.a, b);
404
+ const bAligned = alignToAncestor(b, O, oKeys, 'b', inEntry, moved.b, a);
405
405
  if (!aligned || !bAligned)
406
406
  return undefined;
407
- pairNewItems(a, aligned.ids, b, bAligned.ids);
407
+ const known = new Set(oKeys);
408
+ pairNewItems(a, aligned.ids, b, bAligned.ids, (key) => inEntry.has(key) && !known.has(key));
408
409
  return {
409
410
  oKeys,
410
411
  aIds: aligned.ids,
@@ -23,26 +23,29 @@ export interface Alignment {
23
23
  * side an identity of its own. `ancestorKeys` are the ancestor's items' keys as a read gives them.
24
24
  * An item with a key of its own is named by the key a read gives it: that key the first time the
25
25
  * list holds it, else a hash of its content. A name the ancestor's read gave, or another key a
26
- * read could have given an ancestor item ({@link ancestorAliases}), is that ancestor item; any
26
+ * read could have given an ancestor item ({@link ancestorAliases}) unless an item here holds that
27
+ * item's own key or the ancestor's read gave it to an item of another list, is that ancestor item; any
27
28
  * other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
28
29
  * ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
29
30
  * gives across the entry): that item moved here.
30
31
  *
31
32
  * The rest align as diff3 aligns lines: equal content in order, then equal content out of order
32
33
  * (a move), then, in each gap between two matches, by shared values and last by place
33
- * ({@link pairInGap}).
34
+ * ({@link pairInGap}). `otherSide` is the other version's list: its copy of an item counts here
35
+ * where it holds the same rewrite.
34
36
  * Undefined when the diff ran out of time.
35
37
  */
36
- export declare function alignToAncestor(items: readonly unknown[], ancestorItems: readonly unknown[], ancestorKeys: readonly string[], side: string, inEntry: ReadonlySet<string>, movedIn: ReadonlySet<string>): Alignment | undefined;
38
+ export declare function alignToAncestor(items: readonly unknown[], ancestorItems: readonly unknown[], ancestorKeys: readonly string[], side: string, inEntry: ReadonlySet<string>, movedIn: ReadonlySet<string>, otherSide?: readonly unknown[]): Alignment | undefined;
37
39
  /** True for an identity {@link alignToAncestor} gave an item new on its side. */
38
40
  export declare const isNewIdentity: (id: string) => boolean;
39
41
  /**
40
42
  * Pairs the items new on the base with the items new on the branch that are the same addition:
41
43
  * the same key, else equal content, in order. The branch's identity replaces the base's in
42
- * `baseIds` for each pair, so an item both sides added alike is one item. Pairing is skipped by
43
- * content when the diff runs out of time: the additions stay apart, and none is lost.
44
+ * `baseIds` for each pair, so an item both sides added alike is one item; one moved in from another
45
+ * list (`movedHere`) is no addition, and pairs by key only. Pairing is skipped by content when the
46
+ * diff runs out of time: the additions stay apart, and none is lost.
44
47
  */
45
- export declare function pairNewItems(baseItems: readonly unknown[], baseIds: string[], branchItems: readonly unknown[], branchIds: readonly string[]): void;
48
+ export declare function pairNewItems(baseItems: readonly unknown[], baseIds: string[], branchItems: readonly unknown[], branchIds: readonly string[], movedHere: (key: string) => boolean): void;
46
49
  /**
47
50
  * The order of the merged list's identities: one side's order, with the items only the other side
48
51
  * holds placed after the item that precedes them there. The branch's order leads unless the
@@ -65,17 +65,19 @@ const ancestorAliases = (ancestorItems, ancestorKeys) => {
65
65
  * side an identity of its own. `ancestorKeys` are the ancestor's items' keys as a read gives them.
66
66
  * An item with a key of its own is named by the key a read gives it: that key the first time the
67
67
  * list holds it, else a hash of its content. A name the ancestor's read gave, or another key a
68
- * read could have given an ancestor item ({@link ancestorAliases}), is that ancestor item; any
68
+ * read could have given an ancestor item ({@link ancestorAliases}) unless an item here holds that
69
+ * item's own key or the ancestor's read gave it to an item of another list, is that ancestor item; any
69
70
  * other key marks the item new when it was minted for it, the ancestor knows it (a copy), or the
70
71
  * ancestor's read gave it to an item of another list (`inEntry`, every key the ancestor's read
71
72
  * gives across the entry): that item moved here.
72
73
  *
73
74
  * The rest align as diff3 aligns lines: equal content in order, then equal content out of order
74
75
  * (a move), then, in each gap between two matches, by shared values and last by place
75
- * ({@link pairInGap}).
76
+ * ({@link pairInGap}). `otherSide` is the other version's list: its copy of an item counts here
77
+ * where it holds the same rewrite.
76
78
  * Undefined when the diff ran out of time.
77
79
  */
78
- export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntry, movedIn) {
80
+ export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntry, movedIn, otherSide = []) {
79
81
  const known = new Set(ancestorKeys);
80
82
  const aliases = ancestorAliases(ancestorItems, ancestorKeys);
81
83
  const readKeys = itemKeysAsRead(items);
@@ -103,32 +105,73 @@ export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntr
103
105
  unheldFresh.add(token);
104
106
  });
105
107
  const at = new Map(ancestorKeys.map((key, i) => [key, i]));
106
- const usurped = (j, own) => {
108
+ // Content a copy holds, under a key minted for it that the ancestor doesn't know.
109
+ const copies = (list) => new Set(list.flatMap((item) => {
110
+ const own = ownKey(item);
111
+ return own !== undefined && !hasContentKeyShape(own) && !known.has(own)
112
+ ? [valueToken(item)]
113
+ : [];
114
+ }));
115
+ const [copied, otherCopied] = [copies(items), copies(otherSide)];
116
+ // The other side's copy counts where it holds the same rewrite, so both read that item alike.
117
+ // A key is compared by the ancestor item it names, so an alias matches its read key.
118
+ const names = (key) => key === undefined || known.has(key) ? key : (aliases.get(key) ?? key);
119
+ const otherHolds = new Set(otherSide.map((item) => `${names(ownKey(item))}\u0000${valueToken(item)}`));
120
+ const sameRewrite = (j, own) => otherHolds.has(`${names(own)}\u0000${valueToken(items[j])}`);
121
+ // Whether the item may be the unheld item holding its content: `named` when its key names
122
+ // another, `keyed` when a copy also shows that one was rewritten, `unnamed` when it names none.
123
+ const claim = (j, own) => {
107
124
  if (!hasContentKeyShape(own))
108
- return false;
125
+ return undefined;
109
126
  const content = valueToken(items[j]);
110
127
  const holders = unheld.get(content) ?? 0;
111
128
  if (holders === 0)
112
- return false;
129
+ return undefined;
113
130
  const read = readKeys[j];
114
131
  const named = read !== undefined && known.has(read) ? read : aliases.get(own);
115
132
  const i = named === undefined ? undefined : at.get(named);
116
133
  // A key that has gone stale on both sides is history, not content: it names its item.
117
134
  if (i !== undefined) {
118
135
  if (isMinted(ancestorItems[i]) && isMinted(items[j]))
119
- return false;
136
+ return undefined;
120
137
  // A key takes only an unheld item still under its read key: one gone stale was edited.
121
138
  if (!unheldFresh.has(content))
122
- return false;
123
- return valueToken(ancestorItems[i]) !== content;
139
+ return undefined;
140
+ // With a copy of the item its key names beside it, the side rewrote that item: read by key.
141
+ const was = valueToken(ancestorItems[i]);
142
+ if (was === content)
143
+ return undefined;
144
+ return copied.has(was) || (otherCopied.has(was) && sameRewrite(j, own)) ? 'keyed' : 'named';
124
145
  }
125
146
  // A key naming no item here pairs by content only where one item holds it, twins by place.
126
- return holders === 1;
147
+ return holders === 1 ? 'unnamed' : undefined;
148
+ };
149
+ const claims = items.map((item, j) => {
150
+ const own = ownKey(item);
151
+ return own === undefined ? undefined : claim(j, own);
152
+ });
153
+ const claimants = new Map();
154
+ claims.forEach((c, j) => {
155
+ const content = valueToken(items[j]);
156
+ if (c !== undefined)
157
+ claimants.set(content, (claimants.get(content) ?? 0) + 1);
158
+ });
159
+ // More claimants than holders can't all be those items: one a key names is that item instead.
160
+ const usurped = (j) => {
161
+ const content = valueToken(items[j]);
162
+ if (claims[j] !== 'named')
163
+ return claims[j] === 'unnamed';
164
+ return (claimants.get(content) ?? 0) <= (unheld.get(content) ?? 0);
165
+ };
166
+ const literal = new Set(items.flatMap((item) => ownKey(item) ?? []).filter((k) => known.has(k)));
167
+ const aliasOf = (own) => {
168
+ const id = inEntry.has(own) ? undefined : aliases.get(own);
169
+ return id !== undefined && !literal.has(id) ? id : undefined;
127
170
  };
128
171
  items.forEach((item, j) => {
129
172
  const own = ownKey(item);
130
173
  const read = readKeys[j];
131
- if (own !== undefined && usurped(j, own)) {
174
+ if (own !== undefined && usurped(j)) {
132
175
  minted.add(j);
133
176
  byContent.add(j);
134
177
  stale.add(j);
@@ -141,7 +184,7 @@ export function alignToAncestor(items, ancestorItems, ancestorKeys, side, inEntr
141
184
  : read !== undefined && known.has(read)
142
185
  ? read
143
186
  : !known.has(own)
144
- ? aliases.get(own)
187
+ ? aliasOf(own)
145
188
  : undefined;
146
189
  if (id !== undefined && !taken.has(id)) {
147
190
  ids[j] = id;
@@ -376,10 +419,11 @@ export const isNewIdentity = (id) => id.startsWith(INTERNAL);
376
419
  /**
377
420
  * Pairs the items new on the base with the items new on the branch that are the same addition:
378
421
  * the same key, else equal content, in order. The branch's identity replaces the base's in
379
- * `baseIds` for each pair, so an item both sides added alike is one item. Pairing is skipped by
380
- * content when the diff runs out of time: the additions stay apart, and none is lost.
422
+ * `baseIds` for each pair, so an item both sides added alike is one item; one moved in from another
423
+ * list (`movedHere`) is no addition, and pairs by key only. Pairing is skipped by content when the
424
+ * diff runs out of time: the additions stay apart, and none is lost.
381
425
  */
382
- export function pairNewItems(baseItems, baseIds, branchItems, branchIds) {
426
+ export function pairNewItems(baseItems, baseIds, branchItems, branchIds, movedHere) {
383
427
  const baseNew = baseIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
384
428
  const branchNew = branchIds.flatMap((id, j) => (isNewIdentity(id) ? [j] : []));
385
429
  const pairedBase = new Set();
@@ -404,7 +448,12 @@ export function pairNewItems(baseItems, baseIds, branchItems, branchIds) {
404
448
  // Equal content pairs two additions, unless both carry minted keys: two items reading alike.
405
449
  const restBase = baseNew.filter((j) => !pairedBase.has(j));
406
450
  const restBranch = branchNew.filter((j) => !pairedBranch.has(j));
407
- const pairs = (commonPairs(restBase.map((j) => valueToken(baseItems[j])), restBranch.map((j) => valueToken(branchItems[j]))) ?? []).filter(([x, y]) => !(isMinted(baseItems[restBase[x]]) && isMinted(branchItems[restBranch[y]])));
451
+ const pairs = (commonPairs(restBase.map((j) => valueToken(baseItems[j])), restBranch.map((j) => valueToken(branchItems[j]))) ?? []).filter(([x, y]) => {
452
+ const [a, b] = [baseItems[restBase[x]], branchItems[restBranch[y]]];
453
+ const [ka, kb] = [ownKey(a), ownKey(b)];
454
+ const movedIn = (k) => k !== undefined && movedHere(k);
455
+ return !(isMinted(a) && isMinted(b)) && !(ka !== kb && (movedIn(ka) || movedIn(kb)));
456
+ });
408
457
  for (const [x, y] of pairs)
409
458
  baseIds[restBase[x]] = branchIds[restBranch[y]];
410
459
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The field merge's keys, onto what a git-level merge wrote: git keeps each item under its side's
3
+ * key, which can name another item once the other side changed it.
4
+ */
5
+ /**
6
+ * `merged` with each list item under the key `fieldMerged` gives the item holding its content, in
7
+ * `merged`'s order, alike items matched in order; nested lists alike. Undefined where the two don't
8
+ * hold the same content, item keys and the order of list items aside. A list of plain values is
9
+ * content in its order.
10
+ */
11
+ export declare function rekeyAsMerged(merged: unknown, fieldMerged: unknown): unknown;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The field merge's keys, onto what a git-level merge wrote: git keeps each item under its side's
3
+ * key, which can name another item once the other side changed it.
4
+ */
5
+ import { ITEM_KEY } from '../item-keys.js';
6
+ import { isPlainRecord } from '../validation/field-traversal.js';
7
+ import { isEmptyValue, valueToken } from './value-equality.js';
8
+ const owns = (record, name) => Object.prototype.hasOwnProperty.call(record, name);
9
+ const isRecordList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPlainRecord);
10
+ /** `value`'s content with item keys aside and each list of records in any order. */
11
+ const looseToken = (value) => {
12
+ if (isEmptyValue(value))
13
+ return '';
14
+ if (isRecordList(value))
15
+ return `[${value.map(looseToken).sort().join(',')}]`;
16
+ if (!isPlainRecord(value))
17
+ return valueToken(value);
18
+ const members = Object.entries(value)
19
+ .filter(([name, v]) => name !== ITEM_KEY && !isEmptyValue(v))
20
+ .sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
21
+ return `{${members.map(([name, v]) => `${JSON.stringify(name)}:${looseToken(v)}`).join(',')}}`;
22
+ };
23
+ /**
24
+ * `merged` with each list item under the key `fieldMerged` gives the item holding its content, in
25
+ * `merged`'s order, alike items matched in order; nested lists alike. Undefined where the two don't
26
+ * hold the same content, item keys and the order of list items aside. A list of plain values is
27
+ * content in its order.
28
+ */
29
+ export function rekeyAsMerged(merged, fieldMerged) {
30
+ if (isRecordList(merged) && isRecordList(fieldMerged)) {
31
+ if (merged.length !== fieldMerged.length)
32
+ return undefined;
33
+ const pool = fieldMerged.map((item) => ({ item, token: looseToken(item) }));
34
+ const out = [];
35
+ for (const item of merged) {
36
+ const token = looseToken(item);
37
+ const at = pool.findIndex((p) => p.token === token);
38
+ if (at === -1)
39
+ return undefined;
40
+ const [{ item: match }] = pool.splice(at, 1);
41
+ const rekeyed = rekeyAsMerged(item, match);
42
+ if (rekeyed === undefined)
43
+ return undefined;
44
+ out.push(rekeyed);
45
+ }
46
+ return out;
47
+ }
48
+ if (isPlainRecord(merged) && isPlainRecord(fieldMerged)) {
49
+ // Git's members in git's order, so the text changes only where a key does.
50
+ const out = [];
51
+ const key = owns(fieldMerged, ITEM_KEY) ? fieldMerged[ITEM_KEY] : undefined;
52
+ for (const [name, value] of Object.entries(merged)) {
53
+ if (name === ITEM_KEY) {
54
+ if (key !== undefined)
55
+ out.push([name, key]);
56
+ continue;
57
+ }
58
+ const other = owns(fieldMerged, name) ? fieldMerged[name] : undefined;
59
+ const rekeyed = isEmptyValue(value) && isEmptyValue(other) ? value : rekeyAsMerged(value, other);
60
+ if (rekeyed === undefined)
61
+ return undefined;
62
+ out.push([name, rekeyed]);
63
+ }
64
+ for (const [name, value] of Object.entries(fieldMerged)) {
65
+ if (owns(merged, name))
66
+ continue;
67
+ if (name === ITEM_KEY)
68
+ out.push([name, value]);
69
+ else if (!isEmptyValue(value))
70
+ return undefined;
71
+ }
72
+ return Object.fromEntries(out);
73
+ }
74
+ return valueToken(merged) === valueToken(fieldMerged) ? merged : undefined;
75
+ }
@@ -46,7 +46,7 @@ export class DevClientSafeStrategy {
46
46
  return false; // No real GitHub in local dev mode
47
47
  }
48
48
  syncsBranchesWithBase() {
49
- return false;
49
+ return true;
50
50
  }
51
51
  getPermissionsFileName() {
52
52
  return 'permissions.json';
@@ -39,6 +39,9 @@ class ProdStrategy extends ProdClientSafeStrategy {
39
39
  autoDetectRemotePath: path.join(this.getWorkspaceRoot(), 'remote.git'),
40
40
  };
41
41
  }
42
+ baseUpdateRunsInRequest() {
43
+ return false;
44
+ }
42
45
  requiresExistingRepo() {
43
46
  return false; // Will clone if needed
44
47
  }
@@ -91,6 +94,9 @@ class DevStrategy extends DevClientSafeStrategy {
91
94
  envVarName: 'CANOPYCMS_REMOTE_URL',
92
95
  };
93
96
  }
97
+ baseUpdateRunsInRequest() {
98
+ return true;
99
+ }
94
100
  requiresExistingRepo() {
95
101
  return false;
96
102
  }
@@ -34,8 +34,9 @@ export interface ClientSafeStrategy {
34
34
  supportsComments(): boolean;
35
35
  supportsPullRequests(): boolean;
36
36
  /**
37
- * Whether a worker brings the base branch into editing branches, so a request for the latest
38
- * changes (base-update.ts) gets an answer. Dev runs no git sync.
37
+ * Whether a request for the latest changes (base-update.ts) gets an answer, so the editor offers
38
+ * "Get latest changes": from the worker's sync, or from the request itself
39
+ * ({@link ClientUnsafeStrategy.baseUpdateRunsInRequest}).
39
40
  */
40
41
  syncsBranchesWithBase(): boolean;
41
42
  /** Get the permissions file name (e.g., 'permissions.json' or 'permissions.local.json') */
@@ -79,6 +80,13 @@ export interface ClientUnsafeStrategy extends ClientSafeStrategy {
79
80
  getPermissionsFilePath(root: string): string;
80
81
  getGroupsFilePath(root: string): string;
81
82
  getRemoteUrlConfig(): RemoteUrlConfig;
83
+ /**
84
+ * Whether a request for the latest changes runs the worker's merge for its branch in the request
85
+ * (services.ts `runBaseUpdateNow`), for a mode with no worker loop to answer it. False where the
86
+ * worker answers: there the request is only queued, and a merge in a request would race the
87
+ * worker's own.
88
+ */
89
+ baseUpdateRunsInRequest(): boolean;
82
90
  /** Whether this mode requires an existing git repository */
83
91
  requiresExistingRepo(): boolean;
84
92
  /** The permissions/groups branch: `canopycms-settings-{deploymentName}`. */