Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bcbca82
refactor(lane): unify lane.updateDependents into lane.components via …
davidfirst Apr 27, 2026
8eb6a1d
fix(merge,importer): always fetch all lane entries; prefetch main obj…
davidfirst Apr 27, 2026
444e8fc
feat(snapping,export): cascade hidden lane entries through autotag an…
davidfirst Apr 27, 2026
89d0848
feat(snapping,reset): scenario 1, 5, 6, 7, 8, 9 of cascade spec passing
davidfirst Apr 27, 2026
0f537a6
feat(snapping): scope-side reverse cascade for _snap --update-dependents
davidfirst Apr 27, 2026
4db4c92
test: remove cascade spec copy from bit4/e2e
davidfirst Apr 27, 2026
de49bbb
Merge branch 'master' into rearchitect-update-dependents-skipworkspace
davidfirst Apr 27, 2026
524c937
fix(lane): updateDependents setter sets hasChanged and validates version
davidfirst Apr 27, 2026
a5fbfff
refactor(snapping): simplify addToUpdateDependentsInLane; bound concu…
davidfirst Apr 27, 2026
712bc05
Merge branch 'master' into rearchitect-update-dependents-skipworkspace
davidfirst Apr 28, 2026
5a7c490
perf+fix: cache updateDependents getter in hot path; include hidden i…
davidfirst Apr 28, 2026
e64892d
fix(lane,sources): isEqual covers hidden entries; bound override-bran…
davidfirst Apr 28, 2026
263288f
Merge branch 'master' into rearchitect-update-dependents-skipworkspace
davidfirst Apr 28, 2026
a65c567
fix(lane): isEqual covers skipWorkspace and isDeleted flips
davidfirst Apr 28, 2026
f269bd4
fix(reset): invalidate cached divergeData before checking for residua…
davidfirst Apr 28, 2026
4973029
fix(reset): use setDivergeData(fromCache=false) instead of touching p…
davidfirst Apr 28, 2026
893268c
fix(reset,lane): bitmap restore for soft-deleted, lane invariants
davidfirst Apr 29, 2026
4a24ecc
Merge remote-tracking branch 'origin/master' into rearchitect-update-…
davidfirst Apr 29, 2026
ce7eda3
fix(status,reset): hide cascade updateDependents from workspace-stage…
davidfirst Apr 29, 2026
e2cf284
fix(reset): walk lane parent chain for lane components, not main head
davidfirst Apr 29, 2026
1fe9242
fix(export): drop misleading 'files not tracked' warning for hidden u…
davidfirst Apr 29, 2026
568fead
address copilot review: jsdoc, dead LaneProps field, O(N) classification
davidfirst Apr 29, 2026
e930911
fix(merge-lanes): refresh hidden updateDependents on workspace 'bit l…
davidfirst Apr 30, 2026
3a8ae12
test(lanes): port updateDependents cascade scenarios to bit e2e suite
davidfirst Apr 30, 2026
b374175
Merge branch 'master' into rearchitect-update-dependents-skipworkspace
davidfirst Apr 30, 2026
5291546
docs(component-list): merge consecutive JSDoc blocks on listExportPen…
davidfirst Apr 30, 2026
0af9333
test(lanes): remove cascade scenarios that require 'bit sign' (not in…
davidfirst Apr 30, 2026
521b67b
test(lanes): destroy helper temp dirs before reassigning in scenario 4
davidfirst Apr 30, 2026
ba768e6
test(lanes): add scenario 14 — bit lane history with hidden updateDep…
davidfirst Apr 30, 2026
701d039
feat(lanes): record updateDependents in lane history and rewind them …
davidfirst Apr 30, 2026
0800fbb
fix(merge-lanes): use merged ConsumerComponent for hidden cascade snaps
davidfirst May 1, 2026
2680dae
fix(lanes): treat history.updateDependents as authoritative on checko…
davidfirst May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions components/legacy/component-list/components-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,39 @@ export class ComponentsList {
* will be easier to add it here once all legacy are not using this class and then ScopeMain will be in the
* constructor.
*/
async listExportPendingComponentsIds(lane?: Lane | null): Promise<ComponentIdList> {
/**
* @param includeHiddenLaneEntries when true, lane components with `skipWorkspace: true` (cascade
Comment thread
davidfirst marked this conversation as resolved.
* 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,
{ includeHiddenLaneEntries = false }: { includeHiddenLaneEntries?: boolean } = {}
): Promise<ComponentIdList> {
Comment thread
davidfirst marked this conversation as resolved.
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);
Expand Down Expand Up @@ -201,8 +222,11 @@ export class ComponentsList {
return ComponentIdList.fromArray(updatedIds);
}

async listExportPendingComponents(laneObj?: Lane): Promise<ModelComponent[]> {
const exportPendingComponentsIds: ComponentIdList = await this.listExportPendingComponentsIds(laneObj);
async listExportPendingComponents(
laneObj?: Lane,
options?: { includeHiddenLaneEntries?: boolean }
): Promise<ModelComponent[]> {
const exportPendingComponentsIds: ComponentIdList = await this.listExportPendingComponentsIds(laneObj, options);
return Promise.all(exportPendingComponentsIds.map((id) => this.scope.getModelComponent(id)));
}

Expand Down
62 changes: 48 additions & 14 deletions components/legacy/scope/repositories/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
},
Expand All @@ -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 };
}
}
111 changes: 103 additions & 8 deletions scopes/component/merging/merging.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -684,12 +702,89 @@ 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, 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 are already on the loaded
* ConsumerComponent.
*/
private async snapHiddenForMerge(
hiddenIds: ComponentIdList,
snapMessage: string | undefined,
lane: Lane | undefined
): Promise<MergeSnapResults> {
const legacyScope = this.scope.legacyScope;
const snappedComponents: ConsumerComponent[] = [];
await mapSeries(hiddenIds, async (id) => {
// current lane head is the parent we descend from. The unmergedComponent entry (set in
// `applyVersion`) provides the second parent (main's head) — `_addCompToObjects` reads it.
const laneEntry = lane?.getComponent(id);
const previouslyUsedVersion = laneEntry?.head?.toString();
if (!previouslyUsedVersion) {
throw new BitError(`snapHiddenForMerge: lane entry for ${id.toString()} has no head`);
}
const idAtHead = id.changeVersion(previouslyUsedVersion);
const consumerComponent = await legacyScope.getConsumerComponent(idAtHead);
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;
Comment thread
davidfirst marked this conversation as resolved.
Outdated
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(
Expand Down
7 changes: 6 additions & 1 deletion scopes/component/snapping/reset-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ export async function getComponentsWithOptionToUntag(
): Promise<ModelComponent[]> {
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);
Expand Down
Loading