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
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
2
|
+
import { CommentStore } from '../comment-store.js';
|
|
2
3
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
3
4
|
import { GITHUB_TRACKING_REF_PREFIX } from '../git-manager.js';
|
|
5
|
+
import { MergeConflictStore, MergeConflictsFileMalformedError, conflictFilesOf, withFoundConflicts, withRemappedItemKeys, withoutMergesGone, } from '../merge-conflicts.js';
|
|
4
6
|
import { SYNC_PR_COMMENTS_ACTION } from '../pr-comments.js';
|
|
5
7
|
import { enqueueTask } from '../task-queue/cms-task-queue.js';
|
|
6
8
|
import { getErrorMessage } from '../utils/error.js';
|
|
7
9
|
import { escapeForLog } from '../utils/escape-text.js';
|
|
8
10
|
import { isCanopyInternalPath } from '../utils/git.js';
|
|
9
11
|
import { publishesBranch } from './branch-policy.js';
|
|
10
|
-
import {
|
|
12
|
+
import { buildRekey, commitMerge, commitsApart, isCleanMergeOf, planMerge, publishPlainToLocalRemote, } from './branch-merge.js';
|
|
11
13
|
import { MAX_REPORTED_PATHS, RETIRED_SCHEMA_CACHE_PATH, TRACKED_CANOPY_STATE_FIX, } from './canopy-state.js';
|
|
12
14
|
import { enqueueGitHubPush, readPublishedSha, recordSeenOnGitHub } from './history-rewrite.js';
|
|
13
15
|
import { workerLog, workerLogWarn } from './log.js';
|
|
14
|
-
import {
|
|
16
|
+
import { decidePublish } from './publish-decision.js';
|
|
17
|
+
import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, fetchFromRemoteGit, } from './shared-repo-git.js';
|
|
15
18
|
export const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
16
19
|
const MAX_LISTED_PATH_LENGTH = 200;
|
|
17
20
|
/** Why a push was refused while a rewrite stands (task-runner.ts); editors read it as a failed sync. */
|
|
@@ -33,9 +36,41 @@ export function rewriteCleared(branch) {
|
|
|
33
36
|
: { rewrittenOnGitHub: undefined, syncStatus: 'synced', syncFailureReason: undefined };
|
|
34
37
|
}
|
|
35
38
|
/** A branch this deployment put on GitHub. Any other GitHub branch of that name is not its own. */
|
|
36
|
-
function isPublished({ branch }) {
|
|
39
|
+
export function isPublished({ branch }) {
|
|
37
40
|
return branch.pushedToGitHubAt !== undefined || branch.pullRequestNumber !== undefined;
|
|
38
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Fast-forward the clone to the merge this worker was landing, when `remote.git` holds it on top of
|
|
44
|
+
* the clone, as a crash or a lost lock between publishing it and moving the clone leaves it,
|
|
45
|
+
* whether or not the branch is on GitHub. Resolves why it stopped, or null when there was nothing
|
|
46
|
+
* to catch up with or it caught up.
|
|
47
|
+
*/
|
|
48
|
+
export async function catchUpWithPublished(ctx, args) {
|
|
49
|
+
const { git, branchPath, branchRef } = args;
|
|
50
|
+
const published = await readPublishedSha(ctx, branchRef);
|
|
51
|
+
if (published === null)
|
|
52
|
+
return null;
|
|
53
|
+
// Only the merge this worker stamped as landing: `remote.git` can also hold a same-named branch,
|
|
54
|
+
// or a reviewer's commit, that it never wrote, which other steps judge.
|
|
55
|
+
const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
|
|
56
|
+
if (landing !== published)
|
|
57
|
+
return null;
|
|
58
|
+
const head = (await git.revparse(['HEAD'])).trim();
|
|
59
|
+
if (published === head)
|
|
60
|
+
return null;
|
|
61
|
+
if (!(await holdsCommit(git, published))) {
|
|
62
|
+
await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
|
|
63
|
+
}
|
|
64
|
+
const parents = (await git.raw(['rev-list', '--parents', '-n', '1', '--end-of-options', published]))
|
|
65
|
+
.trim()
|
|
66
|
+
.split(' ');
|
|
67
|
+
// A merge built on the clone's head, or a re-key built on GitHub's commits past it.
|
|
68
|
+
const builtOnHead = parents.length === 3 && parents[1] === head;
|
|
69
|
+
const rekeyPastHead = parents.length === 2 && (await contains(git, published, head));
|
|
70
|
+
if (!builtOnHead && !rekeyPastHead)
|
|
71
|
+
return null;
|
|
72
|
+
return fastForward(ctx, args, head, published);
|
|
73
|
+
}
|
|
39
74
|
/** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
|
|
40
75
|
export async function bringInGitHubCommits(ctx, args) {
|
|
41
76
|
const { git, branchRef, metaFile, dirty, gatewayPolicy } = args;
|
|
@@ -93,6 +128,12 @@ export async function bringInGitHubCommits(ctx, args) {
|
|
|
93
128
|
// below builds on remote.git's head whenever it can.
|
|
94
129
|
if (published !== null && published !== head) {
|
|
95
130
|
if ((await commitsApart(git, head, published)).onlyA === 0) {
|
|
131
|
+
// GitHub's commits, which reconcile moved `remote.git` to, come in re-keyed where they need it.
|
|
132
|
+
if (gitHubTip === published) {
|
|
133
|
+
const rekeyed = await rekeyTakenIn(ctx, args, head, published);
|
|
134
|
+
if (rekeyed)
|
|
135
|
+
return rekeyed;
|
|
136
|
+
}
|
|
96
137
|
const stopped = await fastForward(ctx, args, head, published);
|
|
97
138
|
if (stopped)
|
|
98
139
|
return stopped;
|
|
@@ -285,61 +326,184 @@ async function fastForward(ctx, args, from, to) {
|
|
|
285
326
|
await moveClone(git, branchPath, to);
|
|
286
327
|
}
|
|
287
328
|
catch (err) {
|
|
288
|
-
if (
|
|
329
|
+
if (!overwritesSaves(err))
|
|
289
330
|
throw err;
|
|
290
331
|
workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
|
|
291
332
|
return { kind: 'wait' };
|
|
292
333
|
}
|
|
293
334
|
workerLog(` ${branchDir}: brought in commits from GitHub (now at ${to.slice(0, 7)})`);
|
|
335
|
+
// A merge this worker was landing, which the clone now holds, has landed.
|
|
336
|
+
const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
|
|
337
|
+
if (landing !== undefined && (await contains(git, to, landing))) {
|
|
338
|
+
await landed(ctx, args, landing).catch((err) => workerLogWarn(` ${branchDir}: what a merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
|
|
339
|
+
}
|
|
294
340
|
return null;
|
|
295
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* GitHub's commits brought in under the item keys a field merge gives, where a merge among them
|
|
344
|
+
* left an item under a key that names another item (branch-merge.ts `buildRekey`): one commit on
|
|
345
|
+
* `tip`, recorded, published and moved to in {@link mergeIntoBranch}'s order, its remaps moving the
|
|
346
|
+
* branch's comment threads once it lands. Null, for the caller to fast-forward to `tip` as git
|
|
347
|
+
* keyed it, where no item needs a new key, where the commit could not be published (gateway policy,
|
|
348
|
+
* {@link decidePublish}, a `remote.git` head it doesn't contain), or where the clone's saves would
|
|
349
|
+
* keep the clone from moving to it ({@link savesMeet}).
|
|
350
|
+
*/
|
|
351
|
+
async function rekeyTakenIn(ctx, args, head, tip) {
|
|
352
|
+
const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
|
|
353
|
+
if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef))
|
|
354
|
+
return null;
|
|
355
|
+
const published = await readPublishedSha(ctx, branchRef);
|
|
356
|
+
if ((await decidePublish(git, { head, published })).kind !== 'publish')
|
|
357
|
+
return null;
|
|
358
|
+
const built = await rekeyOf(ctx, args, head, tip);
|
|
359
|
+
if (built === null)
|
|
360
|
+
return null;
|
|
361
|
+
const { sha } = built;
|
|
362
|
+
// A re-key the clone can't move to stays unpublished; the commits come in once the saves do.
|
|
363
|
+
if (args.dirty && (await savesMeet(git, head, sha)))
|
|
364
|
+
return null;
|
|
365
|
+
if (published !== null && !(await contains(git, sha, published)))
|
|
366
|
+
return null;
|
|
367
|
+
const refused = await refuseIncoming(args, head, sha);
|
|
368
|
+
if (refused)
|
|
369
|
+
return refused;
|
|
370
|
+
if (lockLost())
|
|
371
|
+
return { kind: 'wait' };
|
|
372
|
+
// Judged on content: the commit changes item keys only, which `isCleanMergeOf` sets aside.
|
|
373
|
+
if (await changesWhatWasReviewed(ctx, args, head, sha))
|
|
374
|
+
await returnToReview(ctx, args);
|
|
375
|
+
await recordMerge(ctx, args, built, { head, also: tip, fromGitHub: true });
|
|
376
|
+
if (lockLost())
|
|
377
|
+
return { kind: 'wait' };
|
|
378
|
+
await recordSeenOnGitHub(ctx, branchRef, tip);
|
|
379
|
+
if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha)))
|
|
380
|
+
return { kind: 'wait' };
|
|
381
|
+
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
382
|
+
if (lockLost())
|
|
383
|
+
return { kind: 'wait' };
|
|
384
|
+
try {
|
|
385
|
+
await moveClone(git, branchPath, sha);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
if (!overwritesSaves(err))
|
|
389
|
+
throw err;
|
|
390
|
+
// Unreached: `savesMeet` looked under the content-write lock this cycle still holds.
|
|
391
|
+
workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
|
|
392
|
+
return { kind: 'wait' };
|
|
393
|
+
}
|
|
394
|
+
await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this re-key moves is noted on the next cycle: ${getErrorMessage(err)}`));
|
|
395
|
+
await recordGitHubTip(ctx, args, tip);
|
|
396
|
+
workerLog(` ${branchDir}: brought in commits from GitHub, with the items its merges brought in keyed anew (now at ${sha.slice(0, 7)})`);
|
|
397
|
+
return { kind: 'merged', keptEntries: 0 };
|
|
398
|
+
}
|
|
399
|
+
/** {@link buildRekey} on GitHub's commits from `head` to `tip`, or null when none needs a new key. */
|
|
400
|
+
function rekeyOf(ctx, { git, branchPath, branchRef }, head, tip, unchangedIn) {
|
|
401
|
+
return buildRekey(git, branchPath, {
|
|
402
|
+
from: head,
|
|
403
|
+
tip,
|
|
404
|
+
contentRoot: ctx.contentRoot,
|
|
405
|
+
timeoutMs: ctx.taskTimeoutMs,
|
|
406
|
+
message: `Key the items that merges on GitHub brought into ${branchRef}`,
|
|
407
|
+
...(unchangedIn && { unchangedIn }),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Whether the clone's saves (changes, deletions, new files) touch a path `from..to` changes: the
|
|
412
|
+
* clone can't move to `to` until they are committed, and that commit merges against `from`'s keys.
|
|
413
|
+
*/
|
|
414
|
+
async function savesMeet(git, from, to) {
|
|
415
|
+
const saved = new Set((await git.status([...SHARED_REPO_STATUS_ARGS])).files.flatMap((f) => f.from ? [f.path, f.from] : [f.path]));
|
|
416
|
+
const changed = (await git.raw(['diff-tree', '-r', '-z', '--name-only', '--end-of-options', from, to]))
|
|
417
|
+
.split('\0')
|
|
418
|
+
.filter(Boolean);
|
|
419
|
+
return changed.some((file) => saved.has(file));
|
|
420
|
+
}
|
|
421
|
+
/** A move of the clone refused for saves it would overwrite, as git refuses a changed file. */
|
|
422
|
+
class SavesInTheWay extends Error {
|
|
423
|
+
}
|
|
424
|
+
/** Whether `err` is a move refused for the clone's saves: git's own refusal, or {@link moveClone}'s. */
|
|
425
|
+
const overwritesSaves = (err) => err instanceof SavesInTheWay || /would be overwritten/.test(getErrorMessage(err));
|
|
296
426
|
/**
|
|
297
427
|
* Fast-forward the clone to `to`. The caches' markers move after it, as for any bulk mutation, and
|
|
298
428
|
* before it too: nothing repairs a marker lost after the move, as the next cycle sees a clone in
|
|
299
|
-
* step, and the earlier bump leaves only a read racing the move able to stamp the old tree.
|
|
429
|
+
* step, and the earlier bump leaves only a read racing the move able to stamp the old tree. Git
|
|
430
|
+
* refuses to move over a saved change to a file the commits change, but writes a file deleted in
|
|
431
|
+
* the working tree back: an entry the editor deleted, or a renamed entry's old path. So a move over
|
|
432
|
+
* such a deletion is refused here alike ({@link SavesInTheWay}), until the deletion is submitted.
|
|
300
433
|
*/
|
|
301
434
|
async function moveClone(git, branchPath, to) {
|
|
435
|
+
const deleted = (await git.status([...SHARED_REPO_STATUS_ARGS])).files.flatMap((f) => [
|
|
436
|
+
...(f.working_dir === 'D' || f.index === 'D' ? [f.path] : []),
|
|
437
|
+
...(f.from ? [f.from] : []),
|
|
438
|
+
]);
|
|
439
|
+
if (deleted.some((file) => !isCanopyInternalPath(file))) {
|
|
440
|
+
const changed = new Set((await git.raw(['diff-tree', '-r', '-z', '--name-only', '--end-of-options', 'HEAD', to]))
|
|
441
|
+
.split('\0')
|
|
442
|
+
.filter(Boolean));
|
|
443
|
+
const lost = deleted.filter((file) => changed.has(file) && !isCanopyInternalPath(file));
|
|
444
|
+
if (lost.length > 0) {
|
|
445
|
+
throw new SavesInTheWay(`saved deletions would be brought back: ${listPaths(lost)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
302
448
|
await invalidateBranchContentCaches(branchPath);
|
|
303
449
|
await git.raw(['merge', '--ff-only', '--end-of-options', to]);
|
|
304
450
|
await invalidateBranchContentCaches(branchPath);
|
|
305
451
|
}
|
|
306
452
|
/**
|
|
307
|
-
* `other` merged into the branch: built, published to `remote.git
|
|
308
|
-
*
|
|
309
|
-
* last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
|
|
310
|
-
* `
|
|
311
|
-
*
|
|
453
|
+
* `other` merged into the branch: built, recorded, published to `remote.git` and queued for GitHub
|
|
454
|
+
* unless it holds unsubmitted work (publish-decision.ts), and the clone moved to it, in that order,
|
|
455
|
+
* 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
|
|
456
|
+
* base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
|
|
457
|
+
* 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`.
|
|
312
458
|
*/
|
|
313
459
|
export async function mergeIntoBranch(ctx, args, merge) {
|
|
314
460
|
const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
|
|
315
|
-
const { head, other, message, fromGitHub,
|
|
316
|
-
|
|
461
|
+
const { head, other, message, fromGitHub, inReview } = merge;
|
|
462
|
+
// GitHub's merges come in under the field merge's keys, as a fast-forward takes them in, for the
|
|
463
|
+
// entries the merge takes from GitHub whole: one the branch changed too merges by git's keys.
|
|
464
|
+
const bases = fromGitHub
|
|
465
|
+
? (await git.raw(['merge-base', '--all', '--end-of-options', head, other]))
|
|
466
|
+
.split('\n')
|
|
467
|
+
.filter((line) => COMMIT_ID.test(line))
|
|
468
|
+
: [];
|
|
469
|
+
// With one merge base, as the field merge needs; none or several leave git's merge as it is.
|
|
470
|
+
const rekey = bases.length === 1 ? await rekeyOf(ctx, args, head, other, { head, since: bases[0] }) : null;
|
|
471
|
+
const into = rekey?.sha ?? other;
|
|
472
|
+
const plan = await planMerge(git, branchPath, {
|
|
317
473
|
head,
|
|
318
|
-
other,
|
|
319
|
-
|
|
474
|
+
other: into,
|
|
475
|
+
contentRoot: ctx.contentRoot,
|
|
320
476
|
timeoutMs: ctx.taskTimeoutMs,
|
|
321
477
|
});
|
|
322
|
-
|
|
478
|
+
// Nobody is at the editor of a branch in review to choose: a value or file kept over the other
|
|
479
|
+
// side's change would stand unseen in the pull request. Held before any commit is written, as a
|
|
480
|
+
// branch can stay held for days.
|
|
481
|
+
if (inReview &&
|
|
482
|
+
(plan.kind === 'unsettled' || plan.conflicts.length > 0 || plan.keptPaths.length > 0)) {
|
|
323
483
|
workerLog(` ${branchDir}: conflicts with ${other.slice(0, 7)}, so it stays as it is`);
|
|
324
484
|
return { kind: 'wait' };
|
|
325
485
|
}
|
|
326
|
-
if (
|
|
486
|
+
if (plan.kind === 'unsettled') {
|
|
327
487
|
return {
|
|
328
488
|
kind: 'blocked',
|
|
329
|
-
reason: `Git cannot merge ${other} into ${branchRef} by rule: ${
|
|
330
|
-
`${
|
|
489
|
+
reason: `Git cannot merge ${other} into ${branchRef} by rule: ${plan.conflicts.join(', ')}` +
|
|
490
|
+
`${plan.paths.length > 0 ? ` (${listPaths(plan.paths)})` : ''}. Nothing was changed. ` +
|
|
331
491
|
`To continue, a developer merges them by hand.`,
|
|
332
492
|
};
|
|
333
493
|
}
|
|
334
|
-
const {
|
|
494
|
+
const merged = await commitMerge(git, plan, { head, other: into, message });
|
|
495
|
+
// The re-key's remaps cover entries the branch left alone, the merge's those both changed.
|
|
496
|
+
const built = rekey ? { ...merged, keyRemaps: [...rekey.keyRemaps, ...merged.keyRemaps] } : merged;
|
|
497
|
+
const { sha, conflicts } = built;
|
|
335
498
|
const refused = await refuseIncoming(args, head, sha);
|
|
336
499
|
if (refused)
|
|
337
500
|
return refused;
|
|
338
501
|
if (lockLost())
|
|
339
502
|
return { kind: 'wait' };
|
|
340
|
-
|
|
503
|
+
// A field merge of the base settles what git could not, which no reviewer has seen either.
|
|
504
|
+
// Approving waits for the content-write lock the caller holds, so none lands after this check.
|
|
505
|
+
if (await changesWhatWasReviewed(ctx, args, head, sha))
|
|
341
506
|
await returnToReview(ctx, args);
|
|
342
|
-
}
|
|
343
507
|
const published = await readPublishedSha(ctx, branchRef);
|
|
344
508
|
const unpublishable = `This deployment's repository holds commits for ${branchRef} that neither this clone nor ` +
|
|
345
509
|
`GitHub has, so a merge of the two cannot be published. Nothing was changed. Compare the ` +
|
|
@@ -348,29 +512,49 @@ export async function mergeIntoBranch(ctx, args, merge) {
|
|
|
348
512
|
return { kind: 'blocked', reason: unpublishable };
|
|
349
513
|
}
|
|
350
514
|
// Recorded before the merge is visible anywhere: the next cycle only fast-forwards to it, and
|
|
351
|
-
// the conflicts cannot be read back from the merge.
|
|
352
|
-
|
|
353
|
-
|
|
515
|
+
// the conflicts cannot be read back from the merge. A merge that never lands leaves records the
|
|
516
|
+
// next cycle's reconcile turns back.
|
|
517
|
+
await recordMerge(ctx, args, built, { head, ...(fromGitHub && { also: other }), fromGitHub });
|
|
518
|
+
if (lockLost())
|
|
519
|
+
return { kind: 'wait' };
|
|
354
520
|
// Before `remote.git` holds it, as reconcile does: a crash past this point leaves GitHub's commit
|
|
355
521
|
// in `remote.git`, and a removal then has to read as one (`rewrittenFrom`).
|
|
356
522
|
if (fromGitHub)
|
|
357
523
|
await recordSeenOnGitHub(ctx, branchRef, other);
|
|
358
|
-
|
|
524
|
+
const publish = await decidePublish(git, { head, published });
|
|
525
|
+
if (publish.kind === 'publish') {
|
|
359
526
|
if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha))) {
|
|
360
527
|
return { kind: 'blocked', reason: unpublishable };
|
|
361
528
|
}
|
|
362
529
|
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
363
530
|
}
|
|
364
|
-
if (
|
|
365
|
-
|
|
531
|
+
else if (publish.kind === 'hold') {
|
|
532
|
+
workerLog(` ${branchDir}: holds work not yet submitted, so the merge stays unpublished`);
|
|
366
533
|
}
|
|
367
|
-
|
|
534
|
+
// Published and not yet in the clone: the next cycle brings it in, by `bringInGitHubCommits` or
|
|
535
|
+
// `catchUpWithPublished`.
|
|
536
|
+
if (lockLost())
|
|
537
|
+
return { kind: 'wait' };
|
|
538
|
+
try {
|
|
539
|
+
await moveClone(git, branchPath, sha);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
if (!overwritesSaves(err))
|
|
543
|
+
throw err;
|
|
544
|
+
// Callers move a clean tree. Published, the next cycle catches up with it once the saves are
|
|
545
|
+
// committed; held, reconcile turns it back.
|
|
546
|
+
workerLog(` ${branchDir}: local changes overlap the merge, which waits for a submit`);
|
|
547
|
+
return { kind: 'wait' };
|
|
548
|
+
}
|
|
549
|
+
// Landed: a failure here leaves the remaps and the stamp for the next cycle's reconcile.
|
|
550
|
+
await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
|
|
368
551
|
if (fromGitHub)
|
|
369
552
|
await recordGitHubTip(ctx, args, other);
|
|
370
|
-
|
|
371
|
-
|
|
553
|
+
const keptEntries = new Set(conflicts.map(({ contentId }) => contentId)).size;
|
|
554
|
+
workerLog(keptEntries > 0
|
|
555
|
+
? ` ${branchDir}: merged ${other.slice(0, 7)} (${keptEntries} entr${keptEntries === 1 ? 'y' : 'ies'} with values for a person to choose: ${listPaths(conflicts.map(({ path }) => path))})`
|
|
372
556
|
: ` ${branchDir}: merged ${other.slice(0, 7)}`);
|
|
373
|
-
return { kind: 'merged' };
|
|
557
|
+
return { kind: 'merged', keptEntries };
|
|
374
558
|
}
|
|
375
559
|
/**
|
|
376
560
|
* Whether this clone holds `sha`, on git's word for it. A killed `git` answers nothing at all,
|
|
@@ -396,55 +580,263 @@ async function holdsCommit(git, sha) {
|
|
|
396
580
|
* failure throws, since it says nothing about either commit, and the caller's catch retries next
|
|
397
581
|
* cycle: read as no, it would report a rewrite that never happened.
|
|
398
582
|
*/
|
|
399
|
-
async function contains(git, commit, ancestor) {
|
|
583
|
+
export async function contains(git, commit, ancestor) {
|
|
400
584
|
if (!(await holdsCommit(git, commit)) || !(await holdsCommit(git, ancestor)))
|
|
401
585
|
return false;
|
|
402
586
|
return (await commitsApart(git, ancestor, commit)).onlyA === 0;
|
|
403
587
|
}
|
|
404
588
|
/**
|
|
405
|
-
* Whether moving an approved branch
|
|
406
|
-
*
|
|
407
|
-
* it was reviewed.
|
|
408
|
-
*
|
|
589
|
+
* Whether moving an approved branch to `to` changes what was approved: yes unless `to` is exactly
|
|
590
|
+
* the approved commit with the base up to where `to` has it, which leaves the pull request's diff
|
|
591
|
+
* as it was reviewed. The approved commit is the one the approval recorded, so clean merges since
|
|
592
|
+
* it are judged together; `from`, the head before this move, stands in for an approval that
|
|
593
|
+
* recorded none. A recorded commit this clone doesn't hold counts as a change, as does a
|
|
594
|
+
* hand-made change inside a merge commit. Read fresh, so an approval given since the cycle's
|
|
595
|
+
* start counts too.
|
|
409
596
|
*/
|
|
410
597
|
async function changesWhatWasReviewed(ctx, { git, branchPath }, from, to) {
|
|
411
598
|
const fresh = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
|
|
412
599
|
if (fresh?.status !== 'approved')
|
|
413
600
|
return false;
|
|
601
|
+
const approved = fresh.review?.commit ?? from;
|
|
602
|
+
if (!(await holdsCommit(git, approved)))
|
|
603
|
+
return true;
|
|
414
604
|
await fetchFromRemoteGit(git, ctx.remoteGitPath, fresh.baseBranch ?? ctx.baseBranch);
|
|
415
605
|
const base = (await git.revparse(['FETCH_HEAD'])).trim();
|
|
416
606
|
const forkPoint = (await git.raw(['merge-base', '--end-of-options', to, base])).trim();
|
|
417
|
-
return forkPoint === '' || !(await isCleanMergeOf(git, to,
|
|
607
|
+
return forkPoint === '' || !(await isCleanMergeOf(git, to, approved, forkPoint, ctx.contentRoot));
|
|
418
608
|
}
|
|
419
609
|
/**
|
|
420
|
-
* Send an approved branch back to review and clear the verdict from the PR body.
|
|
421
|
-
* moves,
|
|
422
|
-
* to judge again rather than an approval standing over them.
|
|
610
|
+
* Send an approved branch back to review and clear the verdict from the PR body. Called before the
|
|
611
|
+
* clone moves, where a failed save throws, so a crash or a failure leaves the commits for the next
|
|
612
|
+
* cycle to judge again rather than an approval standing over them.
|
|
423
613
|
*/
|
|
424
614
|
export async function returnToReview(ctx, { branchPath, branchDir, branchRef, metaFile }) {
|
|
425
615
|
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { status: 'submitted', review: undefined } }, (current) => current?.branch.name === branchRef && current.branch.status === 'approved');
|
|
426
616
|
if (saved === null)
|
|
427
617
|
return;
|
|
428
|
-
workerLog(` ${branchDir}: back in review, as
|
|
618
|
+
workerLog(` ${branchDir}: back in review, as what was approved changed`);
|
|
429
619
|
await enqueueReviewBlockSync(ctx, branchRef, metaFile);
|
|
430
620
|
}
|
|
431
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Record what a merge left for a person in merge-conflicts.json, with the item keys it changed for
|
|
623
|
+
* the branch's comment threads to take once it lands and the merge as `landing`, and the content
|
|
624
|
+
* IDs it names in `branch.json` `conflictFiles`. The record is first settled against `head`, which
|
|
625
|
+
* the merge builds on, so a record of a merge that never landed can't carry into this one. Before
|
|
626
|
+
* the merge is visible: one that never lands leaves records the next
|
|
627
|
+
* {@link reconcileMergeConflicts} turns back. A merge `also` holds, GitHub's tip the merge takes in,
|
|
628
|
+
* is landing rather than gone. An earlier landing either commit holds is published, and this one
|
|
629
|
+
* replaces it: what it kept over GitHub's commits is noted for the pull request body now.
|
|
630
|
+
*/
|
|
631
|
+
async function recordMerge(ctx, args, built, merge) {
|
|
632
|
+
const { git, branchPath, branchRef } = args;
|
|
633
|
+
const { sha, conflicts, keptPaths, keyRemaps, paths } = built;
|
|
634
|
+
const inHead = await settleRecord(git, branchPath, merge.head, merge.also);
|
|
635
|
+
const earlier = (await new MergeConflictStore(branchPath).read())?.landing;
|
|
636
|
+
if (earlier && earlier.mergeSha !== sha && inHead(earlier.mergeSha)) {
|
|
637
|
+
await noteKeptOverGitHub(ctx, args, earlier.keptOverGitHub ?? []);
|
|
638
|
+
}
|
|
639
|
+
const state = await new MergeConflictStore(branchPath).update((current) => ({
|
|
640
|
+
entries: withFoundConflicts(withRemappedItemKeys(current ? withoutMergesGone(current.entries, inHead) : {}, keyRemaps, sha), conflicts, sha, paths),
|
|
641
|
+
remaps: [
|
|
642
|
+
...(current?.remaps ?? []),
|
|
643
|
+
...keyRemaps.map(({ entryPath, remaps }) => ({ mergeSha: sha, entryPath, remaps })),
|
|
644
|
+
],
|
|
645
|
+
landing: {
|
|
646
|
+
mergeSha: sha,
|
|
647
|
+
...(merge.fromGitHub && keptPaths.length > 0 && { keptOverGitHub: keptPaths }),
|
|
648
|
+
},
|
|
649
|
+
}));
|
|
650
|
+
if (state === null)
|
|
651
|
+
return;
|
|
652
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef ? { branch: conflictFilesOf(state.entries) } : null);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* After the clone moved to `sha`, the merge being landed: the remaps of every merge it holds apply,
|
|
656
|
+
* in the order recorded, the paths it kept over GitHub's commits go in `keptOverGitHub` for the PR
|
|
657
|
+
* body, and it is no longer landing. A remap that fails waits for the next cycle, and keeps nothing
|
|
658
|
+
* else from being noted.
|
|
659
|
+
*/
|
|
660
|
+
async function landed(ctx, { git, branchPath, branchRef, branchDir, metaFile, }, sha) {
|
|
661
|
+
const store = new MergeConflictStore(branchPath);
|
|
662
|
+
const current = await store.read();
|
|
663
|
+
if (current === null)
|
|
664
|
+
return;
|
|
665
|
+
const [inSha] = await mergesHeld(git, current, [sha]);
|
|
666
|
+
await applyRemaps(branchPath, (mergeSha) => inSha.has(mergeSha)).catch((err) => workerLogWarn(` ${branchDir}: comment threads keep their item keys for now: ${getErrorMessage(err)}`));
|
|
667
|
+
const landing = current.landing;
|
|
668
|
+
if (landing?.mergeSha !== sha)
|
|
669
|
+
return;
|
|
670
|
+
await noteKeptOverGitHub(ctx, { branchPath, branchRef, metaFile }, landing.keptOverGitHub ?? []);
|
|
671
|
+
await store.update((now) => {
|
|
672
|
+
if (now?.landing?.mergeSha !== sha)
|
|
673
|
+
return null;
|
|
674
|
+
const { landing: _done, ...rest } = now;
|
|
675
|
+
return rest;
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/** `kept`, paths a merge kept over GitHub's commits, in `keptOverGitHub` for the PR body. */
|
|
679
|
+
async function noteKeptOverGitHub(ctx, { branchPath, branchRef, metaFile, }, kept) {
|
|
680
|
+
if (kept.length === 0)
|
|
681
|
+
return;
|
|
434
682
|
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
|
|
435
683
|
? {
|
|
436
684
|
branch: {
|
|
437
|
-
...
|
|
438
|
-
...(fromGitHub
|
|
439
|
-
? {
|
|
440
|
-
keptOverGitHub: [
|
|
441
|
-
...new Set([...(existing.branch.keptOverGitHub ?? []), ...conflicted]),
|
|
442
|
-
],
|
|
443
|
-
}
|
|
444
|
-
: {}),
|
|
685
|
+
keptOverGitHub: [...new Set([...(existing.branch.keptOverGitHub ?? []), ...kept])],
|
|
445
686
|
},
|
|
446
687
|
}
|
|
447
688
|
: null);
|
|
689
|
+
await enqueueReviewBlockSync(ctx, branchRef, metaFile);
|
|
690
|
+
}
|
|
691
|
+
/** Every merge a record names, its earlier versions' included. */
|
|
692
|
+
function mergesNamedBy(entry) {
|
|
693
|
+
return [
|
|
694
|
+
entry.mergeSha,
|
|
695
|
+
...(entry.fields ?? []).map((f) => f.mergeSha),
|
|
696
|
+
...(entry.previous ? mergesNamedBy(entry.previous) : []),
|
|
697
|
+
];
|
|
698
|
+
}
|
|
699
|
+
/** The merges merge-conflicts.json names, and which of them each of `tips` holds. */
|
|
700
|
+
async function mergesHeld(git, state, tips) {
|
|
701
|
+
const shas = new Set([
|
|
702
|
+
...Object.values(state.entries).flatMap(mergesNamedBy),
|
|
703
|
+
...(state.remaps ?? []).map(({ mergeSha }) => mergeSha),
|
|
704
|
+
...(state.landing ? [state.landing.mergeSha] : []),
|
|
705
|
+
]);
|
|
706
|
+
const held = tips.map(() => new Set());
|
|
707
|
+
for (const sha of shas) {
|
|
708
|
+
for (const [n, tip] of tips.entries()) {
|
|
709
|
+
if (await contains(git, tip, sha))
|
|
710
|
+
held[n].add(sha);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return held;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* The record as `head` has it, or `also` with it: comment threads take the item keys of merges
|
|
717
|
+
* `head` holds, the remaps of merges neither holds go, and records changed by merges neither holds
|
|
718
|
+
* give back the ones they replaced. Resolves whether either holds a merge the record named then; a
|
|
719
|
+
* merge recorded since counts as held, as nothing settled it.
|
|
720
|
+
*/
|
|
721
|
+
async function settleRecord(git, branchPath, head, also) {
|
|
722
|
+
const store = new MergeConflictStore(branchPath);
|
|
723
|
+
const current = await store.read();
|
|
724
|
+
if (current === null)
|
|
725
|
+
return () => true;
|
|
726
|
+
const [held, alsoHeld] = await mergesHeld(git, current, also ? [head, also] : [head]);
|
|
727
|
+
const known = new Set([
|
|
728
|
+
...Object.values(current.entries).flatMap(mergesNamedBy),
|
|
729
|
+
...(current.remaps ?? []).map(({ mergeSha }) => mergeSha),
|
|
730
|
+
]);
|
|
731
|
+
const inHead = (sha) => held.has(sha) || !known.has(sha);
|
|
732
|
+
const kept = (sha) => inHead(sha) || (alsoHeld?.has(sha) ?? false);
|
|
733
|
+
// Threads move only to merges the clone holds; the rest wait, unless neither commit holds them.
|
|
734
|
+
await applyRemaps(branchPath, inHead, (sha) => !kept(sha)).catch((err) => workerLogWarn(` comment threads keep their item keys for now: ${getErrorMessage(err)}`));
|
|
735
|
+
return kept;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Point the branch's comment threads at the item keys the merges in `landed` gave their items, in
|
|
739
|
+
* the order they were recorded, then drop those remaps from the record, and those `gone` names
|
|
740
|
+
* unapplied.
|
|
741
|
+
*/
|
|
742
|
+
async function applyRemaps(branchPath, landed, gone = () => false) {
|
|
743
|
+
const store = new MergeConflictStore(branchPath);
|
|
744
|
+
const pending = (await store.read())?.remaps ?? [];
|
|
745
|
+
const due = pending.filter(({ mergeSha }) => landed(mergeSha));
|
|
746
|
+
if (due.length === 0 && !pending.some(({ mergeSha }) => gone(mergeSha)))
|
|
747
|
+
return;
|
|
748
|
+
const comments = new CommentStore(branchPath);
|
|
749
|
+
for (const { entryPath, remaps } of due)
|
|
750
|
+
await comments.remapItemKeys(entryPath, remaps);
|
|
751
|
+
await store.update((current) => current && {
|
|
752
|
+
...current,
|
|
753
|
+
remaps: (current.remaps ?? []).filter(({ mergeSha }) => !landed(mergeSha) && !gone(mergeSha)),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/** `branch.json` `conflictFiles` as `entries` has them, written only when the two differ. */
|
|
757
|
+
async function followRecord(ctx, branchPath, branchDir, branchRef, entries) {
|
|
758
|
+
const { conflictFiles, conflictStatus } = conflictFilesOf(entries);
|
|
759
|
+
const same = (ids) => JSON.stringify([...(ids ?? [])].sort()) === JSON.stringify(conflictFiles);
|
|
760
|
+
// Read first: the locked update costs every branch a lock each cycle, and nearly all agree.
|
|
761
|
+
const read = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
|
|
762
|
+
if (read === undefined || (branchRef !== null && read.name !== branchRef))
|
|
763
|
+
return;
|
|
764
|
+
if (same(read.conflictFiles))
|
|
765
|
+
return;
|
|
766
|
+
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing !== null &&
|
|
767
|
+
(branchRef === null || existing.branch.name === branchRef) &&
|
|
768
|
+
!same(existing.branch.conflictFiles)
|
|
769
|
+
? { branch: { conflictFiles, conflictStatus } }
|
|
770
|
+
: null, { keepUpdatedAt: true });
|
|
771
|
+
if (saved)
|
|
772
|
+
workerLog(` ${branchDir}: ${conflictFiles.length} entries with conflicts on record`);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Bring merge-conflicts.json in line with the branch's history: a record changed by a merge the
|
|
776
|
+
* branch never got gives back the one it replaced (`withoutMergesGone`), and `branch.json`
|
|
777
|
+
* `conflictFiles` follows the record when the two differ. A merge counts as held while the clone or
|
|
778
|
+
* `remote.git` holds it, since a clone behind its published merge catches up with it; comment
|
|
779
|
+
* threads take a merge's item keys only once the clone holds it. While either can't be read in the
|
|
780
|
+
* clone, nothing is judged. A record that does not parse is moved aside, so the branch's merges
|
|
781
|
+
* carry on with a new one. Each cycle, before the branch takes anything in. A clone with no record
|
|
782
|
+
* has no conflict open, so `conflictFiles` written before the record existed clears. Never throws.
|
|
783
|
+
*/
|
|
784
|
+
export async function reconcileMergeConflicts(ctx, { git, branchPath, branchDir, branchRef, }) {
|
|
785
|
+
try {
|
|
786
|
+
const store = new MergeConflictStore(branchPath);
|
|
787
|
+
let current;
|
|
788
|
+
try {
|
|
789
|
+
current = await store.read();
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
if (!(err instanceof MergeConflictsFileMalformedError))
|
|
793
|
+
throw err;
|
|
794
|
+
const aside = await store.quarantine();
|
|
795
|
+
if (aside !== null) {
|
|
796
|
+
workerLogWarn(` ${branchDir}: its conflict record does not parse; copied it to ${aside}`);
|
|
797
|
+
}
|
|
798
|
+
// The held record, or one another writer left in its place.
|
|
799
|
+
current = await store.read();
|
|
800
|
+
}
|
|
801
|
+
if (current === null) {
|
|
802
|
+
await followRecord(ctx, branchPath, branchDir, branchRef, {});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const published = branchRef === null ? null : await readPublishedSha(ctx, branchRef);
|
|
806
|
+
if (published !== null && !(await holdsCommit(git, published))) {
|
|
807
|
+
await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
|
|
808
|
+
}
|
|
809
|
+
const tips = ['HEAD', ...(published === null ? [] : [published])];
|
|
810
|
+
for (const tip of tips)
|
|
811
|
+
if (!(await holdsCommit(git, tip)))
|
|
812
|
+
return;
|
|
813
|
+
const [inHead, inPublished = new Set()] = await mergesHeld(git, current, tips);
|
|
814
|
+
const held = (sha) => inHead.has(sha) || inPublished.has(sha);
|
|
815
|
+
const entries = (await store.update((now) => {
|
|
816
|
+
if (now === null)
|
|
817
|
+
return null;
|
|
818
|
+
const kept = withoutMergesGone(now.entries, held);
|
|
819
|
+
const { landing, ...rest } = now;
|
|
820
|
+
// A clone catches up only with a merge `remote.git` holds; one it holds lands below.
|
|
821
|
+
// Without `remote.git`'s head to read, a published merge can't be told from a lost one.
|
|
822
|
+
const stays = landing !== undefined && (branchRef === null || held(landing.mergeSha));
|
|
823
|
+
if (JSON.stringify(kept) === JSON.stringify(now.entries) && (stays || !landing)) {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
return { ...rest, entries: kept, ...(stays && { landing }) };
|
|
827
|
+
}))?.entries ?? current.entries;
|
|
828
|
+
await followRecord(ctx, branchPath, branchDir, branchRef, entries);
|
|
829
|
+
await applyRemaps(branchPath, (sha) => inHead.has(sha), (sha) => !held(sha));
|
|
830
|
+
if (current.landing && inHead.has(current.landing.mergeSha) && branchRef !== null) {
|
|
831
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
832
|
+
if (metaFile) {
|
|
833
|
+
await landed(ctx, { git, branchPath, branchRef, branchDir, metaFile }, current.landing.mergeSha);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
|
|
839
|
+
}
|
|
448
840
|
}
|
|
449
841
|
/**
|
|
450
842
|
* Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a GitHub API failure as the gateway passes it on: an Octokit error in-process, or the
|
|
3
|
+
* wire's `GitHubRequestError`, both carrying `status` and the response headers that classify it.
|
|
4
|
+
*/
|
|
5
|
+
/** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
|
|
6
|
+
export declare function getHttpStatus(err: unknown): number | null;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a 403 is a GitHub rate-limit response rather than a plain permission
|
|
9
|
+
* denial. GitHub signals rate limiting on 403s three ways: the primary limit
|
|
10
|
+
* zeroes `x-ratelimit-remaining`, secondary limits often carry `retry-after`,
|
|
11
|
+
* and both produce a message containing "rate limit".
|
|
12
|
+
*/
|
|
13
|
+
export declare function isRateLimitSignal403(err: unknown): boolean;
|
|
14
|
+
/** A 403 that is GitHub denying the credential the operation, not a rate limit. */
|
|
15
|
+
export declare function isPermissionDenial(err: unknown): boolean;
|