diff --git a/components/legacy/scope/repositories/sources.ts b/components/legacy/scope/repositories/sources.ts index fca6b330889f..88334a99e5d1 100644 --- a/components/legacy/scope/repositories/sources.ts +++ b/components/legacy/scope/repositories/sources.ts @@ -733,12 +733,19 @@ 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) { + // merging updateDependents is tricky. Historically the end user never changed it — it was only + // updated by `bit _snap --update-dependents` (bare scope) or a graphql query. In that world, + // an import could safely overwrite the local list with whatever the remote had. + // + // With the workspace-side cascade (`bit snap` rewriting updateDependents via + // `includeUpdateDependentsInSnap`), the local user CAN now have pending changes to + // `updateDependents` that haven't been exported yet. `setOverrideUpdateDependents(true)` on the + // local lane is the signal for "don't blow these away". If an import happens before the export, + // overriding from the remote would silently discard the cascaded snaps. + // + // On export, the remote still only overrides when the incoming lane carries + // `overrideUpdateDependents=true`, matching the original contract. + if (isImport && existingLane && !existingLane.shouldOverrideUpdateDependents()) { existingLane.updateDependents = lane.updateDependents; } if (isExport && existingLane && lane.shouldOverrideUpdateDependents()) { @@ -754,6 +761,15 @@ possible causes: existingLane.updateDependents = lane.updateDependents; } - return { mergeResults, mergeErrors, mergeLane: existingLane || lane }; + const mergeLane = existingLane || lane; + // `overrideUpdateDependents` is a one-shot wire signal from the client to the remote — + // once we've honored it above, it must not persist on the remote scope. Clearing it here + // covers both the "existingLane was merged" path AND the first-time-push path (where we'd + // otherwise store the incoming `lane` as-is with the flag still set). + if (isExport && mergeLane.shouldOverrideUpdateDependents()) { + mergeLane.setOverrideUpdateDependents(false); + } + + return { mergeResults, mergeErrors, mergeLane }; } } diff --git a/scopes/component/merging/merging.main.runtime.ts b/scopes/component/merging/merging.main.runtime.ts index 99a6847e7365..d382cba5f93a 100644 --- a/scopes/component/merging/merging.main.runtime.ts +++ b/scopes/component/merging/merging.main.runtime.ts @@ -323,6 +323,7 @@ export class MergingMain { snapMessage, build, laneId: currentLane?.toLaneId(), + targetLane: currentLane, loose, }); }; @@ -473,6 +474,15 @@ export class MergingMain { const addToCurrentLane = (head: Ref) => { if (!currentLane) throw new Error('currentLane must be defined when adding to the lane'); + // Components in `lane.updateDependents` are hidden (not in workspaces) but still need their + // lane ref refreshed when main advances. Handle them before the lane.components path so + // they keep their "updateDependent" status instead of being promoted to lane.components. + const isUpdateDependent = currentLane.updateDependents?.some((u) => u.isEqualWithoutVersion(id)); + if (isUpdateDependent) { + currentLane.addComponentToUpdateDependents(id.changeVersion(head.toString())); + currentLane.setOverrideUpdateDependents(true); + return; + } if (otherLaneId.isDefault()) { const isPartOfLane = currentLane.components.find((c) => c.id.isEqualWithoutVersion(id)); if (!isPartOfLane) return; @@ -636,11 +646,13 @@ export class MergingMain { snapMessage, build, laneId, + targetLane, loose, }: { snapMessage?: string; build?: boolean; laneId?: LaneId; + targetLane?: Lane; loose?: boolean; } ): Promise { @@ -648,6 +660,15 @@ export class MergingMain { this.logger.debug(`merge-snaps, snapResolvedComponents, total ${unmergedComponents.length.toString()} components`); if (!unmergedComponents.length) return null; const ids = ComponentIdList.fromArray(unmergedComponents.map((r) => ComponentID.fromObject(r.id))); + // Merge-snap ids that overlap with the target lane's existing `updateDependents` must land + // back in `lane.updateDependents`, not be promoted to `lane.components`. Without this, a + // main→lane refresh (UI "update lane") would turn hidden updateDependents into visible lane + // components. + const updateDependentIds = targetLane?.updateDependents?.length + ? ids + .filter((id) => targetLane.updateDependents?.some((u) => u.isEqualWithoutVersion(id))) + .map((id) => id.toString()) + : undefined; if (!this.workspace) { const getLoadAspectOnlyForIds = (): ComponentIdList | undefined => { if (!allComponentsStatus.length || !allComponentsStatus[0].dataMergeResult) return undefined; @@ -679,6 +700,7 @@ export class MergingMain { lane: laneId?.toString(), updatedLegacyComponents: updatedComponents, loadAspectOnlyForIds: getLoadAspectOnlyForIds(), + updateDependentIds, loose, } ); diff --git a/scopes/component/snapping/include-lane-components-for-updep-snap.ts b/scopes/component/snapping/include-lane-components-for-updep-snap.ts new file mode 100644 index 000000000000..b19f221f8d55 --- /dev/null +++ b/scopes/component/snapping/include-lane-components-for-updep-snap.ts @@ -0,0 +1,142 @@ +import type { ComponentID } from '@teambit/component-id'; +import { ComponentIdList } from '@teambit/component-id'; +import { compact } from 'lodash'; +import { pMapPool } from '@teambit/toolbox.promise.map-pool'; +import { concurrentComponentsLimit } from '@teambit/harmony.modules.concurrency'; +import type { Component } from '@teambit/component'; +import type { ScopeMain } from '@teambit/scope'; +import type { Logger } from '@teambit/logger'; +import type { Lane } from '@teambit/objects'; + +export type LaneCompsForUpDepSnap = { + components: Component[]; + ids: ComponentIdList; +}; + +/** + * When `bit _snap --update-dependents` runs in a bare scope, it creates new Version objects for + * the target components (they land in `lane.updateDependents`). Any entry in `lane.components` that + * depends on one of those targets now has a stale dep: its recorded version points to the target's + * pre-updateDependents version (typically the main tag), not the fresh `updateDependents` hash. + * + * This helper finds those affected `lane.components` and returns them as extra snap seeders so + * they can be re-snapped in the same `_snap --update-dependents` pass, with dep refs rewritten to + * the cascaded hashes by `updateDependenciesVersions`. They stay in `lane.components` (not moved + * to `updateDependents`) because they were already there — the caller distinguishes via the + * `updateDependentIds` VersionMakerParam. + * + * The dependency set expands via fixed-point iteration so transitive dependents on the lane are + * picked up too (edit X in updateDependents → Y depends on X → Z depends on Y; all three end up + * in the same snap pass with pre-assigned hashes, which also handles cycles). + */ +export async function findLaneComponentsDependingOnUpdDepTargets({ + lane, + targetIds, + scope, + logger, +}: { + lane: Lane; + targetIds: ComponentIdList; + scope: ScopeMain; + logger: Logger; +}): Promise { + const empty: LaneCompsForUpDepSnap = { components: [], ids: new ComponentIdList() }; + const laneComponents = lane.components; + if (!laneComponents.length || !targetIds.length) return empty; + + const legacyScope = scope.legacyScope; + + // Ensure every lane.components Version object is available locally — we need to inspect their + // recorded deps to decide who to pull in. + const laneCompIds = ComponentIdList.fromArray(laneComponents.map((c) => c.id.changeVersion(c.head.toString()))); + try { + await legacyScope.scopeImporter.importWithoutDeps(laneCompIds, { + cache: true, + lane, + reason: 'for finding lane.components that depend on the updateDependents being snapped', + }); + } catch (err: any) { + logger.debug(`findLaneComponentsDependingOnUpdDepTargets: failed to pre-fetch lane.components: ${err.message}`); + } + + type LoadedEntry = { id: ComponentID; depIds: ComponentID[] }; + const loaded: LoadedEntry[] = []; + // Best-effort loading — the prefetch above swallows import errors, so a single corrupt or + // missing object shouldn't abort the whole `_snap --update-dependents` pass. + for (const laneComp of laneComponents) { + try { + const modelComponent = await legacyScope.getModelComponentIfExist(laneComp.id); + if (!modelComponent) continue; + const version = await modelComponent.loadVersion(laneComp.head.toString(), legacyScope.objects, false); + if (!version) { + logger.debug( + `findLaneComponentsDependingOnUpdDepTargets: Version object for ${laneComp.id.toString()}@${laneComp.head.toString()} is missing, skipping` + ); + continue; + } + // Match the full dependency set used by `updateDependenciesVersions` (runtime + dev + peer + + // extension) so a lane.component that depends on a target only via peer or extension deps is + // still pulled into the snap pass. + const depIds = version.getAllDependenciesIds(); + loaded.push({ id: laneComp.id.changeVersion(laneComp.head.toString()), depIds }); + } catch (err: any) { + logger.debug( + `findLaneComponentsDependingOnUpdDepTargets: failed to load ${laneComp.id.toString()}@${laneComp.head.toString()}: ${err.message}, skipping` + ); + } + } + + if (!loaded.length) return empty; + + const includeSet = new Set(targetIds.map((id) => id.toStringWithoutVersion())); + let changed = true; + while (changed) { + changed = false; + for (const entry of loaded) { + const key = entry.id.toStringWithoutVersion(); + if (includeSet.has(key)) continue; + const matches = entry.depIds.some((depId) => includeSet.has(depId.toStringWithoutVersion())); + if (matches) { + includeSet.add(key); + changed = true; + } + } + } + + const toInclude = loaded.filter((entry) => { + const key = entry.id.toStringWithoutVersion(); + if (!includeSet.has(key)) return false; + // the targets themselves are already in the seed set handled by the caller; don't double-add. + if (targetIds.searchWithoutVersion(entry.id)) return false; + return true; + }); + if (!toInclude.length) return empty; + + // Cap concurrency: on large lanes the fixed-point expansion can pull in many dependents, and an + // unbounded Promise.all of scope.get() calls would spike memory/IO. + const loadedComps = await pMapPool( + toInclude, + async (entry) => { + try { + const comp = await scope.get(entry.id); + if (!comp) { + logger.debug( + `findLaneComponentsDependingOnUpdDepTargets: unable to load ${entry.id.toString()} from scope, skipping` + ); + return undefined; + } + return comp; + } catch (err: any) { + logger.debug( + `findLaneComponentsDependingOnUpdDepTargets: failed to load ${entry.id.toString()} from scope: ${err.message}, skipping` + ); + return undefined; + } + }, + { concurrency: concurrentComponentsLimit() } + ); + const components = compact(loadedComps); + if (!components.length) return empty; + const ids = ComponentIdList.fromArray(components.map((c) => c.id)); + return { components, ids }; +} diff --git a/scopes/component/snapping/include-update-dependents-in-snap.ts b/scopes/component/snapping/include-update-dependents-in-snap.ts new file mode 100644 index 000000000000..77c6b27d576b --- /dev/null +++ b/scopes/component/snapping/include-update-dependents-in-snap.ts @@ -0,0 +1,132 @@ +import type { ComponentID } from '@teambit/component-id'; +import { ComponentIdList } from '@teambit/component-id'; +import type { Component } from '@teambit/component'; +import type { ScopeMain } from '@teambit/scope'; +import type { Logger } from '@teambit/logger'; +import type { Lane } from '@teambit/objects'; + +export type UpdateDependentsForSnap = { + components: Component[]; + ids: ComponentIdList; +}; + +/** + * When snapping on a lane that has `updateDependents`, fold the relevant entries into the same + * snap pass so they land with correct dependency hashes on the first try. Returns the components + * to include as extra snap seeders along with their ids; the caller appends them to the main + * snap's seeds before handing things off to the VersionMaker. + * + * Without this, `updateDependents` go stale as soon as any lane component they depend on is + * re-snapped locally, because the old entries keep pointing at Version objects whose recorded + * dependencies reference outdated lane-component hashes. Re-snapping them in the same pass (rather + * than in a separate post-step) avoids producing two Version objects per cascade and keeps the + * number of downstream Ripple CI builds at one per component, just like a normal snap. + * + * *** Why we base the cascade on main head (and ignore the current updateDependents snap) *** + * The prior updateDependents entry points to an older snap (the one the "snap updates" button + * produced last time). Parenting the new cascade off that old snap would drift the lane off main: + * say A was 0.0.1 when first seeded into updateDependents, but main has since moved to 0.0.2 — + * using the old snap as the parent means the new cascade's history never includes A@0.0.2, and + * divergence calculations get messy. Instead, we always start from A's current main head. The new + * updateDependents snap is a direct descendant of main, one commit ahead, with deps rewritten to + * the lane versions. Any previously cascaded snap on the lane is simply orphaned — that's fine; + * the lane only ever points at the latest. + * + * *** Why the cascade set is a fixed-point expansion *** + * Starting from the ids the user is snapping, we add any updateDependent whose recorded + * dependencies (at main head) reference an id already in the set, then repeat. This handles + * transitive cascades (A -> B -> C, edit C → A and B cascade) and cycles (A -> B -> C -> A) + * because hashes are pre-assigned by `setHashes()` before deps are rewritten. + */ +export async function includeUpdateDependentsInSnap({ + lane, + snapIds, + scope, + logger, +}: { + lane?: Lane; + snapIds: ComponentIdList; + scope: ScopeMain; + logger: Logger; +}): Promise { + const empty: UpdateDependentsForSnap = { components: [], ids: new ComponentIdList() }; + if (!lane) return empty; + const updateDependents = lane.updateDependents; + if (!updateDependents || !updateDependents.length) return empty; + + const legacyScope = scope.legacyScope; + + // Fetch each updateDependent from main (no lane context) so the loaded ConsumerComponent + // carries main's head as its version. This flows through `setNewVersion` into + // `previouslyUsedVersion` and ultimately becomes the parent of the new cascaded snap — keeping + // it a direct descendant of main rather than of an earlier (now-orphaned) updateDependents snap. + const mainIds = ComponentIdList.fromArray(updateDependents.map((id) => id.changeVersion(undefined))); + try { + await scope.import(mainIds, { + preferDependencyGraph: false, + reason: 'for cascading updateDependents in the local snap (using main head as the base)', + }); + } catch (err: any) { + logger.debug(`includeUpdateDependentsInSnap: failed to pre-fetch main head for updateDependents: ${err.message}`); + } + + type LoadedEntry = { id: ComponentID; depIds: ComponentID[]; component: Component }; + const loaded: LoadedEntry[] = []; + // Every step below is best-effort: the prefetch above swallows import errors, so a missing or + // corrupt local object shouldn't convert into a hard snap crash here. Log and skip instead. + for (const updDepId of updateDependents) { + try { + const idWithoutVersion = updDepId.changeVersion(undefined); + const modelComponent = await legacyScope.getModelComponentIfExist(idWithoutVersion); + if (!modelComponent || !modelComponent.head) { + logger.debug(`includeUpdateDependentsInSnap: ${updDepId.toString()} has no main head locally, skipping`); + continue; + } + const mainHeadStr = modelComponent.getTagOfRefIfExists(modelComponent.head) || modelComponent.head.toString(); + const idAtMainHead = idWithoutVersion.changeVersion(mainHeadStr); + const component = await scope.get(idAtMainHead); + if (!component) { + logger.debug(`includeUpdateDependentsInSnap: unable to load ${idAtMainHead.toString()} from scope, skipping`); + continue; + } + const consumerComp = component.state._consumer; + // Mirror `updateDependenciesVersions`, which rewrites deps across runtime, dev, peer and + // extension dependencies. If we skip any of those here, a component that depends on a snapped + // id only through (say) a peerDependency would escape the cascade set and stay stale. + const depIds = consumerComp.getAllDependencies().map((d) => d.id); + loaded.push({ id: component.id, depIds, component }); + } catch (err: any) { + logger.debug(`includeUpdateDependentsInSnap: failed to load ${updDepId.toString()}: ${err.message}, skipping`); + } + } + + if (!loaded.length) return empty; + + const cascadeSet = new Set(snapIds.map((id) => id.toStringWithoutVersion())); + let changed = true; + while (changed) { + changed = false; + for (const entry of loaded) { + const key = entry.id.toStringWithoutVersion(); + if (cascadeSet.has(key)) continue; + const matches = entry.depIds.some((depId) => cascadeSet.has(depId.toStringWithoutVersion())); + if (matches) { + cascadeSet.add(key); + changed = true; + } + } + } + + const toInclude = loaded.filter((entry) => { + const key = entry.id.toStringWithoutVersion(); + if (!cascadeSet.has(key)) return false; + // don't include anything the user is already snapping directly. + if (snapIds.searchWithoutVersion(entry.id)) return false; + return true; + }); + if (!toInclude.length) return empty; + + const components = toInclude.map((entry) => entry.component); + const ids = ComponentIdList.fromArray(components.map((c) => c.id)); + return { components, ids }; +} diff --git a/scopes/component/snapping/snap-cmd.ts b/scopes/component/snapping/snap-cmd.ts index 8c451e28be32..846acf6a1e5b 100644 --- a/scopes/component/snapping/snap-cmd.ts +++ b/scopes/component/snapping/snap-cmd.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import { countBy } from 'lodash'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; +import type { ComponentID } from '@teambit/component-id'; import { IssuesClasses } from '@teambit/component-issues'; import type { Command, CommandOptions, Report } from '@teambit/cli'; import { @@ -161,12 +162,26 @@ export function snapResultReport(results: SnapResults): string | Report { laneName, removedComponents, totalComponentsCount, + cascadedUpdateDependents, }: SnapResults = results; + const isCascaded = (component: ConsumerComponent) => + Boolean(cascadedUpdateDependents?.searchWithoutVersion(component.id)); + // Cascaded updateDependents are hidden lane deps re-snapped to keep the lane consistent — + // they don't live in the workspace bitmap, so we surface them in the "auto-snapped + // dependents" bucket rather than mixing them into "changed components". const changedComponents = snappedComponents.filter((component) => { - return !newComponents.searchWithoutVersion(component.id) && !removedComponents?.searchWithoutVersion(component.id); + return ( + !newComponents.searchWithoutVersion(component.id) && + !removedComponents?.searchWithoutVersion(component.id) && + !isCascaded(component) + ); }); const addedComponents = snappedComponents.filter((component) => newComponents.searchWithoutVersion(component.id)); + const cascadedComponents = snappedComponents.filter(isCascaded); const autoSnappedCount = autoSnappedResults ? autoSnappedResults.length : 0; + const cascadedCount = cascadedComponents.length; + const updatedDependentsCount = autoSnappedCount + cascadedCount; + const hasUpdatedDependents = updatedDependentsCount > 0; const totalCount = totalComponentsCount ?? snappedComponents.length + autoSnappedCount; const formatCompMinimal = (component: ConsumerComponent): string => { @@ -185,8 +200,6 @@ export function snapResultReport(results: SnapResults): string | Report { return output; }; - const hasAutoSnapped = autoSnappedCount > 0; - const buildSections = (formatComp: (c: ConsumerComponent) => string) => { const newSection = formatSection('new components', 'first version for components', addedComponents.map(formatComp)); const changedSection = formatSection( @@ -208,23 +221,27 @@ export function snapResultReport(results: SnapResults): string | Report { '(use "bit export" to push these components to a remote)\n(use "bit reset" to unstage all local versions, or "bit reset --head" to only unstage the latest local snap)' ); - // Build minimal output (no auto-snapped listing, just counts grouped by scope) - const { newSection, changedSection } = buildSections(hasAutoSnapped ? formatCompMinimal : formatCompDetailed); - + // Minimal auto-snap section: count + scope breakdown. Merges regular auto-snapped dependents + // (workspace components) and cascaded updateDependents (hidden lane deps) so users see a + // single rolled-up count and the `--details` output explains the split. const autoSnapSection = (() => { - if (!hasAutoSnapped) return ''; - const scopeCounts = countBy(autoSnappedResults, (r) => r.component.id.scope); + if (!hasUpdatedDependents) return ''; + const autoSnapIds: ComponentID[] = (autoSnappedResults ?? []).map((r) => r.component.id); + const cascadedIds: ComponentID[] = cascadedComponents.map((c) => c.id); + const scopeCounts = countBy([...autoSnapIds, ...cascadedIds], (id) => id.scope); const sorted = Object.entries(scopeCounts).sort(([, a], [, b]) => b - a); const MAX_SHOWN = 4; const shown = sorted.slice(0, MAX_SHOWN).map(([scope, n]) => `${scope} (${n})`); const remaining = sorted.length - MAX_SHOWN; const scopeLine = remaining > 0 ? [...shown, `+ ${remaining} more scopes`].join(' · ') : shown.join(' · '); - const title = formatTitle(`auto-snapped dependents (${autoSnappedCount})`); + const title = formatTitle(`auto-snapped dependents (${updatedDependentsCount})`); const scopes = ` ${scopeLine}`; const hint = formatDetailsHint('full list of auto-snapped dependents'); return `${title}\n${scopes}\n${hint}`; })(); + // Build minimal output (no auto-snapped listing, just counts grouped by scope) + const { newSection, changedSection } = buildSections(hasUpdatedDependents ? formatCompMinimal : formatCompDetailed); const footerParts = [summary, snapExplanation].filter(Boolean).join('\n'); const data = joinSections([ newSection, @@ -235,14 +252,32 @@ export function snapResultReport(results: SnapResults): string | Report { footerParts, ]); - if (!hasAutoSnapped) { + if (!hasUpdatedDependents) { return data; } - // Build detailed output (with full auto-snapped listing) + // Detailed output: per-component auto-snap info is already embedded inline via + // formatCompDetailed. Cascaded updateDependents don't have a single `triggeredBy` (they're + // driven by the overall lane change, not any one workspace component), so we list them in a + // dedicated subsection marked as "not in workspace" so users understand why they don't + // appear in their source tree. const { newSection: newDetailed, changedSection: changedDetailed } = buildSections(formatCompDetailed); + const cascadedDetailSection = cascadedCount + ? formatSection( + 'auto-snapped dependents (not in workspace)', + 'lane updateDependents re-snapped to stay consistent with the changes', + cascadedComponents.map((c) => formatItem(compInBold(c.id))) + ) + : ''; const detailedFooter = [summary, snapExplanation].filter(Boolean).join('\n'); - const details = joinSections([newDetailed, changedDetailed, removedSection, warningsSection, detailedFooter]); + const details = joinSections([ + newDetailed, + changedDetailed, + cascadedDetailSection, + removedSection, + warningsSection, + detailedFooter, + ]); return { data, code: 0, details }; } diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 2c788538a5d8..1a5a2ae55395 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -57,6 +57,8 @@ import ResetCmd from './reset-cmd'; import type { NewDependencies } from './generate-comp-from-scope'; import { addDeps, generateCompFromScope } from './generate-comp-from-scope'; import { FlattenedEdgesGetter } from './flattened-edges'; +import { includeUpdateDependentsInSnap } from './include-update-dependents-in-snap'; +import { findLaneComponentsDependingOnUpdDepTargets } from './include-lane-components-for-updep-snap'; import { SnapDistanceCmd } from './snap-distance-cmd'; import type { ResetResult } from './reset-component'; import { @@ -113,6 +115,11 @@ export type SnapResults = BasicTagResults & { snappedComponents: ConsumerComponent[]; autoSnappedResults: AutoTagResult[]; laneName: string | null; // null if default + // Ids of lane.updateDependents that were cascaded into this snap pass. They appear in + // `snappedComponents` along with the user's direct targets, but the output distinguishes them + // because they're hidden components (not in the workspace bitmap) whose dep refs we rewrote to + // keep the lane internally consistent. + cascadedUpdateDependents?: ComponentIdList; }; export type SnapFromScopeResults = { @@ -362,6 +369,11 @@ export class SnappingMain { ignoreIssues?: string; lane?: string; updateDependents?: boolean; + // Explicit per-id list of components that must land in `lane.updateDependents` instead of + // `lane.components` after the snap. Used by the merge-snap flow (main→lane refresh) where + // the set is determined by the target lane's existing `updateDependents`, not by the + // lane-wide `updateDependents: boolean` flag which is reserved for `_snap --update-dependents`. + updateDependentIds?: string[]; tag?: boolean; // in case of merging lanes, the component files are updated in-memory updatedLegacyComponents?: ConsumerComponent[]; @@ -476,7 +488,42 @@ export class SnappingMain { }); } - const components = [...existingComponents, ...newComponents]; + // When snapping updateDependents, also pull in any lane.components entry that depends on one + // of the targets so it can be re-snapped with the cascaded dep refs in the same pass. These + // dependents stay in `lane.components`; only the explicit targets receive the + // `addToUpdateDependentsInLane` treatment via `updateDependentIds` below. + let laneCompDependents: Component[] = []; + if (params.updateDependents && lane) { + const targetIdsForUpDep = ComponentIdList.fromArray(allCompIds); + const { components: dependents } = await findLaneComponentsDependingOnUpdDepTargets({ + lane, + targetIds: targetIdsForUpDep, + scope: this.scope, + logger: this.logger, + }); + // Guard against the rare case where a dependent surfaced by the cascade helper is already + // an explicit target in the snap set — pushing it twice would produce duplicate snapData + // entries and inflate `allCompIds`. + const allCompIdList = ComponentIdList.fromArray(allCompIds); + laneCompDependents = dependents.filter((dep) => !allCompIdList.searchWithoutVersion(dep.id)); + for (const dependent of laneCompDependents) { + snapDataPerComp.push({ + componentId: dependent.id, + dependencies: [], + aspects: undefined, + message: params.message || 'cascaded from updateDependents snap', + files: undefined, + isNew: false, + mainFile: undefined, + newDependencies: [], + removeDependencies: undefined, + version: undefined, + }); + allCompIds.push(dependent.id); + } + } + + const components = [...existingComponents, ...newComponents, ...laneCompDependents]; // this must be done before we load component aspects later on, because this updated deps may update aspects. await pMapSeries(components, async (component) => { @@ -520,6 +567,20 @@ export class SnappingMain { const ids = ComponentIdList.fromArray(allCompIds); const shouldTag = Boolean(params.tag); + // Switch from the lane-wide `updateDependentsOnLane` to per-component `updateDependentIds` so + // the explicit `_snap --update-dependents` targets land in `lane.updateDependents` while the + // dependent lane.components we folded in above go to `lane.components` like a normal snap. + // If the caller passed an explicit `updateDependentIds` list (merge-snap flow), use it + // directly — those are the ids the target lane already treats as updateDependents. + const updateDependentTargetIds = (() => { + if (params.updateDependentIds?.length) { + return ComponentIdList.fromArray(params.updateDependentIds.map((s) => ComponentID.fromString(s))); + } + if (params.updateDependents && componentIds.length) { + return ComponentIdList.fromArray(componentIds); + } + return undefined; + })(); const makeVersionParams = { ...params, tagDataPerComp: snapDataPerComp.map((s) => ({ @@ -531,7 +592,7 @@ export class SnappingMain { persist: true, isSnap: !shouldTag, message: params.message as string, - updateDependentsOnLane: params.updateDependents, + updateDependentIds: updateDependentTargetIds, }; const results = await this.makeVersion(ids, components, makeVersionParams); @@ -605,6 +666,20 @@ export class SnappingMain { this.logger.debug(`snapping the following components: ${ids.toString()}`); const components = await this.loadComponentsForTagOrSnap(ids); await this.throwForVariousIssues(components, ignoreIssues); + + // If the current lane has `updateDependents`, fold the relevant entries into the same snap + // pass so they land with the correct dep hashes on the first try (rather than going stale and + // needing a second snap). See `include-update-dependents-in-snap.ts` for the rationale. + const currentLaneObject = await consumer.scope.getCurrentLaneObject(); + const { components: updDepComponents, ids: updDepIds } = await includeUpdateDependentsInSnap({ + lane: currentLaneObject || undefined, + snapIds: ids, + scope: this.scope, + logger: this.logger, + }); + const allComponents = [...components, ...updDepComponents]; + const allIds = ComponentIdList.uniqFromArray([...ids, ...updDepIds]); + const makeVersionParams = { editor, ignoreNewestVersion: false, @@ -624,9 +699,10 @@ export class SnappingMain { detachHead, loose, ignoreIssues, + updateDependentIds: updDepIds.length ? updDepIds : undefined, }; const { taggedComponents, autoTaggedResults, stagedConfig, removedComponents, totalComponentsCount } = - await this.makeVersion(ids, components, makeVersionParams); + await this.makeVersion(allIds, allComponents, makeVersionParams); const snapResults: Partial = { snappedComponents: taggedComponents, @@ -634,6 +710,7 @@ export class SnappingMain { newComponents, removedComponents, totalComponentsCount, + cascadedUpdateDependents: updDepIds.length ? updDepIds : undefined, }; const currentLane = consumer.getCurrentLaneId(); @@ -721,14 +798,36 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); if (isRealUntag) { results = await untag(); - // Remove lane history entries that correspond to the reset snaps. - // Each snap uses its batchId as the lane history key, so we can match them. + // Remove lane history entries that correspond to the reset snaps, and rewind the lane's + // `updateDependents` / `overrideUpdateDependents` to the state recorded in the entry + // immediately BEFORE the earliest reset batch. Each snap uses its batchId as the lane + // history key. Without this, a cascaded snap's updateDependents entries stay stuck on the + // now-orphaned cascade hashes. `--head` passes a single batch, `--all` passes all of them; + // picking "the entry before the earliest excluded batch (by date)" gives `--head` + // snap-by-snap granularity while `--all` rewinds all the way back — AND it correctly + // ignores any newer history entries (e.g. a `bit fetch --lanes` that ran after the snap) + // whose content still reflects the post-cascade state. if (currentLane) { const allBatchIds = uniq(results.flatMap((r) => r.batchIds || [])); if (allBatchIds.length) { const laneHistory = await consumer.scope.lanes.getOrCreateLaneHistory(currentLane); + const priorEntry = laneHistory.getLatestEntryBeforeBatches(allBatchIds); laneHistory.removeHistoryEntries(allBatchIds); consumer.scope.objects.add(laneHistory); + + const cascadedToClean = applyUpdateDependentsFromHistoryEntry(currentLane, priorEntry); + // Always persist the lane: `applyUpdateDependentsFromHistoryEntry` may have changed + // just the override flag or the updateDependents list without producing any orphaned + // cascade hashes to clean up, and we still need that lane mutation to land on disk. + consumer.scope.objects.add(currentLane); + for (const id of cascadedToClean) { + // Load the ModelComponent WITHOUT a version. `sources.get()` returns undefined when + // a versioned id's Version object is missing/corrupt — that would silently skip the + // versions-map cleanup here even though dropping a ref doesn't need the Version body. + const modelComp = await consumer.scope.getModelComponentIfExist(id.changeVersion(undefined)); + if (modelComp && id.version) modelComp.removeVersion(id.version); + if (modelComp) consumer.scope.objects.add(modelComp); + } } } @@ -1455,4 +1554,33 @@ another option, in case this dependency is not in main yet is to remove all refe SnappingAspect.addRuntime(SnappingMain); +/** + * Restore `lane.updateDependents` / `overrideUpdateDependents` from a `LaneHistory` entry (the + * entry that immediately precedes the batches being reset), and return the updDep ids that + * existed on the lane but don't appear in the restored snapshot — these are the orphaned cascade + * Version objects that the caller needs to remove from their ModelComponent. + * + * If there's no prior entry (e.g. the lane was created via a path that skipped lane-history), + * we clear updateDependents and the override flag, and report everything currently on the lane + * as orphaned. That's the same effect as saying "there's no known prior state, so fall back to + * an empty one". + */ +function applyUpdateDependentsFromHistoryEntry( + lane: Lane, + priorEntry: { updateDependents?: string[]; overrideUpdateDependents?: boolean } | undefined +): ComponentID[] { + const snapshotIds = (priorEntry?.updateDependents || []).map((s) => ComponentID.fromString(s)); + const snapshotKeys = new Set(snapshotIds.map((id) => id.toString())); + const removed = (lane.updateDependents || []).filter((current) => !snapshotKeys.has(current.toString())); + if (priorEntry) { + lane.setUpdateDependentsAndOverride( + snapshotIds.length ? snapshotIds : undefined, + priorEntry.overrideUpdateDependents || false + ); + } else { + lane.setUpdateDependentsAndOverride(undefined, false); + } + return removed; +} + export default SnappingMain; diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 3ae22c4b282e..39f9b4548291 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -68,6 +68,13 @@ export type VersionMakerParams = { packageManagerConfigRootDir?: string; exitOnFirstFailedTask?: boolean; updateDependentsOnLane?: boolean; + /** + * When the snap pass includes cascaded updateDependents (see + * `include-update-dependents-in-snap.ts`), these ids receive the `addToUpdateDependentsInLane` + * treatment on a per-component basis: their Version object is added to `lane.updateDependents` + * instead of `lane.components`, and the workspace bitmap is left untouched so they stay hidden. + */ + updateDependentIds?: ComponentIdList; setHeadAsParent?: boolean; } & BasicTagParams; @@ -153,7 +160,15 @@ export class VersionMaker { }; } - const { rebuildDepsGraph, build, updateDependentsOnLane, setHeadAsParent, detachHead, overrideHead } = this.params; + const { + rebuildDepsGraph, + build, + updateDependentsOnLane, + updateDependentIds, + setHeadAsParent, + detachHead, + overrideHead, + } = this.params; await this.snapping._addFlattenedDependenciesToComponents(this.allComponentsToTag, rebuildDepsGraph); await this._addDependenciesGraphToComponents(); await this.snapping.throwForDepsFromAnotherLane(this.allComponentsToTag); @@ -161,20 +176,22 @@ export class VersionMaker { this.addBuildStatus(this.allComponentsToTag, BuildStatus.Pending); const currentLane = this.consumer?.getCurrentLaneId(); + const isUpdateDependent = (id: ComponentID) => Boolean(updateDependentIds?.searchWithoutVersion(id)); await mapSeries(this.allComponentsToTag, async (component) => { + const compIsUpdDep = isUpdateDependent(component.id); const results = await this.snapping._addCompToObjects({ source: component, lane, shouldValidateVersion: Boolean(build), addVersionOpts: { - addToUpdateDependentsInLane: updateDependentsOnLane, + addToUpdateDependentsInLane: updateDependentsOnLane || compIsUpdDep, setHeadAsParent, detachHead, overrideHead: overrideHead, }, batchId: this.batchId, }); - if (this.workspace) { + if (this.workspace && !compIsUpdDep) { const modelComponent = component.modelComponent || (await this.legacyScope.getModelComponent(component.id)); await updateVersions( this.workspace, @@ -184,7 +201,7 @@ export class VersionMaker { results.addedVersionStr, true ); - } else { + } else if (!this.workspace) { const tagData = this.params.tagDataPerComp?.find((t) => t.componentId.isEqualWithoutVersion(component.id)); if (tagData?.isNew) results.version.removeAllParents(); } @@ -194,7 +211,28 @@ export class VersionMaker { await this.workspace.scope.legacyScope.stagedSnaps.write(); } const publishedPackages: string[] = []; - const harmonyCompsToTag = await (this.workspace || this.scope).getManyByLegacy(this.allComponentsToTag); + // Cascaded updateDependents are not tracked in the workspace bitmap, so loading them via + // `workspace.getManyByLegacy` would fail when the workspace tries to resolve their rootDir. + // Route them through `scope.getManyByLegacy` while the real workspace components go through + // the normal workspace path, then concatenate. Note: this groups workspace comps first and + // scope-only comps second, which is not strictly the original order of `allComponentsToTag` + // — but in practice the only "scope-only" entries are cascaded updateDependents appended at + // the end of the seed set, so the effective order is preserved. Downstream steps (build, + // linking, logging) don't depend on a specific order across this boundary. + const workspaceComps: ConsumerComponent[] = []; + const scopeOnlyComps: ConsumerComponent[] = []; + this.allComponentsToTag.forEach((comp) => { + if (this.workspace && updateDependentIds?.searchWithoutVersion(comp.id)) { + scopeOnlyComps.push(comp); + } else { + workspaceComps.push(comp); + } + }); + const workspaceHarmonyComps = workspaceComps.length + ? await (this.workspace || this.scope).getManyByLegacy(workspaceComps) + : []; + const scopeHarmonyComps = scopeOnlyComps.length ? await this.scope.getManyByLegacy(scopeOnlyComps) : []; + const harmonyCompsToTag = [...workspaceHarmonyComps, ...scopeHarmonyComps]; // 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. @@ -206,7 +244,12 @@ export class VersionMaker { await this.legacyScope.objects.persist(); await removeMergeConfigFromComponents(unmergedComps, this.allComponentsToTag, this.workspace); if (this.workspace) { - await linkToNodeModulesByComponents(harmonyCompsToTag, this.workspace); + // Exclude cascaded updateDependents from node_modules linking — they have no rootDir in + // the workspace bitmap, so the linker would throw MissingBitMapComponent. + const compsToLink = updateDependentIds + ? harmonyCompsToTag.filter((c) => !updateDependentIds.searchWithoutVersion(c.id)) + : harmonyCompsToTag; + await linkToNodeModulesByComponents(compsToLink, this.workspace); } // clear all objects. otherwise, ModelComponent has the wrong divergeData this.legacyScope.objects.clearObjectsFromCache(); @@ -277,7 +320,13 @@ export class VersionMaker { private async triggerOnPreSnap(autoTagIds: ComponentIdList) { const allFunctions = this.snapping.onPreSnapSlot.values(); - await mapSeries(allFunctions, (func) => func(this.components, autoTagIds, this.params)); + // Exclude cascaded updateDependents: they were loaded from the scope (not the workspace), so + // pre-snap steps that touch workspace files (e.g. formatting) have nothing to act on. + const updateDependentIds = this.params.updateDependentIds; + const components = updateDependentIds + ? this.components.filter((c) => !updateDependentIds.searchWithoutVersion(c.id)) + : this.components; + await mapSeries(allFunctions, (func) => func(components, autoTagIds, this.params)); } private async addLaneObject(lane?: Lane) { @@ -320,7 +369,12 @@ export class VersionMaker { const isolateOptions = { packageManagerConfigRootDir, seedersOnly, populateArtifactsIgnorePkgJson }; const builderOptions = { exitOnFirstFailedTask, skipTests, skipTasks: skipTasksParsed, loose }; - const componentsToBuild = harmonyCompsToTag.filter((c) => !c.isDeleted()); + // Cascaded updateDependents are not part of the workspace and get built by the downstream + // Ripple CI job after export; building them here would require workspace state they don't have. + const updateDependentIds = this.params.updateDependentIds; + const componentsToBuild = harmonyCompsToTag.filter( + (c) => !c.isDeleted() && !updateDependentIds?.searchWithoutVersion(c.id) + ); if (componentsToBuild.length) { const componentsToBuildLegacy: ConsumerComponent[] = componentsToBuild.map((c) => c.state._consumer); await this.scope.reloadAspectsWithNewVersion(componentsToBuildLegacy); @@ -391,7 +445,18 @@ export class VersionMaker { if (!this.workspace) return this.getLaneAutoTagIdsFromScope(idsToTag); // 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()); + // Exclude cascaded updateDependents from the auto-tag trigger set: they aren't in the + // workspace bitmap, and the auto-tag path calls `consumer.loadComponents`, which would throw + // MissingBitMapComponent on them. We don't need them in the trigger set anyway — any + // workspace dependent of an updateDependent already has its dep rewritten to the lane hash by + // the dependency-versions-resolver (`getIdFromUpdateDependentsOnLane`), which makes it look + // modified and land directly in the snap set via `getTagPendingComponentsIds`. The cascade's + // new hash is then propagated by `updateDependenciesVersions` in the same pass. + const updateDependentIds = this.params.updateDependentIds; + const workspaceIds = updateDependentIds + ? idsToTag.filter((id) => !updateDependentIds.searchWithoutVersion(id)) + : idsToTag; + const idsToTriggerAutoTag = workspaceIds.filter((id) => id.hasVersion()); const autoTagDataWithLocalOnly = await this.workspace.getAutoTagInfo( ComponentIdList.fromArray(idsToTriggerAutoTag) ); diff --git a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts index d90c37008d84..163a660863de 100644 --- a/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts +++ b/scopes/lanes/merge-lanes/merge-lanes.main.runtime.ts @@ -306,13 +306,19 @@ export class MergeLanesMain { const legacyScope = this.scope.legacyScope; if (fetchCurrent && !currentLaneId.isDefault()) { // if current is default, it'll be fetch later on - await this.lanes.fetchLaneWithItsComponents(currentLaneId); + // Pass `shouldIncludeUpdateDependents` so the bare-scope also pulls the Version objects for + // the target lane's `updateDependents` entries. Needed when the merge flow then walks them + // (e.g. refreshing them against main's advanced heads during a main→lane "update lane"). + await this.lanes.fetchLaneWithItsComponents(currentLaneId, shouldIncludeUpdateDependents); } const currentLane = currentLaneId.isDefault() ? undefined : await legacyScope.loadLane(currentLaneId); const isDefaultLane = otherLaneId.isDefault(); if (isDefaultLane) { if (!skipFetch) { - const ids = await this.getMainIdsToMerge(currentLane, !excludeNonLaneComps); + // Include the current lane's `updateDependents` here so main-side heads for those ids + // are pre-fetched. Without it, main→lane merge can't pull the refreshed comp2 (main's + // advanced head) to re-cascade the updateDependent against. + 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..cb756aa65718 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,36 @@ 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 the exported ids into workspace components vs. updated dependents. The updated + // dependents come from the lane's `updateDependents` list — hidden components that got + // re-snapped (via a 'Snap updates' action or a local cascade) to keep the lane consistent. + const laneUpdateIds = exportedLanes[0]?.updateDependents; + const updateKeys = new Set(laneUpdateIds?.map((id) => id.toStringWithoutVersion()) ?? []); + const isUpdate = (id: ComponentID) => updateKeys.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( + 'updated dependents', + 'hidden dependents re-snapped and pushed to keep the lane consistent', + 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 afc925461f4c..a0668153401d 100644 --- a/scopes/scope/export/export.main.runtime.ts +++ b/scopes/scope/export/export.main.runtime.ts @@ -30,8 +30,8 @@ import { linkToNodeModulesByIds } from '@teambit/workspace.modules.node-modules- import type { DependencyResolverMain } from '@teambit/dependency-resolver'; import { DependencyResolverAspect } from '@teambit/dependency-resolver'; import { persistRemotes, validateRemotes, removePendingDirs } from './export-scope-components'; -import type { Lane, ModelComponent, ObjectItem, LaneReadmeComponent, BitObject, Ref } from '@teambit/objects'; -import { ObjectList } from '@teambit/objects'; +import type { Lane, ModelComponent, ObjectItem, LaneReadmeComponent, BitObject } from '@teambit/objects'; +import { ObjectList, Ref } from '@teambit/objects'; import { Scope, PersistFailed } from '@teambit/legacy.scope'; import { getAllVersionHashes } from '@teambit/component.snap-distance'; import { ExportAspect } from './export.aspect'; @@ -225,8 +225,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(); + // Lane `updateDependents` are intentionally not tracked in the workspace bitmap — they're + // hidden from the user and exist only to re-align the lane with its dependencies. Excluding + // them here prevents the misleading "component files are not tracked" hint from firing on + // every export of a lane that carries 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. @@ -350,6 +360,17 @@ if the export fails with missing objects/versions/components, run "bit fetch --l } return [head]; } + // When the local lane has cascaded updateDependents (see `includeUpdateDependentsInSnap`), + // divergeData-based `getLocalHashes` can't see those new Version objects because the + // component's `laneHeadLocal` only reflects `lane.components`. Push the updateDependents + // head directly instead — the parent is always an existing remote ref (main head) so there + // are no missing ancestors to worry about. + const updDepEntry = laneObject?.shouldOverrideUpdateDependents() + ? laneObject.updateDependents?.find((id) => id.isEqualWithoutVersion(modelComponent.toComponentId())) + : undefined; + if (updDepEntry?.version) { + return [Ref.from(updDepEntry.version)]; + } const fromWorkspace = this.workspace?.getIdIfExist(modelComponent.toComponentId()); const localTagsOrHashes = await modelComponent.getLocalHashes(scope.objects, fromWorkspace); if (!allVersions) { @@ -751,9 +772,18 @@ ${localOnlyExportPending.map((c) => c.toString()).join('\n')}`); ? await componentsList.listNonNewComponentsIds() : await componentsList.listExportPendingComponentsIds(laneObject); const removedStagedBitIds = await this.getRemovedStagedBitIds(); + // When the lane's updateDependents were cascaded locally (during `bit snap`), the new Version + // objects for those entries are in the local scope, but the per-component "isLocallyChanged" + // heuristic won't detect them because `lane.getComponentHead` only looks at `lane.components`. + // The lane itself sets `overrideUpdateDependents` to signal "I touched updateDependents locally + // — push them". Include those ids explicitly so the export pushes the cascaded Version objects. + const updateDependentsIds = laneObject.shouldOverrideUpdateDependents() + ? (laneObject.updateDependents || []).map((id) => id.changeVersion(undefined)) + : []; const componentsToExport = ComponentIdList.uniqFromArray([ ...componentsToExportWithoutRemoved, ...removedStagedBitIds, + ...updateDependentsIds, ]); return { componentsToExport, laneObject }; } @@ -841,6 +871,19 @@ async function updateLanesAfterExport(consumer: Consumer, lane: Lane) { consumer.setCurrentLane(lane.toLaneId(), true); consumer.scope.scopeJson.removeLaneFromNew(lane.name); lane.isNew = false; + // `overrideUpdateDependents` is a request addressed to the remote — once the export is through, + // the remote has already merged our updateDependents, so the local flag must not linger (on a + // user's workspace lane, it must never be `true` at rest, see Lane.setOverrideUpdateDependents). + if (lane.shouldOverrideUpdateDependents()) { + lane.setOverrideUpdateDependents(false); + consumer.scope.objects.add(lane); + // Record the post-export state in LaneHistory so a later `bit reset` can rewind to this + // checkpoint (not past it). Without this entry, resetting a snap made after an export would + // over-revert back to the pre-export state. + const laneHistory = await consumer.scope.lanes.updateLaneHistory(lane, 'exported'); + consumer.scope.objects.add(laneHistory); + await consumer.scope.objects.persist(); + } } export function isUserTryingToExportLanes(consumer: Consumer) { diff --git a/scopes/scope/importer/importer.main.runtime.ts b/scopes/scope/importer/importer.main.runtime.ts index ed636695a72a..8482d4553940 100644 --- a/scopes/scope/importer/importer.main.runtime.ts +++ b/scopes/scope/importer/importer.main.runtime.ts @@ -364,7 +364,10 @@ export class ImporterMain { const exists = await legacyScope.loadLane(laneId); if (!exists) { laneObject.hasChanged = true; - await legacyScope.lanes.saveLane(laneObject, { saveLaneHistory: false }); + // Record an initial history entry so `bit reset` has a baseline to rewind to after a + // later local cascade snap. Without this, the first reset would wipe `updateDependents` + // entirely because there's no prior entry to restore from. + await legacyScope.lanes.saveLane(laneObject, { laneHistoryMsg: 'fetched from remote' }); } } diff --git a/scopes/scope/objects/models/lane-history.ts b/scopes/scope/objects/models/lane-history.ts index ad50b1c36bb8..e0e121ee5dcb 100644 --- a/scopes/scope/objects/models/lane-history.ts +++ b/scopes/scope/objects/models/lane-history.ts @@ -10,6 +10,19 @@ export type HistoryItem = { log: Log; components: string[]; deleted?: string[]; + /** + * Snapshot of `lane.updateDependents` at the time this history entry was recorded. Used by + * `bit reset` to restore the prior `updateDependents` state when a cascaded snap is rolled + * back, and by audit flows that want to see how updateDependents evolved over the lane's life. + * Optional for backward compatibility with entries written before this field existed. + */ + updateDependents?: string[]; + /** + * The value of `lane.overrideUpdateDependents` at the time this history entry was recorded. + * Needed alongside `updateDependents` so that `bit reset` can restore not just the list but + * whether the lane was in the "needs to push updateDependents" state at that point. + */ + overrideUpdateDependents?: boolean; }; type History = { [uuid: string]: HistoryItem }; @@ -83,7 +96,38 @@ 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 }) }; + const updateDependents = laneObj.updateDependents?.map((id) => id.toString()); + const overrideUpdateDependents = laneObj.shouldOverrideUpdateDependents() || undefined; + this.history[historyKey || v4()] = { + log, + components, + ...(deleted.length && { deleted }), + ...(updateDependents?.length && { updateDependents }), + ...(overrideUpdateDependents && { overrideUpdateDependents }), + }; + } + + /** + * Return the history entry that captures the state immediately BEFORE the earliest of the + * given batches. Used by `bit reset` to find the "post-rewind" target state: entries recorded + * AFTER a cascaded snap (e.g. a later `bit fetch --lanes` that wrote its own history entry) + * must NOT be the rewind target — they'd leave the lane in the post-cascade state. Picking by + * "date < earliest excluded date" guarantees we land on state that predates the reset. + * + * Returns `undefined` when no entry precedes the excluded batches (e.g. the reset covers the + * very first entries on the lane — nothing older to restore from). + */ + getLatestEntryBeforeBatches(batchIds: string[]): HistoryItem | undefined { + const excludedDates = batchIds + .map((id) => this.history[id]?.log.date) + .filter((d): d is string => Boolean(d)) + .map((d) => Number(d)); + if (!excludedDates.length) return undefined; + const earliestExcludedDate = Math.min(...excludedDates); + const candidates = Object.values(this.history) + .filter((e) => Number(e.log.date) < earliestExcludedDate) + .sort((a, b) => Number(b.log.date) - Number(a.log.date)); + return candidates[0]; } removeHistoryEntries(keys: string[]) { diff --git a/scopes/scope/objects/models/lane.ts b/scopes/scope/objects/models/lane.ts index 8e8c6bc39d7f..2ffecc20910e 100644 --- a/scopes/scope/objects/models/lane.ts +++ b/scopes/scope/objects/models/lane.ts @@ -210,12 +210,38 @@ export default class Lane extends BitObject { shouldOverrideUpdateDependents() { return this.overrideUpdateDependents; } + /** + * Single-shot restore of both `updateDependents` and `overrideUpdateDependents` — used by + * `bit reset` when rewinding to a prior `LaneHistory` entry. Direct property reset without + * going through `addComponentToUpdateDependents` (which has "add" semantics and would not + * handle the "wipe and set" case cleanly). + */ + setUpdateDependentsAndOverride(updateDependents: ComponentID[] | undefined, override: boolean) { + this.updateDependents = updateDependents?.length ? [...updateDependents] : undefined; + this.overrideUpdateDependents = override || undefined; + this.hasChanged = true; + } /** * !!! important !!! - * this should get called only on a "temp lane", such as running "bit _snap", which the scope gets destroys after the - * command is done. when _scope exports the lane, this "overrideUpdateDependents" is not saved to the remote-scope. + * this flag is a one-shot instruction for the NEXT export: "the `updateDependents` list on this + * lane was rewritten locally — send them over, the remote should accept them as-is instead of + * ignoring them". Two callers are allowed to set it on a local lane: + * - bare-scope `bit _snap --update-dependents` (the scope is destroyed after the command) + * - workspace `bit snap` when it cascades updateDependents via `includeUpdateDependentsInSnap` + * + * The flag DOES round-trip through `toObject()` / `parse()` — that's how it: + * 1. survives on-disk persistence between `bit snap` and a later `bit export` process; + * 2. travels on the wire as part of the lane payload so the remote's `sources.mergeLane` + * (see `components/legacy/scope/repositories/sources.ts`) can read it off the INCOMING + * lane and decide whether to override `existingLane.updateDependents`. + * + * The remote clears the flag on the merge result before persisting (see `sources.mergeLane` + * with `isExport=true`), so it's never stored on the remote scope — even on a first-time push + * where there's no `existingLane` and the incoming `lane` becomes the stored object. * - * on a user local lane object, this prop should never be true. otherwise, it'll override the remote-scope data. + * In the workspace flow, `updateLanesAfterExport` also clears the flag locally once the push + * has succeeded, so the lane is never left in the "override" state after its request has + * been honored. */ setOverrideUpdateDependents(overrideUpdateDependents: boolean) { this.overrideUpdateDependents = overrideUpdateDependents;