@teambit/ci 1.0.382 → 1.0.384

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.
@@ -74,6 +74,27 @@ function _checkout() {
74
74
  };
75
75
  return data;
76
76
  }
77
+ function _component() {
78
+ const data = require("@teambit/component.snap-distance");
79
+ _component = function () {
80
+ return data;
81
+ };
82
+ return data;
83
+ }
84
+ function _configMerger() {
85
+ const data = require("@teambit/config-merger");
86
+ _configMerger = function () {
87
+ return data;
88
+ };
89
+ return data;
90
+ }
91
+ function _dependencyResolver() {
92
+ const data = require("@teambit/dependency-resolver");
93
+ _dependencyResolver = function () {
94
+ return data;
95
+ };
96
+ return data;
97
+ }
77
98
  function _execa() {
78
99
  const data = _interopRequireDefault(require("execa"));
79
100
  _execa = function () {
@@ -144,6 +165,13 @@ function _lodash() {
144
165
  };
145
166
  return data;
146
167
  }
168
+ function _objects() {
169
+ const data = require("@teambit/objects");
170
+ _objects = function () {
171
+ return data;
172
+ };
173
+ return data;
174
+ }
147
175
  function _sourceBranchDetector() {
148
176
  const data = require("./source-branch-detector");
149
177
  _sourceBranchDetector = function () {
@@ -164,13 +192,17 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
164
192
  function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
165
193
  function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
166
194
  function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
167
- /**
168
- * Sentinel substring emitted by the hub when a lane push is rejected because a lane with the same
169
- * id already exists with a different hash. Thrown as `BitError` from
170
- * `components/legacy/scope/repositories/sources.ts` and wrapped in `UnexpectedNetworkError` across
171
- * the wire, so we match on the message text rather than an error class.
172
- */
195
+ // Two distinct conflicts can surface from the remote on a concurrent `bit ci pr` race.
196
+ // LANE_HASH_MISMATCH fires when both runners called `Lane.create` (the lane didn't exist on
197
+ // the remote yet), so they each minted a random `sha1(v4())` hash `sources.mergeLane` then
198
+ // rejects the second push's lane object on hash mismatch.
199
+ // COMPONENT_DIVERGENCE fires when the lane already exists (both runners `switchToLane`'d
200
+ // and got the same lane hash) but they both snapped the SAME component with DIFFERENT
201
+ // content — `mergeLane`'s per-component diverge check collects a `ComponentNeedsUpdate`
202
+ // and throws `MergeConflictOnRemote("merge error occurred when exporting the component(s)…")`.
203
+ // Both recover through the same adopt-and-rebase path in `rebaseOntoRemoteLane`.
173
204
  const LANE_HASH_MISMATCH_MARKER = 'a lane with the same id already exists with a different hash';
205
+ const COMPONENT_DIVERGENCE_MARKER = 'merge error occurred when exporting';
174
206
  class CiMain {
175
207
  constructor(workspace, builder, status, lanes, snapping, exporter, importer, checkout, logger, config) {
176
208
  this.workspace = workspace;
@@ -362,6 +394,151 @@ class CiMain {
362
394
  this.logger.console(_chalk().default.red(`Failed switching to ${laneName}: ${e.toString()}`));
363
395
  }
364
396
  }
397
+
398
+ /**
399
+ * Sync *config-only* changes from main onto the lane — without a full `bit lane merge`.
400
+ *
401
+ * In this workflow git is the source of truth for files: the PR author merges the default branch
402
+ * into their PR branch, so source changes arrive via git. The one thing git can't carry is
403
+ * config that's already been *tagged into objects* on main — e.g. another PR ran `bit env set` /
404
+ * `bit deps set`; those records lived in `.bitmap`, rode git into main, and `bit ci merge` baked
405
+ * them into the component's Version (clearing them from `.bitmap`). A long-running PR's lane
406
+ * would otherwise miss them.
407
+ *
408
+ * A full lane merge is the wrong tool here: it does a 3-way *file* merge and refuses to run while
409
+ * the workspace has modified components — but in `bit ci pr` the workspace is always dirty (the
410
+ * PR's changes, not yet snapped). So instead we do a per-component 3-way merge of the aspect
411
+ * *config only* (base = common ancestor, ours = lane, theirs = main), keeping the PR's config on
412
+ * conflict, and stash the result on an `unmergedComponents` entry's `mergedConfig`. The
413
+ * subsequent `snap` reads it (via the aspects-merger on component load) and bakes main's config
414
+ * into the new snap, while the snap's files still come from the workspace (git). No file
415
+ * checkout, so no clean-workspace requirement.
416
+ */
417
+ async syncConfigFromMain(laneId) {
418
+ const legacyScope = this.workspace.scope.legacyScope;
419
+ const repo = legacyScope.objects;
420
+ const mainLaneId = this.lanes.getDefaultLaneId();
421
+ const currentLane = await this.lanes.getCurrentLane();
422
+ if (!currentLane) return;
423
+ const workspaceIds = this.workspace.listIds();
424
+ this.logger.console(_chalk().default.blue(`Syncing config changes from ${mainLaneId.toString()} into ${laneId.toString()}`));
425
+ const syncedIds = [];
426
+ for (const laneComp of currentLane.components) {
427
+ try {
428
+ const modelComponent = await legacyScope.getModelComponent(laneComp.id);
429
+ const mainHead = modelComponent.head; // the component's head on main
430
+ if (!mainHead) continue; // component isn't on main — nothing to sync from there
431
+ const laneHead = laneComp.head;
432
+ if (mainHead.isEqual(laneHead)) continue; // lane already points at main's head
433
+
434
+ const divergeData = await (0, _component().getDivergeData)({
435
+ repo,
436
+ modelComponent,
437
+ sourceHead: laneHead,
438
+ targetHead: mainHead,
439
+ throws: false
440
+ });
441
+ // Only sync when main has snaps the lane doesn't (target ahead, or diverged). If the lane
442
+ // is ahead-only / equal there's nothing on main to bring in.
443
+ if (!divergeData.isTargetAhead() && !divergeData.isDiverged()) continue;
444
+ const currentVersion = await modelComponent.loadVersion(laneHead.toString(), repo);
445
+ const otherVersion = await modelComponent.loadVersion(mainHead.toString(), repo);
446
+ // base = common ancestor. When the lane is strictly behind main (no divergence) the common
447
+ // ancestor IS the lane head, so the lane's own aspects serve as the base.
448
+ const baseSnap = divergeData.commonSnapBeforeDiverge;
449
+ const baseVersion = baseSnap ? await modelComponent.loadVersion(baseSnap.toString(), repo) : currentVersion;
450
+ const configMerger = new (_configMerger().ComponentConfigMerger)(laneComp.id.toStringWithoutVersion(), workspaceIds, undefined,
451
+ // merging from main (the default lane) — there's no Lane object for it
452
+ currentVersion.extensions, baseVersion.extensions, otherVersion.extensions, laneId.toString(), mainLaneId.toString(), this.logger, 'ours' // keep the PR's config on a genuine conflict
453
+ );
454
+ const mergedConfig = configMerger.merge().getSuccessfullyMergedConfig();
455
+ if (!mergedConfig || !Object.keys(mergedConfig).length) continue;
456
+
457
+ // Strip dependency deletion markers (version: '-'); the aspects-merger applies mergedConfig
458
+ // as-is, so a leftover '-' would land in the policy.
459
+ this.filterDeletedDependenciesFromConfig(mergedConfig);
460
+
461
+ // Upsert: addEntry throws if an entry for this component already exists. A prior
462
+ // --keep-lane run that crashed mid-snap (or otherwise left unmerged.json entries behind)
463
+ // would otherwise make every later run throw here, skip the component, and keep serving
464
+ // stale config. Remove any existing entry first so repeated runs converge on main's latest.
465
+ legacyScope.objects.unmergedComponents.removeComponent(laneComp.id);
466
+ legacyScope.objects.unmergedComponents.addEntry({
467
+ id: {
468
+ scope: laneComp.id.scope,
469
+ name: laneComp.id.fullName
470
+ },
471
+ head: mainHead,
472
+ laneId: mainLaneId,
473
+ mergedConfig
474
+ });
475
+ syncedIds.push(laneComp.id);
476
+ this.logger.console(_chalk().default.blue(` ${laneComp.id.toStringWithoutVersion()}: applying main's config (${Object.keys(mergedConfig).join(', ')})`));
477
+ } catch (e) {
478
+ // Best-effort per component: one component's config-merge quirk shouldn't abort the whole
479
+ // `bit ci pr`. Log and move on — the build just won't reflect that component's main-side
480
+ // config this run.
481
+ this.logger.console(_chalk().default.yellow(` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})`));
482
+ }
483
+ }
484
+ if (!syncedIds.length) {
485
+ this.logger.console(_chalk().default.blue('No config changes from main to sync'));
486
+ return;
487
+ }
488
+ await legacyScope.objects.unmergedComponents.write();
489
+ // The components were already loaded (and their aspects cached) earlier in this run, before the
490
+ // unmergedComponents entries existed. Clear the cache so the upcoming `snap` reloads them and
491
+ // the aspects-merger folds in the synced `mergedConfig`.
492
+ this.workspace.clearAllComponentsCache();
493
+ this.logger.console(_chalk().default.green(`Synced config from main for ${syncedIds.length} component(s)`));
494
+ }
495
+
496
+ /**
497
+ * Copied from `merging.main.runtime` (`filterDeletedDependenciesFromConfig`): the config merge
498
+ * can emit deletion markers (`version: '-'`) for deps removed on main. The aspects-merger applies
499
+ * `mergedConfig` verbatim, so strip those here to avoid writing a policy entry with version '-'.
500
+ */
501
+ filterDeletedDependenciesFromConfig(mergeConfig) {
502
+ const policy = mergeConfig?.[_dependencyResolver().DependencyResolverAspect.id]?.policy;
503
+ if (!policy) return;
504
+ Object.keys(policy).forEach(depType => {
505
+ const filtered = policy[depType].filter(dep => dep.version !== '-');
506
+ if (filtered.length === 0) delete policy[depType];else policy[depType] = filtered;
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Best-effort, fetch-free check for whether the current (PR) branch is *behind* the default
512
+ * branch — i.e. the default branch has commits the PR branch doesn't contain.
513
+ *
514
+ * We intentionally do NOT `git fetch` here (a fetch in CI can hang on an interactive SSH
515
+ * host-key prompt — that's why `isStaleCiRun` was removed in #10300). We compare against
516
+ * whatever `origin/<default>` ref the checkout already has, which reflects the state the default
517
+ * branch was in when this CI run started — exactly the reference point we care about.
518
+ *
519
+ * Returns true only when we can *confirm* the branch is behind. If the ref can't be resolved or
520
+ * anything else goes wrong, returns false (treat as "not behind" / proceed) so we never silently
521
+ * disable the main→lane config propagation.
522
+ */
523
+ async isBranchBehindDefaultBranch() {
524
+ try {
525
+ const defaultBranch = await this.getDefaultBranchName();
526
+ const defaultRefSha = (await _git().git.revparse([`refs/remotes/origin/${defaultBranch}`])).trim();
527
+ const headSha = (await _git().git.revparse(['HEAD'])).trim();
528
+ if (defaultRefSha === headSha) return false; // identical → up to date
529
+ // We deliberately do NOT use `merge-base --is-ancestor`: it reports the answer via exit code
530
+ // (0 = ancestor, 1 = not), but simple-git's `raw` resolves rather than rejects on exit code
531
+ // 1, so the "not an ancestor" case was silently read as "is an ancestor" — the behind check
532
+ // never fired. Instead compute the merge-base and compare: `merge-base(A, B) === A` iff A is
533
+ // an ancestor of B. When origin/<default> is an ancestor of HEAD the PR already contains it
534
+ // (not behind); otherwise the default branch has commits HEAD doesn't (behind).
535
+ const mergeBase = (await _git().git.raw(['merge-base', defaultRefSha, headSha])).trim();
536
+ return mergeBase !== defaultRefSha;
537
+ } catch (err) {
538
+ this.logger.console(_chalk().default.yellow(`Could not determine whether the PR branch is up to date with the default branch ` + `(proceeding as if up to date): ${err?.message || err}`));
539
+ return false;
540
+ }
541
+ }
365
542
  async verifyWorkspaceStatus() {
366
543
  await this.verifyWorkspaceStatusInternal();
367
544
  this.logger.console('🔨 Build Process');
@@ -380,7 +557,8 @@ class CiMain {
380
557
  message,
381
558
  build,
382
559
  strict,
383
- dryRun
560
+ dryRun,
561
+ keepLane
384
562
  }) {
385
563
  this.logger.console(_chalk().default.blue(`Lane name: ${laneIdStr}`));
386
564
  const originalLane = await this.lanes.getCurrentLane();
@@ -395,6 +573,159 @@ class CiMain {
395
573
  });
396
574
  this.logger.console('🔄 Lane Management');
397
575
 
576
+ // `--keep-lane` opts into reusing the same remote lane across subsequent commits to a PR, so
577
+ // the lane's history and any lane-based UI edits on Bit Cloud survive. Without it we use the
578
+ // default, battle-tested flow: snap onto a throwaway temp lane and delete+recreate the final
579
+ // lane at export time (the lane is recreated on every PR commit).
580
+ if (keepLane) {
581
+ return this.snapAndExportReusingLane({
582
+ laneId,
583
+ originalLane,
584
+ message,
585
+ build,
586
+ dryRun
587
+ });
588
+ }
589
+ return this.snapAndExportWithTempLane({
590
+ laneId,
591
+ originalLane,
592
+ message,
593
+ build,
594
+ dryRun
595
+ });
596
+ }
597
+
598
+ /**
599
+ * `--keep-lane` flow: reuse the existing remote lane (or create it on the first run), merge main
600
+ * into it to pick up config changes that landed since the fork, snap, and export with
601
+ * adopt-on-conflict recovery for concurrent CI pushes. The lane object is preserved across PR
602
+ * commits, so its history and lane-based UI edits on Bit Cloud survive.
603
+ */
604
+ async snapAndExportReusingLane({
605
+ laneId,
606
+ originalLane,
607
+ message,
608
+ build,
609
+ dryRun
610
+ }) {
611
+ // Query the remote (by name, to avoid fetching all lanes) so we know whether to reuse or create
612
+ const existingLanes = await this.lanes.getLanes({
613
+ remote: laneId.scope,
614
+ name: laneId.name
615
+ }).catch(e => {
616
+ if (e.toString().includes('was not found')) return [];
617
+ throw new Error(`Failed to check lane ${laneId.toString()}: ${e.toString()}`);
618
+ });
619
+ const laneExists = existingLanes.length > 0;
620
+ let foundErr;
621
+ try {
622
+ if (laneExists) {
623
+ // Reuse the existing remote lane so that the lane history, lane-based UI edits, and
624
+ // lane-history feature on Bit Cloud all survive across subsequent commits to the same PR.
625
+ // switchToLane fetches the latest lane head from remote.
626
+ this.logger.console(_chalk().default.blue(`Lane ${laneId.toString()} exists on remote, reusing it`));
627
+ await this.switchToLane(laneId.toString());
628
+ // Verify the switch actually landed us on the lane before doing any lane work.
629
+ // switchToLane logs-and-swallows switch failures, so without this guard a failed switch
630
+ // would let syncConfigFromMain and snap run against the wrong lane.
631
+ const switchedLane = await this.lanes.getCurrentLane();
632
+ if (switchedLane?.name !== laneId.name) {
633
+ throw new Error(`Expected to be on lane ${laneId.name} after switching, but current lane is ${switchedLane?.name ?? 'main'}`);
634
+ }
635
+ // Sync config-only changes from main onto the lane, so config that was tagged into objects
636
+ // on main since the lane forked (e.g. `bit deps set` / `bit env set` from another PR, not
637
+ // visible via the workspace's git checkout) is reflected on the lane. Source files are
638
+ // git's job — see syncConfigFromMain.
639
+ //
640
+ // BUT only when the PR branch is actually up to date with the default branch. If the PR is
641
+ // behind (hasn't pulled main's latest), its git checkout still reflects the older fork
642
+ // point, so pulling main's newer config onto the lane would desync the lane from the
643
+ // source. The author merges the default branch into their PR in git; the next `bit ci pr`
644
+ // then propagates it here.
645
+ if (await this.isBranchBehindDefaultBranch()) {
646
+ this.logger.console(_chalk().default.yellow(`PR branch is behind the default branch — skipping config sync from main. ` + `Merge or rebase the default branch into your PR to pick up main's latest config.`));
647
+ } else {
648
+ await this.syncConfigFromMain(laneId);
649
+ }
650
+ } else {
651
+ this.logger.console(_chalk().default.blue(`Creating lane ${laneId.toString()}`));
652
+ const createLaneResult = await this.lanes.createLane(laneId.name, {
653
+ scope: laneId.scope,
654
+ forkLaneNewScope: true
655
+ });
656
+ this.logger.console(_chalk().default.blue(`Created lane ${laneId.toString()} (hash: ${createLaneResult.hash})`));
657
+ }
658
+ const currentLane = await this.lanes.getCurrentLane();
659
+ this.logger.console(_chalk().default.blue(`Current lane: ${currentLane?.name ?? 'main'}`));
660
+ if (currentLane?.name !== laneId.name) {
661
+ throw new Error(`Expected to be on lane ${laneId.name}, but current lane is ${currentLane?.name ?? 'main'}`);
662
+ }
663
+ this.logger.console('📦 Snapping Components');
664
+ const results = await this.snapping.snap({
665
+ message,
666
+ build,
667
+ exitOnFirstFailedTask: true
668
+ });
669
+ if (!results) {
670
+ this.logger.console(_chalk().default.yellow('No changes detected, nothing to snap'));
671
+ return 'No changes detected, nothing to snap';
672
+ }
673
+ const {
674
+ snappedComponents
675
+ } = results;
676
+ const snapOutput = (0, _snapping().snapResultOutput)(results);
677
+ this.logger.console(snapOutput);
678
+ if (dryRun) {
679
+ this.logger.console(_chalk().default.yellow('🏃 Dry-run mode: skipping export'));
680
+ this.logger.console(_chalk().default.green(`Snapped ${snappedComponents.length} component(s) successfully`));
681
+ return snapOutput;
682
+ }
683
+ this.logger.console(_chalk().default.blue(`Exporting ${snappedComponents.length} components`));
684
+ await this.exportWithAdoptOnConflict(laneId, snappedComponents);
685
+ } catch (e) {
686
+ foundErr = e;
687
+ throw e;
688
+ } finally {
689
+ if (foundErr) {
690
+ this.logger.console(_chalk().default.red(`Found error: ${foundErr.message}`));
691
+ }
692
+ // Best-effort cleanup: switch back to the original lane/main. Wrap it so a cleanup
693
+ // failure (failed switch/checkout) only warns instead of throwing out of `finally` and
694
+ // masking the real error from snap/export above (also avoids no-unsafe-finally).
695
+ try {
696
+ this.logger.console('🔄 Cleanup');
697
+ const targetLane = originalLane?.name ?? 'main';
698
+ this.logger.console(_chalk().default.blue(`Switching back to ${targetLane}`));
699
+ const currentLane = await this.lanes.getCurrentLane();
700
+ if (currentLane) {
701
+ await this.switchToLane(targetLane);
702
+ } else {
703
+ this.logger.console(_chalk().default.yellow('Already on main, checking out to head'));
704
+ await this.lanes.checkout.checkout({
705
+ head: true,
706
+ skipNpmInstall: true
707
+ });
708
+ }
709
+ } catch (cleanupErr) {
710
+ this.logger.consoleWarning(`Cleanup after PR snap failed: ${cleanupErr.message}`);
711
+ }
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Default flow: snap onto a uniquely-named temporary lane, then at export time delete any
717
+ * existing remote lane and rename the temp lane to the final name. The temp name minimizes the
718
+ * race window when multiple CI jobs run concurrently on the same branch. Trade-off: the final
719
+ * lane is recreated on every PR commit, so its history and any lane-based UI edits on Bit Cloud
720
+ * don't survive across commits — use `--keep-lane` for that.
721
+ */
722
+ async snapAndExportWithTempLane({
723
+ laneId,
724
+ originalLane,
725
+ message,
726
+ build,
727
+ dryRun
728
+ }) {
398
729
  // Use unique temp lane name to avoid race conditions when multiple CI jobs run concurrently
399
730
  const tempLaneName = `${laneId.name}-${(0, _toolboxString().generateRandomStr)(5)}`;
400
731
  let foundErr;
@@ -525,6 +856,178 @@ class CiMain {
525
856
  }
526
857
  }
527
858
  }
859
+
860
+ /**
861
+ * Export with a recovery path for the two concurrent-CI conflicts that can surface from the
862
+ * remote (see the marker constants at the top of the file): lane-hash mismatch (both runners
863
+ * created fresh lane objects when the lane didn't yet exist on the remote) and per-component
864
+ * divergence (both reused the existing lane but snapped the same component with different
865
+ * content).
866
+ *
867
+ * Recovery: adopt-the-winner. The remote lane (whoever pushed first) becomes canonical. We
868
+ * drop our local lane object, fetch the remote, rebase our snapped Version objects so each
869
+ * one's parent points to the remote head for that component, then swap those rebased Versions
870
+ * in as the new lane heads and re-export. Build artifacts are preserved — only the parent
871
+ * pointers on the Version objects change. Result: both runners' snaps end up chained on a
872
+ * single lane object (last-writer-wins on content for any contested component, with the
873
+ * winner's snap preserved in history as the parent).
874
+ */
875
+ async exportWithAdoptOnConflict(laneId, snappedComponents) {
876
+ try {
877
+ const exportResults = await this.exporter.export();
878
+ this.logger.console(_chalk().default.green(`Exported ${exportResults.componentsIds.length} components`));
879
+ return;
880
+ } catch (e) {
881
+ const msg = e?.message || e?.toString() || '';
882
+ const isLaneHashMismatch = msg.includes(LANE_HASH_MISMATCH_MARKER);
883
+ const isComponentDivergence = msg.includes(COMPONENT_DIVERGENCE_MARKER);
884
+ if (!isLaneHashMismatch && !isComponentDivergence) throw e;
885
+ const cause = isLaneHashMismatch ? 'Lane hash mismatch' : 'Per-component divergence';
886
+ this.logger.console(_chalk().default.yellow(`${cause} on "${laneId.toString()}" — likely a concurrent CI push. Adopting the remote lane and rebasing local snaps onto its heads.`));
887
+ }
888
+ const snappedHeads = snappedComponents.map(c => {
889
+ // A just-snapped component always has a version; guard defensively so a missing one fails
890
+ // with a clear message instead of `Ref.from(undefined)`'s opaque "hash argument is empty".
891
+ if (!c.version) {
892
+ throw new Error(`unable to recover from the lane-hash mismatch: snapped component "${c.id.toString()}" has no version to rebase onto the remote lane`);
893
+ }
894
+ return {
895
+ id: c.id,
896
+ head: _objects().Ref.from(c.version)
897
+ };
898
+ });
899
+ await this.rebaseOntoRemoteLane(laneId, snappedHeads);
900
+ this.logger.console(_chalk().default.blue('Retrying export with rebased snaps'));
901
+ const exportResults = await this.exportWithBusyRetry();
902
+ this.logger.console(_chalk().default.green(`Exported ${exportResults.componentsIds.length} components after rebase`));
903
+ }
904
+
905
+ /**
906
+ * Wrap `exporter.export()` with retry on the "server is busy" error. The retried export's
907
+ * pending-dir lands behind whichever concurrent client is still in the remote's queue (we
908
+ * arrived second by definition — we're the loser of the original race). The 60s wait inside
909
+ * `export-validate.waitIfNeeded` covers the common case, but on slow CI hosts or large pushes
910
+ * we sometimes time out before the other client finishes its persist. A short sleep + retry
911
+ * here just gives the queue room to drain.
912
+ */
913
+ async exportWithBusyRetry(maxAttempts = 3) {
914
+ const isBusyErr = err => (err?.message || err?.toString() || '').includes('server is busy by other exports');
915
+ let lastErr;
916
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
917
+ try {
918
+ return await this.exporter.export();
919
+ } catch (e) {
920
+ lastErr = e;
921
+ if (!isBusyErr(e)) throw e;
922
+ this.logger.console(_chalk().default.yellow(`Export attempt ${attempt}/${maxAttempts} blocked by a busy remote queue. Waiting before retrying.`));
923
+ await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
924
+ }
925
+ }
926
+ throw lastErr;
927
+ }
928
+ async rebaseOntoRemoteLane(laneId, snappedHeads) {
929
+ const legacyScope = this.workspace.scope.legacyScope;
930
+ const repo = legacyScope.objects;
931
+
932
+ // Our local lane object (the one we just snapped onto) shares the LaneId with the remote
933
+ // (winning) lane but has a different, randomly-minted hash. Fetching the remote lane writes it
934
+ // into our scope via `sources.mergeLane`, which rejects a same-id/different-hash lane ("a lane
935
+ // with the same id already exists with a different hash") — so we must drop our local lane
936
+ // object BEFORE the fetch, not after. We can't use `lanes.removeLanes` (it refuses to remove
937
+ // the currently-checked-out lane), so we trash the lane object directly; that also removes it
938
+ // from the scope index, so `loadLane` (the guard's lookup) no longer finds it. Only the lane
939
+ // pointer is trashed — the Version objects we snapped stay in the scope for the rebase below,
940
+ // and the fetch immediately re-persists a same-id lane object, satisfying the current-lane
941
+ // workspace pointer again.
942
+ const localLane = await legacyScope.loadLane(laneId);
943
+ if (localLane) {
944
+ this.logger.console(_chalk().default.blue(`Dropping local lane object ${laneId.toString()} to adopt the remote one`));
945
+ await repo.moveObjectsToTrash([localLane.hash()]);
946
+ }
947
+
948
+ // Fetch the remote (winning) lane and the Version objects it points at. With our local lane
949
+ // object gone, `mergeLane` sees no conflicting same-id lane and persists the remote one.
950
+ this.logger.console(_chalk().default.blue(`Fetching remote lane ${laneId.toString()}`));
951
+ const remoteLane = await this.lanes.fetchLaneWithItsComponents(laneId);
952
+
953
+ // Rewrite each snapped Version's parent to point at the remote head for that component.
954
+ // Bit's Version objects aren't content-addressed — `_hash` is set once and not derived
955
+ // from content — so we can mutate `parents` in place. The hash stays the same, so the
956
+ // build artifacts referenced from the Version remain valid and the lane's component head
957
+ // doesn't need to be re-pointed. The remote receives the updated Version because our
958
+ // first failed export attempt was rejected during the export-validate step (via
959
+ // `sources.mergeLane`'s same-id/different-hash guard) — *before* `ExportPersist` writes any
960
+ // files to disk — so the remote doesn't yet have this hash and won't dedupe-skip it during
961
+ // transfer.
962
+ for (const snap of snappedHeads) {
963
+ const remoteComp = remoteLane.components.find(c => c.id.isEqualWithoutVersion(snap.id));
964
+ if (!remoteComp) continue; // component is only on our lane, not on the remote — no rebase target
965
+ const remoteHead = remoteComp.head;
966
+ if (snap.head.isEqual(remoteHead)) continue;
967
+ const version = await repo.load(snap.head);
968
+ if (!version) {
969
+ throw new Error(`rebaseOntoRemoteLane: unable to load Version object for ${snap.id.toString()} hash ${snap.head.toString()}`);
970
+ }
971
+ if (version.parents.some(p => p.isEqual(remoteHead))) continue; // already chains correctly
972
+
973
+ const beforeParents = version.parents.map(p => p.toString().slice(0, 9)).join(',');
974
+ // Re-point only the lane-lineage parent (the first parent — the predecessor snap on the
975
+ // lane) to the remote head, preserving any additional parents. A snap produced after
976
+ // `syncConfigFromMain` is a merge snap whose second parent links to main's head; overwriting
977
+ // the whole array with `[remoteHead]` would silently drop that merge edge and corrupt the
978
+ // lane's ancestry.
979
+ version.parents = [remoteHead, ...version.parents.slice(1)];
980
+ const afterParents = version.parents.map(p => p.toString().slice(0, 9)).join(',');
981
+ this.logger.console(_chalk().default.blue(`Rebasing ${snap.id.toString()}@${snap.head.toString().slice(0, 9)}: parents [${beforeParents}] → [${afterParents}]`));
982
+ repo.add(version);
983
+
984
+ // Keep the local VersionHistory in sync with the rewritten parents. The first (failed)
985
+ // export already traversed and wrote VersionHistory with this version's *original* parent;
986
+ // without this update the re-export's diverge computation reads that stale history, never
987
+ // sees the remote head as an ancestor, and can send the wrong version set or throw a
988
+ // spurious "no common snap" error. `updateRebasedVersionHistory` only touches the entry if
989
+ // this version already exists in the history (it does, from that first traversal).
990
+ const modelComponent = await legacyScope.getModelComponent(snap.id);
991
+ const versionHistory = await modelComponent.updateRebasedVersionHistory(repo, [version]);
992
+ if (versionHistory) repo.add(versionHistory);
993
+ }
994
+
995
+ // Replace the remote lane's component heads with our snapped Versions. Anything we
996
+ // didn't snap stays as the remote had it.
997
+ //
998
+ // TODO(stale-runner): if two consecutive PR commits trigger two CI runs and the older one
999
+ // finishes last, it will rebase its (older-content) snap on top of the newer one — the
1000
+ // newer commit's snap stays in history but the lane head regresses to the older content.
1001
+ // The fix needs git-SHA-aware staleness detection (embed `git rev-parse HEAD` in the snap
1002
+ // log on creation, compare against the remote head's stored SHA on rebase, abort if our
1003
+ // commit is an ancestor of theirs). The prior `isStaleCiRun` (removed in #10300 for
1004
+ // SSH-prompt reasons) attempted this via `git fetch`; we'd want a fetch-free variant here.
1005
+ const updatedComponents = remoteLane.components.map(c => {
1006
+ const snap = snappedHeads.find(s => s.id.isEqualWithoutVersion(c.id));
1007
+ return snap ? _objectSpread(_objectSpread({}, c), {}, {
1008
+ head: snap.head
1009
+ }) : c;
1010
+ });
1011
+ // Pick up any components we snapped that aren't on the remote lane yet (newly added on this PR).
1012
+ for (const snap of snappedHeads) {
1013
+ if (!updatedComponents.some(c => c.id.isEqualWithoutVersion(snap.id))) {
1014
+ updatedComponents.push({
1015
+ id: snap.id,
1016
+ head: snap.head
1017
+ });
1018
+ }
1019
+ }
1020
+ remoteLane.setLaneComponents(updatedComponents);
1021
+ remoteLane.hasChanged = true;
1022
+ await legacyScope.lanes.saveLane(remoteLane, {
1023
+ saveLaneHistory: false
1024
+ });
1025
+ await repo.persist();
1026
+
1027
+ // Make sure the workspace's current-lane pointer points at the lane we just adopted.
1028
+ this.workspace.consumer.setCurrentLane(laneId, true);
1029
+ await this.workspace.bitMap.write();
1030
+ }
528
1031
  async mergePr({
529
1032
  message: argMessage,
530
1033
  build,
@@ -823,9 +1326,10 @@ class CiMain {
823
1326
  /**
824
1327
  * Export with retry on lane hash-mismatch, caused by a concurrent `bit ci pr` run pushing the
825
1328
  * same lane id between our pre-export delete and the hub's merge (the export takes 1-2 minutes,
826
- * plenty of time to race). Before each retry we skip if the PR branch has advanced past our
827
- * commit - in that case a newer run will publish the correct lane, and retrying with our older
828
- * snaps would regress the PR preview.
1329
+ * plenty of time to race). Used by the default (temp-lane) flow. On mismatch we delete the
1330
+ * remote lane and retry the temp-lane flow recreates the lane on every run anyway, so there's
1331
+ * no lane history to preserve. (The `--keep-lane` flow instead adopts the remote lane and
1332
+ * rebases onto it; see `exportWithAdoptOnConflict`.)
829
1333
  */
830
1334
  async exportWithRetryOnLaneHashMismatch(laneIdStr, maxAttempts = 3) {
831
1335
  const isHashMismatchErr = err => (err?.message || err?.toString() || '').includes(LANE_HASH_MISMATCH_MARKER);