From c5e040c49814a10ce5d004ffc4abf86c93e8bf06 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 20 May 2026 16:18:28 -0400 Subject: [PATCH] feat(lanes): allow --squash on diverged components Previously `bit lane merge --squash` threw when components were diverged in history. Now it produces a single-parent merge snap with the dropped other-lane head captured in Version.squashed metadata. This keeps the merged lane's history self-contained on its own scope, so exporting after merging from a foreign scope does not pull that scope's intermediate snap objects. The existing VersionHistory graph already treats squashed.previousParents as edges, so subsequent re-merges from the same source lane correctly identify the previously-merged head as the diverge base. --- .../legacy/scope/lanes/unmerged-components.ts | 8 + .../lanes/merge-lanes-squash-diverge.e2e.ts | 309 ++++++++++++++++++ .../component/merging/merging.main.runtime.ts | 14 +- .../snapping/snapping.main.runtime.ts | 11 + .../merge-lanes/merge-lanes.main.runtime.ts | 10 +- 5 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 e2e/harmony/lanes/merge-lanes-squash-diverge.e2e.ts diff --git a/components/legacy/scope/lanes/unmerged-components.ts b/components/legacy/scope/lanes/unmerged-components.ts index 38ad196873c6..f14ca9eff559 100644 --- a/components/legacy/scope/lanes/unmerged-components.ts +++ b/components/legacy/scope/lanes/unmerged-components.ts @@ -49,6 +49,14 @@ export type UnmergedComponent = { * aspects config that were merged successfully */ mergedConfig?: Record; + /** + * when true, the upcoming merge-snap should be recorded as a squash: + * the lane-b head (stored in `head`) is NOT added as a second parent; instead it's + * captured in Version.squashed metadata. used by `bit lane merge --squash` for diverged + * components, so lane-b's history stays on its own scope and isn't pulled into the + * merging lane's scope on export. + */ + shouldSquash?: boolean; }; export const UNMERGED_FILENAME = 'unmerged.json'; diff --git a/e2e/harmony/lanes/merge-lanes-squash-diverge.e2e.ts b/e2e/harmony/lanes/merge-lanes-squash-diverge.e2e.ts new file mode 100644 index 000000000000..25d2a1182238 --- /dev/null +++ b/e2e/harmony/lanes/merge-lanes-squash-diverge.e2e.ts @@ -0,0 +1,309 @@ +import chai, { expect } from 'chai'; +import { Helper } from '@teambit/legacy.e2e-helper'; +import chaiFs from 'chai-fs'; + +chai.use(chaiFs); + +describe('merge lanes - squash on diverged', function () { + this.timeout(0); + let helper: Helper; + before(() => { + helper = new Helper(); + }); + after(() => { + helper.scopeHelper.destroy(); + }); + + describe('single scope: two diverged lanes, --squash on merge', () => { + let commonAncestor: string; + let headOnLaneA: string; + let headOnLaneB: string; + let mergeSnap: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + commonAncestor = helper.command.getHead('comp1'); + helper.command.export(); + + helper.command.createLane('lane-a'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A1 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A2 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A3 + headOnLaneA = helper.command.getHeadOfLane('lane-a', 'comp1'); + helper.command.export(); + const laneAWorkspace = helper.scopeHelper.cloneWorkspace(); + + helper.command.switchLocalLane('main'); + helper.command.createLane('lane-b'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B1 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B2 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B3 + headOnLaneB = helper.command.getHeadOfLane('lane-b', 'comp1'); + helper.command.export(); + + helper.scopeHelper.getClonedWorkspace(laneAWorkspace); + helper.command.import(); + helper.command.mergeLane('lane-b', '--squash --auto-merge-resolve theirs'); + mergeSnap = helper.command.getHeadOfLane('lane-a', 'comp1'); + }); + + it('merge snap should have a single parent pointing to lane-a head', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap.parents).to.have.lengthOf(1); + expect(snap.parents[0]).to.equal(headOnLaneA); + }); + + it('merge snap should record squash metadata with the dropped lane-b head', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap).to.have.property('squashed'); + const prevParents: string[] = snap.squashed.previousParents || snap.squashed.previousParentsRefs || []; + expect(prevParents).to.include(headOnLaneB); + }); + + it('bit log should show a clean history without lane-b intermediate snaps', () => { + const log = helper.command.logParsed('comp1'); + const hashes = log.map((l: any) => l.hash); + expect(hashes).to.include(commonAncestor); + expect(hashes).to.include(headOnLaneA); + expect(hashes).to.include(mergeSnap); + // lane-b intermediates should not be reachable from lane-a's head chain + expect(hashes).to.not.include(headOnLaneB); + }); + + it('bit log should not throw', () => { + expect(() => helper.command.logParsed('comp1')).to.not.throw(); + }); + }); + + describe('multi scope: lane-a on scope-a, lane-b on scope-b, diverged, --squash', () => { + let scopeB: string; + let scopeBPath: string; + let headOnLaneA: string; + let headOnLaneB: string; + let mergeSnap: string; + let scopeAAfterExport: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + const newScope = helper.scopeHelper.getNewBareScope(); + scopeB = newScope.scopeName; + scopeBPath = newScope.scopePath; + helper.scopeHelper.addRemoteScope(scopeBPath); + helper.scopeHelper.addRemoteScope(scopeBPath, helper.scopes.remotePath); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, scopeBPath); + + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + + helper.command.createLane('lane-a'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A1 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A2 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // A3 + headOnLaneA = helper.command.getHeadOfLane('lane-a', 'comp1'); + helper.command.export(); + const laneAWorkspace = helper.scopeHelper.cloneWorkspace(); + + helper.command.switchLocalLane('main'); + helper.command.createLane('lane-b', `--scope ${scopeB} --fork-lane-new-scope`); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B1 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B2 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B3 + headOnLaneB = helper.command.getHeadOfLane('lane-b', 'comp1'); + helper.command.export('--fork-lane-new-scope'); + + helper.scopeHelper.getClonedWorkspace(laneAWorkspace); + helper.command.import(); + helper.command.mergeLane(`${scopeB}/lane-b`, '--squash --auto-merge-resolve theirs'); + mergeSnap = helper.command.getHeadOfLane('lane-a', 'comp1'); + helper.command.export(); + scopeAAfterExport = helper.scopeHelper.cloneWorkspace(); + }); + + it('merge snap should have a single parent pointing to lane-a head', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap.parents).to.have.lengthOf(1); + expect(snap.parents[0]).to.equal(headOnLaneA); + }); + + it('merge snap squash metadata should record the dropped lane-b head', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap).to.have.property('squashed'); + const prevParents: string[] = snap.squashed.previousParents || snap.squashed.previousParentsRefs || []; + expect(prevParents).to.include(headOnLaneB); + }); + + it('scope-a should NOT contain lane-b intermediate snaps after export', () => { + // Inspect scope-a's storage: lane-b's snaps should remain on scope-b only. + // catObject against the remote scope path; absence is signalled by throw. + expect(() => helper.command.catObject(headOnLaneB, false, helper.scopes.remotePath)).to.throw(); + }); + + it('scope-a should contain the merge snap', () => { + expect(() => helper.command.catObject(mergeSnap, false, helper.scopes.remotePath)).to.not.throw(); + }); + + it('scope-b should still contain lane-b intermediate snaps', () => { + expect(() => helper.command.catObject(headOnLaneB, false, scopeBPath)).to.not.throw(); + }); + + describe('fresh consumer imports lane-a from scope-a', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.scopeHelper.addRemoteScope(scopeBPath); + helper.command.importLane('lane-a', '-x'); + }); + + it('bit log should not throw on the merged component', () => { + expect(() => helper.command.logParsed('comp1')).to.not.throw(); + }); + + it('bit log should show the merge snap and lane-a chain, not lane-b intermediates', () => { + const log = helper.command.logParsed('comp1'); + const hashes = log.map((l: any) => l.hash); + expect(hashes).to.include(mergeSnap); + expect(hashes).to.include(headOnLaneA); + expect(hashes).to.not.include(headOnLaneB); + }); + + it('bit status should not throw', () => { + expect(() => helper.command.status()).to.not.throw(); + }); + }); + + describe('re-merge of lane-b after it advances with new snaps', () => { + let headOnLaneBAfter: string; + let secondMergeSnap: string; + before(() => { + // advance lane-b on a separate workspace tied to scope-b + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.scopeHelper.addRemoteScope(scopeBPath); + helper.command.runCmd(`bit lane import ${scopeB}/lane-b -x`); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B4 + helper.command.snapAllComponentsWithoutBuild('--unmodified'); // B5 + headOnLaneBAfter = helper.command.getHeadOfLane(`${scopeB}/lane-b`, 'comp1'); + helper.command.export(); + + // return to the lane-a workspace and re-merge lane-b + helper.scopeHelper.getClonedWorkspace(scopeAAfterExport); + helper.command.import(); + helper.command.mergeLane(`${scopeB}/lane-b`, '--squash --auto-merge-resolve theirs'); + secondMergeSnap = helper.command.getHeadOfLane('lane-a', 'comp1'); + }); + + it('second merge snap should have a single parent pointing to the previous merge snap', () => { + const snap = helper.command.catComponent(`comp1@${secondMergeSnap}`); + expect(snap.parents).to.have.lengthOf(1); + expect(snap.parents[0]).to.equal(mergeSnap); + }); + + it('second merge snap should record the new dropped lane-b head (B5), not B3', () => { + const snap = helper.command.catComponent(`comp1@${secondMergeSnap}`); + expect(snap).to.have.property('squashed'); + const prevParents: string[] = snap.squashed.previousParents || snap.squashed.previousParentsRefs || []; + expect(prevParents).to.include(headOnLaneBAfter); + // crucially, should not re-include B3 (already merged previously) + expect(prevParents).to.not.include(headOnLaneB); + }); + + it('bit log after re-merge should not throw', () => { + expect(() => helper.command.logParsed('comp1')).to.not.throw(); + }); + + it('bit log after re-merge should show two merge snaps in the chain, no lane-b intermediates', () => { + const log = helper.command.logParsed('comp1'); + const hashes = log.map((l: any) => l.hash); + expect(hashes).to.include(mergeSnap); + expect(hashes).to.include(secondMergeSnap); + expect(hashes).to.not.include(headOnLaneB); + expect(hashes).to.not.include(headOnLaneBAfter); + }); + + it('re-merge should export to scope-a without errors', () => { + expect(() => helper.command.export()).to.not.throw(); + }); + }); + }); + + describe('sanity: diverged merge without --squash still produces a two-parent merge snap', () => { + let headOnLaneA: string; + let headOnLaneB: string; + let mergeSnap: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + + helper.command.createLane('lane-a'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + headOnLaneA = helper.command.getHeadOfLane('lane-a', 'comp1'); + helper.command.export(); + const laneAWorkspace = helper.scopeHelper.cloneWorkspace(); + + helper.command.switchLocalLane('main'); + helper.command.createLane('lane-b'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + headOnLaneB = helper.command.getHeadOfLane('lane-b', 'comp1'); + helper.command.export(); + + helper.scopeHelper.getClonedWorkspace(laneAWorkspace); + helper.command.import(); + helper.command.mergeLane('lane-b', '--auto-merge-resolve theirs'); + mergeSnap = helper.command.getHeadOfLane('lane-a', 'comp1'); + }); + + it('merge snap should have two parents (lane-a head and lane-b head)', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap.parents).to.have.lengthOf(2); + expect(snap.parents).to.include(headOnLaneA); + expect(snap.parents).to.include(headOnLaneB); + }); + + it('merge snap should NOT have squash metadata', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap).to.not.have.property('squashed'); + }); + }); + + describe('sanity: squash on non-diverged (fast-forward) lane-to-lane merge', () => { + let commonHead: string; + let mergeSnap: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + commonHead = helper.command.getHead('comp1'); + helper.command.export(); + + helper.command.createLane('lane-a'); + // lane-a does NOT advance — stays at the common head + helper.command.export(); + + helper.command.switchLocalLane('main'); + helper.command.createLane('lane-b'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.export(); + + helper.command.switchLocalLane('lane-a'); + helper.command.mergeLane('lane-b', '--squash --auto-merge-resolve theirs'); + mergeSnap = helper.command.getHeadOfLane('lane-a', 'comp1'); + }); + + it('squashed snap on lane-a should have a single parent pointing to the common head', () => { + const snap = helper.command.catComponent(`comp1@${mergeSnap}`); + expect(snap.parents).to.have.lengthOf(1); + expect(snap.parents[0]).to.equal(commonHead); + }); + + it('bit log should not throw', () => { + expect(() => helper.command.logParsed('comp1')).to.not.throw(); + }); + }); +}); diff --git a/scopes/component/merging/merging.main.runtime.ts b/scopes/component/merging/merging.main.runtime.ts index 14645ad7ea50..58c481087c50 100644 --- a/scopes/component/merging/merging.main.runtime.ts +++ b/scopes/component/merging/merging.main.runtime.ts @@ -198,6 +198,7 @@ export class MergingMain { skipDependencyInstallation, detachHead, loose, + shouldSquash, }: { mergeStrategy: MergeStrategy; allComponentsStatus: ComponentMergeStatus[]; @@ -211,6 +212,7 @@ export class MergingMain { skipDependencyInstallation?: boolean; detachHead?: boolean; loose?: boolean; + shouldSquash?: boolean; }): Promise { const consumer = this.workspace?.consumer; const legacyScope = this.scope.legacyScope; @@ -242,7 +244,8 @@ export class MergingMain { otherLaneId, mergeStrategy, currentLane, - detachHead + detachHead, + shouldSquash ); const allConfigMerge = compact(succeededComponents.map((c) => c.configMergeResult)); @@ -401,7 +404,8 @@ export class MergingMain { otherLaneId: LaneId, mergeStrategy: MergeStrategy, currentLane?: Lane, - detachHead?: boolean + detachHead?: boolean, + shouldSquash?: boolean ): Promise { const componentsResults = await mapSeries( succeededComponents, @@ -419,6 +423,7 @@ export class MergingMain { resolvedUnrelated, configMergeResult, detachHead, + shouldSquash, }); } ); @@ -457,6 +462,7 @@ export class MergingMain { resolvedUnrelated, configMergeResult, detachHead, + shouldSquash, }: { currentComponent: ConsumerComponent | null | undefined; id: ComponentID; @@ -468,6 +474,7 @@ export class MergingMain { resolvedUnrelated?: ResolveUnrelatedData; configMergeResult?: ConfigMergeResult; detachHead?: boolean; + shouldSquash?: boolean; }): Promise { const legacyScope = this.scope.legacyScope; let filesStatus = {}; @@ -475,6 +482,9 @@ export class MergingMain { id: { name: id.fullName, scope: id.scope }, head: remoteHead, laneId: otherLaneId, + // diverged components get squashed at snap-creation time (single-parent + squashed metadata) + // when shouldSquash is set. fast-forward squash is handled separately by squashSnaps(). + shouldSquash: shouldSquash && Boolean(mergeResults), }; id = currentComponent ? currentComponent.id : id; const modelComponent = await legacyScope.getModelComponent(id); diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 53f4c500fc49..764cc9eed487 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -1107,6 +1107,17 @@ another option, in case this dependency is not in main yet is to remove all refe version.setUnrelated({ head: unrelated.unrelatedHead, laneId: unrelated.unrelatedLaneId }); version.addAsOnlyParent(unrelated.headOnCurrentLane); } + } else if (unmergedComponent.shouldSquash) { + // --squash on diverged components: record the dropped lane-b head in squashed metadata + // and leave `version.parents` as the single current-lane head. lane-b's history remains + // reachable only on its own scope; the merge snap chain stays self-contained on this lane. + const currentParent = version.parents[0]; + const previousParents = currentParent ? [currentParent, unmergedComponent.head] : [unmergedComponent.head]; + version.setSquashed({ previousParents, laneId: unmergedComponent.laneId }, version.log); + this.logger.debug( + `sources.addSource, unmerged component "${component.name}". squash-on-diverged: dropping parent ${unmergedComponent.head.hash}` + ); + version.log.message = version.log.message || UnmergedComponents.buildSnapMessage(unmergedComponent); } else { // this is adding a second parent to the version. the order is important. the first parent is coming from the current-lane. version.addParent(unmergedComponent.head); diff --git a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts index 4508bd11b948..17110bbfd9ca 100644 --- a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts +++ b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts @@ -245,6 +245,7 @@ export class MergeLanesMain { skipDependencyInstallation, detachHead, loose, + shouldSquash, }); if (snapshot) await lastMerged?.persistSnapshot(snapshot); @@ -735,11 +736,10 @@ async function squashOneComp( // for detach head, it's ok to have it as diverged. as long as the target is ahead, we want to squash. return true; } - throw new BitError(`unable to squash because ${id.toString()} is diverged in history. - consider switching to "${ - otherLaneId.name - }" first, merging "${currentLaneName}", then switching back to "${currentLaneName}" and merging "${otherLaneId.name}" - alternatively, use "--no-squash" flag to keep the entire history of "${otherLaneId.name}"`); + // diverged + --squash: skip the parent-rewrite path here. the merge snap will be created + // by snapForMerge with a single parent (and squashed metadata) because the corresponding + // UnmergedComponent entry has `shouldSquash: true`. see snapping.main.runtime.ts. + return false; } if (divergeData.isSourceAhead()) { // nothing to do. current is ahead, nothing to merge. (it was probably filtered out already as a "failedComponent")