@teambit/ci 1.0.495 → 1.0.500

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.
@@ -15,6 +15,7 @@ import {
15
15
  laneSyncPrBody,
16
16
  racedLedgerPushSummary,
17
17
  } from './lane-sync-executor';
18
+ import type { LaneOwnershipEvidence } from './sync-planner';
18
19
  import { resolveSyncConfig } from './sync-config';
19
20
 
20
21
  /**
@@ -387,6 +388,87 @@ describe('close-pr re-verifies the tip before retiring a branch', () => {
387
388
  });
388
389
  });
389
390
 
391
+ describe('differentLaneStillClaims uses .bitmap blob equality, not commit reachability', () => {
392
+ function executorWithInheritance(inherited: boolean) {
393
+ const executor = new LaneSyncExecutor({
394
+ lanes: {} as any,
395
+ ci: {} as any,
396
+ logger: { console: () => {}, consoleWarning: () => {}, error: () => {} } as any,
397
+ gitHost: undefined,
398
+ cfg: resolveSyncConfig({}),
399
+ defaultScope: 'acme.shop',
400
+ });
401
+ (executor as any).branchInheritsBitmapFromDefault = async () => inherited;
402
+ return (mirroredLane: string | undefined, laneIdStr: string) =>
403
+ (executor as any).differentLaneStillClaims(mirroredLane, laneIdStr, 'my-branch', 'main') as Promise<boolean>;
404
+ }
405
+
406
+ it('does not claim when there is no mirrored lane pointer at all — never even checks the blob', async () => {
407
+ const differentLaneStillClaims = executorWithInheritance(true);
408
+ expect(await differentLaneStillClaims(undefined, 'acme.shop/my-lane')).to.equal(false);
409
+ });
410
+
411
+ it("does not claim for this lane's own pointer — never even checks the blob", async () => {
412
+ const differentLaneStillClaims = executorWithInheritance(true);
413
+ expect(await differentLaneStillClaims('acme.shop/my-lane', 'acme.shop/my-lane')).to.equal(false);
414
+ });
415
+
416
+ it("does not claim when the different-lane .bitmap is byte-identical to the default branch's — inherited", async () => {
417
+ const differentLaneStillClaims = executorWithInheritance(true);
418
+ expect(await differentLaneStillClaims('acme.shop/other-lane', 'acme.shop/my-lane')).to.equal(false);
419
+ });
420
+
421
+ it("STILL claims when the different-lane .bitmap has diverged from the default branch's — halt, not adopt", async () => {
422
+ const differentLaneStillClaims = executorWithInheritance(false);
423
+ expect(await differentLaneStillClaims('acme.shop/other-lane', 'acme.shop/my-lane')).to.equal(true);
424
+ });
425
+ });
426
+
427
+ describe('computeHasIndependentHistory gates the ancestry check to own-live claims with a state commit', () => {
428
+ function executorWithAncestryResult(result: boolean) {
429
+ const calls: Array<[string, string]> = [];
430
+ const executor = new LaneSyncExecutor({
431
+ lanes: {} as any,
432
+ ci: {} as any,
433
+ logger: { console: () => {}, consoleWarning: () => {}, error: () => {} } as any,
434
+ gitHost: undefined,
435
+ cfg: resolveSyncConfig({}),
436
+ defaultScope: 'acme.shop',
437
+ });
438
+ (executor as any).hasIndependentHistoryBelowStateCommit = async (stateCommit: string, defaultBranch: string) => {
439
+ calls.push([stateCommit, defaultBranch]);
440
+ return result;
441
+ };
442
+ const compute = (ownership: LaneOwnershipEvidence, stateCommit: string | undefined, defaultBranch: string) =>
443
+ (executor as any).computeHasIndependentHistory(ownership, stateCommit, defaultBranch) as Promise<boolean>;
444
+ return { compute, calls };
445
+ }
446
+
447
+ it('skips the ancestry check entirely for a reachable claim (own-merged)', async () => {
448
+ const { compute, calls } = executorWithAncestryResult(true);
449
+ expect(await compute('own-merged', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(false);
450
+ expect(calls).to.deep.equal([]);
451
+ });
452
+
453
+ it('skips the ancestry check entirely for own-superseded too', async () => {
454
+ const { compute, calls } = executorWithAncestryResult(true);
455
+ expect(await compute('own-superseded', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(false);
456
+ expect(calls).to.deep.equal([]);
457
+ });
458
+
459
+ it('skips the check when there is no state commit to inspect, even for an own-live claim', async () => {
460
+ const { compute, calls } = executorWithAncestryResult(true);
461
+ expect(await compute('own-live', undefined, 'main')).to.equal(false);
462
+ expect(calls).to.deep.equal([]);
463
+ });
464
+
465
+ it('defers to the ancestry check for an own-live claim with a state commit', async () => {
466
+ const { compute, calls } = executorWithAncestryResult(true);
467
+ expect(await compute('own-live', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(true);
468
+ expect(calls).to.deep.equal([['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main']]);
469
+ });
470
+ });
471
+
390
472
  describe('classifyPushRejection confirms a race before calling it one', () => {
391
473
  const BASE_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
392
474
  const MOVED_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2';
@@ -446,9 +528,8 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
446
528
  (executor as any).lastNonSyncCommitMessage = async () => 'feat: some change';
447
529
  (executor as any).checkoutFromRemote = async () => {};
448
530
  (executor as any).restoreWorkspace = async () => {};
449
- // The export half already succeeded (undefined = no error, matching this file's `Error | undefined`
450
- // contract for `snapAndExportOntoLane`); only the ledger commit that follows raced.
451
- (executor as any).snapAndExportOntoLane = async () => undefined;
531
+ // The export half already succeeded; only the ledger commit that follows raced.
532
+ (executor as any).snapAndExportOntoLane = async () => ({ status: 'exported' });
452
533
  (executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
453
534
 
454
535
  const summary = await (executor as any).executeExportBranch({
@@ -465,6 +546,32 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
465
546
  });
466
547
  });
467
548
 
549
+ // Adoption never snaps or exports, so its raced line must NOT claim the lane was updated — the lane
550
+ // is untouched and only the branch's pointer commit lost the race.
551
+ describe('executeAdoptBranch reports a lane-untouched race when its ledger push races', () => {
552
+ it('surfaces "lane untouched; adoption ledger commit lost the push race" without halting', async () => {
553
+ const { executor } = stubExecutor();
554
+ (executor as any).deps.ci = { hasUnsyncedWorkChanges: async () => false };
555
+ (executor as any).checkoutFromRemote = async () => {};
556
+ (executor as any).restoreWorkspace = async () => {};
557
+ (executor as any).materializeLane = async () => undefined;
558
+ (executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
559
+
560
+ const summary = await (executor as any).executeAdoptBranch({
561
+ target: { hostScope: 'acme.shop', name: 'my-lane' },
562
+ laneIdStr: 'acme.shop/my-lane',
563
+ branch: 'my-lane',
564
+ defaultBranch: 'main',
565
+ });
566
+
567
+ expect(summary).to.equal(
568
+ 'my-lane -> raced (lane untouched; adoption ledger commit lost the push race — next run re-plans)'
569
+ );
570
+ expect(summary).to.not.include('lane updated');
571
+ expect(summary).to.not.include('HALTED');
572
+ });
573
+ });
574
+
468
575
  // A destructive auto-resolution (onConflict git-wins/lane-wins) that already exported must not vanish
469
576
  // from the summary just because the ledger push raced.
470
577
  describe('racedLedgerPushSummary keeps the conflict-policy clause', () => {
@@ -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';
@@ -89,6 +92,15 @@ export function racedLedgerPushSummary(laneName: string, policyClause = ''): str
89
92
  );
90
93
  }
91
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
+
92
104
  export type LaneSyncDeps = {
93
105
  lanes: LanesMain;
94
106
  /** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
@@ -245,6 +257,7 @@ export const KEPT_BECAUSE: Record<BranchKeepReason, string> = {
245
257
  'unmerged-commits': `it carries commits that are not in the default branch`,
246
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`,
247
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`,
248
261
  };
249
262
 
250
263
  export class LaneSyncExecutor {
@@ -423,6 +436,20 @@ export class LaneSyncExecutor {
423
436
  // `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
424
437
  // merely quotes the marker must not read as one we wrote.
425
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
+ );
426
453
 
427
454
  const action = planLaneSync({
428
455
  laneHead,
@@ -430,7 +457,9 @@ export class LaneSyncExecutor {
430
457
  lastSyncedHead,
431
458
  hasDevCommits: branchState.hasDevCommits,
432
459
  tipIsSyncCommit,
460
+ hasIndependentHistory,
433
461
  conflictLabelPresent,
462
+ branchNamesDifferentLane,
434
463
  ownership,
435
464
  });
436
465
 
@@ -497,6 +526,8 @@ export class LaneSyncExecutor {
497
526
  return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
498
527
  case 'merge-diverged':
499
528
  return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
529
+ case 'adopt-branch':
530
+ return this.executeAdoptBranch({ target, laneIdStr, branch, defaultBranch, pr });
500
531
  case 'close-pr':
501
532
  return this.executeClosePr({
502
533
  laneName,
@@ -508,8 +539,12 @@ export class LaneSyncExecutor {
508
539
  keepReason: action.deleteBranch ? undefined : action.keepReason,
509
540
  expectedTipSha: branchState.tipSha,
510
541
  });
511
- case 'halt':
512
- 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
+ }
513
548
  default: {
514
549
  // exhaustiveness guard
515
550
  const unhandled: never = action;
@@ -665,14 +700,14 @@ export class LaneSyncExecutor {
665
700
  await this.checkoutFromRemote(branch, `origin/${branch}`);
666
701
 
667
702
  try {
668
- const exportErr = await this.snapAndExportOntoLane(laneIdStr, message);
669
- if (exportErr) {
703
+ const exported = await this.snapAndExportOntoLane(laneIdStr, message);
704
+ if (exported.status === 'error') {
670
705
  // Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
671
706
  return await this.executeHalt({
672
707
  laneName,
673
708
  laneIdStr,
674
709
  branch,
675
- 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}`,
676
711
  pr: await this.findPr(branch),
677
712
  });
678
713
  }
@@ -697,6 +732,85 @@ export class LaneSyncExecutor {
697
732
  }
698
733
  }
699
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
+ );
809
+ } finally {
810
+ await this.restoreWorkspace(defaultBranch);
811
+ }
812
+ }
813
+
700
814
  /**
701
815
  * Both sides moved since the last sync. Order is load-bearing: (1) merge the lane into the branch's
702
816
  * working tree (`bit checkout head`; conflicts halt or auto-resolve per `sync.onConflict`), (2) snap
@@ -787,14 +901,14 @@ export class LaneSyncExecutor {
787
901
  }
788
902
 
789
903
  // ---- step 2: snap + export the merged tree onto the lane ---------------------------------
790
- const exportErr = await this.snapAndExportOntoLane(
904
+ const exported = await this.snapAndExportOntoLane(
791
905
  laneIdStr,
792
906
  `merge remote lane ${laneIdStr} into ${branch} ${SYNC_COMMIT_MARKER}`
793
907
  );
794
- if (exportErr) {
908
+ if (exported.status === 'error') {
795
909
  return await halt(
796
910
  `merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` +
797
- `result failed: ${exportErr.message}`
911
+ `result failed: ${exported.error.message}`
798
912
  );
799
913
  }
800
914
 
@@ -819,18 +933,20 @@ export class LaneSyncExecutor {
819
933
  }
820
934
 
821
935
  /**
822
- * Snap the workspace's current tree onto the lane and export it; returns the error so the caller can
823
- * halt. Snaps WHATEVER is in the workspace (the switch uses `forceOurs` and never merges files), so
824
- * a diverged tree must already hold the merged content. `keepLane` preserves the lane's history,
825
- * `skipCleanup` keeps the snap's `.bitmap` for the caller to commit, `noDestructiveRecovery` turns
826
- * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be
827
- * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any
828
- * 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.
829
942
  */
