@teambit/ci 1.0.485 → 1.0.487

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.
Files changed (97) hide show
  1. package/ci.docs.mdx +344 -83
  2. package/commands/sync.cmd.ts +77 -0
  3. package/dist/ci.docs.mdx +344 -83
  4. package/dist/ci.main.runtime.d.ts +67 -6
  5. package/dist/ci.main.runtime.js +152 -25
  6. package/dist/ci.main.runtime.js.map +1 -1
  7. package/dist/commands/sync.cmd.d.ts +34 -0
  8. package/dist/commands/sync.cmd.js +57 -0
  9. package/dist/commands/sync.cmd.js.map +1 -0
  10. package/dist/git.d.ts +11 -1
  11. package/dist/git.js +11 -1
  12. package/dist/git.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +26 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/{preview-1785858957533.js → preview-1785883495880.js} +1 -1
  17. package/dist/sync/git-host-provider.d.ts +47 -0
  18. package/dist/sync/git-host-provider.js +54 -0
  19. package/dist/sync/git-host-provider.js.map +1 -0
  20. package/dist/sync/git-host-provider.spec.d.ts +1 -0
  21. package/dist/sync/git-host-provider.spec.js +171 -0
  22. package/dist/sync/git-host-provider.spec.js.map +1 -0
  23. package/dist/sync/git-ops.d.ts +171 -0
  24. package/dist/sync/git-ops.js +354 -0
  25. package/dist/sync/git-ops.js.map +1 -0
  26. package/dist/sync/git-ops.spec.d.ts +1 -0
  27. package/dist/sync/git-ops.spec.js +476 -0
  28. package/dist/sync/git-ops.spec.js.map +1 -0
  29. package/dist/sync/github-client.d.ts +79 -0
  30. package/dist/sync/github-client.js +198 -0
  31. package/dist/sync/github-client.js.map +1 -0
  32. package/dist/sync/github-client.spec.d.ts +1 -0
  33. package/dist/sync/github-client.spec.js +205 -0
  34. package/dist/sync/github-client.spec.js.map +1 -0
  35. package/dist/sync/init-scaffold.d.ts +40 -0
  36. package/dist/sync/init-scaffold.js +326 -0
  37. package/dist/sync/init-scaffold.js.map +1 -0
  38. package/dist/sync/init-scaffold.spec.d.ts +1 -0
  39. package/dist/sync/init-scaffold.spec.js +169 -0
  40. package/dist/sync/init-scaffold.spec.js.map +1 -0
  41. package/dist/sync/lane-sync-executor.d.ts +256 -0
  42. package/dist/sync/lane-sync-executor.js +1249 -0
  43. package/dist/sync/lane-sync-executor.js.map +1 -0
  44. package/dist/sync/lane-sync-executor.spec.d.ts +1 -0
  45. package/dist/sync/lane-sync-executor.spec.js +416 -0
  46. package/dist/sync/lane-sync-executor.spec.js.map +1 -0
  47. package/dist/sync/main-sync-executor.d.ts +62 -0
  48. package/dist/sync/main-sync-executor.js +305 -0
  49. package/dist/sync/main-sync-executor.js.map +1 -0
  50. package/dist/sync/sync-config.d.ts +83 -0
  51. package/dist/sync/sync-config.js +187 -0
  52. package/dist/sync/sync-config.js.map +1 -0
  53. package/dist/sync/sync-config.spec.d.ts +1 -0
  54. package/dist/sync/sync-config.spec.js +254 -0
  55. package/dist/sync/sync-config.spec.js.map +1 -0
  56. package/dist/sync/sync-orchestrator.d.ts +76 -0
  57. package/dist/sync/sync-orchestrator.js +396 -0
  58. package/dist/sync/sync-orchestrator.js.map +1 -0
  59. package/dist/sync/sync-orchestrator.spec.d.ts +1 -0
  60. package/dist/sync/sync-orchestrator.spec.js +155 -0
  61. package/dist/sync/sync-orchestrator.spec.js.map +1 -0
  62. package/dist/sync/sync-planner.d.ts +60 -0
  63. package/dist/sync/sync-planner.js +114 -0
  64. package/dist/sync/sync-planner.js.map +1 -0
  65. package/dist/sync/sync-planner.spec.d.ts +1 -0
  66. package/dist/sync/sync-planner.spec.js +210 -0
  67. package/dist/sync/sync-planner.spec.js.map +1 -0
  68. package/dist/sync/sync-state.d.ts +94 -0
  69. package/dist/sync/sync-state.js +178 -0
  70. package/dist/sync/sync-state.js.map +1 -0
  71. package/dist/sync/sync-state.spec.d.ts +1 -0
  72. package/dist/sync/sync-state.spec.js +271 -0
  73. package/dist/sync/sync-state.spec.js.map +1 -0
  74. package/dist/sync/workspace-lane.d.ts +13 -0
  75. package/dist/sync/workspace-lane.js +27 -0
  76. package/dist/sync/workspace-lane.js.map +1 -0
  77. package/package.json +20 -15
  78. package/sync/git-host-provider.spec.ts +197 -0
  79. package/sync/git-host-provider.ts +89 -0
  80. package/sync/git-ops.spec.ts +506 -0
  81. package/sync/git-ops.ts +334 -0
  82. package/sync/github-client.spec.ts +174 -0
  83. package/sync/github-client.ts +209 -0
  84. package/sync/init-scaffold.spec.ts +142 -0
  85. package/sync/init-scaffold.ts +340 -0
  86. package/sync/lane-sync-executor.spec.ts +389 -0
  87. package/sync/lane-sync-executor.ts +1363 -0
  88. package/sync/main-sync-executor.ts +321 -0
  89. package/sync/sync-config.spec.ts +283 -0
  90. package/sync/sync-config.ts +214 -0
  91. package/sync/sync-orchestrator.spec.ts +120 -0
  92. package/sync/sync-orchestrator.ts +386 -0
  93. package/sync/sync-planner.spec.ts +126 -0
  94. package/sync/sync-planner.ts +99 -0
  95. package/sync/sync-state.spec.ts +263 -0
  96. package/sync/sync-state.ts +176 -0
  97. package/sync/workspace-lane.ts +20 -0
