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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BranchMetadataFileManager } from '../branch-metadata.js';
|
|
4
|
+
import { BranchMetadataCorruptError } from '../branch-metadata-file.js';
|
|
5
|
+
import { editorDeepLink, resolveEditorUrl } from '../editor-url.js';
|
|
6
|
+
import { MergeConflictStore, MergeConflictsFileFormatError, MergeConflictsFileMalformedError, openConflictCount, } from '../merge-conflicts.js';
|
|
7
|
+
import { getErrorMessage, isNodeError, sanitizeErrorMessage } from '../utils/error.js';
|
|
8
|
+
import { escapeForLog } from '../utils/escape-text.js';
|
|
9
|
+
import { GatewayRefusalError, MAX_COMMIT_STATUS_URL_LENGTH } from './branch-policy.js';
|
|
10
|
+
import { getHttpStatus, isPermissionDenial } from './github-errors.js';
|
|
11
|
+
import { workerLog, workerLogWarn } from './log.js';
|
|
12
|
+
/** The most branch names a failure report lists. */
|
|
13
|
+
const MAX_REPORTED_BRANCHES = 50;
|
|
14
|
+
/**
|
|
15
|
+
* What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
|
|
16
|
+
* the record is held after a damaged one was set aside, or does not parse; success otherwise.
|
|
17
|
+
* @internal Exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
export async function conflictsStatusOf(branchRoot) {
|
|
20
|
+
try {
|
|
21
|
+
const record = await new MergeConflictStore(branchRoot).read();
|
|
22
|
+
if (record?.held) {
|
|
23
|
+
return {
|
|
24
|
+
state: 'failure',
|
|
25
|
+
description: 'Conflict record set aside: an admin must release the branch in the editor',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const open = record ? openConflictCount(record.entries) : 0;
|
|
29
|
+
if (open === 0)
|
|
30
|
+
return { state: 'success', description: 'No conflicting changes to resolve' };
|
|
31
|
+
return {
|
|
32
|
+
state: 'failure',
|
|
33
|
+
description: open === 1
|
|
34
|
+
? '1 conflicting change to resolve in the editor'
|
|
35
|
+
: `${open} conflicting changes to resolve in the editor`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof MergeConflictsFileMalformedError ||
|
|
40
|
+
err instanceof MergeConflictsFileFormatError) {
|
|
41
|
+
return { state: 'failure', description: "Couldn't read this branch's conflicts" };
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
|
|
48
|
+
* fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
|
|
49
|
+
* when every status was set, or null when the stage could not say: the branches could not be
|
|
50
|
+
* listed, or a drain cut it off. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
export async function setConflictsStatuses(ctx, githubTips) {
|
|
53
|
+
let due;
|
|
54
|
+
let unreadable;
|
|
55
|
+
try {
|
|
56
|
+
;
|
|
57
|
+
({ due, unreadable } = await dueStatuses(ctx, githubTips));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
workerLogWarn(`Could not read the branches' conflicts for their status: ${getErrorMessage(err)}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const set = ctx.conflictsStatusesSet();
|
|
64
|
+
const failed = [];
|
|
65
|
+
let failure;
|
|
66
|
+
for (const [index, { branch, status, key }] of due.entries()) {
|
|
67
|
+
if (ctx.shutdownSignal().aborted)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
// Bounded, so a GitHub that stalls holds the sync for one timeout, not one per branch.
|
|
71
|
+
await ctx
|
|
72
|
+
.github()
|
|
73
|
+
.setConflictsStatus(status, AbortSignal.any([ctx.shutdownSignal(), AbortSignal.timeout(ctx.taskTimeoutMs)]));
|
|
74
|
+
set.set(branch, key);
|
|
75
|
+
workerLog(`Set the conflicts status of PR #${status.pullRequest} (${escapeForLog(branch)}) at ` +
|
|
76
|
+
`${status.sha.slice(0, 7)}: ${status.state}`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (ctx.shutdownSignal().aborted)
|
|
80
|
+
return null;
|
|
81
|
+
if (err instanceof GatewayRefusalError && err.reason === 'not-head') {
|
|
82
|
+
// GitHub moves a pull request's head a moment after the push, so this cycle's tip can be
|
|
83
|
+
// ahead of it: one more sync for that tip, and no loop if the two never agree.
|
|
84
|
+
const asked = `not-head:${branch}`;
|
|
85
|
+
if (set.get(asked) !== status.sha) {
|
|
86
|
+
set.set(asked, status.sha);
|
|
87
|
+
ctx.requestBranchSync(branch);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const message = sanitizeErrorMessage(getErrorMessage(err));
|
|
92
|
+
failed.push(branch);
|
|
93
|
+
workerLogWarn(`Could not set the conflicts status of ${escapeForLog(branch)}: ${message}`);
|
|
94
|
+
// The gateway's refusals for the pull request itself report as `other`, retried each cycle.
|
|
95
|
+
const reason = err instanceof GatewayRefusalError
|
|
96
|
+
? 'other'
|
|
97
|
+
: isPermissionDenial(err)
|
|
98
|
+
? 'permission'
|
|
99
|
+
: isUnreachable(err)
|
|
100
|
+
? 'unreachable'
|
|
101
|
+
: 'other';
|
|
102
|
+
// A refused credential names its remedy over any other failure.
|
|
103
|
+
if (reason === 'permission' || !failure)
|
|
104
|
+
failure = { reason, message };
|
|
105
|
+
// Every later write would meet the same GitHub; the next cycle tries again.
|
|
106
|
+
if (reason !== 'other') {
|
|
107
|
+
failed.push(...due.slice(index + 1).map((d) => d.branch));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// After GitHub's answers, whose reason and remedy come first.
|
|
113
|
+
for (const u of unreadable)
|
|
114
|
+
failed.push(u.branch);
|
|
115
|
+
failure ??= unreadable[0] && { reason: 'other', message: unreadable[0].message };
|
|
116
|
+
if (!failure)
|
|
117
|
+
return undefined;
|
|
118
|
+
return {
|
|
119
|
+
...failure,
|
|
120
|
+
branches: failed.slice(0, MAX_REPORTED_BRANCHES),
|
|
121
|
+
count: failed.length,
|
|
122
|
+
at: new Date().toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A failure to reach GitHub or the gateway, or GitHub busy: no status at all, a timeout, a rate
|
|
127
|
+
* limit (a 403 that is not a permission denial), or a server error.
|
|
128
|
+
*/
|
|
129
|
+
function isUnreachable(err) {
|
|
130
|
+
const status = getHttpStatus(err);
|
|
131
|
+
return status === null || status === 403 || status === 408 || status === 429 || status >= 500;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Each branch with a pull request whose head lacks the status it calls for, and each whose conflict
|
|
135
|
+
* record could not be read, which costs only its own status. A branch.json that cannot be read says
|
|
136
|
+
* nothing of a pull request, so it is logged and skipped. Throws only when the branches directory
|
|
137
|
+
* cannot be listed.
|
|
138
|
+
*/
|
|
139
|
+
async function dueStatuses(ctx, githubTips) {
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
146
|
+
return { due: [], unreadable: [] };
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const set = ctx.conflictsStatusesSet();
|
|
150
|
+
const due = [];
|
|
151
|
+
const unreadable = [];
|
|
152
|
+
const cannotRead = (branch, err) => {
|
|
153
|
+
const message = sanitizeErrorMessage(getErrorMessage(err));
|
|
154
|
+
workerLogWarn(`Could not read ${escapeForLog(branch)} for its conflicts status: ${message}`);
|
|
155
|
+
unreadable.push({ branch, message });
|
|
156
|
+
};
|
|
157
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
158
|
+
const dir = entry.name;
|
|
159
|
+
// The base branch has no pull request; `.trash-*` directories hold purged branches.
|
|
160
|
+
if (!entry.isDirectory() || dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
|
|
161
|
+
continue;
|
|
162
|
+
const branchRoot = path.join(ctx.contentBranchesPath, dir);
|
|
163
|
+
let meta;
|
|
164
|
+
try {
|
|
165
|
+
meta = await BranchMetadataFileManager.loadOnly(branchRoot);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (!(err instanceof BranchMetadataCorruptError) &&
|
|
169
|
+
!(isNodeError(err) && err.code === 'ENOENT'))
|
|
170
|
+
workerLogWarn(`Could not read ${escapeForLog(dir)}'s branch.json for its conflicts status: ` +
|
|
171
|
+
sanitizeErrorMessage(getErrorMessage(err)));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const branch = meta?.branch;
|
|
175
|
+
if (!branch || branch.status === 'archived' || branch.pullRequestNumber === undefined)
|
|
176
|
+
continue;
|
|
177
|
+
if (branch.pullRequestState === 'merged' || branch.pullRequestState === 'closed')
|
|
178
|
+
continue;
|
|
179
|
+
const sha = githubTips.get(branch.name);
|
|
180
|
+
if (sha === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
let text;
|
|
183
|
+
try {
|
|
184
|
+
text = await conflictsStatusOf(branchRoot);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
cannotRead(branch.name, err);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const editorUrl = branch.editorUrl && resolveEditorUrl({ configured: branch.editorUrl });
|
|
191
|
+
const link = editorUrl && editorDeepLink(editorUrl, branch.name);
|
|
192
|
+
const status = {
|
|
193
|
+
pullRequest: branch.pullRequestNumber,
|
|
194
|
+
sha,
|
|
195
|
+
...text,
|
|
196
|
+
// A link the gateway would refuse is left off, so it never costs the status.
|
|
197
|
+
...(link && link.length <= MAX_COMMIT_STATUS_URL_LENGTH && { targetUrl: link }),
|
|
198
|
+
};
|
|
199
|
+
const key = JSON.stringify(status);
|
|
200
|
+
if (set.get(branch.name) !== key)
|
|
201
|
+
due.push({ branch: branch.name, status, key });
|
|
202
|
+
}
|
|
203
|
+
return { due, unreadable };
|
|
204
|
+
}
|
|
@@ -17,7 +17,7 @@ import type { ContentId } from '../paths/types.js';
|
|
|
17
17
|
* detection put something else there, the pairing by ID puts it right.
|
|
18
18
|
*/
|
|
19
19
|
/** A file in one version of the tree. */
|
|
20
|
-
interface TreeFile {
|
|
20
|
+
export interface TreeFile {
|
|
21
21
|
path: string;
|
|
22
22
|
oid: string;
|
|
23
23
|
mode: string;
|
|
@@ -56,10 +56,25 @@ export interface FieldMergeArgs {
|
|
|
56
56
|
/** The content ID a `.collection.json` path belongs to. */
|
|
57
57
|
collectionIdOf: (file: string) => ContentId | undefined;
|
|
58
58
|
timeoutMs: number;
|
|
59
|
+
/**
|
|
60
|
+
* Git's merge stands, and only the item keys of an entry both sides changed change: to the field
|
|
61
|
+
* merge's, where it holds the same content with no conflict ({@link rekeyAsMerged}).
|
|
62
|
+
*/
|
|
63
|
+
rekeyOnly?: boolean;
|
|
59
64
|
}
|
|
65
|
+
/** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
|
|
66
|
+
export declare function changedFiles(git: SimpleGit, from: string, to: string): Promise<Array<{
|
|
67
|
+
path: string;
|
|
68
|
+
before?: TreeFile;
|
|
69
|
+
after?: TreeFile;
|
|
70
|
+
}>>;
|
|
71
|
+
/**
|
|
72
|
+
* Whether two versions of one entry file under `contentRoot` differ in item keys alone: what a
|
|
73
|
+
* re-key changes. False for anything else, a file's mode or path included.
|
|
74
|
+
*/
|
|
75
|
+
export declare function differsInItemKeysOnly(git: SimpleGit, x: TreeFile, y: TreeFile, contentRoot: string): Promise<boolean>;
|
|
60
76
|
/**
|
|
61
77
|
* Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
|
|
62
78
|
* result says which blobs replace git's, and which conflicts a person must resolve.
|
|
63
79
|
*/
|
|
64
80
|
export declare function resolveFieldMerge(args: FieldMergeArgs): Promise<FieldMergeResult>;
|
|
65
|
-
export {};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { parse as yamlParse } from 'yaml';
|
|
3
3
|
import { entryLogicalPathOfFile, extractIdFromFilename } from '../content-id-index.js';
|
|
4
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
4
5
|
import { parseEntryParts } from '../content-listing.js';
|
|
5
6
|
import { mergeCollectionMeta, mergeEntry, } from '../merge/entry-merge.js';
|
|
7
|
+
import { rekeyAsMerged } from '../merge/rekey.js';
|
|
6
8
|
import { exactToken } from '../merge/value-equality.js';
|
|
7
9
|
import { LOCATION_CONFLICT_KEY, } from '../merge-conflicts.js';
|
|
8
10
|
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
@@ -38,7 +40,7 @@ function unitKeyOf(file, root, collectionIdOf) {
|
|
|
38
40
|
return { key: collection ? `meta:${collection}` : `meta-at:${file}` };
|
|
39
41
|
}
|
|
40
42
|
/** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
|
|
41
|
-
async function changedFiles(git, from, to) {
|
|
43
|
+
export async function changedFiles(git, from, to) {
|
|
42
44
|
const out = await git.raw([
|
|
43
45
|
'diff-tree',
|
|
44
46
|
'-r',
|
|
@@ -322,6 +324,44 @@ const LOCATION = {
|
|
|
322
324
|
sidePaths: {},
|
|
323
325
|
kind: 'changed',
|
|
324
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
|
+
}
|
|
325
365
|
class Resolver {
|
|
326
366
|
constructor(args, moves) {
|
|
327
367
|
this.args = args;
|
|
@@ -473,6 +513,52 @@ class Resolver {
|
|
|
473
513
|
...(merged.keyRemaps.length > 0 && { remaps: merged.keyRemaps }),
|
|
474
514
|
};
|
|
475
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
|
+
}
|
|
476
562
|
/** A `.collection.json` both sides changed and both still have; else git's rule. */
|
|
477
563
|
async collectionMeta(unit, gitClean, gitFile) {
|
|
478
564
|
const { git } = this.args;
|
|
@@ -581,7 +667,14 @@ export async function resolveFieldMerge(args) {
|
|
|
581
667
|
const gitClean = ![...paths].some((p) => args.conflicted.has(p));
|
|
582
668
|
const only = gitHeld.size === 1 ? [...gitHeld.values()][0] : undefined;
|
|
583
669
|
let resolution;
|
|
584
|
-
if (
|
|
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) {
|
|
585
678
|
resolution =
|
|
586
679
|
unit.kind === 'entry'
|
|
587
680
|
? await resolver.entry(unit, gitClean, only)
|
|
@@ -16,7 +16,8 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
16
16
|
* says the serving editor lacks a schema the incoming content names), push this
|
|
17
17
|
* deployment's own settings
|
|
18
18
|
* branch, fast-forward the base branch's workspace, ask the gateway for its branch policy,
|
|
19
|
-
* rebase every branch that is behind it (rebase.ts),
|
|
19
|
+
* rebase every branch that is behind it (rebase.ts), set each pull request's conflicts status
|
|
20
|
+
* (conflicts-status.ts), then sweep old tasks, expired trashed
|
|
20
21
|
* branch directories and, where retention is on, expired archived branches.
|
|
21
22
|
*
|
|
22
23
|
* One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
|
|
@@ -32,7 +33,7 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
32
33
|
* The whole cycle is wrapped so both outcomes record a worker-status.json
|
|
33
34
|
* snapshot, since an operator's only view of this loop is the admin panel.
|
|
34
35
|
*/
|
|
35
|
-
export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'takeBranchSyncRequests' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
|
|
36
|
+
export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'conflictsStatusesSet' | 'isRunning' | 'takeBranchSyncRequests' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
|
|
36
37
|
/**
|
|
37
38
|
* Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
|
|
38
39
|
* remote.git to GitHub. Non-fatal. Skipped when `trackedTips`, GitHub's tips
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -14,6 +14,7 @@ import { escapeForLog } from '../utils/escape-text.js';
|
|
|
14
14
|
import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
|
|
15
15
|
import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
|
|
16
16
|
import { sweepArchivedBranches } from './archived-sweep.js';
|
|
17
|
+
import { setConflictsStatuses } from './conflicts-status.js';
|
|
17
18
|
import { hasPendingHistoryRewrite, recordSeenOnGitHub } from './history-rewrite.js';
|
|
18
19
|
import { runRebaseCycle } from './rebase.js';
|
|
19
20
|
import { describeMove } from './github-gateway.js';
|
|
@@ -519,6 +520,9 @@ export async function syncGit(ctx) {
|
|
|
519
520
|
const rebaseSummary = await runRebaseCycle(ctx, branchPolicy, { tips: githubTips, fetchedAt }, requested.branches);
|
|
520
521
|
if (stoppedForDrain(ctx, 'the cleanup sweeps'))
|
|
521
522
|
return;
|
|
523
|
+
// After the rebase cycle, whose merges can change a branch's conflicts. Heads the cycle's own
|
|
524
|
+
// pushes move are set by the cycle each push wakes.
|
|
525
|
+
const conflictsStatusFailure = await setConflictsStatuses(ctx, githubTips);
|
|
522
526
|
await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
|
|
523
527
|
// For System health: the branches the gateway would refuse to publish, read after the rebase
|
|
524
528
|
// cycle, which archives merged branches and restores renamed ones. Without a policy, a fatal
|
|
@@ -551,6 +555,11 @@ export async function syncGit(ctx) {
|
|
|
551
555
|
const report = ctx.ensureStatusReport();
|
|
552
556
|
report.lastGitSyncAt = new Date().toISOString();
|
|
553
557
|
delete report.lastGitSyncError;
|
|
558
|
+
// null: the stage could not say, so the last report stands.
|
|
559
|
+
if (conflictsStatusFailure)
|
|
560
|
+
report.conflictsStatusFailure = conflictsStatusFailure;
|
|
561
|
+
else if (conflictsStatusFailure === undefined)
|
|
562
|
+
delete report.conflictsStatusFailure;
|
|
554
563
|
// Recorded once the cycle has done everything it can, so System health shows it.
|
|
555
564
|
if (needsOperator !== null) {
|
|
556
565
|
report.lastGitSyncError = { message: needsOperator, at: report.lastGitSyncAt };
|
|
@@ -92,9 +92,9 @@ export declare function catchUpWithPublished(ctx: GitHubCommitsContext, args: Gi
|
|
|
92
92
|
/** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
|
|
93
93
|
export declare function bringInGitHubCommits(ctx: GitHubCommitsContext, args: GitHubCommitsArgs): Promise<GitHubCommitsOutcome>;
|
|
94
94
|
/**
|
|
95
|
-
* `other` merged into the branch: built, recorded, published to `remote.git
|
|
96
|
-
* and the clone moved to it, in that order,
|
|
97
|
-
* only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
|
|
95
|
+
* `other` merged into the branch: built, recorded, published to `remote.git` and queued for GitHub
|
|
96
|
+
* unless it holds unsubmitted work (publish-decision.ts), and the clone moved to it, in that order,
|
|
97
|
+
* so each step leaves what the next cycle picks up and only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
|
|
98
98
|
* base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
|
|
99
99
|
* branch as it is and records nothing, and GitHub's own conflict notice stands on the pull request. A lock lost before the clone moves leaves it where it was: `wait`.
|
|
100
100
|
*/
|