@teambit/ci 1.0.507 → 1.0.512
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ci.main.runtime.d.ts +3 -2
- package/dist/ci.main.runtime.js +5 -6
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/{preview-1787147173446.js → preview-1787168213927.js} +1 -1
- package/dist/sync/git-host-provider.d.ts +9 -0
- package/dist/sync/git-host-provider.js.map +1 -1
- package/dist/sync/github-client.d.ts +28 -0
- package/dist/sync/github-client.js +81 -3
- package/dist/sync/github-client.js.map +1 -1
- package/dist/sync/github-client.spec.js +249 -0
- package/dist/sync/github-client.spec.js.map +1 -1
- package/dist/sync/lane-sync-executor.d.ts +47 -3
- package/dist/sync/lane-sync-executor.js +191 -39
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +336 -0
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/sync-planner.d.ts +12 -2
- package/dist/sync/sync-planner.js +17 -8
- package/dist/sync/sync-planner.js.map +1 -1
- package/dist/sync/sync-planner.spec.js +64 -0
- package/dist/sync/sync-planner.spec.js.map +1 -1
- package/dist/sync/sync-state.d.ts +34 -3
- package/dist/sync/sync-state.js +61 -3
- package/dist/sync/sync-state.js.map +1 -1
- package/dist/sync/sync-state.spec.js +54 -0
- package/dist/sync/sync-state.spec.js.map +1 -1
- package/package.json +15 -15
- package/sync/git-host-provider.ts +8 -0
- package/sync/github-client.spec.ts +160 -0
- package/sync/github-client.ts +90 -3
- package/sync/lane-sync-executor.spec.ts +291 -0
- package/sync/lane-sync-executor.ts +233 -50
- package/sync/sync-planner.spec.ts +41 -0
- package/sync/sync-planner.ts +31 -10
- package/sync/sync-state.spec.ts +48 -0
- 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
|
-
: {
|
|
366
|
-
|
|
367
|
-
|
|
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}
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
682
|
-
*
|
|
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,
|
|
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<
|
|
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
|
-
|
|
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 || !
|
|
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' },
|
package/sync/sync-planner.ts
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
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:
|
|
76
|
-
//
|
|
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 (
|
|
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 (
|
|
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 &&
|
|
136
|
+
if (laneMoved && mayCarryWork) return { type: 'merge-diverged' };
|
|
116
137
|
if (laneMoved) return { type: 'import-lane' };
|
|
117
|
-
if (
|
|
138
|
+
if (mayCarryWork) return { type: 'export-branch' };
|
|
118
139
|
return { type: 'noop', reason: 'converged' };
|
|
119
140
|
}
|
package/sync/sync-state.spec.ts
CHANGED
|
@@ -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);
|