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.
- package/CHANGELOG.md +211 -49
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +112 -23
- package/dist/api/branch-status.d.ts +11 -2
- package/dist/api/branch-status.js +3 -2
- package/dist/api/branch-update-from-base.js +57 -6
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +27 -2
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.js +2 -2
- package/dist/api/content.js +1 -1
- package/dist/api/merge-conflicts.d.ts +10 -1
- package/dist/api/merge-conflicts.js +42 -7
- package/dist/api/types.d.ts +3 -2
- package/dist/base-update.d.ts +6 -0
- package/dist/branch-metadata-file.d.ts +15 -0
- package/dist/branch-metadata-file.js +5 -1
- package/dist/cli/cli.js +238 -108
- package/dist/cli/generate-ai-content.js +20 -6
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +7 -1
- package/dist/editor/Editor.js +39 -16
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +4 -0
- package/dist/editor/base-update-wait.js +7 -0
- package/dist/editor/components/ConflictsGroup.d.ts +13 -1
- package/dist/editor/components/ConflictsGroup.js +52 -12
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +11 -7
- package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
- package/dist/editor/components/EntryChangesDrawer.js +58 -6
- package/dist/editor/conflict-cards.d.ts +4 -1
- package/dist/editor/conflict-cards.js +14 -9
- package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
- package/dist/editor/hooks/useBaseUpdate.js +12 -2
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +80 -6
- package/dist/editor/hooks/useDraftManager.d.ts +8 -1
- package/dist/editor/hooks/useDraftManager.js +16 -1
- package/dist/editor/hooks/useEntryManager.d.ts +8 -3
- package/dist/editor/hooks/useEntryManager.js +6 -5
- package/dist/git-manager.d.ts +1 -0
- package/dist/git-manager.js +8 -3
- package/dist/merge/entry-merge.js +4 -3
- package/dist/merge/list-alignment.d.ts +9 -6
- package/dist/merge/list-alignment.js +65 -16
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/operating-mode/client-safe-strategy.js +1 -1
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +10 -2
- package/dist/services.d.ts +12 -4
- package/dist/services.js +56 -0
- package/dist/types.d.ts +35 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +27 -3
- package/dist/worker/branch-merge.js +106 -5
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +21 -0
- package/dist/worker/cms-worker.js +29 -4
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +17 -2
- package/dist/worker/field-merge.js +95 -2
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +9 -0
- package/dist/worker/github-commits.d.ts +3 -3
- package/dist/worker/github-commits.js +192 -40
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +7 -0
- package/dist/worker/github-gateway-wire.d.ts +30 -10
- package/dist/worker/github-gateway-wire.js +14 -2
- package/dist/worker/github-gateway.d.ts +17 -0
- package/dist/worker/github-gateway.js +37 -1
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +25 -5
- package/dist/worker/rebase.js +225 -81
- package/dist/worker/task-runner.js +16 -39
- package/dist/worker/worker-context.d.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -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
|
package/dist/git-manager.js
CHANGED
|
@@ -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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
-
|
|
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})
|
|
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
|
|
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
|
|
43
|
-
*
|
|
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})
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
123
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
|
380
|
-
*
|
|
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]) =>
|
|
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
|
+
}
|
|
@@ -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
|
|
38
|
-
* changes
|
|
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}`. */
|