@teambit/ci 1.0.507 → 1.0.508

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 (36) hide show
  1. package/dist/ci.main.runtime.d.ts +3 -2
  2. package/dist/ci.main.runtime.js +5 -6
  3. package/dist/ci.main.runtime.js.map +1 -1
  4. package/dist/{preview-1787147173446.js → preview-1787160027560.js} +1 -1
  5. package/dist/sync/git-host-provider.d.ts +9 -0
  6. package/dist/sync/git-host-provider.js.map +1 -1
  7. package/dist/sync/github-client.d.ts +28 -0
  8. package/dist/sync/github-client.js +81 -3
  9. package/dist/sync/github-client.js.map +1 -1
  10. package/dist/sync/github-client.spec.js +249 -0
  11. package/dist/sync/github-client.spec.js.map +1 -1
  12. package/dist/sync/lane-sync-executor.d.ts +47 -3
  13. package/dist/sync/lane-sync-executor.js +191 -39
  14. package/dist/sync/lane-sync-executor.js.map +1 -1
  15. package/dist/sync/lane-sync-executor.spec.js +336 -0
  16. package/dist/sync/lane-sync-executor.spec.js.map +1 -1
  17. package/dist/sync/sync-planner.d.ts +12 -2
  18. package/dist/sync/sync-planner.js +17 -8
  19. package/dist/sync/sync-planner.js.map +1 -1
  20. package/dist/sync/sync-planner.spec.js +64 -0
  21. package/dist/sync/sync-planner.spec.js.map +1 -1
  22. package/dist/sync/sync-state.d.ts +34 -3
  23. package/dist/sync/sync-state.js +61 -3
  24. package/dist/sync/sync-state.js.map +1 -1
  25. package/dist/sync/sync-state.spec.js +54 -0
  26. package/dist/sync/sync-state.spec.js.map +1 -1
  27. package/package.json +15 -15
  28. package/sync/git-host-provider.ts +8 -0
  29. package/sync/github-client.spec.ts +160 -0
  30. package/sync/github-client.ts +90 -3
  31. package/sync/lane-sync-executor.spec.ts +291 -0
  32. package/sync/lane-sync-executor.ts +233 -50
  33. package/sync/sync-planner.spec.ts +41 -0
  34. package/sync/sync-planner.ts +31 -10
  35. package/sync/sync-state.spec.ts +48 -0
  36. package/sync/sync-state.ts +104 -4
@@ -101,6 +101,12 @@ export function racedAdoptionPushSummary(laneName: string): string {
101
101
  return racedSummary(laneName, 'lane untouched; adoption ledger commit lost the push race — next run re-plans');
102
102
  }
103
103
 