830
- 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 }> {
831
947
  try {
832
948
  await ensureCurrentLaneObject(this.deps.lanes);
833
- await this.deps.ci.snapPrCommit({
949
+ const result = await this.deps.ci.snapPrCommit({
834
950
  laneIdStr,
835
951
  message,
836
952
  build: undefined,
@@ -839,9 +955,10 @@ export class LaneSyncExecutor {
839
955
  skipCleanup: true,
840
956
  noDestructiveRecovery: true,
841
957
  });
842
- return undefined;
958
+ if (result === NO_CHANGES_TO_SNAP) return { status: 'noop' };
959
+ return { status: 'exported' };
843
960
  } catch (e: any) {
844
- 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)) };
845
962
  }
846
963
  }
847
964
 
@@ -850,17 +967,18 @@ export class LaneSyncExecutor {
850
967
  * so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
851
968
  * `unreadable` means the lane could no longer be read back, in which case the caller halts;
852
969
  * `raced` means a concurrent run's push already got there first, in which case the caller reports
853
- * a benign noop instead.
970
+ * a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
854
971
  */
855
972
  private async recordLaneHeadOnBranch(
856
973
  target: LaneTarget,
857
974
  laneIdStr: string,
858
- branch: string
975
+ branch: string,
976
+ opts: { adopted?: boolean } = {}
859
977
  ): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
860
978
  const remoteLane = await this.getRemoteLane(target);
861
979
  if (!remoteLane) return { status: 'unreadable' };
862
980
  const laneHead = laneHeadFingerprint(remoteLane.components);
863
- const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
981
+ const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
864
982
  if (pushResult.raced) return { status: 'raced' };
865
983
  return { status: 'ok', laneHead };
866
984
  }
