@teambit/ci 1.0.523 → 1.0.525
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/ci.docs.mdx +26 -13
- package/commands/sync.cmd.ts +2 -2
- package/dist/ci.docs.mdx +26 -13
- package/dist/commands/sync.cmd.js +2 -2
- package/dist/commands/sync.cmd.js.map +1 -1
- package/dist/{preview-1787260228106.js → preview-1787325829408.js} +1 -1
- package/dist/sync/heal-missing-main-files.d.ts +36 -0
- package/dist/sync/heal-missing-main-files.js +180 -0
- package/dist/sync/heal-missing-main-files.js.map +1 -0
- package/dist/sync/lane-sync-executor.d.ts +37 -19
- package/dist/sync/lane-sync-executor.js +102 -42
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +67 -9
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/main-sync-executor.d.ts +3 -0
- package/dist/sync/main-sync-executor.js +23 -4
- package/dist/sync/main-sync-executor.js.map +1 -1
- package/dist/sync/sync-orchestrator.js +1 -0
- package/dist/sync/sync-orchestrator.js.map +1 -1
- package/package.json +22 -20
- package/sync/heal-missing-main-files.ts +153 -0
- package/sync/lane-sync-executor.spec.ts +80 -10
- package/sync/lane-sync-executor.ts +123 -46
- package/sync/main-sync-executor.ts +38 -3
- package/sync/sync-orchestrator.ts +1 -0
|
@@ -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('
|
|
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:
|
|
139
|
-
|
|
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 \(
|
|
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('
|
|
186
|
-
expect(refusal).to.include("
|
|
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
|
|
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('
|
|
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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
`
|
|
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
|
|
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 (
|
|
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
|
|
190
|
-
* halt — no PR is labelled, no branch is written. `existingBranch`
|
|
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
|
-
`
|
|
199
|
-
`
|
|
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
|
|
233
|
-
* cross-scope shape that is a genuine halt: the pair is mid-flight and
|
|
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
|
-
`
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
414
|
-
//
|
|
415
|
-
//
|
|
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(
|
|
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
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
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
|
|
714
|
-
|
|
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
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
1742
|
-
*
|
|
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
|
-
|
|
1822
|
+
own.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``),
|
|
1759
1823
|
'- '
|
|
1760
1824
|
).join('\n');
|
|
1761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1771
|
-
|
|
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
|
/**
|