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