@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.
Files changed (38) hide show
  1. package/ci.docs.mdx +39 -9
  2. package/dist/ci.docs.mdx +39 -9
  3. package/dist/ci.main.runtime.d.ts +6 -0
  4. package/dist/ci.main.runtime.js +24 -3
  5. package/dist/ci.main.runtime.js.map +1 -1
  6. package/dist/{preview-1786400681343.js → preview-1786718956243.js} +1 -1
  7. package/dist/sync/git-ops.d.ts +41 -0
  8. package/dist/sync/git-ops.js +71 -0
  9. package/dist/sync/git-ops.js.map +1 -1
  10. package/dist/sync/git-ops.spec.js +39 -0
  11. package/dist/sync/git-ops.spec.js.map +1 -1
  12. package/dist/sync/lane-sync-executor.d.ts +81 -15
  13. package/dist/sync/lane-sync-executor.js +315 -50
  14. package/dist/sync/lane-sync-executor.js.map +1 -1
  15. package/dist/sync/lane-sync-executor.spec.js +235 -13
  16. package/dist/sync/lane-sync-executor.spec.js.map +1 -1
  17. package/dist/sync/main-sync-executor.js +30 -6
  18. package/dist/sync/main-sync-executor.js.map +1 -1
  19. package/dist/sync/sync-planner.d.ts +10 -1
  20. package/dist/sync/sync-planner.js +37 -13
  21. package/dist/sync/sync-planner.js.map +1 -1
  22. package/dist/sync/sync-planner.spec.js +44 -2
  23. package/dist/sync/sync-planner.spec.js.map +1 -1
  24. package/dist/sync/sync-state.d.ts +33 -1
  25. package/dist/sync/sync-state.js +60 -3
  26. package/dist/sync/sync-state.js.map +1 -1
  27. package/dist/sync/sync-state.spec.js +28 -0
  28. package/dist/sync/sync-state.spec.js.map +1 -1
  29. package/package.json +20 -20
  30. package/sync/git-ops.spec.ts +60 -0
  31. package/sync/git-ops.ts +72 -0
  32. package/sync/lane-sync-executor.spec.ts +215 -9
  33. package/sync/lane-sync-executor.ts +354 -47
  34. package/sync/main-sync-executor.ts +37 -5
  35. package/sync/sync-planner.spec.ts +36 -1
  36. package/sync/sync-planner.ts +36 -16
  37. package/sync/sync-state.spec.ts +33 -0
  38. 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; returns the error so the caller can
136
- * halt. Snaps WHATEVER is in the workspace (the switch uses `forceOurs` and never merges files), so
137
- * a diverged tree must already hold the merged content. `keepLane` preserves the lane's history,
138
- * `skipCleanup` keeps the snap's `.bitmap` for the caller to commit, `noDestructiveRecovery` turns
139
- * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be
140
- * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any
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
- * Returns undefined when the lane can no longer be read, in which case the caller halts.
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 to the remote lane's content; returns the error so the caller can
195
- * halt. Two traps make a plain `switchToLane` insufficient: (1) it defaults to `forceOurs`, which
196
- * never touches the filesystem — the commit would claim the branch mirrors the lane over the default
197
- * branch's files — so `forceOurs` is cleared and `forceTheirs` set; (2) switching onto the lane the
198
- * workspace already sits on throws "already checked out", which `switchToLane` swallows as success
199
- * and nothing materializes — so step off to main first.
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
- return this.executeHalt({
484
- laneName,
485
- laneIdStr,
486
- branch,
487
- reason: action.reason,
488
- pr
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 exportErr = await this.snapAndExportOntoLane(laneIdStr, message);
618
- if (exportErr) {
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}: ${exportErr.message}`,
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 laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
629
- if (!laneHead) {
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 exportErr = await this.snapAndExportOntoLane(laneIdStr, `merge remote lane ${laneIdStr} into ${branch} ${_syncState().SYNC_COMMIT_MARKER}`);
713
- if (exportErr) {
714
- return await halt(`merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` + `result failed: ${exportErr.message}`);
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 laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
719
- if (!laneHead) {
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; returns the error so the caller can
733
- * halt. Snaps WHATEVER is in the workspace (the switch uses `forceOurs` and never merges files), so
734
- * a diverged tree must already hold the merged content. `keepLane` preserves the lane's history,
735
- * `skipCleanup` keeps the snap's `.bitmap` for the caller to commit, `noDestructiveRecovery` turns
736
- * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be
737
- * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any
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 undefined;
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 e instanceof Error ? e : new Error(String(e?.message ?? e));
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
- * Returns undefined when the lane can no longer be read, in which case the caller halts.
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 undefined;
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 laneHead;
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 to the remote lane's content; returns the error so the caller can
1030
- * halt. Two traps make a plain `switchToLane` insufficient: (1) it defaults to `forceOurs`, which
1031
- * never touches the filesystem — the commit would claim the branch mirrors the lane over the default
1032
- * branch's files — so `forceOurs` is cleared and `forceTheirs` set; (2) switching onto the lane the
1033
- * workspace already sits on throws "already checked out", which `switchToLane` swallows as success
1034
- * and nothing materializes — so step off to main first.
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: false,
1054
- forceTheirs: true,
1055
- writeAdoptedFiles: true
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
- // `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or reinterpreted —
1134
- // the configured branch name is user input (see `sync-config.ts`).
1135
- await _git().git.push(['origin', `HEAD:refs/heads/${branch}`]);
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,