@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
@@ -12,11 +12,14 @@ import { laneNameToBranch } from './sync-config';
12
12
  import type { BranchSyncState } from './sync-state';
13
13
  import {
14
14
  CONFLICT_LABEL,
15
+ NO_CHANGES_TO_SNAP,
15
16
  SYNC_COMMIT_MARKER,
17
+ branchBitmapUnchangedSinceFork,
16
18
  branchStateFingerprint,
17
19
  buildSyncCommitMessage,
18
20
  fingerprintIdVersions,
19
21
  readBranchSyncState,
22
+ oldestCommitIsNonSync,
20
23
  hasSyncMarker,
21
24
  isSyncAuthoredMessage,
22
25
  } from './sync-state';
@@ -30,10 +33,13 @@ import {
30
33
  checkoutPristine,
31
34
  checkoutPristineRestore,
32
35
  commitWithIdentity,
36
+ confirmPushRace,
33
37
  deleteBranchArgs,
38
+ dropLocalBranchToRemoteTip,
34
39
  fetchRemoteHeads,
35
40
  isAncestor,
36
41
  isStaleLeaseRejection,
42
+ redactUrlCredentials,
37
43
  refetchBranchTip,
38
44
  } from './git-ops';
39
45
 
@@ -51,6 +57,50 @@ export const HALT_SUMMARY_PREFIX = 'HALTED';
51
57
  */
52
58
  export const REFUSED_SUMMARY_PREFIX = 'REFUSED';
53
59
 