104
+ /**
105
+ * An HTML comment embedded in the run-summary comment's body, so `upsertComment` finds and replaces
106
+ * THIS comment on a later run rather than posting a duplicate summary on every push.
107
+ */
108
+ export const RUN_SUMMARY_MARKER = '<!-- bit-ci-sync:run-summary -->';
109
+
104
110
  export type LaneSyncDeps = {
105
111
  lanes: LanesMain;
106
112
  /** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
@@ -362,11 +368,13 @@ export class LaneSyncExecutor {
362
368
  const branchExists = await branchExistsOnRemote(branch);
363
369
  const branchState: BranchSyncState = branchExists
364
370
  ? await readBranchSyncState(branch, defaultBranch, defaultScope)
365
- : { stateCommit: undefined, bitmap: undefined, hasDevCommits: false, tipMessage: '' };
366
- if (hasSyncMarker(branchState.tipMessage)) {
367
- logger.console('branch tip is a bit-sync commit; reconciler will no-op unless the lane moved');
368
- }
369
-
371
+ : {
372
+ stateCommit: undefined,
373
+ bitmap: undefined,
374
+ hasDevCommits: false,
375
+ stateCommitBundlesSources: false,
376
+ tipMessage: '',
377
+ };
370
378
  // Read before any refusal below: every halt must be suppressible by the conflict label, or a
371
379
  // standing problem re-comments on the same PR on every scheduled run.
372
380
  const pr = await this.findPr(branch);
@@ -444,7 +452,7 @@ export class LaneSyncExecutor {
444
452
 
445
453
  const branchNamesDifferentLane = await this.computeBranchNamesDifferentLane(
446
454
  lastSyncedHead,
447
- branchState.hasDevCommits,
455
+ branchState.hasDevCommits || branchState.stateCommitBundlesSources,
448
456
  mirroredLane,
449
457
  laneIdStr,
450
458
  branch,
@@ -456,6 +464,7 @@ export class LaneSyncExecutor {
456
464
  branchExists,
457
465
  lastSyncedHead,
458
466
  hasDevCommits: branchState.hasDevCommits,
467
+ stateCommitBundlesSources: branchState.stateCommitBundlesSources,
459
468
  tipIsSyncCommit,
460
469
  hasIndependentHistory,
461
470
  conflictLabelPresent,
@@ -467,7 +476,9 @@ export class LaneSyncExecutor {
467
476
  chalk.blue(
468
477
  `${laneName} -> ${action.type} (branch: ${branch}, lane head: ${laneHead?.slice(0, 9) ?? 'none'}, ` +
469
478
  `branch state: ${lastSyncedHead?.slice(0, 9) ?? 'none'}, ` +
470
- `dev commits: ${branchState.hasDevCommits}${laneIsGone ? `, branch claim: ${ownership}` : ''})`
479
+ `dev commits: ${branchState.hasDevCommits}` +
480
+ `${branchState.stateCommitBundlesSources ? ' (+sources bundled into the state commit)' : ''}` +
481
+ `${laneIsGone ? `, branch claim: ${ownership}` : ''})`
471
482
  )
472
483
  );
473
484
 
@@ -477,25 +488,6 @@ export class LaneSyncExecutor {
477
488
  return line;
478
489
  }
479
490
 
480
- // A pair can be converged at the bit level while the tip still holds unsnapped source edits (a
481
- // single commit that rewrites `.bitmap` AND carries an edit is its own state commit) — say so.
482
- // `laneHead` must be checked explicitly: with no lane and no attribution both fingerprints are
483
- // undefined, and `undefined === undefined` would fire on every ordinary developer branch.
484
- if (
485
- action.type === 'noop' &&
486
- action.reason === 'converged' &&
487
- !tipIsSyncCommit &&
488
- laneHead &&
489
- lastSyncedHead === laneHead
490
- ) {
491
- logger.console(
492
- formatWarningSummary(
493
- `converged on bit state, but ${branch}'s tip is not a bit ci sync commit — any source edits it ` +
494
- `carries that were never snapped stay invisible until the next commit on the branch`
495
- )
496
- );
497
- }
498
-
499
491
  switch (action.type) {
500
492
  case 'noop':
501
493
  return `${laneName} -> noop (${action.reason})`;
@@ -512,20 +504,15 @@ export class LaneSyncExecutor {
512
504
  pr,
513
505
  });
514
506
  case 'export-branch':
515
- // The tip is already this reconciler's own commit — it already confirmed everything up to
516
- // it (that is what writing it means), and `hasDevCommits`/`stateCommit` cannot tell a real
517
- // dev commit from one that touches no bit-tracked file (docs, CI config): `stateCommit` is
518
- // derived from `.bitmap`'s content, never from commit messages (sync-state.ts), so a commit
519
- // that leaves `.bitmap` byte-identical never advances it, however many runs re-confirm
520
- // "nothing to snap" on top. Recognizing our own tip here — not by loosening `hasDevCommits`
521
- // itself, which the ownership/retirement path also reads and must stay strict — is what
522
- // makes that settle instead of re-planning `export-branch` forever.
523
- if (tipIsSyncCommit) {
524
- return `${laneName} -> noop (converged; branch tip is already this reconciler's own sync commit)`;
525
- }
526
- return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
507
+ return this.executeExportBranch({
508
+ target,
509
+ laneIdStr,
510
+ branch,
511
+ defaultBranch,
512
+ preExportLane: remoteLane,
513
+ });
527
514
  case 'merge-diverged':
528
- return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
515
+ return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch, preExportLane: remoteLane });
529
516
  case 'adopt-branch':
530
517
  return this.executeAdoptBranch({ target, laneIdStr, branch, defaultBranch, pr });
531
518
  case 'close-pr':
@@ -678,28 +665,63 @@ export class LaneSyncExecutor {
678
665
  }
679
666
 
680
667
  /**
681
- * Push the branch's dev commits back onto the lane: snap+export the branch's tree, then commit the
682
- * resulting `.bitmap` back onto the branch so the next run sees the two sides as converged.
668
+ * Push the branch's dev commits back onto the lane. Probe first, mutate second: like adoption, land
669
+ * the lane in the workspace keeping the BRANCH's files, then ask `bit status` whether the branch's
670
+ * tree holds anything the lane is missing. Nothing → converged, and nothing is written or pushed —
671
+ * which is why a suspected-work plan can repeat run after run without a memo, and why no commit
672
+ * message is ever consulted to suppress it. Something → snap + export, then commit the resulting
673
+ * `.bitmap` back onto the branch so the next run reads the two sides as converged.
683
674
  */
684
675
  private async executeExportBranch({
685
676
  target,
686
677
  laneIdStr,
687
678
  branch,
688
679
  defaultBranch,
680
+ preExportLane,
689
681
  }: {
690
682
  target: LaneTarget;
691
683
  laneIdStr: string;
692
684
  branch: string;
693
685
  defaultBranch: string;
686
+ /** The lane's content BEFORE this export — the baseline the run-summary comment diffs against. */
687
+ preExportLane: LaneData | undefined;
694
688
  }): Promise<string> {
695
689
  const { logger } = this.deps;
696
690
  const laneName = target.name;
697
691
  logger.console(chalk.blue(`Exporting branch ${branch} onto lane ${laneIdStr}`));
698
692
 
699
- const message = await this.lastNonSyncCommitMessage(branch, defaultBranch);
700
693
  await this.checkoutFromRemote(branch, `origin/${branch}`);
701
694
 
702
695
  try {
696
+ const switchErr = await this.materializeLane(laneIdStr, false);
697
+ if (switchErr) {
698
+ return await this.executeHalt({
699
+ laneName,
700
+ laneIdStr,
701
+ branch,
702
+ reason: `failed to check branch ${branch} against lane ${laneIdStr} before exporting: ${switchErr.message}`,
703
+ pr: await this.findPr(branch),
704
+ });
705
+ }
706
+ // The probe's "clean" answer means converged only against the lane the PLAN read. A lane that
707
+ // moved in between must re-plan (merge-diverged owns that shape): comparing the branch's files
708
+ // with the newer lane would misread the lane's own advance as branch work, and the export would
709
+ // put the older content on top of a concurrent developer's push. The materialize above just
710
+ // fetched, so this read and the status below see the same lane.
711
+ if (preExportLane) {
712
+ const laneNow = await this.getRemoteLane(target);
713
+ const movedTo = laneNow ? laneHeadFingerprint(laneNow.components) : undefined;
714
+ if (movedTo !== laneHeadFingerprint(preExportLane.components)) {
715
+ return `${laneName} -> noop (lane ${laneIdStr} moved while this run was planning; the next run re-plans)`;
716
+ }
717
+ }
718
+ if (!(await this.deps.ci.hasUnsyncedWorkChanges())) {
719
+ logger.console(chalk.green(`Branch ${branch}'s tree holds nothing lane ${laneIdStr} is missing`));
720
+ return `${laneName} -> noop (converged)`;
721
+ }
722
+
723
+ // Read only once the probe found work: it walks up to 200 commits, wasted on a converged probe.
724
+ const message = await this.lastNonSyncCommitMessage(branch, defaultBranch);
703
725
  const exported = await this.snapAndExportOntoLane(laneIdStr, message);
704
726
  if (exported.status === 'error') {
705
727
  // Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
@@ -711,6 +733,12 @@ export class LaneSyncExecutor {
711
733
  pr: await this.findPr(branch),
712
734
  });
713
735
  }
736
+ if (exported.status === 'noop') {
737
+ // `bit status` saw a difference the snap did not record. Nothing reached the lane and nothing
738
+ // is pushed, so the next run re-reads the identical state — visible-but-repeating beats a
739
+ // ledger commit asserting a convergence the status read just denied.
740
+ return `${laneName} -> noop (bit status reported unsynced changes but the snap found nothing to record)`;
741
+ }
714
742
 
715
743
  const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
716
744
  if (recorded.status === 'raced') return racedLedgerPushSummary(laneName);
@@ -723,6 +751,17 @@ export class LaneSyncExecutor {
723
751
  pr: await this.findPr(branch),
724
752
  });
725
753
  }
