canopycms 0.12.0 → 0.12.1
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 +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- 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 +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- 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 +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -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 +429 -52
- 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/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -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 +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -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 +90 -0
- package/dist/editor/conflict-cards.js +279 -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 +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- 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 +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- 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 +5 -0
- package/dist/git-manager.js +30 -0
- 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 +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -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/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- 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/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 +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -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
|
|
@@ -324,6 +273,83 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
324
273
|
}
|
|
325
274
|
}
|
|
326
275
|
}
|
|
276
|
+
/** What an outcome answers a request with; undefined when it settles nothing. */
|
|
277
|
+
function answerFor(outcome, probe) {
|
|
278
|
+
// `rebased` on a failure: the base came in, and only publishing the rewrite failed.
|
|
279
|
+
if (outcome.kind === 'rebased' || (outcome.kind === 'failed' && outcome.rebased)) {
|
|
280
|
+
return probe.keptEntries ? 'conflicts' : 'merged';
|
|
281
|
+
}
|
|
282
|
+
if (outcome.kind === 'none')
|
|
283
|
+
return probe.upToDate ? 'up-to-date' : undefined;
|
|
284
|
+
// A save since the request, which the sync never commits: the editor's Retry commits it.
|
|
285
|
+
if (outcome.kind === 'failed' || outcome.kind === 'skippedDirty')
|
|
286
|
+
return 'failed';
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
/** Whether a request is unanswered and recent enough for the worker to chase with early cycles. */
|
|
290
|
+
function worthAnotherCycle(branch) {
|
|
291
|
+
const age = Date.now() - Date.parse(branch.baseUpdateRequestedAt ?? '');
|
|
292
|
+
return (branch.status === 'editing' &&
|
|
293
|
+
isBaseUpdatePending(branch) &&
|
|
294
|
+
age >= -BASE_UPDATE_CLOCK_SKEW_MS &&
|
|
295
|
+
age < BASE_UPDATE_EARLY_RETRY_MS);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
|
|
299
|
+
* wait. Only from a cycle whose GitHub fetch began at or after the request, and only once the
|
|
300
|
+
* branch holds the base that fetch found: an earlier fetch may predate the changes asked for, and a
|
|
301
|
+
* base ref that didn't move (a held or failed update) isn't the latest. While the schema gate holds
|
|
302
|
+
* the base the request stands. Anything else that settles nothing, a lock or a GitHub step on its
|
|
303
|
+
* way among them, asks for another early cycle while the request is recent. Never throws.
|
|
304
|
+
*/
|
|
305
|
+
async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested) {
|
|
306
|
+
const { request } = probe;
|
|
307
|
+
if (!request) {
|
|
308
|
+
// Skipped before its request was read: a provisioning lock held elsewhere.
|
|
309
|
+
if (requested && outcome.kind === 'skippedLocked') {
|
|
310
|
+
const meta = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
|
|
311
|
+
if (meta && worthAnotherCycle(meta.branch))
|
|
312
|
+
ctx.requestBranchSync(branchDir);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!gitHub || ctx.ensureStatusReport().baseHold)
|
|
317
|
+
return;
|
|
318
|
+
const askAgain = () => {
|
|
319
|
+
if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
|
|
320
|
+
ctx.requestBranchSync(request.branchRef);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
// The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
|
|
324
|
+
if (gitHub.fetchedAt < Date.parse(request.requestedAt))
|
|
325
|
+
return askAgain();
|
|
326
|
+
const answer = answerFor(outcome, probe);
|
|
327
|
+
if (answer === undefined)
|
|
328
|
+
return askAgain();
|
|
329
|
+
const gitHubBase = gitHub.tips.get(ctx.baseBranch);
|
|
330
|
+
if (answer !== 'failed' && gitHubBase !== undefined && probe.baseSha !== gitHubBase) {
|
|
331
|
+
return askAgain();
|
|
332
|
+
}
|
|
333
|
+
const lastBaseUpdate = {
|
|
334
|
+
at: new Date(gitHub.fetchedAt).toISOString(),
|
|
335
|
+
outcome: answer,
|
|
336
|
+
...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
|
|
337
|
+
...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
|
|
338
|
+
};
|
|
339
|
+
try {
|
|
340
|
+
// A newer request, made after this fetch began, waits for the next cycle's answer.
|
|
341
|
+
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === request.branchRef &&
|
|
342
|
+
existing.branch.status === 'editing' &&
|
|
343
|
+
existing.branch.baseUpdateRequestedAt === request.requestedAt
|
|
344
|
+
? { branch: { lastBaseUpdate } }
|
|
345
|
+
: null, { keepUpdatedAt: true });
|
|
346
|
+
if (saved)
|
|
347
|
+
workerLog(` ${branchDir}: answered the request for the latest changes (${answer})`);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
327
353
|
/**
|
|
328
354
|
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
329
355
|
*
|
|
@@ -336,7 +362,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
336
362
|
* that branch's cross-host content-write lock and runs a series of git
|
|
337
363
|
* subprocesses against a shared filesystem.
|
|
338
364
|
*/
|
|
339
|
-
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
365
|
+
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set()) {
|
|
340
366
|
const rebased = [];
|
|
341
367
|
const skippedDirty = [];
|
|
342
368
|
const skippedLocked = [];
|
|
@@ -348,6 +374,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
348
374
|
catch {
|
|
349
375
|
return { rebased, skippedDirty, skippedLocked, failed };
|
|
350
376
|
}
|
|
377
|
+
// Branches someone is waiting on go first; the sort is stable, so the rest keep their order.
|
|
378
|
+
branchDirs.sort((a, b) => Number(requested.has(b)) - Number(requested.has(a)));
|
|
351
379
|
for (const branchDir of branchDirs) {
|
|
352
380
|
// A draining worker finishes the branch it is rebasing and starts no other:
|
|
353
381
|
// a rebase killed mid-branch is recovered lossily on the next boot.
|
|
@@ -378,8 +406,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
378
406
|
}
|
|
379
407
|
// The base branch's own clone is refreshed ff-only by
|
|
380
408
|
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
|
|
381
|
-
// this
|
|
382
|
-
//
|
|
409
|
+
// this loop could rewrite its history (the rebase below) and stamp
|
|
410
|
+
// meaningless conflict metadata on it. Compare
|
|
383
411
|
// sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
|
|
384
412
|
// is raw.
|
|
385
413
|
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
@@ -393,7 +421,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
|
|
|
393
421
|
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
394
422
|
continue;
|
|
395
423
|
}
|
|
396
|
-
const
|
|
424
|
+
const probe = {};
|
|
425
|
+
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe);
|
|
426
|
+
await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested.has(branchDir));
|
|
397
427
|
if (outcome.kind === 'rebased')
|
|
398
428
|
rebased.push(branchDir);
|
|
399
429
|
else if (outcome.kind === 'skippedDirty')
|
|
@@ -461,7 +491,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
|
|
|
461
491
|
* content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
|
|
462
492
|
* Never throws, like rebaseOneBranch.
|
|
463
493
|
*/
|
|
464
|
-
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
|
|
494
|
+
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe) {
|
|
465
495
|
let hold;
|
|
466
496
|
try {
|
|
467
497
|
hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
|
|
@@ -481,7 +511,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
|
|
|
481
511
|
if (hold.kind === 'not-provisioned')
|
|
482
512
|
return { kind: 'none' };
|
|
483
513
|
try {
|
|
484
|
-
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
|
|
514
|
+
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe);
|
|
485
515
|
}
|
|
486
516
|
finally {
|
|
487
517
|
await releaseProvisionedWorkspace(hold.release, branchDir);
|
|
@@ -510,6 +540,32 @@ async function lacksPublishedCommits(ctx, git, branchRef) {
|
|
|
510
540
|
await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
|
|
511
541
|
return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
|
|
512
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* {@link reconcileMergeConflicts} for a submitted or approved branch, whose merges of GitHub's
|
|
545
|
+
* commits can leave records too, under the content-write lock as for a branch being edited. Only
|
|
546
|
+
* for a clone with a record, or with `conflictFiles` to clear, so others cost one stat. Never
|
|
547
|
+
* throws.
|
|
548
|
+
*/
|
|
549
|
+
async function reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised) {
|
|
550
|
+
try {
|
|
551
|
+
const flagged = (metaFile.branch.conflictFiles?.length ?? 0) > 0;
|
|
552
|
+
if (!flagged && !(await new MergeConflictStore(branchPath).exists()))
|
|
553
|
+
return;
|
|
554
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
555
|
+
await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async () => {
|
|
556
|
+
await reconcileMergeConflicts(ctx, {
|
|
557
|
+
git: sharedRepoGit(branchPath, 'worktree'),
|
|
558
|
+
branchPath,
|
|
559
|
+
branchDir,
|
|
560
|
+
branchRef: await readCloneRef(branchPath),
|
|
561
|
+
});
|
|
562
|
+
return { kind: 'none' };
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
513
569
|
/**
|
|
514
570
|
* {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
|
|
515
571
|
* rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
|
|
@@ -599,14 +655,20 @@ function pullRequestHoldOf(pullRequest, recordedBase) {
|
|
|
599
655
|
/**
|
|
600
656
|
* Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
|
|
601
657
|
* branch registry. It's bookkeeping, so it leaves `updatedAt`, which the card shows as an edit.
|
|
602
|
-
*
|
|
658
|
+
* `tookBase` says this cycle merged the base in, which GitHub measured the pull request before, so
|
|
659
|
+
* nothing holds it. Best-effort: a failure leaves the card as it was until the next cycle.
|
|
603
660
|
*/
|
|
604
|
-
async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
|
|
661
|
+
async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase) {
|
|
605
662
|
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
663
|
try {
|
|
664
|
+
let hold = tookBase ? undefined : pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
|
|
665
|
+
// A conflict counts only on the commit the clone holds: one measured on an older head, as while
|
|
666
|
+
// the worker's own merge is on its way to GitHub, says nothing about the branch now.
|
|
667
|
+
if (hold?.kind === 'conflicts' && pullRequest.headSha !== (await cloneHead(branchPath))) {
|
|
668
|
+
hold = pullRequestHold?.kind === 'conflicts' ? null : undefined;
|
|
669
|
+
}
|
|
670
|
+
if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
|
|
671
|
+
return;
|
|
610
672
|
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
|
|
611
673
|
// A newer submit's pull request isn't the one measured.
|
|
612
674
|
(current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
|
|
@@ -617,17 +679,24 @@ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullR
|
|
|
617
679
|
workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
|
|
618
680
|
}
|
|
619
681
|
}
|
|
682
|
+
/** The commit a branch clone holds. */
|
|
683
|
+
async function cloneHead(branchPath) {
|
|
684
|
+
await assertSharedRepoConfig(branchPath, 'worktree');
|
|
685
|
+
return (await sharedRepoGit(branchPath, 'worktree').revparse(['HEAD'])).trim();
|
|
686
|
+
}
|
|
620
687
|
/**
|
|
621
688
|
* 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
|
-
*
|
|
689
|
+
* `behind` or `dirty`, so it can merge. A submitted branch stays submitted: the pull request's diff
|
|
690
|
+
* is still the author's changes. An approved branch keeps its approval only for git's own clean
|
|
691
|
+
* merge; any merge the field merge settled sends it back to review (`mergeIntoBranch`). Only from a
|
|
692
|
+
* clone, `remote.git` and GitHub all at one commit, since anything else is work still on its way,
|
|
693
|
+
* and only for the base GitHub measured against: a pull request someone retargeted would gain
|
|
694
|
+
* another branch's commits under its approval. A branch with a failed sync is left for its next
|
|
695
|
+
* submit or a retry of the task, as a push here would clear that failure with the PR still stale. A
|
|
696
|
+
* merge that leaves a value or file for a person leaves the branch as it is (`mergeIntoBranch`'s
|
|
697
|
+
* `inReview`), and withdrawing it lets the merge for a branch being edited record the conflicts,
|
|
698
|
+
* where an editor can resolve them. A failure goes in the cycle's summary only: the step before
|
|
699
|
+
* this one clears `rebaseFailure` each cycle, so recording it would reset its age every time.
|
|
631
700
|
* Never throws.
|
|
632
701
|
*/
|
|
633
702
|
async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
@@ -682,7 +751,7 @@ async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequ
|
|
|
682
751
|
other: baseTip,
|
|
683
752
|
message: `Merge ${base} into ${branchRef}`,
|
|
684
753
|
fromGitHub: false,
|
|
685
|
-
|
|
754
|
+
inReview: true,
|
|
686
755
|
});
|
|
687
756
|
if (outcome.kind === 'blocked') {
|
|
688
757
|
const error = redactCredentials(outcome.reason);
|
|
@@ -728,8 +797,9 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
|
|
|
728
797
|
}
|
|
729
798
|
}
|
|
730
799
|
/**
|
|
731
|
-
*
|
|
732
|
-
*
|
|
800
|
+
* Bring the base branch into ONE branch workspace: by a rebase where nothing
|
|
801
|
+
* needs a person, else by a merge that leaves each value both sides changed
|
|
802
|
+
* differently for one.
|
|
733
803
|
*
|
|
734
804
|
* `branchDir` is the sanitized directory name and `branchPath` its absolute
|
|
735
805
|
* path; the caller has already established that this is a real branch
|
|
@@ -739,7 +809,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
|
|
|
739
809
|
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
740
810
|
* rest of the cycle ([SYNC-M2]).
|
|
741
811
|
*/
|
|
742
|
-
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
|
|
812
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}) {
|
|
743
813
|
// Read only by the catch below. See the `rebased` rider on
|
|
744
814
|
// BranchRebaseOutcome.
|
|
745
815
|
let didRebase = false;
|
|
@@ -747,6 +817,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
747
817
|
// Restored first: everything below this cycle compares `name`.
|
|
748
818
|
const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
|
|
749
819
|
const branchStatus = metaFile?.branch.status;
|
|
820
|
+
// Only an editing branch takes the base in, so only its request can be answered.
|
|
821
|
+
const requestedAt = metaFile?.branch.baseUpdateRequestedAt;
|
|
822
|
+
if (metaFile !== null &&
|
|
823
|
+
branchStatus === 'editing' &&
|
|
824
|
+
requestedAt !== undefined &&
|
|
825
|
+
isBaseUpdatePending(metaFile.branch)) {
|
|
826
|
+
probe.request = { branchRef: metaFile.branch.name, requestedAt };
|
|
827
|
+
}
|
|
750
828
|
// Skip branches that must not be mutated:
|
|
751
829
|
// - submitted/approved: in review, so never rewrite history under an active
|
|
752
830
|
// PR -- but do poll GitHub for the PR's resolution, since nothing else
|
|
@@ -754,19 +832,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
754
832
|
// - archived: finished, with no PR left to poll.
|
|
755
833
|
if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
|
|
756
834
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
835
|
+
await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
|
|
757
836
|
const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
|
|
758
837
|
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' &&
|
|
838
|
+
// GitHub reports `behind` only where up-to-date branches are required, and `dirty` only where
|
|
839
|
+
// git's merge conflicts, so a pull request that merges as it is gets no commits or CI runs.
|
|
840
|
+
const updated = outcome.kind === 'none' &&
|
|
765
841
|
pullRequest?.state === 'open' &&
|
|
766
|
-
pullRequest.mergeable_state === 'behind')
|
|
767
|
-
|
|
842
|
+
(pullRequest.mergeable_state === 'behind' || pullRequest.mergeable_state === 'dirty')
|
|
843
|
+
? await updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub)
|
|
844
|
+
: outcome;
|
|
845
|
+
if (pullRequest !== null) {
|
|
846
|
+
const tookBase = updated.kind === 'rebased';
|
|
847
|
+
await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase);
|
|
768
848
|
}
|
|
769
|
-
return
|
|
849
|
+
return updated;
|
|
770
850
|
}
|
|
771
851
|
if (branchStatus === 'archived') {
|
|
772
852
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
@@ -778,13 +858,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
778
858
|
// The pins also keep git non-interactive (core.editor=true): a rebase never waits on an editor.
|
|
779
859
|
const branchGit = sharedRepoGit(branchPath, 'worktree');
|
|
780
860
|
// [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.
|
|
861
|
+
// dirty check, and hold it for the whole rebase or merge. The dirty check
|
|
862
|
+
// alone is check-then-act, and its residual window spans fetch, replay and
|
|
863
|
+
// the merge's awaited git subprocesses on EFS. A save landing in there makes
|
|
864
|
+
// the next replayed commit fail, and the `rebase --abort` that follows
|
|
865
|
+
// hard-resets the tree, discarding it. The editor already got its 200.
|
|
788
866
|
//
|
|
789
867
|
// Zero-retry acquisition, deliberately: on contention this branch is
|
|
790
868
|
// skipped and retried next sync cycle (~5 min), the same principle as the
|
|
@@ -902,6 +980,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
902
980
|
// every publish path stays disarmed -- the safe direction.
|
|
903
981
|
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
904
982
|
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
983
|
+
await reconcileMergeConflicts(ctx, {
|
|
984
|
+
git: branchGit,
|
|
985
|
+
branchPath,
|
|
986
|
+
branchDir,
|
|
987
|
+
branchRef: canPublish ? branchRef : null,
|
|
988
|
+
});
|
|
905
989
|
const commitArgs = canPublish && metaFile !== null
|
|
906
990
|
? {
|
|
907
991
|
git: branchGit,
|
|
@@ -927,6 +1011,13 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
927
1011
|
else if (outcome.kind !== 'in-step') {
|
|
928
1012
|
return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
|
|
929
1013
|
}
|
|
1014
|
+
// A merge of the base this worker published and never moved the clone to comes first.
|
|
1015
|
+
const caughtUp = await catchUpWithPublished(ctx, commitArgs);
|
|
1016
|
+
if (caughtUp?.kind === 'blocked') {
|
|
1017
|
+
return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
|
|
1018
|
+
}
|
|
1019
|
+
if (caughtUp)
|
|
1020
|
+
return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
|
|
930
1021
|
}
|
|
931
1022
|
if (editorDirt.length > 0) {
|
|
932
1023
|
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
@@ -977,7 +1068,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
977
1068
|
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
978
1069
|
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
979
1070
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
1071
|
+
probe.baseSha = fetchedBaseTip;
|
|
980
1072
|
if (behindCount === 0) {
|
|
1073
|
+
probe.upToDate = true;
|
|
981
1074
|
// Already in sync -- the overwhelmingly common outcome per branch per
|
|
982
1075
|
// cycle, so skip the save entirely when metadata already reflects a
|
|
983
1076
|
// clean state. save() eager-regenerates the branch registry
|
|
@@ -988,9 +1081,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
988
1081
|
// concurrent editor-driven metadata change in that window is not
|
|
989
1082
|
// clobbered by a stale skip decision.
|
|
990
1083
|
//
|
|
991
|
-
// Catching up leaves the conflict record alone: it names
|
|
992
|
-
//
|
|
993
|
-
// the branch merges (see recordConflicts). A lingering rebaseFailure
|
|
1084
|
+
// Catching up leaves the conflict record alone: it names values a person
|
|
1085
|
+
// has yet to choose, which stays true until they do. A lingering rebaseFailure
|
|
994
1086
|
// is cleared, or it sticks as a stale warning forever: nothing else
|
|
995
1087
|
// touches a caught-up branch, so no other save site clears it. One
|
|
996
1088
|
// whose cause stands stays: remote.git still holds a commit this clone
|
|
@@ -1007,17 +1099,42 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1007
1099
|
return { kind: 'none' };
|
|
1008
1100
|
}
|
|
1009
1101
|
// `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.
|
|
1102
|
+
// (a value a person chose, a maintainer's conflict resolution) would be undone, then
|
|
1103
|
+
// force-published. A branch holding one merges the base instead. So does one whose commits
|
|
1104
|
+
// touch canopycms state: a replay writes their bytes over the live files, and a pair that
|
|
1105
|
+
// adds then removes a file deletes it.
|
|
1014
1106
|
const aboveBase = `${fetchedBaseTip}..HEAD`;
|
|
1015
1107
|
const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
|
|
1016
1108
|
const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
|
|
1017
1109
|
(await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
|
|
1018
|
-
|
|
1110
|
+
const head = (await branchGit.revparse(['HEAD'])).trim();
|
|
1111
|
+
// Commits on top of what this branch published, as "Get latest changes" makes from saved work
|
|
1112
|
+
// on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
|
|
1113
|
+
// published commit under them, which reads as a push this clone never saw. A merge publishes
|
|
1114
|
+
// them as a fast-forward. A same-named ref at a commit the base holds is no work of this branch.
|
|
1115
|
+
const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
|
|
1116
|
+
const aheadOfPublished = published !== null &&
|
|
1117
|
+
published !== head &&
|
|
1118
|
+
metaFile?.branch.name === branchRef &&
|
|
1119
|
+
isPublished(metaFile) &&
|
|
1120
|
+
(await contains(branchGit, head, published)) &&
|
|
1121
|
+
!(await contains(branchGit, fetchedBaseTip, published));
|
|
1122
|
+
/**
|
|
1123
|
+
* The base taken in by a merge (github-commits.ts), which decides each value against the fork
|
|
1124
|
+
* point and leaves what both sides changed differently for a person, rather than a rebase,
|
|
1125
|
+
* whose rounds can only settle a conflict by rule. A clone with no branch checked out, or no
|
|
1126
|
+
* `branch.json`, cannot name the ref the merge publishes, so it stays behind.
|
|
1127
|
+
*/
|
|
1128
|
+
const takeBaseByMerge = async () => {
|
|
1129
|
+
if (commitArgs === null) {
|
|
1130
|
+
const reason = `the base conflicts with this clone's work, and the clone has no branch checked out, ` +
|
|
1131
|
+
`or no branch.json, to merge it into`;
|
|
1132
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
1133
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
1134
|
+
return { kind: 'failed', error: reason };
|
|
1135
|
+
}
|
|
1019
1136
|
const outcome = await mergeIntoBranch(ctx, commitArgs, {
|
|
1020
|
-
head
|
|
1137
|
+
head,
|
|
1021
1138
|
other: fetchedBaseTip,
|
|
1022
1139
|
message: `Merge ${ctx.baseBranch} into ${branchRef}`,
|
|
1023
1140
|
fromGitHub: false,
|
|
@@ -1025,20 +1142,39 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1025
1142
|
if (outcome.kind === 'blocked') {
|
|
1026
1143
|
return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
|
|
1027
1144
|
}
|
|
1028
|
-
if (outcome.kind !== 'merged')
|
|
1029
|
-
|
|
1145
|
+
if (outcome.kind !== 'merged') {
|
|
1146
|
+
if (!lockCompromised())
|
|
1147
|
+
return { kind: 'none' };
|
|
1148
|
+
workerLogWarn(` Skipping ${branchDir}: a lock it held was lost mid-merge (retrying next cycle)`);
|
|
1149
|
+
return { kind: 'skippedLocked' };
|
|
1150
|
+
}
|
|
1151
|
+
probe.keptEntries = outcome.keptEntries;
|
|
1030
1152
|
await clearRebaseFailure(ctx, branchPath, branchRef);
|
|
1031
1153
|
return { kind: 'rebased' };
|
|
1154
|
+
};
|
|
1155
|
+
if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
|
|
1156
|
+
return await takeBaseByMerge();
|
|
1157
|
+
}
|
|
1158
|
+
// A rebase only where it takes in exactly what a merge would, with nothing for a person: git
|
|
1159
|
+
// conflicts nowhere, and the field merge decides no file otherwise than git does.
|
|
1160
|
+
if (commitArgs !== null &&
|
|
1161
|
+
!mergesAsGitDoes(await planMerge(branchGit, branchPath, {
|
|
1162
|
+
head,
|
|
1163
|
+
other: fetchedBaseTip,
|
|
1164
|
+
contentRoot: ctx.contentRoot,
|
|
1165
|
+
timeoutMs: ctx.taskTimeoutMs,
|
|
1166
|
+
}))) {
|
|
1167
|
+
return await takeBaseByMerge();
|
|
1032
1168
|
}
|
|
1033
1169
|
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
1034
1170
|
// Read BOTH sides before rewriting anything: the arming guard after the
|
|
1035
1171
|
// rebase compares what remote.git published against what this clone is
|
|
1036
1172
|
// about to rebase away, and the clone's pre-rebase tip is exactly what
|
|
1037
1173
|
// the rebase makes unreadable.
|
|
1038
|
-
const preRebaseHead =
|
|
1039
|
-
const publishedSha =
|
|
1174
|
+
const preRebaseHead = head;
|
|
1175
|
+
const publishedSha = published;
|
|
1040
1176
|
await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
|
|
1041
|
-
const { completed,
|
|
1177
|
+
const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
|
|
1042
1178
|
// Outside the round loop's try/catch, so a throwing test hook can
|
|
1043
1179
|
// never be misread as a rebase error.
|
|
1044
1180
|
if (completed)
|
|
@@ -1072,6 +1208,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1072
1208
|
if (lockCompromised()) {
|
|
1073
1209
|
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
1210
|
}
|
|
1211
|
+
if (conflicted) {
|
|
1212
|
+
// The round loop aborted the rebase; this tells the caches the tree moved back.
|
|
1213
|
+
await abortRebase(branchGit, branchPath).catch(() => { });
|
|
1214
|
+
return await takeBaseByMerge();
|
|
1215
|
+
}
|
|
1075
1216
|
if (!completed) {
|
|
1076
1217
|
// failureReason is only set on the "unexpected error" break above;
|
|
1077
1218
|
// MAX_REBASE_ROUNDS exhaustion is a distinct exit with no message of
|
|
@@ -1098,18 +1239,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1098
1239
|
// and (via the on-disk generation marker) in the Lambda containers
|
|
1099
1240
|
// sharing this filesystem.
|
|
1100
1241
|
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.
|
|
1242
|
+
probe.keptEntries = 0;
|
|
1243
|
+
workerLog(` Rebased ${branchDir} successfully`);
|
|
1244
|
+
// Recorded only on a branch.json that still names the branch this cycle read. The cycle
|
|
1245
|
+
// completed, so any prior failure record goes, whatever conflicts stay on record.
|
|
1107
1246
|
await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
|
|
1108
1247
|
? {
|
|
1109
1248
|
branch: {
|
|
1110
|
-
|
|
1111
|
-
// The cycle completed, so clear any prior failure record regardless
|
|
1112
|
-
// of the conflict outcome.
|
|
1249
|
+
conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
|
|
1113
1250
|
rebaseFailure: undefined,
|
|
1114
1251
|
},
|
|
1115
1252
|
}
|