diff --git a/components/legacy/component-list/components-list.ts b/components/legacy/component-list/components-list.ts index 25c8b0e014fe..f69df6a6cd88 100644 --- a/components/legacy/component-list/components-list.ts +++ b/components/legacy/component-list/components-list.ts @@ -152,9 +152,12 @@ export class ComponentsList { } /** - * @todo: this is not the full list. It's missing the deleted-components. - * will be easier to add it here once all legacy are not using this class and then ScopeMain will be in the - * constructor. + * @todo: this is not the full list. It's missing the deleted-components. will be easier to add it + * here once all legacy are not using this class and then ScopeMain will be in the constructor. + * + * Returns every locally-changed component pending export, including hidden lane entries + * (`skipWorkspace: true`). Callers that need a workspace-only view (e.g. `bit status`'s "staged + * components" section) filter out hidden entries themselves at the call site. */ async listExportPendingComponentsIds(lane?: Lane | null): Promise { const fromBitMap = this.bitMap.getAllIdsAvailableOnLaneIncludeRemoved(); @@ -162,12 +165,15 @@ export class ComponentsList { const pendingExportComponents = await pFilter(modelComponents, async (component: ModelComponent) => { const foundInBitMap = fromBitMap.searchWithoutVersion(component.toComponentId()); if (!foundInBitMap) { - // it's not on the .bitmap only in the scope, as part of the out-of-sync feature, it should - // be considered as staged and should be exported. same for soft-removed components, which are on scope only. - // notice that we use `hasLocalChanges` - // and not `isLocallyChanged` by purpose. otherwise, cached components that were not - // updated from a remote will be calculated as remote-ahead in the setDivergeData and will - // be exported unexpectedly. + // it's not on the .bitmap only in the scope. Two cases land here: + // - out-of-sync: a workspace component that lost its bitmap entry but still has scope data + // - hidden lane entry: a `skipWorkspace: true` entry on the current lane (produced by + // cascade-on-snap or fetched from a remote that ran the bare-scope cascade producer); + // these never enter the workspace bitmap by design + const laneEntry = lane?.getComponent(component.toComponentId()); + if (lane && laneEntry) { + return component.isLocallyChanged(this.scope.objects, lane); + } return component.isLocallyChangedRegardlessOfLanes(); } return component.isLocallyChanged(this.scope.objects, lane, foundInBitMap); diff --git a/components/legacy/e2e-helper/e2e-helper.ts b/components/legacy/e2e-helper/e2e-helper.ts index 64cb4984afa7..b87359654004 100644 --- a/components/legacy/e2e-helper/e2e-helper.ts +++ b/components/legacy/e2e-helper/e2e-helper.ts @@ -21,6 +21,7 @@ import ScopeJsonHelper from './e2e-scope-json-helper'; import type { ScopesOptions } from './e2e-scopes'; import ScopesData from './e2e-scopes'; import CapsulesHelper from './e2e-capsules-helper'; +import SnappingHelper from './e2e-snapping-helper'; export type HelperOptions = { scopesOptions?: ScopesOptions; @@ -44,6 +45,7 @@ export class Helper { scopeHelper: ScopeHelper; git: GitHelper; capsules: CapsulesHelper; + snapping: SnappingHelper; constructor(helperOptions?: HelperOptions) { this.debugMode = Boolean(process.env.npm_config_debug) || process.argv.includes('--debug'); // debug mode shows the workspace/scopes dirs and doesn't delete them this.scopes = new ScopesData(helperOptions?.scopesOptions); // generates dirs and scope names @@ -85,6 +87,7 @@ export class Helper { this.env = new EnvHelper(this.command, this.fs, this.scopes, this.scopeHelper, this.fixtures, this.extensions); this.general = new GeneralHelper(this.scopes, this.npm, this.command); this.capsules = new CapsulesHelper(this.command); + this.snapping = new SnappingHelper(); } } diff --git a/components/legacy/e2e-helper/e2e-snapping-helper.ts b/components/legacy/e2e-helper/e2e-snapping-helper.ts new file mode 100644 index 000000000000..17d381d338d4 --- /dev/null +++ b/components/legacy/e2e-helper/e2e-snapping-helper.ts @@ -0,0 +1,29 @@ +import { execFileSync } from 'child_process'; +import path from 'path'; +import type { SnapDataPerCompRaw } from '@teambit/snapping'; + +/** + * In-process invocation of `SnappingMain.snapFromScope` against a bare scope path. Spawns + * `snap-from-scope-runner.js` so each call gets a fresh Node process — `loadBit` accumulates + * module-level state across in-process invocations and that state leaks into downstream + * shell-spawned `bit` commands when many scenarios share a single test process. Used by e2e tests + * that need to seed `lane.updateDependents` (hidden cascade entries with `skipWorkspace: true`) + * on a remote lane. + */ +export default class SnappingHelper { + async snapFromScope( + scopePath: string, + snapData: SnapDataPerCompRaw[], + options: { + lane?: string; + updateDependents?: boolean; + push?: boolean; + message?: string; + } = {} + ): Promise { + const runnerPath = path.resolve(__dirname, 'snap-from-scope-runner.js'); + execFileSync('node', [runnerPath, scopePath, JSON.stringify(snapData), JSON.stringify(options)], { + stdio: 'inherit', + }); + } +} diff --git a/components/legacy/e2e-helper/index.ts b/components/legacy/e2e-helper/index.ts index 019c5bc43646..122fa307c663 100644 --- a/components/legacy/e2e-helper/index.ts +++ b/components/legacy/e2e-helper/index.ts @@ -15,6 +15,7 @@ import ScopeHelper from './e2e-scope-helper'; import ScopeJsonHelper from './e2e-scope-json-helper'; import ScopesData, { ScopesOptions, DEFAULT_OWNER } from './e2e-scopes'; import CapsulesHelper from './e2e-capsules-helper'; +import SnappingHelper from './e2e-snapping-helper'; import * as fixtures from './fixtures'; export { @@ -36,6 +37,7 @@ export { ScopeHelper, ScopeJsonHelper, CapsulesHelper, + SnappingHelper, fixtures, DEFAULT_OWNER, }; diff --git a/components/legacy/e2e-helper/snap-from-scope-runner.ts b/components/legacy/e2e-helper/snap-from-scope-runner.ts new file mode 100644 index 000000000000..8b97bf4b8f64 --- /dev/null +++ b/components/legacy/e2e-helper/snap-from-scope-runner.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-console */ +/** + * Standalone subprocess runner for `helper.snapping.snapFromScope`. The helper spawns this script + * via `child_process` so each invocation gets a clean Node process — `loadBit` mutates module-level + * state that doesn't fully reset between in-process calls, and accumulating that state across many + * scenarios in one process surfaces as "Version X not found in scope" failures during downstream + * shell-spawned `bit` commands. + * + * argv: + */ +import { loadBit } from '@teambit/bit'; +import type { SnappingMain } from '@teambit/snapping'; +import { SnappingAspect } from '@teambit/snapping'; + +async function main(): Promise { + const [, , scopePath, snapDataJson, optionsJson] = process.argv; + const snapData = JSON.parse(snapDataJson); + const options = JSON.parse(optionsJson); + + const harmony = await loadBit(scopePath); + const snapping = harmony.get(SnappingAspect.id); + await snapping.snapFromScope(snapData, { + lane: options.lane, + updateDependents: options.updateDependents, + push: options.push, + message: options.message, + build: false, + disableTagAndSnapPipelines: true, + }); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/components/legacy/scope/repositories/sources.ts b/components/legacy/scope/repositories/sources.ts index fca6b330889f..29a6d30b8d29 100644 --- a/components/legacy/scope/repositories/sources.ts +++ b/components/legacy/scope/repositories/sources.ts @@ -352,7 +352,14 @@ to quickly fix the issue, please delete the object at "${this.objects().objectPa } } - const head = component.head || laneItem?.head; + // when on a lane, walk the LANE's parent chain — `laneItem.head` is the lane head we're + // about to rewind, and the prior snap lives in *its* parent graph, not in main's. Falling + // back to `component.head` (main head) here was a long-standing bug that surfaced once the + // component-tagged-on-main-then-imported-to-lane case became common (cascade-on-snap): + // `bit reset --head` walked main's parents, found none for the tag, returned undefined, + // and `lane.removeComponent` was called — leaving the bitmap to rewind all the way back to + // the imported tag instead of the previous lane snap. + const head = laneItem?.head || component.head; if (!head) { return undefined; } @@ -643,7 +650,14 @@ otherwise, to collaborate on the same lane as the remote, you'll need to remove if (isExport) { if (existingLane) { existingLane.addComponent(component); - existingLane.removeComponentFromUpdateDependentsIfExist(component.id); + // promote-on-import: a previously hidden updateDependent is being added as visible + // (incoming bucket switched). Strip the stale hidden entry so the lane doesn't end + // up with both a visible and hidden entry for the same id. Only do this when the + // incoming entry is itself visible — otherwise we'd remove the hidden entry we + // just added (the seed cascade flow). + if (!component.skipWorkspace) { + existingLane.removeComponentFromUpdateDependentsIfExist(component.id); + } } if (!sentVersionHashes?.includes(component.head.toString())) { // during export, the remote might got a lane when some components were not sent from the client. ignore them. @@ -720,40 +734,25 @@ possible causes: mergeResults.push({ mergedComponent: modelComponent, mergedVersions: [] }); }; - await pMap( - lane.components, - async (component) => { - await mergeLaneComponent(component); - }, - { concurrency: concurrentComponentsLimit() } - ); + // Run every lane entry — visible AND hidden (skipWorkspace) — through the same + // per-component diverge check. Earlier this iterated visible-only and routed hidden entries + // through a separate override-flag-governed replacement path; that path was winner-takes-all + // and could silently overwrite a concurrent producer's hidden cascade. Unifying here gives + // hidden entries the same divergence guarantees as visible ones: same-head no-op, + // target-ahead accept, local-ahead no-op, diverge → reject on export / silent-keep-local + // on import (user resolves via reset+re-cascade). + await pMap(lane.components, async (component) => mergeLaneComponent(component), { + concurrency: concurrentComponentsLimit(), + }); // downgrade the schema if the incoming lane has a lower schema because it's possible that components are deleted // in the incoming lane but because it has an old schema, it doesn't have the "isDeleted" prop. leaving the schema // of current lane as 1.0.0 will mistakenly think that the component is not deleted. if (existingLane?.hasChanged && existingLane.includeDeletedData() && !lane.includeDeletedData()) { existingLane.setSchemaToNotSupportDeletedData(); } - // merging updateDependents is tricky. the end user should never change it, only get it as is from the remote. - // this prop gets updated with snap-from-scope with --update-dependents flag. and a graphql query should remove entries - // from there. other than these 2 places, it should never change. so when a user imports it, always override. - // if it is being exported, the remote should override it only when it comes from the snap-from-scope command, to - // indicate this, the lane should have the overrideUpdateDependents prop set to true. - if (isImport && existingLane) { - existingLane.updateDependents = lane.updateDependents; - } - if (isExport && existingLane && lane.shouldOverrideUpdateDependents()) { - await Promise.all( - (lane.updateDependents || []).map(async (id) => { - const existing = existingLane.updateDependents?.find((existingId) => existingId.isEqualWithoutVersion(id)); - if (!existing || existing.version !== id.version) { - const mergedComponent = await getModelComponent(id); - mergeResults.push({ mergedComponent, mergedVersions: [id.version] }); - } - }) - ); - existingLane.updateDependents = lane.updateDependents; - } - return { mergeResults, mergeErrors, mergeLane: existingLane || lane }; + const mergeLane = existingLane || lane; + + return { mergeResults, mergeErrors, mergeLane }; } } diff --git a/e2e/harmony/lanes/update-dependents-cascade.e2e.ts b/e2e/harmony/lanes/update-dependents-cascade.e2e.ts new file mode 100644 index 000000000000..34141d2dc675 --- /dev/null +++ b/e2e/harmony/lanes/update-dependents-cascade.e2e.ts @@ -0,0 +1,918 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import chai, { expect } from 'chai'; +import { Helper, NpmCiRegistry, supportNpmCiRegistryTesting } from '@teambit/legacy.e2e-helper'; +import chaiFs from 'chai-fs'; + +chai.use(chaiFs); + +/** + * Cascade behavior on a lane that has `updateDependents` (hidden `skipWorkspace: true` entries). + * The seed step uses `helper.snapping.snapFromScope` — an in-process call to + * `SnappingMain.snapFromScope` against a bare scope, which is what produces those entries. + * + * The two sides being exercised: + * 1. Local `bit snap` on a lane with existing `updateDependents` folds the affected entries + * into the same snap pass, producing one Version per cascaded component (scenarios 1, 5, 6). + * 2. The bare-scope "snap updates" path also re-snaps any entries in `lane.components` that + * depend on the new updateDependent, so the lane doesn't end up with + * `compA@lane.components -> compB@main` once `compB` enters `lane.updateDependents` + * (scenario 4). + * + * Divergence/merge-resolution (scenario 3 inner block) is pending a design decision on how + * "parent = main head" updateDependents should interact with reset/re-snap and remote merge. + */ +describe('local snap cascades updateDependents on the lane', function () { + this.timeout(0); + let helper: Helper; + before(() => { + helper = new Helper(); + }); + after(() => { + helper.scopeHelper.destroy(); + }); + + /** + * Common starting state used by every scenario: + * main: comp1@0.0.1 -> comp2@0.0.1 -> comp3@0.0.1 + * lane `dev` on remote: + * components: [ comp3@ ] + * updateDependents: [ comp2@ ] + */ + async function buildBaseRemoteState(): Promise<{ + comp3HeadOnLaneInitial: string; + comp2InUpdDepInitial: string; + }> { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(3); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + helper.command.createLane(); + helper.command.snapComponentWithoutBuild('comp3', '--skip-auto-snap --unmodified'); + helper.command.export(); + const comp3HeadOnLaneInitial = helper.command.getHeadOfLane('dev', 'comp3'); + + const bareSnap = helper.scopeHelper.getNewBareScope('-bare-seed-updep'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap.scopePath); + await helper.snapping.snapFromScope( + bareSnap.scopePath, + [{ componentId: `${helper.scopes.remote}/comp2`, message: 'initial update-dependent' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2InUpdDepInitial = lane.updateDependents[0].split('@')[1]; + return { comp3HeadOnLaneInitial, comp2InUpdDepInitial }; + } + + // --------------------------------------------------------------------------------------------- + // Scenario 1: basic cascade — workspace has only comp3, snaps it, comp2 (in updateDependents) + // should be auto-re-snapped with the new comp3 version, and the parent chain should be intact. + // --------------------------------------------------------------------------------------------- + describe('scenario 1: workspace has the lane component only (no workspace dependents)', () => { + let comp3HeadOnLaneInitial: string; + let comp2InUpdDepInitial: string; + let comp3HeadAfterLocalSnap: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp3HeadOnLaneInitial = base.comp3HeadOnLaneInitial; + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + comp3HeadAfterLocalSnap = helper.command.getHeadOfLane('dev', 'comp3'); + }); + + it('comp3 should have advanced on the lane', () => { + expect(comp3HeadAfterLocalSnap).to.not.equal(comp3HeadOnLaneInitial); + }); + + it('comp2 in updateDependents should be re-snapped to a new hash', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(lane.updateDependents).to.have.lengthOf(1); + const comp2NewVersion = lane.updateDependents[0].split('@')[1]; + expect(comp2NewVersion).to.not.equal(comp2InUpdDepInitial); + }); + + it('cascaded comp2 should point at the new comp3 head', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2 = helper.command.catComponent(lane.updateDependents[0], helper.scopes.remotePath); + const comp3Dep = comp2.dependencies.find((d) => d.id.name === 'comp3'); + expect(comp3Dep.id.version).to.equal(comp3HeadAfterLocalSnap); + }); + + it('comp2 should NOT appear in the workspace bitmap (still a hidden updateDependent)', () => { + const bitMap = helper.bitMap.read(); + expect(bitMap).to.not.have.property('comp2'); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 4 (first "snap updates" click on a lane with existing lane.components that depend + // on the new updateDependent): the workspace user has both compA and compC on the lane from the + // start; compB lives only on main. When compA was snapped on the lane, its recorded dep on + // compB was still compB@main because compB hadn't entered the lane yet. + // + // The first time the user clicks "snap updates" in the UI, compB is introduced into + // `updateDependents`. After that click, compA on the lane should be re-snapped so its compB + // dep points at the *new* updateDependent snap — otherwise compA keeps pointing at compB@main + // and the lane's graph isn't internally consistent. + // --------------------------------------------------------------------------------------------- + (supportNpmCiRegistryTesting ? describe : describe.skip)( + 'scenario 4: first snap-updates click re-snaps lane.components that depend on the new updateDependent', + () => { + let comp1InitialLaneSnap: string; + let comp2NewHash: string; + let npmCiRegistry: NpmCiRegistry; + + before(async () => { + // Destroy the outer helper's temp dirs before swapping in a dot-scope helper, otherwise + // the original instance's workspaces/scopes leak for the rest of the suite. + helper.scopeHelper.destroy(); + helper = new Helper({ scopesOptions: { remoteScopeWithDot: true } }); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + npmCiRegistry = new NpmCiRegistry(helper); + await npmCiRegistry.init(); + npmCiRegistry.configureCiInPackageJsonHarmony(); + helper.fixtures.populateComponents(3); + helper.command.tagAllComponents(); + helper.command.export(); + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + npmCiRegistry.setResolver(); + helper.command.createLane(); + helper.command.importComponent('comp1'); + helper.command.importComponent('comp3'); + helper.command.snapAllComponentsWithoutBuild('--unmodified'); + helper.command.export(); + const laneBeforeSnapUpdates = helper.command.catLane('dev', helper.scopes.remotePath); + const comp1BeforeEntry = laneBeforeSnapUpdates.components.find((c) => c.id.name === 'comp1'); + expect(comp1BeforeEntry, 'comp1 must be on lane.components before snap-updates').to.exist; + comp1InitialLaneSnap = comp1BeforeEntry.head; + + // Sanity-check the "bug" starting state: comp1's lane snap currently depends on + // comp2@0.0.1 (main). The fix needs to rewrite this once snap-updates runs. + const comp1BeforeObj = helper.command.catComponent( + `${helper.scopes.remote}/comp1@${comp1InitialLaneSnap}`, + helper.scopes.remotePath + ); + const comp2DepBefore = comp1BeforeObj.dependencies.find((d) => d.id.name === 'comp2'); + expect(comp2DepBefore, 'comp1 must have a comp2 dep before snap-updates').to.exist; + expect(comp2DepBefore.id.version, 'pre-snap-updates comp2 dep should be the main tag').to.equal('0.0.1'); + + const bareSnap = helper.scopeHelper.getNewBareScope('-bare-snap-updates'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap.scopePath); + await helper.snapping.snapFromScope( + bareSnap.scopePath, + [{ componentId: `${helper.scopes.remote}/comp2`, message: 'first snap-updates click' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + const laneAfterSnapUpdates = helper.command.catLane('dev', helper.scopes.remotePath); + comp2NewHash = laneAfterSnapUpdates.updateDependents[0].split('@')[1]; + }); + after(() => { + npmCiRegistry.destroy(); + // Destroy this scenario's dot-scope helper before swapping back, so its temp dirs + // don't outlive the describe block. + helper.scopeHelper.destroy(); + helper = new Helper(); + }); + + it('comp2 (B) enters lane.updateDependents', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(lane.updateDependents).to.have.lengthOf(1); + expect(lane.updateDependents[0]).to.include('comp2'); + }); + + it('comp1 (A) on the lane should be re-snapped with its comp2 dep pointing at the new updateDependent', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp1OnLane = lane.components.find((c) => c.id.name === 'comp1'); + expect(comp1OnLane, 'comp1 must still be in lane.components').to.exist; + expect(comp1OnLane.head).to.not.equal(comp1InitialLaneSnap); + + const comp1 = helper.command.catComponent( + `${helper.scopes.remote}/comp1@${comp1OnLane.head}`, + helper.scopes.remotePath + ); + const comp2Dep = comp1.dependencies.find((d) => d.id.name === 'comp2'); + expect(comp2Dep, 'comp1 should still declare a comp2 dep').to.exist; + expect(comp2Dep.id.version).to.equal(comp2NewHash); + }); + + it('comp1 stays in lane.components (it was never a hidden updateDependent)', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp1InUpdDep = (lane.updateDependents || []).find((s) => s.includes('comp1')); + expect(comp1InUpdDep, 'comp1 must NOT be in updateDependents').to.be.undefined; + }); + } + ); + + // --------------------------------------------------------------------------------------------- + // Scenario 5: transitive cascade inside updateDependents. Both comp1 and comp2 live in + // updateDependents (comp1 depending on comp2, comp2 on comp3). When a local snap changes + // comp3, the fixed-point expansion must cascade comp2 (direct dependent on comp3) AND comp1 + // (transitive dependent via comp2) — all in one pass, and comp1's comp2 dep must point at the + // newly-cascaded comp2 hash, not the pre-cascade one. + // --------------------------------------------------------------------------------------------- + describe('scenario 5: transitive cascade inside updateDependents', () => { + let comp2InUpdDepInitial: string; + let comp1InUpdDepInitial: string; + let comp3HeadAfterLocalSnap: string; + + before(async () => { + helper = new Helper(); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(3); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + helper.command.createLane(); + helper.command.snapComponentWithoutBuild('comp3', '--skip-auto-snap --unmodified'); + helper.command.export(); + + // Seed comp2 first so comp1's comp2 dep resolves to the updDep hash (not the main tag). + const bareSnap1 = helper.scopeHelper.getNewBareScope('-bare-seed-updep-comp2'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap1.scopePath); + await helper.snapping.snapFromScope( + bareSnap1.scopePath, + [{ componentId: `${helper.scopes.remote}/comp2`, message: 'seed comp2' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + const laneAfterSeedComp2 = helper.command.catLane('dev', helper.scopes.remotePath); + comp2InUpdDepInitial = laneAfterSeedComp2.updateDependents[0].split('@')[1]; + + const bareSnap2 = helper.scopeHelper.getNewBareScope('-bare-seed-updep-comp1'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap2.scopePath); + await helper.snapping.snapFromScope( + bareSnap2.scopePath, + [{ componentId: `${helper.scopes.remote}/comp1`, message: 'seed comp1' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + const laneAfterSeedComp1 = helper.command.catLane('dev', helper.scopes.remotePath); + const comp1Entry = laneAfterSeedComp1.updateDependents.find((s) => s.includes('comp1')); + expect(comp1Entry, 'comp1 must have been seeded into updateDependents').to.exist; + comp1InUpdDepInitial = (comp1Entry as string).split('@')[1]; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + comp3HeadAfterLocalSnap = helper.command.getHeadOfLane('dev', 'comp3'); + }); + + it('both comp1 and comp2 are cascaded to new hashes in updateDependents', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(lane.updateDependents).to.have.lengthOf(2); + const comp2New = lane.updateDependents.find((s) => s.includes('comp2')); + const comp1New = lane.updateDependents.find((s) => s.includes('comp1')); + expect(comp2New, 'comp2 must still be in updateDependents').to.exist; + expect(comp1New, 'comp1 must still be in updateDependents').to.exist; + expect((comp2New as string).split('@')[1]).to.not.equal(comp2InUpdDepInitial); + expect((comp1New as string).split('@')[1]).to.not.equal(comp1InUpdDepInitial); + }); + + it('cascaded comp2 depends on the new comp3 head', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2Str = lane.updateDependents.find((s) => s.includes('comp2')) as string; + const comp2 = helper.command.catComponent(comp2Str, helper.scopes.remotePath); + const comp3Dep = comp2.dependencies.find((d) => d.id.name === 'comp3'); + expect(comp3Dep.id.version).to.equal(comp3HeadAfterLocalSnap); + }); + + it('cascaded comp1 depends on the cascaded comp2 (not the old updDep comp2)', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp1Str = lane.updateDependents.find((s) => s.includes('comp1')) as string; + const comp2Str = lane.updateDependents.find((s) => s.includes('comp2')) as string; + const comp2NewHash = comp2Str.split('@')[1]; + const comp1 = helper.command.catComponent(comp1Str, helper.scopes.remotePath); + const comp2Dep = comp1.dependencies.find((d) => d.id.name === 'comp2'); + expect(comp2Dep.id.version).to.equal(comp2NewHash); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 6: promote-on-import. A component in `updateDependents` is later imported into the + // workspace and snapped directly. It should transition cleanly to `lane.components` and the + // stale `updateDependents` entry must be cleared. + // --------------------------------------------------------------------------------------------- + describe('scenario 6: promote-on-import — importing an updateDependent then snapping it moves it to lane.components', () => { + let comp2InUpdDepInitial: string; + + before(async () => { + helper = new Helper(); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(3); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + helper.command.createLane(); + helper.command.snapComponentWithoutBuild('comp3', '--skip-auto-snap --unmodified'); + helper.command.export(); + + const bareSnap = helper.scopeHelper.getNewBareScope('-bare-seed-updep'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap.scopePath); + await helper.snapping.snapFromScope( + bareSnap.scopePath, + [{ componentId: `${helper.scopes.remote}/comp2`, message: 'seed comp2' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + const initialLane = helper.command.catLane('dev', helper.scopes.remotePath); + comp2InUpdDepInitial = initialLane.updateDependents[0].split('@')[1]; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + // Explicitly import comp2 — the "promote" step. After this, comp2 is tracked in the + // workspace bitmap and is a first-class lane component candidate, not a hidden updDep. + helper.command.importComponent('comp2'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp2/index.js`, "module.exports = () => 'comp2-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + }); + + it('comp2 should be in lane.components with a fresh snap', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2InComponents = lane.components.find((c) => c.id.name === 'comp2'); + expect(comp2InComponents, 'comp2 must be in lane.components').to.exist; + expect((comp2InComponents as any).head).to.not.equal(comp2InUpdDepInitial); + }); + + it('comp2 should NOT appear in lane.updateDependents (the stale entry must be cleared)', () => { + const lane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2InUpdDep = (lane.updateDependents || []).find((s) => s.includes('comp2')); + expect(comp2InUpdDep, 'comp2 must not be in updateDependents once it has been promoted').to.be.undefined; + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 3: two users diverge on the same lane — both locally snap comp3. The cascade must + // produce comp2 snaps that diverge alongside comp3, and resolution (reset / merge) must work + // on both comp3 AND the cascaded comp2. + // --------------------------------------------------------------------------------------------- + describe('scenario 3: divergence — two users snap the same lane concurrently', () => { + let userBPath: string; + let comp2InUpdDepInitial: string; + let comp2AfterUserAExport: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + // User B — clone of A's pre-snap state. Keep it aside. + userBPath = helper.scopeHelper.cloneWorkspace(); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2-userA';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + const laneAfterA = helper.command.catLane('dev', helper.scopes.remotePath); + comp2AfterUserAExport = laneAfterA.updateDependents[0].split('@')[1]; + + helper.scopeHelper.getClonedWorkspace(userBPath); + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2-userB';"); + helper.command.snapAllComponentsWithoutBuild(); + }); + + it('user A`s export should advance the comp2 entry in updateDependents past the initial state', () => { + expect(comp2AfterUserAExport).to.not.equal(comp2InUpdDepInitial); + }); + + it('user B`s export should be rejected because the lane is diverged', () => { + const exportCmd = () => helper.command.export(); + expect(exportCmd).to.throw(/diverged|merge|reset|update/i); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 7: import must not clobber a pending local cascade. After cascade-on-snap rewrites + // a hidden updateDependent locally, a `bit fetch --lanes` between snap and export should keep + // the local cascade in place — `mergeLaneComponent` sees local-ahead and no-ops on import. + // --------------------------------------------------------------------------------------------- + describe('scenario 7: local cascade survives a `bit fetch --lanes` before export', () => { + let comp2InUpdDepInitial: string; + let comp2AfterLocalSnap: string; + let comp3HeadAfterLocalSnap: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + + const laneAfterSnap = helper.command.catLane('dev'); + comp2AfterLocalSnap = laneAfterSnap.updateDependents[0].split('@')[1]; + comp3HeadAfterLocalSnap = helper.command.getHeadOfLane('dev', 'comp3'); + + expect(comp2AfterLocalSnap).to.not.equal(comp2InUpdDepInitial); + + helper.command.fetchAllLanes(); + }); + + it('local lane.updateDependents still points at the cascaded comp2 hash (not reverted to the remote version)', () => { + const localLane = helper.command.catLane('dev'); + expect(localLane.updateDependents).to.have.lengthOf(1); + const localComp2 = localLane.updateDependents[0].split('@')[1]; + expect(localComp2).to.equal(comp2AfterLocalSnap); + expect(localComp2).to.not.equal(comp2InUpdDepInitial); + }); + + it('bit export still publishes the cascade to the remote afterward', () => { + helper.command.export(); + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + const remoteComp2 = remoteLane.updateDependents[0].split('@')[1]; + expect(remoteComp2).to.equal(comp2AfterLocalSnap); + expect(remoteComp2).to.not.equal(comp2InUpdDepInitial); + }); + + it('cascaded comp2 on the remote points at the new comp3 head', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + const remoteComp2 = helper.command.catComponent(remoteLane.updateDependents[0], helper.scopes.remotePath); + const comp3Dep = remoteComp2.dependencies.find((d) => d.id.name === 'comp3'); + expect(comp3Dep.id.version).to.equal(comp3HeadAfterLocalSnap); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 8: `bit reset` must revert the cascade, not just the user's direct snap. The cascade + // snap shares a `batchId` with the user's direct snap (set in `version-maker.makeVersion`), and + // `reset` collects every batchId from the versions it removes and walks the lane-history + // backwards through those entries — including the cascade ones — to restore the lane to its + // pre-snap state end-to-end. + // --------------------------------------------------------------------------------------------- + describe('scenario 8: bit reset reverts the cascade, not just the direct snap', () => { + let comp2InUpdDepInitial: string; + let comp3HeadBeforeLocalSnap: string; + let laneAfterReset: Record; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + comp3HeadBeforeLocalSnap = base.comp3HeadOnLaneInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + + const laneAfterSnap = helper.command.catLane('dev'); + expect(laneAfterSnap.updateDependents[0].split('@')[1]).to.not.equal(comp2InUpdDepInitial); + + helper.command.resetAll(); + laneAfterReset = helper.command.catLane('dev'); + }); + + it('comp3 on the lane should rewind to its pre-snap head', () => { + const comp3OnLane = laneAfterReset.components.find((c) => c.id.name === 'comp3'); + expect(comp3OnLane.head).to.equal(comp3HeadBeforeLocalSnap); + }); + + it('lane.updateDependents should revert to the pre-cascade comp2 hash', () => { + expect(laneAfterReset.updateDependents).to.have.lengthOf(1); + const comp2After = laneAfterReset.updateDependents[0].split('@')[1]; + expect(comp2After).to.equal(comp2InUpdDepInitial); + }); + + it('a subsequent export should leave the remote lane unchanged from its pre-snap state', () => { + helper.command.export(); + const remoteLaneAfter = helper.command.catLane('dev', helper.scopes.remotePath); + const comp3OnRemote = remoteLaneAfter.components.find((c) => c.id.name === 'comp3'); + expect(comp3OnRemote.head).to.equal(comp3HeadBeforeLocalSnap); + expect(remoteLaneAfter.updateDependents).to.have.lengthOf(1); + expect(remoteLaneAfter.updateDependents[0].split('@')[1]).to.equal(comp2InUpdDepInitial); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 9: `bit reset --head` after TWO consecutive local snaps must only rewind the LATEST + // snap's cascade — the first snap's cascade must stay intact. This exercises the per-batch + // history on the lane: the first snap's cascade entry must survive while the second snap's + // cascade is rolled back. + // --------------------------------------------------------------------------------------------- + describe('scenario 9: bit reset --head rewinds only the last snap, not both cascades', () => { + let comp2InUpdDepInitial: string; + let comp2AfterFirstSnap: string; + let laneAfterResetHead: Record; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + const laneAfterFirst = helper.command.catLane('dev'); + comp2AfterFirstSnap = laneAfterFirst.updateDependents[0].split('@')[1]; + + expect(comp2AfterFirstSnap).to.not.equal(comp2InUpdDepInitial); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v3';"); + helper.command.snapAllComponentsWithoutBuild(); + const laneAfterSecond = helper.command.catLane('dev'); + expect(laneAfterSecond.updateDependents[0].split('@')[1]).to.not.equal(comp2AfterFirstSnap); + + helper.command.resetAll('--head'); + laneAfterResetHead = helper.command.catLane('dev'); + }); + + it('lane.updateDependents should point at the FIRST-snap cascade comp2 hash (not reverted to pre-cascade)', () => { + expect(laneAfterResetHead.updateDependents).to.have.lengthOf(1); + const comp2After = laneAfterResetHead.updateDependents[0].split('@')[1]; + expect(comp2After).to.equal(comp2AfterFirstSnap); + expect(comp2After).to.not.equal(comp2InUpdDepInitial); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 11: `bit import` on a hidden updateDependent (no edit, no snap) must leave the + // workspace consistent — bitmap presence, `bit status` not erroring, `bit list` reporting the + // comp, and a clean export round-trip leaving the remote lane unchanged. + // --------------------------------------------------------------------------------------------- + describe('scenario 11: bit import on a hidden updateDependent leaves the workspace consistent', () => { + let comp2InUpdDepInitial: string; + let comp3HeadOnLaneInitial: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + comp3HeadOnLaneInitial = base.comp3HeadOnLaneInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + + helper.command.importComponent('comp2'); + }); + + it('comp2 should land in the workspace bitmap', () => { + const bitMap = helper.bitMap.read(); + expect(bitMap).to.have.property('comp2'); + }); + + it('bit status runs cleanly (no thrown errors, no merge-pending)', () => { + const status = helper.command.statusJson(); + expect(status).to.be.an('object'); + expect(status.invalidComponents || []).to.have.lengthOf(0); + }); + + it('bit list reports comp2 with a resolvable version', () => { + const list = helper.command.listLocalScopeParsed(); + const comp2 = list.find((c: Record) => c.id.includes('/comp2')); + expect(comp2, 'comp2 should appear in `bit list`').to.exist; + }); + + it('comp2 stays in lane.updateDependents on the remote (import alone does not promote)', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(remoteLane.updateDependents).to.have.lengthOf(1); + expect(remoteLane.updateDependents[0].split('@')[1]).to.equal(comp2InUpdDepInitial); + }); + + it('the lane`s visible components list still has comp3 only (no leak from the import)', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(remoteLane.components).to.have.lengthOf(1); + const comp3OnLane = remoteLane.components.find((c) => c.id.name === 'comp3'); + expect(comp3OnLane, 'comp3 must stay on lane.components').to.exist; + expect(comp3OnLane.head).to.equal(comp3HeadOnLaneInitial); + }); + + it('a no-op export after the import leaves the remote lane untouched', () => { + try { + helper.command.export(); + } catch (err: any) { + if (!String(err?.message || err).match(/nothing to export/i)) throw err; + } + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(remoteLane.updateDependents).to.have.lengthOf(1); + expect(remoteLane.updateDependents[0].split('@')[1]).to.equal(comp2InUpdDepInitial); + expect(remoteLane.components).to.have.lengthOf(1); + const comp3OnLane = remoteLane.components.find((c) => c.id.name === 'comp3'); + expect(comp3OnLane.head).to.equal(comp3HeadOnLaneInitial); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 12: `bit status` must run cleanly after `bit reset --head` on a lane that has + // workspace-direct snaps + hidden updateDependent cascades. Locks down the regression where + // resetting a head'd cascade left the workspace's bitmap entry pointing at the pre-snap version + // (the imported tag), but the modelComponent's local view of that version had been dropped — so + // a subsequent `bit status` threw `ComponentsPendingImport (comp3@)`. + // --------------------------------------------------------------------------------------------- + describe('scenario 12: bit status is clean after reset --head on lane with cascades', () => { + before(async () => { + await buildBaseRemoteState(); + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + // TWO consecutive workspace snaps — each cascades comp2. + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v3';"); + helper.command.snapAllComponentsWithoutBuild(); + + helper.command.resetAll('--head'); + }); + + it('bit status should not throw ComponentsPendingImport for the visible component', () => { + const status = helper.command.statusJson(); + expect(status.importPendingComponents || []).to.have.lengthOf(0); + }); + + it('bit status should not list hidden updateDependents under stagedComponents', () => { + const status = helper.command.statusJson(); + const stagedNames = (status.stagedComponents || []).map((c: any) => { + const id = typeof c === 'string' ? c : c.id; + return id.split('/').pop().split('@')[0]; + }); + expect(stagedNames).to.not.include('comp1'); + expect(stagedNames).to.not.include('comp2'); + }); + + it('bit status should surface the locally-cascaded comp2 under pendingUpdateDependents', () => { + // After `reset --head`, the lane still has the FIRST-snap cascade entry for comp2 (locally + // pending export). It must show up in the dedicated `pendingUpdateDependents` field — the + // same set `bit export` later prints under "exported updates". + const status = helper.command.statusJson(); + const pending = (status.pendingUpdateDependents || []) as string[]; + const comp2InPending = pending.find((id) => id.split('/').pop() === 'comp2'); + expect(comp2InPending, 'comp2 must appear in pendingUpdateDependents').to.exist; + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 13: workspace `bit lane merge main` must refresh `lane.updateDependents` so hidden + // entries stay in sync with main's advanced head. + // --------------------------------------------------------------------------------------------- + describe('scenario 13: workspace `bit lane merge main` refreshes updateDependents when main advances', () => { + let comp2InUpdDepInitial: string; + let comp2HeadOnMainAfterAdvance: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + // Advance comp2 on main with a REAL file change. The cascade snap on the lane (comp2 is + // hidden) must absorb this content via 3-way merge — `snapHiddenForMerge` has to use the + // merged ConsumerComponent produced by `applyVersion`, not just reload the lane-head + // version, otherwise main-side content drift is silently lost. + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importComponent('*'); + helper.fs.outputFile(`${helper.scopes.remote}/comp2/index.js`, "module.exports = () => 'comp2-main-v2';"); + helper.command.tagAllWithoutBuild('-m "advance-main"'); + helper.command.export(); + comp2HeadOnMainAfterAdvance = helper.command.getHead(`${helper.scopes.remote}/comp2`); + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.mergeLaneWithoutBuild('main', '--no-squash'); + helper.command.export(); + }); + + it('lane.updateDependents[comp2] should point at a NEW hash after the workspace merge', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + expect(remoteLane.updateDependents).to.have.lengthOf(1); + const comp2HashAfterMerge = remoteLane.updateDependents[0].split('@')[1]; + expect(comp2HashAfterMerge).to.not.equal(comp2InUpdDepInitial); + }); + + it('lane.updateDependents[comp2] should descend from main`s advanced head (proper 3-way merge)', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2 = helper.command.catComponent(remoteLane.updateDependents[0], helper.scopes.remotePath); + expect(comp2.parents).to.include(comp2HeadOnMainAfterAdvance); + expect(comp2.parents).to.have.lengthOf(2); + }); + + it('cascaded comp2 must absorb main-side content (file ref equals main`s)', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + const cascaded = helper.command.catComponent(remoteLane.updateDependents[0], helper.scopes.remotePath); + const mainAdvanced = helper.command.catComponent( + `${helper.scopes.remote}/comp2@${comp2HeadOnMainAfterAdvance}`, + helper.scopes.remotePath + ); + // Same blob ref means the merge result took main-side content, not lane-head content. + expect(cascaded.files[0].file).to.equal(mainAdvanced.files[0].file); + }); + + it('comp2 must stay in lane.updateDependents, NOT be promoted to lane.components', () => { + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + const comp2InComponents = remoteLane.components.find((c) => c.id.name === 'comp2'); + expect(comp2InComponents, 'comp2 must not leak into lane.components').to.be.undefined; + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 14: `bit lane history` on a lane that contains hidden updateDependents must run + // cleanly and produce a fresh entry whenever the lane changes — including when the only change + // is a hidden cascade. `Lane.isEqual` covers `skipWorkspace`, so a cascade-only state delta + // flips `hasChanged` and triggers `updateLaneHistory` in `saveLane`. + // --------------------------------------------------------------------------------------------- + describe('scenario 14: bit lane history on a lane with hidden updateDependents', () => { + let historyBeforeLocalSnap: Array>; + let historyAfterLocalSnap: Array>; + let comp2InUpdDepInitial: string; + let comp3HeadAfterLocalSnap: string; + let comp2HeadAfterCascade: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + historyBeforeLocalSnap = helper.command.laneHistoryParsed(); + + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + comp3HeadAfterLocalSnap = helper.command.getHeadOfLane('dev', 'comp3'); + const remoteLane = helper.command.catLane('dev', helper.scopes.remotePath); + comp2HeadAfterCascade = remoteLane.updateDependents[0].split('@')[1]; + + historyAfterLocalSnap = helper.command.laneHistoryParsed(); + }); + + it('bit lane history runs cleanly on a lane that has hidden updateDependents', () => { + expect(historyBeforeLocalSnap).to.be.an('array').and.not.empty; + historyBeforeLocalSnap.forEach((entry) => { + expect(entry).to.have.property('id'); + expect(entry).to.have.property('components').that.is.an('array'); + }); + }); + + it('history entries created BEFORE the workspace snap include the seeded comp2 hash under updateDependents', () => { + const seedEntries = historyBeforeLocalSnap.filter((e) => + (e.updateDependents || []).some((s: string) => s.endsWith(`@${comp2InUpdDepInitial}`)) + ); + expect(seedEntries, 'expected at least one history entry with the seed comp2 hash').to.not.be.empty; + }); + + it('a workspace cascade snap appends a new history entry', () => { + expect(historyAfterLocalSnap.length).to.be.greaterThan(historyBeforeLocalSnap.length); + }); + + it('the new history entry records the advanced comp3 head among its components', () => { + const newEntries = historyAfterLocalSnap.filter((e) => !historyBeforeLocalSnap.some((b) => b.id === e.id)); + expect(newEntries, 'expected at least one new history entry after the cascade snap').to.not.be.empty; + const comp3RefsInNewEntries = newEntries.flatMap((e) => + (e.components || []).filter((c: string) => c.includes('/comp3@')) + ); + expect(comp3RefsInNewEntries.some((ref: string) => ref.endsWith(`@${comp3HeadAfterLocalSnap}`))).to.be.true; + }); + + it('the new history entry records the cascaded comp2 hash under updateDependents (separate from components)', () => { + const newEntries = historyAfterLocalSnap.filter((e) => !historyBeforeLocalSnap.some((b) => b.id === e.id)); + const comp2RefsInUpdateDependents = newEntries.flatMap((e) => + (e.updateDependents || []).filter((c: string) => c.includes('/comp2@')) + ); + expect(comp2RefsInUpdateDependents.some((ref: string) => ref.endsWith(`@${comp2HeadAfterCascade}`))).to.be.true; + // and the cascaded comp2 must NOT leak into history.components — that field drives + // checkout/revert workspace materialization, which would mis-promote a hidden entry. + const comp2RefsInComponents = newEntries.flatMap((e) => + (e.components || []).filter((c: string) => c.includes('/comp2@')) + ); + expect(comp2RefsInComponents).to.have.lengthOf(0); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 15: `bit lane checkout ` is a workspace-navigation operation. It rewrites + // the workspace files / bitmap of visible components but never touches the lane object — same + // for visible heads (which stay put) and for hidden updateDependents (which stay at the + // post-cascade hash). If the user keeps working on the lane, the next snap re-cascades off the + // new files; if they fork, the new lane starts fresh. + // --------------------------------------------------------------------------------------------- + describe('scenario 15: bit lane checkout leaves hidden updateDependents untouched on the lane', () => { + let comp2InUpdDepInitial: string; + let comp3HeadOnLaneInitial: string; + let comp2HeadAfterCascade: string; + let comp3HeadAfterLocalSnap: string; + let preCascadeHistoryId: string; + + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + comp3HeadOnLaneInitial = base.comp3HeadOnLaneInitial; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + + // Snapshot the history-id BEFORE the cascade snap. + const historyBeforeCascade = helper.command.laneHistoryParsed(); + const matchingEntry = historyBeforeCascade.find((e) => + (e.updateDependents || []).some((s: string) => s.endsWith(`@${comp2InUpdDepInitial}`)) + ); + expect(matchingEntry, 'expected a history entry pointing at the pre-cascade comp2 hash').to.exist; + preCascadeHistoryId = (matchingEntry as Record).id; + + // Cascade snap: comp3 advances on lane, comp2 (hidden) cascades to a new hash. + helper.fs.outputFile(`${helper.scopes.remote}/comp3/index.js`, "module.exports = () => 'comp3-v2';"); + helper.command.snapAllComponentsWithoutBuild(); + comp3HeadAfterLocalSnap = helper.command.getHeadOfLane('dev', 'comp3'); + const laneAfterCascade = helper.command.catLane('dev'); + comp2HeadAfterCascade = laneAfterCascade.updateDependents[0].split('@')[1]; + + expect(comp3HeadAfterLocalSnap).to.not.equal(comp3HeadOnLaneInitial); + expect(comp2HeadAfterCascade).to.not.equal(comp2InUpdDepInitial); + + helper.command.runCmd(`bit lane checkout ${preCascadeHistoryId} -x`); + }); + + it('lane.updateDependents should stay at the post-cascade hash (lane is not mutated by checkout)', () => { + const localLane = helper.command.catLane('dev'); + expect(localLane.updateDependents).to.have.lengthOf(1); + expect(localLane.updateDependents[0].split('@')[1]).to.equal(comp2HeadAfterCascade); + }); + + it('comp2 must stay hidden (not promoted to lane.components)', () => { + const localLane = helper.command.catLane('dev'); + const comp2InComponents = localLane.components.find((c) => c.id.name === 'comp2'); + expect(comp2InComponents, 'comp2 must not leak into lane.components').to.be.undefined; + }); + + it('comp2 must NOT appear in the workspace bitmap after the checkout', () => { + const bitMap = helper.bitMap.read(); + expect(bitMap).to.not.have.property('comp2'); + }); + }); + + // --------------------------------------------------------------------------------------------- + // Scenario 16: a workspace `bit fetch --lanes` picks up a producer's hidden cascade that + // landed on the remote. After unifying hidden entries into mergeLaneComponent's diverge check, + // there's no override-flag-governed import guard — the workspace's local lane simply + // fast-forwards to the producer's hash on fetch. + // --------------------------------------------------------------------------------------------- + describe('scenario 16: workspace fetch picks up a producer hidden cascade', () => { + let comp2AfterProducerPush: string; + let comp2InUpdDepInitial: string; + before(async () => { + const base = await buildBaseRemoteState(); + comp2InUpdDepInitial = base.comp2InUpdDepInitial; + + // Workspace imports the lane but does NOT cascade-snap yet — its local lane sits on the + // initial seeded comp2. The override flag is undefined locally. + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath); + helper.command.importLane('dev', '-x'); + helper.command.importComponent('comp3'); + const localLaneBefore = helper.command.catLane('dev'); + expect(localLaneBefore.updateDependents[0].split('@')[1]).to.equal(comp2InUpdDepInitial); + + // Producer pushes a fresh hidden cascade for comp2. This is non-divergent — the lane on + // remote moves from the seed to the producer's hash; workspace just hasn't seen it yet. + const bareProducer = helper.scopeHelper.getNewBareScope('-bare-cascade-ahead'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareProducer.scopePath); + helper.command.runCmd(`bit fetch ${helper.scopes.remote}/dev --lanes`, bareProducer.scopePath); + await helper.snapping.snapFromScope( + bareProducer.scopePath, + [{ componentId: `${helper.scopes.remote}/comp2`, message: 'producer cascade before workspace cascade' }], + { lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true } + ); + const laneAfterProducer = helper.command.catLane('dev', helper.scopes.remotePath); + comp2AfterProducerPush = laneAfterProducer.updateDependents[0].split('@')[1]; + expect(comp2AfterProducerPush).to.not.equal(comp2InUpdDepInitial); + + helper.command.fetchAllLanes(); + }); + + it('the workspace`s local lane reflects the producer`s cascade after fetch', () => { + const localLane = helper.command.catLane('dev'); + expect(localLane.updateDependents[0].split('@')[1]).to.equal(comp2AfterProducerPush); + }); + }); +}); diff --git a/scopes/component/merging/merging.main.runtime.ts b/scopes/component/merging/merging.main.runtime.ts index 99a6847e7365..1e153fb05029 100644 --- a/scopes/component/merging/merging.main.runtime.ts +++ b/scopes/component/merging/merging.main.runtime.ts @@ -424,7 +424,17 @@ export class MergingMain { ); if (this.workspace) { - const compsToWrite = compact(componentsResults.map((c) => c.legacyCompToWrite)); + // Hidden lane updateDependents (skipWorkspace=true) live only on the lane and in the scope. + // Writing them to the workspace would (a) leak internal lane plumbing into bitmap/files, + // and (b) confuse downstream classifiers that key off bitmap-presence (for example, the + // cascade-on-snap detector in version-maker treats "in bitmap" as "workspace tracked", + // which would route the subsequent merge-snap into `lane.components` instead of refreshing + // `lane.updateDependents`). Filter them out here — they participate in the merge through + // the unmergedComponents queue and the snap path, but not through workspace I/O. + const visibleResults = componentsResults.filter( + (c) => !currentLane?.getComponent(c.applyVersionResult.id)?.skipWorkspace + ); + const compsToWrite = compact(visibleResults.map((c) => c.legacyCompToWrite)); const manyComponentsWriterOpts = { consumer: this.workspace.consumer, components: compsToWrite, @@ -473,11 +483,17 @@ export class MergingMain { const addToCurrentLane = (head: Ref) => { if (!currentLane) throw new Error('currentLane must be defined when adding to the lane'); - if (otherLaneId.isDefault()) { - const isPartOfLane = currentLane.components.find((c) => c.id.isEqualWithoutVersion(id)); - if (!isPartOfLane) return; - } - currentLane.addComponent({ id, head }); + const existingOnLane = currentLane.components.find((c) => c.id.isEqualWithoutVersion(id)); + if (otherLaneId.isDefault() && !existingOnLane) return; + // preserve the existing entry's `skipWorkspace` flag so a merge that refreshes a hidden + // updateDependent doesn't accidentally promote it into the workspace-tracked bucket (and + // vice versa). This is how scenario 10 (`_merge-lane main dev`) keeps the cascaded entry + // in `lane.updateDependents` after the merge advances it to main's new head. + currentLane.addComponent({ + id, + head, + ...(existingOnLane?.skipWorkspace && { skipWorkspace: true }), + }); }; const convertHashToTagIfPossible = (componentId: ComponentID): ComponentID => { @@ -684,10 +700,27 @@ export class MergingMain { ); return results; } - return this.snapping.snap({ - legacyBitIds: ids, - build, + // Hidden lane updateDependents (skipWorkspace=true) ride the same `makeVersion` batch as + // visible workspace components. version-maker's `isHiddenLaneEntry` detection (workspace flow: + // not-in-bitmap) routes each entry correctly. workspace.getMany picks up disk-merged files for + // visible; the in-memory merged ConsumerComponents from `applyVersion` are passed through for + // hidden (which have no disk state). Single pipeline → consistent log/buildStatus/ + // flattenedDependencies/lane-history/stagedSnaps for all merge-cascade snaps. + const lane = await this.scope.legacyScope.getCurrentLaneObject(); + const hiddenIds = lane + ? ComponentIdList.fromArray(ids.filter((id) => lane.getComponent(id)?.skipWorkspace)) + : new ComponentIdList(); + const visibleIds = ComponentIdList.fromArray( + ids.filter((id) => !hiddenIds.find((h) => h.isEqualWithoutVersion(id))) + ); + const hiddenLegacyComponents = updatedComponents.filter((c) => + hiddenIds.find((h) => h.isEqualWithoutVersion(c.componentId)) + ); + return this.snapping.snapForMerge({ + visibleIds, + hiddenLegacyComponents, message: snapMessage, + build, loose, }); } diff --git a/scopes/component/snapping/reset-component.ts b/scopes/component/snapping/reset-component.ts index a7430538084c..f346b14e1617 100644 --- a/scopes/component/snapping/reset-component.ts +++ b/scopes/component/snapping/reset-component.ts @@ -147,6 +147,9 @@ export async function getComponentsWithOptionToUntag( ): Promise { const componentList = new ComponentsList(workspace); const laneObj = await workspace.getCurrentLaneObject(); + // The result includes hidden updateDependents — `bit reset` reverts cascade snaps end-to-end. + // The bitmap-update step in `snapping.reset` skips hidden entries explicitly so we don't try + // to write workspace state for components that don't live in the workspace. const components: ModelComponent[] = await componentList.listExportPendingComponents(laneObj); const removedStagedIds = await remove.getRemovedStaged(); if (!removedStagedIds.length) return components; diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 2c788538a5d8..82ac00e12275 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -539,9 +539,15 @@ export class SnappingMain { let exportedIds: ComponentIdList | undefined; if (params.push) { const updatedLane = lane ? await this.scope.legacyScope.loadLane(lane.toLaneId()) : undefined; + // include auto-tagged ids in the export set. For the bare-scope reverse cascade + // (`snapFromScope({ updateDependents: true })`), `getLaneAutoTagIdsFromScope` re-snaps + // lane.components that depend on the new hidden entry, and those new snaps must be pushed + // alongside the explicit target. + const autoTaggedIds = (results.autoTaggedResults || []).map((r) => r.component.id); + const idsToExport = ComponentIdList.uniqFromArray([...ids, ...autoTaggedIds]); const { exported } = await this.exporter.pushToScopes({ scope: this.scope.legacyScope, - ids, + ids: idsToExport, allVersions: false, laneObject: updatedLane, // no need other snaps. only the latest one. without this option, when snapping on lane from another-scope, it @@ -676,6 +682,79 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); } } + /** + * Workspace-side merge snap. Routes both visible workspace components AND hidden lane + * updateDependents (skipWorkspace=true) through the same `makeVersion` pipeline that + * `snap`/`snapFromScope` use, so cascade snaps get fresh log/buildStatus/flattenedDependencies/ + * lane-history/stagedSnaps just like every other snap. + * + * Visible: workspace.getMany picks up files written to disk by `applyVersion`. + * Hidden: applyVersion's in-memory merged ConsumerComponents are passed in directly — they + * have no bitmap entry, so they can't go through `loadComponentsForTagOrSnap`. + * + * version-maker's `isHiddenLaneEntry` detection (workspace-flow: not-in-bitmap) routes the + * hidden ones to the right branches (no bitmap update, stagedSnaps tracking, + * `addToUpdateDependentsInLane`). + */ + async snapForMerge({ + visibleIds, + hiddenLegacyComponents, + message, + build, + loose, + }: { + visibleIds: ComponentIdList; + hiddenLegacyComponents: ConsumerComponent[]; + message?: string; + build?: boolean; + loose?: boolean; + }): Promise<{ + snappedComponents: ConsumerComponent[]; + autoSnappedResults: AutoTagResult[]; + removedComponents?: ComponentIdList; + } | null> { + if (!this.workspace) throw new OutsideWorkspaceError(); + if (!visibleIds.length && !hiddenLegacyComponents.length) return null; + + this.logger.debug(`snapForMerge, visible: ${visibleIds.length}, hidden: ${hiddenLegacyComponents.length}`); + const visibleHarmony = visibleIds.length ? await this.loadComponentsForTagOrSnap(visibleIds) : []; + const hiddenHarmony = hiddenLegacyComponents.length ? await this.scope.getManyByLegacy(hiddenLegacyComponents) : []; + // issue checks are workspace-source-tree concerns — hidden entries are scope-only + if (visibleHarmony.length) await this.throwForVariousIssues(visibleHarmony); + + const hiddenIds = ComponentIdList.fromArray(hiddenLegacyComponents.map((c) => c.componentId)); + const allIds = ComponentIdList.uniqFromArray([...visibleIds, ...hiddenIds]); + const allComponents = [...visibleHarmony, ...hiddenHarmony]; + + const makeVersionParams = { + ignoreNewestVersion: false, + message: message || '', + skipTests: false, + skipAutoTag: false, + persist: true, + soft: false, + build, + isSnap: true, + packageManagerConfigRootDir: this.workspace.path, + loose, + }; + + const { taggedComponents, autoTaggedResults, stagedConfig, removedComponents } = await this.makeVersion( + allIds, + allComponents, + makeVersionParams + ); + + await this.workspace.consumer.onDestroy(`merge-snap (message: ${message || 'N/A'})`); + await stagedConfig?.write(); + + return { + snappedComponents: taggedComponents, + autoSnappedResults: autoTaggedResults, + removedComponents, + }; + } + /** * remove tags/snaps that exist locally, which were not exported yet. * once a tag/snap is exported, it's impossible to delete it as other components may depend on it @@ -738,6 +817,13 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); await pMapSeries(results, async ({ component, versionToSetInBitmap }) => { if (!component) return; + // hidden lane entries (skipWorkspace) are not in the workspace bitmap, so we shouldn't + // try to update bitmap state for them — `removeLocalVersion` already rewound the lane's + // hidden head to its prior cascade hash (or removed the entry entirely if no prior). + // Check the lane's skipWorkspace flag explicitly — a soft-deleted (visible) entry is also + // absent from bitmap but `updateVersions` knows how to restore it from stagedConfig. + const isHiddenLaneEntry = Boolean(currentLane?.getComponent(component.toComponentId())?.skipWorkspace); + if (isHiddenLaneEntry) return; await updateVersions(this.workspace, stagedConfig, currentLaneId, component, versionToSetInBitmap, false); }); await this.workspace.scope.legacyScope.stagedSnaps.write(); diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 3ae22c4b282e..20179054acc1 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -162,19 +162,40 @@ export class VersionMaker { const currentLane = this.consumer?.getCurrentLaneId(); await mapSeries(this.allComponentsToTag, async (component) => { + // hidden lane entries (skipWorkspace) cascade through autotag but must not enter the + // workspace bitmap. Detect via two signals: + // - workspace flow: absence-from-bitmap (cascade autotag loaded the comp from scope) + // - bare-scope flow: the lane already marks the entry as `skipWorkspace` + // Workspace flow that *promotes* a previously-hidden entry (scenario 6 — `bit import` then + // `bit snap`) relies on the workspace having the bitmap entry, so we treat it as visible. + const laneEntry = lane?.getComponent(component.id); + const isHiddenLaneEntry = Boolean( + (this.consumer && !this.consumer.bitMap.getComponentIfExist(component.id, { ignoreVersion: true })) || + (!this.consumer && laneEntry?.skipWorkspace) + ); + // explicit signal to addVersion. Order matters — auto-tag cascade results check the + // existing entry's bucket BEFORE applying the caller-level `updateDependentsOnLane` flag, + // so a bare-scope reverse cascade that auto-tags a *visible* lane.components dependent + // doesn't accidentally move it into the hidden bucket. + // - explicit target of `snapFromScope({ updateDependents: true })` → hidden (caller flag) + // - hidden cascade snap (auto-tagged) → keep hidden, raise override flag + // - workspace component (in bitmap) → promote to visible (the promote-on-import path) + // - auto-tagged visible lane component → keep visible + const isExplicitTarget = this.ids.searchWithoutVersion(component.id) !== undefined; + const addToUpdateDependentsInLane = (updateDependentsOnLane && isExplicitTarget) || isHiddenLaneEntry; const results = await this.snapping._addCompToObjects({ source: component, lane, shouldValidateVersion: Boolean(build), addVersionOpts: { - addToUpdateDependentsInLane: updateDependentsOnLane, + addToUpdateDependentsInLane, setHeadAsParent, detachHead, overrideHead: overrideHead, }, batchId: this.batchId, }); - if (this.workspace) { + if (this.workspace && !isHiddenLaneEntry) { const modelComponent = component.modelComponent || (await this.legacyScope.getModelComponent(component.id)); await updateVersions( this.workspace, @@ -184,9 +205,13 @@ export class VersionMaker { results.addedVersionStr, true ); - } else { - const tagData = this.params.tagDataPerComp?.find((t) => t.componentId.isEqualWithoutVersion(component.id)); - if (tagData?.isNew) results.version.removeAllParents(); + } else if (this.workspace && isHiddenLaneEntry) { + // hidden cascade snaps don't get a bitmap entry, but the new Version still needs to be + // tracked in stagedSnaps so `bit export` includes it when computing the export set and + // sends its objects over the wire. + const modelComponent = component.modelComponent || (await this.legacyScope.getModelComponent(component.id)); + const hash = modelComponent.getRef(results.addedVersionStr); + if (hash) this.workspace.scope.legacyScope.stagedSnaps.addSnap(hash.toString()); } }); @@ -194,7 +219,18 @@ export class VersionMaker { await this.workspace.scope.legacyScope.stagedSnaps.write(); } const publishedPackages: string[] = []; - const harmonyCompsToTag = await (this.workspace || this.scope).getManyByLegacy(this.allComponentsToTag); + // hidden lane entries are scope-only — `workspace.getManyByLegacy` would throw + // MissingBitMapComponent for them. Route the workspace path through visible-only and load + // any hidden cascade entries from scope so the build pipeline still sees them. + const visibleCompsToTag = this.workspace + ? this.allComponentsToTag.filter((c) => this.consumer?.bitMap.getComponentIfExist(c.id, { ignoreVersion: true })) + : this.allComponentsToTag; + const hiddenCompsToTag = this.workspace + ? this.allComponentsToTag.filter((c) => !this.consumer?.bitMap.getComponentIfExist(c.id, { ignoreVersion: true })) + : []; + const harmonyVisibleCompsToTag = await (this.workspace || this.scope).getManyByLegacy(visibleCompsToTag); + const harmonyHiddenCompsToTag = hiddenCompsToTag.length ? await this.scope.getManyByLegacy(hiddenCompsToTag) : []; + const harmonyCompsToTag = [...harmonyVisibleCompsToTag, ...harmonyHiddenCompsToTag]; // this is not necessarily the same as the previous allComponentsToTag. although it should be, because // harmonyCompsToTag is created from allComponentsToTag. however, for aspects, the getMany returns them from cache // and therefore, their instance of ConsumerComponent can be different than the one in allComponentsToTag. @@ -388,24 +424,53 @@ export class VersionMaker { private async getAutoTagData(idsToTag: ComponentIdList): Promise { if (this.params.skipAutoTag) return []; - if (!this.workspace) return this.getLaneAutoTagIdsFromScope(idsToTag); + // hidden lane entries (skipWorkspace: true) are scope-only — they're not in the workspace + // bitmap, so the workspace autotag candidate pool can't see them. Always run the scope-side + // autotag pass alongside the workspace one when on a lane, so cascading a hidden updateDependent + // off a workspace snap (scenario 1) works. + const fromScope = await this.getLaneAutoTagIdsFromScope(idsToTag, /* hiddenOnly */ Boolean(this.workspace)); + if (!this.workspace) return fromScope; // ids without versions are new. it's impossible that tagged (and not-modified) components has - // them as dependencies. - const idsToTriggerAutoTag = idsToTag.filter((id) => id.hasVersion()); + // them as dependencies. Also filter out hidden lane entries — getAutoTagInfo loads + // `[potentialComponents, ...changedComponents]` from the workspace, so a hidden id in + // changedComponents throws MissingBitMapComponent. Hidden cascade is already covered by the + // scope-side `fromScope` pass above. + const consumer = this.workspace.consumer; + const idsToTriggerAutoTag = idsToTag.filter( + (id) => id.hasVersion() && consumer.bitMap.getComponentIfExist(id, { ignoreVersion: true }) + ); const autoTagDataWithLocalOnly = await this.workspace.getAutoTagInfo( ComponentIdList.fromArray(idsToTriggerAutoTag) ); const localOnly = this.workspace?.listLocalOnly(); - return localOnly + const fromWorkspace = localOnly ? autoTagDataWithLocalOnly.filter((autoTagItem) => !localOnly.hasWithoutVersion(autoTagItem.component.id)) : autoTagDataWithLocalOnly; + if (!fromScope.length) return fromWorkspace; + // Dedupe: workspace autotag wins if the same id surfaces in both (the workspace consumer + // component is the authoritative one to snap). + const workspaceIds = new Set(fromWorkspace.map((a) => a.component.id.toStringWithoutVersion())); + const fromScopeFiltered = fromScope.filter((a) => !workspaceIds.has(a.component.id.toStringWithoutVersion())); + return [...fromWorkspace, ...fromScopeFiltered]; } - private async getLaneAutoTagIdsFromScope(idsToTag: ComponentIdList): Promise { + private async getLaneAutoTagIdsFromScope(idsToTag: ComponentIdList, hiddenOnly = false): Promise { const lane = await this.legacyScope.getCurrentLaneObject(); if (!lane) return []; - const laneCompIds = lane.toComponentIds(); - const graphIds = await this.scope.getGraphIds(laneCompIds); + // for the workspace+lane path we only care about hidden entries — workspace autotag handles + // visible ones. for the bare-scope path (no workspace), include all lane entries. + const candidateLaneEntries = hiddenOnly ? lane.components.filter((c) => c.skipWorkspace) : lane.components; + if (!candidateLaneEntries.length) return []; + const laneCompIds = ComponentIdList.fromArray( + candidateLaneEntries.map((c) => c.id.changeVersion(c.head.toString())) + ); + // include `idsToTag` in the graph too. For the bare-scope reverse cascade + // (`snapFromScope({ updateDependents: true })`), the targeted hidden entry is being NEWLY + // introduced to the lane and isn't in candidateLaneEntries yet — without seeding it into + // the graph, predecessors lookup wouldn't surface lane.components that depend on it, and + // the reverse cascade (re-snap visible dependents) wouldn't fire. + const graphSeedIds = ComponentIdList.uniqFromArray([...laneCompIds, ...idsToTag]); + const graphIds = await this.scope.getGraphIds(graphSeedIds); const dependentsMap = idsToTag.reduce( (acc, id) => { const dependents = graphIds.predecessors(id.toString()); diff --git a/scopes/component/status/status-cmd.ts b/scopes/component/status/status-cmd.ts index 8228840b7557..4294424fbdd5 100644 --- a/scopes/component/status/status-cmd.ts +++ b/scopes/component/status/status-cmd.ts @@ -56,6 +56,7 @@ type StatusJsonResults = { currentLaneId: string; forkedLaneId: string | undefined; workspaceIssues: string[]; + pendingUpdateDependents: string[]; }; export class StatusCmd implements Command { @@ -120,6 +121,7 @@ for maximum speed (skips aspect loading entirely), use "bit mini-status".`; forkedLaneId, workspaceIssues, localOnly, + pendingUpdateDependents, }: StatusResult = await this.status.status({ lanes, ignoreCircularDependencies }); return { newComponents: newComponents.map((c) => c.toStringWithoutVersion()), @@ -152,6 +154,7 @@ for maximum speed (skips aspect loading entirely), use "bit mini-status".`; currentLaneId: currentLaneId.toString(), forkedLaneId: forkedLaneId?.toString(), workspaceIssues, + pendingUpdateDependents: pendingUpdateDependents.map((id) => id.toStringWithoutVersion()), }; } diff --git a/scopes/component/status/status-formatter.ts b/scopes/component/status/status-formatter.ts index e8eab3aa2e6d..bd9ad96dea91 100644 --- a/scopes/component/status/status-formatter.ts +++ b/scopes/component/status/status-formatter.ts @@ -49,6 +49,7 @@ export function formatStatusOutput( componentsWithIssues, importPendingComponents, autoTagPendingComponents, + pendingUpdateDependents, invalidComponents, locallySoftRemoved, remotelySoftRemoved, @@ -185,6 +186,14 @@ or use "bit merge [component-id] --abort" (for prior "bit merge" command)`; autoTagPendingComponents.map((c) => format(c)) ); + const pendingUpdateDependentsDesc = + "(impacted dependents that will be pushed on next export — from local cascade or 'Snap updates')"; + const pendingUpdateDependentsOutput = formatSection( + 'pending update-dependents', + pendingUpdateDependentsDesc, + pendingUpdateDependents.map((c) => format(c)) + ); + const componentsWithIssuesToPrint = componentsWithIssues.filter((c) => c.issues.hasTagBlockerIssues() || warnings); const compWithIssuesDesc = '(fix the issues according to the suggested solution)'; const compWithIssuesOutput = formatSection( @@ -304,6 +313,7 @@ use "bit fetch ${forkedLaneId.toString()} --lanes" to update ${forkedLaneId.name modifiedComponentOutput, snappedComponentsOutput, stagedComponentsOutput, + pendingUpdateDependentsOutput, softTaggedComponentsOutput, unavailableOnMainOutput, autoTagPendingOutput, @@ -339,7 +349,8 @@ use "bit fetch ${forkedLaneId.toString()} --lanes" to update ${forkedLaneId.name sections.push({ content: importPendingWarning.trimEnd() }); } - const sectionEntries: Array<{ content: string; autoTag?: boolean }> = [ + type CollapsibleSpec = { ids: ComponentID[]; title: string; desc: string }; + const sectionEntries: Array<{ content: string; collapse?: CollapsibleSpec }> = [ { content: outdatedStr }, { content: pendingMergeStr }, { content: updatesFromMainOutput }, @@ -350,35 +361,44 @@ use "bit fetch ${forkedLaneId.toString()} --lanes" to update ${forkedLaneId.name { content: modifiedComponentOutput }, { content: snappedComponentsOutput }, { content: stagedComponentsOutput }, + { + content: pendingUpdateDependentsOutput, + collapse: { + ids: pendingUpdateDependents, + title: 'pending update-dependents', + desc: pendingUpdateDependentsDesc, + }, + }, { content: softTaggedComponentsOutput }, { content: unavailableOnMainOutput }, - { content: autoTagPendingOutput, autoTag: true }, + { + content: autoTagPendingOutput, + collapse: { ids: autoTagPendingComponents, title: 'components pending auto-tag', desc: autoTagPendingDesc }, + }, { content: compWithIssuesOutput }, { content: invalidComponentOutput }, { content: locallySoftRemovedOutput }, { content: remotelySoftRemovedOutput }, ]; + const buildCollapsibleSummary = ({ ids, title, desc }: CollapsibleSpec): string => { + const scopeCounts = countBy(ids, (id) => id.scope); + const sorted = Object.entries(scopeCounts).sort(([, a], [, b]) => b - a); + const MAX_SHOWN = 4; + const shown = sorted.slice(0, MAX_SHOWN).map(([scope, n]) => `${scope} (${n})`); + const remaining = sorted.length - MAX_SHOWN; + const scopeLine = remaining > 0 ? [...shown, `+ ${remaining} more scopes`].join(' · ') : shown.join(' · '); + const titleLine = formatTitle(`${title} (${ids.length})`); + const descLine = chalk.dim(` ${desc}`); + const scopesLine = ` ${scopeLine}`; + const hint = chalk.dim('— use --expand to list'); + return `${titleLine}\n${descLine}\n${scopesLine} ${hint}`; + }; + for (const entry of sectionEntries) { if (!entry.content) continue; - if (entry.autoTag) { - const count = autoTagPendingComponents.length; - const scopeCounts = countBy(autoTagPendingComponents, (id) => id.scope); - const sorted = Object.entries(scopeCounts).sort(([, a], [, b]) => b - a); - const MAX_SHOWN = 4; - const shown = sorted.slice(0, MAX_SHOWN).map(([scope, n]) => `${scope} (${n})`); - const remaining = sorted.length - MAX_SHOWN; - const scopeLine = remaining > 0 ? [...shown, `+ ${remaining} more scopes`].join(' · ') : shown.join(' · '); - const title = formatTitle(`components pending auto-tag (${count})`); - const desc = chalk.dim(` ${autoTagPendingDesc}`); - const scopes = ` ${scopeLine}`; - const hint = chalk.dim('— use --expand to list'); - sections.push({ - content: entry.content, - collapsible: { - summary: `${title}\n${desc}\n${scopes} ${hint}`, - }, - }); + if (entry.collapse) { + sections.push({ content: entry.content, collapsible: { summary: buildCollapsibleSummary(entry.collapse) } }); } else { sections.push({ content: entry.content }); } diff --git a/scopes/component/status/status.main.runtime.ts b/scopes/component/status/status.main.runtime.ts index 28eb975917af..73fb571271e5 100644 --- a/scopes/component/status/status.main.runtime.ts +++ b/scopes/component/status/status.main.runtime.ts @@ -57,6 +57,9 @@ export type StatusResult = { forkedLaneId?: LaneId; workspaceIssues: string[]; localOnly: ComponentID[]; + // hidden lane updateDependents (`skipWorkspace: true`) that have local snaps pending export. + // Mirror what `bit export` surfaces under the "exported updates" section. + pendingUpdateDependents: ComponentID[]; }; export type MiniStatusResults = { @@ -99,7 +102,24 @@ export class StatusMain { loadOpts )) as ConsumerComponent[]; const modifiedComponents = await this.workspace.modified(loadOpts); - const stagedComponents: ModelComponent[] = await componentsList.listExportPendingComponents(laneObj); + // `listExportPendingComponents` returns every locally-changed pending-export entry, including + // hidden lane updateDependents (`skipWorkspace: true`). Status splits them into two views: + // visible → "staged components" (workspace-tracked); hidden → "pending update-dependents" + // (mirrors `bit export`'s "exported updates" section). Hidden entries don't have a .bitmap + // counterpart, so they shouldn't surface as if they were workspace components. + const allPendingForExport: ModelComponent[] = await componentsList.listExportPendingComponents(laneObj); + // Precompute hidden ids once. Without this each split would call `lane.getComponent()` per + // pending-export entry, and that does a linear scan over `lane.components` — O(N·M) on big lanes. + const hiddenIds = new Set( + (laneObj?.components || []).filter((c) => c.skipWorkspace).map((c) => c.id.toStringWithoutVersion()) + ); + const stagedComponents: ModelComponent[] = []; + const pendingUpdateDependents: ComponentID[] = []; + for (const mc of allPendingForExport) { + const id = mc.toComponentId(); + if (hiddenIds.has(id.toStringWithoutVersion())) pendingUpdateDependents.push(id); + else stagedComponents.push(mc); + } await this.addRemovedStagedIfNeeded(stagedComponents); const stagedComponentsWithVersions = await pMapSeries(stagedComponents, async (stagedComp) => { const id = stagedComp.toComponentId(); @@ -178,6 +198,7 @@ export class StatusMain { forkedLaneId, workspaceIssues: workspaceIssues.map((err) => err.message), localOnly, + pendingUpdateDependents: ComponentID.sortIds(pendingUpdateDependents), }; } diff --git a/scopes/harmony/api-server/api-for-ide.ts b/scopes/harmony/api-server/api-for-ide.ts index 347eb6894cb3..869283403437 100644 --- a/scopes/harmony/api-server/api-for-ide.ts +++ b/scopes/harmony/api-server/api-for-ide.ts @@ -213,12 +213,16 @@ export class APIForIDE { async getCurrentLaneObject(): Promise { const currentLane = await this.lanes.getCurrentLane(); if (!currentLane) return undefined; - const components = currentLane.components.map((c) => { - return { - id: c.id.toStringWithoutVersion(), - head: c.head.toString(), - }; - }); + // hidden (skipWorkspace: true) lane components are not workspace-tracked, so the IDE should + // not surface them alongside the user's edited components. + const components = currentLane.components + .filter((c) => !c.skipWorkspace) + .map((c) => { + return { + id: c.id.toStringWithoutVersion(), + head: c.head.toString(), + }; + }); return { name: currentLane.name, scope: currentLane.scope, diff --git a/scopes/lanes/lanes/lane.cmd.ts b/scopes/lanes/lanes/lane.cmd.ts index 55e446953571..ad5c9b8b4d54 100644 --- a/scopes/lanes/lanes/lane.cmd.ts +++ b/scopes/lanes/lanes/lane.cmd.ts @@ -476,7 +476,10 @@ export class LaneHistoryCmd implements Command { const { historyItem } = data; const date = this.getDateString(historyItem.log.date); const message = historyItem.log.message; - return `${id} ${date} ${historyItem.log.username} ${message}\n\n${historyItem.components.join('\n')}`; + const updateDependentsBlock = historyItem.updateDependents?.length + ? `\n\nupdateDependents:\n${historyItem.updateDependents.join('\n')}` + : ''; + return `${id} ${date} ${historyItem.log.username} ${message}\n\n${historyItem.components.join('\n')}${updateDependentsBlock}`; } const { history, sortedIds } = data; @@ -500,6 +503,7 @@ export class LaneHistoryCmd implements Command { username: historyItem.log.username, message: historyItem.log.message, components: historyItem.components, + ...(historyItem.updateDependents?.length && { updateDependents: historyItem.updateDependents }), }; } @@ -512,6 +516,7 @@ export class LaneHistoryCmd implements Command { username: item.log.username, message: item.log.message, components: item.components, + ...(item.updateDependents?.length && { updateDependents: item.updateDependents }), }; }); } diff --git a/scopes/lanes/lanes/switch-lanes.ts b/scopes/lanes/lanes/switch-lanes.ts index b557cdffcbc3..4cd7812d55d0 100644 --- a/scopes/lanes/lanes/switch-lanes.ts +++ b/scopes/lanes/lanes/switch-lanes.ts @@ -123,7 +123,7 @@ export class LaneSwitcher { this.switchProps.remoteLane = remoteLane; this.laneToSwitchTo = remoteLane; this.logger.debug(`populatePropsAccordingToRemoteLane, completed`); - return remoteLane.components.map((l) => l.id.changeVersion(l.head.toString())); + return [...remoteLane.toComponentIds()]; } private async populatePropsAccordingToDefaultLane() { @@ -135,7 +135,7 @@ export class LaneSwitcher { this.laneIdToSwitchTo = localLane.toLaneId(); this.laneToSwitchTo = localLane; this.throwForSwitchingToCurrentLane(); - return localLane.components.map((c) => c.id.changeVersion(c.head.toString())); + return [...localLane.toComponentIds()]; } private throwForSwitchingToCurrentLane() { diff --git a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts index d90c37008d84..4508bd11b948 100644 --- a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts +++ b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts @@ -93,6 +93,14 @@ export class MergeLanesMain { } const currentLaneId = this.workspace.consumer.getCurrentLaneId(); const otherLaneId = await this.workspace.consumer.getParsedLaneId(laneName); + // Hidden lane updateDependents must participate in every merge — otherwise main→lane refresh + // (`bit lane merge main`) leaves the lane's cascaded entries stuck on their old main-head + // base, and lane→main merge would push a partially-consistent lane state. The bare-scope + // counterpart (`bit _merge-lane`, used by Ripple / the UI's "update lane" button) already + // sets this; the workspace path was the missing leg. + if (options.shouldIncludeUpdateDependents === undefined) { + options.shouldIncludeUpdateDependents = true; + } return this.mergeLane(otherLaneId, currentLaneId, options); } @@ -312,7 +320,10 @@ export class MergeLanesMain { const isDefaultLane = otherLaneId.isDefault(); if (isDefaultLane) { if (!skipFetch) { - const ids = await this.getMainIdsToMerge(currentLane, !excludeNonLaneComps); + // pass `shouldIncludeUpdateDependents` so the prefetch covers main objects for the + // lane's hidden entries too — the per-component merge engine needs main-side Version + // objects locally to compute divergence against the hidden cascade snaps. + const ids = await this.getMainIdsToMerge(currentLane, !excludeNonLaneComps, shouldIncludeUpdateDependents); const compIdList = ComponentIdList.fromArray(ids).toVersionLatest(); await this.importer.importObjectsFromMainIfExist(compIdList); } diff --git a/scopes/scope/export/export-cmd.ts b/scopes/scope/export/export-cmd.ts index 520f3d4da945..d2a2a19c01fe 100644 --- a/scopes/scope/export/export-cmd.ts +++ b/scopes/scope/export/export-cmd.ts @@ -5,6 +5,7 @@ import { ejectTemplate } from '@teambit/eject'; import { COMPONENT_PATTERN_HELP } from '@teambit/legacy.constants'; import chalk from 'chalk'; import { isEmpty } from 'lodash'; +import type { ComponentID } from '@teambit/component-id'; import type { ExportMain, ExportResult } from './export.main.runtime'; export class ExportCmd implements Command { @@ -111,15 +112,38 @@ exporting is the final step after development and versioning to share components return formatSection('exported lane', '', [formatItem(chalk.bold(exportedLane))]); } const lanesOutput = exportedLanes.length ? ` the lane ${chalk.bold(exportedLanes[0].id())} and` : ''; - const items = componentsIds.map((id) => { + // Split exported ids into "regular lane components" vs "updates" — match the UI's + // terminology (the 'Snap updates' button surfaces lane.updateDependents as updates rather + // than top-level components). Hidden cascade snaps land in the 'exported updates' section + // so users aren't told they exported components they don't have in the workspace. + // Precompute a Set keyed by `toStringWithoutVersion()` so the per-id classification is + // O(1) instead of an O(N·M) linear scan over `laneUpdateIds`. + const laneUpdateKeys = new Set((exportedLanes[0]?.updateDependents || []).map((u) => u.toStringWithoutVersion())); + const isUpdate = (id: ComponentID) => laneUpdateKeys.has(id.toStringWithoutVersion()); + const renderItem = (id: ComponentID) => { if (!verbose) return formatItem(chalk.bold(id.toString())); const versions = newIdsOnRemote .filter((newId) => newId.isEqualWithoutVersion(id)) .map((newId) => newId.version); return formatItem(`${chalk.bold(id.toString())} - ${versions.join(', ') || 'n/a'}`); - }); - const desc = `exported${lanesOutput} the following component(s)`; - return formatSection('exported components', desc, items); + }; + const regularIds = componentsIds.filter((id) => !isUpdate(id)); + const updateIds = componentsIds.filter(isUpdate); + const componentsPart = regularIds.length + ? formatSection( + 'exported components', + `exported${lanesOutput} the following component(s)`, + regularIds.map(renderItem) + ) + : ''; + const updatesPart = updateIds.length + ? formatSection( + 'exported updates', + "impacted dependents pushed to keep the lane consistent (from a 'Snap updates' / local cascade)", + updateIds.map(renderItem) + ) + : ''; + return [componentsPart, updatesPart].filter(Boolean).join('\n'); })(); const nonExistOnBitMapSection = (() => { diff --git a/scopes/scope/export/export.main.runtime.ts b/scopes/scope/export/export.main.runtime.ts index 4a7cbb22dcf5..0927c22bc241 100644 --- a/scopes/scope/export/export.main.runtime.ts +++ b/scopes/scope/export/export.main.runtime.ts @@ -238,8 +238,18 @@ if the export fails with missing objects/versions/components, run "bit fetch --l if (laneObject) await updateLanesAfterExport(consumer, laneObject); const removedIds = await this.getRemovedStagedBitIds(); const workspaceIds = this.workspace.listIds(); + // Hidden lane updateDependents (skipWorkspace=true entries) are intentionally not tracked in + // the workspace bitmap — they exist only to re-align the lane with its dependencies during + // cascade. Excluding them here suppresses the misleading "component files are not tracked" + // warning that would otherwise fire on every export carrying updateDependents. + const laneUpdateDependents = laneObject?.updateDependents + ? ComponentIdList.fromArray(laneObject.updateDependents) + : undefined; const nonExistOnBitMap = exported.filter( - (id) => !workspaceIds.hasWithoutVersion(id) && !removedIds.hasWithoutVersion(id) + (id) => + !workspaceIds.hasWithoutVersion(id) && + !removedIds.hasWithoutVersion(id) && + !laneUpdateDependents?.hasWithoutVersion(id) ); const updatedIds = _updateIdsOnBitMap(consumer.bitMap, updatedLocally); // re-generate the package.json, this way, it has the correct data in the componentId prop. diff --git a/scopes/scope/importer/import-components.ts b/scopes/scope/importer/import-components.ts index 03bee3b95b17..a8f2953bf3d6 100644 --- a/scopes/scope/importer/import-components.ts +++ b/scopes/scope/importer/import-components.ts @@ -549,7 +549,12 @@ if you just want to get a quick look into this snap, create a new workspace and if (!this.options.lanes) { throw new Error(`getBitIdsForLanes: this.options.lanes must be set`); } - const remoteLaneIds = this.remoteLane?.toComponentIds() || new ComponentIdList(); + // include hidden (skipWorkspace) entries — `importObjectsOnLane` runs with `objectsOnly=true` + // and is the workspace's `bit fetch --lanes` fetch path. Hidden entries' Version objects must + // be present locally so subsequent merge/diverge checks can resolve their lane heads. + // (Hidden entries don't enter the workspace bitmap regardless — the bitmap-write step is + // separate and filters them out by `skipWorkspace`.) + const remoteLaneIds = this.remoteLane?.toComponentIdsIncludeUpdateDependents() || new ComponentIdList(); if (!this.options.ids.length) { const bitMapIds = this.consumer.bitMap.getAllBitIds(); diff --git a/scopes/scope/importer/importer.main.runtime.ts b/scopes/scope/importer/importer.main.runtime.ts index ed636695a72a..cf7924cf9eea 100644 --- a/scopes/scope/importer/importer.main.runtime.ts +++ b/scopes/scope/importer/importer.main.runtime.ts @@ -140,7 +140,11 @@ export class ImporterMain { * once done, merge the lane object and save it as well. */ async fetchLaneComponents(lane: Lane, includeUpdateDependents = false) { - const ids = includeUpdateDependents ? lane.toComponentIdsIncludeUpdateDependents() : lane.toComponentIds(); + // hidden (skipWorkspace) entries are part of the lane's graph and the merge engine needs + // their Version objects available locally to do per-component diverge checks. We always + // fetch the full set; the `includeUpdateDependents` flag now only controls server-side + // semantics around the wire-format `updateDependents` array, not whether to fetch them. + const ids = lane.toComponentIdsIncludeUpdateDependents(); await this.scope.legacyScope.scopeImporter.importMany({ ids, lane, @@ -259,8 +263,12 @@ export class ImporterMain { private async fetchLanesUsingScope(lanes: Lane[]): Promise { const resultsPerLane = await pMapSeries(lanes, async (lane) => { this.logger.setStatusLine(`fetching lane ${lane.name}`); + // include hidden (skipWorkspace) entries — bare-scope consumers (Ripple CI cascade producer, + // GC, anything calling `bit fetch / --lanes`) need their Version objects + // locally to operate on the lane. Without them, the lane object references heads whose + // Version objects are missing — `snapFromScope`, merge, and `getDivergeData` blow up. const importResults = await this.scope.legacyScope.scopeImporter.importMany({ - ids: lane.toComponentIds(), + ids: lane.toComponentIdsIncludeUpdateDependents(), lane, reason: `for fetching lane ${lane.id()}`, }); diff --git a/scopes/scope/objects/models/lane-history.ts b/scopes/scope/objects/models/lane-history.ts index ad50b1c36bb8..69ade317865c 100644 --- a/scopes/scope/objects/models/lane-history.ts +++ b/scopes/scope/objects/models/lane-history.ts @@ -10,6 +10,11 @@ export type HistoryItem = { log: Log; components: string[]; deleted?: string[]; + // hidden lane entries (`skipWorkspace: true`) at the time of the snapshot. Recorded in their + // own field so `historyItem.components` keeps its workspace-checkout/revert contract intact — + // those flows must not materialize hidden entries into the bitmap. `lane checkout/revert` use + // this list to rewind `lane.updateDependents` directly on the lane object. + updateDependents?: string[]; }; type History = { [uuid: string]: HistoryItem }; @@ -83,7 +88,16 @@ export class LaneHistory extends BitObject { const deleted = laneObj.components .filter((c) => c.isDeleted) .map((c) => c.id.changeVersion(c.head.toString()).toString()); - this.history[historyKey || v4()] = { log, components, ...(deleted.length && { deleted }) }; + // Always write `updateDependents` (even when empty) so checkout/revert can distinguish a + // post-PR entry that legitimately had no hidden entries (drop current hidden) from a legacy + // pre-PR entry that never recorded the field at all (leave current hidden alone). + const updateDependents = (laneObj.updateDependents || []).map((id) => id.toString()); + this.history[historyKey || v4()] = { + log, + components, + ...(deleted.length && { deleted }), + updateDependents, + }; } removeHistoryEntries(keys: string[]) { diff --git a/scopes/scope/objects/models/lane.ts b/scopes/scope/objects/models/lane.ts index 8e8c6bc39d7f..72f7a06daaa3 100644 --- a/scopes/scope/objects/models/lane.ts +++ b/scopes/scope/objects/models/lane.ts @@ -23,12 +23,15 @@ export type LaneProps = { name: string; scope: string; log: Log; + // hidden lane entries (formerly the separate `updateDependents` array) are part of `components` + // with `skipWorkspace: true`. There is no separate `updateDependents` field on `LaneProps` — + // `Lane.parse` hoists the wire-format `updateDependents` into `components` before constructing. components?: LaneComponent[]; hash: string; schema?: string; readmeComponent?: LaneReadmeComponent; forkedFrom?: LaneId; - updateDependents?: ComponentID[]; + /** @deprecated kept on the wire so older servers still accept hidden-entry updates from this client. Remove after the rollout window. */ overrideUpdateDependents?: boolean; }; @@ -36,7 +39,15 @@ const OLD_LANE_SCHEMA = '0.0.0'; const SCHEMA_INCLUDING_DELETED_COMPONENTS_DATA = '1.0.0'; const CURRENT_LANE_SCHEMA = SCHEMA_INCLUDING_DELETED_COMPONENTS_DATA; -export type LaneComponent = { id: ComponentID; head: Ref; isDeleted?: boolean }; +/** + * `skipWorkspace: true` marks a component that participates in the lane's graph (Ripple CI builds + * it, merges refresh it) but is hidden from workspace-facing flows (`bit status`, `bit compile`, + * `bit install`, the bitmap). On the wire and on disk, these entries live in the separate + * `updateDependents` array for backward compatibility with older clients; in-memory they are + * hoisted into `components` so every per-component machinery (autotag, 3-way merge, reset, + * garbage collection) operates on one unified list instead of branching on "regular vs. hidden". + */ +export type LaneComponent = { id: ComponentID; head: Ref; isDeleted?: boolean; skipWorkspace?: boolean }; export type LaneReadmeComponent = { id: ComponentID; head: Ref | null }; export default class Lane extends BitObject { name: string; @@ -49,13 +60,7 @@ export default class Lane extends BitObject { _hash: string; // reason for the underscore prefix is that we already have hash as a method isNew = false; // doesn't get saved in the object. only needed for in-memory instance hasChanged = false; // doesn't get saved in the object. only needed for in-memory instance - /** - * populated when a user clicks on "update" in the UI. it's a list of components that are dependents on the - * components in the lane. their dependencies are updated according to the lane. - * from the CLI perspective, it's added by "bit _snap" and merged by "bit _merge-lane". - * otherwise, the user is not aware of it. it's not imported to the workspace and the objects are not fetched. - */ - updateDependents?: ComponentID[]; + /** @deprecated wire-format compat shim for older servers — no merge-path reader in this codebase. Remove after the rollout window. */ private overrideUpdateDependents?: boolean; constructor(props: LaneProps) { super(); @@ -68,9 +73,55 @@ export default class Lane extends BitObject { this.readmeComponent = props.readmeComponent; this.forkedFrom = props.forkedFrom; this.schema = props.schema || OLD_LANE_SCHEMA; - this.updateDependents = props.updateDependents; this.overrideUpdateDependents = props.overrideUpdateDependents; } + /** @deprecated wire-format compat shim — set by hidden-entry writers so older servers' export-merge branch still updates their hidden bucket. Remove after the rollout window. */ + setOverrideUpdateDependents(overrideUpdateDependents: boolean) { + this.overrideUpdateDependents = overrideUpdateDependents; + this.hasChanged = true; + } + /** + * Components that live only in the lane's graph (Ripple CI / merge / GC) but are hidden from + * workspace-facing flows. Kept as a derived view over `components` for source-compat with + * callers that read or assign to `lane.updateDependents` directly. + */ + get updateDependents(): ComponentID[] | undefined { + const hidden = this.components.filter((c) => c.skipWorkspace); + if (!hidden.length) return undefined; + return hidden.map((c) => c.id.changeVersion(c.head.toString())); + } + set updateDependents(next: ComponentID[] | undefined) { + const currentHidden = this.components + .filter((c) => c.skipWorkspace) + .map((c) => c.id.changeVersion(c.head.toString()).toString()) + .sort(); + const nextHidden = (next || []).map((id) => { + if (!id.hasVersion()) { + throw new ValidationError(`Lane.updateDependents: component "${id.toString()}" is missing a version`); + } + return id.toString(); + }); + const nextHiddenSorted = [...nextHidden].sort(); + if (isEqual(currentHidden, nextHiddenSorted)) return; + // drop every existing hidden entry, then add the replacement set. Also drop any *visible* + // entry whose id collides with an incoming hidden id — this handles a remote-merge bucket + // flip (visible → hidden) without leaving two entries for the same component, which would + // violate the no-duplicates invariant in `Lane.validate()`. + const nextIdsWithoutVersion = new Set((next || []).map((id) => id.toStringWithoutVersion())); + this.components = this.components.filter( + (c) => !c.skipWorkspace && !nextIdsWithoutVersion.has(c.id.toStringWithoutVersion()) + ); + if (next?.length) { + for (const id of next) { + this.components.push({ + id: id.changeVersion(undefined), + head: Ref.from(id.version as string), + skipWorkspace: true, + }); + } + } + this.hasChanged = true; + } id(): string { return this.scope + LANE_REMOTE_DELIMITER + this.name; } @@ -97,11 +148,18 @@ export default class Lane extends BitObject { lane.validate(); } toObject() { + // split the unified components list at the wire boundary so older clients (which only know + // the separate `components` / `updateDependents` arrays) keep round-tripping cleanly. + const visibleComponents = this.components.filter((c) => !c.skipWorkspace); + const hiddenComponents = this.components.filter((c) => c.skipWorkspace); + const updateDependents = hiddenComponents.length + ? hiddenComponents.map((c) => c.id.changeVersion(c.head.toString()).toString()) + : undefined; const obj = pickBy( { name: this.name, scope: this.scope, - components: this.components.map((component) => ({ + components: visibleComponents.map((component) => ({ id: { scope: component.id.scope, name: component.id.fullName }, head: component.head.toString(), ...(component.isDeleted && { isDeleted: component.isDeleted }), @@ -113,8 +171,12 @@ export default class Lane extends BitObject { }, forkedFrom: this.forkedFrom && this.forkedFrom.toObject(), schema: this.schema, - updateDependents: this.updateDependents?.map((c) => c.toString()), - overrideUpdateDependents: this.overrideUpdateDependents, + updateDependents, + // @deprecated kept for older servers' merge gating; remove after the rollout window. + // Only emit when hidden entries are actually present — emitting it alongside + // `updateDependents: undefined` would let an older server interpret the payload as + // "authoritatively clear updateDependents" and wipe its remote hidden bucket. + overrideUpdateDependents: updateDependents ? this.overrideUpdateDependents : undefined, }, (val) => !!val ); @@ -146,15 +208,30 @@ export default class Lane extends BitObject { } static parse(contents: string, hash: string): Lane { const laneObject = JSON.parse(contents); + const visibleComponents: LaneComponent[] = laneObject.components.map((component) => ({ + id: ComponentID.fromObject({ scope: component.id.scope, name: component.id.name }), + head: new Ref(component.head), + isDeleted: component.isDeleted, + })); + // hoist wire-format `updateDependents` into the unified components list with + // `skipWorkspace: true`. Old clients on the other side of the wire still see the separate + // `updateDependents` array thanks to the reverse demote in `toObject()`. + const hiddenComponents: LaneComponent[] = (laneObject.updateDependents || []).map((raw: string) => { + const compId = ComponentID.fromString(raw); + if (!compId.hasVersion()) { + throw new ValidationError(`Lane.parse: updateDependents entry ${raw} is missing a version`); + } + return { + id: compId.changeVersion(undefined), + head: Ref.from(compId.version as string), + skipWorkspace: true, + }; + }); return Lane.from({ name: laneObject.name, scope: laneObject.scope, log: laneObject.log, - components: laneObject.components.map((component) => ({ - id: ComponentID.fromObject({ scope: component.id.scope, name: component.id.name }), - head: new Ref(component.head), - isDeleted: component.isDeleted, - })), + components: [...visibleComponents, ...hiddenComponents], readmeComponent: laneObject.readmeComponent && { id: ComponentID.fromObject({ scope: laneObject.readmeComponent.id.scope, @@ -163,7 +240,7 @@ export default class Lane extends BitObject { head: laneObject.readmeComponent.head && new Ref(laneObject.readmeComponent.head), }, forkedFrom: laneObject.forkedFrom && LaneId.from(laneObject.forkedFrom.name, laneObject.forkedFrom.scope), - updateDependents: laneObject.updateDependents?.map((c) => ComponentID.fromString(c)), + // @deprecated wire-format compat shim — preserve through round-trips. Remove after the rollout window. overrideUpdateDependents: laneObject.overrideUpdateDependents, hash: laneObject.hash || hash, schema: laneObject.schema, @@ -179,10 +256,20 @@ export default class Lane extends BitObject { addComponent(component: LaneComponent) { const existsComponent = this.getComponent(component.id); if (existsComponent) { - if (!existsComponent.head.isEqual(component.head)) this.hasChanged = true; + // note: `skipWorkspace` follows the incoming value (including undefined). That's how + // scenario 6 "promote-on-import" works — a hidden entry being re-added without the flag + // flips to a visible first-class lane component without a separate move operation. + if ( + !existsComponent.head.isEqual(component.head) || + existsComponent.skipWorkspace !== component.skipWorkspace || + Boolean(existsComponent.isDeleted) !== Boolean(component.isDeleted) + ) { + this.hasChanged = true; + } existsComponent.id = component.id; existsComponent.head = component.head; existsComponent.isDeleted = component.isDeleted; + existsComponent.skipWorkspace = component.skipWorkspace; } else { logger.debug(`Lane.addComponent, adding component ${component.id.toString()} to lane ${this.id()}`); this.components.push(component); @@ -190,36 +277,28 @@ export default class Lane extends BitObject { } } removeComponentFromUpdateDependentsIfExist(componentId: ComponentID) { - const updateDependentsList = ComponentIdList.fromArray(this.updateDependents || []); - const exist = updateDependentsList.searchWithoutVersion(componentId); - if (!exist) return; - this.updateDependents = updateDependentsList.removeIfExist(exist); - if (!this.updateDependents.length) this.updateDependents = undefined; - this.hasChanged = true; + const before = this.components.length; + this.components = this.components.filter((c) => !(c.skipWorkspace && c.id.isEqualWithoutVersion(componentId))); + if (this.components.length !== before) this.hasChanged = true; } addComponentToUpdateDependents(componentId: ComponentID) { - this.removeComponentFromUpdateDependentsIfExist(componentId); - (this.updateDependents ||= []).push(componentId); + if (!componentId.hasVersion()) { + throw new ValidationError(`Lane.addComponentToUpdateDependents: ${componentId.toString()} is missing a version`); + } + // replace any existing entry (hidden or visible) for this id so we never land with two + // entries for the same component, regardless of which bucket it was previously in. + this.components = this.components.filter((c) => !c.id.isEqualWithoutVersion(componentId)); + this.components.push({ + id: componentId.changeVersion(undefined), + head: Ref.from(componentId.version as string), + skipWorkspace: true, + }); this.hasChanged = true; } removeAllUpdateDependents() { - if (this.updateDependents?.length) return; - this.updateDependents = undefined; - this.hasChanged = true; - } - shouldOverrideUpdateDependents() { - return this.overrideUpdateDependents; - } - /** - * !!! important !!! - * this should get called only on a "temp lane", such as running "bit _snap", which the scope gets destroys after the - * command is done. when _scope exports the lane, this "overrideUpdateDependents" is not saved to the remote-scope. - * - * on a user local lane object, this prop should never be true. otherwise, it'll override the remote-scope data. - */ - setOverrideUpdateDependents(overrideUpdateDependents: boolean) { - this.overrideUpdateDependents = overrideUpdateDependents; - this.hasChanged = true; + const before = this.components.length; + this.components = this.components.filter((c) => !c.skipWorkspace); + if (this.components.length !== before) this.hasChanged = true; } removeComponent(id: ComponentID): boolean { @@ -240,7 +319,12 @@ export default class Lane extends BitObject { setLaneComponents(laneComponents: LaneComponent[]) { // this gets called when adding lane-components from other lanes/remotes, so it's better to // clone the objects to not change the original data. - this.components = laneComponents.map((c) => ({ id: c.id.clone(), head: c.head.clone() })); + this.components = laneComponents.map((c) => ({ + id: c.id.clone(), + head: c.head.clone(), + ...(c.isDeleted && { isDeleted: c.isDeleted }), + ...(c.skipWorkspace && { skipWorkspace: c.skipWorkspace }), + })); this.hasChanged = true; } setReadmeComponent(id?: ComponentID) { @@ -293,11 +377,18 @@ export default class Lane extends BitObject { toBitIds(): ComponentIdList { return this.toComponentIds(); } + /** + * Returns only visible (non-skipWorkspace) components — the workspace-facing view. + * Callers that need every entry in the lane's graph (Ripple CI build set, garbage collector, + * merge engine) should use {@link toComponentIdsIncludeUpdateDependents} instead. + */ toComponentIds(): ComponentIdList { - return ComponentIdList.fromArray(this.components.map((c) => c.id.changeVersion(c.head.toString()))); + return ComponentIdList.fromArray( + this.components.filter((c) => !c.skipWorkspace).map((c) => c.id.changeVersion(c.head.toString())) + ); } toComponentIdsIncludeUpdateDependents(): ComponentIdList { - return ComponentIdList.fromArray(this.toComponentIds().concat(this.updateDependents || [])); + return ComponentIdList.fromArray(this.components.map((c) => c.id.changeVersion(c.head.toString()))); } toLaneId() { return new LaneId({ scope: this.scope, name: this.name }); @@ -323,17 +414,18 @@ export default class Lane extends BitObject { this.hasChanged = true; } getCompHeadIncludeUpdateDependents(componentId: ComponentID): Ref | undefined { - const comp = this.getComponent(componentId); - if (comp) return comp.head; - const fromUpdateDependents = this.updateDependents?.find((c) => c.isEqualWithoutVersion(componentId)); - if (fromUpdateDependents) return Ref.from(fromUpdateDependents.version); - return undefined; + // `getComponent` scans the unified `components` list, which already contains hidden entries + // (formerly `updateDependents`), so the dual lookup collapses into a single call. + return this.getComponent(componentId)?.head; } validate() { const message = `unable to save Lane object "${this.id()}"`; - const bitIds = this.toComponentIds(); + // validate over ALL components including hidden ones — a duplicate id across the visible and + // hidden buckets is still an invariant violation (the wire format serializes them separately, + // but the in-memory unified list must not carry the same id twice). + const allBitIds = this.toComponentIdsIncludeUpdateDependents(); this.components.forEach((component) => { - if (bitIds.filterWithoutVersion(component.id).length > 1) { + if (allBitIds.filterWithoutVersion(component.id).length > 1) { throw new ValidationError(`${message}, the following component is duplicated "${component.id.fullName}"`); } if (!isSnap(component.head.hash)) { @@ -353,14 +445,32 @@ export default class Lane extends BitObject { } isEqual(lane: Lane): boolean { if (this.id() !== lane.id()) return false; - const thisComponents = this.toComponentIds().toStringArray().sort(); - const otherComponents = lane.toComponentIds().toStringArray().sort(); - return isEqual(thisComponents, otherComponents); + // include every per-component bit that affects the wire format (id, head, skipWorkspace, + // isDeleted), not just id+head. The three real callers (`importer.fetchLaneComponents`, + // `importer.fetchLanesUsingScope`, `import-components`) use this to decide whether to write + // a LaneHistory entry. A bucket flip (skipWorkspace) or a soft-delete flip with the same + // head is still a meaningful state change — a different `toObject()` payload — so it must + // trigger the history write. Sort by a stable key so order doesn't affect equality. + const normalize = (l: Lane) => + l.components + .map((c) => ({ + id: c.id.toStringWithoutVersion(), + head: c.head.toString(), + skipWorkspace: Boolean(c.skipWorkspace), + isDeleted: Boolean(c.isDeleted), + })) + .sort((a, b) => + `${a.id}@${a.head}:${a.skipWorkspace ? 1 : 0}:${a.isDeleted ? 1 : 0}`.localeCompare( + `${b.id}@${b.head}:${b.skipWorkspace ? 1 : 0}:${b.isDeleted ? 1 : 0}` + ) + ); + return isEqual(normalize(this), normalize(lane)); } clone() { return new Lane({ ...this, hash: this._hash, + // @deprecated preserve compat shim through clone. Remove after the rollout window. overrideUpdateDependents: this.overrideUpdateDependents, components: cloneDeep(this.components), }); diff --git a/scopes/scope/objects/models/model-component.ts b/scopes/scope/objects/models/model-component.ts index a3bba530d480..4489d4d59113 100644 --- a/scopes/scope/objects/models/model-component.ts +++ b/scopes/scope/objects/models/model-component.ts @@ -704,11 +704,26 @@ export default class Component extends BitObject { if (parent && !parent.isEqual(versionToAddRef)) { version.addAsOnlyParent(parent); } - if (addToUpdateDependentsInLane) { - lane.addComponentToUpdateDependents(currentBitId.changeVersion(versionToAddRef.toString())); + // when the caller didn't explicitly opt in or out, preserve the existing entry's + // skipWorkspace state. This is what makes scenario 10 work via the unified architecture: + // a merge-from-main that produces a new snap for a hidden updateDependent must keep that + // entry hidden, not promote it into workspace-tracked state. Workspace-snap producers that + // want to PROMOTE a previously-hidden entry (scenario 6) need to pass + // `addToUpdateDependentsInLane: false` explicitly — they know they're acting on a workspace + // comp. + const existingEntry = lane.getComponent(currentBitId); + const shouldBeHidden = addToUpdateDependentsInLane ?? existingEntry?.skipWorkspace ?? false; + lane.addComponent({ + id: currentBitId, + head: versionToAddRef, + isDeleted: version.isRemoved(), + ...(shouldBeHidden && { skipWorkspace: true }), + }); + if (shouldBeHidden) { + // @deprecated wire-format compat shim — older servers gate their export-merge hidden-update + // branch on this flag. Without it, our cascade pushes wouldn't propagate to a remote that + // hasn't yet upgraded to the unified diverge-check path. Remove after the rollout window. lane.setOverrideUpdateDependents(true); - } else { - lane.addComponent({ id: currentBitId, head: versionToAddRef, isDeleted: version.isRemoved() }); } if (lane.readmeComponent && lane.readmeComponent.id.fullName === currentBitId.fullName) { diff --git a/scopes/scope/version-history/version-history.main.runtime.ts b/scopes/scope/version-history/version-history.main.runtime.ts index f5fd017324a5..7f6578f2e2ff 100644 --- a/scopes/scope/version-history/version-history.main.runtime.ts +++ b/scopes/scope/version-history/version-history.main.runtime.ts @@ -70,7 +70,9 @@ export class VersionHistoryMain { if (options.fromAllLanes) { const lanes = await this.scope.legacyScope.lanes.listLanes(); for await (const lane of lanes) { - const headOnLane = lane.getComponentHead(id); + // include hidden updateDependent entries — a component's version history should cover + // every lane that has a head for it, regardless of which bucket it lives in on the lane. + const headOnLane = lane.getCompHeadIncludeUpdateDependents(id); if (!headOnLane) continue; const laneResults = await modelComponent.populateVersionHistoryIfMissingGracefully( repo,