754
+
755
+ // Best-effort: a comment failure must never undo a git/lane pair that already converged.
756
+ await this.postRunSummaryComment({
757
+ target,
758
+ laneIdStr,
759
+ branch,
760
+ branchTipSha: recorded.branchTipSha,
761
+ laneHead: recorded.laneHead,
762
+ preComponents: laneSummaryComponents(preExportLane),
763
+ postComponents: laneSummaryComponents(recorded.remoteLane),
764
+ });
726
765
  return (
727
766
  `${laneName} -> export-branch (lane ${laneIdStr} @ ${recorded.laneHead.slice(0, 9)}, ` +
728
767
  `branch ${branch} updated)`
@@ -824,11 +863,14 @@ export class LaneSyncExecutor {
824
863
  laneIdStr,
825
864
  branch,
826
865
  defaultBranch,
866
+ preExportLane,
827
867
  }: {
828
868
  target: LaneTarget;
829
869
  laneIdStr: string;
830
870
  branch: string;
831
871
  defaultBranch: string;
872
+ /** The lane's content BEFORE this merge export — the baseline the run-summary comment diffs against. */
873
+ preExportLane: LaneData | undefined;
832
874
  }): Promise<string> {
833
875
  const { logger } = this.deps;
834
876
  const laneName = target.name;
@@ -846,6 +888,17 @@ export class LaneSyncExecutor {
846
888
  // lane pointer and the merge base, and a stale local branch must never leak into the result.
847
889
  await this.checkoutFromRemote(branch, `origin/${branch}`);
848
890
 
891
+ // Same freshness rule as the export probe: the plan's inputs must still describe the world.
892
+ // A lane that moved since planning re-plans — the next run merges the newer head — rather
893
+ // than proceeding on a divergence assessment the planner never made.
894
+ if (preExportLane) {
895
+ const laneNow = await this.getRemoteLane(target);
896
+ const movedTo = laneNow ? laneHeadFingerprint(laneNow.components) : undefined;
897
+ if (movedTo !== laneHeadFingerprint(preExportLane.components)) {
898
+ return `${laneName} -> noop (lane ${laneIdStr} moved while this run was planning; the next run re-plans)`;
899
+ }
900
+ }
901
+
849
902
  // ---- step 1: merge the lane's snaps into the branch's working tree -----------------------
850
903
  const merge = await this.mergeLaneIntoBranchWorkingTree(laneIdStr);
851
904
  if (merge.error) {
@@ -918,8 +971,22 @@ export class LaneSyncExecutor {
918
971
  if (recorded.status === 'unreadable') {
919
972
  return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
920
973
  }
974
+ // The summary comment belongs only to runs that exported — same as the export path, and a
975
+ // nothing-new merge (the branch had nothing of its own) must not claim it exported.
976
+ if (exported.status === 'exported') {
977
+ await this.postRunSummaryComment({
978
+ target,
979
+ laneIdStr,
980
+ branch,
981
+ branchTipSha: recorded.branchTipSha,
982
+ laneHead: recorded.laneHead,
983
+ preComponents: laneSummaryComponents(preExportLane),
984
+ postComponents: laneSummaryComponents(recorded.remoteLane),
985
+ });
986
+ }
987
+ const exportClause = exported.status === 'exported' ? 'then exported' : 'the merge left nothing new to export';
921
988
  return (
922
- `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` +
989
+ `${laneName} -> merge-diverged (${policyClause}merged lane into branch, ${exportClause}; lane ${laneIdStr} @ ` +
923
990
  `${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)`
924
991
  );
925
992
  } catch (e: any) {
@@ -967,20 +1034,25 @@ export class LaneSyncExecutor {
967
1034
  * so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
968
1035
  * `unreadable` means the lane could no longer be read back, in which case the caller halts;
969
1036
  * `raced` means a concurrent run's push already got there first, in which case the caller reports
970
- * a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
1037
+ * a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`. The re-queried lane is
1038
+ * also the caller's only cheap source of POST-export component versions.
971
1039
  */
972
1040
  private async recordLaneHeadOnBranch(
973
1041
  target: LaneTarget,
974
1042
  laneIdStr: string,
975
1043
  branch: string,
976
1044
  opts: { adopted?: boolean } = {}
977
- ): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
1045
+ ): Promise<
1046
+ | { status: 'unreadable' }
1047
+ | { status: 'raced' }
1048
+ | { status: 'ok'; laneHead: string; remoteLane: LaneData; branchTipSha?: string }
1049
+ > {
978
1050
  const remoteLane = await this.getRemoteLane(target);
979
1051
  if (!remoteLane) return { status: 'unreadable' };
980
1052
  const laneHead = laneHeadFingerprint(remoteLane.components);
981
1053
  const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
982
1054
  if (pushResult.raced) return { status: 'raced' };
983
- return { status: 'ok', laneHead };
1055
+ return { status: 'ok', laneHead, remoteLane, branchTipSha: pushResult.pushedSha };
984
1056
  }
985
1057
 
986
1058
  /**
@@ -1137,13 +1209,13 @@ export class LaneSyncExecutor {
1137
1209
  /** Gates `differentLaneStillClaims` to the planner's first-contact path, the only one that reads it. */
1138
1210
  private async computeBranchNamesDifferentLane(
1139
1211
  lastSyncedHead: string | undefined,
1140
- hasDevCommits: boolean,
1212
+ mayCarryWork: boolean,
1141
1213
  mirroredLane: string | undefined,
1142
1214
  laneIdStr: string,
1143
1215
  branch: string,
1144
1216
  defaultBranch: string
1145
1217
  ): Promise<boolean> {
1146
- if (lastSyncedHead || !hasDevCommits) return false;
1218
+ if (lastSyncedHead || !mayCarryWork) return false;
1147
1219
  return this.differentLaneStillClaims(mirroredLane, laneIdStr, branch, defaultBranch);
1148
1220
  }
1149
1221
 
@@ -1460,6 +1532,56 @@ export class LaneSyncExecutor {
1460
1532
  }
1461
1533
  }
1462
1534
 
1535
+ /**
1536
+ * Post (or update) ONE maintained comment on the branch's PR: what this export did to the scope —
1537
+ * the components it snapped and the synced branch/lane anchors — information the branch's git diff
1538
+ * cannot show, since the snap step alone can move dependency ranges the PR's files never touched.
1539
+ * Best-effort throughout: no configured git host, no open PR yet, or a provider that doesn't
1540
+ * implement `upsertComment` are all normal states, not failures — the git/lane pair already
1541
+ * converged without this comment. A comment API error warns and never fails the run.
1542
+ */
1543
+ private async postRunSummaryComment({
1544
+ target,
1545
+ laneIdStr,
1546
+ branch,
1547
+ branchTipSha,
1548
+ laneHead,
1549
+ preComponents,
1550
+ postComponents,
1551
+ }: {
1552
+ target: LaneTarget;
1553
+ laneIdStr: string;
1554
+ branch: string;
1555
+ /** The ledger sha THIS run pushed — the anchor is never refetched from the mutable remote tip. */
1556
+ branchTipSha: string | undefined;
1557
+ laneHead: string;
1558
+ preComponents: LaneData['components'] | undefined;
1559
+ postComponents: LaneData['components'];
1560
+ }): Promise<void> {
1561
+ const { gitHost, logger } = this.deps;
1562
+ if (!gitHost?.upsertComment) return;
1563
+ const pr = await this.findPr(branch);
1564
+ if (!pr) return;
1565
+ // Same URL shape as the lane-sync PR body (line ~1249): the scope that HOSTS the lane, not
1566
+ // necessarily `defaultScope`.
1567
+ const laneUrl = `https://${getCloudDomain()}/${target.hostScope.replace('.', '/')}/~lane/${target.name}`;
1568
+ // Body construction sits INSIDE the guard: `currentBranchTip` is a git refetch and can reject,
1569
+ // and an export that already succeeded must never be failed by its own progress report.
1570
+ try {
1571
+ const body = runSummaryCommentBody({
1572
+ laneIdStr,
1573
+ laneUrl,
1574
+ branch,
1575
+ branchTipSha,
1576
+ laneHead,
1577
+ changed: changedLaneComponents(preComponents, postComponents),
1578
+ });
1579
+ await gitHost.upsertComment(pr.number, RUN_SUMMARY_MARKER, body);
1580
+ } catch (e: any) {
1581
+ logger.consoleWarning(`Could not post the run-summary comment on ${branch}'s PR: ${e?.message || e}`);
1582
+ }
1583
+ }
1584
+
1463
1585
  /**
1464
1586
  * The subject of the newest commit on the branch that isn't one of our own sync commits — it's
1465
1587
  * the developer's own description of the change, and becomes the snap message on the lane.
@@ -1480,13 +1602,16 @@ export class LaneSyncExecutor {
1480
1602
  * the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
1481
1603
  * benign noop instead of halting; any other push failure still throws.
1482
1604
  */
1483
- private async commitAllAndPush(branch: string, message: string): Promise<{ raced: boolean }> {
1605
+ private async commitAllAndPush(branch: string, message: string): Promise<{ raced: boolean; pushedSha?: string }> {
1484
1606
  // The local knowledge of `origin/<branch>` right now — no fetch, `fetchOnce` already ran for this
1485
1607
  // whole executor run — is exactly what our about-to-be-made commit is based on. After a rejection,
1486
1608
  // this is the baseline `classifyPushRejection` checks the remote against to confirm a real race.
1487
1609
  const baseSha = await this.localRemoteTip(branch);
1488
1610
  await addAllExceptScopeAndModules();
1489
1611
  await commitWithIdentity(message, { extraArgs: ['--allow-empty'] });
1612
+ // The sha of the commit this run is about to push — the summary anchors on it, never on a
1613
+ // refetch of the mutable remote tip (a concurrent dev push must not read as the synced state).
1614
+ const pushedSha = (await git.revparse(['HEAD'])).trim() || undefined;
1490
1615
  try {
1491
1616
  // `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or
1492
1617
  // reinterpreted — the configured branch name is user input (see `sync-config.ts`).
@@ -1512,7 +1637,7 @@ export class LaneSyncExecutor {
1512
1637
  return { raced: true };
1513
1638
  }
1514
1639
  this.deps.logger.console(chalk.green(`Pushed ${branch}`));
1515
- return { raced: false };
1640
+ return { raced: false, pushedSha };
1516
1641
  }
1517
1642
 
1518
1643
  /** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
@@ -1646,6 +1771,64 @@ export function laneSyncPrBody({
1646
1771
  ].join('\n');
1647
1772
  }
1648
1773
 
1774
+ /**
1775
+ * The lane components this export actually changed: new on the lane, or moved to a new head. Diffed
1776
+ * by id against the lane's content BEFORE the export — `LaneData.head` is the snap hash bit assigns a
1777
+ * lane component, so comparing it (not looking at git) is the only way to name what the snap did.
1778
+ */
1779
+ /**
1780
+ * The lane entries the run SUMMARY diffs: the visible components plus the hidden `updateDependents`
1781
+ * (a cascade export moves those heads too, and a summary reading "nothing changed" over one would be
1782
+ * false). Summary-only — the fingerprint and the foreign-scope check keep reading `components` alone.
1783
+ */
1784
+ export function laneSummaryComponents(lane: LaneData | undefined): LaneData['components'] {
1785
+ return lane ? [...lane.components, ...(lane.updateDependents ?? [])] : [];
1786
+ }
1787
+
1788
+ export function changedLaneComponents(
1789
+ before: LaneData['components'] | undefined,
1790
+ after: LaneData['components']
1791
+ ): LaneData['components'] {
1792
+ const beforeHeads = new Map((before ?? []).map((comp) => [comp.id.toStringWithoutVersion(), comp.head]));
1793
+ return after.filter((comp) => beforeHeads.get(comp.id.toStringWithoutVersion()) !== comp.head);
1794
+ }
1795
+
1796
+ /**
1797
+ * What a sync run did to the scope — information the branch's own git diff cannot show, since the
1798
+ * snap step can move a component's recorded version with no file in the PR ever changing. Kept to one
1799
+ * maintained comment (`RUN_SUMMARY_MARKER` + `upsertComment`) rather than one post per push.
1800
+ */
1801
+ export function runSummaryCommentBody({
1802
+ laneIdStr,
1803
+ laneUrl,
1804
+ branch,
1805
+ branchTipSha,
1806
+ laneHead,
1807
+ changed,
1808
+ }: {
1809
+ laneIdStr: string;
1810
+ laneUrl: string;
1811
+ branch: string;
1812
+ branchTipSha: string | undefined;
1813
+ laneHead: string;
1814
+ changed: LaneData['components'];
1815
+ }): string {
1816
+ const listed = capEntries(
1817
+ changed.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``),
1818
+ '- '
1819
+ ).join('\n');
1820
+ return [
1821
+ RUN_SUMMARY_MARKER,
1822
+ `**bit ci sync** synced this branch with lane [\`${laneIdStr}\`](${laneUrl}).`,
1823
+ '',
1824
+ `Components snapped (${changed.length}):`,
1825
+ listed || '_none — nothing on the lane changed this run_',
1826
+ '',
1827
+ `- branch: \`${branch}\`${branchTipSha ? ` @ \`${branchTipSha.slice(0, 9)}\`` : ''}`,
1828
+ `- lane: [\`${laneIdStr}\`](${laneUrl}) @ \`${laneHead.slice(0, 9)}\``,
1829
+ ].join('\n');
1830
+ }
1831
+
1649
1832
  /**
1650
1833
  * Single-quote a value interpolated into a copy-pasteable shell command. Lane names may contain `$` and
1651
1834
  * `!`, and a configured branch name may contain anything git accepts as a ref — including a quote.
@@ -115,6 +115,47 @@ const table: Array<{
115
115
  { name: 'lane moved, branch untouched -> import-lane', input: { laneHead: 'L2' }, type: 'import-lane' },
116
116
  { name: 'dev commits, lane unchanged -> export-branch', input: { hasDevCommits: true }, type: 'export-branch' },
117
117
  { name: 'both moved -> merge-diverged', input: { laneHead: 'L2', hasDevCommits: true }, type: 'merge-diverged' },
118
+ {
119
+ // Suspected work only — git cannot tell whether the bundled sources are inside the recorded snap,
120
+ // so the executor probes with a read-only status check and settles without writing when clean.
121
+ name: 'sources bundled into the state commit, otherwise converged -> export-branch (a probe)',
122
+ input: { stateCommitBundlesSources: true, tipIsSyncCommit: false },
123
+ type: 'export-branch',
124
+ action: { type: 'export-branch' },
125
+ },
126
+ {
127
+ name: 'bundled sources count as work when the lane also moved -> merge-diverged',
128
+ input: { laneHead: 'L2', stateCommitBundlesSources: true, tipIsSyncCommit: false },
129
+ type: 'merge-diverged',
130
+ },
131
+ {
132
+ name: 'own-live with bundled sources -> KEEP the branch, naming the commits',
133
+ input: { ...laneGone('own-live'), stateCommitBundlesSources: true, tipIsSyncCommit: false },
134
+ type: 'close-pr',
135
+ action: { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' },
136
+ },
137
+ {
138
+ // Our own ledger commits bundle sources too (import-lane writes the lane's files, merge-diverged
139
+ // the merged tree) — already-exported content, not work. No probe can run against a deleted lane,
140
+ // so the deletion path keeps consulting the tip marker, in the same direction it always has.
141
+ name: 'own-live, bundled sources but the tip is OUR OWN ledger commit -> close-pr AND delete',
142
+ input: { ...laneGone('own-live'), stateCommitBundlesSources: true, tipIsSyncCommit: true },
143
+ type: 'close-pr',
144
+ action: { type: 'close-pr', deleteBranch: true },
145
+ },
146
+ {
147
+ // The convergence path is the opposite: a probe IS available there, and a tip that merely CLAIMS
148
+ // to be ours (a squash body quoting the marker) must never suppress it — probe, don't trust.
149
+ name: 'bundled sources with a tip claiming to be ours still probe -> export-branch',
150
+ input: { stateCommitBundlesSources: true, tipIsSyncCommit: true },
151
+ type: 'export-branch',
152
+ },
153
+ {
154
+ // Adoption probes via `bit status`, so suspected bundled work routes there like dev commits do.
155
+ name: 'existing branch, never synced, bundled sources -> adopt-branch (first contact)',
156
+ input: { lastSyncedHead: undefined, ownership: 'inherited-or-none', stateCommitBundlesSources: true },
157
+ type: 'adopt-branch',
158
+ },
118
159
  {
119
160
  name: 'existing branch, never synced, no dev commits, lane EXISTS -> import-lane (adopt branch)',
120
161
  input: { lastSyncedHead: undefined, ownership: 'inherited-or-none' },
@@ -23,10 +23,20 @@ export type LaneSyncInput = {
23
23
  lastSyncedHead?: string;
24
24
  /** whether the branch carries commits on top of the commit that last wrote its `.bitmap` */
25
25
  hasDevCommits: boolean;
26
+ /**
27
+ * Whether the state commit also touched non-`.bitmap` files — SUSPECTED work git cannot confirm (the
28
+ * files may already be inside the recorded snap). Content-only, so this reconciler's own
29
+ * source-bundling ledger commits read `true` too. Plans the probing `export-branch` when everything
30
+ * else reads converged; treated like dev commits everywhere a wrong "no work" answer could lose
31
+ * something (deletion, divergence, first contact).
32
+ */
33
+ stateCommitBundlesSources?: boolean;
26
34
  /**
27
35
  * Whether the reconciler itself wrote the tip commit. The one exception to "markers are annotations":
28
- * consulted only on the branch-deletion path, and only ever to WITHHOLD a deletion. Must be computed
29
- * with `isSyncAuthoredMessage`, not the loop guard's substring match.
36
+ * consulted only on the branch-deletion path — to withhold a deletion, and to discount bundled
37
+ * sources our own ledger commit carries (no probe can run against a deleted lane). It must never
38
+ * decide convergence: a squash body quoting the marker forges it. Must be computed with
39
+ * `isSyncAuthoredMessage`, not the loop guard's substring match.
30
40
  */
31
41
  tipIsSyncCommit: boolean;
32
42
  /**
@@ -54,6 +64,9 @@ export type BranchKeepReason =
54
64
  export type LaneSyncAction =
55
65
  | { type: 'noop'; reason: string }
56
66
  | { type: 'import-lane' }
67
+ // May be a probe: the executor asks `bit status` (a read) whether the branch's tree holds anything
68
+ // the lane is missing, and a clean answer settles as converged without writing or pushing a thing —
69
+ // which is why planning it on mere suspicion, run after run, is harmless.
57
70
  | { type: 'export-branch' }
58
71
  | { type: 'merge-diverged' }
59
72
  | { type: 'adopt-branch' }
@@ -64,7 +77,14 @@ export type LaneSyncAction =
64
77
 
65
78
  export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
66
79
  const { laneHead, branchExists, lastSyncedHead, hasDevCommits, conflictLabelPresent, ownership } = input;
67
- const { tipIsSyncCommit, hasIndependentHistory, branchNamesDifferentLane } = input;
80
+ const { tipIsSyncCommit, hasIndependentHistory, branchNamesDifferentLane, stateCommitBundlesSources } = input;
81
+ // Suspected-or-real work: everywhere a wrong "no work" answer could lose something, bundled sources
82
+ // count like dev commits — the executor's read-only probe is what tells them apart.
83
+ const mayCarryWork = hasDevCommits || Boolean(stateCommitBundlesSources);
84
+ // The deletion path cannot probe (the lane is gone), so there — and only there — the tip marker
85
+ // discounts the sources our own ledger commits bundle (import-lane writes the lane's files,
86
+ // merge-diverged the merged tree). Same message dependence, same direction, as it always had.
87
+ const mayCarryWorkForDeletion = hasDevCommits || (Boolean(stateCommitBundlesSources) && !tipIsSyncCommit);
68
88
  if (conflictLabelPresent) {
69
89
  return { type: 'noop', reason: 'PR is labeled bit-sync-conflict; resolve and remove the label to resume' };
70
90
  }
@@ -72,11 +92,11 @@ export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
72
92
  if (!branchExists) return { type: 'noop', reason: 'lane and branch both absent' };
73
93
  switch (ownership) {
74
94
  case 'own-live':
75
- // Each check is a separate reason to withhold a delete: dev commits are work that exists
76
- // nowhere else; a non-sync tip may be a developer's own `.bitmap` write; an adopted branch's
95
+ // Each check is a separate reason to withhold a delete: real or suspected work exists nowhere
96
+ // else; a non-sync tip may be a developer's own `.bitmap` write; an adopted branch's
77
97
  // pre-existing history was never proven disposable. A false keep is harmless, a false delete
78
98
  // destroys the only copy.
79
- if (hasDevCommits) {
99
+ if (mayCarryWorkForDeletion) {
80
100
  return { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' };
81
101
  }
82
102
  if (!tipIsSyncCommit) {
@@ -99,21 +119,22 @@ export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
99
119
  }
100
120
  if (!branchExists) return { type: 'import-lane' };
101
121
  if (!lastSyncedHead) {
102
- if (hasDevCommits) {
122
+ if (mayCarryWork) {
103
123
  if (branchNamesDifferentLane) {
104
124
  return {
105
125
  type: 'halt',
106
126
  reason: 'branch has commits but its .bitmap names a different lane; cannot tell which side is newer',
107
127
  };
108
128
  }
109
- // The lane exists but the pair has no sync history: first contact, not a conflict.
129
+ // The lane exists but the pair has no sync history: first contact, not a conflict — and
130
+ // adoption already probes via `bit status`, so bundled sources are safe to route here too.
110
131
  return { type: 'adopt-branch' };
111
132
  }
112
133
  return { type: 'import-lane' };
113
134
  }
114
135
  const laneMoved = laneHead !== lastSyncedHead;
115
- if (laneMoved && hasDevCommits) return { type: 'merge-diverged' };
136
+ if (laneMoved && mayCarryWork) return { type: 'merge-diverged' };
116
137
  if (laneMoved) return { type: 'import-lane' };
117
- if (hasDevCommits) return { type: 'export-branch' };
138
+ if (mayCarryWork) return { type: 'export-branch' };
118
139
  return { type: 'noop', reason: 'converged' };
119
140
  }
@@ -11,6 +11,8 @@ import {
11
11
  isSyncAuthoredMessage,
12
12
  parseBranchBitmap,
13
13
  parseDevCommitCount,
14
+ statusReportsUnsyncedWork,
15
+ touchesBeyondBitmap,
14
16
  } from './sync-state';
15
17
 
16
18
  const DEFAULT_SCOPE = 'acme.shop';
@@ -200,6 +202,39 @@ const DEV_COMMIT_COUNTS: Array<[string, string, boolean]> = [
200
202
  ['git writing an error where the count was expected', 'fatal: bad revision', true],
201
203
  ];
202
204
 
205
+ describe('statusReportsUnsyncedWork', () => {
206
+ const empty = {
207
+ newComponents: [],
208
+ modifiedComponents: [],
209
+ stagedComponents: [],
210
+ locallySoftRemoved: [],
211
+ pendingUpdateDependents: [],
212
+ mergePendingComponents: [],
213
+ componentsDuringMergeState: [],
214
+ invalidComponents: [],
215
+ importPendingComponents: [],
216
+ };
217
+
218
+ it('an all-empty status is converged', () => {
219
+ expect(statusReportsUnsyncedWork(empty)).to.equal(false);
220
+ });
221
+
222
+ it('a modified component is work', () => {
223
+ expect(statusReportsUnsyncedWork({ ...empty, modifiedComponents: ['comp1'] })).to.equal(true);
224
+ });
225
+
226
+ // An unloadable component is UNKNOWN, not converged: its sources may hold the branch's work, and
227
+ // "not knowing" must route to the snap (which fails loudly), never to a silent converged answer.
228
+ it('an invalid (unloadable) component is work, not convergence', () => {
229
+ expect(statusReportsUnsyncedWork({ ...empty, invalidComponents: [{ id: 'comp1' }] })).to.equal(true);
230
+ });
231
+
232
+ // StatusMain SPLITS pending-import errors out of invalidComponents — same unknown, different array.
233
+ it('a pending-import component is the same unknown, not convergence', () => {
234
+ expect(statusReportsUnsyncedWork({ ...empty, importPendingComponents: ['comp1'] })).to.equal(true);
235
+ });
236
+ });
237
+
203
238
  describe('parseDevCommitCount', () => {
204
239
  DEV_COMMIT_COUNTS.forEach(([name, raw, hasDevCommits]) => {
205
240
  it(`${hasDevCommits ? 'keeps the branch' : 'permits retirement'} for ${name}`, () => {
@@ -208,6 +243,19 @@ describe('parseDevCommitCount', () => {
208
243
  });
209
244
  });
210
245
 
246
+ describe('touchesBeyondBitmap', () => {
247
+ it('is false for an empty diff and for a .bitmap-only commit', () => {
248
+ expect(touchesBeyondBitmap('')).to.equal(false);
249
+ expect(touchesBeyondBitmap('\n')).to.equal(false);
250
+ expect(touchesBeyondBitmap('.bitmap\n')).to.equal(false);
251
+ });
252
+
253
+ it('is true when any source file rides in the same commit as the .bitmap write', () => {
254
+ expect(touchesBeyondBitmap('.bitmap\ncomp1/index.js\n')).to.equal(true);
255
+ expect(touchesBeyondBitmap('comp1/index.js\n')).to.equal(true);
256
+ });
257
+ });
258
+
211
259
  describe('oldestCommitIsNonSync', () => {
212
260
  it('is false for an empty range — no commits at all', () => {
213
261
  expect(oldestCommitIsNonSync('')).to.equal(false);