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