@teambit/ci 1.0.383 → 1.0.385

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;
@@ -346,6 +378,13 @@ class CiMain {
346
378
  status
347
379
  };
348
380
  }
381
+
382
+ /**
383
+ * Returns the caught Error on failure, or undefined on success (including the "already checked
384
+ * out" no-op case). Callers that need to react to a specific failure mode (e.g. stale lane) can
385
+ * inspect the returned error; existing callers ignore it and rely on a follow-up
386
+ * `getCurrentLane()` check.
387
+ */
349
388
  async switchToLane(laneName, options = {}) {
350
389
  this.logger.console(_chalk().default.blue(`Switching to ${laneName}`));
351
390
  try {
@@ -355,11 +394,158 @@ class CiMain {
355
394
  skipDependencyInstallation: true
356
395
  }, options));
357
396
  } catch (e) {
358
- if (e.toString().includes('already checked out')) {
397
+ if (e?.toString().includes('already checked out')) {
359
398
  this.logger.console(_chalk().default.yellow(`Lane ${laneName} already checked out, skipping checkout`));
360
- return true;
399
+ return undefined;
361
400
  }
362
- this.logger.console(_chalk().default.red(`Failed switching to ${laneName}: ${e.toString()}`));
401
+ this.logger.console(_chalk().default.red(`Failed switching to ${laneName}: ${e?.toString() ?? e}`));
402
+ return e;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ /**
408
+ * Sync *config-only* changes from main onto the lane — without a full `bit lane merge`.
409
+ *
410
+ * In this workflow git is the source of truth for files: the PR author merges the default branch
411
+ * into their PR branch, so source changes arrive via git. The one thing git can't carry is
412
+ * config that's already been *tagged into objects* on main — e.g. another PR ran `bit env set` /
413
+ * `bit deps set`; those records lived in `.bitmap`, rode git into main, and `bit ci merge` baked
414
+ * them into the component's Version (clearing them from `.bitmap`). A long-running PR's lane
415
+ * would otherwise miss them.
416
+ *
417
+ * A full lane merge is the wrong tool here: it does a 3-way *file* merge and refuses to run while
418
+ * the workspace has modified components — but in `bit ci pr` the workspace is always dirty (the
419
+ * PR's changes, not yet snapped). So instead we do a per-component 3-way merge of the aspect
420
+ * *config only* (base = common ancestor, ours = lane, theirs = main), keeping the PR's config on
421
+ * conflict, and stash the result on an `unmergedComponents` entry's `mergedConfig`. The
422
+ * subsequent `snap` reads it (via the aspects-merger on component load) and bakes main's config
423
+ * into the new snap, while the snap's files still come from the workspace (git). No file
424
+ * checkout, so no clean-workspace requirement.
425
+ */
426
+ async syncConfigFromMain(laneId) {
427
+ const legacyScope = this.workspace.scope.legacyScope;
428
+ const repo = legacyScope.objects;
429
+ const mainLaneId = this.lanes.getDefaultLaneId();
430
+ const currentLane = await this.lanes.getCurrentLane();
431
+ if (!currentLane) return;
432
+ const workspaceIds = this.workspace.listIds();
433
+ this.logger.console(_chalk().default.blue(`Syncing config changes from ${mainLaneId.toString()} into ${laneId.toString()}`));
434
+ const syncedIds = [];
435
+ for (const laneComp of currentLane.components) {
436
+ try {
437
+ const modelComponent = await legacyScope.getModelComponent(laneComp.id);
438
+ const mainHead = modelComponent.head; // the component's head on main
439
+ if (!mainHead) continue; // component isn't on main — nothing to sync from there
440
+ const laneHead = laneComp.head;
441
+ if (mainHead.isEqual(laneHead)) continue; // lane already points at main's head
442
+
443
+ const divergeData = await (0, _component().getDivergeData)({
444
+ repo,
445
+ modelComponent,
446
+ sourceHead: laneHead,
447
+ targetHead: mainHead,
448
+ throws: false
449
+ });
450
+ // Only sync when main has snaps the lane doesn't (target ahead, or diverged). If the lane
451
+ // is ahead-only / equal there's nothing on main to bring in.
452
+ if (!divergeData.isTargetAhead() && !divergeData.isDiverged()) continue;
453
+ const currentVersion = await modelComponent.loadVersion(laneHead.toString(), repo);
454
+ const otherVersion = await modelComponent.loadVersion(mainHead.toString(), repo);
455
+ // base = common ancestor. When the lane is strictly behind main (no divergence) the common
456
+ // ancestor IS the lane head, so the lane's own aspects serve as the base.
457
+ const baseSnap = divergeData.commonSnapBeforeDiverge;
458
+ const baseVersion = baseSnap ? await modelComponent.loadVersion(baseSnap.toString(), repo) : currentVersion;
459
+ const configMerger = new (_configMerger().ComponentConfigMerger)(laneComp.id.toStringWithoutVersion(), workspaceIds, undefined,
460
+ // merging from main (the default lane) — there's no Lane object for it
461
+ currentVersion.extensions, baseVersion.extensions, otherVersion.extensions, laneId.toString(), mainLaneId.toString(), this.logger, 'ours' // keep the PR's config on a genuine conflict
462
+ );
463
+ const mergedConfig = configMerger.merge().getSuccessfullyMergedConfig();
464
+ if (!mergedConfig || !Object.keys(mergedConfig).length) continue;
465
+
466
+ // Strip dependency deletion markers (version: '-'); the aspects-merger applies mergedConfig
467
+ // as-is, so a leftover '-' would land in the policy.
468
+ this.filterDeletedDependenciesFromConfig(mergedConfig);
469
+
470
+ // Upsert: addEntry throws if an entry for this component already exists. A prior
471
+ // --keep-lane run that crashed mid-snap (or otherwise left unmerged.json entries behind)
472
+ // would otherwise make every later run throw here, skip the component, and keep serving
473
+ // stale config. Remove any existing entry first so repeated runs converge on main's latest.
474
+ legacyScope.objects.unmergedComponents.removeComponent(laneComp.id);
475
+ legacyScope.objects.unmergedComponents.addEntry({
476
+ id: {
477
+ scope: laneComp.id.scope,
478
+ name: laneComp.id.fullName
479
+ },
480
+ head: mainHead,
481
+ laneId: mainLaneId,
482
+ mergedConfig
483
+ });
484
+ syncedIds.push(laneComp.id);
485
+ this.logger.console(_chalk().default.blue(` ${laneComp.id.toStringWithoutVersion()}: applying main's config (${Object.keys(mergedConfig).join(', ')})`));
486
+ } catch (e) {
487
+ // Best-effort per component: one component's config-merge quirk shouldn't abort the whole
488
+ // `bit ci pr`. Log and move on — the build just won't reflect that component's main-side
489
+ // config this run.
490
+ this.logger.console(_chalk().default.yellow(` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})`));
491
+ }
492
+ }
493
+ if (!syncedIds.length) {
494
+ this.logger.console(_chalk().default.blue('No config changes from main to sync'));
495
+ return;
496
+ }
497
+ await legacyScope.objects.unmergedComponents.write();
498
+ // The components were already loaded (and their aspects cached) earlier in this run, before the
499
+ // unmergedComponents entries existed. Clear the cache so the upcoming `snap` reloads them and
500
+ // the aspects-merger folds in the synced `mergedConfig`.
501
+ this.workspace.clearAllComponentsCache();
502
+ this.logger.console(_chalk().default.green(`Synced config from main for ${syncedIds.length} component(s)`));
503
+ }
504
+
505
+ /**
506
+ * Copied from `merging.main.runtime` (`filterDeletedDependenciesFromConfig`): the config merge
507
+ * can emit deletion markers (`version: '-'`) for deps removed on main. The aspects-merger applies
508
+ * `mergedConfig` verbatim, so strip those here to avoid writing a policy entry with version '-'.
509
+ */
510
+ filterDeletedDependenciesFromConfig(mergeConfig) {
511
+ const policy = mergeConfig?.[_dependencyResolver().DependencyResolverAspect.id]?.policy;
512
+ if (!policy) return;
513
+ Object.keys(policy).forEach(depType => {
514
+ const filtered = policy[depType].filter(dep => dep.version !== '-');
515
+ if (filtered.length === 0) delete policy[depType];else policy[depType] = filtered;
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Best-effort, fetch-free check for whether the current (PR) branch is *behind* the default
521
+ * branch — i.e. the default branch has commits the PR branch doesn't contain.
522
+ *
523
+ * We intentionally do NOT `git fetch` here (a fetch in CI can hang on an interactive SSH
524
+ * host-key prompt — that's why `isStaleCiRun` was removed in #10300). We compare against
525
+ * whatever `origin/<default>` ref the checkout already has, which reflects the state the default
526
+ * branch was in when this CI run started — exactly the reference point we care about.
527
+ *
528
+ * Returns true only when we can *confirm* the branch is behind. If the ref can't be resolved or
529
+ * anything else goes wrong, returns false (treat as "not behind" / proceed) so we never silently
530
+ * disable the main→lane config propagation.
531
+ */
532
+ async isBranchBehindDefaultBranch() {
533
+ try {
534
+ const defaultBranch = await this.getDefaultBranchName();
535
+ const defaultRefSha = (await _git().git.revparse([`refs/remotes/origin/${defaultBranch}`])).trim();
536
+ const headSha = (await _git().git.revparse(['HEAD'])).trim();
537
+ if (defaultRefSha === headSha) return false; // identical → up to date
538
+ // We deliberately do NOT use `merge-base --is-ancestor`: it reports the answer via exit code
539
+ // (0 = ancestor, 1 = not), but simple-git's `raw` resolves rather than rejects on exit code
540
+ // 1, so the "not an ancestor" case was silently read as "is an ancestor" — the behind check
541
+ // never fired. Instead compute the merge-base and compare: `merge-base(A, B) === A` iff A is
542
+ // an ancestor of B. When origin/<default> is an ancestor of HEAD the PR already contains it
543
+ // (not behind); otherwise the default branch has commits HEAD doesn't (behind).
544
+ const mergeBase = (await _git().git.raw(['merge-base', defaultRefSha, headSha])).trim();
545
+ return mergeBase !== defaultRefSha;
546
+ } catch (err) {
547
+ 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}`));
548
+ return false;
363
549
  }
364
550
  }
365
551
  async verifyWorkspaceStatus() {
@@ -380,7 +566,8 @@ class CiMain {
380
566
  message,
381
567
  build,
382
568
  strict,
383
- dryRun
569
+ dryRun,
570
+ keepLane
384
571
  }) {
385
572
  this.logger.console(_chalk().default.blue(`Lane name: ${laneIdStr}`));
386
573
  const originalLane = await this.lanes.getCurrentLane();
@@ -395,6 +582,231 @@ class CiMain {
395
582
  });
396
583
  this.logger.console('🔄 Lane Management');
397
584
 
585
+ // `--keep-lane` opts into reusing the same remote lane across subsequent commits to a PR, so
586
+ // the lane's history and any lane-based UI edits on Bit Cloud survive. Without it we use the
587
+ // default, battle-tested flow: snap onto a throwaway temp lane and delete+recreate the final
588
+ // lane at export time (the lane is recreated on every PR commit).
589
+ if (keepLane) {
590
+ return this.snapAndExportReusingLane({
591
+ laneId,
592
+ originalLane,
593
+ message,
594
+ build,
595
+ dryRun
596
+ });
597
+ }
598
+ return this.snapAndExportWithTempLane({
599
+ laneId,
600
+ originalLane,
601
+ message,
602
+ build,
603
+ dryRun
604
+ });
605
+ }
606
+
607
+ /**
608
+ * `--keep-lane` flow: reuse the existing remote lane (or create it on the first run), merge main
609
+ * into it to pick up config changes that landed since the fork, snap, and export with
610
+ * adopt-on-conflict recovery for concurrent CI pushes. The lane object is preserved across PR
611
+ * commits, so its history and lane-based UI edits on Bit Cloud survive.
612
+ */
613
+ async snapAndExportReusingLane({
614
+ laneId,
615
+ originalLane,
616
+ message,
617
+ build,
618
+ dryRun
619
+ }) {
620
+ // Query the remote (by name, to avoid fetching all lanes) so we know whether to reuse or create
621
+ const existingLanes = await this.lanes.getLanes({
622
+ remote: laneId.scope,
623
+ name: laneId.name
624
+ }).catch(e => {
625
+ if (e.toString().includes('was not found')) return [];
626
+ throw new Error(`Failed to check lane ${laneId.toString()}: ${e.toString()}`);
627
+ });
628
+ const laneExists = existingLanes.length > 0;
629
+ let foundErr;
630
+ try {
631
+ if (laneExists) {
632
+ // Reuse the existing remote lane so that the lane history, lane-based UI edits, and
633
+ // lane-history feature on Bit Cloud all survive across subsequent commits to the same PR.
634
+ // switchToLane fetches the latest lane head from remote.
635
+ this.logger.console(_chalk().default.blue(`Lane ${laneId.toString()} exists on remote, reusing it`));
636
+ const switchErr = await this.switchToLane(laneId.toString());
637
+ // switchToLane returns the caught error (undefined on success). Combine with a
638
+ // current-lane-state probe — comparing BOTH name AND scope, so a same-named lane in a
639
+ // different scope can't masquerade as a successful switch.
640
+ const switchedLane = await this.lanes.getCurrentLane();
641
+ const landedOnLane = switchedLane?.name === laneId.name && switchedLane?.scope === laneId.scope;
642
+ if (landedOnLane) {
643
+ // Sync config-only changes from main onto the lane, so config that was tagged into
644
+ // objects on main since the lane forked (e.g. `bit deps set` / `bit env set` from
645
+ // another PR, not visible via the workspace's git checkout) is reflected on the lane.
646
+ // Source files are git's job — see syncConfigFromMain.
647
+ //
648
+ // BUT only when the PR branch is actually up to date with the default branch. If the PR
649
+ // is behind (hasn't pulled main's latest), its git checkout still reflects the older
650
+ // fork point, so pulling main's newer config onto the lane would desync the lane from
651
+ // the source. The author merges the default branch into their PR in git; the next
652
+ // `bit ci pr` then propagates it here.
653
+ if (await this.isBranchBehindDefaultBranch()) {
654
+ 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.`));
655
+ } else {
656
+ await this.syncConfigFromMain(laneId);
657
+ }
658
+ } else {
659
+ // Switch failed even though the remote lane exists. The destructive recovery below
660
+ // (delete the remote lane + recreate fresh) is safe only when the failure is the
661
+ // specific "stale lane" pattern — the lane references a ModelComponent the PR has
662
+ // since removed/renamed (`unable to merge lane …, the component … was not found`).
663
+ // For any other failure (transient network blip during fetch, auth error, lane locked
664
+ // by Cloud UI, etc.) destroying lane history would be the wrong response, so we
665
+ // rethrow and let the caller report the real cause.
666
+ const errMsg = switchErr?.toString() ?? '';
667
+ const isStaleLane = errMsg.includes('unable to merge lane');
668
+ if (!isStaleLane) {
669
+ throw new Error(`Failed to switch to remote lane ${laneId.toString()}: ${errMsg || '(no error captured)'}. ` + `Refusing destructive recovery for this failure class — the error doesn't match the ` + `stale-lane marker, so deleting the lane could destroy real history. Investigate or retry.`);
670
+ }
671
+ this.logger.console(_chalk().default.yellow(`Stale remote lane ${laneId.toString()} — switching failed. ` + `Deleting it and creating a fresh lane to recover.`));
672
+ // Re-check the remote lane's hash immediately before deleting. The central-hub delete
673
+ // API is name-based — there's no compare-and-swap — so two CI jobs racing the same
674
+ // recovery could otherwise have job B delete job A's freshly-recreated lane. By
675
+ // re-fetching here we shrink the TOCTOU window to milliseconds: if A's recreate landed
676
+ // before our re-fetch, the hash changed and we skip the delete entirely. The downstream
677
+ // export then hits the lane-hash mismatch and lands in `exportWithAdoptOnConflict`,
678
+ // which rebases our snaps onto the winner's lane — no destroyed history.
679
+ const staleHash = existingLanes[0]?.hash;
680
+ const recheck = await this.lanes.getLanes({
681
+ remote: laneId.scope,
682
+ name: laneId.name
683
+ }).catch(() => []);
684
+ const currentRemoteHash = recheck[0]?.hash;
685
+ const remoteChanged = staleHash && currentRemoteHash && currentRemoteHash !== staleHash;
686
+ if (remoteChanged) {
687
+ this.logger.console(_chalk().default.blue(`Remote lane ${laneId.toString()} changed since we first checked (hash ` + `${staleHash.slice(0, 9)} → ${currentRemoteHash.slice(0, 9)}) — another concurrent ` + `recovery already recreated it. Skipping the delete; export will adopt-on-conflict.`));
688
+ } else {
689
+ await this.lanes.removeLanes([laneId.toString()], {
690
+ remote: true,
691
+ force: true
692
+ }).catch(e => {
693
+ const msg = e?.toString() ?? '';
694
+ // Tolerate the race where another concurrent recovery deleted the lane first — the
695
+ // desired post-condition (lane gone from remote) is already met.
696
+ if (msg.includes('was not found') || msg.includes('not found')) {
697
+ this.logger.console(_chalk().default.blue(`Remote lane ${laneId.toString()} was already gone — proceeding`));
698
+ return;
699
+ }
700
+ throw new Error(`Failed to delete stale remote lane ${laneId.toString()}: ${msg || e}`);
701
+ });
702
+ }
703
+ // switchToLane fetched the remote lane and persisted it into the local scope's lane
704
+ // index (via `importLaneObject` → `legacyScope.lanes.saveLane`) BEFORE the underlying
705
+ // merge failed. Without dropping that local copy here, the upcoming `createLane` would
706
+ // hit the "lane … already exists" guard in create-lane.ts. Same trash-the-local-object
707
+ // pattern as `rebaseOntoRemoteLane`.
708
+ const legacyScope = this.workspace.scope.legacyScope;
709
+ const localLane = await legacyScope.loadLane(laneId);
710
+ if (localLane) {
711
+ await legacyScope.objects.moveObjectsToTrash([localLane.hash()]);
712
+ }
713
+ // Reset the workspace's current-lane pointer to main before createLane, so the new lane
714
+ // is forked from main with an empty component list. `createLane` populates new lanes
715
+ // from `consumer.getCurrentLaneObject()` regardless of `forkLaneNewScope` (which only
716
+ // suppresses the cross-scope guard) — if `originalLane` is non-default (a developer
717
+ // running `bit ci pr` from a lane), without this reset the "fresh" lane would silently
718
+ // inherit `originalLane`'s components. Check the return value: a silent failure here
719
+ // would defeat the whole point of the reset.
720
+ const resetErr = await this.switchToLane('main');
721
+ const afterReset = await this.lanes.getCurrentLane();
722
+ if (resetErr || afterReset) {
723
+ throw new Error(`Failed to reset to main before recreating ${laneId.toString()}: ` + `${resetErr?.toString() ?? `(still on lane "${afterReset?.name}")`}. ` + `Aborting to avoid silently forking the recreated lane from the wrong source.`);
724
+ }
725
+ const createLaneResult = await this.lanes.createLane(laneId.name, {
726
+ scope: laneId.scope,
727
+ forkLaneNewScope: true
728
+ });
729
+ this.logger.console(_chalk().default.blue(`Recreated lane ${laneId.toString()} (hash: ${createLaneResult.hash})`));
730
+ }
731
+ } else {
732
+ this.logger.console(_chalk().default.blue(`Creating lane ${laneId.toString()}`));
733
+ const createLaneResult = await this.lanes.createLane(laneId.name, {
734
+ scope: laneId.scope,
735
+ forkLaneNewScope: true
736
+ });
737
+ this.logger.console(_chalk().default.blue(`Created lane ${laneId.toString()} (hash: ${createLaneResult.hash})`));
738
+ }
739
+ const currentLane = await this.lanes.getCurrentLane();
740
+ this.logger.console(_chalk().default.blue(`Current lane: ${currentLane?.name ?? 'main'}`));
741
+ if (currentLane?.name !== laneId.name) {
742
+ throw new Error(`Expected to be on lane ${laneId.name}, but current lane is ${currentLane?.name ?? 'main'}`);
743
+ }
744
+ this.logger.console('📦 Snapping Components');
745
+ const results = await this.snapping.snap({
746
+ message,
747
+ build,
748
+ exitOnFirstFailedTask: true
749
+ });
750
+ if (!results) {
751
+ this.logger.console(_chalk().default.yellow('No changes detected, nothing to snap'));
752
+ return 'No changes detected, nothing to snap';
753
+ }
754
+ const {
755
+ snappedComponents
756
+ } = results;
757
+ const snapOutput = (0, _snapping().snapResultOutput)(results);
758
+ this.logger.console(snapOutput);
759
+ if (dryRun) {
760
+ this.logger.console(_chalk().default.yellow('🏃 Dry-run mode: skipping export'));
761
+ this.logger.console(_chalk().default.green(`Snapped ${snappedComponents.length} component(s) successfully`));
762
+ return snapOutput;
763
+ }
764
+ this.logger.console(_chalk().default.blue(`Exporting ${snappedComponents.length} components`));
765
+ await this.exportWithAdoptOnConflict(laneId, snappedComponents);
766
+ } catch (e) {
767
+ foundErr = e;
768
+ throw e;
769
+ } finally {
770
+ if (foundErr) {
771
+ this.logger.console(_chalk().default.red(`Found error: ${foundErr.message}`));
772
+ }
773
+ // Best-effort cleanup: switch back to the original lane/main. Wrap it so a cleanup
774
+ // failure (failed switch/checkout) only warns instead of throwing out of `finally` and
775
+ // masking the real error from snap/export above (also avoids no-unsafe-finally).
776
+ try {
777
+ this.logger.console('🔄 Cleanup');
778
+ const targetLane = originalLane?.name ?? 'main';
779
+ this.logger.console(_chalk().default.blue(`Switching back to ${targetLane}`));
780
+ const currentLane = await this.lanes.getCurrentLane();
781
+ if (currentLane) {
782
+ await this.switchToLane(targetLane);
783
+ } else {
784
+ this.logger.console(_chalk().default.yellow('Already on main, checking out to head'));
785
+ await this.lanes.checkout.checkout({
786
+ head: true,
787
+ skipNpmInstall: true
788
+ });
789
+ }
790
+ } catch (cleanupErr) {
791
+ this.logger.consoleWarning(`Cleanup after PR snap failed: ${cleanupErr.message}`);
792
+ }
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Default flow: snap onto a uniquely-named temporary lane, then at export time delete any
798
+ * existing remote lane and rename the temp lane to the final name. The temp name minimizes the
799
+ * race window when multiple CI jobs run concurrently on the same branch. Trade-off: the final
800
+ * lane is recreated on every PR commit, so its history and any lane-based UI edits on Bit Cloud
801
+ * don't survive across commits — use `--keep-lane` for that.
802
+ */
803
+ async snapAndExportWithTempLane({
804
+ laneId,
805
+ originalLane,
806
+ message,
807
+ build,
808
+ dryRun
809
+ }) {
398
810
  // Use unique temp lane name to avoid race conditions when multiple CI jobs run concurrently
399
811
  const tempLaneName = `${laneId.name}-${(0, _toolboxString().generateRandomStr)(5)}`;
400
812
  let foundErr;
@@ -525,6 +937,178 @@ class CiMain {
525
937
  }
526
938
  }
527
939
  }
940
+
941
+ /**
942
+ * Export with a recovery path for the two concurrent-CI conflicts that can surface from the
943
+ * remote (see the marker constants at the top of the file): lane-hash mismatch (both runners
944
+ * created fresh lane objects when the lane didn't yet exist on the remote) and per-component
945
+ * divergence (both reused the existing lane but snapped the same component with different
946
+ * content).
947
+ *
948
+ * Recovery: adopt-the-winner. The remote lane (whoever pushed first) becomes canonical. We
949
+ * drop our local lane object, fetch the remote, rebase our snapped Version objects so each
950
+ * one's parent points to the remote head for that component, then swap those rebased Versions
951
+ * in as the new lane heads and re-export. Build artifacts are preserved — only the parent
952
+ * pointers on the Version objects change. Result: both runners' snaps end up chained on a
953
+ * single lane object (last-writer-wins on content for any contested component, with the
954
+ * winner's snap preserved in history as the parent).
955
+ */
956
+ async exportWithAdoptOnConflict(laneId, snappedComponents) {
957
+ try {
958
+ const exportResults = await this.exporter.export();
959
+ this.logger.console(_chalk().default.green(`Exported ${exportResults.componentsIds.length} components`));
960
+ return;
961
+ } catch (e) {
962
+ const msg = e?.message || e?.toString() || '';
963
+ const isLaneHashMismatch = msg.includes(LANE_HASH_MISMATCH_MARKER);
964
+ const isComponentDivergence = msg.includes(COMPONENT_DIVERGENCE_MARKER);
965
+ if (!isLaneHashMismatch && !isComponentDivergence) throw e;
966
+ const cause = isLaneHashMismatch ? 'Lane hash mismatch' : 'Per-component divergence';
967
+ 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.`));
968
+ }
969
+ const snappedHeads = snappedComponents.map(c => {
970
+ // A just-snapped component always has a version; guard defensively so a missing one fails
971
+ // with a clear message instead of `Ref.from(undefined)`'s opaque "hash argument is empty".
972
+ if (!c.version) {
973
+ 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`);
974
+ }
975
+ return {
976
+ id: c.id,
977
+ head: _objects().Ref.from(c.version)
978
+ };
979
+ });
980
+ await this.rebaseOntoRemoteLane(laneId, snappedHeads);
981
+ this.logger.console(_chalk().default.blue('Retrying export with rebased snaps'));
982
+ const exportResults = await this.exportWithBusyRetry();
983
+ this.logger.console(_chalk().default.green(`Exported ${exportResults.componentsIds.length} components after rebase`));
984
+ }
985
+
986
+ /**
987
+ * Wrap `exporter.export()` with retry on the "server is busy" error. The retried export's
988
+ * pending-dir lands behind whichever concurrent client is still in the remote's queue (we
989
+ * arrived second by definition — we're the loser of the original race). The 60s wait inside
990
+ * `export-validate.waitIfNeeded` covers the common case, but on slow CI hosts or large pushes
991
+ * we sometimes time out before the other client finishes its persist. A short sleep + retry
992
+ * here just gives the queue room to drain.
993
+ */
994
+ async exportWithBusyRetry(maxAttempts = 3) {
995
+ const isBusyErr = err => (err?.message || err?.toString() || '').includes('server is busy by other exports');
996
+ let lastErr;
997
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
998
+ try {
999
+ return await this.exporter.export();
1000
+ } catch (e) {
1001
+ lastErr = e;
1002
+ if (!isBusyErr(e)) throw e;
1003
+ this.logger.console(_chalk().default.yellow(`Export attempt ${attempt}/${maxAttempts} blocked by a busy remote queue. Waiting before retrying.`));
1004
+ await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
1005
+ }
1006
+ }
1007
+ throw lastErr;
1008
+ }
1009
+ async rebaseOntoRemoteLane(laneId, snappedHeads) {
1010
+ const legacyScope = this.workspace.scope.legacyScope;
1011
+ const repo = legacyScope.objects;
1012
+
1013
+ // Our local lane object (the one we just snapped onto) shares the LaneId with the remote
1014
+ // (winning) lane but has a different, randomly-minted hash. Fetching the remote lane writes it
1015
+ // into our scope via `sources.mergeLane`, which rejects a same-id/different-hash lane ("a lane
1016
+ // with the same id already exists with a different hash") — so we must drop our local lane
1017
+ // object BEFORE the fetch, not after. We can't use `lanes.removeLanes` (it refuses to remove
1018
+ // the currently-checked-out lane), so we trash the lane object directly; that also removes it
1019
+ // from the scope index, so `loadLane` (the guard's lookup) no longer finds it. Only the lane
1020
+ // pointer is trashed — the Version objects we snapped stay in the scope for the rebase below,
1021
+ // and the fetch immediately re-persists a same-id lane object, satisfying the current-lane
1022
+ // workspace pointer again.
1023
+ const localLane = await legacyScope.loadLane(laneId);
1024
+ if (localLane) {
1025
+ this.logger.console(_chalk().default.blue(`Dropping local lane object ${laneId.toString()} to adopt the remote one`));
1026
+ await repo.moveObjectsToTrash([localLane.hash()]);
1027
+ }
1028
+
1029
+ // Fetch the remote (winning) lane and the Version objects it points at. With our local lane
1030
+ // object gone, `mergeLane` sees no conflicting same-id lane and persists the remote one.
1031
+ this.logger.console(_chalk().default.blue(`Fetching remote lane ${laneId.toString()}`));
1032
+ const remoteLane = await this.lanes.fetchLaneWithItsComponents(laneId);
1033
+
1034
+ // Rewrite each snapped Version's parent to point at the remote head for that component.
1035
+ // Bit's Version objects aren't content-addressed — `_hash` is set once and not derived
1036
+ // from content — so we can mutate `parents` in place. The hash stays the same, so the
1037
+ // build artifacts referenced from the Version remain valid and the lane's component head
1038
+ // doesn't need to be re-pointed. The remote receives the updated Version because our
1039
+ // first failed export attempt was rejected during the export-validate step (via
1040
+ // `sources.mergeLane`'s same-id/different-hash guard) — *before* `ExportPersist` writes any
1041
+ // files to disk — so the remote doesn't yet have this hash and won't dedupe-skip it during
1042
+ // transfer.
1043
+ for (const snap of snappedHeads) {
1044
+ const remoteComp = remoteLane.components.find(c => c.id.isEqualWithoutVersion(snap.id));
1045
+ if (!remoteComp) continue; // component is only on our lane, not on the remote — no rebase target
1046
+ const remoteHead = remoteComp.head;
1047
+ if (snap.head.isEqual(remoteHead)) continue;
1048
+ const version = await repo.load(snap.head);
1049
+ if (!version) {
1050
+ throw new Error(`rebaseOntoRemoteLane: unable to load Version object for ${snap.id.toString()} hash ${snap.head.toString()}`);
1051
+ }
1052
+ if (version.parents.some(p => p.isEqual(remoteHead))) continue; // already chains correctly
1053
+
1054
+ const beforeParents = version.parents.map(p => p.toString().slice(0, 9)).join(',');
1055
+ // Re-point only the lane-lineage parent (the first parent — the predecessor snap on the
1056
+ // lane) to the remote head, preserving any additional parents. A snap produced after
1057
+ // `syncConfigFromMain` is a merge snap whose second parent links to main's head; overwriting
1058
+ // the whole array with `[remoteHead]` would silently drop that merge edge and corrupt the
1059
+ // lane's ancestry.
1060
+ version.parents = [remoteHead, ...version.parents.slice(1)];
1061
+ const afterParents = version.parents.map(p => p.toString().slice(0, 9)).join(',');
1062
+ this.logger.console(_chalk().default.blue(`Rebasing ${snap.id.toString()}@${snap.head.toString().slice(0, 9)}: parents [${beforeParents}] → [${afterParents}]`));
1063
+ repo.add(version);
1064
+
1065
+ // Keep the local VersionHistory in sync with the rewritten parents. The first (failed)
1066
+ // export already traversed and wrote VersionHistory with this version's *original* parent;
1067
+ // without this update the re-export's diverge computation reads that stale history, never
1068
+ // sees the remote head as an ancestor, and can send the wrong version set or throw a
1069
+ // spurious "no common snap" error. `updateRebasedVersionHistory` only touches the entry if
1070
+ // this version already exists in the history (it does, from that first traversal).
1071
+ const modelComponent = await legacyScope.getModelComponent(snap.id);
1072
+ const versionHistory = await modelComponent.updateRebasedVersionHistory(repo, [version]);
1073
+ if (versionHistory) repo.add(versionHistory);
1074
+ }
1075
+
1076
+ // Replace the remote lane's component heads with our snapped Versions. Anything we
1077
+ // didn't snap stays as the remote had it.
1078
+ //
1079
+ // TODO(stale-runner): if two consecutive PR commits trigger two CI runs and the older one
1080
+ // finishes last, it will rebase its (older-content) snap on top of the newer one — the
1081
+ // newer commit's snap stays in history but the lane head regresses to the older content.
1082
+ // The fix needs git-SHA-aware staleness detection (embed `git rev-parse HEAD` in the snap
1083
+ // log on creation, compare against the remote head's stored SHA on rebase, abort if our
1084
+ // commit is an ancestor of theirs). The prior `isStaleCiRun` (removed in #10300 for
1085
+ // SSH-prompt reasons) attempted this via `git fetch`; we'd want a fetch-free variant here.
1086
+ const updatedComponents = remoteLane.components.map(c => {
1087
+ const snap = snappedHeads.find(s => s.id.isEqualWithoutVersion(c.id));
1088
+ return snap ? _objectSpread(_objectSpread({}, c), {}, {
1089
+ head: snap.head
1090
+ }) : c;
1091
+ });
1092
+ // Pick up any components we snapped that aren't on the remote lane yet (newly added on this PR).
1093
+ for (const snap of snappedHeads) {
1094
+ if (!updatedComponents.some(c => c.id.isEqualWithoutVersion(snap.id))) {
1095
+ updatedComponents.push({
1096
+ id: snap.id,
1097
+ head: snap.head
1098
+ });
1099
+ }
1100
+ }
1101
+ remoteLane.setLaneComponents(updatedComponents);
1102
+ remoteLane.hasChanged = true;
1103
+ await legacyScope.lanes.saveLane(remoteLane, {
1104
+ saveLaneHistory: false
1105
+ });
1106
+ await repo.persist();
1107
+
1108
+ // Make sure the workspace's current-lane pointer points at the lane we just adopted.
1109
+ this.workspace.consumer.setCurrentLane(laneId, true);
1110
+ await this.workspace.bitMap.write();
1111
+ }
528
1112
  async mergePr({
529
1113
  message: argMessage,
530
1114
  build,
@@ -823,9 +1407,10 @@ class CiMain {
823
1407
  /**
824
1408
  * Export with retry on lane hash-mismatch, caused by a concurrent `bit ci pr` run pushing the
825
1409
  * 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.
1410
+ * plenty of time to race). Used by the default (temp-lane) flow. On mismatch we delete the
1411
+ * remote lane and retry the temp-lane flow recreates the lane on every run anyway, so there's
1412
+ * no lane history to preserve. (The `--keep-lane` flow instead adopts the remote lane and
1413
+ * rebases onto it; see `exportWithAdoptOnConflict`.)
829
1414
  */
830
1415
  async exportWithRetryOnLaneHashMismatch(laneIdStr, maxAttempts = 3) {
831
1416
  const isHashMismatchErr = err => (err?.message || err?.toString() || '').includes(LANE_HASH_MISMATCH_MARKER);