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
package/dist/worker/rebase.js
CHANGED
|
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
4
4
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
5
|
+
import { MergeConflictStore } from '../merge-conflicts.js';
|
|
5
6
|
import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
|
|
7
|
+
import { BASE_UPDATE_CLOCK_SKEW_MS, BASE_UPDATE_EARLY_RETRY_MS, isBaseUpdatePending, } from '../base-update.js';
|
|
6
8
|
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
7
9
|
import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
|
|
8
10
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
@@ -11,8 +13,8 @@ import { CANOPY_META_DIR, isCanopyInternalPath, isRebaseInProgress } from '../ut
|
|
|
11
13
|
import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
|
|
12
14
|
import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
|
|
13
15
|
import { publishesBranch } from './branch-policy.js';
|
|
14
|
-
import { commitsApart,
|
|
15
|
-
import { bringInGitHubCommits, mergeIntoBranch, returnToReview, rewriteCleared, } from './github-commits.js';
|
|
16
|
+
import { commitsApart, mergesAsGitDoes, planMerge } from './branch-merge.js';
|
|
17
|
+
import { bringInGitHubCommits, catchUpWithPublished, contains, isPublished, mergeIntoBranch, reconcileMergeConflicts, returnToReview, rewriteCleared, } from './github-commits.js';
|
|
16
18
|
import { workerLog, workerLogWarn } from './log.js';
|
|
17
19
|
import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoConfig, fetchFromRemoteGit, sharedRepoGit, } from './shared-repo-git.js';
|
|
18
20
|
/**
|
|
@@ -46,8 +48,12 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
46
48
|
branch: buildMergedBranchUpdate(current.branch, data.merged_at ? new Date(data.merged_at) : undefined),
|
|
47
49
|
}
|
|
48
50
|
: null);
|
|
49
|
-
if (saved)
|
|
51
|
+
if (saved) {
|
|
50
52
|
workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
53
|
+
// The base most likely moved with it, after this cycle's fetch: the next cycle brings
|
|
54
|
+
// that into the other branches without waiting out the interval.
|
|
55
|
+
ctx.requestBranchSync();
|
|
56
|
+
}
|
|
51
57
|
return null;
|
|
52
58
|
}
|
|
53
59
|
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
@@ -131,44 +137,44 @@ async function abortRebase(git, branchPath) {
|
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
/**
|
|
134
|
-
* Drive `git rebase` to a finished state,
|
|
135
|
-
*
|
|
140
|
+
* Drive `git rebase` to a finished state, stopping at the first conflict, and
|
|
141
|
+
* report what happened. A conflict is never resolved here: settling one by rule
|
|
142
|
+
* in a round's replay reverts base's change, and the merge the caller falls back
|
|
143
|
+
* to decides each value against the fork point.
|
|
136
144
|
*
|
|
137
|
-
* ABORT OWNERSHIP is split across
|
|
138
|
-
* the abort" would be wrong in a way that invites deleting a live one:
|
|
145
|
+
* ABORT OWNERSHIP is split across several sites, none redundant; "the caller
|
|
146
|
+
* owns the abort" would be wrong in a way that invites deleting a live one:
|
|
139
147
|
* - the **unexpected-error** exit aborts HERE, before breaking, so the caller's
|
|
140
148
|
* `!completed` abort is a caught no-op for that path;
|
|
149
|
+
* - the **conflict** exit aborts HERE too, and the caller's `conflicted` branch,
|
|
150
|
+
* which returns before `!completed`, calls `abortRebase` again: a no-op abort
|
|
151
|
+
* that tells the caches the replay moved the tree back;
|
|
141
152
|
* - the **lock-compromised** exit leaves the rebase in progress and is aborted
|
|
142
153
|
* by `rebaseOneBranch`'s own `lockCompromised() && !completed` branch,
|
|
143
154
|
* which returns `skippedLocked` before the `!completed` block below it is
|
|
144
155
|
* reached -- a distinct site that cannot be folded into that one;
|
|
145
|
-
* - the **
|
|
146
|
-
*
|
|
156
|
+
* - the **MAX_REBASE_ROUNDS** exit is the only one the caller's `!completed`
|
|
157
|
+
* abort actually covers;
|
|
147
158
|
* - a THROW (`branchGit.status()` is the first statement of the round catch, so
|
|
148
159
|
* a vanished `.git` or an EFS error escapes, as can the test hook beside it)
|
|
149
160
|
* unwinds to `rebaseOneBranch`'s outer catch, where the last-resort abort in
|
|
150
161
|
* its `finally` is the backstop -- not dead code.
|
|
151
162
|
* (A fifth abort, in the interrupted-rebase recovery, cleans up a PREVIOUS run.)
|
|
152
163
|
*
|
|
153
|
-
* What this function DOES guarantee is narrower: no per-file resolution failure
|
|
154
|
-
* escapes it. `checkout --theirs` on a MODIFY/DELETE conflict throws straight
|
|
155
|
-
* out of the round loop, skipping both abort sites and wedging the clone
|
|
156
|
-
* forever, so those set `failureReason` and break instead.
|
|
157
|
-
*
|
|
158
164
|
* `isLockCompromised` is re-read every round rather than passed as a value:
|
|
159
|
-
* [SYNC-C1] the content-write lock can be lost BETWEEN rounds, and
|
|
160
|
-
*
|
|
165
|
+
* [SYNC-C1] the content-write lock can be lost BETWEEN rounds, and `--skip` is
|
|
166
|
+
* as destructive as the initial rebase.
|
|
161
167
|
*/
|
|
162
168
|
async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLockCompromised) {
|
|
163
|
-
const conflictedFiles = [];
|
|
164
169
|
let nextAction = 'start';
|
|
165
170
|
let completed = false;
|
|
171
|
+
let conflicted = false;
|
|
166
172
|
// Captured only on the "unexpected error" exit below, for the failed-summary
|
|
167
173
|
// entry pushed at the `if (!completed)` check.
|
|
168
174
|
let failureReason;
|
|
169
175
|
for (let round = 0; round < MAX_REBASE_ROUNDS && !completed; round++) {
|
|
170
176
|
// Re-checked every round: the compromise can land between rounds, and
|
|
171
|
-
// `--
|
|
177
|
+
// `--skip` is as destructive as the initial rebase. Handled
|
|
172
178
|
// after the loop so it cannot be mistaken for the `!completed`
|
|
173
179
|
// rebase-FAILURE path below.
|
|
174
180
|
if (isLockCompromised())
|
|
@@ -179,95 +185,38 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
|
|
|
179
185
|
// remote-tracking ref.
|
|
180
186
|
await branchGit.rebase([fetchedBaseTip]);
|
|
181
187
|
}
|
|
182
|
-
else if (nextAction === 'continue') {
|
|
183
|
-
await branchGit.rebase(['--continue']);
|
|
184
|
-
}
|
|
185
188
|
else {
|
|
186
189
|
await branchGit.rebase(['--skip']);
|
|
187
190
|
}
|
|
188
191
|
completed = true;
|
|
189
192
|
}
|
|
190
193
|
catch (rebaseErr) {
|
|
191
|
-
nextAction = 'continue';
|
|
192
194
|
const st = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
|
|
193
195
|
if (st.conflicted.length > 0) {
|
|
194
196
|
await ctx.afterConflictDetectedForTesting();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
// U/D "deleted by them" -- the BRANCH deleted it, base modified it,
|
|
207
|
-
// and git leaves base's version in the tree. Keeping the branch
|
|
208
|
-
// version means honouring the delete: git rm.
|
|
209
|
-
// D/U "deleted by us" -- base deleted it, the BRANCH modified it,
|
|
210
|
-
// and git leaves the branch's version in the tree. Keeping the
|
|
211
|
-
// branch version means keeping it: git add.
|
|
212
|
-
//
|
|
213
|
-
// A per-file resolution that STILL fails routes into the `!completed`
|
|
214
|
-
// path below, which aborts and records, rather than escaping.
|
|
215
|
-
//
|
|
216
|
-
// `--sparse` on add/rm: a conflicted path outside a sparse clone's cone
|
|
217
|
-
// is materialised for the conflict, but git refuses to stage it without.
|
|
218
|
-
const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
|
|
219
|
-
let resolutionFailure;
|
|
220
|
-
for (const file of st.conflicted) {
|
|
221
|
-
const kind = conflictKind.get(file);
|
|
222
|
-
try {
|
|
223
|
-
if (kind === 'UD') {
|
|
224
|
-
await branchGit.raw(['rm', '-f', '--sparse', '--', file]);
|
|
225
|
-
}
|
|
226
|
-
else if (kind === 'DU') {
|
|
227
|
-
await branchGit.raw(['add', '--sparse', '--', file]);
|
|
228
|
-
}
|
|
229
|
-
else {
|
|
230
|
-
await branchGit.raw(['checkout', '--theirs', '--', file]);
|
|
231
|
-
await branchGit.raw(['add', '--sparse', '--', file]);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
catch (resolveErr) {
|
|
235
|
-
resolutionFailure =
|
|
236
|
-
`failed to resolve conflicted file '${file}' (status ${kind ?? '??'}): ` +
|
|
237
|
-
getErrorMessage(resolveErr);
|
|
238
|
-
break;
|
|
239
|
-
}
|
|
240
|
-
conflictedFiles.push(file);
|
|
241
|
-
}
|
|
242
|
-
if (resolutionFailure !== undefined) {
|
|
243
|
-
// Same exit shape as the "unexpected error" branch below: set
|
|
244
|
-
// failureReason and break, letting the `!completed` block do the
|
|
245
|
-
// single `rebase --abort` and record the failure once.
|
|
246
|
-
failureReason = resolutionFailure;
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
// nextAction stays 'continue'
|
|
197
|
+
workerLog(` ${branchDir}: a replayed commit conflicts, so the base comes in by a merge`);
|
|
198
|
+
conflicted = true;
|
|
199
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
|
|
203
|
+
if (msg.toLowerCase().includes('nothing to commit') ||
|
|
204
|
+
msg.toLowerCase().includes('apply --skip')) {
|
|
205
|
+
// A replayed commit base already holds: skip it.
|
|
206
|
+
nextAction = 'skip';
|
|
250
207
|
}
|
|
251
208
|
else {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Unexpected error — abort and leave the branch behind.
|
|
260
|
-
// conflictStatus/conflictFiles are deliberately not updated: the
|
|
261
|
-
// rebase did not complete, so it replaced nothing to record.
|
|
262
|
-
workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
263
|
-
failureReason = msg || 'Unknown error';
|
|
264
|
-
await branchGit.rebase(['--abort']).catch(() => { });
|
|
265
|
-
break;
|
|
266
|
-
}
|
|
209
|
+
// Unexpected error — abort and leave the branch behind.
|
|
210
|
+
// conflictStatus/conflictFiles are deliberately not updated: the
|
|
211
|
+
// rebase did not complete, so it replaced nothing to record.
|
|
212
|
+
workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
213
|
+
failureReason = msg || 'Unknown error';
|
|
214
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
215
|
+
break;
|
|
267
216
|
}
|
|
268
217
|
}
|
|
269
218
|
}
|
|
270
|
-
return { completed,
|
|
219
|
+
return { completed, conflicted, failureReason };
|
|
271
220
|
}
|
|
272
221
|
/**
|
|
273
222
|
* [SYNC-H1] Carry a just-rebased history forward into `remote.git`, and queue
|
|
@@ -311,9 +260,14 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
311
260
|
`${branchRef}, so the rewrite is not published`);
|
|
312
261
|
return;
|
|
313
262
|
}
|
|
314
|
-
|
|
263
|
+
const publish = await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha, rebasedHead);
|
|
264
|
+
if (publish.kind === 'published') {
|
|
315
265
|
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
316
266
|
}
|
|
267
|
+
else if (publish.kind === 'failed') {
|
|
268
|
+
const reason = redactCredentials(`this branch's rebased history could not be published into the deployment's repository: ${publish.message}`);
|
|
269
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
|
|
270
|
+
}
|
|
317
271
|
}
|
|
318
272
|
else {
|
|
319
273
|
const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
|
|
@@ -324,19 +278,108 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
324
278
|
}
|
|
325
279
|
}
|
|
326
280
|
}
|
|
281
|
+
/** What an outcome answers a request with; undefined when it settles nothing. */
|
|
282
|
+
function answerFor(outcome, probe) {
|
|
283
|
+
// `rebased` on a failure: the base came in, and only publishing the rewrite failed.
|
|
284
|
+
if (outcome.kind === 'rebased' || (outcome.kind === 'failed' && outcome.rebased)) {
|
|
285
|
+
return probe.keptEntries ? 'conflicts' : 'merged';
|
|
286
|
+
}
|
|
287
|
+
if (outcome.kind === 'none')
|
|
288
|
+
return probe.upToDate ? 'up-to-date' : undefined;
|
|
289
|
+
// A save since the request, which the sync never commits: the editor's Retry commits it.
|
|
290
|
+
if (outcome.kind === 'failed' || outcome.kind === 'skippedDirty')
|
|
291
|
+
return 'failed';
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
/** Whether a request is unanswered and recent enough for the worker to chase with early cycles. */
|
|
295
|
+
function worthAnotherCycle(branch) {
|
|
296
|
+
const age = Date.now() - Date.parse(branch.baseUpdateRequestedAt ?? '');
|
|
297
|
+
return (branch.status === 'editing' &&
|
|
298
|
+
isBaseUpdatePending(branch) &&
|
|
299
|
+
age >= -BASE_UPDATE_CLOCK_SKEW_MS &&
|
|
300
|
+
age < BASE_UPDATE_EARLY_RETRY_MS);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
|
|
304
|
+
* wait. Only from a fetch begun at or after the request, and only once the branch holds the base
|
|
305
|
+
* that fetch found: an earlier fetch may predate the changes asked for, and a base ref that didn't
|
|
306
|
+
* move (a held or failed update) isn't the latest. While the schema gate holds the base the request
|
|
307
|
+
* stands. Anything else that settles nothing, a lock or a GitHub step on its way among them, asks
|
|
308
|
+
* for another early cycle while the request is recent. Never throws.
|
|
309
|
+
*/
|
|
310
|
+
async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested) {
|
|
311
|
+
const { request } = probe;
|
|
312
|
+
if (!request) {
|
|
313
|
+
// Skipped before its request was read: a provisioning lock held elsewhere.
|
|
314
|
+
if (requested && outcome.kind === 'skippedLocked') {
|
|
315
|
+
const meta = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
|
|
316
|
+
if (meta && worthAnotherCycle(meta.branch))
|
|
317
|
+
ctx.requestBranchSync(branchDir);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!source || ctx.ensureStatusReport().baseHold)
|
|
322
|
+
return;
|
|
323
|
+
const askAgain = () => {
|
|
324
|
+
if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
|
|
325
|
+
ctx.requestBranchSync(request.branchRef);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
// The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
|
|
329
|
+
if (source.fetchedAt < Date.parse(request.requestedAt))
|
|
330
|
+
return askAgain();
|
|
331
|
+
const answer = answerFor(outcome, probe);
|
|
332
|
+
if (answer === undefined)
|
|
333
|
+
return askAgain();
|
|
334
|
+
if (answer !== 'failed' && source.baseTip !== undefined && probe.baseSha !== source.baseTip) {
|
|
335
|
+
return askAgain();
|
|
336
|
+
}
|
|
337
|
+
const lastBaseUpdate = {
|
|
338
|
+
at: new Date(source.fetchedAt).toISOString(),
|
|
339
|
+
outcome: answer,
|
|
340
|
+
...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
|
|
341
|
+
...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
|
|
342
|
+
};
|
|
343
|
+
try {
|
|
344
|
+
// A newer request, made after this fetch began, waits for the next cycle's answer.
|
|
345
|
+
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === request.branchRef &&
|
|
346
|
+
existing.branch.status === 'editing' &&
|
|
347
|
+
existing.branch.baseUpdateRequestedAt === request.requestedAt
|
|
348
|
+
? { branch: { lastBaseUpdate } }
|
|
349
|
+
: null, { keepUpdatedAt: true });
|
|
350
|
+
if (saved)
|
|
351
|
+
workerLog(` ${branchDir}: answered the request for the latest changes (${answer})`);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** A run with no GitHub publishes nothing there: an empty namespace is a gateway that pushes nothing. */
|
|
358
|
+
const NO_GITHUB_POLICY = {
|
|
359
|
+
configured: [],
|
|
360
|
+
githubDefault: null,
|
|
361
|
+
namespace: '',
|
|
362
|
+
legacy: [],
|
|
363
|
+
settingsBranch: null,
|
|
364
|
+
};
|
|
365
|
+
/** The GitHub fetch's answer source: when it began, and the base it found. */
|
|
366
|
+
const sourceOf = (ctx, gitHub) => ({
|
|
367
|
+
fetchedAt: gitHub.fetchedAt,
|
|
368
|
+
baseTip: gitHub.tips.get(ctx.baseBranch),
|
|
369
|
+
});
|
|
327
370
|
/**
|
|
328
371
|
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
329
372
|
*
|
|
330
|
-
* The per-branch work is {@link
|
|
331
|
-
* over `content-branches
|
|
332
|
-
*
|
|
333
|
-
*
|
|
373
|
+
* The per-branch work is {@link syncBranchDir}; what stays here is the walk
|
|
374
|
+
* over `content-branches/` and folding each branch's outcome into the summary.
|
|
375
|
+
* `source` answers requests for the latest changes; the GitHub fetch's by default. `local` is a
|
|
376
|
+
* run with no GitHub (see {@link syncBranchDir}).
|
|
334
377
|
*
|
|
335
378
|
* Deliberately sequential rather than concurrent: each branch's rebase holds
|
|
336
379
|
* that branch's cross-host content-write lock and runs a series of git
|
|
337
380
|
* subprocesses against a shared filesystem.
|
|
338
381
|
*/
|
|
339
|
-
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
382
|
+
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set(), source = gitHub && sourceOf(ctx, gitHub), local = false) {
|
|
340
383
|
const rebased = [];
|
|
341
384
|
const skippedDirty = [];
|
|
342
385
|
const skippedLocked = [];
|
|
@@ -348,6 +391,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
348
391
|
catch {
|
|
349
392
|
return { rebased, skippedDirty, skippedLocked, failed };
|
|
350
393
|
}
|
|
394
|
+
// Branches someone is waiting on go first; the sort is stable, so the rest keep their order.
|
|
395
|
+
branchDirs.sort((a, b) => Number(requested.has(b)) - Number(requested.has(a)));
|
|
351
396
|
for (const branchDir of branchDirs) {
|
|
352
397
|
// A draining worker finishes the branch it is rebasing and starts no other:
|
|
353
398
|
// a rebase killed mid-branch is recovered lossily on the next boot.
|
|
@@ -355,45 +400,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
355
400
|
workerLog(' Rebase cycle stopped: the worker is draining');
|
|
356
401
|
break;
|
|
357
402
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
// (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
|
|
361
|
-
// skip logs below don't fire for them every cycle; everything else still
|
|
362
|
-
// logs loudly on skip.
|
|
363
|
-
if (branchDir.startsWith('.') || branchDir === 'branches.json') {
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
367
|
-
const gitDir = path.join(branchPath, '.git');
|
|
368
|
-
try {
|
|
369
|
-
const stat = await fs.stat(gitDir);
|
|
370
|
-
if (!stat.isDirectory()) {
|
|
371
|
-
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
catch {
|
|
376
|
-
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
// The base branch's own clone is refreshed ff-only by
|
|
380
|
-
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
|
|
381
|
-
// this conflict-resolution loop could rewrite its history (the --theirs
|
|
382
|
-
// loop below) and stamp meaningless conflict metadata on it. Compare
|
|
383
|
-
// sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
|
|
384
|
-
// is raw.
|
|
385
|
-
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
386
|
-
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
// A settings branch is an orphan: rebasing it onto the base would give it content
|
|
390
|
-
// history. The API refuses to provision one, so a directory under that name is a
|
|
391
|
-
// leftover and is left alone.
|
|
392
|
-
if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
|
|
393
|
-
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
403
|
+
const outcome = await syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested.has(branchDir), {}, local);
|
|
404
|
+
if (outcome === null)
|
|
394
405
|
continue;
|
|
395
|
-
}
|
|
396
|
-
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub);
|
|
397
406
|
if (outcome.kind === 'rebased')
|
|
398
407
|
rebased.push(branchDir);
|
|
399
408
|
else if (outcome.kind === 'skippedDirty')
|
|
@@ -410,6 +419,82 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
410
419
|
}
|
|
411
420
|
return { rebased, skippedDirty, skippedLocked, failed };
|
|
412
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* One entry of `content-branches/`: the structural pre-filter (entries that are not branch
|
|
424
|
+
* workspaces at all, the base branch's own clone, a settings branch), then
|
|
425
|
+
* {@link holdAndRebaseOneBranch} and the answer to its request. Null for an entry skipped before
|
|
426
|
+
* any of that. Never throws. A `local` run has no GitHub: it publishes nothing there, and
|
|
427
|
+
* `remote.git` is where a branch's work is published.
|
|
428
|
+
*/
|
|
429
|
+
async function syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested, probe, local = false) {
|
|
430
|
+
// Known structural entries under content-branches/, not branch workspaces:
|
|
431
|
+
// branches.json (registry snapshot) and dot-prefixed entries
|
|
432
|
+
// (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
|
|
433
|
+
// skip logs below don't fire for them every cycle; everything else still
|
|
434
|
+
// logs loudly on skip.
|
|
435
|
+
if (branchDir.startsWith('.') || branchDir === 'branches.json')
|
|
436
|
+
return null;
|
|
437
|
+
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
438
|
+
const gitDir = path.join(branchPath, '.git');
|
|
439
|
+
try {
|
|
440
|
+
const stat = await fs.stat(gitDir);
|
|
441
|
+
if (!stat.isDirectory()) {
|
|
442
|
+
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
// The base branch's own clone is refreshed ff-only by
|
|
451
|
+
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
|
|
452
|
+
// this loop could rewrite its history (the rebase below) and stamp
|
|
453
|
+
// meaningless conflict metadata on it. Compare
|
|
454
|
+
// sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
|
|
455
|
+
// is raw.
|
|
456
|
+
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
457
|
+
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
// A settings branch is an orphan: rebasing it onto the base would give it content
|
|
461
|
+
// history. The API refuses to provision one, so a directory under that name is a
|
|
462
|
+
// leftover and is left alone.
|
|
463
|
+
if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
|
|
464
|
+
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, local ? NO_GITHUB_POLICY : gatewayPolicy, gitHub, probe, local);
|
|
468
|
+
await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested);
|
|
469
|
+
return outcome;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Bring the base into one branch now and answer its request for the latest changes, for a mode that
|
|
473
|
+
* runs no worker loop (operating-mode `baseUpdateRunsInRequest`). With no GitHub, the base is
|
|
474
|
+
* `remote.git`'s, so the answer is from this run's start. A run that answers nothing writes
|
|
475
|
+
* `failed`, since nothing else ever will: `busy` says a lock held it back, which a retry in a moment
|
|
476
|
+
* gets past. Never throws but for the metadata write of that fallback.
|
|
477
|
+
*/
|
|
478
|
+
export async function syncBranchNow(ctx, branchName) {
|
|
479
|
+
const startedAt = Date.now();
|
|
480
|
+
const branchDir = sanitizeBranchName(branchName);
|
|
481
|
+
const probe = {};
|
|
482
|
+
const outcome = await syncBranchDir(ctx, branchDir, undefined, undefined, { fetchedAt: startedAt }, true, probe, true);
|
|
483
|
+
const busy = outcome?.kind === 'skippedLocked';
|
|
484
|
+
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
485
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchName &&
|
|
486
|
+
existing.branch.status === 'editing' &&
|
|
487
|
+
existing.branch.baseUpdateRequestedAt !== undefined &&
|
|
488
|
+
Date.parse(existing.branch.baseUpdateRequestedAt) <= startedAt &&
|
|
489
|
+
isBaseUpdatePending(existing.branch)
|
|
490
|
+
? {
|
|
491
|
+
branch: {
|
|
492
|
+
lastBaseUpdate: { at: new Date(startedAt).toISOString(), outcome: 'failed' },
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
: null, { keepUpdatedAt: true });
|
|
496
|
+
return busy ? 'busy' : 'answered';
|
|
497
|
+
}
|
|
413
498
|
/**
|
|
414
499
|
* The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
|
|
415
500
|
* file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
|
|
@@ -461,7 +546,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
|
|
|
461
546
|
* content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
|
|
462
547
|
* Never throws, like rebaseOneBranch.
|
|
463
548
|
*/
|
|
464
|
-
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
|
|
549
|
+
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe, local) {
|
|
465
550
|
let hold;
|
|
466
551
|
try {
|
|
467
552
|
hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
|
|
@@ -481,18 +566,62 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
|
|
|
481
566
|
if (hold.kind === 'not-provisioned')
|
|
482
567
|
return { kind: 'none' };
|
|
483
568
|
try {
|
|
484
|
-
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
|
|
569
|
+
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe, local);
|
|
485
570
|
}
|
|
486
571
|
finally {
|
|
487
572
|
await releaseProvisionedWorkspace(hold.release, branchDir);
|
|
488
573
|
}
|
|
489
574
|
}
|
|
490
|
-
/**
|
|
491
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Clear a recorded failure and any recorded rewrite, the caller knowing neither stands, with
|
|
577
|
+
* `alsoClear` in the same write.
|
|
578
|
+
*/
|
|
579
|
+
async function clearRebaseFailure(ctx, branchPath, branchRef, alsoClear = {}) {
|
|
492
580
|
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
|
|
493
|
-
? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
|
|
581
|
+
? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch), ...alsoClear } }
|
|
494
582
|
: null);
|
|
495
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* {@link recordLatestChangesWaiting} for a branch the sync skips for its saved work: marked while it
|
|
586
|
+
* lacks the base `remote.git` holds. Read from `remote.git` rather than fetched, so the skip moves no
|
|
587
|
+
* ref. Best-effort, so the skip stays a skip.
|
|
588
|
+
*/
|
|
589
|
+
async function noteLatestChangesWaiting(ctx, git, branchPath, metaFile) {
|
|
590
|
+
if (metaFile.branch.status !== 'editing')
|
|
591
|
+
return;
|
|
592
|
+
let baseTip;
|
|
593
|
+
let behind;
|
|
594
|
+
try {
|
|
595
|
+
baseTip = await readPublishedSha(ctx, ctx.baseBranch);
|
|
596
|
+
behind = baseTip !== null && !(await contains(git, 'HEAD', baseTip));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
workerLogWarn(` Could not compare ${escapeForLog(metaFile.branch.name)} with the base: ${getErrorMessage(err)}`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await recordLatestChangesWaiting(ctx, branchPath, metaFile, behind ? (baseTip ?? undefined) : undefined);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Mark the branch `metaFile` names as lacking `baseSha`, the base commit the sync first left out, or
|
|
606
|
+
* clear the mark when undefined: the automatic sync never commits saved work, so a branch holding
|
|
607
|
+
* some waits for its author's "Get latest changes" (base-update.ts). Written only when the mark comes
|
|
608
|
+
* or goes, as every save regenerates the branch registry, and as bookkeeping, which leaves
|
|
609
|
+
* `updatedAt`. Best-effort: a failure leaves the hint as it was until the next cycle.
|
|
610
|
+
*/
|
|
611
|
+
async function recordLatestChangesWaiting(ctx, branchPath, metaFile, baseSha) {
|
|
612
|
+
const { name } = metaFile.branch;
|
|
613
|
+
const flips = (current) => (current === undefined) !== (baseSha === undefined);
|
|
614
|
+
if (!flips(metaFile.branch.latestChangesWaiting))
|
|
615
|
+
return;
|
|
616
|
+
try {
|
|
617
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === name && flips(current.branch.latestChangesWaiting)
|
|
618
|
+
? { branch: { latestChangesWaiting: baseSha } }
|
|
619
|
+
: null, { keepUpdatedAt: true });
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
workerLogWarn(` Could not record the latest changes waiting for ${escapeForLog(name)}: ${getErrorMessage(err)}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
496
625
|
/** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
|
|
497
626
|
async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
|
|
498
627
|
// [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
|
|
@@ -510,6 +639,32 @@ async function lacksPublishedCommits(ctx, git, branchRef) {
|
|
|
510
639
|
await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
|
|
511
640
|
return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
|
|
512
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* {@link reconcileMergeConflicts} for a submitted or approved branch, whose merges of GitHub's
|
|
644
|
+
* commits can leave records too, under the content-write lock as for a branch being edited. Only
|
|
645
|
+
* for a clone with a record, or with `conflictFiles` to clear, so others cost one stat. Never
|
|
646
|
+
* throws.
|
|
647
|
+
*/
|
|
648
|
+
async function reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised) {
|
|
649
|
+
try {
|
|
650
|
+
const flagged = (metaFile.branch.conflictFiles?.length ?? 0) > 0;
|
|
651
|
+
if (!flagged && !(await new MergeConflictStore(branchPath).exists()))
|
|
652
|
+
return;
|
|
653
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
654
|
+
await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async () => {
|
|
655
|
+
await reconcileMergeConflicts(ctx, {
|
|
656
|
+
git: sharedRepoGit(branchPath, 'worktree'),
|
|
657
|
+
branchPath,
|
|
658
|
+
branchDir,
|
|
659
|
+
branchRef: await readCloneRef(branchPath),
|
|
660
|
+
});
|
|
661
|
+
return { kind: 'none' };
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
513
668
|
/**
|
|
514
669
|
* {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
|
|
515
670
|
* rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
|
|
@@ -599,14 +754,20 @@ function pullRequestHoldOf(pullRequest, recordedBase) {
|
|
|
599
754
|
/**
|
|
600
755
|
* Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
|
|
601
756
|
* branch registry. It's bookkeeping, so it leaves `updatedAt`, which the card shows as an edit.
|
|
602
|
-
*
|
|
757
|
+
* `tookBase` says this cycle merged the base in, which GitHub measured the pull request before, so
|
|
758
|
+
* nothing holds it. Best-effort: a failure leaves the card as it was until the next cycle.
|
|
603
759
|
*/
|
|
604
|
-
async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
|
|
760
|
+
async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase) {
|
|
605
761
|
const { name, baseBranch, pullRequestHold, submittedAt } = metaFile.branch;
|
|
606
|
-
const hold = pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
|
|
607
|
-
if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
|
|
608
|
-
return;
|
|
609
762
|
try {
|
|
763
|
+
let hold = tookBase ? undefined : pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
|
|
764
|
+
// A conflict counts only on the commit the clone holds: one measured on an older head, as while
|
|
765
|
+
// the worker's own merge is on its way to GitHub, says nothing about the branch now.
|
|
766
|
+
if (hold?.kind === 'conflicts' && pullRequest.headSha !== (await cloneHead(branchPath))) {
|
|
767
|
+
hold = pullRequestHold?.kind === 'conflicts' ? null : undefined;
|
|
768
|
+
}
|
|
769
|
+
if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
|
|
770
|
+
return;
|
|
610
771
|
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
|
|
611
772
|
// A newer submit's pull request isn't the one measured.
|
|
612
773
|
(current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
|
|
@@ -617,17 +778,24 @@ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullR
|
|
|
617
778
|
workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
|
|
618
779
|
}
|
|
619
780
|
}
|
|
781
|
+
/** The commit a branch clone holds. */
|
|
782
|
+
async function cloneHead(branchPath) {
|
|
783
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
784
|
+
return (await sharedRepoGit(branchPath, 'worktree').revparse(['HEAD'])).trim();
|
|
785
|
+
}
|
|
620
786
|
/**
|
|
621
787
|
* Merge the base into a submitted or approved branch whose open pull request GitHub reports
|
|
622
|
-
* `behind`, so it can merge.
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
788
|
+
* `behind` or `dirty`, so it can merge. A submitted branch stays submitted: the pull request's diff
|
|
789
|
+
* is still the author's changes. An approved branch keeps its approval only for git's own clean
|
|
790
|
+
* merge; any merge the field merge settled sends it back to review (`mergeIntoBranch`). Only from a
|
|
791
|
+
* clone, `remote.git` and GitHub all at one commit, since anything else is work still on its way,
|
|
792
|
+
* and only for the base GitHub measured against: a pull request someone retargeted would gain
|
|
793
|
+
* another branch's commits under its approval. A branch with a failed sync is left for its next
|
|
794
|
+
* submit or a retry of the task, as a push here would clear that failure with the PR still stale. A
|
|
795
|
+
* merge that leaves a value or file for a person leaves the branch as it is (`mergeIntoBranch`'s
|
|
796
|
+
* `inReview`), and withdrawing it lets the merge for a branch being edited record the conflicts,
|
|
797
|
+
* where an editor can resolve them. A failure goes in the cycle's summary only: the step before
|
|
798
|
+
* this one clears `rebaseFailure` each cycle, so recording it would reset its age every time.
|
|
631
799
|
* Never throws.
|
|
632
800
|
*/
|
|
633
801
|
async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
@@ -682,7 +850,7 @@ async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequ
|
|
|
682
850
|
other: baseTip,
|
|
683
851
|
message: `Merge ${base} into ${branchRef}`,
|
|
684
852
|
fromGitHub: false,
|
|
685
|
-
|
|
853
|
+
inReview: true,
|
|
686
854
|
});
|
|
687
855
|
if (outcome.kind === 'blocked') {
|
|
688
856
|
const error = redactCredentials(outcome.reason);
|
|
@@ -728,8 +896,9 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
|
|
|
728
896
|
}
|
|
729
897
|
}
|
|
730
898
|
/**
|
|
731
|
-
*
|
|
732
|
-
*
|
|
899
|
+
* Bring the base branch into ONE branch workspace: by a rebase where nothing
|
|
900
|
+
* needs a person, else by a merge that leaves each value both sides changed
|
|
901
|
+
* differently for one.
|
|
733
902
|
*
|
|
734
903
|
* `branchDir` is the sanitized directory name and `branchPath` its absolute
|
|
735
904
|
* path; the caller has already established that this is a real branch
|
|
@@ -739,7 +908,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
|
|
|
739
908
|
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
740
909
|
* rest of the cycle ([SYNC-M2]).
|
|
741
910
|
*/
|
|
742
|
-
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
911
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}, local = false) {
|
|
743
912
|
// Read only by the catch below. See the `rebased` rider on
|
|
744
913
|
// BranchRebaseOutcome.
|
|
745
914
|
let didRebase = false;
|
|
@@ -747,6 +916,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
747
916
|
// Restored first: everything below this cycle compares `name`.
|
|
748
917
|
const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
|
|
749
918
|
const branchStatus = metaFile?.branch.status;
|
|
919
|
+
// Only an editing branch takes the base in, so only its request can be answered.
|
|
920
|
+
const requestedAt = metaFile?.branch.baseUpdateRequestedAt;
|
|
921
|
+
if (metaFile !== null &&
|
|
922
|
+
branchStatus === 'editing' &&
|
|
923
|
+
requestedAt !== undefined &&
|
|
924
|
+
isBaseUpdatePending(metaFile.branch)) {
|
|
925
|
+
probe.request = { branchRef: metaFile.branch.name, requestedAt };
|
|
926
|
+
}
|
|
750
927
|
// Skip branches that must not be mutated:
|
|
751
928
|
// - submitted/approved: in review, so never rewrite history under an active
|
|
752
929
|
// PR -- but do poll GitHub for the PR's resolution, since nothing else
|
|
@@ -754,19 +931,23 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
754
931
|
// - archived: finished, with no PR left to poll.
|
|
755
932
|
if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
|
|
756
933
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
934
|
+
// Submit committed the saved work; a withdraw lets the next skip mark it again.
|
|
935
|
+
await recordLatestChangesWaiting(ctx, branchPath, metaFile);
|
|
936
|
+
await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
|
|
757
937
|
const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
|
|
758
938
|
const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// GitHub reports `behind` only where up-to-date branches are required, so a repository
|
|
763
|
-
// that doesn't require them gets no commits or CI runs from this.
|
|
764
|
-
if (outcome.kind === 'none' &&
|
|
939
|
+
// GitHub reports `behind` only where up-to-date branches are required, and `dirty` only where
|
|
940
|
+
// git's merge conflicts, so a pull request that merges as it is gets no commits or CI runs.
|
|
941
|
+
const updated = outcome.kind === 'none' &&
|
|
765
942
|
pullRequest?.state === 'open' &&
|
|
766
|
-
pullRequest.mergeable_state === 'behind')
|
|
767
|
-
|
|
943
|
+
(pullRequest.mergeable_state === 'behind' || pullRequest.mergeable_state === 'dirty')
|
|
944
|
+
? await updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub)
|
|
945
|
+
: outcome;
|
|
946
|
+
if (pullRequest !== null) {
|
|
947
|
+
const tookBase = updated.kind === 'rebased';
|
|
948
|
+
await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase);
|
|
768
949
|
}
|
|
769
|
-
return
|
|
950
|
+
return updated;
|
|
770
951
|
}
|
|
771
952
|
if (branchStatus === 'archived') {
|
|
772
953
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
@@ -778,13 +959,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
778
959
|
// The pins also keep git non-interactive (core.editor=true): a rebase never waits on an editor.
|
|
779
960
|
const branchGit = sharedRepoGit(branchPath, 'worktree');
|
|
780
961
|
// [SYNC-C1] Take the branch's cross-host content-write lock BEFORE the
|
|
781
|
-
// dirty check, and hold it for the whole rebase. The dirty check
|
|
782
|
-
// check-then-act, and its residual window spans fetch, replay and
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// SUCCEEDS (nothing logs a failure at all), and `rebase --abort`
|
|
787
|
-
// hard-resets the tree. The editor already got its 200 either way.
|
|
962
|
+
// dirty check, and hold it for the whole rebase or merge. The dirty check
|
|
963
|
+
// alone is check-then-act, and its residual window spans fetch, replay and
|
|
964
|
+
// the merge's awaited git subprocesses on EFS. A save landing in there makes
|
|
965
|
+
// the next replayed commit fail, and the `rebase --abort` that follows
|
|
966
|
+
// hard-resets the tree, discarding it. The editor already got its 200.
|
|
788
967
|
//
|
|
789
968
|
// Zero-retry acquisition, deliberately: on contention this branch is
|
|
790
969
|
// skipped and retried next sync cycle (~5 min), the same principle as the
|
|
@@ -902,6 +1081,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
902
1081
|
// every publish path stays disarmed -- the safe direction.
|
|
903
1082
|
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
904
1083
|
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
1084
|
+
await reconcileMergeConflicts(ctx, {
|
|
1085
|
+
git: branchGit,
|
|
1086
|
+
branchPath,
|
|
1087
|
+
branchDir,
|
|
1088
|
+
branchRef: canPublish ? branchRef : null,
|
|
1089
|
+
});
|
|
905
1090
|
const commitArgs = canPublish && metaFile !== null
|
|
906
1091
|
? {
|
|
907
1092
|
git: branchGit,
|
|
@@ -915,6 +1100,27 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
915
1100
|
lockLost: lockCompromised,
|
|
916
1101
|
}
|
|
917
1102
|
: null;
|
|
1103
|
+
const skipIfDirty = async () => {
|
|
1104
|
+
if (editorDirt.length === 0)
|
|
1105
|
+
return { kind: 'none' };
|
|
1106
|
+
if (metaFile !== null)
|
|
1107
|
+
await noteLatestChangesWaiting(ctx, branchGit, branchPath, metaFile);
|
|
1108
|
+
return { kind: 'skippedDirty' };
|
|
1109
|
+
};
|
|
1110
|
+
// A merge of GitHub's commits whose result holds the base `remote.git` holds answers a request
|
|
1111
|
+
// for the latest changes as a merge of the base does, conflicts included.
|
|
1112
|
+
const tookInByGitHubMerge = async (keptEntries) => {
|
|
1113
|
+
if (editorDirt.length > 0)
|
|
1114
|
+
return await skipIfDirty();
|
|
1115
|
+
const baseTip = await readPublishedSha(ctx, ctx.baseBranch).catch(() => null);
|
|
1116
|
+
const holdsBase = baseTip !== null && (await contains(branchGit, 'HEAD', baseTip).catch(() => false));
|
|
1117
|
+
if (!holdsBase)
|
|
1118
|
+
return { kind: 'none' };
|
|
1119
|
+
probe.baseSha = baseTip;
|
|
1120
|
+
probe.keptEntries = keptEntries;
|
|
1121
|
+
await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
|
|
1122
|
+
return { kind: 'rebased' };
|
|
1123
|
+
};
|
|
918
1124
|
// Before the dirty return: a fast-forward passes saves that touch other files.
|
|
919
1125
|
let unpushed = false;
|
|
920
1126
|
if (commitArgs !== null) {
|
|
@@ -924,13 +1130,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
924
1130
|
}
|
|
925
1131
|
if (outcome.kind === 'unpushed')
|
|
926
1132
|
unpushed = true;
|
|
927
|
-
else if (outcome.kind
|
|
928
|
-
return
|
|
1133
|
+
else if (outcome.kind === 'merged')
|
|
1134
|
+
return await tookInByGitHubMerge(outcome.keptEntries);
|
|
1135
|
+
else if (outcome.kind !== 'in-step')
|
|
1136
|
+
return await skipIfDirty();
|
|
1137
|
+
// A merge of the base this worker published and never moved the clone to comes first.
|
|
1138
|
+
const caughtUp = await catchUpWithPublished(ctx, commitArgs);
|
|
1139
|
+
if (caughtUp?.kind === 'blocked') {
|
|
1140
|
+
return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
|
|
929
1141
|
}
|
|
1142
|
+
if (caughtUp)
|
|
1143
|
+
return await skipIfDirty();
|
|
930
1144
|
}
|
|
931
1145
|
if (editorDirt.length > 0) {
|
|
932
1146
|
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
933
|
-
return
|
|
1147
|
+
return await skipIfDirty();
|
|
934
1148
|
}
|
|
935
1149
|
// `git rebase` refuses to start over ANY modified tracked file, so
|
|
936
1150
|
// tracked canopycms state blocks. The worker never untracks it here:
|
|
@@ -952,21 +1166,32 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
952
1166
|
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
953
1167
|
return { kind: 'failed', error: reason };
|
|
954
1168
|
}
|
|
955
|
-
// [SYNC-H1] Self-heal: finish
|
|
956
|
-
// branch is not behind base
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
// unmarked branches (nearly all of them, every cycle) cost nothing.
|
|
1169
|
+
// [SYNC-H1] Self-heal: finish a publish that never left remote.git at
|
|
1170
|
+
// the marker's commit, even when this branch is not behind base -- a
|
|
1171
|
+
// crash in the arming sequence below, or a failed push. Gated on the
|
|
1172
|
+
// loop-top snapshot, so unmarked branches (nearly all of them, every
|
|
1173
|
+
// cycle) cost nothing.
|
|
961
1174
|
if (canPublish && metaFile?.branch.historyRewrittenFrom) {
|
|
962
|
-
await reconcilePendingRewrite(ctx, {
|
|
1175
|
+
const pending = await reconcilePendingRewrite(ctx, {
|
|
963
1176
|
branchPath,
|
|
964
1177
|
branchDir,
|
|
965
1178
|
branchRef,
|
|
966
1179
|
headSha: (await branchGit.revparse(['HEAD'])).trim(),
|
|
967
1180
|
marker: metaFile.branch.historyRewrittenFrom,
|
|
1181
|
+
target: metaFile.branch.historyRewrittenTo,
|
|
968
1182
|
gatewayPolicy,
|
|
969
1183
|
});
|
|
1184
|
+
// A rebase now would replace the rewrite remote.git still waits for, along with any commit
|
|
1185
|
+
// made on top of it since, which only a submit may publish.
|
|
1186
|
+
if (pending.kind === 'failed') {
|
|
1187
|
+
// [REDACT] failed[] is served to the browser via worker-status.json.
|
|
1188
|
+
const reason = redactCredentials(pending.reason);
|
|
1189
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
1190
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
|
|
1191
|
+
return { kind: 'failed', error: reason };
|
|
1192
|
+
}
|
|
1193
|
+
if (pending.kind === 'waiting')
|
|
1194
|
+
return { kind: 'none' };
|
|
970
1195
|
}
|
|
971
1196
|
await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
|
|
972
1197
|
// rev-list, not status.behind, which needs an upstream tracking branch
|
|
@@ -977,7 +1202,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
977
1202
|
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
978
1203
|
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
979
1204
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
1205
|
+
probe.baseSha = fetchedBaseTip;
|
|
980
1206
|
if (behindCount === 0) {
|
|
1207
|
+
probe.upToDate = true;
|
|
981
1208
|
// Already in sync -- the overwhelmingly common outcome per branch per
|
|
982
1209
|
// cycle, so skip the save entirely when metadata already reflects a
|
|
983
1210
|
// clean state. save() eager-regenerates the branch registry
|
|
@@ -988,14 +1215,15 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
988
1215
|
// concurrent editor-driven metadata change in that window is not
|
|
989
1216
|
// clobbered by a stale skip decision.
|
|
990
1217
|
//
|
|
991
|
-
// Catching up leaves the conflict record alone: it names
|
|
992
|
-
//
|
|
993
|
-
// the branch merges (see recordConflicts). A lingering rebaseFailure
|
|
1218
|
+
// Catching up leaves the conflict record alone: it names values a person
|
|
1219
|
+
// has yet to choose, which stays true until they do. A lingering rebaseFailure
|
|
994
1220
|
// is cleared, or it sticks as a stale warning forever: nothing else
|
|
995
1221
|
// touches a caught-up branch, so no other save site clears it. One
|
|
996
1222
|
// whose cause stands stays: remote.git still holds a commit this clone
|
|
997
1223
|
// lacks, and the next submit is refused for it.
|
|
998
1224
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1225
|
+
if (currentMeta !== null)
|
|
1226
|
+
await recordLatestChangesWaiting(ctx, branchPath, currentMeta);
|
|
999
1227
|
if (currentMeta?.branch.rebaseFailure === undefined) {
|
|
1000
1228
|
return { kind: 'none' };
|
|
1001
1229
|
}
|
|
@@ -1007,17 +1235,46 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1007
1235
|
return { kind: 'none' };
|
|
1008
1236
|
}
|
|
1009
1237
|
// `git rebase` drops merge commits and replays what they merged, so whatever one settled
|
|
1010
|
-
// (a
|
|
1011
|
-
// A branch holding one merges the base instead. So does one whose commits
|
|
1012
|
-
// state: a replay writes their bytes over the live files, and a pair that
|
|
1013
|
-
// a file deletes it.
|
|
1238
|
+
// (a value a person chose, a maintainer's conflict resolution) would be undone, then
|
|
1239
|
+
// force-published. A branch holding one merges the base instead. So does one whose commits
|
|
1240
|
+
// touch canopycms state: a replay writes their bytes over the live files, and a pair that
|
|
1241
|
+
// adds then removes a file deletes it.
|
|
1014
1242
|
const aboveBase = `${fetchedBaseTip}..HEAD`;
|
|
1015
1243
|
const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
|
|
1016
1244
|
const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
|
|
1017
1245
|
(await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
|
|
1018
|
-
|
|
1246
|
+
const head = (await branchGit.revparse(['HEAD'])).trim();
|
|
1247
|
+
// Commits on top of what this branch published, as "Get latest changes" makes from saved work
|
|
1248
|
+
// on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
|
|
1249
|
+
// published commit under them, which reads as a push this clone never saw. A merge keeps
|
|
1250
|
+
// them, unpublished until the submit pushes it (publish-decision.ts). A same-named ref at a
|
|
1251
|
+
// commit the base holds is no work of this branch.
|
|
1252
|
+
const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
|
|
1253
|
+
// With no GitHub, a commit of the branch's in remote.git is published however it got there.
|
|
1254
|
+
const aheadOfPublished = published !== null &&
|
|
1255
|
+
metaFile?.branch.name === branchRef &&
|
|
1256
|
+
(local ||
|
|
1257
|
+
(published !== head &&
|
|
1258
|
+
// A rewrite's tip is this branch's own publish, on GitHub or not.
|
|
1259
|
+
(isPublished(metaFile) || published === metaFile.branch.historyRewrittenTo))) &&
|
|
1260
|
+
(await contains(branchGit, head, published)) &&
|
|
1261
|
+
!(await contains(branchGit, fetchedBaseTip, published));
|
|
1262
|
+
/**
|
|
1263
|
+
* The base taken in by a merge (github-commits.ts), which decides each value against the fork
|
|
1264
|
+
* point and leaves what both sides changed differently for a person, rather than a rebase,
|
|
1265
|
+
* whose rounds can only settle a conflict by rule. A clone with no branch checked out, or no
|
|
1266
|
+
* `branch.json`, cannot name the ref the merge publishes, so it stays behind.
|
|
1267
|
+
*/
|
|
1268
|
+
const takeBaseByMerge = async () => {
|
|
1269
|
+
if (commitArgs === null) {
|
|
1270
|
+
const reason = `the base conflicts with this clone's work, and the clone has no branch checked out, ` +
|
|
1271
|
+
`or no branch.json, to merge it into`;
|
|
1272
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
1273
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
1274
|
+
return { kind: 'failed', error: reason };
|
|
1275
|
+
}
|
|
1019
1276
|
const outcome = await mergeIntoBranch(ctx, commitArgs, {
|
|
1020
|
-
head
|
|
1277
|
+
head,
|
|
1021
1278
|
other: fetchedBaseTip,
|
|
1022
1279
|
message: `Merge ${ctx.baseBranch} into ${branchRef}`,
|
|
1023
1280
|
fromGitHub: false,
|
|
@@ -1025,20 +1282,42 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1025
1282
|
if (outcome.kind === 'blocked') {
|
|
1026
1283
|
return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
|
|
1027
1284
|
}
|
|
1028
|
-
if (outcome.kind !== 'merged')
|
|
1029
|
-
|
|
1030
|
-
|
|
1285
|
+
if (outcome.kind !== 'merged') {
|
|
1286
|
+
if (!lockCompromised())
|
|
1287
|
+
return { kind: 'none' };
|
|
1288
|
+
workerLogWarn(` Skipping ${branchDir}: a lock it held was lost mid-merge (retrying next cycle)`);
|
|
1289
|
+
return { kind: 'skippedLocked' };
|
|
1290
|
+
}
|
|
1291
|
+
probe.keptEntries = outcome.keptEntries;
|
|
1292
|
+
await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
|
|
1031
1293
|
return { kind: 'rebased' };
|
|
1294
|
+
};
|
|
1295
|
+
if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
|
|
1296
|
+
return await takeBaseByMerge();
|
|
1297
|
+
}
|
|
1298
|
+
// A rebase only where it takes in exactly what a merge would, with nothing for a person: git
|
|
1299
|
+
// conflicts nowhere, and the field merge decides no file otherwise than git does.
|
|
1300
|
+
if (commitArgs !== null &&
|
|
1301
|
+
!mergesAsGitDoes(await planMerge(branchGit, branchPath, {
|
|
1302
|
+
head,
|
|
1303
|
+
other: fetchedBaseTip,
|
|
1304
|
+
contentRoot: ctx.contentRoot,
|
|
1305
|
+
timeoutMs: ctx.taskTimeoutMs,
|
|
1306
|
+
}))) {
|
|
1307
|
+
return await takeBaseByMerge();
|
|
1032
1308
|
}
|
|
1033
1309
|
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
1034
1310
|
// Read BOTH sides before rewriting anything: the arming guard after the
|
|
1035
1311
|
// rebase compares what remote.git published against what this clone is
|
|
1036
1312
|
// about to rebase away, and the clone's pre-rebase tip is exactly what
|
|
1037
1313
|
// the rebase makes unreadable.
|
|
1038
|
-
const preRebaseHead =
|
|
1039
|
-
const publishedSha =
|
|
1314
|
+
const preRebaseHead = head;
|
|
1315
|
+
const publishedSha = published;
|
|
1040
1316
|
await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
|
|
1041
|
-
const { completed,
|
|
1317
|
+
const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
|
|
1318
|
+
// The tip the rebase made, read before anything else awaits: past a lost lock a commit can
|
|
1319
|
+
// land on it, and only this tip may be published as the rewrite.
|
|
1320
|
+
const rebasedHead = (await branchGit.revparse(['HEAD'])).trim();
|
|
1042
1321
|
// Outside the round loop's try/catch, so a throwing test hook can
|
|
1043
1322
|
// never be misread as a rebase error.
|
|
1044
1323
|
if (completed)
|
|
@@ -1072,6 +1351,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1072
1351
|
if (lockCompromised()) {
|
|
1073
1352
|
workerLogWarn(` A lock held for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
|
|
1074
1353
|
}
|
|
1354
|
+
if (conflicted) {
|
|
1355
|
+
// The round loop aborted the rebase; this tells the caches the tree moved back.
|
|
1356
|
+
await abortRebase(branchGit, branchPath).catch(() => { });
|
|
1357
|
+
return await takeBaseByMerge();
|
|
1358
|
+
}
|
|
1075
1359
|
if (!completed) {
|
|
1076
1360
|
// failureReason is only set on the "unexpected error" break above;
|
|
1077
1361
|
// MAX_REBASE_ROUNDS exhaustion is a distinct exit with no message of
|
|
@@ -1098,19 +1382,16 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1098
1382
|
// and (via the on-disk generation marker) in the Lambda containers
|
|
1099
1383
|
// sharing this filesystem.
|
|
1100
1384
|
await invalidateBranchContentCaches(branchPath);
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
: ` Rebased ${branchDir} successfully`);
|
|
1106
|
-
// Recorded only on a branch.json that still names the branch this cycle read.
|
|
1385
|
+
probe.keptEntries = 0;
|
|
1386
|
+
workerLog(` Rebased ${branchDir} successfully`);
|
|
1387
|
+
// Recorded only on a branch.json that still names the branch this cycle read. The cycle
|
|
1388
|
+
// completed, so any prior failure record goes, whatever conflicts stay on record.
|
|
1107
1389
|
await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
|
|
1108
1390
|
? {
|
|
1109
1391
|
branch: {
|
|
1110
|
-
|
|
1111
|
-
// The cycle completed, so clear any prior failure record regardless
|
|
1112
|
-
// of the conflict outcome.
|
|
1392
|
+
conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
|
|
1113
1393
|
rebaseFailure: undefined,
|
|
1394
|
+
latestChangesWaiting: undefined,
|
|
1114
1395
|
},
|
|
1115
1396
|
}
|
|
1116
1397
|
: null);
|
|
@@ -1121,7 +1402,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1121
1402
|
branchRef,
|
|
1122
1403
|
publishedSha,
|
|
1123
1404
|
preRebaseHead,
|
|
1124
|
-
rebasedHead
|
|
1405
|
+
rebasedHead,
|
|
1125
1406
|
gatewayPolicy,
|
|
1126
1407
|
});
|
|
1127
1408
|
return { kind: 'rebased' };
|