@teambit/ci 1.0.494 → 1.0.497
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ci.docs.mdx +39 -9
- package/dist/ci.docs.mdx +39 -9
- package/dist/ci.main.runtime.d.ts +6 -0
- package/dist/ci.main.runtime.js +24 -3
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/{preview-1786400681343.js → preview-1786718956243.js} +1 -1
- package/dist/sync/git-ops.d.ts +41 -0
- package/dist/sync/git-ops.js +71 -0
- package/dist/sync/git-ops.js.map +1 -1
- package/dist/sync/git-ops.spec.js +39 -0
- package/dist/sync/git-ops.spec.js.map +1 -1
- package/dist/sync/lane-sync-executor.d.ts +81 -15
- package/dist/sync/lane-sync-executor.js +315 -50
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +235 -13
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/main-sync-executor.js +30 -6
- package/dist/sync/main-sync-executor.js.map +1 -1
- package/dist/sync/sync-planner.d.ts +10 -1
- package/dist/sync/sync-planner.js +37 -13
- package/dist/sync/sync-planner.js.map +1 -1
- package/dist/sync/sync-planner.spec.js +44 -2
- package/dist/sync/sync-planner.spec.js.map +1 -1
- package/dist/sync/sync-state.d.ts +33 -1
- package/dist/sync/sync-state.js +60 -3
- package/dist/sync/sync-state.js.map +1 -1
- package/dist/sync/sync-state.spec.js +28 -0
- package/dist/sync/sync-state.spec.js.map +1 -1
- package/package.json +20 -20
- package/sync/git-ops.spec.ts +60 -0
- package/sync/git-ops.ts +72 -0
- package/sync/lane-sync-executor.spec.ts +215 -9
- package/sync/lane-sync-executor.ts +354 -47
- package/sync/main-sync-executor.ts +37 -5
- package/sync/sync-planner.spec.ts +36 -1
- package/sync/sync-planner.ts +36 -16
- package/sync/sync-state.spec.ts +33 -0
- package/sync/sync-state.ts +62 -1
|
@@ -12,11 +12,14 @@ import { laneNameToBranch } from './sync-config';
|
|
|
12
12
|
import type { BranchSyncState } from './sync-state';
|
|
13
13
|
import {
|
|
14
14
|
CONFLICT_LABEL,
|
|
15
|
+
NO_CHANGES_TO_SNAP,
|
|
15
16
|
SYNC_COMMIT_MARKER,
|
|
17
|
+
branchBitmapUnchangedSinceFork,
|
|
16
18
|
branchStateFingerprint,
|
|
17
19
|
buildSyncCommitMessage,
|
|
18
20
|
fingerprintIdVersions,
|
|
19
21
|
readBranchSyncState,
|
|
22
|
+
oldestCommitIsNonSync,
|
|
20
23
|
hasSyncMarker,
|
|
21
24
|
isSyncAuthoredMessage,
|
|
22
25
|
} from './sync-state';
|
|
@@ -30,10 +33,13 @@ import {
|
|
|
30
33
|
checkoutPristine,
|
|
31
34
|
checkoutPristineRestore,
|
|
32
35
|
commitWithIdentity,
|
|
36
|
+
confirmPushRace,
|
|
33
37
|
deleteBranchArgs,
|
|
38
|
+
dropLocalBranchToRemoteTip,
|
|
34
39
|
fetchRemoteHeads,
|
|
35
40
|
isAncestor,
|
|
36
41
|
isStaleLeaseRejection,
|
|
42
|
+
redactUrlCredentials,
|
|
37
43
|
refetchBranchTip,
|
|
38
44
|
} from './git-ops';
|
|
39
45
|
|
|
@@ -51,6 +57,50 @@ export const HALT_SUMMARY_PREFIX = 'HALTED';
|
|
|
51
57
|
*/
|
|
52
58
|
export const REFUSED_SUMMARY_PREFIX = 'REFUSED';
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A raced verdict: the run lost a push race to a concurrent run and exits green — never a halt, never
|
|
62
|
+
* the `bit-sync-conflict` label; a raced push is not a content conflict. Nothing retries in-run, so
|
|
63
|
+
* convergence waits on the NEXT trigger (webhook or scheduled run): a deployment without the cron can
|
|
64
|
+
* mirror stale content until the next event. One builder so the `-> raced (` stem a consumer routes on
|
|
65
|
+
* lives at one site, like `HALT_SUMMARY_PREFIX`.
|
|
66
|
+
*/
|
|
67
|
+
function racedSummary(laneName: string, detail: string): string {
|
|
68
|
+
return `${laneName} -> raced (${detail})`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `executeImportLane`'s own push (materializing the lane onto the branch) lost the race. Nothing on
|
|
73
|
+
* the LANE changed here — only the branch-side commit failed to land — so the next run's plan is
|
|
74
|
+
* unaffected: it simply retries the same import against the winner's tip.
|
|
75
|
+
*/
|
|
76
|
+
function racedBranchPushSummary(laneName: string): string {
|
|
77
|
+
return racedSummary(laneName, 'branch push lost the race to a concurrent run; next run re-plans');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `recordLaneHeadOnBranch`'s ledger-commit push lost the race, AFTER the snap/export that precedes it
|
|
82
|
+
* already succeeded — the lane genuinely moved. Only the branch's own acknowledgment of that (the
|
|
83
|
+
* `.bitmap` pointer commit) failed to land, so the next run reads the branch as behind the lane it
|
|
84
|
+
* itself just updated and plans `import-lane` to catch it up. `policyClause` carries a destructive
|
|
85
|
+
* auto-resolution (onConflict git-wins/lane-wins) that already exported — it must not vanish from the
|
|
86
|
+
* summary just because the ledger push raced.
|
|
87
|
+
*/
|
|
88
|
+
export function racedLedgerPushSummary(laneName: string, policyClause = ''): string {
|
|
89
|
+
return racedSummary(
|
|
90
|
+
laneName,
|
|
91
|
+
`${policyClause}lane updated; branch ledger commit lost the push race — next run re-plans`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `executeAdoptBranch`'s ledger-commit push lost the race. Unlike the export paths, adoption never
|
|
97
|
+
* snaps or exports — the LANE is untouched; only the branch's pointer commit failed to land. The next
|
|
98
|
+
* run re-plans first contact against the winner's tip.
|
|
99
|
+
*/
|
|
100
|
+
export function racedAdoptionPushSummary(laneName: string): string {
|
|
101
|
+
return racedSummary(laneName, 'lane untouched; adoption ledger commit lost the push race — next run re-plans');
|
|
102
|
+
}
|
|
103
|
+
|
|
54
104
|
export type LaneSyncDeps = {
|
|
55
105
|
lanes: LanesMain;
|
|
56
106
|
/** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
|
|
@@ -207,6 +257,7 @@ export const KEPT_BECAUSE: Record<BranchKeepReason, string> = {
|
|
|
207
257
|
'unmerged-commits': `it carries commits that are not in the default branch`,
|
|
208
258
|
'tip-not-a-sync-commit': `its tip is not one of this reconciler's own commits, so the work in it may exist nowhere else`,
|
|
209
259
|
'tip-advanced-during-run': `its tip advanced after the ownership evidence was read, so it may carry work this run never saw`,
|
|
260
|
+
'adopted-branch': `it was adopted from a pre-existing branch, and only merging (not deleting) proves its history is safe to retire`,
|
|
210
261
|
};
|
|
211
262
|
|
|
212
263
|
export class LaneSyncExecutor {
|
|
@@ -385,6 +436,20 @@ export class LaneSyncExecutor {
|
|
|
385
436
|
// `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
|
|
386
437
|
// merely quotes the marker must not read as one we wrote.
|
|
387
438
|
const tipIsSyncCommit = isSyncAuthoredMessage(branchState.tipMessage);
|
|
439
|
+
const hasIndependentHistory = await this.computeHasIndependentHistory(
|
|
440
|
+
ownership,
|
|
441
|
+
branchState.stateCommit,
|
|
442
|
+
defaultBranch
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const branchNamesDifferentLane = await this.computeBranchNamesDifferentLane(
|
|
446
|
+
lastSyncedHead,
|
|
447
|
+
branchState.hasDevCommits,
|
|
448
|
+
mirroredLane,
|
|
449
|
+
laneIdStr,
|
|
450
|
+
branch,
|
|
451
|
+
defaultBranch
|
|
452
|
+
);
|
|
388
453
|
|
|
389
454
|
const action = planLaneSync({
|
|
390
455
|
laneHead,
|
|
@@ -392,7 +457,9 @@ export class LaneSyncExecutor {
|
|
|
392
457
|
lastSyncedHead,
|
|
393
458
|
hasDevCommits: branchState.hasDevCommits,
|
|
394
459
|
tipIsSyncCommit,
|
|
460
|
+
hasIndependentHistory,
|
|
395
461
|
conflictLabelPresent,
|
|
462
|
+
branchNamesDifferentLane,
|
|
396
463
|
ownership,
|
|
397
464
|
});
|
|
398
465
|
|
|
@@ -459,6 +526,8 @@ export class LaneSyncExecutor {
|
|
|
459
526
|
return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
|
|
460
527
|
case 'merge-diverged':
|
|
461
528
|
return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
|
|
529
|
+
case 'adopt-branch':
|
|
530
|
+
return this.executeAdoptBranch({ target, laneIdStr, branch, defaultBranch, pr });
|
|
462
531
|
case 'close-pr':
|
|
463
532
|
return this.executeClosePr({
|
|
464
533
|
laneName,
|
|
@@ -470,8 +539,12 @@ export class LaneSyncExecutor {
|
|
|
470
539
|
keepReason: action.deleteBranch ? undefined : action.keepReason,
|
|
471
540
|
expectedTipSha: branchState.tipSha,
|
|
472
541
|
});
|
|
473
|
-
case 'halt':
|
|
474
|
-
|
|
542
|
+
case 'halt': {
|
|
543
|
+
// The planner only has the boolean; the different-lane id and its PR advice live here.
|
|
544
|
+
const commentNote =
|
|
545
|
+
branchNamesDifferentLane && mirroredLane ? branchMirrorsOtherLaneNote(mirroredLane, laneIdStr) : undefined;
|
|
546
|
+
return this.executeHalt({ laneName, laneIdStr, branch, reason: action.reason, pr, commentNote });
|
|
547
|
+
}
|
|
475
548
|
default: {
|
|
476
549
|
// exhaustiveness guard
|
|
477
550
|
const unhandled: never = action;
|
|
@@ -583,7 +656,13 @@ export class LaneSyncExecutor {
|
|
|
583
656
|
});
|
|
584
657
|
}
|
|
585
658
|
|
|
586
|
-
await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
659
|
+
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
660
|
+
if (pushResult.raced) {
|
|
661
|
+
// Still ensure the PR: this early return skips the file's only PR-creation site, and the
|
|
662
|
+
// converged state every later run reads plans a bare noop, which never repairs a missing PR.
|
|
663
|
+
if (!pr) await this.openPrForLane({ target, laneIdStr, branch, defaultBranch, laneHead, remoteLane });
|
|
664
|
+
return racedBranchPushSummary(laneName);
|
|
665
|
+
}
|
|
587
666
|
|
|
588
667
|
let prUrl = pr?.htmlUrl;
|
|
589
668
|
if (!pr) {
|
|
@@ -621,20 +700,21 @@ export class LaneSyncExecutor {
|
|
|
621
700
|
await this.checkoutFromRemote(branch, `origin/${branch}`);
|
|
622
701
|
|
|
623
702
|
try {
|
|
624
|
-
const
|
|
625
|
-
if (
|
|
703
|
+
const exported = await this.snapAndExportOntoLane(laneIdStr, message);
|
|
704
|
+
if (exported.status === 'error') {
|
|
626
705
|
// Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
|
|
627
706
|
return await this.executeHalt({
|
|
628
707
|
laneName,
|
|
629
708
|
laneIdStr,
|
|
630
709
|
branch,
|
|
631
|
-
reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${
|
|
710
|
+
reason: `failed to snap and export branch ${branch} onto lane ${laneIdStr}: ${exported.error.message}`,
|
|
632
711
|
pr: await this.findPr(branch),
|
|
633
712
|
});
|
|
634
713
|
}
|
|
635
714
|
|
|
636
|
-
const
|
|
637
|
-
if (
|
|
715
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
716
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName);
|
|
717
|
+
if (recorded.status === 'unreadable') {
|
|
638
718
|
return await this.executeHalt({
|
|
639
719
|
laneName,
|
|
640
720
|
laneIdStr,
|
|
@@ -643,7 +723,89 @@ export class LaneSyncExecutor {
|
|
|
643
723
|
pr: await this.findPr(branch),
|
|
644
724
|
});
|
|
645
725
|
}
|
|
646
|
-
return
|
|
726
|
+
return (
|
|
727
|
+
`${laneName} -> export-branch (lane ${laneIdStr} @ ${recorded.laneHead.slice(0, 9)}, ` +
|
|
728
|
+
`branch ${branch} updated)`
|
|
729
|
+
);
|
|
730
|
+
} finally {
|
|
731
|
+
await this.restoreWorkspace(defaultBranch);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* First contact: the lane exists, the branch maps to it by name, but the branch's committed
|
|
737
|
+
* `.bitmap` has no state for it (typically `bit ci pr --keep-lane` created the lane from this very
|
|
738
|
+
* branch without recording the pointer in git). Adoption only ever RECORDS a pointer, never resolves
|
|
739
|
+
* content: switch onto the lane keeping the branch's files, then ask `bit status` whether anything
|
|
740
|
+
* differs. Nothing → ledger commit; something → halt, lane untouched. Deliberately a status READ,
|
|
741
|
+
* never a snap: `snapping.snap()` writes an unexported Version object even under `dryRun`, and that
|
|
742
|
+
* residue can trip a later lane in the same `--all` run.
|
|
743
|
+
*/
|
|
744
|
+
private async executeAdoptBranch({
|
|
745
|
+
target,
|
|
746
|
+
laneIdStr,
|
|
747
|
+
branch,
|
|
748
|
+
defaultBranch,
|
|
749
|
+
pr,
|
|
750
|
+
}: {
|
|
751
|
+
target: LaneTarget;
|
|
752
|
+
laneIdStr: string;
|
|
753
|
+
branch: string;
|
|
754
|
+
defaultBranch: string;
|
|
755
|
+
pr?: PrInfo;
|
|
756
|
+
}): Promise<string> {
|
|
757
|
+
const { logger } = this.deps;
|
|
758
|
+
const laneName = target.name;
|
|
759
|
+
logger.console(
|
|
760
|
+
formatWarningSummary(
|
|
761
|
+
`Adopting branch ${branch}: lane ${laneIdStr} exists, but the branch's committed .bitmap has no state for it (first contact)`
|
|
762
|
+
)
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
await this.checkoutFromRemote(branch, `origin/${branch}`);
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const switchErr = await this.materializeLane(laneIdStr, false);
|
|
769
|
+
if (switchErr) {
|
|
770
|
+
return await this.executeHalt({
|
|
771
|
+
laneName,
|
|
772
|
+
laneIdStr,
|
|
773
|
+
branch,
|
|
774
|
+
reason: `failed to check branch ${branch} against lane ${laneIdStr} for adoption: ${switchErr.message}`,
|
|
775
|
+
pr: pr ?? (await this.findPr(branch)),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const wouldChangeLane = await this.deps.ci.hasUnsyncedWorkChanges();
|
|
780
|
+
if (wouldChangeLane) {
|
|
781
|
+
// Nothing was snapped or exported — the lane is untouched.
|
|
782
|
+
return await this.executeHalt({
|
|
783
|
+
laneName,
|
|
784
|
+
laneIdStr,
|
|
785
|
+
branch,
|
|
786
|
+
reason:
|
|
787
|
+
'branch has commits but its .bitmap records no state for this lane; cannot tell which side is ' +
|
|
788
|
+
`newer (adopting would change lane ${laneIdStr}'s content, not just record a pointer)`,
|
|
789
|
+
pr: pr ?? (await this.findPr(branch)),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Branch content already matches the lane — the ledger commit is all that is missing.
|
|
794
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch, { adopted: true });
|
|
795
|
+
if (recorded.status === 'raced') return racedAdoptionPushSummary(laneName);
|
|
796
|
+
if (recorded.status === 'unreadable') {
|
|
797
|
+
return await this.executeHalt({
|
|
798
|
+
laneName,
|
|
799
|
+
laneIdStr,
|
|
800
|
+
branch,
|
|
801
|
+
reason: `lane ${laneIdStr} could not be read back from the remote after adopting branch ${branch}`,
|
|
802
|
+
pr: pr ?? (await this.findPr(branch)),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return (
|
|
806
|
+
`${laneName} -> adopt-branch (branch ${branch} had no lane state; adopted onto lane ${laneIdStr} @ ` +
|
|
807
|
+
`${recorded.laneHead.slice(0, 9)})`
|
|
808
|
+
);
|
|
647
809
|
} finally {
|
|
648
810
|
await this.restoreWorkspace(defaultBranch);
|
|
649
811
|
}
|
|
@@ -739,25 +901,26 @@ export class LaneSyncExecutor {
|
|
|
739
901
|
}
|
|
740
902
|
|
|
741
903
|
// ---- step 2: snap + export the merged tree onto the lane ---------------------------------
|
|
742
|
-
const
|
|
904
|
+
const exported = await this.snapAndExportOntoLane(
|
|
743
905
|
laneIdStr,
|
|
744
906
|
`merge remote lane ${laneIdStr} into ${branch} ${SYNC_COMMIT_MARKER}`
|
|
745
907
|
);
|
|
746
|
-
if (
|
|
908
|
+
if (exported.status === 'error') {
|
|
747
909
|
return await halt(
|
|
748
910
|
`merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` +
|
|
749
|
-
`result failed: ${
|
|
911
|
+
`result failed: ${exported.error.message}`
|
|
750
912
|
);
|
|
751
913
|
}
|
|
752
914
|
|
|
753
915
|
// ---- step 3: record the new lane head on the branch --------------------------------------
|
|
754
|
-
const
|
|
755
|
-
if (
|
|
916
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
917
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName, policyClause);
|
|
918
|
+
if (recorded.status === 'unreadable') {
|
|
756
919
|
return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
|
|
757
920
|
}
|
|
758
921
|
return (
|
|
759
922
|
`${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` +
|
|
760
|
-
`${laneHead.slice(0, 9)}, branch ${branch} updated)`
|
|
923
|
+
`${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)`
|
|
761
924
|
);
|
|
762
925
|
} catch (e: any) {
|
|
763
926
|
// Something unforeseen — halt anyway: no single lane may abort the rest of the run.
|
|
@@ -770,18 +933,20 @@ export class LaneSyncExecutor {
|
|
|
770
933
|
}
|
|
771
934
|
|
|
772
935
|
/**
|
|
773
|
-
* Snap the workspace's current tree onto the lane and export it
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* fetch, so it never warms a cold scope.
|
|
936
|
+
* Snap the workspace's current tree onto the lane and export it. Snaps WHATEVER is in the workspace
|
|
937
|
+
* (the switch uses `forceOurs` and never merges files), so a diverged tree must already hold the
|
|
938
|
+
* merged content. `keepLane` preserves the lane's history, `skipCleanup` keeps the snap's `.bitmap`
|
|
939
|
+
* for the caller to commit, `noDestructiveRecovery` turns stale-lane recovery (delete + re-fork the
|
|
940
|
+
* remote lane) into a throw. The lane object must be imported BEFORE delegating: a switch onto the
|
|
941
|
+
* lane the workspace is already on no-ops before any fetch, so it never warms a cold scope.
|
|
780
942
|
*/
|
|
781
|
-
private async snapAndExportOntoLane(
|
|
943
|
+
private async snapAndExportOntoLane(
|
|
944
|
+
laneIdStr: string,
|
|
945
|
+
message: string
|
|
946
|
+
): Promise<{ status: 'noop' | 'exported' } | { status: 'error'; error: Error }> {
|
|
782
947
|
try {
|
|
783
948
|
await ensureCurrentLaneObject(this.deps.lanes);
|
|
784
|
-
await this.deps.ci.snapPrCommit({
|
|
949
|
+
const result = await this.deps.ci.snapPrCommit({
|
|
785
950
|
laneIdStr,
|
|
786
951
|
message,
|
|
787
952
|
build: undefined,
|
|
@@ -790,27 +955,32 @@ export class LaneSyncExecutor {
|
|
|
790
955
|
skipCleanup: true,
|
|
791
956
|
noDestructiveRecovery: true,
|
|
792
957
|
});
|
|
793
|
-
return
|
|
958
|
+
if (result === NO_CHANGES_TO_SNAP) return { status: 'noop' };
|
|
959
|
+
return { status: 'exported' };
|
|
794
960
|
} catch (e: any) {
|
|
795
|
-
return e instanceof Error ? e : new Error(String(e?.message ?? e));
|
|
961
|
+
return { status: 'error', error: e instanceof Error ? e : new Error(String(e?.message ?? e)) };
|
|
796
962
|
}
|
|
797
963
|
}
|
|
798
964
|
|
|
799
965
|
/**
|
|
800
966
|
* Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
|
|
801
967
|
* so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
|
|
802
|
-
*
|
|
968
|
+
* `unreadable` means the lane could no longer be read back, in which case the caller halts;
|
|
969
|
+
* `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`.
|
|
803
971
|
*/
|
|
804
972
|
private async recordLaneHeadOnBranch(
|
|
805
973
|
target: LaneTarget,
|
|
806
974
|
laneIdStr: string,
|
|
807
|
-
branch: string
|
|
808
|
-
|
|
975
|
+
branch: string,
|
|
976
|
+
opts: { adopted?: boolean } = {}
|
|
977
|
+
): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
|
|
809
978
|
const remoteLane = await this.getRemoteLane(target);
|
|
810
|
-
if (!remoteLane) return
|
|
979
|
+
if (!remoteLane) return { status: 'unreadable' };
|
|
811
980
|
const laneHead = laneHeadFingerprint(remoteLane.components);
|
|
812
|
-
await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
813
|
-
return
|
|
981
|
+
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
|
|
982
|
+
if (pushResult.raced) return { status: 'raced' };
|
|
983
|
+
return { status: 'ok', laneHead };
|
|
814
984
|
}
|
|
815
985
|
|
|
816
986
|
/**
|
|
@@ -925,6 +1095,80 @@ export class LaneSyncExecutor {
|
|
|
925
1095
|
}
|
|
926
1096
|
}
|
|
927
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* Gates `hasIndependentHistoryBelowStateCommit`'s git call to the one case it can change the outcome
|
|
1100
|
+
* for: an `own-live` claim (the only ownership that would otherwise license an immediate delete) with
|
|
1101
|
+
* a reachable state commit to inspect. Extracted out of `reconcileLane` to keep the branching there.
|
|
1102
|
+
*/
|
|
1103
|
+
private async computeHasIndependentHistory(
|
|
1104
|
+
ownership: LaneOwnershipEvidence,
|
|
1105
|
+
stateCommit: string | undefined,
|
|
1106
|
+
defaultBranch: string
|
|
1107
|
+
): Promise<boolean> {
|
|
1108
|
+
if (ownership !== 'own-live' || !stateCommit) return false;
|
|
1109
|
+
return this.hasIndependentHistoryBelowStateCommit(stateCommit, defaultBranch);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Whether the OLDEST commit unique to the branch is NOT bit-authored — durable, ancestry-based proof
|
|
1114
|
+
* a human created the branch before this reconciler touched it (a trailer would not survive later
|
|
1115
|
+
* ledger commits; see `oldestCommitIsNonSync` for the oldest-only rationale). Unreadable answers
|
|
1116
|
+
* `true`: a deletion guard errs toward keeping.
|
|
1117
|
+
*/
|
|
1118
|
+
private async hasIndependentHistoryBelowStateCommit(stateCommit: string, defaultBranch: string): Promise<boolean> {
|
|
1119
|
+
try {
|
|
1120
|
+
const log = await git.raw([
|
|
1121
|
+
'log',
|
|
1122
|
+
`origin/${defaultBranch}..${stateCommit}`,
|
|
1123
|
+
'--first-parent',
|
|
1124
|
+
'--reverse',
|
|
1125
|
+
'--format=%B%x1e',
|
|
1126
|
+
]);
|
|
1127
|
+
return oldestCommitIsNonSync(log);
|
|
1128
|
+
} catch (e: any) {
|
|
1129
|
+
this.deps.logger.consoleWarning(
|
|
1130
|
+
`Could not read ${stateCommit}'s history against ${defaultBranch} to check for independent, ` +
|
|
1131
|
+
`pre-existing branch history; treating it as present (the branch is kept, not deleted): ${e?.message || e}`
|
|
1132
|
+
);
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Gates `differentLaneStillClaims` to the planner's first-contact path, the only one that reads it. */
|
|
1138
|
+
private async computeBranchNamesDifferentLane(
|
|
1139
|
+
lastSyncedHead: string | undefined,
|
|
1140
|
+
hasDevCommits: boolean,
|
|
1141
|
+
mirroredLane: string | undefined,
|
|
1142
|
+
laneIdStr: string,
|
|
1143
|
+
branch: string,
|
|
1144
|
+
defaultBranch: string
|
|
1145
|
+
): Promise<boolean> {
|
|
1146
|
+
if (lastSyncedHead || !hasDevCommits) return false;
|
|
1147
|
+
return this.differentLaneStillClaims(mirroredLane, laneIdStr, branch, defaultBranch);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Whether adoption must refuse `branch` because its `.bitmap` names a DIFFERENT lane's still-live
|
|
1152
|
+
* claim. Blob equality with the fork-point `.bitmap`, not commit reachability: an `own-superseded`
|
|
1153
|
+
* branch is reachable yet still carries live work; only a `.bitmap` the branch never changed proves
|
|
1154
|
+
* the pointer is inherited. Unreadable stays conservative and blocks adoption.
|
|
1155
|
+
*/
|
|
1156
|
+
private async differentLaneStillClaims(
|
|
1157
|
+
mirroredLane: string | undefined,
|
|
1158
|
+
laneIdStr: string,
|
|
1159
|
+
branch: string,
|
|
1160
|
+
defaultBranch: string
|
|
1161
|
+
): Promise<boolean> {
|
|
1162
|
+
if (!mirroredLane || mirroredLane === laneIdStr) return false;
|
|
1163
|
+
const inherited = await this.branchInheritsBitmapFromDefault(branch, defaultBranch);
|
|
1164
|
+
return !inherited;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Seam over `branchBitmapUnchangedSinceFork`, so `differentLaneStillClaims` is unit-testable without a real remote. */
|
|
1168
|
+
private branchInheritsBitmapFromDefault(branch: string, defaultBranch: string): Promise<boolean> {
|
|
1169
|
+
return branchBitmapUnchangedSinceFork(branch, defaultBranch);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
928
1172
|
/**
|
|
929
1173
|
* The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
|
|
930
1174
|
* Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
|
|
@@ -1126,14 +1370,17 @@ export class LaneSyncExecutor {
|
|
|
1126
1370
|
}
|
|
1127
1371
|
|
|
1128
1372
|
/**
|
|
1129
|
-
* Drive the workspace's filesystem
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1373
|
+
* Drive the workspace's filesystem onto the remote lane; returns the error so the caller can halt.
|
|
1374
|
+
* Two traps make a plain `switchToLane` insufficient: (1) its default `forceOurs` never touches the
|
|
1375
|
+
* filesystem, so the caller's commit would misrepresent whichever side it claims mirrors the other;
|
|
1376
|
+
* (2) switching onto the lane the workspace already sits on throws "already checked out", which
|
|
1377
|
+
* `switchToLane` swallows as success and nothing materializes — so step off to main first.
|
|
1378
|
+
*
|
|
1379
|
+
* `theirsWins` picks the direction: `true` (import-lane) checks the lane's files out over the disk;
|
|
1380
|
+
* `false` (adopt-branch) keeps the workspace's files — the branch's content is what the lane is
|
|
1381
|
+
* being asked to reflect.
|
|
1135
1382
|
*/
|
|
1136
|
-
private async materializeLane(laneIdStr: string): Promise<Error | undefined> {
|
|
1383
|
+
private async materializeLane(laneIdStr: string, theirsWins = true): Promise<Error | undefined> {
|
|
1137
1384
|
const { logger } = this.deps;
|
|
1138
1385
|
const target = await this.deps.lanes.parseLaneId(laneIdStr);
|
|
1139
1386
|
// Compares name AND scope, and reads from `.bitmap`, not the scope's lane object — the object read
|
|
@@ -1154,9 +1401,16 @@ export class LaneSyncExecutor {
|
|
|
1154
1401
|
}
|
|
1155
1402
|
|
|
1156
1403
|
const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, {
|
|
1157
|
-
forceOurs:
|
|
1158
|
-
forceTheirs:
|
|
1159
|
-
|
|
1404
|
+
forceOurs: !theirsWins,
|
|
1405
|
+
forceTheirs: theirsWins,
|
|
1406
|
+
// The whole lane has to land in the workspace here, including components it has that this
|
|
1407
|
+
// workspace doesn't track yet - materializing the lane is the point of this step. That is the
|
|
1408
|
+
// opposite of `bit ci pr`, where the git checkout is the source of truth and `switchToLane`'s
|
|
1409
|
+
// `workspaceOnly` default keeps the switch from writing lane-only components.
|
|
1410
|
+
workspaceOnly: false,
|
|
1411
|
+
// Follows the direction: in adopt mode the missing-component retry must not write the lane's
|
|
1412
|
+
// files over the branch's — the ledger commit would then push the lane's content onto the branch.
|
|
1413
|
+
writeAdoptedFiles: theirsWins,
|
|
1160
1414
|
});
|
|
1161
1415
|
if (switchErr) return switchErr;
|
|
1162
1416
|
|
|
@@ -1222,15 +1476,68 @@ export class LaneSyncExecutor {
|
|
|
1222
1476
|
/**
|
|
1223
1477
|
* Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
|
|
1224
1478
|
* push means someone pushed concurrently and the next run should re-plan rather than clobber.
|
|
1225
|
-
* `--allow-empty` is only insurance against `git commit` failing the lane outright.
|
|
1479
|
+
* `--allow-empty` is only insurance against `git commit` failing the lane outright. Returns whether
|
|
1480
|
+
* the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
|
|
1481
|
+
* benign noop instead of halting; any other push failure still throws.
|
|
1226
1482
|
*/
|
|
1227
|
-
private async commitAllAndPush(branch: string, message: string) {
|
|
1483
|
+
private async commitAllAndPush(branch: string, message: string): Promise<{ raced: boolean }> {
|
|
1484
|
+
// The local knowledge of `origin/<branch>` right now — no fetch, `fetchOnce` already ran for this
|
|
1485
|
+
// whole executor run — is exactly what our about-to-be-made commit is based on. After a rejection,
|
|
1486
|
+
// this is the baseline `classifyPushRejection` checks the remote against to confirm a real race.
|
|
1487
|
+
const baseSha = await this.localRemoteTip(branch);
|
|
1228
1488
|
await addAllExceptScopeAndModules();
|
|
1229
1489
|
await commitWithIdentity(message, { extraArgs: ['--allow-empty'] });
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1490
|
+
try {
|
|
1491
|
+
// `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or
|
|
1492
|
+
// reinterpreted — the configured branch name is user input (see `sync-config.ts`).
|
|
1493
|
+
await git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
1494
|
+
} catch (e: any) {
|
|
1495
|
+
if ((await this.classifyPushRejection(branch, baseSha, e)) !== 'confirmed-race') throw e;
|
|
1496
|
+
// Drop the losing commit: left on refs/heads/<branch>, its orphan sibling would trip
|
|
1497
|
+
// `checkoutPristine`'s guard on this clone's next branch-touching run. The confirmation just
|
|
1498
|
+
// refetched the winner's tip, so `origin/<branch>` resolves.
|
|
1499
|
+
const dropErr = await dropLocalBranchToRemoteTip(branch);
|
|
1500
|
+
if (dropErr) {
|
|
1501
|
+
this.deps.logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
|
|
1502
|
+
}
|
|
1503
|
+
// Keep the original rejection text (redacted: git prints the remote URL, which may embed
|
|
1504
|
+
// credentials): the raced verdict exits green, and without the text a persistent wording-matched
|
|
1505
|
+
// failure on a busy branch would leave no diagnosable trace anywhere.
|
|
1506
|
+
this.deps.logger.console(
|
|
1507
|
+
formatWarningSummary(
|
|
1508
|
+
`Push to ${branch} was rejected and the branch has since moved — a concurrent run got there first; ` +
|
|
1509
|
+
`re-planning on the next sync. The rejection: ${redactUrlCredentials(String(e?.message || e))}`
|
|
1510
|
+
)
|
|
1511
|
+
);
|
|
1512
|
+
return { raced: true };
|
|
1513
|
+
}
|
|
1233
1514
|
this.deps.logger.console(chalk.green(`Pushed ${branch}`));
|
|
1515
|
+
return { raced: false };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
|
|
1519
|
+
private async localRemoteTip(branch: string): Promise<string | undefined> {
|
|
1520
|
+
try {
|
|
1521
|
+
return String(await git.raw(['rev-parse', `origin/${branch}`])).trim() || undefined;
|
|
1522
|
+
} catch {
|
|
1523
|
+
return undefined;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* `confirmPushRace` (git-ops) with this executor's seam: `currentBranchTip` (shared with the close-pr
|
|
1529
|
+
* retirement path) keeps the confirmation unit-testable without a real remote. Residual risk,
|
|
1530
|
+
* accepted: "the branch moved" is not "a sync run won" — on a branch that moves during every run
|
|
1531
|
+
* window, a persistent wording-matched failure still classifies as raced each time. The caller logs
|
|
1532
|
+
* the original rejection text so that shape stays diagnosable. Only answers; the caller decides to
|
|
1533
|
+
* swallow or rethrow.
|
|
1534
|
+
*/
|
|
1535
|
+
private async classifyPushRejection(
|
|
1536
|
+
branch: string,
|
|
1537
|
+
baseSha: string | undefined,
|
|
1538
|
+
e: any
|
|
1539
|
+
): Promise<'confirmed-race' | 'not-a-race'> {
|
|
1540
|
+
return confirmPushRace(branch, baseSha, String(e?.message || e), (b) => this.currentBranchTip(b));
|
|
1234
1541
|
}
|
|
1235
1542
|
|
|
1236
1543
|
private async openPrForLane({
|
|
@@ -16,10 +16,13 @@ import {
|
|
|
16
16
|
checkoutPristine,
|
|
17
17
|
checkoutPristineRestore,
|
|
18
18
|
commitWithIdentity,
|
|
19
|
+
confirmPushRace,
|
|
20
|
+
dropLocalBranchToRemoteTip,
|
|
19
21
|
fetchRemoteHeads,
|
|
20
22
|
gitWithIdentity,
|
|
21
23
|
isNonContentPath,
|
|
22
24
|
isStaleLeaseRejection,
|
|
25
|
+
redactUrlCredentials,
|
|
23
26
|
} from './git-ops';
|
|
24
27
|
|
|
25
28
|
export type MainSyncDeps = {
|
|
@@ -89,7 +92,7 @@ export class MainSyncExecutor {
|
|
|
89
92
|
if (catchUpErr) {
|
|
90
93
|
const refork = await this.reforkIfMachineOwned(branch);
|
|
91
94
|
if (refork === 'human-owned') return `${HALT_SUMMARY_PREFIX} main -> ${catchUpErr}`;
|
|
92
|
-
if (refork === 'raced') return
|
|
95
|
+
if (refork === 'raced') return racedMainSummary(branch);
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -156,10 +159,34 @@ export class MainSyncExecutor {
|
|
|
156
159
|
|
|
157
160
|
await addAllExceptScopeAndModules();
|
|
158
161
|
await commitWithIdentity(mainSyncCommitMessage(drift.length));
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
// The local knowledge of `origin/<branch>` — the tip this run built on; after a rejection it is
|
|
163
|
+
// the baseline `confirmPushRace` checks the re-fetched remote against.
|
|
164
|
+
const baseSha = await git
|
|
165
|
+
.revparse([`origin/${branch}`])
|
|
166
|
+
.then((sha) => sha.trim() || undefined)
|
|
167
|
+
.catch(() => undefined);
|
|
168
|
+
// Never force. Unambiguous refspec — see `lane-sync-executor.commitAllAndPush`. A rejection means
|
|
169
|
+
// a concurrent run pushed in between; what happens next depends on the mode. Direct-push: the
|
|
170
|
+
// rejection IS the default branch's safety story — stay loud, never swallow. 'pr' mode: the push
|
|
171
|
+
// targets the machine-owned sync branch, where a concurrent-run overlap is the same benign race
|
|
172
|
+
// the lane executor tolerates (`reforkIfMachineOwned` already defers the same way) — confirm it
|
|
173
|
+
// and defer.
|
|
174
|
+
try {
|
|
175
|
+
await git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
176
|
+
} catch (e: any) {
|
|
177
|
+
const message = String(e?.message || e);
|
|
178
|
+
if (directPush || (await confirmPushRace(branch, baseSha, message)) !== 'confirmed-race') throw e;
|
|
179
|
+
// Drop the losing commit: left on the local sync branch, its orphan sibling would trip the
|
|
180
|
+
// next run's `checkoutPristine` guard. The original rejection text is kept for diagnosability.
|
|
181
|
+
const dropErr = await dropLocalBranchToRemoteTip(branch);
|
|
182
|
+
if (dropErr) {
|
|
183
|
+
logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
|
|
184
|
+
}
|
|
185
|
+
logger.console(
|
|
186
|
+
formatWarningSummary(`main -> push to ${branch} lost to a concurrent run: ${redactUrlCredentials(message)}`)
|
|
187
|
+
);
|
|
188
|
+
return racedMainSummary(branch);
|
|
189
|
+
}
|
|
163
190
|
logger.console(chalk.green(`Pushed ${branch}`));
|
|
164
191
|
|
|
165
192
|
if (directPush) {
|
|
@@ -329,6 +356,11 @@ export class MainSyncExecutor {
|
|
|
329
356
|
}
|
|
330
357
|
}
|
|
331
358
|
|
|
359
|
+
/** The deferral line both raced shapes share: the catch-up refork race and the sync-branch push race. */
|
|
360
|
+
function racedMainSummary(branch: string): string {
|
|
361
|
+
return `main -> ${branch} was updated by a concurrent run — deferring to it`;
|
|
362
|
+
}
|
|
363
|
+
|
|
332
364
|
/** The sync commit message: no `Bit-Lane-Head` trailer — main sync keeps no state. */
|
|
333
365
|
function mainSyncCommitMessage(driftCount: number): string {
|
|
334
366
|
return [
|