canopycms 0.8.1 → 0.8.2
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 +102 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +28 -8
- package/dist/cli/generate-ai-content.js +13 -3
- package/dist/cli/init.js +2 -2
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
package/dist/worker/rebase.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
4
|
-
import { extractIdFromFilename } from '../content-id-index.js';
|
|
5
4
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
6
5
|
import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
|
|
7
|
-
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
8
|
-
import { ROOT_COLLECTION_ID } from '../paths/types.js';
|
|
9
6
|
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
10
7
|
import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
|
|
11
8
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
12
9
|
import { escapeForLog } from '../utils/escape-text.js';
|
|
13
|
-
import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
|
|
10
|
+
import { CANOPY_META_DIR, isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
|
|
14
11
|
import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
|
|
15
12
|
import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
|
|
13
|
+
import { publishesBranch } from './branch-policy.js';
|
|
14
|
+
import { commitsApart, conflictFilesToContentIds, recordConflicts } from './branch-merge.js';
|
|
15
|
+
import { bringInGitHubCommits, mergeIntoBranch, returnToReview, rewriteCleared, } from './github-commits.js';
|
|
16
16
|
import { workerLog, workerLogWarn } from './log.js';
|
|
17
17
|
import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoConfig, fetchFromRemoteGit, sharedRepoGit, } from './shared-repo-git.js';
|
|
18
18
|
/**
|
|
@@ -24,12 +24,12 @@ import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoCo
|
|
|
24
24
|
* markAsMerged API so both paths produce identical archived-branch metadata.
|
|
25
25
|
* closed-without-merge -> record pullRequestState only; an admin decides the
|
|
26
26
|
* workflow transition. Best-effort: a failure is logged, swallowed and retried
|
|
27
|
-
* next sync cycle.
|
|
27
|
+
* next sync cycle. Resolves GitHub's answer for a pull request not merged.
|
|
28
28
|
*/
|
|
29
29
|
export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
30
30
|
const prNumber = metaFile?.branch.pullRequestNumber;
|
|
31
31
|
if (!prNumber)
|
|
32
|
-
return;
|
|
32
|
+
return null;
|
|
33
33
|
// Recorded only on a branch.json that still names the branch this poll read: never a missing
|
|
34
34
|
// one, nor another branch's that shares the key.
|
|
35
35
|
const sameBranch = (current) => current?.branch.name === metaFile?.branch.name;
|
|
@@ -41,27 +41,31 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
41
41
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
42
42
|
// GitHub's actual merge time when available; buildMergedBranchUpdate
|
|
43
43
|
// falls back to "now" when merged_at is absent.
|
|
44
|
-
const saved = await meta.
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
const saved = await meta.update((current) => current !== null && sameBranch(current)
|
|
45
|
+
? {
|
|
46
|
+
branch: buildMergedBranchUpdate(current.branch, data.merged_at ? new Date(data.merged_at) : undefined),
|
|
47
|
+
}
|
|
48
|
+
: null);
|
|
47
49
|
if (saved)
|
|
48
50
|
workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
49
|
-
return;
|
|
51
|
+
return null;
|
|
50
52
|
}
|
|
51
53
|
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
52
54
|
// Re-load fresh, not the `metaFile` snapshot passed in: a concurrent Lambda
|
|
53
55
|
// write (an editor re-submitting) may have landed since it was taken.
|
|
54
56
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
55
57
|
if (currentMeta?.branch.pullRequestState === newState)
|
|
56
|
-
return;
|
|
58
|
+
return data;
|
|
57
59
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
58
60
|
const saved = await meta.saveIf({ branch: { pullRequestState: newState } }, sameBranch);
|
|
59
61
|
if (saved)
|
|
60
62
|
workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
|
|
63
|
+
return data;
|
|
61
64
|
}
|
|
62
65
|
catch (err) {
|
|
63
66
|
// Non-fatal: transient GitHub/network errors are retried next cycle.
|
|
64
67
|
workerLogWarn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
|
|
68
|
+
return null;
|
|
65
69
|
}
|
|
66
70
|
finally {
|
|
67
71
|
clearTimeout(timer);
|
|
@@ -114,6 +118,18 @@ async function recordRebaseFailure(ctx, branchPath, branchDir, message, branchRe
|
|
|
114
118
|
}
|
|
115
119
|
/** Safety limit against a rebase that never converges. */
|
|
116
120
|
const MAX_REBASE_ROUNDS = 50;
|
|
121
|
+
/**
|
|
122
|
+
* Abort a rebase. It moves the tree back under any snapshot a read resolved mid-replay, which the
|
|
123
|
+
* caches' markers must invalidate like any other working-tree move, however the abort ended.
|
|
124
|
+
*/
|
|
125
|
+
async function abortRebase(git, branchPath) {
|
|
126
|
+
try {
|
|
127
|
+
await git.rebase(['--abort']);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await invalidateBranchContentCaches(branchPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
117
133
|
/**
|
|
118
134
|
* Drive `git rebase` to a finished state, resolving each conflict in favour of
|
|
119
135
|
* the BRANCH's version, and report what happened.
|
|
@@ -253,70 +269,6 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
|
|
|
253
269
|
}
|
|
254
270
|
return { completed, conflictedFiles, failureReason };
|
|
255
271
|
}
|
|
256
|
-
/**
|
|
257
|
-
* Map the file paths a rebase resolved --theirs onto the ContentIds the editor
|
|
258
|
-
* shows as conflicted, deduplicated. IDs rather than paths because they are
|
|
259
|
-
* immutable: a later slug rename must not orphan a conflict marker. Paths that
|
|
260
|
-
* carry no recoverable ID are dropped rather than guessed at.
|
|
261
|
-
*
|
|
262
|
-
* Pure.
|
|
263
|
-
*/
|
|
264
|
-
function conflictFilesToContentIds(conflictedFiles, contentRoot) {
|
|
265
|
-
// Entry files carry their ID in the filename. A `.collection.json` has none
|
|
266
|
-
// of its own (extractIdFromFilename returns null for dot-prefixed files), so
|
|
267
|
-
// the ID comes from the parent directory; the root content directory has no
|
|
268
|
-
// embedded ID at all, so ROOT_COLLECTION_ID stands in -- but only for the
|
|
269
|
-
// configured contentRoot.
|
|
270
|
-
//
|
|
271
|
-
// Two different notions of "parent" are needed, and conflating them is the
|
|
272
|
-
// bug this guards against (see schema-store.ts's contentRootName doc comment
|
|
273
|
-
// for the same shape elsewhere):
|
|
274
|
-
// - `parentDir` (a basename) recovers a SUB-collection's own embedded ID,
|
|
275
|
-
// correct as a basename since a collection directory carries its ID in its
|
|
276
|
-
// own name, one path segment;
|
|
277
|
-
// - `parentPath` (the full normalized relative parent path) is what must be
|
|
278
|
-
// compared against `contentRoot`, which is documented (config/helpers.ts)
|
|
279
|
-
// as allowed to span multiple segments (e.g. "cms/content"). Comparing a
|
|
280
|
-
// basename against that full value is always false, silently dropping the
|
|
281
|
-
// root collection's conflict. Git reports POSIX paths and the configured
|
|
282
|
-
// value may use either separator, so both sides go through
|
|
283
|
-
// normalizeFilesystemPath first.
|
|
284
|
-
const normalizedContentRoot = normalizeFilesystemPath(contentRoot);
|
|
285
|
-
const conflictIds = [...new Set(conflictedFiles)]
|
|
286
|
-
.map((f) => {
|
|
287
|
-
const fileId = extractIdFromFilename(path.basename(f));
|
|
288
|
-
if (fileId)
|
|
289
|
-
return fileId;
|
|
290
|
-
const parentDir = path.basename(path.dirname(f));
|
|
291
|
-
const dirId = extractIdFromFilename(parentDir);
|
|
292
|
-
if (dirId)
|
|
293
|
-
return dirId;
|
|
294
|
-
// Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
|
|
295
|
-
// configured content root. Other unrecognized paths are filtered out.
|
|
296
|
-
const parentPath = normalizeFilesystemPath(path.dirname(f));
|
|
297
|
-
if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
|
|
298
|
-
return ROOT_COLLECTION_ID;
|
|
299
|
-
}
|
|
300
|
-
return null;
|
|
301
|
-
})
|
|
302
|
-
.filter((id) => id !== null);
|
|
303
|
-
return [...new Set(conflictIds)];
|
|
304
|
-
}
|
|
305
|
-
/**
|
|
306
|
-
* The conflict record after a rebase: the IDs already recorded plus this
|
|
307
|
-
* rebase's. A conflict means this branch's version replaced base's change to
|
|
308
|
-
* that entry, which a later clean rebase does not undo, so the record only
|
|
309
|
-
* grows until the branch merges (buildMergedBranchUpdate clears it).
|
|
310
|
-
*
|
|
311
|
-
* Pure.
|
|
312
|
-
*/
|
|
313
|
-
function recordConflicts(recorded, found) {
|
|
314
|
-
const conflictFiles = [...new Set([...(recorded ?? []), ...found])];
|
|
315
|
-
return {
|
|
316
|
-
conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
|
|
317
|
-
conflictFiles,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
272
|
/**
|
|
321
273
|
* [SYNC-H1] Carry a just-rebased history forward into `remote.git`, and queue
|
|
322
274
|
* the GitHub hop.
|
|
@@ -331,6 +283,7 @@ function recordConflicts(recorded, found) {
|
|
|
331
283
|
*/
|
|
332
284
|
async function carryForwardRewrittenHistory(ctx, args) {
|
|
333
285
|
const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead, gatewayPolicy } = args;
|
|
286
|
+
const { rebasedHead } = args;
|
|
334
287
|
if (publishedSha !== null) {
|
|
335
288
|
if (publishedSha === preRebaseHead) {
|
|
336
289
|
// ARMING GUARD: remote.git holds EXACTLY what this clone just rebased
|
|
@@ -353,7 +306,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
353
306
|
// reconcilePendingRewrite() completes on a later cycle. Pushing first
|
|
354
307
|
// would leave remote.git rewritten, GitHub stale and nothing recorded --
|
|
355
308
|
// unrecoverable, and diagnosed as a false "another deployment" collision.
|
|
356
|
-
if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha))) {
|
|
309
|
+
if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha, rebasedHead))) {
|
|
357
310
|
workerLogWarn(` ${branchDir}: rebased, but its branch.json is gone or names another branch than ` +
|
|
358
311
|
`${branchRef}, so the rewrite is not published`);
|
|
359
312
|
return;
|
|
@@ -383,7 +336,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
383
336
|
* that branch's cross-host content-write lock and runs a series of git
|
|
384
337
|
* subprocesses against a shared filesystem.
|
|
385
338
|
*/
|
|
386
|
-
export async function runRebaseCycle(ctx, gatewayPolicy) {
|
|
339
|
+
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
387
340
|
const rebased = [];
|
|
388
341
|
const skippedDirty = [];
|
|
389
342
|
const skippedLocked = [];
|
|
@@ -440,7 +393,7 @@ export async function runRebaseCycle(ctx, gatewayPolicy) {
|
|
|
440
393
|
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
441
394
|
continue;
|
|
442
395
|
}
|
|
443
|
-
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy);
|
|
396
|
+
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub);
|
|
444
397
|
if (outcome.kind === 'rebased')
|
|
445
398
|
rebased.push(branchDir);
|
|
446
399
|
else if (outcome.kind === 'skippedDirty')
|
|
@@ -508,7 +461,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
|
|
|
508
461
|
* content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
|
|
509
462
|
* Never throws, like rebaseOneBranch.
|
|
510
463
|
*/
|
|
511
|
-
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy) {
|
|
464
|
+
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
|
|
512
465
|
let hold;
|
|
513
466
|
try {
|
|
514
467
|
hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
|
|
@@ -528,12 +481,248 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy)
|
|
|
528
481
|
if (hold.kind === 'not-provisioned')
|
|
529
482
|
return { kind: 'none' };
|
|
530
483
|
try {
|
|
531
|
-
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy);
|
|
484
|
+
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
|
|
532
485
|
}
|
|
533
486
|
finally {
|
|
534
487
|
await releaseProvisionedWorkspace(hold.release, branchDir);
|
|
535
488
|
}
|
|
536
489
|
}
|
|
490
|
+
/** Clear a recorded failure and any recorded rewrite: the caller knows neither stands. */
|
|
491
|
+
async function clearRebaseFailure(ctx, branchPath, branchRef) {
|
|
492
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
|
|
493
|
+
? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
|
|
494
|
+
: null);
|
|
495
|
+
}
|
|
496
|
+
/** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
|
|
497
|
+
async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
|
|
498
|
+
// [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
|
|
499
|
+
// paths from commits.
|
|
500
|
+
const error = redactCredentials(reason);
|
|
501
|
+
workerLogWarn(` ${branchDir}: ${error}`);
|
|
502
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, error, branchRef);
|
|
503
|
+
return { kind: 'failed', error };
|
|
504
|
+
}
|
|
505
|
+
/** Whether `remote.git` holds commits for `branchRef` that this clone lacks. */
|
|
506
|
+
async function lacksPublishedCommits(ctx, git, branchRef) {
|
|
507
|
+
const published = await readPublishedSha(ctx, branchRef);
|
|
508
|
+
if (published === null)
|
|
509
|
+
return false;
|
|
510
|
+
await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
|
|
511
|
+
return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
|
|
515
|
+
* rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
|
|
516
|
+
* branch GitHub holds as it is costs no lock. Never throws, so the caller's merge poll still runs.
|
|
517
|
+
*/
|
|
518
|
+
async function bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
519
|
+
try {
|
|
520
|
+
const branchRef = await readCloneRef(branchPath);
|
|
521
|
+
if (branchRef === null)
|
|
522
|
+
return { kind: 'none' };
|
|
523
|
+
const gitHubTip = gitHub?.tips.get(branchRef);
|
|
524
|
+
// No branch on GitHub leaves nothing to bring in, and nothing a past failure can still be about.
|
|
525
|
+
if (gitHubTip === undefined) {
|
|
526
|
+
if (gitHub === undefined)
|
|
527
|
+
return { kind: 'none' };
|
|
528
|
+
const { rebaseFailure, rewrittenOnGitHub } = metaFile.branch;
|
|
529
|
+
if (rebaseFailure !== undefined || rewrittenOnGitHub !== undefined) {
|
|
530
|
+
await clearRebaseFailure(ctx, branchPath, branchRef);
|
|
531
|
+
}
|
|
532
|
+
return { kind: 'none' };
|
|
533
|
+
}
|
|
534
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
535
|
+
const branchGit = sharedRepoGit(branchPath, 'worktree');
|
|
536
|
+
const head = (await branchGit.revparse(['HEAD'])).trim();
|
|
537
|
+
if (head === gitHubTip &&
|
|
538
|
+
metaFile.branch.lastGitHubTip === gitHubTip &&
|
|
539
|
+
metaFile.branch.rebaseFailure === undefined &&
|
|
540
|
+
metaFile.branch.rewrittenOnGitHub === undefined &&
|
|
541
|
+
(await readPublishedSha(ctx, branchRef)) === gitHubTip) {
|
|
542
|
+
return { kind: 'none' };
|
|
543
|
+
}
|
|
544
|
+
return await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async (lockLost) => {
|
|
545
|
+
const status = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
|
|
546
|
+
const commitArgs = {
|
|
547
|
+
git: branchGit,
|
|
548
|
+
branchPath,
|
|
549
|
+
branchDir,
|
|
550
|
+
branchRef,
|
|
551
|
+
metaFile,
|
|
552
|
+
gitHub,
|
|
553
|
+
dirty: status.files.some((f) => !isCanopyInternalPath(f.path)),
|
|
554
|
+
gatewayPolicy,
|
|
555
|
+
lockLost,
|
|
556
|
+
};
|
|
557
|
+
const outcome = await bringInGitHubCommits(ctx, commitArgs);
|
|
558
|
+
if (outcome.kind === 'blocked') {
|
|
559
|
+
// What is on GitHub is not what was approved, and the verdict must not say otherwise.
|
|
560
|
+
await returnToReview(ctx, commitArgs);
|
|
561
|
+
return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
|
|
562
|
+
}
|
|
563
|
+
if (outcome.kind === 'in-step' && metaFile.branch.rebaseFailure !== undefined) {
|
|
564
|
+
await clearRebaseFailure(ctx, branchPath, branchRef);
|
|
565
|
+
}
|
|
566
|
+
return { kind: 'none' };
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const message = redactCredentials(getErrorMessage(err));
|
|
571
|
+
workerLogWarn(` ${branchDir}: could not bring in commits from GitHub: ${message}`);
|
|
572
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, message);
|
|
573
|
+
return { kind: 'failed', error: message };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* What GitHub's answer says keeps this pull request behind its base and needs a person, undefined
|
|
578
|
+
* for nothing, or null for no information: GitHub answers `unknown` until it has computed the
|
|
579
|
+
* state, which it redoes after every push to the base. A base other than the branch's own comes
|
|
580
|
+
* first, because withdrawing resolves nothing there: the worker rebases onto the recorded base.
|
|
581
|
+
* Pure.
|
|
582
|
+
*/
|
|
583
|
+
function pullRequestHoldOf(pullRequest, recordedBase) {
|
|
584
|
+
const { state, mergeable_state: mergeState, baseRef } = pullRequest;
|
|
585
|
+
if (state !== 'open')
|
|
586
|
+
return undefined;
|
|
587
|
+
if (mergeState === undefined || mergeState === 'unknown')
|
|
588
|
+
return null;
|
|
589
|
+
if (mergeState !== 'behind' && mergeState !== 'dirty')
|
|
590
|
+
return undefined;
|
|
591
|
+
if (baseRef !== undefined && baseRef !== recordedBase)
|
|
592
|
+
return { kind: 'retargeted', base: baseRef };
|
|
593
|
+
return mergeState === 'dirty' ? { kind: 'conflicts' } : undefined;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
|
|
597
|
+
* branch registry. It's bookkeeping, so it leaves `updatedAt`, which the card shows as an edit.
|
|
598
|
+
* Best-effort: a failure leaves the card as it was until the next cycle.
|
|
599
|
+
*/
|
|
600
|
+
async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
|
|
601
|
+
const { name, baseBranch, pullRequestHold, submittedAt } = metaFile.branch;
|
|
602
|
+
const hold = pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
|
|
603
|
+
if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
|
|
604
|
+
return;
|
|
605
|
+
try {
|
|
606
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
|
|
607
|
+
// A newer submit's pull request isn't the one measured.
|
|
608
|
+
(current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
|
|
609
|
+
? { branch: { pullRequestHold: hold } }
|
|
610
|
+
: null, { keepUpdatedAt: true });
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Merge the base into a submitted or approved branch whose open pull request GitHub reports
|
|
618
|
+
* `behind`, so it can merge. It keeps the approval and the PR body: the pull request's diff is what
|
|
619
|
+
* was reviewed. Only from a clone, `remote.git` and GitHub all at one commit, since anything else
|
|
620
|
+
* is work still on its way, and only for the base GitHub measured against: a pull request someone
|
|
621
|
+
* retargeted would gain another branch's commits under its approval. A branch with a failed sync
|
|
622
|
+
* is left for its next submit or a retry of the task, as a push here would clear that failure with
|
|
623
|
+
* the PR still stale. A conflict leaves the branch as it is; withdrawing it brings the rebase
|
|
624
|
+
* loop's rule into play, where an editor can see the result. A failure goes in the cycle's summary
|
|
625
|
+
* only: the step before this one clears `rebaseFailure` each cycle, so recording it would reset its
|
|
626
|
+
* age every time.
|
|
627
|
+
* Never throws.
|
|
628
|
+
*/
|
|
629
|
+
async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
630
|
+
try {
|
|
631
|
+
const branchRef = await readCloneRef(branchPath);
|
|
632
|
+
const gitHubTip = branchRef === null ? undefined : gitHub?.tips.get(branchRef);
|
|
633
|
+
if (branchRef === null || gitHubTip === undefined)
|
|
634
|
+
return { kind: 'none' };
|
|
635
|
+
if (ctx.ensureStatusReport().baseHold)
|
|
636
|
+
return { kind: 'none' };
|
|
637
|
+
if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef))
|
|
638
|
+
return { kind: 'none' };
|
|
639
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
640
|
+
const branchGit = sharedRepoGit(branchPath, 'worktree');
|
|
641
|
+
return await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async (lockLost) => {
|
|
642
|
+
// Read fresh: the poll above can have archived it, and a person withdrawn it.
|
|
643
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
644
|
+
const { status: state, name, baseBranch, syncStatus } = metaFile?.branch ?? {};
|
|
645
|
+
if (metaFile === null || name !== branchRef)
|
|
646
|
+
return { kind: 'none' };
|
|
647
|
+
if (state !== 'submitted' && state !== 'approved')
|
|
648
|
+
return { kind: 'none' };
|
|
649
|
+
if (syncStatus === 'sync-failed')
|
|
650
|
+
return { kind: 'none' };
|
|
651
|
+
const base = baseBranch ?? ctx.baseBranch;
|
|
652
|
+
if (pullRequest.baseRef !== base)
|
|
653
|
+
return { kind: 'none' };
|
|
654
|
+
const head = (await branchGit.revparse(['HEAD'])).trim();
|
|
655
|
+
if (head !== gitHubTip || (await readPublishedSha(ctx, branchRef)) !== head) {
|
|
656
|
+
return { kind: 'none' };
|
|
657
|
+
}
|
|
658
|
+
const status = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
|
|
659
|
+
if (status.files.some((f) => !isCanopyInternalPath(f.path)))
|
|
660
|
+
return { kind: 'none' };
|
|
661
|
+
await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, base);
|
|
662
|
+
const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
663
|
+
if ((await commitsApart(branchGit, head, baseTip)).onlyB === 0)
|
|
664
|
+
return { kind: 'none' };
|
|
665
|
+
const commitArgs = {
|
|
666
|
+
git: branchGit,
|
|
667
|
+
branchPath,
|
|
668
|
+
branchDir,
|
|
669
|
+
branchRef,
|
|
670
|
+
metaFile,
|
|
671
|
+
gitHub,
|
|
672
|
+
dirty: false,
|
|
673
|
+
gatewayPolicy,
|
|
674
|
+
lockLost,
|
|
675
|
+
};
|
|
676
|
+
const outcome = await mergeIntoBranch(ctx, commitArgs, {
|
|
677
|
+
head,
|
|
678
|
+
other: baseTip,
|
|
679
|
+
message: `Merge ${base} into ${branchRef}`,
|
|
680
|
+
fromGitHub: false,
|
|
681
|
+
stopOnConflict: true,
|
|
682
|
+
});
|
|
683
|
+
if (outcome.kind === 'blocked') {
|
|
684
|
+
const error = redactCredentials(outcome.reason);
|
|
685
|
+
workerLogWarn(` ${branchDir}: ${error}`);
|
|
686
|
+
return { kind: 'failed', error };
|
|
687
|
+
}
|
|
688
|
+
return outcome.kind === 'merged' ? { kind: 'rebased' } : { kind: 'none' };
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
const message = redactCredentials(getErrorMessage(err));
|
|
693
|
+
workerLogWarn(` ${branchDir}: could not update it from the base: ${message}`);
|
|
694
|
+
return { kind: 'failed', error: message };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Run `work` under a branch's content-write lock, taken zero-retry, as a save or a submit can hold
|
|
699
|
+
* it: busy is `skippedLocked`, retried next cycle. `work` gets whether either lock was lost.
|
|
700
|
+
*/
|
|
701
|
+
async function withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, work) {
|
|
702
|
+
let contentLockCompromised = false;
|
|
703
|
+
let releaseContentLock;
|
|
704
|
+
try {
|
|
705
|
+
releaseContentLock = await tryAcquireContentWriteLock(branchPath, (lockErr) => {
|
|
706
|
+
contentLockCompromised = true;
|
|
707
|
+
workerLogWarn(` Content-write lock lost for ${branchDir}: ${getErrorMessage(lockErr)}`);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (lockErr) {
|
|
711
|
+
if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
|
|
712
|
+
workerLog(` Skipping ${branchDir}: content write in progress (retrying next cycle)`);
|
|
713
|
+
return { kind: 'skippedLocked' };
|
|
714
|
+
}
|
|
715
|
+
throw lockErr;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
return await work(() => contentLockCompromised || isProvisioningLockCompromised());
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
await releaseContentLock().catch((releaseErr) => {
|
|
722
|
+
workerLogWarn(` Failed to release content-write lock for ${branchDir}: ${getErrorMessage(releaseErr)}`);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
537
726
|
/**
|
|
538
727
|
* Rebase ONE branch workspace onto the base branch, keeping the branch's
|
|
539
728
|
* version of every conflicting file.
|
|
@@ -546,7 +735,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy)
|
|
|
546
735
|
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
547
736
|
* rest of the cycle ([SYNC-M2]).
|
|
548
737
|
*/
|
|
549
|
-
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy) {
|
|
738
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
550
739
|
// Read only by the catch below. See the `rebased` rider on
|
|
551
740
|
// BranchRebaseOutcome.
|
|
552
741
|
let didRebase = false;
|
|
@@ -559,10 +748,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
559
748
|
// PR -- but do poll GitHub for the PR's resolution, since nothing else
|
|
560
749
|
// tells the worker a merge/close happened;
|
|
561
750
|
// - archived: already merged, with no PR left to poll.
|
|
562
|
-
if (branchStatus === 'submitted' || branchStatus === 'approved') {
|
|
751
|
+
if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
|
|
563
752
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
564
|
-
await
|
|
565
|
-
|
|
753
|
+
const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
|
|
754
|
+
const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
|
|
755
|
+
if (pullRequest !== null) {
|
|
756
|
+
await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest);
|
|
757
|
+
}
|
|
758
|
+
// GitHub reports `behind` only where up-to-date branches are required, so a repository
|
|
759
|
+
// that doesn't require them gets no commits or CI runs from this.
|
|
760
|
+
if (outcome.kind === 'none' &&
|
|
761
|
+
pullRequest?.state === 'open' &&
|
|
762
|
+
pullRequest.mergeable_state === 'behind') {
|
|
763
|
+
return updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub);
|
|
764
|
+
}
|
|
765
|
+
return outcome;
|
|
566
766
|
}
|
|
567
767
|
if (branchStatus === 'archived') {
|
|
568
768
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
@@ -667,7 +867,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
667
867
|
}
|
|
668
868
|
workerLogWarn(` ${branchDir}: found an interrupted rebase (this worker's own abandoned work) -- aborting it to recover the branch`);
|
|
669
869
|
try {
|
|
670
|
-
await branchGit
|
|
870
|
+
await abortRebase(branchGit, branchPath);
|
|
671
871
|
}
|
|
672
872
|
catch (abortErr) {
|
|
673
873
|
// Leave it for the next cycle rather than pressing on: every step
|
|
@@ -692,6 +892,38 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
692
892
|
dirtyCheck = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
|
|
693
893
|
}
|
|
694
894
|
const editorDirt = dirtyCheck.files.filter((f) => !isCanopyInternalPath(f.path));
|
|
895
|
+
// The clone's own ref name: branchDir is the sanitized DIRECTORY name and
|
|
896
|
+
// need not match it. A literal 'HEAD' means a detached clone (a crashed
|
|
897
|
+
// rebase can leave one), where nothing below can safely name a ref, so
|
|
898
|
+
// every publish path stays disarmed -- the safe direction.
|
|
899
|
+
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
900
|
+
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
901
|
+
const commitArgs = canPublish && metaFile !== null
|
|
902
|
+
? {
|
|
903
|
+
git: branchGit,
|
|
904
|
+
branchPath,
|
|
905
|
+
branchDir,
|
|
906
|
+
branchRef,
|
|
907
|
+
metaFile,
|
|
908
|
+
gitHub,
|
|
909
|
+
dirty: editorDirt.length > 0,
|
|
910
|
+
gatewayPolicy,
|
|
911
|
+
lockLost: lockCompromised,
|
|
912
|
+
}
|
|
913
|
+
: null;
|
|
914
|
+
// Before the dirty return: a fast-forward passes saves that touch other files.
|
|
915
|
+
let unpushed = false;
|
|
916
|
+
if (commitArgs !== null) {
|
|
917
|
+
const outcome = await bringInGitHubCommits(ctx, commitArgs);
|
|
918
|
+
if (outcome.kind === 'blocked') {
|
|
919
|
+
return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
|
|
920
|
+
}
|
|
921
|
+
if (outcome.kind === 'unpushed')
|
|
922
|
+
unpushed = true;
|
|
923
|
+
else if (outcome.kind !== 'in-step') {
|
|
924
|
+
return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
695
927
|
if (editorDirt.length > 0) {
|
|
696
928
|
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
697
929
|
return { kind: 'skippedDirty' };
|
|
@@ -716,12 +948,6 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
716
948
|
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
717
949
|
return { kind: 'failed', error: reason };
|
|
718
950
|
}
|
|
719
|
-
// The clone's own ref name: branchDir is the sanitized DIRECTORY name and
|
|
720
|
-
// need not match it. A literal 'HEAD' means a detached clone (a crashed
|
|
721
|
-
// rebase can leave one), where nothing below can safely name a ref, so
|
|
722
|
-
// every publish path stays disarmed -- the safe direction.
|
|
723
|
-
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
724
|
-
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
725
951
|
// [SYNC-H1] Self-heal: finish an interrupted publish even when this
|
|
726
952
|
// branch is not behind base. Every crash window in the arming sequence
|
|
727
953
|
// below leaves the marker set with the work unfinished, and this is what
|
|
@@ -762,15 +988,44 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
762
988
|
// base change this branch's version replaced, which stays true until
|
|
763
989
|
// the branch merges (see recordConflicts). A lingering rebaseFailure
|
|
764
990
|
// is cleared, or it sticks as a stale warning forever: nothing else
|
|
765
|
-
// touches a caught-up branch, so no other save site clears it.
|
|
991
|
+
// touches a caught-up branch, so no other save site clears it. One
|
|
992
|
+
// whose cause stands stays: remote.git still holds a commit this clone
|
|
993
|
+
// lacks, and the next submit is refused for it.
|
|
766
994
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
767
995
|
if (currentMeta?.branch.rebaseFailure === undefined) {
|
|
768
996
|
return { kind: 'none' };
|
|
769
997
|
}
|
|
998
|
+
if (canPublish && (await lacksPublishedCommits(ctx, branchGit, branchRef))) {
|
|
999
|
+
return { kind: 'none' };
|
|
1000
|
+
}
|
|
770
1001
|
const storedName = currentMeta.branch.name;
|
|
771
1002
|
await meta.saveIf({ branch: { rebaseFailure: undefined } }, (existing) => existing?.branch.name === storedName);
|
|
772
1003
|
return { kind: 'none' };
|
|
773
1004
|
}
|
|
1005
|
+
// `git rebase` drops merge commits and replays what they merged, so whatever one settled
|
|
1006
|
+
// (a kept version, a maintainer's conflict resolution) would be undone, then force-published.
|
|
1007
|
+
// A branch holding one merges the base instead. So does one whose commits touch canopycms
|
|
1008
|
+
// state: a replay writes their bytes over the live files, and a pair that adds then removes
|
|
1009
|
+
// a file deletes it.
|
|
1010
|
+
const aboveBase = `${fetchedBaseTip}..HEAD`;
|
|
1011
|
+
const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
|
|
1012
|
+
const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
|
|
1013
|
+
(await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
|
|
1014
|
+
if (commitArgs !== null && (cannotReplay || unpushed)) {
|
|
1015
|
+
const outcome = await mergeIntoBranch(ctx, commitArgs, {
|
|
1016
|
+
head: (await branchGit.revparse(['HEAD'])).trim(),
|
|
1017
|
+
other: fetchedBaseTip,
|
|
1018
|
+
message: `Merge ${ctx.baseBranch} into ${branchRef}`,
|
|
1019
|
+
fromGitHub: false,
|
|
1020
|
+
});
|
|
1021
|
+
if (outcome.kind === 'blocked') {
|
|
1022
|
+
return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
|
|
1023
|
+
}
|
|
1024
|
+
if (outcome.kind !== 'merged')
|
|
1025
|
+
return { kind: 'none' };
|
|
1026
|
+
await clearRebaseFailure(ctx, branchPath, branchRef);
|
|
1027
|
+
return { kind: 'rebased' };
|
|
1028
|
+
}
|
|
774
1029
|
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
775
1030
|
// Read BOTH sides before rewriting anything: the arming guard after the
|
|
776
1031
|
// rebase compares what remote.git published against what this clone is
|
|
@@ -807,7 +1062,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
807
1062
|
if (lockCompromised() && !completed) {
|
|
808
1063
|
workerLogWarn(` Skipping ${branchDir}: a lock it held was compromised mid-rebase (retrying next cycle)`);
|
|
809
1064
|
// No-op when no rebase is in progress; failure is expected there.
|
|
810
|
-
await branchGit
|
|
1065
|
+
await abortRebase(branchGit, branchPath).catch(() => { });
|
|
811
1066
|
return { kind: 'skippedLocked' };
|
|
812
1067
|
}
|
|
813
1068
|
if (lockCompromised()) {
|
|
@@ -820,7 +1075,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
820
1075
|
workerLogWarn(failureReason !== undefined
|
|
821
1076
|
? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
|
|
822
1077
|
: ` Rebase of ${branchDir} did not complete within ${MAX_REBASE_ROUNDS} rounds, aborting`);
|
|
823
|
-
await branchGit
|
|
1078
|
+
await abortRebase(branchGit, branchPath).catch(() => { });
|
|
824
1079
|
const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_REBASE_ROUNDS} rounds`;
|
|
825
1080
|
// [REDACT] failed[] folds into worker-status.json's lastGitSync.failed,
|
|
826
1081
|
// served to the browser -- failureReason can be an arbitrary git error
|
|
@@ -862,6 +1117,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
862
1117
|
branchRef,
|
|
863
1118
|
publishedSha,
|
|
864
1119
|
preRebaseHead,
|
|
1120
|
+
rebasedHead: (await branchGit.revparse(['HEAD'])).trim(),
|
|
865
1121
|
gatewayPolicy,
|
|
866
1122
|
});
|
|
867
1123
|
return { kind: 'rebased' };
|
|
@@ -885,7 +1141,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
885
1141
|
try {
|
|
886
1142
|
if (await isRebaseInProgress(branchPath)) {
|
|
887
1143
|
workerLogWarn(` ${branchDir}: rebase still in progress on exit -- aborting so the clone is not left wedged`);
|
|
888
|
-
await branchGit
|
|
1144
|
+
await abortRebase(branchGit, branchPath);
|
|
889
1145
|
}
|
|
890
1146
|
}
|
|
891
1147
|
catch (abortErr) {
|
|
@@ -15,7 +15,7 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
15
15
|
* here that reads branch metadata written by the rebase loop MUST re-read it
|
|
16
16
|
* rather than trust a snapshot.
|
|
17
17
|
*/
|
|
18
|
-
export type TaskRunnerContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'taskDir' | 'remoteGitPath' | 'contentBranchesPath' | 'taskTimeoutMs' | 'maxTasksPerCycle' | 'maxRetries' | 'log' | 'github' | 'branchWorkspacePath' | 'executeTask' | 'pushBranchToGitHub' | 'isRunning' | 'isDraining' | 'shutdownSignal' | 'ensureStatusReport'>;
|
|
18
|
+
export type TaskRunnerContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'taskDir' | 'remoteGitPath' | 'contentBranchesPath' | 'taskTimeoutMs' | 'gitSyncIntervalMs' | 'maxTasksPerCycle' | 'maxRetries' | 'log' | 'github' | 'branchWorkspacePath' | 'executeTask' | 'pushBranchToGitHub' | 'isRunning' | 'isDraining' | 'shutdownSignal' | 'ensureStatusReport'>;
|
|
19
19
|
/**
|
|
20
20
|
* An error inherent to the task itself (malformed payload, unknown action):
|
|
21
21
|
* retrying can never succeed, so the task should fail fast instead of
|