@teambit/ci 1.0.494 → 1.0.497
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/ci.docs.mdx +39 -9
- package/dist/ci.docs.mdx +39 -9
- package/dist/ci.main.runtime.d.ts +6 -0
- package/dist/ci.main.runtime.js +24 -3
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/{preview-1786400681343.js → preview-1786718956243.js} +1 -1
- package/dist/sync/git-ops.d.ts +41 -0
- package/dist/sync/git-ops.js +71 -0
- package/dist/sync/git-ops.js.map +1 -1
- package/dist/sync/git-ops.spec.js +39 -0
- package/dist/sync/git-ops.spec.js.map +1 -1
- package/dist/sync/lane-sync-executor.d.ts +81 -15
- package/dist/sync/lane-sync-executor.js +315 -50
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +235 -13
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/main-sync-executor.js +30 -6
- package/dist/sync/main-sync-executor.js.map +1 -1
- package/dist/sync/sync-planner.d.ts +10 -1
- package/dist/sync/sync-planner.js +37 -13
- package/dist/sync/sync-planner.js.map +1 -1
- package/dist/sync/sync-planner.spec.js +44 -2
- package/dist/sync/sync-planner.spec.js.map +1 -1
- package/dist/sync/sync-state.d.ts +33 -1
- package/dist/sync/sync-state.js +60 -3
- package/dist/sync/sync-state.js.map +1 -1
- package/dist/sync/sync-state.spec.js +28 -0
- package/dist/sync/sync-state.spec.js.map +1 -1
- package/package.json +20 -20
- package/sync/git-ops.spec.ts +60 -0
- package/sync/git-ops.ts +72 -0
- package/sync/lane-sync-executor.spec.ts +215 -9
- package/sync/lane-sync-executor.ts +354 -47
- package/sync/main-sync-executor.ts +37 -5
- package/sync/sync-planner.spec.ts +36 -1
- package/sync/sync-planner.ts +36 -16
- package/sync/sync-state.spec.ts +33 -0
- package/sync/sync-state.ts +62 -1
|
@@ -17,6 +17,21 @@ export declare const HALT_SUMMARY_PREFIX = "HALTED";
|
|
|
17
17
|
* sync conflict.
|
|
18
18
|
*/
|
|
19
19
|
export declare const REFUSED_SUMMARY_PREFIX = "REFUSED";
|
|
20
|
+
/**
|
|
21
|
+
* `recordLaneHeadOnBranch`'s ledger-commit push lost the race, AFTER the snap/export that precedes it
|
|
22
|
+
* already succeeded — the lane genuinely moved. Only the branch's own acknowledgment of that (the
|
|
23
|
+
* `.bitmap` pointer commit) failed to land, so the next run reads the branch as behind the lane it
|
|
24
|
+
* itself just updated and plans `import-lane` to catch it up. `policyClause` carries a destructive
|
|
25
|
+
* auto-resolution (onConflict git-wins/lane-wins) that already exported — it must not vanish from the
|
|
26
|
+
* summary just because the ledger push raced.
|
|
27
|
+
*/
|
|
28
|
+
export declare function racedLedgerPushSummary(laneName: string, policyClause?: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* `executeAdoptBranch`'s ledger-commit push lost the race. Unlike the export paths, adoption never
|
|
31
|
+
* snaps or exports — the LANE is untouched; only the branch's pointer commit failed to land. The next
|
|
32
|
+
* run re-plans first contact against the winner's tip.
|
|
33
|
+
*/
|
|
34
|
+
export declare function racedAdoptionPushSummary(laneName: string): string;
|
|
20
35
|
export type LaneSyncDeps = {
|
|
21
36
|
lanes: LanesMain;
|
|
22
37
|
/** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
|
|
@@ -122,6 +137,16 @@ export declare class LaneSyncExecutor {
|
|
|
122
137
|
* resulting `.bitmap` back onto the branch so the next run sees the two sides as converged.
|
|
123
138
|
*/
|
|
124
139
|
private executeExportBranch;
|
|
140
|
+
/**
|
|
141
|
+
* First contact: the lane exists, the branch maps to it by name, but the branch's committed
|
|
142
|
+
* `.bitmap` has no state for it (typically `bit ci pr --keep-lane` created the lane from this very
|
|
143
|
+
* branch without recording the pointer in git). Adoption only ever RECORDS a pointer, never resolves
|
|
144
|
+
* content: switch onto the lane keeping the branch's files, then ask `bit status` whether anything
|
|
145
|
+
* differs. Nothing → ledger commit; something → halt, lane untouched. Deliberately a status READ,
|
|
146
|
+
* never a snap: `snapping.snap()` writes an unexported Version object even under `dryRun`, and that
|
|
147
|
+
* residue can trip a later lane in the same `--all` run.
|
|
148
|
+
*/
|
|
149
|
+
private executeAdoptBranch;
|
|
125
150
|
/**
|
|
126
151
|
* Both sides moved since the last sync. Order is load-bearing: (1) merge the lane into the branch's
|
|
127
152
|
* working tree (`bit checkout head`; conflicts halt or auto-resolve per `sync.onConflict`), (2) snap
|
|
@@ -132,19 +157,20 @@ export declare class LaneSyncExecutor {
|
|
|
132
157
|
*/
|
|
133
158
|
private executeMergeDiverged;
|
|
134
159
|
/**
|
|
135
|
-
* Snap the workspace's current tree onto the lane and export it
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* fetch, so it never warms a cold scope.
|
|
160
|
+
* Snap the workspace's current tree onto the lane and export it. Snaps WHATEVER is in the workspace
|
|
161
|
+
* (the switch uses `forceOurs` and never merges files), so a diverged tree must already hold the
|
|
162
|
+
* merged content. `keepLane` preserves the lane's history, `skipCleanup` keeps the snap's `.bitmap`
|
|
163
|
+
* for the caller to commit, `noDestructiveRecovery` turns stale-lane recovery (delete + re-fork the
|
|
164
|
+
* remote lane) into a throw. The lane object must be imported BEFORE delegating: a switch onto the
|
|
165
|
+
* lane the workspace is already on no-ops before any fetch, so it never warms a cold scope.
|
|
142
166
|
*/
|
|
143
167
|
private snapAndExportOntoLane;
|
|
144
168
|
/**
|
|
145
169
|
* Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
|
|
146
170
|
* so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
|
|
147
|
-
*
|
|
171
|
+
* `unreadable` means the lane could no longer be read back, in which case the caller halts;
|
|
172
|
+
* `raced` means a concurrent run's push already got there first, in which case the caller reports
|
|
173
|
+
* a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
|
|
148
174
|
*/
|
|
149
175
|
private recordLaneHeadOnBranch;
|
|
150
176
|
/**
|
|
@@ -170,6 +196,30 @@ export declare class LaneSyncExecutor {
|
|
|
170
196
|
* direction: left alone, never retired.
|
|
171
197
|
*/
|
|
172
198
|
private assessBranchOwnership;
|
|
199
|
+
/**
|
|
200
|
+
* Gates `hasIndependentHistoryBelowStateCommit`'s git call to the one case it can change the outcome
|
|
201
|
+
* for: an `own-live` claim (the only ownership that would otherwise license an immediate delete) with
|
|
202
|
+
* a reachable state commit to inspect. Extracted out of `reconcileLane` to keep the branching there.
|
|
203
|
+
*/
|
|
204
|
+
private computeHasIndependentHistory;
|
|
205
|
+
/**
|
|
206
|
+
* Whether the OLDEST commit unique to the branch is NOT bit-authored — durable, ancestry-based proof
|
|
207
|
+
* a human created the branch before this reconciler touched it (a trailer would not survive later
|
|
208
|
+
* ledger commits; see `oldestCommitIsNonSync` for the oldest-only rationale). Unreadable answers
|
|
209
|
+
* `true`: a deletion guard errs toward keeping.
|
|
210
|
+
*/
|
|
211
|
+
private hasIndependentHistoryBelowStateCommit;
|
|
212
|
+
/** Gates `differentLaneStillClaims` to the planner's first-contact path, the only one that reads it. */
|
|
213
|
+
private computeBranchNamesDifferentLane;
|
|
214
|
+
/**
|
|
215
|
+
* Whether adoption must refuse `branch` because its `.bitmap` names a DIFFERENT lane's still-live
|
|
216
|
+
* claim. Blob equality with the fork-point `.bitmap`, not commit reachability: an `own-superseded`
|
|
217
|
+
* branch is reachable yet still carries live work; only a `.bitmap` the branch never changed proves
|
|
218
|
+
* the pointer is inherited. Unreadable stays conservative and blocks adoption.
|
|
219
|
+
*/
|
|
220
|
+
private differentLaneStillClaims;
|
|
221
|
+
/** Seam over `branchBitmapUnchangedSinceFork`, so `differentLaneStillClaims` is unit-testable without a real remote. */
|
|
222
|
+
private branchInheritsBitmapFromDefault;
|
|
173
223
|
/**
|
|
174
224
|
* The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
|
|
175
225
|
* Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
|
|
@@ -191,12 +241,15 @@ export declare class LaneSyncExecutor {
|
|
|
191
241
|
*/
|
|
192
242
|
private executeHalt;
|
|
193
243
|
/**
|
|
194
|
-
* Drive the workspace's filesystem
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
244
|
+
* Drive the workspace's filesystem onto the remote lane; returns the error so the caller can halt.
|
|
245
|
+
* Two traps make a plain `switchToLane` insufficient: (1) its default `forceOurs` never touches the
|
|
246
|
+
* filesystem, so the caller's commit would misrepresent whichever side it claims mirrors the other;
|
|
247
|
+
* (2) switching onto the lane the workspace already sits on throws "already checked out", which
|
|
248
|
+
* `switchToLane` swallows as success and nothing materializes — so step off to main first.
|
|
249
|
+
*
|
|
250
|
+
* `theirsWins` picks the direction: `true` (import-lane) checks the lane's files out over the disk;
|
|
251
|
+
* `false` (adopt-branch) keeps the workspace's files — the branch's content is what the lane is
|
|
252
|
+
* being asked to reflect.
|
|
200
253
|
*/
|
|
201
254
|
private materializeLane;
|
|
202
255
|
private fetchOnce;
|
|
@@ -219,9 +272,22 @@ export declare class LaneSyncExecutor {
|
|
|
219
272
|
/**
|
|
220
273
|
* Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
|
|
221
274
|
* push means someone pushed concurrently and the next run should re-plan rather than clobber.
|
|
222
|
-
* `--allow-empty` is only insurance against `git commit` failing the lane outright.
|
|
275
|
+
* `--allow-empty` is only insurance against `git commit` failing the lane outright. Returns whether
|
|
276
|
+
* the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
|
|
277
|
+
* benign noop instead of halting; any other push failure still throws.
|
|
223
278
|
*/
|
|
224
279
|
private commitAllAndPush;
|
|
280
|
+
/** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
|
|
281
|
+
private localRemoteTip;
|
|
282
|
+
/**
|
|
283
|
+
* `confirmPushRace` (git-ops) with this executor's seam: `currentBranchTip` (shared with the close-pr
|
|
284
|
+
* retirement path) keeps the confirmation unit-testable without a real remote. Residual risk,
|
|
285
|
+
* accepted: "the branch moved" is not "a sync run won" — on a branch that moves during every run
|
|
286
|
+
* window, a persistent wording-matched failure still classifies as raced each time. The caller logs
|
|
287
|
+
* the original rejection text so that shape stays diagnosable. Only answers; the caller decides to
|
|
288
|
+
* swallow or rethrow.
|
|
289
|
+
*/
|
|
290
|
+
private classifyPushRejection;
|
|
225
291
|
private openPrForLane;
|
|
226
292
|
/**
|
|
227
293
|
* Put the workspace back on main with the default branch checked out. Best-effort, warn-only, so a
|
|
@@ -17,6 +17,8 @@ exports.haltCommentBody = haltCommentBody;
|
|
|
17
17
|
exports.isProtectedBranch = isProtectedBranch;
|
|
18
18
|
exports.laneHeadFingerprint = laneHeadFingerprint;
|
|
19
19
|
exports.laneSyncPrBody = laneSyncPrBody;
|
|
20
|
+
exports.racedAdoptionPushSummary = racedAdoptionPushSummary;
|
|
21
|
+
exports.racedLedgerPushSummary = racedLedgerPushSummary;
|
|
20
22
|
function _chalk() {
|
|
21
23
|
const data = _interopRequireDefault(require("chalk"));
|
|
22
24
|
_chalk = function () {
|
|
@@ -104,6 +106,47 @@ const HALT_SUMMARY_PREFIX = exports.HALT_SUMMARY_PREFIX = 'HALTED';
|
|
|
104
106
|
* sync conflict.
|
|
105
107
|
*/
|
|
106
108
|
const REFUSED_SUMMARY_PREFIX = exports.REFUSED_SUMMARY_PREFIX = 'REFUSED';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A raced verdict: the run lost a push race to a concurrent run and exits green — never a halt, never
|
|
112
|
+
* the `bit-sync-conflict` label; a raced push is not a content conflict. Nothing retries in-run, so
|
|
113
|
+
* convergence waits on the NEXT trigger (webhook or scheduled run): a deployment without the cron can
|
|
114
|
+
* mirror stale content until the next event. One builder so the `-> raced (` stem a consumer routes on
|
|
115
|
+
* lives at one site, like `HALT_SUMMARY_PREFIX`.
|
|
116
|
+
*/
|
|
117
|
+
function racedSummary(laneName, detail) {
|
|
118
|
+
return `${laneName} -> raced (${detail})`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `executeImportLane`'s own push (materializing the lane onto the branch) lost the race. Nothing on
|
|
123
|
+
* the LANE changed here — only the branch-side commit failed to land — so the next run's plan is
|
|
124
|
+
* unaffected: it simply retries the same import against the winner's tip.
|
|
125
|
+
*/
|
|
126
|
+
function racedBranchPushSummary(laneName) {
|
|
127
|
+
return racedSummary(laneName, 'branch push lost the race to a concurrent run; next run re-plans');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `recordLaneHeadOnBranch`'s ledger-commit push lost the race, AFTER the snap/export that precedes it
|
|
132
|
+
* already succeeded — the lane genuinely moved. Only the branch's own acknowledgment of that (the
|
|
133
|
+
* `.bitmap` pointer commit) failed to land, so the next run reads the branch as behind the lane it
|
|
134
|
+
* itself just updated and plans `import-lane` to catch it up. `policyClause` carries a destructive
|
|
135
|
+
* auto-resolution (onConflict git-wins/lane-wins) that already exported — it must not vanish from the
|
|
136
|
+
* summary just because the ledger push raced.
|
|
137
|
+
*/
|
|
138
|
+
function racedLedgerPushSummary(laneName, policyClause = '') {
|
|
139
|
+
return racedSummary(laneName, `${policyClause}lane updated; branch ledger commit lost the push race — next run re-plans`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `executeAdoptBranch`'s ledger-commit push lost the race. Unlike the export paths, adoption never
|
|
144
|
+
* snaps or exports — the LANE is untouched; only the branch's pointer commit failed to land. The next
|
|
145
|
+
* run re-plans first contact against the winner's tip.
|
|
146
|
+
*/
|
|
147
|
+
function racedAdoptionPushSummary(laneName) {
|
|
148
|
+
return racedSummary(laneName, 'lane untouched; adoption ledger commit lost the push race — next run re-plans');
|
|
149
|
+
}
|
|
107
150
|
/**
|
|
108
151
|
* Fingerprint of a lane's content — same primitive as `branchStateFingerprint`, so "equal" means
|
|
109
152
|
* exactly "the branch records every lane component at the lane's head".
|
|
@@ -228,7 +271,8 @@ function dryRunSummaryLine(laneName, action) {
|
|
|
228
271
|
const KEPT_BECAUSE = exports.KEPT_BECAUSE = {
|
|
229
272
|
'unmerged-commits': `it carries commits that are not in the default branch`,
|
|
230
273
|
'tip-not-a-sync-commit': `its tip is not one of this reconciler's own commits, so the work in it may exist nowhere else`,
|
|
231
|
-
'tip-advanced-during-run': `its tip advanced after the ownership evidence was read, so it may carry work this run never saw
|
|
274
|
+
'tip-advanced-during-run': `its tip advanced after the ownership evidence was read, so it may carry work this run never saw`,
|
|
275
|
+
'adopted-branch': `it was adopted from a pre-existing branch, and only merging (not deleting) proves its history is safe to retire`
|
|
232
276
|
};
|
|
233
277
|
class LaneSyncExecutor {
|
|
234
278
|
constructor(deps) {
|
|
@@ -405,13 +449,17 @@ class LaneSyncExecutor {
|
|
|
405
449
|
// `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
|
|
406
450
|
// merely quotes the marker must not read as one we wrote.
|
|
407
451
|
const tipIsSyncCommit = (0, _syncState().isSyncAuthoredMessage)(branchState.tipMessage);
|
|
452
|
+
const hasIndependentHistory = await this.computeHasIndependentHistory(ownership, branchState.stateCommit, defaultBranch);
|
|
453
|
+
const branchNamesDifferentLane = await this.computeBranchNamesDifferentLane(lastSyncedHead, branchState.hasDevCommits, mirroredLane, laneIdStr, branch, defaultBranch);
|
|
408
454
|
const action = (0, _syncPlanner().planLaneSync)({
|
|
409
455
|
laneHead,
|
|
410
456
|
branchExists,
|
|
411
457
|
lastSyncedHead,
|
|
412
458
|
hasDevCommits: branchState.hasDevCommits,
|
|
413
459
|
tipIsSyncCommit,
|
|
460
|
+
hasIndependentHistory,
|
|
414
461
|
conflictLabelPresent,
|
|
462
|
+
branchNamesDifferentLane,
|
|
415
463
|
ownership
|
|
416
464
|
});
|
|
417
465
|
logger.console(_chalk().default.blue(`${laneName} -> ${action.type} (branch: ${branch}, lane head: ${laneHead?.slice(0, 9) ?? 'none'}, ` + `branch state: ${lastSyncedHead?.slice(0, 9) ?? 'none'}, ` + `dev commits: ${branchState.hasDevCommits}${laneIsGone ? `, branch claim: ${ownership}` : ''})`));
|
|
@@ -468,6 +516,14 @@ class LaneSyncExecutor {
|
|
|
468
516
|
branch,
|
|
469
517
|
defaultBranch
|
|
470
518
|
});
|
|
519
|
+
case 'adopt-branch':
|
|
520
|
+
return this.executeAdoptBranch({
|
|
521
|
+
target,
|
|
522
|
+
laneIdStr,
|
|
523
|
+
branch,
|
|
524
|
+
defaultBranch,
|
|
525
|
+
pr
|
|
526
|
+
});
|
|
471
527
|
case 'close-pr':
|
|
472
528
|
return this.executeClosePr({
|
|
473
529
|
laneName,
|
|
@@ -480,13 +536,18 @@ class LaneSyncExecutor {
|
|
|
480
536
|
expectedTipSha: branchState.tipSha
|
|
481
537
|
});
|
|
482
538
|
case 'halt':
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
laneIdStr
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
539
|
+
{
|
|
540
|
+
// The planner only has the boolean; the different-lane id and its PR advice live here.
|
|
541
|
+
const commentNote = branchNamesDifferentLane && mirroredLane ? branchMirrorsOtherLaneNote(mirroredLane, laneIdStr) : undefined;
|
|
542
|
+
return this.executeHalt({
|
|
543
|
+
laneName,
|
|
544
|
+
laneIdStr,
|
|
545
|
+
branch,
|
|
546
|
+
reason: action.reason,
|
|
547
|
+
pr,
|
|
548
|
+
commentNote
|
|
549
|
+
});
|
|
550
|
+
}
|
|
490
551
|
default:
|
|
491
552
|
{
|
|
492
553
|
// exhaustiveness guard
|
|
@@ -578,7 +639,20 @@ class LaneSyncExecutor {
|
|
|
578
639
|
pr
|
|
579
640
|
});
|
|
580
641
|
}
|
|
581
|
-
await this.commitAllAndPush(branch, (0, _syncState().buildSyncCommitMessage)(laneIdStr, laneHead));
|
|
642
|
+
const pushResult = await this.commitAllAndPush(branch, (0, _syncState().buildSyncCommitMessage)(laneIdStr, laneHead));
|
|
643
|
+
if (pushResult.raced) {
|
|
644
|
+
// Still ensure the PR: this early return skips the file's only PR-creation site, and the
|
|
645
|
+
// converged state every later run reads plans a bare noop, which never repairs a missing PR.
|
|
646
|
+
if (!pr) await this.openPrForLane({
|
|
647
|
+
target,
|
|
648
|
+
laneIdStr,
|
|
649
|
+
branch,
|
|
650
|
+
defaultBranch,
|
|
651
|
+
laneHead,
|
|
652
|
+
remoteLane
|
|
653
|
+
});
|
|
654
|
+
return racedBranchPushSummary(laneName);
|
|
655
|
+
}
|
|
582
656
|
let prUrl = pr?.htmlUrl;
|
|
583
657
|
if (!pr) {
|
|
584
658
|
prUrl = await this.openPrForLane({
|
|
@@ -614,19 +688,20 @@ class LaneSyncExecutor {
|
|
|
614
688
|
const message = await this.lastNonSyncCommitMessage(branch, defaultBranch);
|
|
615
689
|
await this.checkoutFromRemote(branch, `origin/${branch}`);
|
|
616
690
|
try {
|
|
617
|
-
const
|
|
618
|
-
if (
|
|
691
|
+
const exported = await this.snapAndExportOntoLane(laneIdStr, message);
|
|
692
|
+
if (exported.status === 'error') {
|
|
619
693
|
// Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
|
|
620
694
|
return await this.executeHalt({
|
|
621
695
|
laneName,
|
|
622
696
|
laneIdStr,
|
|
623
697
|
branch,
|
|
624
|
-
reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${
|
|
698
|
+
reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${exported.error.message}`,
|
|
625
699
|
pr: await this.findPr(branch)
|
|
626
700
|
});
|
|
627
701
|
}
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
702
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
703
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName);
|
|
704
|
+
if (recorded.status === 'unreadable') {
|
|
630
705
|
return await this.executeHalt({
|
|
631
706
|
laneName,
|
|
632
707
|
laneIdStr,
|
|
@@ -635,7 +710,72 @@ class LaneSyncExecutor {
|
|
|
635
710
|
pr: await this.findPr(branch)
|
|
636
711
|
});
|
|
637
712
|
}
|
|
638
|
-
return `${laneName} -> export-branch (lane ${laneIdStr} @ ${laneHead.slice(0, 9)}, branch ${branch} updated)`;
|
|
713
|
+
return `${laneName} -> export-branch (lane ${laneIdStr} @ ${recorded.laneHead.slice(0, 9)}, ` + `branch ${branch} updated)`;
|
|
714
|
+
} finally {
|
|
715
|
+
await this.restoreWorkspace(defaultBranch);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* First contact: the lane exists, the branch maps to it by name, but the branch's committed
|
|
721
|
+
* `.bitmap` has no state for it (typically `bit ci pr --keep-lane` created the lane from this very
|
|
722
|
+
* branch without recording the pointer in git). Adoption only ever RECORDS a pointer, never resolves
|
|
723
|
+
* content: switch onto the lane keeping the branch's files, then ask `bit status` whether anything
|
|
724
|
+
* differs. Nothing → ledger commit; something → halt, lane untouched. Deliberately a status READ,
|
|
725
|
+
* never a snap: `snapping.snap()` writes an unexported Version object even under `dryRun`, and that
|
|
726
|
+
* residue can trip a later lane in the same `--all` run.
|
|
727
|
+
*/
|
|
728
|
+
async executeAdoptBranch({
|
|
729
|
+
target,
|
|
730
|
+
laneIdStr,
|
|
731
|
+
branch,
|
|
732
|
+
defaultBranch,
|
|
733
|
+
pr
|
|
734
|
+
}) {
|
|
735
|
+
const {
|
|
736
|
+
logger
|
|
737
|
+
} = this.deps;
|
|
738
|
+
const laneName = target.name;
|
|
739
|
+
logger.console((0, _cli().formatWarningSummary)(`Adopting branch ${branch}: lane ${laneIdStr} exists, but the branch's committed .bitmap has no state for it (first contact)`));
|
|
740
|
+
await this.checkoutFromRemote(branch, `origin/${branch}`);
|
|
741
|
+
try {
|
|
742
|
+
const switchErr = await this.materializeLane(laneIdStr, false);
|
|
743
|
+
if (switchErr) {
|
|
744
|
+
return await this.executeHalt({
|
|
745
|
+
laneName,
|
|
746
|
+
laneIdStr,
|
|
747
|
+
branch,
|
|
748
|
+
reason: `failed to check branch ${branch} against lane ${laneIdStr} for adoption: ${switchErr.message}`,
|
|
749
|
+
pr: pr ?? (await this.findPr(branch))
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
const wouldChangeLane = await this.deps.ci.hasUnsyncedWorkChanges();
|
|
753
|
+
if (wouldChangeLane) {
|
|
754
|
+
// Nothing was snapped or exported — the lane is untouched.
|
|
755
|
+
return await this.executeHalt({
|
|
756
|
+
laneName,
|
|
757
|
+
laneIdStr,
|
|
758
|
+
branch,
|
|
759
|
+
reason: 'branch has commits but its .bitmap records no state for this lane; cannot tell which side is ' + `newer (adopting would change lane ${laneIdStr}'s content, not just record a pointer)`,
|
|
760
|
+
pr: pr ?? (await this.findPr(branch))
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Branch content already matches the lane — the ledger commit is all that is missing.
|
|
765
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch, {
|
|
766
|
+
adopted: true
|
|
767
|
+
});
|
|
768
|
+
if (recorded.status === 'raced') return racedAdoptionPushSummary(laneName);
|
|
769
|
+
if (recorded.status === 'unreadable') {
|
|
770
|
+
return await this.executeHalt({
|
|
771
|
+
laneName,
|
|
772
|
+
laneIdStr,
|
|
773
|
+
branch,
|
|
774
|
+
reason: `lane ${laneIdStr} could not be read back from the remote after adopting branch ${branch}`,
|
|
775
|
+
pr: pr ?? (await this.findPr(branch))
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return `${laneName} -> adopt-branch (branch ${branch} had no lane state; adopted onto lane ${laneIdStr} @ ` + `${recorded.laneHead.slice(0, 9)})`;
|
|
639
779
|
} finally {
|
|
640
780
|
await this.restoreWorkspace(defaultBranch);
|
|
641
781
|
}
|
|
@@ -709,17 +849,18 @@ class LaneSyncExecutor {
|
|
|
709
849
|
}
|
|
710
850
|
|
|
711
851
|
// ---- step 2: snap + export the merged tree onto the lane ---------------------------------
|
|
712
|
-
const
|
|
713
|
-
if (
|
|
714
|
-
return await halt(`merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` + `result failed: ${
|
|
852
|
+
const exported = await this.snapAndExportOntoLane(laneIdStr, `merge remote lane ${laneIdStr} into ${branch} ${_syncState().SYNC_COMMIT_MARKER}`);
|
|
853
|
+
if (exported.status === 'error') {
|
|
854
|
+
return await halt(`merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` + `result failed: ${exported.error.message}`);
|
|
715
855
|
}
|
|
716
856
|
|
|
717
857
|
// ---- step 3: record the new lane head on the branch --------------------------------------
|
|
718
|
-
const
|
|
719
|
-
if (
|
|
858
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
859
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName, policyClause);
|
|
860
|
+
if (recorded.status === 'unreadable') {
|
|
720
861
|
return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
|
|
721
862
|
}
|
|
722
|
-
return `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` + `${laneHead.slice(0, 9)}, branch ${branch} updated)`;
|
|
863
|
+
return `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` + `${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)`;
|
|
723
864
|
} catch (e) {
|
|
724
865
|
// Something unforeseen — halt anyway: no single lane may abort the rest of the run.
|
|
725
866
|
return await halt(`unexpected failure while reconciling diverged lane ${laneIdStr} with branch ${branch}: ${e?.message || e}`);
|
|
@@ -729,18 +870,17 @@ class LaneSyncExecutor {
|
|
|
729
870
|
}
|
|
730
871
|
|
|
731
872
|
/**
|
|
732
|
-
* Snap the workspace's current tree onto the lane and export it
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
* fetch, so it never warms a cold scope.
|
|
873
|
+
* Snap the workspace's current tree onto the lane and export it. Snaps WHATEVER is in the workspace
|
|
874
|
+
* (the switch uses `forceOurs` and never merges files), so a diverged tree must already hold the
|
|
875
|
+
* merged content. `keepLane` preserves the lane's history, `skipCleanup` keeps the snap's `.bitmap`
|
|
876
|
+
* for the caller to commit, `noDestructiveRecovery` turns stale-lane recovery (delete + re-fork the
|
|
877
|
+
* remote lane) into a throw. The lane object must be imported BEFORE delegating: a switch onto the
|
|
878
|
+
* lane the workspace is already on no-ops before any fetch, so it never warms a cold scope.
|
|
739
879
|
*/
|
|
740
880
|
async snapAndExportOntoLane(laneIdStr, message) {
|
|
741
881
|
try {
|
|
742
882
|
await (0, _workspaceLane().ensureCurrentLaneObject)(this.deps.lanes);
|
|
743
|
-
await this.deps.ci.snapPrCommit({
|
|
883
|
+
const result = await this.deps.ci.snapPrCommit({
|
|
744
884
|
laneIdStr,
|
|
745
885
|
message,
|
|
746
886
|
build: undefined,
|
|
@@ -749,23 +889,41 @@ class LaneSyncExecutor {
|
|
|
749
889
|
skipCleanup: true,
|
|
750
890
|
noDestructiveRecovery: true
|
|
751
891
|
});
|
|
752
|
-
return
|
|
892
|
+
if (result === _syncState().NO_CHANGES_TO_SNAP) return {
|
|
893
|
+
status: 'noop'
|
|
894
|
+
};
|
|
895
|
+
return {
|
|
896
|
+
status: 'exported'
|
|
897
|
+
};
|
|
753
898
|
} catch (e) {
|
|
754
|
-
return
|
|
899
|
+
return {
|
|
900
|
+
status: 'error',
|
|
901
|
+
error: e instanceof Error ? e : new Error(String(e?.message ?? e))
|
|
902
|
+
};
|
|
755
903
|
}
|
|
756
904
|
}
|
|
757
905
|
|
|
758
906
|
/**
|
|
759
907
|
* Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
|
|
760
908
|
* so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
|
|
761
|
-
*
|
|
909
|
+
* `unreadable` means the lane could no longer be read back, in which case the caller halts;
|
|
910
|
+
* `raced` means a concurrent run's push already got there first, in which case the caller reports
|
|
911
|
+
* a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
|
|
762
912
|
*/
|
|
763
|
-
async recordLaneHeadOnBranch(target, laneIdStr, branch) {
|
|
913
|
+
async recordLaneHeadOnBranch(target, laneIdStr, branch, opts = {}) {
|
|
764
914
|
const remoteLane = await this.getRemoteLane(target);
|
|
765
|
-
if (!remoteLane) return
|
|
915
|
+
if (!remoteLane) return {
|
|
916
|
+
status: 'unreadable'
|
|
917
|
+
};
|
|
766
918
|
const laneHead = laneHeadFingerprint(remoteLane.components);
|
|
767
|
-
await this.commitAllAndPush(branch, (0, _syncState().buildSyncCommitMessage)(laneIdStr, laneHead));
|
|
768
|
-
return
|
|
919
|
+
const pushResult = await this.commitAllAndPush(branch, (0, _syncState().buildSyncCommitMessage)(laneIdStr, laneHead, opts));
|
|
920
|
+
if (pushResult.raced) return {
|
|
921
|
+
status: 'raced'
|
|
922
|
+
};
|
|
923
|
+
return {
|
|
924
|
+
status: 'ok',
|
|
925
|
+
laneHead
|
|
926
|
+
};
|
|
769
927
|
}
|
|
770
928
|
|
|
771
929
|
/**
|
|
@@ -867,6 +1025,55 @@ class LaneSyncExecutor {
|
|
|
867
1025
|
}
|
|
868
1026
|
}
|
|
869
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Gates `hasIndependentHistoryBelowStateCommit`'s git call to the one case it can change the outcome
|
|
1030
|
+
* for: an `own-live` claim (the only ownership that would otherwise license an immediate delete) with
|
|
1031
|
+
* a reachable state commit to inspect. Extracted out of `reconcileLane` to keep the branching there.
|
|
1032
|
+
*/
|
|
1033
|
+
async computeHasIndependentHistory(ownership, stateCommit, defaultBranch) {
|
|
1034
|
+
if (ownership !== 'own-live' || !stateCommit) return false;
|
|
1035
|
+
return this.hasIndependentHistoryBelowStateCommit(stateCommit, defaultBranch);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Whether the OLDEST commit unique to the branch is NOT bit-authored — durable, ancestry-based proof
|
|
1040
|
+
* a human created the branch before this reconciler touched it (a trailer would not survive later
|
|
1041
|
+
* ledger commits; see `oldestCommitIsNonSync` for the oldest-only rationale). Unreadable answers
|
|
1042
|
+
* `true`: a deletion guard errs toward keeping.
|
|
1043
|
+
*/
|
|
1044
|
+
async hasIndependentHistoryBelowStateCommit(stateCommit, defaultBranch) {
|
|
1045
|
+
try {
|
|
1046
|
+
const log = await _git().git.raw(['log', `origin/${defaultBranch}..${stateCommit}`, '--first-parent', '--reverse', '--format=%B%x1e']);
|
|
1047
|
+
return (0, _syncState().oldestCommitIsNonSync)(log);
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
this.deps.logger.consoleWarning(`Could not read ${stateCommit}'s history against ${defaultBranch} to check for independent, ` + `pre-existing branch history; treating it as present (the branch is kept, not deleted): ${e?.message || e}`);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Gates `differentLaneStillClaims` to the planner's first-contact path, the only one that reads it. */
|
|
1055
|
+
async computeBranchNamesDifferentLane(lastSyncedHead, hasDevCommits, mirroredLane, laneIdStr, branch, defaultBranch) {
|
|
1056
|
+
if (lastSyncedHead || !hasDevCommits) return false;
|
|
1057
|
+
return this.differentLaneStillClaims(mirroredLane, laneIdStr, branch, defaultBranch);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether adoption must refuse `branch` because its `.bitmap` names a DIFFERENT lane's still-live
|
|
1062
|
+
* claim. Blob equality with the fork-point `.bitmap`, not commit reachability: an `own-superseded`
|
|
1063
|
+
* branch is reachable yet still carries live work; only a `.bitmap` the branch never changed proves
|
|
1064
|
+
* the pointer is inherited. Unreadable stays conservative and blocks adoption.
|
|
1065
|
+
*/
|
|
1066
|
+
async differentLaneStillClaims(mirroredLane, laneIdStr, branch, defaultBranch) {
|
|
1067
|
+
if (!mirroredLane || mirroredLane === laneIdStr) return false;
|
|
1068
|
+
const inherited = await this.branchInheritsBitmapFromDefault(branch, defaultBranch);
|
|
1069
|
+
return !inherited;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Seam over `branchBitmapUnchangedSinceFork`, so `differentLaneStillClaims` is unit-testable without a real remote. */
|
|
1073
|
+
branchInheritsBitmapFromDefault(branch, defaultBranch) {
|
|
1074
|
+
return (0, _syncState().branchBitmapUnchangedSinceFork)(branch, defaultBranch);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
870
1077
|
/**
|
|
871
1078
|
* The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
|
|
872
1079
|
* Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
|
|
@@ -1026,14 +1233,17 @@ class LaneSyncExecutor {
|
|
|
1026
1233
|
}
|
|
1027
1234
|
|
|
1028
1235
|
/**
|
|
1029
|
-
* Drive the workspace's filesystem
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1236
|
+
* Drive the workspace's filesystem onto the remote lane; returns the error so the caller can halt.
|
|
1237
|
+
* Two traps make a plain `switchToLane` insufficient: (1) its default `forceOurs` never touches the
|
|
1238
|
+
* filesystem, so the caller's commit would misrepresent whichever side it claims mirrors the other;
|
|
1239
|
+
* (2) switching onto the lane the workspace already sits on throws "already checked out", which
|
|
1240
|
+
* `switchToLane` swallows as success and nothing materializes — so step off to main first.
|
|
1241
|
+
*
|
|
1242
|
+
* `theirsWins` picks the direction: `true` (import-lane) checks the lane's files out over the disk;
|
|
1243
|
+
* `false` (adopt-branch) keeps the workspace's files — the branch's content is what the lane is
|
|
1244
|
+
* being asked to reflect.
|
|
1035
1245
|
*/
|
|
1036
|
-
async materializeLane(laneIdStr) {
|
|
1246
|
+
async materializeLane(laneIdStr, theirsWins = true) {
|
|
1037
1247
|
const {
|
|
1038
1248
|
logger
|
|
1039
1249
|
} = this.deps;
|
|
@@ -1050,9 +1260,16 @@ class LaneSyncExecutor {
|
|
|
1050
1260
|
}
|
|
1051
1261
|
}
|
|
1052
1262
|
const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, {
|
|
1053
|
-
forceOurs:
|
|
1054
|
-
forceTheirs:
|
|
1055
|
-
|
|
1263
|
+
forceOurs: !theirsWins,
|
|
1264
|
+
forceTheirs: theirsWins,
|
|
1265
|
+
// The whole lane has to land in the workspace here, including components it has that this
|
|
1266
|
+
// workspace doesn't track yet - materializing the lane is the point of this step. That is the
|
|
1267
|
+
// opposite of `bit ci pr`, where the git checkout is the source of truth and `switchToLane`'s
|
|
1268
|
+
// `workspaceOnly` default keeps the switch from writing lane-only components.
|
|
1269
|
+
workspaceOnly: false,
|
|
1270
|
+
// Follows the direction: in adopt mode the missing-component retry must not write the lane's
|
|
1271
|
+
// files over the branch's — the ledger commit would then push the lane's content onto the branch.
|
|
1272
|
+
writeAdoptedFiles: theirsWins
|
|
1056
1273
|
});
|
|
1057
1274
|
if (switchErr) return switchErr;
|
|
1058
1275
|
|
|
@@ -1123,17 +1340,65 @@ class LaneSyncExecutor {
|
|
|
1123
1340
|
/**
|
|
1124
1341
|
* Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
|
|
1125
1342
|
* push means someone pushed concurrently and the next run should re-plan rather than clobber.
|
|
1126
|
-
* `--allow-empty` is only insurance against `git commit` failing the lane outright.
|
|
1343
|
+
* `--allow-empty` is only insurance against `git commit` failing the lane outright. Returns whether
|
|
1344
|
+
* the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
|
|
1345
|
+
* benign noop instead of halting; any other push failure still throws.
|
|
1127
1346
|
*/
|
|
1128
1347
|
async commitAllAndPush(branch, message) {
|
|
1348
|
+
// The local knowledge of `origin/<branch>` right now — no fetch, `fetchOnce` already ran for this
|
|
1349
|
+
// whole executor run — is exactly what our about-to-be-made commit is based on. After a rejection,
|
|
1350
|
+
// this is the baseline `classifyPushRejection` checks the remote against to confirm a real race.
|
|
1351
|
+
const baseSha = await this.localRemoteTip(branch);
|
|
1129
1352
|
await (0, _gitOps().addAllExceptScopeAndModules)();
|
|
1130
1353
|
await (0, _gitOps().commitWithIdentity)(message, {
|
|
1131
1354
|
extraArgs: ['--allow-empty']
|
|
1132
1355
|
});
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1356
|
+
try {
|
|
1357
|
+
// `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or
|
|
1358
|
+
// reinterpreted — the configured branch name is user input (see `sync-config.ts`).
|
|
1359
|
+
await _git().git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
1360
|
+
} catch (e) {
|
|
1361
|
+
if ((await this.classifyPushRejection(branch, baseSha, e)) !== 'confirmed-race') throw e;
|
|
1362
|
+
// Drop the losing commit: left on refs/heads/<branch>, its orphan sibling would trip
|
|
1363
|
+
// `checkoutPristine`'s guard on this clone's next branch-touching run. The confirmation just
|
|
1364
|
+
// refetched the winner's tip, so `origin/<branch>` resolves.
|
|
1365
|
+
const dropErr = await (0, _gitOps().dropLocalBranchToRemoteTip)(branch);
|
|
1366
|
+
if (dropErr) {
|
|
1367
|
+
this.deps.logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
|
|
1368
|
+
}
|
|
1369
|
+
// Keep the original rejection text (redacted: git prints the remote URL, which may embed
|
|
1370
|
+
// credentials): the raced verdict exits green, and without the text a persistent wording-matched
|
|
1371
|
+
// failure on a busy branch would leave no diagnosable trace anywhere.
|
|
1372
|
+
this.deps.logger.console((0, _cli().formatWarningSummary)(`Push to ${branch} was rejected and the branch has since moved — a concurrent run got there first; ` + `re-planning on the next sync. The rejection: ${(0, _gitOps().redactUrlCredentials)(String(e?.message || e))}`));
|
|
1373
|
+
return {
|
|
1374
|
+
raced: true
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1136
1377
|
this.deps.logger.console(_chalk().default.green(`Pushed ${branch}`));
|
|
1378
|
+
return {
|
|
1379
|
+
raced: false
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
|
|
1384
|
+
async localRemoteTip(branch) {
|
|
1385
|
+
try {
|
|
1386
|
+
return String(await _git().git.raw(['rev-parse', `origin/${branch}`])).trim() || undefined;
|
|
1387
|
+
} catch {
|
|
1388
|
+
return undefined;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* `confirmPushRace` (git-ops) with this executor's seam: `currentBranchTip` (shared with the close-pr
|
|
1394
|
+
* retirement path) keeps the confirmation unit-testable without a real remote. Residual risk,
|
|
1395
|
+
* accepted: "the branch moved" is not "a sync run won" — on a branch that moves during every run
|
|
1396
|
+
* window, a persistent wording-matched failure still classifies as raced each time. The caller logs
|
|
1397
|
+
* the original rejection text so that shape stays diagnosable. Only answers; the caller decides to
|
|
1398
|
+
* swallow or rethrow.
|
|
1399
|
+
*/
|
|
1400
|
+
async classifyPushRejection(branch, baseSha, e) {
|
|
1401
|
+
return (0, _gitOps().confirmPushRace)(branch, baseSha, String(e?.message || e), b => this.currentBranchTip(b));
|
|
1137
1402
|
}
|
|
1138
1403
|
async openPrForLane({
|
|
1139
1404
|
target,
|