@teambit/ci 1.0.523 → 1.0.524

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.
@@ -0,0 +1,153 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { formatWarningSummary } from '@teambit/cli';
4
+ import type { Logger } from '@teambit/logger';
5
+ import type { Workspace } from '@teambit/workspace';
6
+ import type { ComponentID } from '@teambit/component-id';
7
+ import { ComponentIdList } from '@teambit/component-id';
8
+ import { capEntries } from './lane-sync-executor';
9
+
10
+ /**
11
+ * A `.bitmap` entry whose recorded `mainFile` is no longer on disk cannot be loaded at all — the
12
+ * component loader throws `MainFileRemoved`, and that one entry fails the whole `bit checkout head`
13
+ * the main-scope reconciler runs, so every scheduled run stays red until a human intervenes.
14
+ *
15
+ * The shape is legitimate: a new version moves the component's main file (e.g. an env stops emitting
16
+ * `dist/`, so main goes from `dist/index.d.ts` back to source), the old path is deleted in git, and
17
+ * this repository's `.bitmap` still names it.
18
+ *
19
+ * Two heals, cheapest first:
20
+ * 1. RETARGET — the scope's current version names a main file that IS in this repository, so only
21
+ * the `.bitmap` pointer is stale. Rewrite it and the component loads; nothing moves on disk.
22
+ * 2. UNTRACK — the scope's current version was read, and the main file it names is not here
23
+ * either. Drop the entry; main sync's `includeNewFromScope` re-imports the component from the
24
+ * scope's main. This one writes the component to a fresh directory, so it shows up as a move in
25
+ * the sync PR.
26
+ *
27
+ * An entry is only ever untracked on a POSITIVE read of the scope's head. A component the scope does
28
+ * not have, or whose objects could not be read (a failed import, a corrupt object), is left exactly
29
+ * as it is and reported: a transient remote failure must not delete a `.bitmap` entry, and the run
30
+ * failing loudly on it is the honest outcome.
31
+ *
32
+ * Deliberately ci-local: making the checkout itself tolerate an unloadable component would change
33
+ * `bit checkout` for every caller passing a "theirs" resolution.
34
+ */
35
+ export type MainFileHeal = { id: string; retargetedTo?: string };
36
+
37
+ /**
38
+ * Fetch the remote head of the stale components. Without it the local scope still holds the version
39
+ * this repository already had — whose main file is the missing one — and every entry would look
40
+ * unrepairable. A failure is not fatal on its own (local objects may still answer), but it must
41
+ * never be mistaken for "the scope says this is gone", and its cause is the first thing an operator
42
+ * needs when a scheduled run starts leaving entries untouched — so it is carried, not swallowed.
43
+ */
44
+ async function importHeadsOf(
45
+ workspace: Workspace,
46
+ ids: ComponentID[]
47
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
48
+ try {
49
+ await workspace.scope.legacyScope.scopeImporter.importWithoutDeps(
50
+ ComponentIdList.fromArray(ids).toVersionLatest(),
51
+ { cache: false, ignoreMissingHead: true }
52
+ );
53
+ return { ok: true };
54
+ } catch (e: any) {
55
+ return { ok: false, error: e?.message || String(e) };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * What the scope's current (head) version says the component's main file is. The three outcomes are
61
+ * deliberately distinct: only `read` licenses a destructive heal.
62
+ */
63
+ type ScopeMainFile =
64
+ | { status: 'read'; mainFile: string }
65
+ /** the scope has no such component, or no head for it — nothing to compare against */
66
+ | { status: 'absent' }
67
+ /** objects could not be read (failed import, corrupt object) — the answer is unknown */
68
+ | { status: 'unreadable'; reason: string };
69
+
70
+ async function mainFileOnScopeHead(workspace: Workspace, id: ComponentID): Promise<ScopeMainFile> {
71
+ const legacyScope = workspace.scope.legacyScope;
72
+ try {
73
+ const modelComponent = await legacyScope.getModelComponentIfExist(id);
74
+ const head = modelComponent?.getHeadRegardlessOfLaneAsTagOrHash();
75
+ if (!modelComponent || !head) return { status: 'absent' };
76
+ const version = await modelComponent.loadVersion(head, legacyScope.objects);
77
+ if (!version?.mainFile) return { status: 'absent' };
78
+ return { status: 'read', mainFile: version.mainFile };
79
+ } catch (e: any) {
80
+ return { status: 'unreadable', reason: e?.message || String(e) };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Heal every `.bitmap` entry whose main file is missing, and persist. Returns what was healed so the
86
+ * caller can report it; the `.bitmap` change rides the sync commit like any other drift.
87
+ */
88
+ export async function healMissingMainFiles(workspace: Workspace, logger: Logger): Promise<MainFileHeal[]> {
89
+ const workspacePath = workspace.path;
90
+ const { bitMap } = workspace.consumer;
91
+ const onDisk = (rootDir: string, file: string) => fs.existsSync(path.join(workspacePath, rootDir, file));
92
+
93
+ const stale = bitMap.components.filter((componentMap) => {
94
+ const { rootDir, mainFile } = componentMap;
95
+ return Boolean(rootDir) && Boolean(mainFile) && !onDisk(rootDir as string, mainFile);
96
+ });
97
+ if (!stale.length) return [];
98
+
99
+ const headFetch = await importHeadsOf(
100
+ workspace,
101
+ stale.map((componentMap) => componentMap.id)
102
+ );
103
+
104
+ const healed: MainFileHeal[] = [];
105
+ const skipped: string[] = [];
106
+ for (const componentMap of stale) {
107
+ const id = componentMap.id.toStringWithoutVersion();
108
+ const onScope = await mainFileOnScopeHead(workspace, componentMap.id);
109
+ if (onScope.status === 'read' && onDisk(componentMap.rootDir as string, onScope.mainFile)) {
110
+ componentMap.mainFile = onScope.mainFile;
111
+ bitMap.markAsChanged();
112
+ healed.push({ id, retargetedTo: onScope.mainFile });
113
+ } else if (onScope.status === 'read') {
114
+ // Positively read, and the file it names is not here either: the whole component is gone from
115
+ // this tree, so the entry cannot be repaired in place.
116
+ bitMap.removeComponent(componentMap.id);
117
+ healed.push({ id });
118
+ } else {
119
+ // Unknown, never assumed: leave the entry and let the checkout fail with its own message.
120
+ skipped.push(`${id} (${onScope.status === 'absent' ? 'not on the scope' : `unreadable: ${onScope.reason}`})`);
121
+ }
122
+ }
123
+ if (healed.length) await bitMap.write();
124
+
125
+ if (skipped.length) {
126
+ logger.consoleWarning(
127
+ `Left ${skipped.length} .bitmap entr(ies) with a missing main file untouched — the scope could not ` +
128
+ `confirm what to repair them to: ${capEntries(skipped).join(', ')}` +
129
+ (headFetch.ok ? '' : `. Fetching their heads failed first: ${headFetch.error}`)
130
+ );
131
+ }
132
+
133
+ const retargeted = healed.filter((heal) => heal.retargetedTo);
134
+ const untracked = healed.filter((heal) => !heal.retargetedTo);
135
+ if (retargeted.length) {
136
+ logger.console(
137
+ formatWarningSummary(
138
+ `Repointed ${retargeted.length} .bitmap entr(ies) at the main file the scope's current version ` +
139
+ `records: ${capEntries(retargeted.map((heal) => `${heal.id} -> ${heal.retargetedTo}`)).join(', ')}`
140
+ )
141
+ );
142
+ }
143
+ if (untracked.length) {
144
+ logger.console(
145
+ formatWarningSummary(
146
+ `Untracked ${untracked.length} component(s) whose recorded main file is gone from this repository, ` +
147
+ `so the scope's current version can be written in their place: ` +
148
+ `${capEntries(untracked.map((heal) => heal.id)).join(', ')}`
149
+ )
150
+ );
151
+ }
152
+ return healed;
153
+ }
@@ -15,6 +15,7 @@ import {
15
15
  laneHeadFingerprint,
16
16
  LaneSyncExecutor,
17
17
  laneSyncPrBody,
18
+ ownLaneComponents,
18
19
  racedLedgerPushSummary,
19
20
  RUN_SUMMARY_MARKER,
20
21
  runSummaryCommentBody,
@@ -102,12 +103,38 @@ describe('foreignLaneComponents', () => {
102
103
  });
103
104
  });
104
105
 
106
+ describe('ownLaneComponents', () => {
107
+ const DEFAULT_SCOPE = 'acme.shop';
108
+ const ours = comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1');
109
+ const theirs = comp('other.scope/comp2', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2');
110
+
111
+ it('is the exact complement of foreignLaneComponents: together they cover the lane', () => {
112
+ const own = ownLaneComponents([ours, theirs], DEFAULT_SCOPE);
113
+ expect(own.map((c) => c.id.toStringWithoutVersion())).to.deep.equal(['acme.shop/comp1']);
114
+ expect(foreignLaneComponents([ours, theirs], DEFAULT_SCOPE)).to.deep.equal(['other.scope/comp2']);
115
+ });
116
+
117
+ it('fingerprints only the own slice, so a foreign head moving does not read as the lane moving', () => {
118
+ const theirsMoved = comp('other.scope/comp2', 'cccccccccccccccccccccccccccccccccccccc33');
119
+ expect(laneHeadFingerprint(ownLaneComponents([ours, theirs], DEFAULT_SCOPE))).to.equal(
120
+ laneHeadFingerprint(ownLaneComponents([ours, theirsMoved], DEFAULT_SCOPE))
121
+ );
122
+ });
123
+
124
+ it('an own head moving still reads as the lane moving', () => {
125
+ const oursMoved = comp('acme.shop/comp1', 'dddddddddddddddddddddddddddddddddddddd44');
126
+ expect(laneHeadFingerprint(ownLaneComponents([ours, theirs], DEFAULT_SCOPE))).to.not.equal(
127
+ laneHeadFingerprint(ownLaneComponents([oursMoved, theirs], DEFAULT_SCOPE))
128
+ );
129
+ });
130
+ });
131
+
105
132
  describe('crossScopeDescription', () => {
106
133
  const DEFAULT_SCOPE = 'acme.shop';
107
134
 
108
135
  it('names the foreign scopes and this repository scope', () => {
109
136
  const description = crossScopeDescription(['other.scope/comp2', 'third.scope/comp3'], DEFAULT_SCOPE);
110
- expect(description).to.include('components from scope(s) other.scope, third.scope');
137
+ expect(description).to.include('every component is from scope(s) other.scope, third.scope');
111
138
  expect(description).to.include(`(this repo maps scope ${DEFAULT_SCOPE})`);
112
139
  expect(description).to.include('foreign components: other.scope/comp2, third.scope/comp3');
113
140
  });
@@ -129,15 +156,21 @@ describe('crossScopeDescription', () => {
129
156
  describe('laneSyncPrBody', () => {
130
157
  const LANE_HEAD = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
131
158
 
132
- function body(componentCount: number) {
159
+ function body(componentCount: number, foreignCount = 0) {
133
160
  return laneSyncPrBody({
134
161
  laneIdStr: 'acme.shop/my-lane',
135
162
  laneUrl: 'https://bit.cloud/acme/shop/~lane/my-lane',
136
163
  branch: 'my-lane',
137
164
  laneHead: LANE_HEAD,
138
- components: Array.from({ length: componentCount }, (_, index) =>
139
- comp(`acme.shop/namespace/component-with-a-realistic-name-${index}`, `${'0'.repeat(39)}${index % 10}`)
140
- ),
165
+ components: [
166
+ ...Array.from({ length: componentCount }, (_, index) =>
167
+ comp(`acme.shop/namespace/component-with-a-realistic-name-${index}`, `${'0'.repeat(39)}${index % 10}`)
168
+ ),
169
+ ...Array.from({ length: foreignCount }, (_, index) =>
170
+ comp(`other.scope/namespace/foreign-component-${index}`, `${'1'.repeat(39)}${index % 10}`)
171
+ ),
172
+ ],
173
+ defaultScope: 'acme.shop',
141
174
  });
142
175
  }
143
176
 
@@ -165,15 +198,43 @@ describe('laneSyncPrBody', () => {
165
198
  it('says so explicitly when the lane has no components', () => {
166
199
  expect(body(0)).to.include('_none_');
167
200
  });
201
+
202
+ it('a single-scope lane gets no foreign section and no "N of M" phrasing', () => {
203
+ const rendered = body(3);
204
+ expect(rendered).to.include('Components on the lane (3):');
205
+ expect(rendered).to.not.include('other scopes');
206
+ expect(rendered).to.not.include(' of ');
207
+ });
208
+
209
+ it('a cross-scope lane lists the mirrored slice and the foreign components separately', () => {
210
+ const rendered = body(2, 3);
211
+ expect(rendered).to.include('Components mirrored from the lane (2 of 5):');
212
+ expect(rendered).to.include('component-with-a-realistic-name-1');
213
+ expect(rendered).to.include('Also on this lane, from other scopes (3)');
214
+ expect(rendered).to.include('package dependencies');
215
+ expect(rendered).to.include('`other.scope/namespace/foreign-component-2`');
216
+ // The foreign entries must not appear in the mirrored list's id/head format.
217
+ expect(rendered).to.not.include('`other.scope/namespace/foreign-component-0` @');
218
+ });
219
+
220
+ it('the foreign section is capped like the mirrored list, so the body stays inside the host limit', () => {
221
+ const rendered = body(2, 100);
222
+ expect(rendered).to.include('foreign-component-19');
223
+ expect(rendered).to.not.include('foreign-component-20`');
224
+ expect(rendered).to.include('and 80 more');
225
+ expect(body(2000, 3000).length).to.be.below(60000);
226
+ });
168
227
  });
169
228
 
229
+ // These outcomes apply ONLY to a lane with no own-scope components at all — a lane that merely
230
+ // includes foreign components is reconciled over its own-scope slice and never reaches them.
170
231
  describe('cross-scope outcome messages', () => {
171
232
  const DEFAULT_SCOPE = 'acme.shop';
172
233
  const FOREIGN = ['other.scope/comp2'];
173
234
 
174
235
  it('an enumerated lane is SKIPPED, in the vocabulary of a healthy run', () => {
175
236
  const summary = crossScopeSkipSummary('my-lane', FOREIGN, DEFAULT_SCOPE);
176
- expect(summary).to.match(/^my-lane -> skipped \(cross-scope lane: /);
237
+ expect(summary).to.match(/^my-lane -> skipped \(nothing to mirror: /);
177
238
  expect(summary).to.include('no branch created');
178
239
  // A HALTED/REFUSED marker in this line would flip the exit code of a healthy repository.
179
240
  expect(summary).to.not.include('HALTED');
@@ -182,8 +243,8 @@ describe('cross-scope outcome messages', () => {
182
243
 
183
244
  it('an explicitly requested lane is REFUSED, with the reason and the "nothing was written" promise', () => {
184
245
  const refusal = crossScopeRefusal(FOREIGN, DEFAULT_SCOPE);
185
- expect(refusal).to.include('cross-scope lane: components from scope(s) other.scope');
186
- expect(refusal).to.include("not supported yet — see the docs' Cross-scope lanes section");
246
+ expect(refusal).to.include('nothing to mirror: every component is from scope(s) other.scope');
247
+ expect(refusal).to.include("See the docs' Cross-scope lanes section");
187
248
  expect(refusal).to.include('No branch was created and nothing was written');
188
249
  });
189
250
 
@@ -193,9 +254,9 @@ describe('cross-scope outcome messages', () => {
193
254
  expect(refusal).to.not.include('No branch was created');
194
255
  });
195
256
 
196
- it('a lane that became cross-scope mid-flight names the branch it can no longer converge with', () => {
257
+ it('a mirrored lane whose own slice emptied names the branch it can no longer converge with', () => {
197
258
  const reason = crossScopeMidFlightHaltReason('my-lane', FOREIGN, DEFAULT_SCOPE);
198
- expect(reason).to.include('lane became cross-scope after it was mirrored onto my-lane');
259
+ expect(reason).to.include('every acme.shop component left the lane after it was mirrored onto my-lane');
199
260
  expect(reason).to.include('can no longer be reconciled automatically');
200
261
  });
201
262
  });
@@ -602,6 +663,15 @@ describe('executeExportBranch bails when the lane moved since planning', () => {
602
663
  expect(summary).to.include('my-lane -> noop (converged)');
603
664
  expect(touched).to.deep.equal(['status']);
604
665
  });
666
+
667
+ it('a lane whose only movement is a foreign head proceeds — foreign heads are not this mirror to diverge', async () => {
668
+ const planned = [...L1, comp('other.scope/comp2', 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5')];
669
+ const current = [...L1, comp('other.scope/comp2', 'fffffffffffffffffffffffffffffffffffffff6')];
670
+ const { run, touched } = stubExport(current, planned);
671
+ const summary = await run();
672
+ expect(summary).to.include('my-lane -> noop (converged)');
673
+ expect(touched).to.deep.equal(['status']);
674
+ });
605
675
  });
606
676
 
607
677
  // The merge path snaps whatever the merge produced — which can be nothing new, now routinely: a
@@ -135,15 +135,25 @@ function laneComponentIds(components: LaneData['components']): string[] {
135
135
  }
136
136
 
137
137
  /**
138
- * The lane's components that do not belong to `defaultScope`. A lane may span scopes, and the
139
- * reconciler acts on a lane whole (fingerprint, materialize, snap, export), so foreign content is
140
- * refused rather than written into this repository. Soft-deleted components and `updateDependents`
141
- * entries are deliberately not read — the reconciler never materializes or exports them either.
138
+ * The lane's components that do not belong to `defaultScope`. A lane may span scopes; this
139
+ * repository reconciles only its own-scope slice (see `ownLaneComponents`), and the foreign entries
140
+ * ride along on the lane untouched — sourced in their own scopes' repositories and reachable here
141
+ * only as package dependencies. Soft-deleted components and `updateDependents` entries are
142
+ * deliberately not read — the reconciler never materializes or exports them either.
142
143
  */
143
144
  export function foreignLaneComponents(components: LaneData['components'], defaultScope: string): string[] {
144
145
  return components.filter((comp) => comp.id.scope !== defaultScope).map((comp) => comp.id.toStringWithoutVersion());
145
146
  }
146
147
 
148
+ /**
149
+ * The slice of the lane this repository mirrors: the components that belong to `defaultScope`.
150
+ * Every reconciliation act — fingerprint, materialize, snap, export — operates on this slice, so a
151
+ * foreign component's head moving neither wakes the mirror nor reads as divergence.
152
+ */
153
+ export function ownLaneComponents(components: LaneData['components'], defaultScope: string): LaneData['components'] {
154
+ return components.filter((comp) => comp.id.scope === defaultScope);
155
+ }
156
+
147
157
  /** How many foreign component ids a cross-scope message names before it summarizes the rest. */
148
158
  const MAX_LISTED_FOREIGN_COMPONENTS = 5;
149
159
 
@@ -163,40 +173,45 @@ export function capEntries(entries: string[], overflowPrefix = '', max: number =
163
173
  return [...entries.slice(0, max), `${overflowPrefix}…and ${entries.length - max} more`];
164
174
  }
165
175
 
166
- /** The shared clause describing why a lane is cross-scope, used by the skip/refusal/halt outcomes. */
176
+ /**
177
+ * The shared clause describing a lane with nothing of this repository's scope on it, used by the
178
+ * skip/refusal/halt outcomes. Only such a lane is refused: a lane that merely INCLUDES foreign
179
+ * components is reconciled over its own-scope slice.
180
+ */
167
181
  export function crossScopeDescription(foreignIds: string[], defaultScope: string): string {
168
182
  // The scope is everything before the FIRST '/': a component id is `<scope>/<namespace…>/<name>`.
169
183
  const scopes = [...new Set(foreignIds.map((id) => id.split('/', 1)[0]))].sort();
170
184
  const sample = capEntries(foreignIds, '', MAX_LISTED_FOREIGN_COMPONENTS).join(', ');
171
185
  return (
172
- `components from scope(s) ${scopes.join(', ')} (this repo maps scope ${defaultScope}); ` +
186
+ `every component is from scope(s) ${scopes.join(', ')} (this repo maps scope ${defaultScope}); ` +
173
187
  `foreign components: ${sample}`
174
188
  );
175
189
  }
176
190
 
177
191
  /**
178
- * A cross-scope lane that was merely enumerated: a skip, and the run stays green — a standing, valid
179
- * lane must not turn every scheduled run permanently red.
192
+ * A lane with no own-scope components that was merely enumerated: a skip, and the run stays green — a
193
+ * standing, valid lane must not turn every scheduled run permanently red.
180
194
  */
181
195
  export function crossScopeSkipSummary(laneName: string, foreignIds: string[], defaultScope: string): string {
182
196
  return (
183
- `${laneName} -> skipped (cross-scope lane: ${crossScopeDescription(foreignIds, defaultScope)} — ` +
197
+ `${laneName} -> skipped (nothing to mirror: ${crossScopeDescription(foreignIds, defaultScope)} — ` +
184
198
  `no branch created; see the docs' Cross-scope lanes section)`
185
199
  );
186
200
  }
187
201
 
188
202
  /**
189
- * A cross-scope lane the user named explicitly: exits non-zero with the explanation. A refusal, not a
190
- * halt — no PR is labelled, no branch is written. `existingBranch` keeps the closing sentence truthful
191
- * when a branch of that name already exists.
203
+ * A lane with no own-scope components that the user named explicitly: exits non-zero with the
204
+ * explanation. A refusal, not a halt — no PR is labelled, no branch is written. `existingBranch`
205
+ * keeps the closing sentence truthful when a branch of that name already exists.
192
206
  */
193
207
  export function crossScopeRefusal(foreignIds: string[], defaultScope: string, existingBranch?: string): string {
194
208
  const outcome = existingBranch
195
209
  ? `Nothing was written; the existing branch ${existingBranch} was left untouched`
196
210
  : 'No branch was created and nothing was written';
197
211
  return (
198
- `cross-scope lane: ${crossScopeDescription(foreignIds, defaultScope)}; syncing cross-scope lanes is ` +
199
- `not supported yet — see the docs' Cross-scope lanes section. ${outcome}`
212
+ `nothing to mirror: ${crossScopeDescription(foreignIds, defaultScope)} — this repository reconciles ` +
213
+ `only its own scope's slice of a lane, and this lane has none. See the docs' Cross-scope lanes ` +
214
+ `section. ${outcome}`
200
215
  );
201
216
  }
202
217
 
@@ -229,13 +244,15 @@ Do NOT run the usual "bit lane import" resolution steps on this branch — they
229
244
  }
230
245
 
231
246
  /**
232
- * A lane that became cross-scope after this repository had already mirrored it onto a branch — the one
233
- * cross-scope shape that is a genuine halt: the pair is mid-flight and can no longer converge.
247
+ * A lane whose own-scope slice emptied out after this repository had already mirrored it onto a
248
+ * branch — the one cross-scope shape that is a genuine halt: the pair is mid-flight, and an empty
249
+ * slice fingerprints as a constant, which would read as converged forever over a stale mirror.
234
250
  */
235
251
  export function crossScopeMidFlightHaltReason(branch: string, foreignIds: string[], defaultScope: string): string {
236
252
  return (
237
- `lane became cross-scope after it was mirrored onto ${branch}: ${crossScopeDescription(foreignIds, defaultScope)}; ` +
238
- `the branch and the lane can no longer be reconciled automatically — see the docs' Cross-scope lanes section`
253
+ `every ${defaultScope} component left the lane after it was mirrored onto ${branch}: ` +
254
+ `${crossScopeDescription(foreignIds, defaultScope)}; the branch and the lane can no longer be ` +
255
+ `reconciled automatically — see the docs' Cross-scope lanes section`
239
256
  );
240
257
  }
241
258
 
@@ -364,7 +381,11 @@ export class LaneSyncExecutor {
364
381
 
365
382
  const remoteLane = await this.getRemoteLane(target);
366
383
 
367
- const laneHead = remoteLane ? laneHeadFingerprint(remoteLane.components) : undefined;
384
+ // The slice of the lane this repository reconciles. Everything below — fingerprint, materialize,
385
+ // snap, export — acts on it alone: foreign components stay on the lane untouched and reach this
386
+ // repository only as package dependencies, so a foreign head moving is invisible here.
387
+ const ownComponents = remoteLane ? ownLaneComponents(remoteLane.components, defaultScope) : [];
388
+ const laneHead = remoteLane ? laneHeadFingerprint(ownComponents) : undefined;
368
389
  const branchExists = await branchExistsOnRemote(branch);
369
390
  const branchState: BranchSyncState = branchExists
370
391
  ? await readBranchSyncState(branch, defaultBranch, defaultScope)
@@ -410,11 +431,13 @@ export class LaneSyncExecutor {
410
431
  });
411
432
  }
412
433
 
413
- // A cross-scope lane cannot be reconciled here — the reconciler acts on a lane whole, so mirroring
414
- // one would write another repository's components into this one. The check lives in the executor
415
- // because every trigger funnels through here, and it needs the lane's content.
434
+ // A lane with NO own-scope components cannot be reconciled here — there is nothing to mirror, and
435
+ // an empty slice fingerprints as a constant, which would read as converged forever. A lane that
436
+ // merely INCLUDES foreign components proceeds: it is reconciled over its own-scope slice. The
437
+ // check lives in the executor because every trigger funnels through here, and it needs the lane's
438
+ // content.
416
439
  const foreign = remoteLane ? foreignLaneComponents(remoteLane.components, defaultScope) : [];
417
- if (foreign.length) {
440
+ if (foreign.length && !ownComponents.length) {
418
441
  return this.crossScopeOutcome({
419
442
  laneName,
420
443
  laneIdStr,
@@ -429,16 +452,27 @@ export class LaneSyncExecutor {
429
452
  dryRun,
430
453
  });
431
454
  }
455
+ if (foreign.length) {
456
+ logger.console(
457
+ formatWarningSummary(
458
+ `${laneName}: cross-scope lane — mirroring its ${ownComponents.length} ${defaultScope} component(s); ` +
459
+ `${foreign.length} component(s) from other scope(s) stay on the lane as package dependencies ` +
460
+ `(${capEntries(foreign, '', MAX_LISTED_FOREIGN_COMPONENTS).join(', ')})`
461
+ )
462
+ );
463
+ }
432
464
  // Attribution to THIS lane is required: the claim was computed for whichever lane the branch's
433
465
  // `.bitmap` names, and a claim on someone else's behalf licenses nothing here.
434
466
  const laneIsGone = branchExists && !laneHead;
435
467
  const ownership: LaneOwnershipEvidence = laneIsGone && mirroredLane === laneIdStr ? claim : 'inherited-or-none';
436
468
 
437
469
  // What the branch reflects, read off its own `.bitmap` — only when that `.bitmap` names THIS lane;
438
- // otherwise the planner's `!lastSyncedHead` rows handle "no state of ours".
470
+ // otherwise the planner's `!lastSyncedHead` rows handle "no state of ours". Read over the OWN
471
+ // slice's ids, the same set `laneHead` fingerprints — a foreign component is never in `.bitmap`
472
+ // and counting it would read the pair as permanently diverged.
439
473
  const lastSyncedHead =
440
474
  remoteLane && branchState.bitmap && mirroredLane === laneIdStr
441
- ? branchStateFingerprint(branchState.bitmap, laneComponentIds(remoteLane.components))
475
+ ? branchStateFingerprint(branchState.bitmap, laneComponentIds(ownComponents))
442
476
  : undefined;
443
477
 
444
478
  // `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
@@ -541,10 +575,12 @@ export class LaneSyncExecutor {
541
575
  }
542
576
 
543
577
  /**
544
- * What a cross-scope lane means for this repository: a mid-flight halt when the branch is this
545
- * lane's live mirror (checked first — the problem is the state of the pair, not the phrasing of the
546
- * request); a refusal when the user named the lane explicitly; otherwise an enumerated skip that
547
- * stays green — one standing cross-scope lane must not fail every scheduled run forever.
578
+ * What a lane with NO own-scope components means for this repository (a lane that merely includes
579
+ * foreign components never reaches here — it is reconciled over its own-scope slice): a mid-flight
580
+ * halt when the branch is this lane's live mirror (checked first — the problem is the state of the
581
+ * pair, not the phrasing of the request); a refusal when the user named the lane explicitly;
582
+ * otherwise an enumerated skip that stays green — one standing foreign lane must not fail every
583
+ * scheduled run forever.
548
584
  */
549
585
  private async crossScopeOutcome({
550
586
  laneName,
@@ -707,11 +743,13 @@ export class LaneSyncExecutor {
707
743
  // moved in between must re-plan (merge-diverged owns that shape): comparing the branch's files
708
744
  // with the newer lane would misread the lane's own advance as branch work, and the export would
709
745
  // put the older content on top of a concurrent developer's push. The materialize above just
710
- // fetched, so this read and the status below see the same lane.
746
+ // fetched, so this read and the status below see the same lane. Own-slice fingerprints on both
747
+ // sides: a foreign head moving concurrently is not this pair's divergence.
711
748
  if (preExportLane) {
712
749
  const laneNow = await this.getRemoteLane(target);
713
- const movedTo = laneNow ? laneHeadFingerprint(laneNow.components) : undefined;
714
- if (movedTo !== laneHeadFingerprint(preExportLane.components)) {
750
+ const { defaultScope } = this.deps;
751
+ const movedTo = laneNow ? laneHeadFingerprint(ownLaneComponents(laneNow.components, defaultScope)) : undefined;
752
+ if (movedTo !== laneHeadFingerprint(ownLaneComponents(preExportLane.components, defaultScope))) {
715
753
  return `${laneName} -> noop (lane ${laneIdStr} moved while this run was planning; the next run re-plans)`;
716
754
  }
717
755
  }
@@ -890,11 +928,13 @@ export class LaneSyncExecutor {
890
928
 
891
929
  // Same freshness rule as the export probe: the plan's inputs must still describe the world.
892
930
  // A lane that moved since planning re-plans — the next run merges the newer head — rather
893
- // than proceeding on a divergence assessment the planner never made.
931
+ // than proceeding on a divergence assessment the planner never made. Own-slice on both sides,
932
+ // like every fingerprint here.
894
933
  if (preExportLane) {
895
934
  const laneNow = await this.getRemoteLane(target);
896
- const movedTo = laneNow ? laneHeadFingerprint(laneNow.components) : undefined;
897
- if (movedTo !== laneHeadFingerprint(preExportLane.components)) {
935
+ const { defaultScope } = this.deps;
936
+ const movedTo = laneNow ? laneHeadFingerprint(ownLaneComponents(laneNow.components, defaultScope)) : undefined;
937
+ if (movedTo !== laneHeadFingerprint(ownLaneComponents(preExportLane.components, defaultScope))) {
898
938
  return `${laneName} -> noop (lane ${laneIdStr} moved while this run was planning; the next run re-plans)`;
899
939
  }
900
940
  }
@@ -1049,7 +1089,8 @@ export class LaneSyncExecutor {
1049
1089
  > {
1050
1090
  const remoteLane = await this.getRemoteLane(target);
1051
1091
  if (!remoteLane) return { status: 'unreadable' };
1052
- const laneHead = laneHeadFingerprint(remoteLane.components);
1092
+ // Own-slice, like the planner's `laneHead`: the ledger records what THIS repository mirrors.
1093
+ const laneHead = laneHeadFingerprint(ownLaneComponents(remoteLane.components, this.deps.defaultScope));
1053
1094
  const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
1054
1095
  if (pushResult.raced) return { status: 'raced' };
1055
1096
  return { status: 'ok', laneHead, remoteLane, branchTipSha: pushResult.pushedSha };
@@ -1101,6 +1142,10 @@ export class LaneSyncExecutor {
1101
1142
  promptMergeOptions: false,
1102
1143
  skipNpmInstall: true,
1103
1144
  workspaceOnly: false,
1145
+ // `workspaceOnly: false` also ADDS lane components the workspace lacks; on a cross-scope lane
1146
+ // that must stop at the own-scope slice — a foreign component merged in here would ride the
1147
+ // sync commit into this repository.
1148
+ restrictToScopes: [this.deps.defaultScope],
1104
1149
  });
1105
1150
 
1106
1151
  let conflictedFileCount = 0;
@@ -1480,6 +1525,10 @@ export class LaneSyncExecutor {
1480
1525
  // opposite of `bit ci pr`, where the git checkout is the source of truth and `switchToLane`'s
1481
1526
  // `workspaceOnly` default keeps the switch from writing lane-only components.
1482
1527
  workspaceOnly: false,
1528
+ // A cross-scope lane's foreign components must never be written into this repository — they are
1529
+ // sourced elsewhere and reach this workspace only as package dependencies. The lane object
1530
+ // itself is still fetched and saved whole, so a later export preserves the foreign entries.
1531
+ restrictToScopes: [this.deps.defaultScope],
1483
1532
  // Follows the direction: in adopt mode the missing-component retry must not write the lane's
1484
1533
  // files over the branch's — the ledger commit would then push the lane's content onto the branch.
1485
1534
  writeAdoptedFiles: theirsWins,
@@ -1574,7 +1623,8 @@ export class LaneSyncExecutor {
1574
1623
  branch,
1575
1624
  branchTipSha,
1576
1625
  laneHead,
1577
- changed: changedLaneComponents(preComponents, postComponents),
1626
+ // Own-slice only: a foreign head that moved concurrently was not snapped by this export.
1627
+ changed: ownLaneComponents(changedLaneComponents(preComponents, postComponents), this.deps.defaultScope),
1578
1628
  });
1579
1629
  await gitHost.upsertComment(pr.number, RUN_SUMMARY_MARKER, body);
1580
1630
  } catch (e: any) {
@@ -1689,7 +1739,14 @@ export class LaneSyncExecutor {
1689
1739
  // `replace('.', '/')` replaces only the first dot, which is correct: a scope id may contain at
1690
1740
  // most one dot (is-valid-scope-name.ts) — the same conversion `ScopeUrl.toPathname` does.
1691
1741
  const laneUrl = `https://${getCloudDomain()}/${target.hostScope.replace('.', '/')}/~lane/${target.name}`;
1692
- const body = laneSyncPrBody({ laneIdStr, laneUrl, branch, laneHead, components: remoteLane.components });
1742
+ const body = laneSyncPrBody({
1743
+ laneIdStr,
1744
+ laneUrl,
1745
+ branch,
1746
+ laneHead,
1747
+ components: remoteLane.components,
1748
+ defaultScope: this.deps.defaultScope,
1749
+ });
1693
1750
  try {
1694
1751
  const created = await gitHost.createPr({
1695
1752
  head: branch,
@@ -1738,8 +1795,11 @@ function isConflictFileStatus(status: string): boolean {
1738
1795
  }
1739
1796
 
1740
1797
  /**
1741
- * The body of the pull request that mirrors a lane onto a branch. The component list is capped so the
1742
- * body stays inside the git host's size limit however large the lane is; the total is not.
1798
+ * The body of the pull request that mirrors a lane onto a branch. On a cross-scope lane only the
1799
+ * own-scope slice is mirrored, and the foreign components get their own section — the branch's diff
1800
+ * cannot show them, and a reviewer must know the lane carries more than this repository sees. Every
1801
+ * component list is capped so the body stays inside the git host's size limit however large the lane
1802
+ * is; the totals are not.
1743
1803
  */
1744
1804
  export function laneSyncPrBody({
1745
1805
  laneIdStr,
@@ -1747,28 +1807,45 @@ export function laneSyncPrBody({
1747
1807
  branch,
1748
1808
  laneHead,
1749
1809
  components,
1810
+ defaultScope,
1750
1811
  }: {
1751
1812
  laneIdStr: string;
1752
1813
  laneUrl: string;
1753
1814
  branch: string;
1754
1815
  laneHead: string;
1755
1816
  components: LaneData['components'];
1817
+ defaultScope: string;
1756
1818
  }): string {
1819
+ const own = ownLaneComponents(components, defaultScope);
1820
+ const foreignIds = foreignLaneComponents(components, defaultScope);
1757
1821
  const listed = capEntries(
1758
- components.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``),
1822
+ own.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``),
1759
1823
  '- '
1760
1824
  ).join('\n');
1761
- return [
1825
+ const lines = [
1762
1826
  `Mirrors the Bit lane [\`${laneIdStr}\`](${laneUrl}) onto \`${branch}\`.`,
1763
1827
  '',
1764
1828
  `- lane: ${laneUrl}`,
1765
1829
  `- lane head: \`${laneHead}\``,
1766
1830
  '',
1767
- `Components on the lane (${components.length}):`,
1831
+ foreignIds.length
1832
+ ? `Components mirrored from the lane (${own.length} of ${components.length}):`
1833
+ : `Components on the lane (${components.length}):`,
1768
1834
  listed || '_none_',
1769
- '',
1770
- 'This PR is maintained by `bit ci sync` — push to the branch to send changes back to the lane.',
1771
- ].join('\n');
1835
+ ];
1836
+ if (foreignIds.length) {
1837
+ lines.push(
1838
+ '',
1839
+ `Also on this lane, from other scopes (${foreignIds.length}) — not mirrored into this repository; ` +
1840
+ `this branch consumes them as package dependencies at their lane versions:`,
1841
+ capEntries(
1842
+ foreignIds.map((id) => `- \`${id}\``),
1843
+ '- '
1844
+ ).join('\n')
1845
+ );
1846
+ }
1847
+ lines.push('', 'This PR is maintained by `bit ci sync` — push to the branch to send changes back to the lane.');
1848
+ return lines.join('\n');
1772
1849
  }
1773
1850
 
1774
1851
  /**