Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions components/legacy/scope/lanes/unmerged-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export type UnmergedComponent = {
* aspects config that were merged successfully
*/
mergedConfig?: Record<string, any>;
/**
* 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';
Expand Down
309 changes: 309 additions & 0 deletions e2e/harmony/lanes/merge-lanes-squash-diverge.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
14 changes: 12 additions & 2 deletions scopes/component/merging/merging.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export class MergingMain {
skipDependencyInstallation,
detachHead,
loose,
shouldSquash,
}: {
mergeStrategy: MergeStrategy;
allComponentsStatus: ComponentMergeStatus[];
Expand All @@ -211,6 +212,7 @@ export class MergingMain {
skipDependencyInstallation?: boolean;
detachHead?: boolean;
loose?: boolean;
shouldSquash?: boolean;
}): Promise<ApplyVersionResults> {
const consumer = this.workspace?.consumer;
const legacyScope = this.scope.legacyScope;
Expand Down Expand Up @@ -242,7 +244,8 @@ export class MergingMain {
otherLaneId,
mergeStrategy,
currentLane,
detachHead
detachHead,
shouldSquash
);

const allConfigMerge = compact(succeededComponents.map((c) => c.configMergeResult));
Expand Down Expand Up @@ -401,7 +404,8 @@ export class MergingMain {
otherLaneId: LaneId,
mergeStrategy: MergeStrategy,
currentLane?: Lane,
detachHead?: boolean
detachHead?: boolean,
shouldSquash?: boolean
): Promise<ApplyVersionWithComps[]> {
const componentsResults = await mapSeries(
succeededComponents,
Expand All @@ -419,6 +423,7 @@ export class MergingMain {
resolvedUnrelated,
configMergeResult,
detachHead,
shouldSquash,
});
}
);
Expand Down Expand Up @@ -457,6 +462,7 @@ export class MergingMain {
resolvedUnrelated,
configMergeResult,
detachHead,
shouldSquash,
}: {
currentComponent: ConsumerComponent | null | undefined;
id: ComponentID;
Expand All @@ -468,13 +474,17 @@ export class MergingMain {
resolvedUnrelated?: ResolveUnrelatedData;
configMergeResult?: ConfigMergeResult;
detachHead?: boolean;
shouldSquash?: boolean;
}): Promise<ApplyVersionWithComps> {
const legacyScope = this.scope.legacyScope;
let filesStatus = {};
const unmergedComponent: UnmergedComponent = {
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);
Expand Down
Loading