Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
49 changes: 37 additions & 12 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,27 +743,42 @@ 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()) {
// 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]));
await Promise.all(
(lane.updateDependents || []).map(async (id) => {
const existing = existingLane.updateDependents?.find((existingId) => existingId.isEqualWithoutVersion(id));
incomingHidden.map(async (id) => {
const existing = existingByName.get(id.toStringWithoutVersion());
if (!existing || existing.version !== id.version) {
const mergedComponent = await getModelComponent(id);
Comment thread
davidfirst marked this conversation as resolved.
Outdated
mergeResults.push({ mergedComponent, mergedVersions: [id.version] });
}
})
);
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 };
}
}
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
Loading