diff --git a/components/legacy/component-list/components-list.ts b/components/legacy/component-list/components-list.ts index 25c8b0e014fe..81fd3eede3c1 100644 --- a/components/legacy/component-list/components-list.ts +++ b/components/legacy/component-list/components-list.ts @@ -152,22 +152,41 @@ 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. + * + * @param includeHiddenLaneEntries when true, lane components with `skipWorkspace: true` (cascade + * updateDependents) are included if they have local snaps. Default `false` keeps the + * workspace-staged view used by `bit status` free of internal lane plumbing — those entries + * don't live in `.bitmap` and surfacing them as if they were workspace components confuses the + * "staged components" output. Export and reset opt in (`true`) for different reasons: + * - export: the cascade snap's Version object must land in the export bundle, otherwise the + * lane object would reference a Version the remote doesn't have. + * - reset: the cascade snap itself must be reverted end-to-end (cascade spec scenario 8); 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. */ - async listExportPendingComponentsIds(lane?: Lane | null): Promise { + async listExportPendingComponentsIds( + lane?: Lane | null, + { includeHiddenLaneEntries = false }: { includeHiddenLaneEntries?: boolean } = {} + ): Promise { const fromBitMap = this.bitMap.getAllIdsAvailableOnLaneIncludeRemoved(); const modelComponents = await this.getModelComponents(); 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` lane component (cascade-on-snap, bare-scope + // `_snap --update-dependents`) that exists only in scope+lane, never the workspace + const laneEntry = lane?.getComponent(component.toComponentId()); + if (laneEntry?.skipWorkspace) { + if (!includeHiddenLaneEntries) return false; + return component.isLocallyChanged(this.scope.objects, lane); + } + if (lane && laneEntry) { + return component.isLocallyChanged(this.scope.objects, lane); + } return component.isLocallyChangedRegardlessOfLanes(); } return component.isLocallyChanged(this.scope.objects, lane, foundInBitMap); @@ -201,8 +220,11 @@ export class ComponentsList { return ComponentIdList.fromArray(updatedIds); } - async listExportPendingComponents(laneObj?: Lane): Promise { - const exportPendingComponentsIds: ComponentIdList = await this.listExportPendingComponentsIds(laneObj); + async listExportPendingComponents( + laneObj?: Lane, + options?: { includeHiddenLaneEntries?: boolean } + ): Promise { + const exportPendingComponentsIds: ComponentIdList = await this.listExportPendingComponentsIds(laneObj, options); return Promise.all(exportPendingComponentsIds.map((id) => this.scope.getModelComponent(id))); } 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..4fe7a6f57c2c 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; } @@ -720,8 +727,15 @@ possible causes: mergeResults.push({ mergedComponent: modelComponent, mergedVersions: [] }); }; + // hidden (`skipWorkspace: true`) lane components go through the dedicated updateDependents + // merge branch below, not the per-component loop. On import, the legacy semantic is "remote is + // authoritative for hidden entries"; on export, the `overrideUpdateDependents` wire signal + // governs when the client's hidden entries replace the server's. Rewiring hidden entries into + // the full per-component diverge-check would need matching cascade mechanics in this layer too, + // which is outside the foundation-only scope. + const visibleIncomingComponents = lane.components.filter((c) => !c.skipWorkspace); await pMap( - lane.components, + visibleIncomingComponents, async (component) => { await mergeLaneComponent(component); }, @@ -733,27 +747,47 @@ possible causes: 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) { + // hidden (skipWorkspace) entries are merged here via the legacy `updateDependents`-shaped + // override/wire path. New flows that produce hidden entries (workspace cascade-on-snap, the + // bare-scope `_snap --update-dependents`) set `overrideUpdateDependents=true` to claim the + // local list as authoritative; we honor it on export and protect it from being clobbered on + // import. + if (isImport && existingLane && !existingLane.shouldOverrideUpdateDependents()) { 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)); + // Cache both sides outside the loop. With the unified-components getter, each + // `lane.updateDependents` access recomputes (filter + map) the hidden slice; without + // caching, an export with N incoming hidden entries against an existing lane with M + // recomputes the existing array N times for an O(N·M²) lookup. Snapshot once → O(N·M). + const incomingHidden = lane.updateDependents || []; + const existingHidden = existingLane.updateDependents || []; + const existingByName = new Map(existingHidden.map((id) => [id.toStringWithoutVersion(), id])); + // Bound concurrency — each task may load a ModelComponent from storage. With many incoming + // hidden entries, an unbounded `Promise.all` could spike I/O during a large export. Match + // the per-component merge loop above which uses `concurrentComponentsLimit()`. + await pMap( + incomingHidden, + async (id) => { + const existing = existingByName.get(id.toStringWithoutVersion()); if (!existing || existing.version !== id.version) { const mergedComponent = await getModelComponent(id); mergeResults.push({ mergedComponent, mergedVersions: [id.version] }); } - }) + }, + { concurrency: concurrentComponentsLimit() } ); - existingLane.updateDependents = lane.updateDependents; + existingLane.updateDependents = incomingHidden; + } + + const mergeLane = existingLane || lane; + // `overrideUpdateDependents` is a one-shot wire signal from client to remote — once we've + // honored it above, it must not persist on the remote scope. Clear it so that subsequent + // imports of the same lane object don't see a stale "local is authoritative" claim. + if (isExport && mergeLane.shouldOverrideUpdateDependents()) { + mergeLane.setOverrideUpdateDependents(false); } - return { mergeResults, mergeErrors, 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..e89fcf28a375 --- /dev/null +++ b/e2e/harmony/lanes/update-dependents-cascade.e2e.ts @@ -0,0 +1,870 @@ +/* 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. `bit snap` rewrites + // `updateDependents` locally and flags the lane with `overrideUpdateDependents=true` to signal + // "these are pending, don't blow them away". A `bit fetch --lanes` between snap and export + // must not wipe the cascaded hashes. + // --------------------------------------------------------------------------------------------- + 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. We capture + // the pre-cascade `updateDependents` in `Lane.updateDependentsBeforeCascade` at cascade time, + // and `reset` uses it 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); + expect(laneAfterSnap.overrideUpdateDependents).to.equal(true); + + 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('overrideUpdateDependents should be cleared', () => { + expect(laneAfterReset.overrideUpdateDependents).to.be.undefined; + }); + + 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, with `overrideUpdateDependents` still `true` (one cascade pending). + // --------------------------------------------------------------------------------------------- + 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); + }); + + it('overrideUpdateDependents should remain true — the first cascade is still pending', () => { + expect(laneAfterResetHead.overrideUpdateDependents).to.equal(true); + }); + }); + + // --------------------------------------------------------------------------------------------- + // 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'); + }); + }); + + // --------------------------------------------------------------------------------------------- + // 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 ` must rewind hidden updateDependents on the lane + // alongside the visible components. Hidden entries don't go through the workspace `checkout` + // path (no bitmap, no files), so the rewind happens directly on the lane object — + // `lane.updateDependents` is set to the historical hashes and the lane is saved. + // --------------------------------------------------------------------------------------------- + describe('scenario 15: bit lane checkout rewinds hidden updateDependents 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. This is what we'll checkout to. + 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 rewind to the pre-cascade comp2 hash', () => { + const localLane = helper.command.catLane('dev'); + expect(localLane.updateDependents).to.have.lengthOf(1); + expect(localLane.updateDependents[0].split('@')[1]).to.equal(comp2InUpdDepInitial); + }); + + it('comp2 must stay hidden after the checkout (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'); + }); + }); +}); diff --git a/scopes/component/merging/merging.main.runtime.ts b/scopes/component/merging/merging.main.runtime.ts index 99a6847e7365..52268bdd2464 100644 --- a/scopes/component/merging/merging.main.runtime.ts +++ b/scopes/component/merging/merging.main.runtime.ts @@ -27,6 +27,8 @@ import { ImporterAspect } from '@teambit/importer'; import type { Logger, LoggerMain } from '@teambit/logger'; import { LoggerAspect } from '@teambit/logger'; import { compact } from 'lodash'; +import { sha1 } from '@teambit/toolbox.crypto.sha1'; +import { v4 } from 'uuid'; import type { ApplyVersionWithComps, CheckoutMain, ComponentStatusBase } from '@teambit/checkout'; import { CheckoutAspect, removeFilesIfNeeded, updateFileStatus } from '@teambit/checkout'; import type { ConfigMergerMain, ConfigMergeResult, PolicyDependency } from '@teambit/config-merger'; @@ -424,7 +426,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 +485,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,12 +702,94 @@ export class MergingMain { ); return results; } - return this.snapping.snap({ - legacyBitIds: ids, + // Split hidden lane updateDependents (skipWorkspace=true) from visible workspace components. + // Hidden entries have no workspace files — `loadComponentsForTagOrSnap` -> `workspace.getMany` + // would throw `ComponentsPendingImport`, and the snap pipeline's capsule isolator would fail + // anyway. Snap them via the scope-side path instead (same approach scenario 10 takes through + // `_merge-lane main`). Visible entries continue through the regular workspace snap. + 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))) + ); + + let hiddenResults: MergeSnapResults = null; + if (hiddenIds.length) { + hiddenResults = await this.snapHiddenForMerge(hiddenIds, updatedComponents, snapMessage, lane); + } + + if (!visibleIds.length) return hiddenResults; + + const visibleResults = await this.snapping.snap({ + legacyBitIds: visibleIds, build, message: snapMessage, loose, }); + + if (!hiddenResults) return visibleResults; + if (!visibleResults) return hiddenResults; + return { + ...visibleResults, + snappedComponents: [...visibleResults.snappedComponents, ...hiddenResults.snappedComponents], + autoSnappedResults: [...visibleResults.autoSnappedResults, ...hiddenResults.autoSnappedResults], + }; + } + + /** + * Scope-side snap for hidden lane updateDependents during a workspace merge. We can't go through + * `snapping.snap` (loads via workspace, which has no files for hidden entries) and can't call + * `snapping.snapFromScope` (it explicitly rejects workspace context). Instead, build the merge + * Version directly via `_addCompToObjects` — the second parent comes from the unmergedComponent + * entry (set in `applyVersion`), and the merge dep rewrites + main-side content/config changes + * are already on the merged `ConsumerComponent` produced by `applyVersion` and threaded in via + * `updatedComponents`. + */ + private async snapHiddenForMerge( + hiddenIds: ComponentIdList, + updatedComponents: ConsumerComponent[], + snapMessage: string | undefined, + lane: Lane | undefined + ): Promise { + const legacyScope = this.scope.legacyScope; + const snappedComponents: ConsumerComponent[] = []; + await mapSeries(hiddenIds, async (id) => { + const laneEntry = lane?.getComponent(id); + const previouslyUsedVersion = laneEntry?.head?.toString(); + if (!previouslyUsedVersion) { + throw new BitError(`snapHiddenForMerge: lane entry for ${id.toString()} has no head`); + } + // Prefer the merged ConsumerComponent produced by `applyVersion` so main-side file/aspect + // changes flow into the cascade snap. Fall back to the lane-head version when no merged + // result is available (e.g., a future caller path that snaps a hidden entry without + // running the merge engine first). + const merged = updatedComponents.find((c) => c.componentId.isEqualWithoutVersion(id)); + const consumerComponent = + merged || (await legacyScope.getConsumerComponent(id.changeVersion(previouslyUsedVersion))); + if (snapMessage) consumerComponent.log = { ...consumerComponent.log, message: snapMessage } as any; + // assign a fresh hash so `_addCompToObjects` records a new snap (and the override flag in + // addVersion fires while the lane carries the entry as hidden). + consumerComponent.version = sha1(v4()); + consumerComponent.previouslyUsedVersion = previouslyUsedVersion; + await this.snapping._addCompToObjects({ + source: consumerComponent, + lane, + // hidden entries cascade only — explicit caller intent here is to refresh the lane's + // hidden bucket without promoting to visible (existingEntry.skipWorkspace stays true). + addVersionOpts: { addToUpdateDependentsInLane: true }, + }); + snappedComponents.push(consumerComponent); + legacyScope.objects.unmergedComponents.removeComponent(id); + }); + if (lane) legacyScope.objects.add(lane); + await legacyScope.objects.persist(); + return { + snappedComponents, + autoSnappedResults: [], + removedComponents: new ComponentIdList(), + }; } private async tagAllLaneComponent( diff --git a/scopes/component/snapping/reset-component.ts b/scopes/component/snapping/reset-component.ts index a7430538084c..a871287b1c74 100644 --- a/scopes/component/snapping/reset-component.ts +++ b/scopes/component/snapping/reset-component.ts @@ -147,7 +147,12 @@ export async function getComponentsWithOptionToUntag( ): Promise { const componentList = new ComponentsList(workspace); const laneObj = await workspace.getCurrentLaneObject(); - const components: ModelComponent[] = await componentList.listExportPendingComponents(laneObj); + // include hidden updateDependents — `bit reset` is expected to revert cascade snaps end-to-end + // (see scenario 8 of the cascade spec). The bitmap-update step in `snapping.reset` skips them + // 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, { + includeHiddenLaneEntries: true, + }); const removedStagedIds = await remove.getRemovedStaged(); if (!removedStagedIds.length) return components; const removedStagedBitIds = removedStagedIds.map((id) => id); diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 2c788538a5d8..de6c06f522ef 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -532,6 +532,12 @@ export class SnappingMain { isSnap: !shouldTag, message: params.message as string, updateDependentsOnLane: params.updateDependents, + // when adding a hidden updateDependent, the unified architecture relies on autotag + // (`getLaneAutoTagIdsFromScope`) to find lane.components that depend on the new entry and + // re-snap them with the cascaded dep — that's scenario 4. Callers that pass + // `skipAutoTag: true` (e.g., dot-cli's snap-from-scope command) are overridden here in the + // updateDependents flow specifically. + skipAutoTag: params.updateDependents ? false : params.skipAutoTag, }; const results = await this.makeVersion(ids, components, makeVersionParams); @@ -539,9 +545,14 @@ 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 `_snap --update-dependents` (scenario 4), + // `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 @@ -738,9 +749,48 @@ 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(); + // if the reset cleared every locally-cascaded hidden entry, drop the wire-level + // `overrideUpdateDependents` flag — there's no longer a pending claim of "my hidden list + // is authoritative". `bit reset --head` may leave some cascades unresolved (an earlier snap + // is still local), in which case the flag must stay raised until those are rewound or + // exported. + if (currentLane?.shouldOverrideUpdateDependents()) { + const repo = this.scope.legacyScope.objects; + const hiddenEntries = currentLane.components.filter((c) => c.skipWorkspace); + // bound concurrency — each task does scope reads, head population and a diverge-data + // computation, which can spike I/O on large lanes. Match the convention used elsewhere + // (e.g., merging.getMergeStatus) of `concurrentComponentsLimit()`. + const anyHasLocalHashes = await pMap( + hiddenEntries, + async (entry) => { + const mc = await this.scope.legacyScope.getModelComponentIfExist(entry.id); + if (!mc) return false; + // refresh the modelComponent's lane heads against the post-reset lane state, then + // force-recompute divergeData (fromCache=false). `getLocalHashes` would otherwise + // see a stale source head (the pre-reset cascade hash) because `setDivergeData` + // defaults to using the cached value, which still reflects pre-reset state. + await mc.populateLocalAndRemoteHeads(repo, currentLane); + await mc.setDivergeData(repo, true, false); + const local = await mc.getLocalHashes(repo); + return local.length > 0; + }, + { concurrency: concurrentComponentsLimit() } + ); + if (!anyHasLocalHashes.some(Boolean)) { + currentLane.setOverrideUpdateDependents(false); + await consumer.scope.lanes.saveLane(currentLane, { saveLaneHistory: false }); + } + } } else { results = await softUntag(); consumer.bitMap.markAsChanged(); diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 3ae22c4b282e..3ba70803d084 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -162,19 +162,41 @@ 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 `_snap --update-dependents` (scenario 4) that auto-tags a *visible* + // lane.components dependent (comp1 in the test) doesn't accidentally move it into the + // hidden bucket. + // - explicit target of `_snap --update-dependents` → hidden (caller flag) + // - hidden cascade snap (auto-tagged) → keep hidden, raise override flag + // - workspace component (in bitmap) → promote to visible (scenario 6) + // - 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,6 +206,13 @@ export class VersionMaker { results.addedVersionStr, true ); + } 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()); } else { const tagData = this.params.tagDataPerComp?.find((t) => t.componentId.isEqualWithoutVersion(component.id)); if (tagData?.isNew) results.version.removeAllParents(); @@ -194,7 +223,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,7 +428,12 @@ 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()); @@ -396,16 +441,34 @@ export class VersionMaker { 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 bare-scope `_snap --update-dependents` (scenario + // 4), 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/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 e82ede9577fd..9c39f4d222ff 100644 --- a/scopes/lanes/lanes/lane.cmd.ts +++ b/scopes/lanes/lanes/lane.cmd.ts @@ -475,7 +475,10 @@ export class LaneHistoryCmd implements Command { if (singleItem && historyItem) { 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 items = Object.keys(history).map((uuid) => { @@ -497,6 +500,7 @@ export class LaneHistoryCmd implements Command { username: historyItem.log.username, message: historyItem.log.message, components: historyItem.components, + ...(historyItem.updateDependents?.length && { updateDependents: historyItem.updateDependents }), }; } @@ -508,6 +512,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/lanes.main.runtime.ts b/scopes/lanes/lanes/lanes.main.runtime.ts index 4333f5ef4650..f2c3fc244a0b 100644 --- a/scopes/lanes/lanes/lanes.main.runtime.ts +++ b/scopes/lanes/lanes/lanes.main.runtime.ts @@ -237,6 +237,7 @@ export class LanesMain { isLane: true, lane, }); + await this.restoreUpdateDependentsFromHistory(historyItem, lane); return results; } @@ -287,9 +288,37 @@ export class LanesMain { results.addedComponents = [...(results.addedComponents || []), ...(deletedResults.addedComponents || [])]; } + await this.restoreUpdateDependentsFromHistory(historyItem, lane); + return results; } + /** + * Rewind hidden lane entries (`skipWorkspace: true`) to the historical state. The visible + * components flow through `checkout.checkout` (workspace materialization); hidden entries have + * no workspace counterpart so we rewrite the lane's hidden bucket directly and persist. + * + * The historical list is treated as authoritative: hidden entries that exist now but weren't in + * the snapshot get dropped; entries in the snapshot get added/updated. When the field is + * `undefined` (legacy history entry written before this PR), we don't know what was there, so + * we leave the current hidden bucket alone. + */ + private async restoreUpdateDependentsFromHistory(historyItem: HistoryItem, lane: Lane | null | undefined) { + if (!lane || historyItem.updateDependents === undefined) return; + const historicalHidden = historyItem.updateDependents.map((id) => ComponentID.fromString(id)); + if (historicalHidden.length) { + await this.scope.legacyScope.scopeImporter.importMany({ + ids: ComponentIdList.fromArray(historicalHidden), + lane, + reason: 'to restore hidden updateDependents from lane history', + }); + } + // Setter drops every current hidden entry and applies the historical list (which may be + // empty). This is what makes the historical snapshot authoritative. + lane.updateDependents = historicalHidden; + await this.scope.legacyScope.lanes.saveLane(lane, { saveLaneHistory: false }); + } + private async getHistoryItemOfCurrentLane(historyId: string): Promise { const laneId = this.getCurrentLaneId(); if (!laneId || laneId.isDefault()) { 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..9c84ee1e55b6 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. @@ -762,7 +772,7 @@ ${localOnlyExportPending.map((c) => c.toString()).join('\n')}`); const componentsList = new ComponentsList(this.workspace); const componentsToExportWithoutRemoved = includeNonStaged ? await componentsList.listNonNewComponentsIds() - : await componentsList.listExportPendingComponentsIds(laneObject); + : await componentsList.listExportPendingComponentsIds(laneObject, { includeHiddenLaneEntries: true }); const removedStagedBitIds = await this.getRemovedStagedBitIds(); const componentsToExport = ComponentIdList.uniqFromArray([ ...componentsToExportWithoutRemoved, diff --git a/scopes/scope/importer/importer.main.runtime.ts b/scopes/scope/importer/importer.main.runtime.ts index ed636695a72a..f94d3abaceb5 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, 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..99ea39cc00be 100644 --- a/scopes/scope/objects/models/lane.ts +++ b/scopes/scope/objects/models/lane.ts @@ -23,12 +23,14 @@ 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[]; overrideUpdateDependents?: boolean; }; @@ -36,7 +38,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 +59,6 @@ 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[]; private overrideUpdateDependents?: boolean; constructor(props: LaneProps) { super(); @@ -68,9 +71,51 @@ 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; } + /** + * 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. Preserves array-identity + // semantics callers expect from `lane.updateDependents = lane.updateDependents` reassignment. + // 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 +142,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,7 +165,7 @@ export default class Lane extends BitObject { }, forkedFrom: this.forkedFrom && this.forkedFrom.toObject(), schema: this.schema, - updateDependents: this.updateDependents?.map((c) => c.toString()), + updateDependents, overrideUpdateDependents: this.overrideUpdateDependents, }, (val) => !!val @@ -146,15 +198,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 +230,6 @@ 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)), overrideUpdateDependents: laneObject.overrideUpdateDependents, hash: laneObject.hash || hash, schema: laneObject.schema, @@ -179,10 +245,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,22 +266,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; + const before = this.components.length; + this.components = this.components.filter((c) => !c.skipWorkspace); + if (this.components.length !== before) this.hasChanged = true; } shouldOverrideUpdateDependents() { return this.overrideUpdateDependents; @@ -240,7 +322,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 +380,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 +417,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,9 +448,26 @@ 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({ diff --git a/scopes/scope/objects/models/model-component.ts b/scopes/scope/objects/models/model-component.ts index a3bba530d480..170f5a0bef8c 100644 --- a/scopes/scope/objects/models/model-component.ts +++ b/scopes/scope/objects/models/model-component.ts @@ -704,11 +704,29 @@ 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) { + // wire-level signal so `sources.mergeLane` on the export path accepts the new hidden + // entry as authoritative (the remote's existing hidden hash for this id gets replaced). + // We set this on every write to a hidden entry — explicit cascade producers (bare-scope + // `_snap --update-dependents`), workspace cascade-on-snap (future), AND merge-snaps that + // refresh hidden entries (scenario 10). The remote clears the flag on its stored copy + // post-merge so it never persists across an unrelated future fetch. 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,