@@ -977,6 +1095,80 @@ export class LaneSyncExecutor {
977
1095
  }
978
1096
  }
979
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
+
980
1172
  /**
981
1173
  * The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
982
1174
  * Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
@@ -1178,14 +1370,17 @@ export class LaneSyncExecutor {
1178
1370
  }
1179
1371
 
1180
1372
  /**
1181
- * Drive the workspace's filesystem to the remote lane's content; returns the error so the caller can
1182
- * halt. Two traps make a plain `switchToLane` insufficient: (1) it defaults to `forceOurs`, which
1183
- * never touches the filesystem — the commit would claim the branch mirrors the lane over the default
1184
- * branch's files — so `forceOurs` is cleared and `forceTheirs` set; (2) switching onto the lane the
1185
- * workspace already sits on throws "already checked out", which `switchToLane` swallows as success
1186
- * 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.
1187
1382
  */
1188
- private async materializeLane(laneIdStr: string): Promise<Error | undefined> {
1383
+ private async materializeLane(laneIdStr: string, theirsWins = true): Promise<Error | undefined> {
1189
1384
  const { logger } = this.deps;
1190
1385
  const target = await this.deps.lanes.parseLaneId(laneIdStr);
1191
1386
  // Compares name AND scope, and reads from `.bitmap`, not the scope's lane object — the object read
@@ -1206,9 +1401,16 @@ export class LaneSyncExecutor {
1206
1401
  }
1207
1402
 
1208
1403
  const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, {
1209
- forceOurs: false,
1210
- forceTheirs: true,
1211
- 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,
1212
1414
  });
1213
1415
  if (switchErr) return switchErr;
1214
1416
 
@@ -62,6 +62,13 @@ const table: Array<{
62
62
  type: 'close-pr',
63
63
  action: { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' },
64
64
  },
65
+ {
66
+ // Adoption proved content matched, never that the branch's pre-existing history is disposable.
67
+ name: 'own-live, no dev commits, OUR tip, but it is the adoption ledger commit -> KEEP, never delete',
68
+ input: { ...laneGone('own-live'), hasIndependentHistory: true },
69
+ type: 'close-pr',
70
+ action: { type: 'close-pr', deleteBranch: false, keepReason: 'adopted-branch' },
71
+ },
65
72
  {
66
73
  name: 'own-merged (branch tip already in the default branch) -> close-pr AND delete',
67
74
  input: laneGone('own-merged'),
@@ -75,6 +82,12 @@ const table: Array<{
75
82
  type: 'close-pr',
76
83
  action: { type: 'close-pr', deleteBranch: true },
77
84
  },
85
+ {
86
+ name: 'own-merged deletes even when the tip is the adoption ledger commit — reachability is enough',
87
+ input: { ...laneGone('own-merged'), hasIndependentHistory: true },
88
+ type: 'close-pr',
89
+ action: { type: 'close-pr', deleteBranch: true },
90
+ },
78
91
  {
79
92
  name: 'own-superseded (PR merged, then more commits pushed) -> close-pr but KEEP the branch',
80
93
  input: laneGone('own-superseded'),
@@ -108,9 +121,31 @@ const table: Array<{
108
121
  type: 'import-lane',
109
122
  },
110
123
  {
111
- name: 'existing branch, never synced, has dev commits, lane EXISTS -> halt (ambiguous)',
124
+ name: 'existing branch, never synced, has dev commits, lane EXISTS -> adopt-branch (first contact)',
112
125
  input: { lastSyncedHead: undefined, ownership: 'inherited-or-none', hasDevCommits: true },
126
+ type: 'adopt-branch',
127
+ },
128
+ {
129
+ name: 'existing branch, never synced, has dev commits, but .bitmap names a DIFFERENT lane -> halt',
130
+ input: {
131
+ lastSyncedHead: undefined,
132
+ ownership: 'inherited-or-none',
133
+ hasDevCommits: true,
134
+ branchNamesDifferentLane: true,
135
+ },
113
136
  type: 'halt',
137
+ reason: 'names a different lane',
138
+ },
139
+ {
140
+ // The executor resolves an inherited (merged) different-lane pointer to `false` — history, not a claim.
141
+ name: 'existing branch, has dev commits, .bitmap names a MERGED-AND-INHERITED different lane -> adopt-branch',
142
+ input: {
143
+ lastSyncedHead: undefined,
144
+ ownership: 'inherited-or-none',
145
+ hasDevCommits: true,
146
+ branchNamesDifferentLane: false,
147
+ },
148
+ type: 'adopt-branch',
114
149
  },
115
150
  ];
116
151