Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
16 changes: 10 additions & 6 deletions components/legacy/component-list/components-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,16 @@ export class ComponentsList {
const pendingExportComponents = await pFilter(modelComponents, async (component: ModelComponent) => {
const foundInBitMap = fromBitMap.searchWithoutVersion(component.toComponentId());
if (!foundInBitMap) {
// it's not on the .bitmap only in the scope, as part of the out-of-sync feature, it should
// be considered as staged and should be exported. same for soft-removed components, which are on scope only.
// notice that we use `hasLocalChanges`
// and not `isLocallyChanged` by purpose. otherwise, cached components that were not
// updated from a remote will be calculated as remote-ahead in the setDivergeData and will
// be exported unexpectedly.
// it's not on the .bitmap only in the scope. Two cases land here:
// - out-of-sync: a workspace component that lost its bitmap entry but still has scope data
// - hidden lane entry: a `skipWorkspace: true` lane component (cascade-on-snap, bare-scope
// `_snap --update-dependents`) that exists only in scope+lane, never the workspace
// For the lane case, fall back to lane-aware divergence so the cascade snap is detected
// as source-ahead and gets exported. Without this, the new hidden version would be
// computed but never sent over the wire, and the remote would reject the lane object.
if (lane && lane.getComponent(component.toComponentId())) {
return component.isLocallyChanged(this.scope.objects, lane);
}
return component.isLocallyChangedRegardlessOfLanes();
}
return component.isLocallyChanged(this.scope.objects, lane, foundInBitMap);
Expand Down
36 changes: 27 additions & 9 deletions components/legacy/scope/repositories/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,10 @@ to quickly fix the issue, please delete the object at "${this.objects().objectPa
}
}

const head = component.head || laneItem?.head;
// for hidden lane entries (skipWorkspace), the lane head is independent of main's head and
// we need to walk *its* parent chain to find the prior snap to rewind to. Prefer the lane
// head over the modelComponent's main head in that case.
const head = laneItem?.skipWorkspace ? laneItem.head : component.head || laneItem?.head;
if (!head) {
Comment thread
davidfirst marked this conversation as resolved.
Outdated
return undefined;
}
Expand Down Expand Up @@ -720,8 +723,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,12 +743,12 @@ 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()) {
Expand All @@ -754,6 +764,14 @@ possible causes:
existingLane.updateDependents = lane.updateDependents;
}

return { mergeResults, mergeErrors, mergeLane: existingLane || lane };
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 };
}
}
16 changes: 11 additions & 5 deletions scopes/component/merging/merging.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,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
50 changes: 49 additions & 1 deletion scopes/component/snapping/snapping.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,16 +532,27 @@ 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);

const { taggedComponents } = results;
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
Expand Down Expand Up @@ -738,9 +749,46 @@ 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).
const isHiddenLaneEntry = !consumer.bitMap.getComponentIfExist(component.toComponentId(), {
ignoreVersion: true,
});
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;
Comment thread
davidfirst marked this conversation as resolved.
// refresh the modelComponent's lane heads against the post-reset lane state, so
// `getLocalHashes` (which derives from divergeData) sees the rewound source head and
// can correctly determine "no unexported snaps remain".
await mc.populateLocalAndRemoteHeads(repo, currentLane);
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();
Expand Down
77 changes: 69 additions & 8 deletions scopes/component/snapping/version-maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,19 +162,39 @@ 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`
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,
Expand All @@ -184,6 +204,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();
Expand All @@ -194,7 +221,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.
Expand Down Expand Up @@ -388,24 +426,47 @@ export class VersionMaker {

private async getAutoTagData(idsToTag: ComponentIdList): Promise<AutoTagResult[]> {
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());
const autoTagDataWithLocalOnly = await this.workspace.getAutoTagInfo(
ComponentIdList.fromArray(idsToTriggerAutoTag)
);
const localOnly = this.workspace?.listLocalOnly();
return localOnly
const fromWorkspace = localOnly
? autoTagDataWithLocalOnly.filter((autoTagItem) => !localOnly.hasWithoutVersion(autoTagItem.component.id))
: autoTagDataWithLocalOnly;
if (!fromScope.length) return fromWorkspace;
// Dedupe: workspace autotag wins if the same id surfaces in both (the workspace consumer
// component is the authoritative one to snap).
const workspaceIds = new Set(fromWorkspace.map((a) => a.component.id.toStringWithoutVersion()));
const fromScopeFiltered = fromScope.filter((a) => !workspaceIds.has(a.component.id.toStringWithoutVersion()));
return [...fromWorkspace, ...fromScopeFiltered];
}

private async getLaneAutoTagIdsFromScope(idsToTag: ComponentIdList): Promise<AutoTagResult[]> {
private async getLaneAutoTagIdsFromScope(idsToTag: ComponentIdList, hiddenOnly = false): Promise<AutoTagResult[]> {
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());
Expand Down
16 changes: 10 additions & 6 deletions scopes/harmony/api-server/api-for-ide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,16 @@ export class APIForIDE {
async getCurrentLaneObject(): Promise<LaneObj | undefined> {
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,
Expand Down
Loading