@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.
- package/commands/pr.cmd.ts +7 -0
- package/dist/ci.main.runtime.d.ts +93 -4
- package/dist/ci.main.runtime.js +598 -13
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/commands/pr.cmd.d.ts +1 -0
- package/dist/commands/pr.cmd.js +3 -2
- package/dist/commands/pr.cmd.js.map +1 -1
- package/dist/{preview-1779993564749.js → preview-1780066402004.js} +1 -1
- package/package.json +17 -12
package/dist/ci.main.runtime.js
CHANGED
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
399
|
+
return undefined;
|
|
361
400
|
}
|
|
362
|
-
this.logger.console(_chalk().default.red(`Failed switching to ${laneName}: ${e
|
|
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).
|
|
827
|
-
*
|
|
828
|
-
*
|
|
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);
|