@@ -0,0 +1,1363 @@
1
+ import chalk from 'chalk';
2
+ import { formatWarningSummary } from '@teambit/cli';
3
+ import type { Logger } from '@teambit/logger';
4
+ import type { LanesMain } from '@teambit/lanes';
5
+ import type { LaneData } from '@teambit/legacy.scope';
6
+ import { getCloudDomain } from '@teambit/legacy.constants';
7
+ import { FileStatus, type MergeStrategy } from '@teambit/component.modules.merge-helper';
8
+ import { git } from '../git';
9
+ import type { CiMain } from '../ci.main.runtime';
10
+ import type { CiSyncConfig, LaneTarget } from './sync-config';
11
+ import { laneNameToBranch } from './sync-config';
12
+ import type { BranchSyncState } from './sync-state';
13
+ import {
14
+ CONFLICT_LABEL,
15
+ SYNC_COMMIT_MARKER,
16
+ branchStateFingerprint,
17
+ buildSyncCommitMessage,
18
+ fingerprintIdVersions,
19
+ readBranchSyncState,
20
+ hasSyncMarker,
21
+ isSyncAuthoredMessage,
22
+ } from './sync-state';
23
+ import { currentLaneIdStr, ensureCurrentLaneObject } from './workspace-lane';
24
+ import type { GitHostProvider, PrInfo } from './git-host-provider';
25
+ import type { BranchKeepReason, LaneOwnershipEvidence, LaneSyncAction } from './sync-planner';
26
+ import { planLaneSync } from './sync-planner';
27
+ import {
28
+ addAllExceptScopeAndModules,
29
+ branchExistsOnRemote,
30
+ checkoutPristine,
31
+ checkoutPristineRestore,
32
+ commitWithIdentity,
33
+ deleteBranchArgs,
34
+ fetchRemoteHeads,
35
+ isAncestor,
36
+ isStaleLeaseRejection,
37
+ refetchBranchTip,
38
+ } from './git-ops';
39
+
40
+ /**
41
+ * Prefix of the summary line for a lane that could not be reconciled. `syncLane` deliberately does
42
+ * NOT throw on halt — one unreconcilable lane must not abort the rest of the run; the command layer
43
+ * scans for this prefix to decide the exit code.
44
+ */
45
+ export const HALT_SUMMARY_PREFIX = 'HALTED';
46
+
47
+ /**
48
+ * Prefix for a target the reconciler refuses. Unlike a halt, nothing is mid-flight and no PR is
49
+ * annotated — the run exits non-zero, but the command layer reports a plain refusal rather than a
50
+ * sync conflict.
51
+ */
52
+ export const REFUSED_SUMMARY_PREFIX = 'REFUSED';
53
+
54
+ export type LaneSyncDeps = {
55
+ lanes: LanesMain;
56
+ /** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
57
+ ci: CiMain;
58
+ logger: Logger;
59
+ /** undefined => PR operations are logged and skipped; the git half of the sync still runs. */
60
+ gitHost?: GitHostProvider;
61
+ cfg: Required<CiSyncConfig>;
62
+ defaultScope: string;
63
+ };
64
+
65
+ /**
66
+ * Fingerprint of a lane's content — same primitive as `branchStateFingerprint`, so "equal" means
67
+ * exactly "the branch records every lane component at the lane's head".
68
+ *
69
+ * We deliberately do NOT use `LaneData.hash`: that hash is minted randomly at lane-creation time
70
+ * and does not change when the lane's components advance. This is derived from content instead.
71
+ */
72
+ export function laneHeadFingerprint(components: LaneData['components']): string {
73
+ return fingerprintIdVersions(components.map((comp) => `${comp.id.toStringWithoutVersion()}@${comp.head}`));
74
+ }
75
+
76
+ /** The lane's component ids, without versions — the id set `branchStateFingerprint` reads off the branch. */
77
+ function laneComponentIds(components: LaneData['components']): string[] {
78
+ return components.map((comp) => comp.id.toStringWithoutVersion());
79
+ }
80
+
81
+ /**
82
+ * The lane's components that do not belong to `defaultScope`. A lane may span scopes, and the
83
+ * reconciler acts on a lane whole (fingerprint, materialize, snap, export), so foreign content is
84
+ * refused rather than written into this repository. Soft-deleted components and `updateDependents`
85
+ * entries are deliberately not read — the reconciler never materializes or exports them either.
86
+ */
87
+ export function foreignLaneComponents(components: LaneData['components'], defaultScope: string): string[] {
88
+ return components.filter((comp) => comp.id.scope !== defaultScope).map((comp) => comp.id.toStringWithoutVersion());
89
+ }
90
+
91
+ /** How many foreign component ids a cross-scope message names before it summarizes the rest. */
92
+ const MAX_LISTED_FOREIGN_COMPONENTS = 5;
93
+
94
+ /**
95
+ * How many component ids a PR body or comment enumerates before summarizing the rest. GitHub caps a
96
+ * PR body / issue comment at 65,536 characters and rejects the whole request over it.
97
+ */
98
+ const MAX_LISTED_COMPONENTS = 20;
99
+
100
+ /**
101
+ * `entries`, capped at `max`, with a final "…and K more" entry. The bound is on entries, not rendered
102
+ * length, so a markdown list is never cut mid-line. `overflowPrefix` carries the caller's list marker
103
+ * onto the summary line.
104
+ */
105
+ export function capEntries(entries: string[], overflowPrefix = '', max: number = MAX_LISTED_COMPONENTS): string[] {
106
+ if (entries.length <= max) return entries;
107
+ return [...entries.slice(0, max), `${overflowPrefix}…and ${entries.length - max} more`];
108
+ }
109
+
110
+ /** The shared clause describing why a lane is cross-scope, used by the skip/refusal/halt outcomes. */
111
+ export function crossScopeDescription(foreignIds: string[], defaultScope: string): string {
112
+ // The scope is everything before the FIRST '/': a component id is `<scope>/<namespace…>/<name>`.
113
+ const scopes = [...new Set(foreignIds.map((id) => id.split('/', 1)[0]))].sort();
114
+ const sample = capEntries(foreignIds, '', MAX_LISTED_FOREIGN_COMPONENTS).join(', ');
115
+ return (
116
+ `components from scope(s) ${scopes.join(', ')} (this repo maps scope ${defaultScope}); ` +
117
+ `foreign components: ${sample}`
118
+ );
119
+ }
120
+
121
+ /**
122
+ * A cross-scope lane that was merely enumerated: a skip, and the run stays green — a standing, valid
123
+ * lane must not turn every scheduled run permanently red.
124
+ */
125
+ export function crossScopeSkipSummary(laneName: string, foreignIds: string[], defaultScope: string): string {
126
+ return (
127
+ `${laneName} -> skipped (cross-scope lane: ${crossScopeDescription(foreignIds, defaultScope)} — ` +
128
+ `no branch created; see the docs' Cross-scope lanes section)`
129
+ );
130
+ }
131
+
132
+ /**
133
+ * A cross-scope lane the user named explicitly: exits non-zero with the explanation. A refusal, not a
134
+ * halt — no PR is labelled, no branch is written. `existingBranch` keeps the closing sentence truthful
135
+ * when a branch of that name already exists.
136
+ */
137
+ export function crossScopeRefusal(foreignIds: string[], defaultScope: string, existingBranch?: string): string {
138
+ const outcome = existingBranch
139
+ ? `Nothing was written; the existing branch ${existingBranch} was left untouched`
140
+ : 'No branch was created and nothing was written';
141
+ return (
142
+ `cross-scope lane: ${crossScopeDescription(foreignIds, defaultScope)}; syncing cross-scope lanes is ` +
143
+ `not supported yet — see the docs' Cross-scope lanes section. ${outcome}`
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Halt reason for a branch that is the live mirror of a different lane — two lanes with the same name
149
+ * in different scopes map to the same branch. Names both ids so the human can tell which owns it.
150
+ */
151
+ export function branchMirrorsOtherLaneReason(branch: string, mirroredLaneIdStr: string, laneIdStr: string): string {
152
+ return (
153
+ `branch ${branch} mirrors lane ${mirroredLaneIdStr}; refusing to plan for ${laneIdStr} — two lanes ` +
154
+ `with the same name in different scopes map to the same branch, and reconciling this one would ` +
155
+ `overwrite the other lane's mirror`
156
+ );
157
+ }
158
+
159
+ /**
160
+ * PR comment for the branch-aliasing halt — the one halt whose PR belongs to a DIFFERENT lane. It must
161
+ * not carry the default "import the lane" steps, which would perform the overwrite the halt prevented.
162
+ */
163
+ export function branchMirrorsOtherLaneNote(mirroredLaneIdStr: string, laneIdStr: string): string {
164
+ return `This pull request belongs to lane \`${mirroredLaneIdStr}\`, and nothing is wrong with it.
165
+
166
+ Lane \`${laneIdStr}\` has the same lane *name* in a different scope, so it maps to this same branch, and \
167
+ \`bit ci sync\` refused to reconcile it here rather than overwrite this branch with the other lane's content.
168
+ Both lanes stay unsynced until the collision is resolved: rename one of the two lanes, or map one of them to \
169
+ a different branch via the \`branches\` option of the \`teambit.git/ci\` sync config.
170
+
171
+ Do NOT run the usual "bit lane import" resolution steps on this branch — they would import \
172
+ \`${laneIdStr}\` over \`${mirroredLaneIdStr}\`'s mirror.`;
173
+ }
174
+
175
+ /**
176
+ * A lane that became cross-scope after this repository had already mirrored it onto a branch — the one
177
+ * cross-scope shape that is a genuine halt: the pair is mid-flight and can no longer converge.
178
+ */
179
+ export function crossScopeMidFlightHaltReason(branch: string, foreignIds: string[], defaultScope: string): string {
180
+ return (
181
+ `lane became cross-scope after it was mirrored onto ${branch}: ${crossScopeDescription(foreignIds, defaultScope)}; ` +
182
+ `the branch and the lane can no longer be reconciled automatically — see the docs' Cross-scope lanes section`
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Branches `executeClosePr` refuses to delete whatever the ownership evidence concluded — an
188
+ * unconditional guard at the one site that deletes remote branches.
189
+ */
190
+ export function isProtectedBranch(branch: string, defaultBranch: string, mainSyncBranch: string): boolean {
191
+ return branch === defaultBranch || branch === mainSyncBranch;
192
+ }
193
+
194
+ /**
195
+ * The summary line a dry run reports for a planned action. A planned halt carries the same prefix the
196
+ * real run would: `summarizeSync` recognizes that prefix and nothing else, so without it a `--dry-run`
197
+ * exits 0 on a plan that needs a human. Refusals never reach here — they are returned before planning.
198
+ */
199
+ export function dryRunSummaryLine(laneName: string, action: LaneSyncAction): string {
200
+ return action.type === 'halt'
201
+ ? `${HALT_SUMMARY_PREFIX} ${laneName} -> ${action.reason}`
202
+ : `${laneName} -> ${action.type}`;
203
+ }
204
+
205
+ /** Why each `BranchKeepReason` withheld the deletion — one wording for the PR comment and the summary. */
206
+ export const KEPT_BECAUSE: Record<BranchKeepReason, string> = {
207
+ 'unmerged-commits': `it carries commits that are not in the default branch`,
208
+ 'tip-not-a-sync-commit': `its tip is not one of this reconciler's own commits, so the work in it may exist nowhere else`,
209
+ 'tip-advanced-during-run': `its tip advanced after the ownership evidence was read, so it may carry work this run never saw`,
210
+ };
211
+
212
+ export class LaneSyncExecutor {
213
+ constructor(private deps: LaneSyncDeps) {}
214
+
215
+ /** Every `origin/<ref>` read assumes current remote refs; fetch once per executor, not per lane. */
216
+ private fetched = false;
217
+
218
+ /**
219
+ * Reconcile one lane with its git branch/PR. Returns a summary line (HALT/REFUSED prefixed on
220
+ * failure) and NEVER throws — the contract the `--all` loop is written against, for every failure
221
+ * mode: the steps route expected failures to `executeHalt`, this wrapper covers the unexpected ones.
222
+ */
223
+ async syncLane(
224
+ target: LaneTarget,
225
+ opts: {
226
+ dryRun?: boolean;
227
+ /** The user named this lane on the command line; decides how a cross-scope lane is reported. */
228
+ explicitTarget?: boolean;
229
+ } = {}
230
+ ): Promise<string> {
231
+ const { cfg, logger } = this.deps;
232
+ const laneName = target.name;
233
+ const laneIdStr = `${target.hostScope}/${laneName}`;
234
+ // Declared outside the try so the catch can tell "reconciling failed" from "the name never mapped
235
+ // to a branch". The mapping must run INSIDE the try: `laneNameToBranch` throws on a lane name git
236
+ // cannot accept as a ref, and this method's contract is that it never throws.
237
+ let branch: string | undefined;
238
+ try {
239
+ // The branch mapping is by lane NAME only; everything addressed to bit uses the lane's real id.
240
+ branch = laneNameToBranch(laneName, cfg);
241
+ return await this.reconcileLane({
242
+ target,
243
+ laneIdStr,
244
+ branch,
245
+ dryRun: opts.dryRun,
246
+ explicitTarget: opts.explicitTarget,
247
+ });
248
+ } catch (e: any) {
249
+ const reason = `unexpected error: ${e?.message || e}`;
250
+ // No branch => nothing to annotate: report-only, but still HALT-prefixed so the run exits non-zero.
251
+ if (branch === undefined) {
252
+ logger.console(formatWarningSummary(`Cannot sync lane ${laneIdStr}: ${reason}`));
253
+ return `${HALT_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
254
+ }
255
+ // This catch fires for exceptions thrown before `reconcileLane`'s own dry-run handling gets a
256
+ // say; without the guard an early crash would label a PR on a run that promised no writes.
257
+ if (opts.dryRun) {
258
+ return this.haltOrReport({ laneName, laneIdStr, branch, reason, dryRun: true });
259
+ }
260
+ try {
261
+ return await this.executeHalt({ laneName, laneIdStr, branch, reason, pr: await this.findPr(branch) });
262
+ } catch (haltError: any) {
263
+ // The halt itself failed; throwing here would abort the remaining lanes, so report and move on.
264
+ logger.error(`bit ci sync: failed to halt lane ${laneIdStr}`, haltError);
265
+ logger.consoleWarning(`Could not record the halt of lane ${laneIdStr}: ${haltError?.message || haltError}`);
266
+ return `${HALT_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
267
+ }
268
+ }
269
+ }
270
+
271
+ private async reconcileLane({
272
+ target,
273
+ laneIdStr,
274
+ branch,
275
+ dryRun,
276
+ explicitTarget,
277
+ }: {
278
+ target: LaneTarget;
279
+ laneIdStr: string;
280
+ branch: string;
281
+ dryRun?: boolean;
282
+ explicitTarget?: boolean;
283
+ }): Promise<string> {
284
+ const { logger, defaultScope, cfg } = this.deps;
285
+ const laneName = target.name;
286
+
287
+ await this.fetchOnce();
288
+
289
+ const defaultBranch = await this.deps.ci.getDefaultBranchName();
290
+
291
+ // RESERVED BRANCHES: a `branches` override can map a lane onto the default or main-sync branch,
292
+ // and the lane path would then commit and push to a branch only the main-scope path may own. The
293
+ // guard lives here because this is the single funnel every trigger passes through.
294
+ if (isProtectedBranch(branch, defaultBranch, cfg.mainSyncBranch)) {
295
+ const reason =
296
+ `lane ${laneIdStr} maps to ${branch}, which is ` +
297
+ `${branch === defaultBranch ? "the repository's default branch" : 'the main sync branch maintained by this command'}; ` +
298
+ `the main scope is reconciled by "bit ci sync --main", never as a lane. Nothing was written`;
299
+ if (explicitTarget) {
300
+ logger.console(formatWarningSummary(`Cannot sync lane ${laneIdStr}: ${reason}`));
301
+ return `${REFUSED_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
302
+ }
303
+ const summary = `${laneName} -> skipped (${reason})`;
304
+ logger.console(formatWarningSummary(summary));
305
+ return summary;
306
+ }
307
+
308
+ const remoteLane = await this.getRemoteLane(target);
309
+
310
+ const laneHead = remoteLane ? laneHeadFingerprint(remoteLane.components) : undefined;
311
+ const branchExists = await branchExistsOnRemote(branch);
312
+ const branchState: BranchSyncState = branchExists
313
+ ? await readBranchSyncState(branch, defaultBranch, defaultScope)
314
+ : { stateCommit: undefined, bitmap: undefined, hasDevCommits: false, tipMessage: '' };
315
+ if (hasSyncMarker(branchState.tipMessage)) {
316
+ logger.console('branch tip is a bit-sync commit; reconciler will no-op unless the lane moved');
317
+ }
318
+
319
+ // Read before any refusal below: every halt must be suppressible by the conflict label, or a
320
+ // standing problem re-comments on the same PR on every scheduled run.
321
+ const pr = await this.findPr(branch);
322
+ const conflictLabelPresent = pr?.labels.includes(CONFLICT_LABEL) ?? false;
323
+ const suppressedByLabel = `${laneName} -> noop (PR is labeled ${CONFLICT_LABEL}; resolve and remove the label to resume)`;
324
+
325
+ // Which lane, if any, this branch is the LIVE mirror of. Attribution (the `.bitmap` lane pointer)
326
+ // alone proves nothing: a merged sync PR's state is inherited by every branch cut from the default
327
+ // branch afterwards. Only a state commit the default branch does not contain says "mirror, now".
328
+ const mirroredLane = branchState.bitmap?.laneIdStr;
329
+ const claim: LaneOwnershipEvidence =
330
+ mirroredLane && branchState.stateCommit
331
+ ? await this.assessBranchOwnership({ branch, defaultBranch, stateCommit: branchState.stateCommit })
332
+ : 'inherited-or-none';
333
+ const mirroredLaneIdStr = claim === 'own-live' ? mirroredLane : undefined;
334
+
335
+ // TWO LANES, ONE BRANCH: the mapping is keyed on the lane NAME, so same-named lanes in different
336
+ // scopes collide. Planning against another lane's live mirror would hijack it — halt instead.
337
+ // Only when this lane exists: with no lane, `close-pr` already refuses without attribution, and
338
+ // halting a branch this repository cannot resolve would leave a permanently red run with no PR.
339
+ if (laneHead && mirroredLaneIdStr && mirroredLaneIdStr !== laneIdStr) {
340
+ if (conflictLabelPresent) return suppressedByLabel;
341
+ // The PR annotated here belongs to `mirroredLaneIdStr`, the lane that owns this branch;
342
+ // `commentNote` tells its reviewers why a label appeared on a PR whose own lane is healthy.
343
+ return this.haltOrReport({
344
+ laneName,
345
+ laneIdStr,
346
+ branch,
347
+ reason: branchMirrorsOtherLaneReason(branch, mirroredLaneIdStr, laneIdStr),
348
+ pr,
349
+ dryRun,
350
+ commentNote: branchMirrorsOtherLaneNote(mirroredLaneIdStr, laneIdStr),
351
+ });
352
+ }
353
+
354
+ // A cross-scope lane cannot be reconciled here — the reconciler acts on a lane whole, so mirroring
355
+ // one would write another repository's components into this one. The check lives in the executor
356
+ // because every trigger funnels through here, and it needs the lane's content.
357
+ const foreign = remoteLane ? foreignLaneComponents(remoteLane.components, defaultScope) : [];
358
+ if (foreign.length) {
359
+ return this.crossScopeOutcome({
360
+ laneName,
361
+ laneIdStr,
362
+ branch,
363
+ branchExists,
364
+ foreign,
365
+ midFlight: mirroredLaneIdStr === laneIdStr,
366
+ explicit: Boolean(explicitTarget),
367
+ conflictLabelPresent,
368
+ suppressedByLabel,
369
+ pr,
370
+ dryRun,
371
+ });
372
+ }
373
+ // Attribution to THIS lane is required: the claim was computed for whichever lane the branch's
374
+ // `.bitmap` names, and a claim on someone else's behalf licenses nothing here.
375
+ const laneIsGone = branchExists && !laneHead;
376
+ const ownership: LaneOwnershipEvidence = laneIsGone && mirroredLane === laneIdStr ? claim : 'inherited-or-none';
377
+
378
+ // What the branch reflects, read off its own `.bitmap` — only when that `.bitmap` names THIS lane;
379
+ // otherwise the planner's `!lastSyncedHead` rows handle "no state of ours".
380
+ const lastSyncedHead =
381
+ remoteLane && branchState.bitmap && mirroredLane === laneIdStr
382
+ ? branchStateFingerprint(branchState.bitmap, laneComponentIds(remoteLane.components))
383
+ : undefined;
384
+
385
+ // `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
386
+ // merely quotes the marker must not read as one we wrote.
387
+ const tipIsSyncCommit = isSyncAuthoredMessage(branchState.tipMessage);
388
+
389
+ const action = planLaneSync({
390
+ laneHead,
391
+ branchExists,
392
+ lastSyncedHead,
393
+ hasDevCommits: branchState.hasDevCommits,
394
+ tipIsSyncCommit,
395
+ conflictLabelPresent,
396
+ ownership,
397
+ });
398
+
399
+ logger.console(
400
+ chalk.blue(
401
+ `${laneName} -> ${action.type} (branch: ${branch}, lane head: ${laneHead?.slice(0, 9) ?? 'none'}, ` +
402
+ `branch state: ${lastSyncedHead?.slice(0, 9) ?? 'none'}, ` +
403
+ `dev commits: ${branchState.hasDevCommits}${laneIsGone ? `, branch claim: ${ownership}` : ''})`
404
+ )
405
+ );
406
+
407
+ if (dryRun) {
408
+ const line = dryRunSummaryLine(laneName, action);
409
+ logger.console(formatWarningSummary(`Dry-run: ${line}`));
410
+ return line;
411
+ }
412
+
413
+ // A pair can be converged at the bit level while the tip still holds unsnapped source edits (a
414
+ // single commit that rewrites `.bitmap` AND carries an edit is its own state commit) — say so.
415
+ // `laneHead` must be checked explicitly: with no lane and no attribution both fingerprints are
416
+ // undefined, and `undefined === undefined` would fire on every ordinary developer branch.
417
+ if (
418
+ action.type === 'noop' &&
419
+ action.reason === 'converged' &&
420
+ !tipIsSyncCommit &&
421
+ laneHead &&
422
+ lastSyncedHead === laneHead
423
+ ) {
424
+ logger.console(
425
+ formatWarningSummary(
426
+ `converged on bit state, but ${branch}'s tip is not a bit ci sync commit — any source edits it ` +
427
+ `carries that were never snapped stay invisible until the next commit on the branch`
428
+ )
429
+ );
430
+ }
431
+
432
+ switch (action.type) {
433
+ case 'noop':
434
+ return `${laneName} -> noop (${action.reason})`;
435
+ case 'import-lane':
436
+ // The planner only emits import-lane when the lane exists, so both casts are safe.
437
+ return this.executeImportLane({
438
+ target,
439
+ laneIdStr,
440
+ branch,
441
+ branchExists,
442
+ defaultBranch,
443
+ laneHead: laneHead as string,
444
+ remoteLane: remoteLane as LaneData,
445
+ pr,
446
+ });
447
+ case 'export-branch':
448
+ return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
449
+ case 'merge-diverged':
450
+ return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
451
+ case 'close-pr':
452
+ return this.executeClosePr({
453
+ laneName,
454
+ laneIdStr,
455
+ branch,
456
+ defaultBranch,
457
+ pr,
458
+ deleteBranch: action.deleteBranch,
459
+ keepReason: action.deleteBranch ? undefined : action.keepReason,
460
+ expectedTipSha: branchState.tipSha,
461
+ });
462
+ case 'halt':
463
+ return this.executeHalt({ laneName, laneIdStr, branch, reason: action.reason, pr });
464
+ default: {
465
+ // exhaustiveness guard
466
+ const unhandled: never = action;
467
+ throw new Error(`bit ci sync: unhandled lane sync action ${JSON.stringify(unhandled)}`);
468
+ }
469
+ }
470
+ }
471
+
472
+ /**
473
+ * What a cross-scope lane means for this repository: a mid-flight halt when the branch is this
474
+ * lane's live mirror (checked first — the problem is the state of the pair, not the phrasing of the
475
+ * request); a refusal when the user named the lane explicitly; otherwise an enumerated skip that
476
+ * stays green — one standing cross-scope lane must not fail every scheduled run forever.
477
+ */
478
+ private async crossScopeOutcome({
479
+ laneName,
480
+ laneIdStr,
481
+ branch,
482
+ branchExists,
483
+ foreign,
484
+ midFlight,
485
+ explicit,
486
+ conflictLabelPresent,
487
+ suppressedByLabel,
488
+ pr,
489
+ dryRun,
490
+ }: {
491
+ laneName: string;
492
+ laneIdStr: string;
493
+ branch: string;
494
+ branchExists: boolean;
495
+ foreign: string[];
496
+ midFlight: boolean;
497
+ explicit: boolean;
498
+ conflictLabelPresent: boolean;
499
+ suppressedByLabel: string;
500
+ pr?: PrInfo;
501
+ dryRun?: boolean;
502
+ }): Promise<string> {
503
+ const { logger, defaultScope } = this.deps;
504
+
505
+ if (midFlight) {
506
+ // Self-suppression like every other halt — otherwise this would re-comment on every scheduled run.
507
+ if (conflictLabelPresent) return suppressedByLabel;
508
+ return this.haltOrReport({
509
+ laneName,
510
+ laneIdStr,
511
+ branch,
512
+ reason: crossScopeMidFlightHaltReason(branch, foreign, defaultScope),
513
+ pr,
514
+ dryRun,
515
+ });
516
+ }
517
+
518
+ if (explicit) {
519
+ const reason = crossScopeRefusal(foreign, defaultScope, branchExists ? branch : undefined);
520
+ logger.console(formatWarningSummary(`Cannot sync lane ${laneIdStr}: ${reason}`));
521
+ return `${REFUSED_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
522
+ }
523
+
524
+ const summary = crossScopeSkipSummary(laneName, foreign, defaultScope);
525
+ logger.console(formatWarningSummary(summary));
526
+ return summary;
527
+ }
528
+
529
+ /**
530
+ * Mirror the remote lane onto the branch: check the branch out, materialize the lane into the
531
+ * workspace, and commit the result. The committed `.bitmap` IS the record of the branch's state.
532
+ */
533
+ private async executeImportLane({
534
+ target,
535
+ laneIdStr,
536
+ branch,
537
+ branchExists,
538
+ defaultBranch,
539
+ laneHead,
540
+ remoteLane,
541
+ pr,
542
+ }: {
543
+ target: LaneTarget;
544
+ laneIdStr: string;
545
+ branch: string;
546
+ branchExists: boolean;
547
+ defaultBranch: string;
548
+ laneHead: string;
549
+ remoteLane: LaneData;
550
+ pr?: PrInfo;
551
+ }): Promise<string> {
552
+ const { logger } = this.deps;
553
+ const laneName = target.name;
554
+ logger.console(chalk.blue(`Importing lane ${laneIdStr} onto branch ${branch}`));
555
+
556
+ // A brand-new lane branch forks from the default branch; an existing one is reset to whatever
557
+ // the remote has, so a stale local copy of the branch can never leak into the sync commit.
558
+ const startPoint = branchExists ? `origin/${branch}` : `origin/${defaultBranch}`;
559
+ await this.checkoutFromRemote(branch, startPoint);
560
+
561
+ try {
562
+ // A switch that moved the pointer without materializing the files would make every later run
563
+ // read the pair as converged over content the branch never received — see `materializeLane`.
564
+ const switchErr = await this.materializeLane(laneIdStr);
565
+ if (switchErr) {
566
+ return await this.executeHalt({
567
+ laneName,
568
+ laneIdStr,
569
+ branch,
570
+ reason: `failed to switch to lane ${laneIdStr}: ${switchErr.message}`,
571
+ pr,
572
+ });
573
+ }
574
+
575
+ await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
576
+
577
+ let prUrl = pr?.htmlUrl;
578
+ if (!pr) {
579
+ prUrl = await this.openPrForLane({ target, laneIdStr, branch, defaultBranch, laneHead, remoteLane });
580
+ }
581
+ return (
582
+ `${laneName} -> import-lane (pushed ${branch} @ lane ${laneHead.slice(0, 9)}` +
583
+ `${prUrl ? `, PR ${prUrl}` : ''})`
584
+ );
585
+ } finally {
586
+ await this.restoreWorkspace(defaultBranch);
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Push the branch's dev commits back onto the lane: snap+export the branch's tree, then commit the
592
+ * resulting `.bitmap` back onto the branch so the next run sees the two sides as converged.
593
+ */
594
+ private async executeExportBranch({
595
+ target,
596
+ laneIdStr,
597
+ branch,
598
+ defaultBranch,
599
+ }: {
600
+ target: LaneTarget;
601
+ laneIdStr: string;
602
+ branch: string;
603
+ defaultBranch: string;
604
+ }): Promise<string> {
605
+ const { logger } = this.deps;
606
+ const laneName = target.name;
607
+ logger.console(chalk.blue(`Exporting branch ${branch} onto lane ${laneIdStr}`));
608
+
609
+ const message = await this.lastNonSyncCommitMessage(branch, defaultBranch);
610
+ await this.checkoutFromRemote(branch, `origin/${branch}`);
611
+
612
+ try {
613
+ const exportErr = await this.snapAndExportOntoLane(laneIdStr, message);
614
+ if (exportErr) {
615
+ // Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
616
+ return await this.executeHalt({
617
+ laneName,
618
+ laneIdStr,
619
+ branch,
620
+ reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${exportErr.message}`,
621
+ pr: await this.findPr(branch),
622
+ });
623
+ }
624
+
625
+ const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
626
+ if (!laneHead) {
627
+ return await this.executeHalt({
628
+ laneName,
629
+ laneIdStr,
630
+ branch,
631
+ reason: `lane ${laneIdStr} could not be read back from the remote after export`,
632
+ pr: await this.findPr(branch),
633
+ });
634
+ }
635
+ return `${laneName} -> export-branch (lane ${laneIdStr} @ ${laneHead.slice(0, 9)}, branch ${branch} updated)`;
636
+ } finally {
637
+ await this.restoreWorkspace(defaultBranch);
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Both sides moved since the last sync. Order is load-bearing: (1) merge the lane into the branch's
643
+ * working tree (`bit checkout head`; conflicts halt or auto-resolve per `sync.onConflict`), (2) snap
644
+ * + export the merged tree — the snap IS the merge, (3) commit the resulting `.bitmap` back onto the
645
+ * branch. The merge cannot be skipped: the export path's switch runs under `forceOurs`, which never
646
+ * touches the filesystem, so a bare export would silently revert every lane-side file edit and then
647
+ * assert convergence over the loss. Never throws; anything unexpected halts.
648
+ */
649
+ private async executeMergeDiverged({
650
+ target,
651
+ laneIdStr,
652
+ branch,
653
+ defaultBranch,
654
+ }: {
655
+ target: LaneTarget;
656
+ laneIdStr: string;
657
+ branch: string;
658
+ defaultBranch: string;
659
+ }): Promise<string> {
660
+ const { logger } = this.deps;
661
+ const laneName = target.name;
662
+ logger.console(
663
+ formatWarningSummary(
664
+ `Diverged: lane ${laneIdStr} and branch ${branch} both moved since the last sync — attempting to converge`
665
+ )
666
+ );
667
+
668
+ const halt = async (reason: string) =>
669
+ this.executeHalt({ laneName, laneIdStr, branch, reason, pr: await this.findPr(branch) });
670
+
671
+ try {
672
+ // Force-checkout `origin/<branch>` and reload `.bitmap`: the merge depends on that file for the
673
+ // lane pointer and the merge base, and a stale local branch must never leak into the result.
674
+ await this.checkoutFromRemote(branch, `origin/${branch}`);
675
+
676
+ // ---- step 1: merge the lane's snaps into the branch's working tree -----------------------
677
+ const merge = await this.mergeLaneIntoBranchWorkingTree(laneIdStr);
678
+ if (merge.error) {
679
+ return await halt(`failed to merge lane ${laneIdStr} into branch ${branch}: ${merge.error.message}`);
680
+ }
681
+ let policyClause = '';
682
+ if (merge.conflicts.length) {
683
+ // Discard the conflict markers before ANY next step: a halt must not leave a half-merged tree,
684
+ // and the policy re-merge would read markers on disk as the branch's own content.
685
+ await this.checkoutFromRemote(branch, `origin/${branch}`);
686
+ if (this.deps.cfg.onConflict === 'halt') {
687
+ // Bounded: this reason is posted as the halt PR comment.
688
+ return await halt(`merge conflicts in: ${capEntries(merge.conflicts).join(', ')}`);
689
+ }
690
+ // Side mapping per bit's checkout semantics: `checkout head` merges the lane's head INTO the
691
+ // workspace, whose tree is the branch's — so 'ours' = the branch (git-wins) and 'theirs' = the
692
+ // incoming lane head (lane-wins).
693
+ const strategy: MergeStrategy = this.deps.cfg.onConflict === 'git-wins' ? 'ours' : 'theirs';
694
+ logger.console(
695
+ formatWarningSummary(
696
+ `Merge conflicts in ${capEntries(merge.conflicts).join(', ')} — resolving by policy ` +
697
+ `sync.onConflict "${this.deps.cfg.onConflict}" (bit merge strategy: ${strategy})`
698
+ )
699
+ );
700
+ const resolved = await this.mergeLaneIntoBranchWorkingTree(laneIdStr, strategy);
701
+ if (resolved.error) {
702
+ return await halt(
703
+ `failed to merge lane ${laneIdStr} into branch ${branch} under sync.onConflict ` +
704
+ `"${this.deps.cfg.onConflict}": ${resolved.error.message}`
705
+ );
706
+ }
707
+ if (resolved.conflicts.length) {
708
+ // 'ours'/'theirs' cannot leave a conflict by construction; if bit reports one anyway,
709
+ // exporting it as if resolved is the one unacceptable outcome — reset and halt.
710
+ await this.checkoutFromRemote(branch, `origin/${branch}`);
711
+ return await halt(
712
+ `merge conflicts in ${capEntries(resolved.conflicts).join(', ')} survived the ` +
713
+ `"${this.deps.cfg.onConflict}" auto-resolution`
714
+ );
715
+ }
716
+ // The count comes from the manual pass; the policy pass no longer sees a conflict.
717
+ policyClause = `conflicts auto-resolved: ${this.deps.cfg.onConflict} on ${merge.conflictedFileCount} file(s); `;
718
+ logger.console(
719
+ chalk.green(
720
+ `Resolved ${merge.conflictedFileCount} conflicted file(s) by "${this.deps.cfg.onConflict}" — ` +
721
+ `snapping the merged tree`
722
+ )
723
+ );
724
+ } else {
725
+ logger.console(
726
+ chalk.green(`Merged lane ${laneIdStr} into ${branch} with no conflicts — snapping the merged tree`)
727
+ );
728
+ }
729
+
730
+ // ---- step 2: snap + export the merged tree onto the lane ---------------------------------
731
+ const exportErr = await this.snapAndExportOntoLane(
732
+ laneIdStr,
733
+ `merge remote lane ${laneIdStr} into ${branch} ${SYNC_COMMIT_MARKER}`
734
+ );
735
+ if (exportErr) {
736
+ return await halt(
737
+ `merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` +
738
+ `result failed: ${exportErr.message}`
739
+ );
740
+ }
741
+
742
+ // ---- step 3: record the new lane head on the branch --------------------------------------
743
+ const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
744
+ if (!laneHead) {
745
+ return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
746
+ }
747
+ return (
748
+ `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` +
749
+ `${laneHead.slice(0, 9)}, branch ${branch} updated)`
750
+ );
751
+ } catch (e: any) {
752
+ // Something unforeseen — halt anyway: no single lane may abort the rest of the run.
753
+ return await halt(
754
+ `unexpected failure while reconciling diverged lane ${laneIdStr} with branch ${branch}: ${e?.message || e}`
755
+ );
756
+ } finally {
757
+ await this.restoreWorkspace(defaultBranch);
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Snap the workspace's current tree onto the lane and export it; returns the error so the caller can
763
+ * halt. Snaps WHATEVER is in the workspace (the switch uses `forceOurs` and never merges files), so
764
+ * a diverged tree must already hold the merged content. `keepLane` preserves the lane's history,
765
+ * `skipCleanup` keeps the snap's `.bitmap` for the caller to commit, `noDestructiveRecovery` turns
766
+ * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be
767
+ * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any
768
+ * fetch, so it never warms a cold scope.
769
+ */
770
+ private async snapAndExportOntoLane(laneIdStr: string, message: string): Promise<Error | undefined> {
771
+ try {
772
+ await ensureCurrentLaneObject(this.deps.lanes);
773
+ await this.deps.ci.snapPrCommit({
774
+ laneIdStr,
775
+ message,
776
+ build: undefined,
777
+ strict: undefined,
778
+ keepLane: true,
779
+ skipCleanup: true,
780
+ noDestructiveRecovery: true,
781
+ });
782
+ return undefined;
783
+ } catch (e: any) {
784
+ return e instanceof Error ? e : new Error(String(e?.message ?? e));
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
790
+ * so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
791
+ * Returns undefined when the lane can no longer be read, in which case the caller halts.
792
+ */
793
+ private async recordLaneHeadOnBranch(
794
+ target: LaneTarget,
795
+ laneIdStr: string,
796
+ branch: string
797
+ ): Promise<string | undefined> {
798
+ const remoteLane = await this.getRemoteLane(target);
799
+ if (!remoteLane) return undefined;
800
+ const laneHead = laneHeadFingerprint(remoteLane.components);
801
+ await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
802
+ return laneHead;
803
+ }
804
+
805
+ /**
806
+ * Merge the lane's snaps into the branch's working tree via `bit checkout head` (three-way merge,
807
+ * base = the branch's `.bitmap` version; `workspaceOnly` must stay false so lane-only components are
808
+ * added). Returns conflicted ids + file count rather than throwing. Not `mergeLanes.mergeLane`: both
809
+ * sides here are the same lane id, which `validateMergeFlags` rejects, and `mergeLane` refuses to
810
+ * run with modified components — exactly the branch's state here.
811
+ */
812
+ private async mergeLaneIntoBranchWorkingTree(
813
+ laneIdStr: string,
814
+ mergeStrategy: MergeStrategy = 'manual'
815
+ ): Promise<{ conflicts: string[]; conflictedFileCount: number; error?: Error }> {
816
+ const { logger, lanes } = this.deps;
817
+ try {
818
+ // `checkout head` merges into the CURRENT lane; if the workspace is not on it, the merge would
819
+ // silently resolve to main's heads and write main's content over the dev work — refuse instead.
820
+ // `currentLaneIdStr`, NOT `lanes.getCurrentLane()`: the latter answers "main" on a cold runner.
821
+ const target = await lanes.parseLaneId(laneIdStr);
822
+ const current = currentLaneIdStr(lanes);
823
+ if (current !== target.toString()) {
824
+ return {
825
+ conflicts: [],
826
+ conflictedFileCount: 0,
827
+ error: new Error(
828
+ `the branch's .bitmap points at "${current ?? 'main'}" ` +
829
+ `rather than ${laneIdStr}, so the lane's snaps cannot be merged into the branch's working tree`
830
+ ),
831
+ };
832
+ }
833
+
834
+ // Cold runner: the lane object may not be in this scope at all — import before the merge.
835
+ await ensureCurrentLaneObject(lanes);
836
+
837
+ logger.console(
838
+ chalk.blue(
839
+ `Merging lane ${laneIdStr} into the branch's working tree (bit checkout head ` +
840
+ `${mergeStrategy === 'manual' ? '--manual' : `--auto-merge-resolve ${mergeStrategy}`})`
841
+ )
842
+ );
843
+ // `checkoutByCLIValues` (rather than `checkout`) because it runs `importer.importCurrentObjects()`
844
+ // first — without it, `head` would resolve to the stale lane heads this workspace already had.
845
+ const results = await lanes.checkout.checkoutByCLIValues('', {
846
+ head: true,
847
+ mergeStrategy,
848
+ promptMergeOptions: false,
849
+ skipNpmInstall: true,
850
+ workspaceOnly: false,
851
+ });
852
+
853
+ let conflictedFileCount = 0;
854
+ const conflicts: string[] = [];
855
+ (results.components || []).forEach((comp) => {
856
+ const conflictedFiles = Object.values(comp.filesStatus || {}).filter(isConflictFileStatus);
857
+ if (!conflictedFiles.length) return;
858
+ conflictedFileCount += conflictedFiles.length;
859
+ conflicts.push(comp.id.toStringWithoutVersion());
860
+ });
861
+ // If bit's own summary flag disagrees with the per-file scan, trust it — a missed conflict
862
+ // would get exported as if it were resolved.
863
+ if (!conflicts.length && results.leftUnresolvedConflicts) conflicts.push('(component not reported)');
864
+ return { conflicts, conflictedFileCount };
865
+ } catch (e: any) {
866
+ return {
867
+ conflicts: [],
868
+ conflictedFileCount: 0,
869
+ error: e instanceof Error ? e : new Error(String(e?.message ?? e)),
870
+ };
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Put the working tree on `branch` at a pristine copy of `startPoint` and reload the `.bitmap` it
876
+ * brings (see `checkoutPristine`), so no leftover from a previous lane leaks into this lane's sync
877
+ * commit and the following bit operation resolves against this branch's `.bitmap`. Also the recovery
878
+ * after a merge attempt: resetting to `origin/<branch>` puts both sides back on the fetched tip.
879
+ */
880
+ private async checkoutFromRemote(branch: string, startPoint: string) {
881
+ await checkoutPristine(branch, startPoint, () => this.deps.ci.reloadWorkspaceFromDisk());
882
+ }
883
+
884
+ /**
885
+ * The reachability half of `LaneOwnershipEvidence`; the attribution half lives at the call site.
886
+ * An unanswerable question resolves to `inherited-or-none` — every other answer permits deleting a
887
+ * branch, and a failed `merge-base` is not evidence of anything. A foreign-hosted lane's branch
888
+ * reached by branch name attributes as `inherited-or-none` (scope mismatch), which is the safe
889
+ * direction: left alone, never retired.
890
+ */
891
+ private async assessBranchOwnership({
892
+ branch,
893
+ defaultBranch,
894
+ stateCommit,
895
+ }: {
896
+ branch: string;
897
+ defaultBranch: string;
898
+ stateCommit: string;
899
+ }): Promise<LaneOwnershipEvidence> {
900
+ const { logger } = this.deps;
901
+ try {
902
+ if (!(await isAncestor(stateCommit, `origin/${defaultBranch}`))) return 'own-live';
903
+ // The PR was merged. Deleting is only safe if the tip is in the default branch too; otherwise
904
+ // work was pushed after the merge and lives nowhere else.
905
+ if (await isAncestor(`origin/${branch}`, `origin/${defaultBranch}`)) return 'own-merged';
906
+ return 'own-superseded';
907
+ } catch (e: any) {
908
+ logger.consoleWarning(
909
+ `Could not establish whether ${branch} is already merged into ${defaultBranch}, so its ownership ` +
910
+ `could not be determined; treating it as inherited-or-none (no branch is retired, and the branch ` +
911
+ `is not treated as any lane's live mirror): ${e?.message || e}`
912
+ );
913
+ return 'inherited-or-none';
914
+ }
915
+ }
916
+
917
+ /**
918
+ * The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
919
+ * Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
920
+ * "work that exists nowhere else" from "we did not write the tip".
921
+ */
922
+ private async executeClosePr({
923
+ laneName,
924
+ laneIdStr,
925
+ branch,
926
+ defaultBranch,
927
+ pr,
928
+ deleteBranch,
929
+ keepReason,
930
+ expectedTipSha,
931
+ }: {
932
+ laneName: string;
933
+ laneIdStr: string;
934
+ branch: string;
935
+ defaultBranch: string;
936
+ pr?: PrInfo;
937
+ deleteBranch: boolean;
938
+ keepReason?: BranchKeepReason;
939
+ /** the tip every input to the deletion decision was read from; the delete leases on it */
940
+ expectedTipSha?: string;
941
+ }): Promise<string> {
942
+ const { logger, gitHost } = this.deps;
943
+ const prClause = pr ? `PR #${pr.number} closed` : 'no open PR';
944
+
945
+ // Every input to the deletion decision — tip-is-sync-commit, dev commits, ownership — comes from refs
946
+ // fetched once at the start of the run, and an `--all` run can spend minutes on earlier lanes. Re-read
947
+ // this one branch first: the evidence licensed deleting THAT commit, not whatever the branch holds now.
948
+ let keep = keepReason;
949
+ let deleteAt: string | undefined;
950
+ if (deleteBranch) {
951
+ const currentTip = expectedTipSha ? await this.currentBranchTip(branch) : undefined;
952
+ if (currentTip && currentTip === expectedTipSha) deleteAt = currentTip;
953
+ else {
954
+ keep = 'tip-advanced-during-run';
955
+ logger.consoleWarning(
956
+ `Not retiring branch ${branch}: its tip is now ${currentTip ?? 'unreadable'} rather than the ` +
957
+ `${expectedTipSha ?? 'unknown'} its ownership evidence was read from — it advanced during this run`
958
+ );
959
+ }
960
+ }
961
+
962
+ const keptBecause = KEPT_BECAUSE[keep ?? 'unmerged-commits'];
963
+ const closeComment = deleteAt
964
+ ? `Lane ${laneIdStr} was removed/archived on bit.cloud.`
965
+ : `Lane ${laneIdStr} was removed/archived on bit.cloud. The branch \`${branch}\` is being kept: ` +
966
+ `${keptBecause}.`;
967
+ if (gitHost && pr) {
968
+ logger.console(chalk.blue(`Closing PR #${pr.number} for removed lane ${laneIdStr}`));
969
+ await gitHost.closePr(pr.number, closeComment);
970
+ } else if (gitHost) {
971
+ // Not an error: the PR may have been merged or closed by hand already, or never existed.
972
+ logger.console(
973
+ formatWarningSummary(
974
+ `No open PR found for ${branch} — ${deleteAt ? 'only retiring the branch' : 'nothing to close'}`
975
+ )
976
+ );
977
+ } else {
978
+ logger.console(formatWarningSummary(`No configured git host provider — skipping PR close for ${branch}`));
979
+ }
980
+
981
+ if (!deleteAt) {
982
+ if (keep === 'tip-not-a-sync-commit') {
983
+ logger.console(
984
+ formatWarningSummary(
985
+ `lane removed remotely, but ${branch}'s tip is not a bit ci sync commit — a developer wrote the ` +
986
+ `branch's current bit state, so it is not safe to assume the branch is only our mirror; keeping it`
987
+ )
988
+ );
989
+ return `${laneName} -> close-pr (${prClause}, branch ${branch} kept: its tip was not written by bit ci sync)`;
990
+ }
991
+ if (keep === 'tip-advanced-during-run') {
992
+ return `${laneName} -> close-pr (${prClause}, branch ${branch} kept: ${keptBecause})`;
993
+ }
994
+ logger.console(
995
+ formatWarningSummary(`lane removed remotely but branch carries unmerged commits; keeping branch ${branch}`)
996
+ );
997
+ return (
998
+ `${laneName} -> close-pr (${prClause}, branch ${branch} kept: ` +
999
+ `it carries commits missing from the default branch)`
1000
+ );
1001
+ }
1002
+
1003
+ // Reaching this guard means something upstream is wrong; log it and keep the branch.
1004
+ if (isProtectedBranch(branch, defaultBranch, this.deps.cfg.mainSyncBranch)) {
1005
+ logger.consoleWarning(
1006
+ `Refusing to delete branch ${branch}: it is ${
1007
+ branch === defaultBranch ? 'the default branch' : 'the main sync branch'
1008
+ }, whatever the ownership evidence concluded`
1009
+ );
1010
+ return `${laneName} -> close-pr (${prClause}, branch ${branch} kept: deleting it is never allowed)`;
1011
+ }
1012
+
1013
+ // Best-effort: the branch may be protected, or already removed by hand.
1014
+ let branchDeleted = true;
1015
+ let leaseRefused = false;
1016
+ try {
1017
+ await this.pushBranchDeletion(branch, deleteAt);
1018
+ } catch (e: any) {
1019
+ const message = String(e?.message || e);
1020
+ branchDeleted = false;
1021
+ // The second belt: the re-read above and this push are not atomic, so the server gets the last word.
1022
+ leaseRefused = isStaleLeaseRejection(message);
1023
+ logger.consoleWarning(
1024
+ leaseRefused
1025
+ ? `Not retiring branch ${branch}: the remote refused the lease on ${deleteAt} — it advanced ` +
1026
+ `between the re-read and the delete`
1027
+ : `Could not delete remote branch ${branch}: ${message}`
1028
+ );
1029
+ }
1030
+ if (leaseRefused) {
1031
+ return `${laneName} -> close-pr (${prClause}, branch ${branch} kept: ${KEPT_BECAUSE['tip-advanced-during-run']})`;
1032
+ }
1033
+ return `${laneName} -> close-pr (${prClause}, branch ${branch} ${branchDeleted ? 'deleted' : 'left in place'})`;
1034
+ }
1035
+
1036
+ /** Seams for the retirement path, so the one irreversible outcome is unit-testable without a remote. */
1037
+ private currentBranchTip(branch: string): Promise<string | undefined> {
1038
+ return refetchBranchTip(branch);
1039
+ }
1040
+
1041
+ private async pushBranchDeletion(branch: string, expectedTipSha: string): Promise<void> {
1042
+ await git.push(deleteBranchArgs(branch, expectedTipSha));
1043
+ }
1044
+
1045
+ /**
1046
+ * Record a halt without touching the PR on a dry run — `--dry-run` promises no PR writes, and
1047
+ * labelling freezes a lane's syncs. The line stays HALT-prefixed either way. Every pre-planning halt
1048
+ * goes through here so the guard cannot be forgotten at one site.
1049
+ */
1050
+ private async haltOrReport({
1051
+ laneName,
1052
+ laneIdStr,
1053
+ branch,
1054
+ reason,
1055
+ pr,
1056
+ dryRun,
1057
+ commentNote,
1058
+ }: {
1059
+ laneName: string;
1060
+ laneIdStr: string;
1061
+ branch: string;
1062
+ reason: string;
1063
+ pr?: PrInfo;
1064
+ dryRun?: boolean;
1065
+ commentNote?: string;
1066
+ }): Promise<string> {
1067
+ if (dryRun) {
1068
+ const { logger } = this.deps;
1069
+ logger.console(formatWarningSummary(`Cannot sync lane ${laneIdStr} automatically: ${reason}`));
1070
+ logger.console(formatWarningSummary('Dry-run: the PR is not labelled or commented on'));
1071
+ return `${HALT_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
1072
+ }
1073
+ return this.executeHalt({ laneName, laneIdStr, branch, reason, pr, commentNote });
1074
+ }
1075
+
1076
+ /**
1077
+ * Hand the lane back to a human: label the PR so subsequent runs skip it, and comment the resolution
1078
+ * steps. `commentNote` replaces the default steps for halts where they would be wrong.
1079
+ */
1080
+ private async executeHalt({
1081
+ laneName,
1082
+ laneIdStr,
1083
+ branch,
1084
+ reason,
1085
+ pr,
1086
+ commentNote,
1087
+ }: {
1088
+ laneName: string;
1089
+ laneIdStr: string;
1090
+ branch: string;
1091
+ reason: string;
1092
+ pr?: PrInfo;
1093
+ commentNote?: string;
1094
+ }): Promise<string> {
1095
+ const { logger, gitHost } = this.deps;
1096
+ logger.console(formatWarningSummary(`Cannot sync lane ${laneIdStr} automatically: ${reason}`));
1097
+ if (gitHost && pr) {
1098
+ try {
1099
+ await gitHost.addLabel(pr.number, CONFLICT_LABEL);
1100
+ await gitHost.comment(pr.number, haltCommentBody({ reason, branch, laneId: laneIdStr, note: commentNote }));
1101
+ } catch (e: any) {
1102
+ // Failing to annotate the PR must not replace the real reason with a git-host API error.
1103
+ logger.consoleWarning(`Failed to annotate PR #${pr.number} with the sync conflict: ${e?.message || e}`);
1104
+ }
1105
+ } else if (gitHost) {
1106
+ logger.console(
1107
+ formatWarningSummary(`No open PR found for ${branch} — the halt is recorded in this run's summary only`)
1108
+ );
1109
+ } else {
1110
+ logger.console(
1111
+ formatWarningSummary(`No configured git host provider — skipping conflict label/comment for ${branch}`)
1112
+ );
1113
+ }
1114
+ return `${HALT_SUMMARY_PREFIX} ${laneName} -> ${reason}`;
1115
+ }
1116
+
1117
+ /**
1118
+ * Drive the workspace's filesystem to the remote lane's content; returns the error so the caller can
1119
+ * halt. Two traps make a plain `switchToLane` insufficient: (1) it defaults to `forceOurs`, which
1120
+ * never touches the filesystem — the commit would claim the branch mirrors the lane over the default
1121
+ * branch's files — so `forceOurs` is cleared and `forceTheirs` set; (2) switching onto the lane the
1122
+ * workspace already sits on throws "already checked out", which `switchToLane` swallows as success
1123
+ * and nothing materializes — so step off to main first.
1124
+ */
1125
+ private async materializeLane(laneIdStr: string): Promise<Error | undefined> {
1126
+ const { logger } = this.deps;
1127
+ const target = await this.deps.lanes.parseLaneId(laneIdStr);
1128
+ // Compares name AND scope, and reads from `.bitmap`, not the scope's lane object — the object read
1129
+ // answers "main" on a cold runner and would skip the step-off to main (see `workspace-lane.ts`).
1130
+ const isOnTarget = () => currentLaneIdStr(this.deps.lanes) === target.toString();
1131
+
1132
+ if (isOnTarget()) {
1133
+ logger.console(
1134
+ formatWarningSummary(
1135
+ `Workspace is already on ${laneIdStr} — stepping off to main so the re-import actually runs`
1136
+ )
1137
+ );
1138
+ const toMainErr = await this.deps.ci.switchToLaneForSync('main');
1139
+ if (toMainErr) return toMainErr;
1140
+ if (isOnTarget()) {
1141
+ return new Error(`unable to leave lane ${laneIdStr} before re-importing it; the workspace is still on it`);
1142
+ }
1143
+ }
1144
+
1145
+ const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, { forceOurs: false, forceTheirs: true });
1146
+ if (switchErr) return switchErr;
1147
+
1148
+ // Verify before the caller commits a `.bitmap` asserting this lane's content.
1149
+ if (!isOnTarget()) {
1150
+ return new Error(
1151
+ `switching to lane ${laneIdStr} reported success but the workspace is on ` +
1152
+ `"${currentLaneIdStr(this.deps.lanes) ?? 'main'}"`
1153
+ );
1154
+ }
1155
+ return undefined;
1156
+ }
1157
+
1158
+ private async fetchOnce() {
1159
+ if (this.fetched) return;
1160
+ await fetchRemoteHeads();
1161
+ this.fetched = true;
1162
+ }
1163
+
1164
+ /**
1165
+ * The remote lane's data, or undefined when the lane no longer exists. The remote queried must be
1166
+ * the lane's HOSTING scope: asking `defaultScope` for a lane it does not host answers "was not
1167
+ * found" — the input that drives `close-pr`, which deletes branches.
1168
+ */
1169
+ private async getRemoteLane(target: LaneTarget): Promise<LaneData | undefined> {
1170
+ const { hostScope, name } = target;
1171
+ const lanes = await this.deps.lanes.getLanes({ remote: hostScope, name }).catch((e) => {
1172
+ // "was not found" = the lane is gone: a legitimate state (drives close-pr), not an error.
1173
+ if (e.toString().includes('was not found')) return [];
1174
+ throw new Error(`Failed to read lane ${hostScope}/${name} from the remote: ${e.toString()}`);
1175
+ });
1176
+ return lanes[0];
1177
+ }
1178
+
1179
+ /**
1180
+ * The open PR for the branch, if any. A git-host API hiccup degrades to "no PR known" rather than
1181
+ * failing the lane — the git side of the sync is still correct and the next run re-reads the PR.
1182
+ */
1183
+ private async findPr(branch: string): Promise<PrInfo | undefined> {
1184
+ const { gitHost, logger } = this.deps;
1185
+ if (!gitHost) return undefined;
1186
+ try {
1187
+ return await gitHost.findPrByBranch(branch);
1188
+ } catch (e: any) {
1189
+ logger.consoleWarning(`Could not look up the PR for ${branch}: ${e?.message || e}`);
1190
+ return undefined;
1191
+ }
1192
+ }
1193
+
1194
+ /**
1195
+ * The subject of the newest commit on the branch that isn't one of our own sync commits — it's
1196
+ * the developer's own description of the change, and becomes the snap message on the lane.
1197
+ */
1198
+ private async lastNonSyncCommitMessage(branch: string, defaultBranch: string): Promise<string> {
1199
+ const log = await git.log([`origin/${branch}`, '--max-count=200']);
1200
+ const entry = log.all.find((item) => {
1201
+ const message = item.body ? `${item.message}\n\n${item.body}` : item.message;
1202
+ return !hasSyncMarker(message);
1203
+ });
1204
+ return entry?.message || `chore: sync ${branch} into the lane (from ${defaultBranch})`;
1205
+ }
1206
+
1207
+ /**
1208
+ * Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
1209
+ * push means someone pushed concurrently and the next run should re-plan rather than clobber.
1210
+ * `--allow-empty` is only insurance against `git commit` failing the lane outright.
1211
+ */
1212
+ private async commitAllAndPush(branch: string, message: string) {
1213
+ await addAllExceptScopeAndModules();
1214
+ await commitWithIdentity(message, { extraArgs: ['--allow-empty'] });
1215
+ // `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or reinterpreted —
1216
+ // the configured branch name is user input (see `sync-config.ts`).
1217
+ await git.push(['origin', `HEAD:refs/heads/${branch}`]);
1218
+ this.deps.logger.console(chalk.green(`Pushed ${branch}`));
1219
+ }
1220
+
1221
+ private async openPrForLane({
1222
+ target,
1223
+ laneIdStr,
1224
+ branch,
1225
+ defaultBranch,
1226
+ laneHead,
1227
+ remoteLane,
1228
+ }: {
1229
+ target: LaneTarget;
1230
+ laneIdStr: string;
1231
+ branch: string;
1232
+ defaultBranch: string;
1233
+ laneHead: string;
1234
+ remoteLane: LaneData;
1235
+ }): Promise<string | undefined> {
1236
+ const { gitHost, logger } = this.deps;
1237
+ if (!gitHost) {
1238
+ logger.console(formatWarningSummary(`No configured git host provider — skipping PR creation for ${branch}`));
1239
+ return undefined;
1240
+ }
1241
+ // The lane page lives under the scope that HOSTS the lane, not necessarily `defaultScope`.
1242
+ // `replace('.', '/')` replaces only the first dot, which is correct: a scope id may contain at
1243
+ // most one dot (is-valid-scope-name.ts) — the same conversion `ScopeUrl.toPathname` does.
1244
+ const laneUrl = `https://${getCloudDomain()}/${target.hostScope.replace('.', '/')}/~lane/${target.name}`;
1245
+ const body = laneSyncPrBody({ laneIdStr, laneUrl, branch, laneHead, components: remoteLane.components });
1246
+ try {
1247
+ const created = await gitHost.createPr({
1248
+ head: branch,
1249
+ base: defaultBranch,
1250
+ title: `Lane sync: ${laneIdStr}`,
1251
+ body,
1252
+ });
1253
+ logger.console(chalk.green(`Opened PR ${created.htmlUrl} for lane ${laneIdStr}`));
1254
+ return created.htmlUrl;
1255
+ } catch (e: any) {
1256
+ // The branch is already pushed — the load-bearing half. A failed PR creation shouldn't fail the
1257
+ // lane; the next run retries.
1258
+ logger.consoleWarning(`Could not open a PR for ${branch}: ${e?.message || e}`);
1259
+ return undefined;
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Put the workspace back on main with the default branch checked out. Best-effort, warn-only, so a
1265
+ * restore hiccup can't throw out of a `finally` and mask the real error. The checkout is forced
1266
+ * (switching back to main rewrites `.bitmap`); the scoped clean discards files a halted lane
1267
+ * materialized but never committed, which would otherwise be staged onto the NEXT lane's branch;
1268
+ * the reload makes the next lane resolve against the right checkout. This is a checkout, never a
1269
+ * push — and `MainSyncExecutor.restoreWorkspace` must not diverge from it.
1270
+ */
1271
+ private async restoreWorkspace(defaultBranch: string) {
1272
+ const { logger } = this.deps;
1273
+ try {
1274
+ if (currentLaneIdStr(this.deps.lanes)) {
1275
+ const switchErr = await this.deps.ci.switchToLaneForSync('main');
1276
+ if (switchErr) logger.consoleWarning(`Could not switch the workspace back to main: ${switchErr.message}`);
1277
+ }
1278
+ await checkoutPristineRestore(defaultBranch, () => this.deps.ci.reloadWorkspaceFromDisk());
1279
+ } catch (e: any) {
1280
+ logger.consoleWarning(`Could not restore the workspace after sync: ${e?.message || e}`);
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Whether a `filesStatus` entry represents a conflict. Compared against `FileStatus` rather than
1287
+ * string literals — its values are chalk-colored labels. `deletedConflict` counts too.
1288
+ */
1289
+ function isConflictFileStatus(status: string): boolean {
1290
+ return status === FileStatus.manual || status === FileStatus.binaryConflict || status === FileStatus.deletedConflict;
1291
+ }
1292
+
1293
+ /**
1294
+ * The body of the pull request that mirrors a lane onto a branch. The component list is capped so the
1295
+ * body stays inside the git host's size limit however large the lane is; the total is not.
1296
+ */
1297
+ export function laneSyncPrBody({
1298
+ laneIdStr,
1299
+ laneUrl,
1300
+ branch,
1301
+ laneHead,
1302
+ components,
1303
+ }: {
1304
+ laneIdStr: string;
1305
+ laneUrl: string;
1306
+ branch: string;
1307
+ laneHead: string;
1308
+ components: LaneData['components'];
1309
+ }): string {
1310
+ const listed = capEntries(
1311
+ components.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``),
1312
+ '- '
1313
+ ).join('\n');
1314
+ return [
1315
+ `Mirrors the Bit lane [\`${laneIdStr}\`](${laneUrl}) onto \`${branch}\`.`,
1316
+ '',
1317
+ `- lane: ${laneUrl}`,
1318
+ `- lane head: \`${laneHead}\``,
1319
+ '',
1320
+ `Components on the lane (${components.length}):`,
1321
+ listed || '_none_',
1322
+ '',
1323
+ 'This PR is maintained by `bit ci sync` — push to the branch to send changes back to the lane.',
1324
+ ].join('\n');
1325
+ }
1326
+
1327
+ /**
1328
+ * Single-quote a value interpolated into a copy-pasteable shell command. Lane names may contain `$` and
1329
+ * `!`, and a configured branch name may contain anything git accepts as a ref — including a quote.
1330
+ */
1331
+ function shellQuote(value: string): string {
1332
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1333
+ }
1334
+
1335
+ /**
1336
+ * Resolution instructions posted on a halted PR. `note` replaces the default steps for halts where
1337
+ * they do not apply (the branch-aliasing halt, whose PR belongs to a different lane).
1338
+ */
1339
+ export function haltCommentBody({
1340
+ reason,
1341
+ branch,
1342
+ laneId,
1343
+ note,
1344
+ }: {
1345
+ reason: string;
1346
+ branch: string;
1347
+ laneId: string;
1348
+ note?: string;
1349
+ }): string {
1350
+ const resolution =
1351
+ note ??
1352
+ `To resolve locally:
1353
+ git fetch origin && git checkout ${shellQuote(branch)}
1354
+ bit lane import ${shellQuote(laneId)}
1355
+ # resolve conflicts, commit the result, then:
1356
+ git push origin ${shellQuote(branch)}`;
1357
+ return `bit-git-sync could not reconcile this branch automatically: ${reason}
1358
+
1359
+ ${resolution}
1360
+
1361
+ Remove the \`bit-sync-conflict\` label to resume syncing.
1362
+ `;
1363
+ }