60
+ /**
61
+ * A raced verdict: the run lost a push race to a concurrent run and exits green — never a halt, never
62
+ * the `bit-sync-conflict` label; a raced push is not a content conflict. Nothing retries in-run, so
63
+ * convergence waits on the NEXT trigger (webhook or scheduled run): a deployment without the cron can
64
+ * mirror stale content until the next event. One builder so the `-> raced (` stem a consumer routes on
65
+ * lives at one site, like `HALT_SUMMARY_PREFIX`.
66
+ */
67
+ function racedSummary(laneName: string, detail: string): string {
68
+ return `${laneName} -> raced (${detail})`;
69
+ }
70
+
71
+ /**
72
+ * `executeImportLane`'s own push (materializing the lane onto the branch) lost the race. Nothing on
73
+ * the LANE changed here — only the branch-side commit failed to land — so the next run's plan is
74
+ * unaffected: it simply retries the same import against the winner's tip.
75
+ */
76
+ function racedBranchPushSummary(laneName: string): string {
77
+ return racedSummary(laneName, 'branch push lost the race to a concurrent run; next run re-plans');
78
+ }
79
+
80
+ /**
81
+ * `recordLaneHeadOnBranch`'s ledger-commit push lost the race, AFTER the snap/export that precedes it
82
+ * already succeeded — the lane genuinely moved. Only the branch's own acknowledgment of that (the
83
+ * `.bitmap` pointer commit) failed to land, so the next run reads the branch as behind the lane it
84
+ * itself just updated and plans `import-lane` to catch it up. `policyClause` carries a destructive
85
+ * auto-resolution (onConflict git-wins/lane-wins) that already exported — it must not vanish from the
86
+ * summary just because the ledger push raced.
87
+ */
88
+ export function racedLedgerPushSummary(laneName: string, policyClause = ''): string {
89
+ return racedSummary(
90
+ laneName,
91
+ `${policyClause}lane updated; branch ledger commit lost the push race — next run re-plans`
92
+ );
93
+ }
94
+
95
+ /**
96
+ * `executeAdoptBranch`'s ledger-commit push lost the race. Unlike the export paths, adoption never
97
+ * snaps or exports — the LANE is untouched; only the branch's pointer commit failed to land. The next
98
+ * run re-plans first contact against the winner's tip.
99
+ */
100
+ export function racedAdoptionPushSummary(laneName: string): string {
101
+ return racedSummary(laneName, 'lane untouched; adoption ledger commit lost the push race — next run re-plans');
102
+ }
103
+
54
104
  export type LaneSyncDeps = {
55
105
  lanes: LanesMain;
56
106
  /** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
@@ -207,6 +257,7 @@ export const KEPT_BECAUSE: Record<BranchKeepReason, string> = {
207
257
  'unmerged-commits': `it carries commits that are not in the default branch`,
208
258
  '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`,
209
259
  'tip-advanced-during-run': `its tip advanced after the ownership evidence was read, so it may carry work this run never saw`,
260
+ 'adopted-branch': `it was adopted from a pre-existing branch, and only merging (not deleting) proves its history is safe to retire`,
210
261
  };
211
262
 
212
263
  export class LaneSyncExecutor {
@@ -385,6 +436,20 @@ export class LaneSyncExecutor {
385
436
  // `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
386
437
  // merely quotes the marker must not read as one we wrote.
387
438
  const tipIsSyncCommit = isSyncAuthoredMessage(branchState.tipMessage);
439
+ const hasIndependentHistory = await this.computeHasIndependentHistory(
440
+ ownership,
441
+ branchState.stateCommit,
442
+ defaultBranch
443
+ );
444
+
445
+ const branchNamesDifferentLane = await this.computeBranchNamesDifferentLane(
446
+ lastSyncedHead,
447
+ branchState.hasDevCommits,
448
+ mirroredLane,
449
+ laneIdStr,
450
+ branch,
451
+ defaultBranch
452
+ );
388
453
 
389
454
  const action = planLaneSync({
390
455
  laneHead,
@@ -392,7 +457,9 @@ export class LaneSyncExecutor {
392
457
  lastSyncedHead,
393
458
  hasDevCommits: branchState.hasDevCommits,
394
459
  tipIsSyncCommit,
460
+ hasIndependentHistory,
395
461
  conflictLabelPresent,
462
+ branchNamesDifferentLane,
396
463
  ownership,
397
464
  });
398
465
 
@@ -459,6 +526,8 @@ export class LaneSyncExecutor {
459
526
  return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
460
527
  case 'merge-diverged':
461
528
  return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
529
+ case 'adopt-branch':
530
+ return this.executeAdoptBranch({ target, laneIdStr, branch, defaultBranch, pr });
462
531
  case 'close-pr':
463
532
  return this.executeClosePr({
464
533
  laneName,
@@ -470,8 +539,12 @@ export class LaneSyncExecutor {
470
539
  keepReason: action.deleteBranch ? undefined : action.keepReason,
471
540
  expectedTipSha: branchState.tipSha,
472
541
  });
473
- case 'halt':
474
- return this.executeHalt({ laneName, laneIdStr, branch, reason: action.reason, pr });
542
+ case 'halt': {
543
+ // The planner only has the boolean; the different-lane id and its PR advice live here.
544
+ const commentNote =
545
+ branchNamesDifferentLane && mirroredLane ? branchMirrorsOtherLaneNote(mirroredLane, laneIdStr) : undefined;
546
+ return this.executeHalt({ laneName, laneIdStr, branch, reason: action.reason, pr, commentNote });
547
+ }
475
548
  default: {
476
549
  // exhaustiveness guard
477
550
  const unhandled: never = action;
@@ -583,7 +656,13 @@ export class LaneSyncExecutor {
583
656
  });
584
657
  }
585
658
 
586
- await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
659
+ const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
660
+ if (pushResult.raced) {
661
+ // Still ensure the PR: this early return skips the file's only PR-creation site, and the
662
+ // converged state every later run reads plans a bare noop, which never repairs a missing PR.
663
+ if (!pr) await this.openPrForLane({ target, laneIdStr, branch, defaultBranch, laneHead, remoteLane });
664
+ return racedBranchPushSummary(laneName);
665
+ }
587
666
 
588
667
  let prUrl = pr?.htmlUrl;
589
668
  if (!pr) {
@@ -621,20 +700,21 @@ export class LaneSyncExecutor {
621
700
  await this.checkoutFromRemote(branch, `origin/${branch}`);
622
701
 
623
702
  try {
624
- const exportErr = await this.snapAndExportOntoLane(laneIdStr, message);
625
- if (exportErr) {
703
+ const exported = await this.snapAndExportOntoLane(laneIdStr, message);
704
+ if (exported.status === 'error') {
626
705
  // Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
627
706
  return await this.executeHalt({
628
707
  laneName,
629
708
  laneIdStr,
630
709
  branch,
631
- reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${exportErr.message}`,
710
+ reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${exported.error.message}`,
632
711
  pr: await this.findPr(branch),
633
712
  });
634
713
  }
635
714
 
636
- const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
637
- if (!laneHead) {
715
+ const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
716
+ if (recorded.status === 'raced') return racedLedgerPushSummary(laneName);
717
+ if (recorded.status === 'unreadable') {
638
718
  return await this.executeHalt({
639
719
  laneName,
640
720
  laneIdStr,
@@ -643,7 +723,89 @@ export class LaneSyncExecutor {
643
723
  pr: await this.findPr(branch),
644
724
  });
645
725
  }
646
- return `${laneName} -> export-branch (lane ${laneIdStr} @ ${laneHead.slice(0, 9)}, branch ${branch} updated)`;
726
+ return (
727
+ `${laneName} -> export-branch (lane ${laneIdStr} @ ${recorded.laneHead.slice(0, 9)}, ` +
728
+ `branch ${branch} updated)`
729
+ );
730
+ } finally {
731
+ await this.restoreWorkspace(defaultBranch);
732
+ }
733
+ }
734
+
735
+ /**
736
+ * First contact: the lane exists, the branch maps to it by name, but the branch's committed
737
+ * `.bitmap` has no state for it (typically `bit ci pr --keep-lane` created the lane from this very
738
+ * branch without recording the pointer in git). Adoption only ever RECORDS a pointer, never resolves
739
+ * content: switch onto the lane keeping the branch's files, then ask `bit status` whether anything
740
+ * differs. Nothing → ledger commit; something → halt, lane untouched. Deliberately a status READ,
741
+ * never a snap: `snapping.snap()` writes an unexported Version object even under `dryRun`, and that
742
+ * residue can trip a later lane in the same `--all` run.
743
+ */
744
+ private async executeAdoptBranch({
745
+ target,
746
+ laneIdStr,
747
+ branch,
748
+ defaultBranch,
749
+ pr,
750
+ }: {
751
+ target: LaneTarget;
752
+ laneIdStr: string;
753
+ branch: string;
754
+ defaultBranch: string;
755
+ pr?: PrInfo;
756
+ }): Promise<string> {
757
+ const { logger } = this.deps;
758
+ const laneName = target.name;
759
+ logger.console(
760
+ formatWarningSummary(
761
+ `Adopting branch ${branch}: lane ${laneIdStr} exists, but the branch's committed .bitmap has no state for it (first contact)`
762
+ )
763
+ );
764
+
765
+ await this.checkoutFromRemote(branch, `origin/${branch}`);
766
+
767
+ try {
768
+ const switchErr = await this.materializeLane(laneIdStr, false);
769
+ if (switchErr) {
770
+ return await this.executeHalt({
771
+ laneName,
772
+ laneIdStr,
773
+ branch,
774
+ reason: `failed to check branch ${branch} against lane ${laneIdStr} for adoption: ${switchErr.message}`,
775
+ pr: pr ?? (await this.findPr(branch)),
776
+ });
777
+ }
778
+
779
+ const wouldChangeLane = await this.deps.ci.hasUnsyncedWorkChanges();
780
+ if (wouldChangeLane) {
781
+ // Nothing was snapped or exported — the lane is untouched.
782
+ return await this.executeHalt({
783
+ laneName,
784
+ laneIdStr,
785
+ branch,
786
+ reason:
787
+ 'branch has commits but its .bitmap records no state for this lane; cannot tell which side is ' +
788
+ `newer (adopting would change lane ${laneIdStr}'s content, not just record a pointer)`,
789
+ pr: pr ?? (await this.findPr(branch)),
790
+ });
791
+ }
792
+
793
+ // Branch content already matches the lane — the ledger commit is all that is missing.
794
+ const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch, { adopted: true });
795
+ if (recorded.status === 'raced') return racedAdoptionPushSummary(laneName);
796
+ if (recorded.status === 'unreadable') {
797
+ return await this.executeHalt({
798
+ laneName,
799
+ laneIdStr,
800
+ branch,
801
+ reason: `lane ${laneIdStr} could not be read back from the remote after adopting branch ${branch}`,
802
+ pr: pr ?? (await this.findPr(branch)),
803
+ });
804
+ }
805
+ return (
806
+ `${laneName} -> adopt-branch (branch ${branch} had no lane state; adopted onto lane ${laneIdStr} @ ` +
807
+ `${recorded.laneHead.slice(0, 9)})`
808
+ );
647
809
  } finally {
648
810
  await this.restoreWorkspace(defaultBranch);
649
811
  }
@@ -739,25 +901,26 @@ export class LaneSyncExecutor {
739
901
  }
740
902
 
741
903
  // ---- step 2: snap + export the merged tree onto the lane ---------------------------------
742
- const exportErr = await this.snapAndExportOntoLane(
904
+ const exported = await this.snapAndExportOntoLane(
743
905
  laneIdStr,
744
906
  `merge remote lane ${laneIdStr} into ${branch} ${SYNC_COMMIT_MARKER}`
745
907
  );
746
- if (exportErr) {
908
+ if (exported.status === 'error') {
747
909
  return await halt(
748
910
  `merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` +
749
- `result failed: ${exportErr.message}`
911
+ `result failed: ${exported.error.message}`
750
912
  );
751
913
  }
752
914
 
753
915
  // ---- step 3: record the new lane head on the branch --------------------------------------
754
- const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
755
- if (!laneHead) {
916
+ const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
917
+ if (recorded.status === 'raced') return racedLedgerPushSummary(laneName, policyClause);
918
+ if (recorded.status === 'unreadable') {
756
919
  return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
757
920
  }
758
921
  return (
759
922
  `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` +
760
- `${laneHead.slice(0, 9)}, branch ${branch} updated)`
923
+ `${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)`
761
924
  );
762
925
  } catch (e: any) {
763
926
  // Something unforeseen — halt anyway: no single lane may abort the rest of the run.
@@ -770,18 +933,20 @@ export class LaneSyncExecutor {
770
933
  }
771
934
 
772
935
  /**
773
- * Snap the workspace's current tree onto the lane and export it; returns the error so the caller can
774
- * halt. Snaps WHATEVER is in the workspace (the switch uses `forceOurs` and never merges files), so
775
- * a diverged tree must already hold the merged content. `keepLane` preserves the lane's history,
776
- * `skipCleanup` keeps the snap's `.bitmap` for the caller to commit, `noDestructiveRecovery` turns
777
- * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be
778
- * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any
779
- * fetch, so it never warms a cold scope.
936
+ * Snap the workspace's current tree onto the lane and export it. Snaps WHATEVER is in the workspace
937
+ * (the switch uses `forceOurs` and never merges files), so a diverged tree must already hold the
938
+ * merged content. `keepLane` preserves the lane's history, `skipCleanup` keeps the snap's `.bitmap`
939
+ * for the caller to commit, `noDestructiveRecovery` turns stale-lane recovery (delete + re-fork the
940
+ * remote lane) into a throw. The lane object must be imported BEFORE delegating: a switch onto the
941
+ * lane the workspace is already on no-ops before any fetch, so it never warms a cold scope.
780
942
  */
781
- private async snapAndExportOntoLane(laneIdStr: string, message: string): Promise<Error | undefined> {
943
+ private async snapAndExportOntoLane(
944
+ laneIdStr: string,
945
+ message: string
946
+ ): Promise<{ status: 'noop' | 'exported' } | { status: 'error'; error: Error }> {
782
947
  try {
783
948
  await ensureCurrentLaneObject(this.deps.lanes);
784
- await this.deps.ci.snapPrCommit({
949
+ const result = await this.deps.ci.snapPrCommit({
785
950
  laneIdStr,
786
951
  message,
787
952
  build: undefined,
@@ -790,27 +955,32 @@ export class LaneSyncExecutor {
790
955
  skipCleanup: true,
791
956
  noDestructiveRecovery: true,
792
957
  });
793
- return undefined;
958
+ if (result === NO_CHANGES_TO_SNAP) return { status: 'noop' };
959
+ return { status: 'exported' };
794
960
  } catch (e: any) {
795
- return e instanceof Error ? e : new Error(String(e?.message ?? e));
961
+ return { status: 'error', error: e instanceof Error ? e : new Error(String(e?.message ?? e)) };
796
962
  }
797
963
  }
798
964
 
799
965
  /**
800
966
  * Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
801
967
  * so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
802
- * Returns undefined when the lane can no longer be read, in which case the caller halts.
968
+ * `unreadable` means the lane could no longer be read back, in which case the caller halts;
969
+ * `raced` means a concurrent run's push already got there first, in which case the caller reports
970
+ * a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
803
971
  */
804
972
  private async recordLaneHeadOnBranch(
805
973
  target: LaneTarget,
806
974
  laneIdStr: string,
807
- branch: string
808
- ): Promise<string | undefined> {
975
+ branch: string,
976
+ opts: { adopted?: boolean } = {}
977
+ ): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
809
978
  const remoteLane = await this.getRemoteLane(target);
810
- if (!remoteLane) return undefined;
979
+ if (!remoteLane) return { status: 'unreadable' };
811
980
  const laneHead = laneHeadFingerprint(remoteLane.components);
812
- await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
813
- return laneHead;
981
+ const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
982
+ if (pushResult.raced) return { status: 'raced' };
983
+ return { status: 'ok', laneHead };
814
984
  }
815
985
 
816
986
  /**
@@ -925,6 +1095,80 @@ export class LaneSyncExecutor {
925
1095
  }
926
1096
  }
927
1097
 
1098
+ /**
1099
+ * Gates `hasIndependentHistoryBelowStateCommit`'s git call to the one case it can change the outcome
1100
+ * for: an `own-live` claim (the only ownership that would otherwise license an immediate delete) with
1101
+ * a reachable state commit to inspect. Extracted out of `reconcileLane` to keep the branching there.
1102
+ */
1103
+ private async computeHasIndependentHistory(
1104
+ ownership: LaneOwnershipEvidence,
1105
+ stateCommit: string | undefined,
1106
+ defaultBranch: string
1107
+ ): Promise<boolean> {
1108
+ if (ownership !== 'own-live' || !stateCommit) return false;
1109
+ return this.hasIndependentHistoryBelowStateCommit(stateCommit, defaultBranch);
1110
+ }
1111
+
1112
+ /**
1113
+ * Whether the OLDEST commit unique to the branch is NOT bit-authored — durable, ancestry-based proof
1114
+ * a human created the branch before this reconciler touched it (a trailer would not survive later
1115
+ * ledger commits; see `oldestCommitIsNonSync` for the oldest-only rationale). Unreadable answers
1116
+ * `true`: a deletion guard errs toward keeping.
1117
+ */
1118
+ private async hasIndependentHistoryBelowStateCommit(stateCommit: string, defaultBranch: string): Promise<boolean> {
1119
+ try {
1120
+ const log = await git.raw([
1121
+ 'log',
1122
+ `origin/${defaultBranch}..${stateCommit}`,
1123
+ '--first-parent',
1124
+ '--reverse',
1125
+ '--format=%B%x1e',
1126
+ ]);
1127
+ return oldestCommitIsNonSync(log);
1128
+ } catch (e: any) {
1129
+ this.deps.logger.consoleWarning(
1130
+ `Could not read ${stateCommit}'s history against ${defaultBranch} to check for independent, ` +
1131
+ `pre-existing branch history; treating it as present (the branch is kept, not deleted): ${e?.message || e}`
1132
+ );
1133
+ return true;
1134
+ }
1135
+ }
1136
+
1137
+ /** Gates `differentLaneStillClaims` to the planner's first-contact path, the only one that reads it. */
1138
+ private async computeBranchNamesDifferentLane(
1139
+ lastSyncedHead: string | undefined,
1140
+ hasDevCommits: boolean,
1141
+ mirroredLane: string | undefined,
1142
+ laneIdStr: string,
1143
+ branch: string,
1144
+ defaultBranch: string
1145
+ ): Promise<boolean> {
1146
+ if (lastSyncedHead || !hasDevCommits) return false;
1147
+ return this.differentLaneStillClaims(mirroredLane, laneIdStr, branch, defaultBranch);
1148
+ }
1149
+
1150
+ /**
1151
+ * Whether adoption must refuse `branch` because its `.bitmap` names a DIFFERENT lane's still-live
1152
+ * claim. Blob equality with the fork-point `.bitmap`, not commit reachability: an `own-superseded`
1153
+ * branch is reachable yet still carries live work; only a `.bitmap` the branch never changed proves
1154
+ * the pointer is inherited. Unreadable stays conservative and blocks adoption.
1155
+ */
1156
+ private async differentLaneStillClaims(
1157
+ mirroredLane: string | undefined,
1158
+ laneIdStr: string,
1159
+ branch: string,
1160
+ defaultBranch: string
1161
+ ): Promise<boolean> {
1162
+ if (!mirroredLane || mirroredLane === laneIdStr) return false;
1163
+ const inherited = await this.branchInheritsBitmapFromDefault(branch, defaultBranch);
1164
+ return !inherited;
1165
+ }
1166
+
1167
+ /** Seam over `branchBitmapUnchangedSinceFork`, so `differentLaneStillClaims` is unit-testable without a real remote. */
1168
+ private branchInheritsBitmapFromDefault(branch: string, defaultBranch: string): Promise<boolean> {
1169
+ return branchBitmapUnchangedSinceFork(branch, defaultBranch);
1170
+ }
1171
+
928
1172
  /**
929
1173
  * The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
930
1174
  * Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
@@ -1126,14 +1370,17 @@ export class LaneSyncExecutor {
1126
1370
  }
1127
1371
 
1128
1372
  /**
1129
- * Drive the workspace's filesystem to the remote lane's content; returns the error so the caller can
1130
- * halt. Two traps make a plain `switchToLane` insufficient: (1) it defaults to `forceOurs`, which
1131
- * never touches the filesystem — the commit would claim the branch mirrors the lane over the default
1132
- * branch's files — so `forceOurs` is cleared and `forceTheirs` set; (2) switching onto the lane the
1133
- * workspace already sits on throws "already checked out", which `switchToLane` swallows as success
1134
- * and nothing materializes — so step off to main first.
1373
+ * Drive the workspace's filesystem onto the remote lane; returns the error so the caller can halt.
1374
+ * Two traps make a plain `switchToLane` insufficient: (1) its default `forceOurs` never touches the
1375
+ * filesystem, so the caller's commit would misrepresent whichever side it claims mirrors the other;
1376
+ * (2) switching onto the lane the workspace already sits on throws "already checked out", which
1377
+ * `switchToLane` swallows as success and nothing materializes — so step off to main first.
1378
+ *
1379
+ * `theirsWins` picks the direction: `true` (import-lane) checks the lane's files out over the disk;
1380
+ * `false` (adopt-branch) keeps the workspace's files — the branch's content is what the lane is
1381
+ * being asked to reflect.
1135
1382
  */
1136
- private async materializeLane(laneIdStr: string): Promise<Error | undefined> {
1383
+ private async materializeLane(laneIdStr: string, theirsWins = true): Promise<Error | undefined> {
1137
1384
  const { logger } = this.deps;
1138
1385
  const target = await this.deps.lanes.parseLaneId(laneIdStr);
1139
1386
  // Compares name AND scope, and reads from `.bitmap`, not the scope's lane object — the object read
@@ -1154,9 +1401,16 @@ export class LaneSyncExecutor {
1154
1401
  }
1155
1402
 
1156
1403
  const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, {
1157
- forceOurs: false,
1158
- forceTheirs: true,
1159
- writeAdoptedFiles: true,
1404
+ forceOurs: !theirsWins,
1405
+ forceTheirs: theirsWins,
1406
+ // The whole lane has to land in the workspace here, including components it has that this
1407
+ // workspace doesn't track yet - materializing the lane is the point of this step. That is the
1408
+ // opposite of `bit ci pr`, where the git checkout is the source of truth and `switchToLane`'s
1409
+ // `workspaceOnly` default keeps the switch from writing lane-only components.
1410
+ workspaceOnly: false,
1411
+ // Follows the direction: in adopt mode the missing-component retry must not write the lane's
1412
+ // files over the branch's — the ledger commit would then push the lane's content onto the branch.
1413
+ writeAdoptedFiles: theirsWins,
1160
1414
  });
1161
1415
  if (switchErr) return switchErr;
1162
1416
 
@@ -1222,15 +1476,68 @@ export class LaneSyncExecutor {
1222
1476
  /**
1223
1477
  * Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
1224
1478
  * push means someone pushed concurrently and the next run should re-plan rather than clobber.
1225
- * `--allow-empty` is only insurance against `git commit` failing the lane outright.
1479
+ * `--allow-empty` is only insurance against `git commit` failing the lane outright. Returns whether
1480
+ * the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
1481
+ * benign noop instead of halting; any other push failure still throws.
1226
1482
  */
1227
- private async commitAllAndPush(branch: string, message: string) {
1483
+ private async commitAllAndPush(branch: string, message: string): Promise<{ raced: boolean }> {
1484
+ // The local knowledge of `origin/<branch>` right now — no fetch, `fetchOnce` already ran for this
1485
+ // whole executor run — is exactly what our about-to-be-made commit is based on. After a rejection,
1486
+ // this is the baseline `classifyPushRejection` checks the remote against to confirm a real race.
1487
+ const baseSha = await this.localRemoteTip(branch);
1228
1488
  await addAllExceptScopeAndModules();
1229
1489
  await commitWithIdentity(message, { extraArgs: ['--allow-empty'] });
1230
- // `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or reinterpreted —
1231
- // the configured branch name is user input (see `sync-config.ts`).
1232
- await git.push(['origin', `HEAD:refs/heads/${branch}`]);
1490
+ try {
1491
+ // `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or
1492
+ // reinterpreted — the configured branch name is user input (see `sync-config.ts`).
1493
+ await git.push(['origin', `HEAD:refs/heads/${branch}`]);
1494
+ } catch (e: any) {
1495
+ if ((await this.classifyPushRejection(branch, baseSha, e)) !== 'confirmed-race') throw e;
1496
+ // Drop the losing commit: left on refs/heads/<branch>, its orphan sibling would trip
1497
+ // `checkoutPristine`'s guard on this clone's next branch-touching run. The confirmation just
1498
+ // refetched the winner's tip, so `origin/<branch>` resolves.
1499
+ const dropErr = await dropLocalBranchToRemoteTip(branch);
1500
+ if (dropErr) {
1501
+ this.deps.logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
1502
+ }
1503
+ // Keep the original rejection text (redacted: git prints the remote URL, which may embed
1504
+ // credentials): the raced verdict exits green, and without the text a persistent wording-matched
1505
+ // failure on a busy branch would leave no diagnosable trace anywhere.
1506
+ this.deps.logger.console(
1507
+ formatWarningSummary(
1508
+ `Push to ${branch} was rejected and the branch has since moved — a concurrent run got there first; ` +
1509
+ `re-planning on the next sync. The rejection: ${redactUrlCredentials(String(e?.message || e))}`
1510
+ )
1511
+ );
1512
+ return { raced: true };
1513
+ }
1233
1514
  this.deps.logger.console(chalk.green(`Pushed ${branch}`));
1515
+ return { raced: false };
1516
+ }
1517
+
1518
+ /** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
1519
+ private async localRemoteTip(branch: string): Promise<string | undefined> {
1520
+ try {
1521
+ return String(await git.raw(['rev-parse', `origin/${branch}`])).trim() || undefined;
1522
+ } catch {
1523
+ return undefined;
1524
+ }
1525
+ }
1526
+
1527
+ /**
1528
+ * `confirmPushRace` (git-ops) with this executor's seam: `currentBranchTip` (shared with the close-pr
1529
+ * retirement path) keeps the confirmation unit-testable without a real remote. Residual risk,
1530
+ * accepted: "the branch moved" is not "a sync run won" — on a branch that moves during every run
1531
+ * window, a persistent wording-matched failure still classifies as raced each time. The caller logs
1532
+ * the original rejection text so that shape stays diagnosable. Only answers; the caller decides to
1533
+ * swallow or rethrow.
1534
+ */
1535
+ private async classifyPushRejection(
1536
+ branch: string,
1537
+ baseSha: string | undefined,
1538
+ e: any
1539
+ ): Promise<'confirmed-race' | 'not-a-race'> {
1540
+ return confirmPushRace(branch, baseSha, String(e?.message || e), (b) => this.currentBranchTip(b));
1234
1541
  }
1235
1542
 
1236
1543
  private async openPrForLane({
@@ -16,10 +16,13 @@ import {
16
16
  checkoutPristine,
17
17
  checkoutPristineRestore,
18
18
  commitWithIdentity,
19
+ confirmPushRace,
20
+ dropLocalBranchToRemoteTip,
19
21
  fetchRemoteHeads,
20
22
  gitWithIdentity,
21
23
  isNonContentPath,
22
24
  isStaleLeaseRejection,
25
+ redactUrlCredentials,
23
26
  } from './git-ops';
24
27
 
25
28
  export type MainSyncDeps = {
@@ -89,7 +92,7 @@ export class MainSyncExecutor {
89
92
  if (catchUpErr) {
90
93
  const refork = await this.reforkIfMachineOwned(branch);
91
94
  if (refork === 'human-owned') return `${HALT_SUMMARY_PREFIX} main -> ${catchUpErr}`;
92
- if (refork === 'raced') return `main -> ${branch} was updated by a concurrent run — deferring to it`;
95
+ if (refork === 'raced') return racedMainSummary(branch);
93
96
  }
94
97
  }
95
98
 
@@ -156,10 +159,34 @@ export class MainSyncExecutor {
156
159
 
157
160
  await addAllExceptScopeAndModules();
158
161
  await commitWithIdentity(mainSyncCommitMessage(drift.length));
159
- // Never force: a rejected push means a concurrent run pushed in between, and in direct-push mode
160
- // that rejection is the whole safety story for the default branch. Unambiguous refspec — see
161
- // `lane-sync-executor.commitAllAndPush`.
162
- await git.push(['origin', `HEAD:refs/heads/${branch}`]);
162
+ // The local knowledge of `origin/<branch>` — the tip this run built on; after a rejection it is
163
+ // the baseline `confirmPushRace` checks the re-fetched remote against.
164
+ const baseSha = await git
165
+ .revparse([`origin/${branch}`])
166
+ .then((sha) => sha.trim() || undefined)
167
+ .catch(() => undefined);
168
+ // Never force. Unambiguous refspec — see `lane-sync-executor.commitAllAndPush`. A rejection means
169
+ // a concurrent run pushed in between; what happens next depends on the mode. Direct-push: the
170
+ // rejection IS the default branch's safety story — stay loud, never swallow. 'pr' mode: the push
171
+ // targets the machine-owned sync branch, where a concurrent-run overlap is the same benign race
172
+ // the lane executor tolerates (`reforkIfMachineOwned` already defers the same way) — confirm it
173
+ // and defer.
174
+ try {
175
+ await git.push(['origin', `HEAD:refs/heads/${branch}`]);
176
+ } catch (e: any) {
177
+ const message = String(e?.message || e);
178
+ if (directPush || (await confirmPushRace(branch, baseSha, message)) !== 'confirmed-race') throw e;
179
+ // Drop the losing commit: left on the local sync branch, its orphan sibling would trip the
180
+ // next run's `checkoutPristine` guard. The original rejection text is kept for diagnosability.
181
+ const dropErr = await dropLocalBranchToRemoteTip(branch);
182
+ if (dropErr) {
183
+ logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
184
+ }
185
+ logger.console(
186
+ formatWarningSummary(`main -> push to ${branch} lost to a concurrent run: ${redactUrlCredentials(message)}`)
187
+ );
188
+ return racedMainSummary(branch);
189
+ }
163
190
  logger.console(chalk.green(`Pushed ${branch}`));
164
191
 
165
192
  if (directPush) {
@@ -329,6 +356,11 @@ export class MainSyncExecutor {
329
356
  }
330
357
  }
331
358
 
359
+ /** The deferral line both raced shapes share: the catch-up refork race and the sync-branch push race. */
360
+ function racedMainSummary(branch: string): string {
361
+ return `main -> ${branch} was updated by a concurrent run — deferring to it`;
362
+ }
363
+
332
364
  /** The sync commit message: no `Bit-Lane-Head` trailer — main sync keeps no state. */
333
365
  function mainSyncCommitMessage(driftCount: number): string {
334
366
  return [