@teambit/ci 1.0.495 → 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-1786461108763.js → preview-1786718956243.js} +1 -1
- package/dist/sync/lane-sync-executor.d.ts +56 -14
- package/dist/sync/lane-sync-executor.js +196 -37
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +127 -3
- package/dist/sync/lane-sync-executor.spec.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 +21 -21
- package/sync/lane-sync-executor.spec.ts +110 -3
- package/sync/lane-sync-executor.ts +234 -32
- 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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
laneSyncPrBody,
|
|
16
16
|
racedLedgerPushSummary,
|
|
17
17
|
} from './lane-sync-executor';
|
|
18
|
+
import type { LaneOwnershipEvidence } from './sync-planner';
|
|
18
19
|
import { resolveSyncConfig } from './sync-config';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -387,6 +388,87 @@ describe('close-pr re-verifies the tip before retiring a branch', () => {
|
|
|
387
388
|
});
|
|
388
389
|
});
|
|
389
390
|
|
|
391
|
+
describe('differentLaneStillClaims uses .bitmap blob equality, not commit reachability', () => {
|
|
392
|
+
function executorWithInheritance(inherited: boolean) {
|
|
393
|
+
const executor = new LaneSyncExecutor({
|
|
394
|
+
lanes: {} as any,
|
|
395
|
+
ci: {} as any,
|
|
396
|
+
logger: { console: () => {}, consoleWarning: () => {}, error: () => {} } as any,
|
|
397
|
+
gitHost: undefined,
|
|
398
|
+
cfg: resolveSyncConfig({}),
|
|
399
|
+
defaultScope: 'acme.shop',
|
|
400
|
+
});
|
|
401
|
+
(executor as any).branchInheritsBitmapFromDefault = async () => inherited;
|
|
402
|
+
return (mirroredLane: string | undefined, laneIdStr: string) =>
|
|
403
|
+
(executor as any).differentLaneStillClaims(mirroredLane, laneIdStr, 'my-branch', 'main') as Promise<boolean>;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
it('does not claim when there is no mirrored lane pointer at all — never even checks the blob', async () => {
|
|
407
|
+
const differentLaneStillClaims = executorWithInheritance(true);
|
|
408
|
+
expect(await differentLaneStillClaims(undefined, 'acme.shop/my-lane')).to.equal(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("does not claim for this lane's own pointer — never even checks the blob", async () => {
|
|
412
|
+
const differentLaneStillClaims = executorWithInheritance(true);
|
|
413
|
+
expect(await differentLaneStillClaims('acme.shop/my-lane', 'acme.shop/my-lane')).to.equal(false);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("does not claim when the different-lane .bitmap is byte-identical to the default branch's — inherited", async () => {
|
|
417
|
+
const differentLaneStillClaims = executorWithInheritance(true);
|
|
418
|
+
expect(await differentLaneStillClaims('acme.shop/other-lane', 'acme.shop/my-lane')).to.equal(false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("STILL claims when the different-lane .bitmap has diverged from the default branch's — halt, not adopt", async () => {
|
|
422
|
+
const differentLaneStillClaims = executorWithInheritance(false);
|
|
423
|
+
expect(await differentLaneStillClaims('acme.shop/other-lane', 'acme.shop/my-lane')).to.equal(true);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('computeHasIndependentHistory gates the ancestry check to own-live claims with a state commit', () => {
|
|
428
|
+
function executorWithAncestryResult(result: boolean) {
|
|
429
|
+
const calls: Array<[string, string]> = [];
|
|
430
|
+
const executor = new LaneSyncExecutor({
|
|
431
|
+
lanes: {} as any,
|
|
432
|
+
ci: {} as any,
|
|
433
|
+
logger: { console: () => {}, consoleWarning: () => {}, error: () => {} } as any,
|
|
434
|
+
gitHost: undefined,
|
|
435
|
+
cfg: resolveSyncConfig({}),
|
|
436
|
+
defaultScope: 'acme.shop',
|
|
437
|
+
});
|
|
438
|
+
(executor as any).hasIndependentHistoryBelowStateCommit = async (stateCommit: string, defaultBranch: string) => {
|
|
439
|
+
calls.push([stateCommit, defaultBranch]);
|
|
440
|
+
return result;
|
|
441
|
+
};
|
|
442
|
+
const compute = (ownership: LaneOwnershipEvidence, stateCommit: string | undefined, defaultBranch: string) =>
|
|
443
|
+
(executor as any).computeHasIndependentHistory(ownership, stateCommit, defaultBranch) as Promise<boolean>;
|
|
444
|
+
return { compute, calls };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
it('skips the ancestry check entirely for a reachable claim (own-merged)', async () => {
|
|
448
|
+
const { compute, calls } = executorWithAncestryResult(true);
|
|
449
|
+
expect(await compute('own-merged', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(false);
|
|
450
|
+
expect(calls).to.deep.equal([]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('skips the ancestry check entirely for own-superseded too', async () => {
|
|
454
|
+
const { compute, calls } = executorWithAncestryResult(true);
|
|
455
|
+
expect(await compute('own-superseded', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(false);
|
|
456
|
+
expect(calls).to.deep.equal([]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('skips the check when there is no state commit to inspect, even for an own-live claim', async () => {
|
|
460
|
+
const { compute, calls } = executorWithAncestryResult(true);
|
|
461
|
+
expect(await compute('own-live', undefined, 'main')).to.equal(false);
|
|
462
|
+
expect(calls).to.deep.equal([]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('defers to the ancestry check for an own-live claim with a state commit', async () => {
|
|
466
|
+
const { compute, calls } = executorWithAncestryResult(true);
|
|
467
|
+
expect(await compute('own-live', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main')).to.equal(true);
|
|
468
|
+
expect(calls).to.deep.equal([['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', 'main']]);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
390
472
|
describe('classifyPushRejection confirms a race before calling it one', () => {
|
|
391
473
|
const BASE_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
|
|
392
474
|
const MOVED_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2';
|
|
@@ -446,9 +528,8 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
|
|
|
446
528
|
(executor as any).lastNonSyncCommitMessage = async () => 'feat: some change';
|
|
447
529
|
(executor as any).checkoutFromRemote = async () => {};
|
|
448
530
|
(executor as any).restoreWorkspace = async () => {};
|
|
449
|
-
// The export half already succeeded
|
|
450
|
-
|
|
451
|
-
(executor as any).snapAndExportOntoLane = async () => undefined;
|
|
531
|
+
// The export half already succeeded; only the ledger commit that follows raced.
|
|
532
|
+
(executor as any).snapAndExportOntoLane = async () => ({ status: 'exported' });
|
|
452
533
|
(executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
|
|
453
534
|
|
|
454
535
|
const summary = await (executor as any).executeExportBranch({
|
|
@@ -465,6 +546,32 @@ describe('executeExportBranch reports the ledger-race wording when only the ledg
|
|
|
465
546
|
});
|
|
466
547
|
});
|
|
467
548
|
|
|
549
|
+
// Adoption never snaps or exports, so its raced line must NOT claim the lane was updated — the lane
|
|
550
|
+
// is untouched and only the branch's pointer commit lost the race.
|
|
551
|
+
describe('executeAdoptBranch reports a lane-untouched race when its ledger push races', () => {
|
|
552
|
+
it('surfaces "lane untouched; adoption ledger commit lost the push race" without halting', async () => {
|
|
553
|
+
const { executor } = stubExecutor();
|
|
554
|
+
(executor as any).deps.ci = { hasUnsyncedWorkChanges: async () => false };
|
|
555
|
+
(executor as any).checkoutFromRemote = async () => {};
|
|
556
|
+
(executor as any).restoreWorkspace = async () => {};
|
|
557
|
+
(executor as any).materializeLane = async () => undefined;
|
|
558
|
+
(executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
|
|
559
|
+
|
|
560
|
+
const summary = await (executor as any).executeAdoptBranch({
|
|
561
|
+
target: { hostScope: 'acme.shop', name: 'my-lane' },
|
|
562
|
+
laneIdStr: 'acme.shop/my-lane',
|
|
563
|
+
branch: 'my-lane',
|
|
564
|
+
defaultBranch: 'main',
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
expect(summary).to.equal(
|
|
568
|
+
'my-lane -> raced (lane untouched; adoption ledger commit lost the push race — next run re-plans)'
|
|
569
|
+
);
|
|
570
|
+
expect(summary).to.not.include('lane updated');
|
|
571
|
+
expect(summary).to.not.include('HALTED');
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
468
575
|
// A destructive auto-resolution (onConflict git-wins/lane-wins) that already exported must not vanish
|
|
469
576
|
// from the summary just because the ledger push raced.
|
|
470
577
|
describe('racedLedgerPushSummary keeps the conflict-policy clause', () => {
|
|
@@ -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';
|
|
@@ -89,6 +92,15 @@ export function racedLedgerPushSummary(laneName: string, policyClause = ''): str
|
|
|
89
92
|
);
|
|
90
93
|
}
|
|
91
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
|
+
|
|
92
104
|
export type LaneSyncDeps = {
|
|
93
105
|
lanes: LanesMain;
|
|
94
106
|
/** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
|
|
@@ -245,6 +257,7 @@ export const KEPT_BECAUSE: Record<BranchKeepReason, string> = {
|
|
|
245
257
|
'unmerged-commits': `it carries commits that are not in the default branch`,
|
|
246
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`,
|
|
247
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`,
|
|
248
261
|
};
|
|
249
262
|
|
|
250
263
|
export class LaneSyncExecutor {
|
|
@@ -423,6 +436,20 @@ export class LaneSyncExecutor {
|
|
|
423
436
|
// `isSyncAuthoredMessage`, NOT `hasSyncMarker`: this feeds a branch deletion, and a message that
|
|
424
437
|
// merely quotes the marker must not read as one we wrote.
|
|
425
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
|
+
);
|
|
426
453
|
|
|
427
454
|
const action = planLaneSync({
|
|
428
455
|
laneHead,
|
|
@@ -430,7 +457,9 @@ export class LaneSyncExecutor {
|
|
|
430
457
|
lastSyncedHead,
|
|
431
458
|
hasDevCommits: branchState.hasDevCommits,
|
|
432
459
|
tipIsSyncCommit,
|
|
460
|
+
hasIndependentHistory,
|
|
433
461
|
conflictLabelPresent,
|
|
462
|
+
branchNamesDifferentLane,
|
|
434
463
|
ownership,
|
|
435
464
|
});
|
|
436
465
|
|
|
@@ -497,6 +526,8 @@ export class LaneSyncExecutor {
|
|
|
497
526
|
return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch });
|
|
498
527
|
case 'merge-diverged':
|
|
499
528
|
return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch });
|
|
529
|
+
case 'adopt-branch':
|
|
530
|
+
return this.executeAdoptBranch({ target, laneIdStr, branch, defaultBranch, pr });
|
|
500
531
|
case 'close-pr':
|
|
501
532
|
return this.executeClosePr({
|
|
502
533
|
laneName,
|
|
@@ -508,8 +539,12 @@ export class LaneSyncExecutor {
|
|
|
508
539
|
keepReason: action.deleteBranch ? undefined : action.keepReason,
|
|
509
540
|
expectedTipSha: branchState.tipSha,
|
|
510
541
|
});
|
|
511
|
-
case 'halt':
|
|
512
|
-
|
|
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
|
+
}
|
|
513
548
|
default: {
|
|
514
549
|
// exhaustiveness guard
|
|
515
550
|
const unhandled: never = action;
|
|
@@ -665,14 +700,14 @@ export class LaneSyncExecutor {
|
|
|
665
700
|
await this.checkoutFromRemote(branch, `origin/${branch}`);
|
|
666
701
|
|
|
667
702
|
try {
|
|
668
|
-
const
|
|
669
|
-
if (
|
|
703
|
+
const exported = await this.snapAndExportOntoLane(laneIdStr, message);
|
|
704
|
+
if (exported.status === 'error') {
|
|
670
705
|
// Halt rather than propagate: one lane's failed snap/export must not abort the lanes after it.
|
|
671
706
|
return await this.executeHalt({
|
|
672
707
|
laneName,
|
|
673
708
|
laneIdStr,
|
|
674
709
|
branch,
|
|
675
|
-
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}`,
|
|
676
711
|
pr: await this.findPr(branch),
|
|
677
712
|
});
|
|
678
713
|
}
|
|
@@ -697,6 +732,85 @@ export class LaneSyncExecutor {
|
|
|
697
732
|
}
|
|
698
733
|
}
|
|
699
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
|
+
);
|
|
809
|
+
} finally {
|
|
810
|
+
await this.restoreWorkspace(defaultBranch);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
700
814
|
/**
|
|
701
815
|
* Both sides moved since the last sync. Order is load-bearing: (1) merge the lane into the branch's
|
|
702
816
|
* working tree (`bit checkout head`; conflicts halt or auto-resolve per `sync.onConflict`), (2) snap
|
|
@@ -787,14 +901,14 @@ export class LaneSyncExecutor {
|
|
|
787
901
|
}
|
|
788
902
|
|
|
789
903
|
// ---- step 2: snap + export the merged tree onto the lane ---------------------------------
|
|
790
|
-
const
|
|
904
|
+
const exported = await this.snapAndExportOntoLane(
|
|
791
905
|
laneIdStr,
|
|
792
906
|
`merge remote lane ${laneIdStr} into ${branch} ${SYNC_COMMIT_MARKER}`
|
|
793
907
|
);
|
|
794
|
-
if (
|
|
908
|
+
if (exported.status === 'error') {
|
|
795
909
|
return await halt(
|
|
796
910
|
`merged lane ${laneIdStr} into branch ${branch} cleanly, but snapping/exporting the merged ` +
|
|
797
|
-
`result failed: ${
|
|
911
|
+
`result failed: ${exported.error.message}`
|
|
798
912
|
);
|
|
799
913
|
}
|
|
800
914
|
|
|
@@ -819,18 +933,20 @@ export class LaneSyncExecutor {
|
|
|
819
933
|
}
|
|
820
934
|
|
|
821
935
|
/**
|
|
822
|
-
* Snap the workspace's current tree onto the lane and export it
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
* 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.
|
|
829
942
|
*/
|
|
830
|
-
private async snapAndExportOntoLane(
|
|
943
|
+
private async snapAndExportOntoLane(
|
|
944
|
+
laneIdStr: string,
|
|
945
|
+
message: string
|
|
946
|
+
): Promise<{ status: 'noop' | 'exported' } | { status: 'error'; error: Error }> {
|
|
831
947
|
try {
|
|
832
948
|
await ensureCurrentLaneObject(this.deps.lanes);
|
|
833
|
-
await this.deps.ci.snapPrCommit({
|
|
949
|
+
const result = await this.deps.ci.snapPrCommit({
|
|
834
950
|
laneIdStr,
|
|
835
951
|
message,
|
|
836
952
|
build: undefined,
|
|
@@ -839,9 +955,10 @@ export class LaneSyncExecutor {
|
|
|
839
955
|
skipCleanup: true,
|
|
840
956
|
noDestructiveRecovery: true,
|
|
841
957
|
});
|
|
842
|
-
return
|
|
958
|
+
if (result === NO_CHANGES_TO_SNAP) return { status: 'noop' };
|
|
959
|
+
return { status: 'exported' };
|
|
843
960
|
} catch (e: any) {
|
|
844
|
-
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)) };
|
|
845
962
|
}
|
|
846
963
|
}
|
|
847
964
|
|
|
@@ -850,17 +967,18 @@ export class LaneSyncExecutor {
|
|
|
850
967
|
* so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
|
|
851
968
|
* `unreadable` means the lane could no longer be read back, in which case the caller halts;
|
|
852
969
|
* `raced` means a concurrent run's push already got there first, in which case the caller reports
|
|
853
|
-
* a benign noop instead.
|
|
970
|
+
* a benign noop instead. `adopted` adds the audit-only `ADOPTION_TRAILER`.
|
|
854
971
|
*/
|
|
855
972
|
private async recordLaneHeadOnBranch(
|
|
856
973
|
target: LaneTarget,
|
|
857
974
|
laneIdStr: string,
|
|
858
|
-
branch: string
|
|
975
|
+
branch: string,
|
|
976
|
+
opts: { adopted?: boolean } = {}
|
|
859
977
|
): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
|
|
860
978
|
const remoteLane = await this.getRemoteLane(target);
|
|
861
979
|
if (!remoteLane) return { status: 'unreadable' };
|
|
862
980
|
const laneHead = laneHeadFingerprint(remoteLane.components);
|
|
863
|
-
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
981
|
+
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead, opts));
|
|
864
982
|
if (pushResult.raced) return { status: 'raced' };
|
|
865
983
|
return { status: 'ok', laneHead };
|
|
866
984
|
}
|
|
@@ -977,6 +1095,80 @@ export class LaneSyncExecutor {
|
|
|
977
1095
|
}
|
|
978
1096
|
}
|
|
979
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
|
+
|
|
980
1172
|
/**
|
|
981
1173
|
* The lane is gone from bit.cloud — close its PR, and retire the branch if the claim on it allows.
|
|
982
1174
|
* Deleting the branch is the one irreversible thing this command does; `keepReason` distinguishes
|
|
@@ -1178,14 +1370,17 @@ export class LaneSyncExecutor {
|
|
|
1178
1370
|
}
|
|
1179
1371
|
|
|
1180
1372
|
/**
|
|
1181
|
-
* Drive the workspace's filesystem
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
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.
|
|
1187
1382
|
*/
|
|
1188
|
-
private async materializeLane(laneIdStr: string): Promise<Error | undefined> {
|
|
1383
|
+
private async materializeLane(laneIdStr: string, theirsWins = true): Promise<Error | undefined> {
|
|
1189
1384
|
const { logger } = this.deps;
|
|
1190
1385
|
const target = await this.deps.lanes.parseLaneId(laneIdStr);
|
|
1191
1386
|
// Compares name AND scope, and reads from `.bitmap`, not the scope's lane object — the object read
|
|
@@ -1206,9 +1401,16 @@ export class LaneSyncExecutor {
|
|
|
1206
1401
|
}
|
|
1207
1402
|
|
|
1208
1403
|
const switchErr = await this.deps.ci.switchToLaneForSync(laneIdStr, {
|
|
1209
|
-
forceOurs:
|
|
1210
|
-
forceTheirs:
|
|
1211
|
-
|
|
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,
|
|
1212
1414
|
});
|
|
1213
1415
|
if (switchErr) return switchErr;
|
|
1214
1416
|
|
|
@@ -62,6 +62,13 @@ const table: Array<{
|
|
|
62
62
|
type: 'close-pr',
|
|
63
63
|
action: { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' },
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
// Adoption proved content matched, never that the branch's pre-existing history is disposable.
|
|
67
|
+
name: 'own-live, no dev commits, OUR tip, but it is the adoption ledger commit -> KEEP, never delete',
|
|
68
|
+
input: { ...laneGone('own-live'), hasIndependentHistory: true },
|
|
69
|
+
type: 'close-pr',
|
|
70
|
+
action: { type: 'close-pr', deleteBranch: false, keepReason: 'adopted-branch' },
|
|
71
|
+
},
|
|
65
72
|
{
|
|
66
73
|
name: 'own-merged (branch tip already in the default branch) -> close-pr AND delete',
|
|
67
74
|
input: laneGone('own-merged'),
|
|
@@ -75,6 +82,12 @@ const table: Array<{
|
|
|
75
82
|
type: 'close-pr',
|
|
76
83
|
action: { type: 'close-pr', deleteBranch: true },
|
|
77
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: 'own-merged deletes even when the tip is the adoption ledger commit — reachability is enough',
|
|
87
|
+
input: { ...laneGone('own-merged'), hasIndependentHistory: true },
|
|
88
|
+
type: 'close-pr',
|
|
89
|
+
action: { type: 'close-pr', deleteBranch: true },
|
|
90
|
+
},
|
|
78
91
|
{
|
|
79
92
|
name: 'own-superseded (PR merged, then more commits pushed) -> close-pr but KEEP the branch',
|
|
80
93
|
input: laneGone('own-superseded'),
|
|
@@ -108,9 +121,31 @@ const table: Array<{
|
|
|
108
121
|
type: 'import-lane',
|
|
109
122
|
},
|
|
110
123
|
{
|
|
111
|
-
name: 'existing branch, never synced, has dev commits, lane EXISTS ->
|
|
124
|
+
name: 'existing branch, never synced, has dev commits, lane EXISTS -> adopt-branch (first contact)',
|
|
112
125
|
input: { lastSyncedHead: undefined, ownership: 'inherited-or-none', hasDevCommits: true },
|
|
126
|
+
type: 'adopt-branch',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'existing branch, never synced, has dev commits, but .bitmap names a DIFFERENT lane -> halt',
|
|
130
|
+
input: {
|
|
131
|
+
lastSyncedHead: undefined,
|
|
132
|
+
ownership: 'inherited-or-none',
|
|
133
|
+
hasDevCommits: true,
|
|
134
|
+
branchNamesDifferentLane: true,
|
|
135
|
+
},
|
|
113
136
|
type: 'halt',
|
|
137
|
+
reason: 'names a different lane',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
// The executor resolves an inherited (merged) different-lane pointer to `false` — history, not a claim.
|
|
141
|
+
name: 'existing branch, has dev commits, .bitmap names a MERGED-AND-INHERITED different lane -> adopt-branch',
|
|
142
|
+
input: {
|
|
143
|
+
lastSyncedHead: undefined,
|
|
144
|
+
ownership: 'inherited-or-none',
|
|
145
|
+
hasDevCommits: true,
|
|
146
|
+
branchNamesDifferentLane: false,
|
|
147
|
+
},
|
|
148
|
+
type: 'adopt-branch',
|
|
114
149
|
},
|
|
115
150
|
];
|
|
116
151
|
|