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