From 5f20bfbd7faf2c89d673773a8c41d7642c0953ca Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 11:31:24 -0400 Subject: [PATCH 01/14] fix(ci): fail the status verification only on components the run snaps or tags A real scope carries components with tag blockers (circular dependencies on teambit.api-reference). The global verification halted every snap in the repository, while bit snap itself scopes its checks to the snapped components. The verification now fails only on issues in listTagPendingIds; bit ci verify stays global. Co-Authored-By: Claude Fable 5 --- e2e/harmony/ci-sync.e2e.ts | 34 +++++++++++++++++++++++ scopes/git/ci/ci.main.runtime.ts | 46 ++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index 797ced629d78..4f747382beab 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1120,6 +1120,40 @@ describe('bit ci sync', function () { }); }); + // A real scope carries components with tag blockers (e.g. circular dependencies). The snap only + // includes the lane's pending components, so a blocker on an untouched component must not halt it. + describe('a snap-blocking issue on a component the lane never touches', () => { + const LANE = 'clean-lane'; + let defaultBranch: string; + let devPath: string; + + before(() => { + ({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] })); + // a circular pair on main: a tag blocker on components no lane sync will ever snap + helper.fs.outputFile('comp3/index.js', `require('@${helper.scopes.remote}/comp4');`); + helper.fs.outputFile('comp4/index.js', `require('@${helper.scopes.remote}/comp3');`); + helper.command.addComponent('comp3'); + helper.command.addComponent('comp4'); + helper.command.install(); + helper.command.tagAllWithoutBuild('--ignore-issues="CircularDependencies"'); + helper.command.export(); + helper.command.runCmd('git add -A'); + helper.command.runCmd('git commit -m "add a circular pair to main"'); + helper.command.runCmd(`git push origin ${defaultBranch}`); + devPath = createLaneWithSnap(LANE, { 'comp1/index.js': comp1Src('lane-snap-1') }, 'lane snap 1'); + seedSync(LANE); + branchSideCommit(LANE, defaultBranch, 'comp1/index.js', comp1Src('dev-commit-1'), 'dev commit on comp1'); + }); + + it('snaps the dev commit onto the lane although the untouched pair has a tag blocker', () => { + const { output, exitCode } = syncRun(LANE); + expect(exitCode, `bit ci sync output:\n${output}`).to.equal(0); + expect(output).to.not.include('Workspace status verification failed'); + expect(output).to.include(`${LANE} -> export-branch`); + expect(laneTipFile(devPath, 'comp1/index.js')).to.include('dev-commit-1'); + }); + }); + describe('a stale bit-sync/main that conflicts with the default branch', () => { const SYNC_BRANCH = 'bit-sync/main'; let defaultBranch: string; diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index 183077f012c7..0aa50b2607fc 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -1,6 +1,6 @@ import type { RuntimeDefinition, SlotRegistry } from '@teambit/harmony'; import { Slot } from '@teambit/harmony'; -import { CLIAspect, type CLIMain, MainRuntime } from '@teambit/cli'; +import { CLIAspect, type CLIMain, MainRuntime, formatWarningSummary } from '@teambit/cli'; import { LoggerAspect, type LoggerMain, type Logger } from '@teambit/logger'; import { WorkspaceAspect, type Workspace } from '@teambit/workspace'; import { BuilderAspect, type BuilderMain } from '@teambit/builder'; @@ -434,22 +434,46 @@ export class CiMain { return 'chore: update .bitmap and lockfiles as needed [skip ci]'; } - private async verifyWorkspaceStatusInternal(strict: boolean = false) { + /** + * `scopeToPendingComponents`: fail only on issues in the components a snap/tag would include + * (`listTagPendingIds`). A real scope carries components with tag blockers (e.g. circular + * dependencies), and a global failure would block every snap in the repo — including snaps that + * never touch the blocked components. The snap itself still refuses its own components' blockers. + */ + private async verifyWorkspaceStatusInternal( + strict: boolean = false, + { scopeToPendingComponents = false }: { scopeToPendingComponents?: boolean } = {} + ) { this.logger.console('📊 Workspace Status'); this.logger.console(chalk.blue('Verifying status of workspace')); + const formatOptions = strict + ? { strict: true, warnings: true } // When strict=true, fail on both issues and warnings + : { failOnError: true, warnings: false }; // By default, fail only on errors (tag blockers) const status = await this.status.status({ lanes: true }); - const { data: statusOutput, code } = await this.status.formatStatusOutput( - status, - strict - ? { strict: true, warnings: true } // When strict=true, fail on both errors and warnings - : { failOnError: true, warnings: false } // By default, fail only on errors (tag blockers) - ); + const { data: statusOutput, code } = await this.status.formatStatusOutput(status, formatOptions); // Log the formatted status output this.logger.console(statusOutput); - if (code !== 0) { + let effectiveCode = code; + if (code !== 0 && scopeToPendingComponents) { + const pending = ComponentIdList.fromArray(await this.workspace.listTagPendingIds()); + const scoped = { + ...status, + componentsWithIssues: status.componentsWithIssues.filter((c) => pending.hasWithoutVersion(c.id)), + }; + ({ code: effectiveCode } = await this.status.formatStatusOutput(scoped, formatOptions)); + if (effectiveCode === 0) { + this.logger.console( + formatWarningSummary( + 'The issues above are on components this run does not snap or tag — they do not block it' + ) + ); + } + } + + if (effectiveCode !== 0) { throw new Error('Workspace status verification failed'); } @@ -868,7 +892,7 @@ export class CiMain { const laneId = await this.lanes.parseLaneId(laneIdStr); - await this.verifyWorkspaceStatusInternal(strict); + await this.verifyWorkspaceStatusInternal(strict, { scopeToPendingComponents: true }); await this.importer .import({ @@ -1652,7 +1676,7 @@ export class CiMain { ); } - const { status } = await this.verifyWorkspaceStatusInternal(strict); + const { status } = await this.verifyWorkspaceStatusInternal(strict, { scopeToPendingComponents: true }); const hasSoftTaggedComponents = status.softTaggedComponents.length > 0; From 4540f7f24ecf2915c286becf2f0ba22c1c39da02 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 13:31:20 -0400 Subject: [PATCH 02/14] feat(ci): pure classification helpers for dependency-context drift --- scopes/git/ci/sync/context-drift.spec.ts | 76 ++++++++++++++++++++++++ scopes/git/ci/sync/context-drift.ts | 52 ++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 scopes/git/ci/sync/context-drift.spec.ts create mode 100644 scopes/git/ci/sync/context-drift.ts diff --git a/scopes/git/ci/sync/context-drift.spec.ts b/scopes/git/ci/sync/context-drift.spec.ts new file mode 100644 index 000000000000..ae8167c4ea91 --- /dev/null +++ b/scopes/git/ci/sync/context-drift.spec.ts @@ -0,0 +1,76 @@ +import { expect } from 'chai'; +import { classifyPayloadDiff, convergenceMessage, blockerNamesUnion } from './context-drift'; + +describe('classifyPayloadDiff', () => { + const base = { + files: [{ file: 'aaa', relativePath: 'index.js' }], + mainFile: 'index.js', + packageDependencies: { 'is-odd': '1.0.0' }, + devPackageDependencies: {}, + peerPackageDependencies: {}, + log: { date: '1', username: 'a' }, + }; + + it('classifies a package-range-only change as depOnly', () => { + const fromFs = { ...base, packageDependencies: { 'is-odd': '3.0.1' }, log: { date: '2', username: 'b' } }; + const res = classifyPayloadDiff(base, fromFs); + expect(res.depOnly).to.equal(true); + expect(res.changedKeys).to.deep.equal(['packageDependencies']); + }); + + it('classifies a dev/peer reclassification as depOnly', () => { + const recorded = { ...base, peerDependencies: [{ id: 'scope/link' }], dependencies: [] }; + const fromFs = { ...base, peerDependencies: [], dependencies: [{ id: 'scope/link' }] }; + expect(classifyPayloadDiff(recorded, fromFs).depOnly).to.equal(true); + }); + + it('rejects a file change even when deps also changed', () => { + const fromFs = { + ...base, + files: [{ file: 'bbb', relativePath: 'index.js' }], + packageDependencies: { 'is-odd': '3.0.1' }, + }; + const res = classifyPayloadDiff(base, fromFs); + expect(res.depOnly).to.equal(false); + expect(res.changedKeys).to.include('files'); + }); + + it('rejects an extensions (config) change', () => { + const fromFs = { ...base, extensions: [{ name: 'teambit.envs/envs', config: { env: 'x' } }] }; + expect(classifyPayloadDiff(base, fromFs).depOnly).to.equal(false); + }); +}); + +describe('convergenceMessage', () => { + it('names the recorded and running bit versions', () => { + const msg = convergenceMessage(['1.12.61', '1.12.61', undefined], '2.0.69'); + expect(msg).to.equal('chore: align dependency context (recorded with bit 1.12.61, workspace runs bit 2.0.69)'); + }); + it('lists distinct recorded versions', () => { + const msg = convergenceMessage(['1.12.61', '2.0.10'], '2.0.69'); + expect(msg).to.include('1.12.61, 2.0.10'); + }); + it('handles no recorded versions', () => { + expect(convergenceMessage([undefined], '2.0.69')).to.equal( + 'chore: align dependency context (workspace runs bit 2.0.69)' + ); + }); +}); + +describe('blockerNamesUnion', () => { + const entry = (idStr: string, names: string[], blocker: boolean) => ({ + id: { toStringWithoutVersion: () => idStr }, + issues: { getAllIssueNames: () => names, hasTagBlockerIssues: () => blocker }, + }); + + it('unions blocker issue names of in-set components only', () => { + const res = blockerNamesUnion( + [entry('s/a', ['CircularDependencies'], true), entry('s/b', ['MissingDists'], true)], + new Set(['s/a']) + ); + expect(res).to.equal('CircularDependencies'); + }); + it('returns undefined when no in-set component has blockers', () => { + expect(blockerNamesUnion([entry('s/a', ['X'], false)], new Set(['s/a']))).to.equal(undefined); + }); +}); diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts new file mode 100644 index 000000000000..35bffcd2598d --- /dev/null +++ b/scopes/git/ci/sync/context-drift.ts @@ -0,0 +1,52 @@ +import { isEqual, omit } from 'lodash'; + +export const DRIFT_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'extensionDependencies', + 'flattenedDependencies', + 'packageDependencies', + 'devPackageDependencies', + 'peerPackageDependencies', +] as const; + +// Keys that legitimately differ between a recorded Version and one rebuilt +// from the filesystem, independent of any user change. +const VOLATILE_FIELDS = ['log', 'parents', 'squashed', 'origin'] as const; + +const EXCLUDED = [...DRIFT_FIELDS, ...VOLATILE_FIELDS]; + +export function classifyPayloadDiff( + recorded: Record, + fromFs: Record +): { depOnly: boolean; changedKeys: string[] } { + const keys = new Set([...Object.keys(recorded), ...Object.keys(fromFs)]); + const changedKeys = [...keys].filter( + (k) => !(VOLATILE_FIELDS as readonly string[]).includes(k) && !isEqual(recorded[k], fromFs[k]) + ); + const depOnly = isEqual(omit(recorded, EXCLUDED), omit(fromFs, EXCLUDED)); + return { depOnly, changedKeys }; +} + +export function convergenceMessage(recordedBitVersions: (string | undefined)[], runningBitVersion: string): string { + const distinct = [...new Set(recordedBitVersions.filter(Boolean))] as string[]; + const recordedPart = distinct.length ? `recorded with bit ${distinct.join(', ')}, ` : ''; + return `chore: align dependency context (${recordedPart}workspace runs bit ${runningBitVersion})`; +} + +export function blockerNamesUnion( + componentsWithIssues: { + id: { toStringWithoutVersion(): string }; + issues: { getAllIssueNames(): string[]; hasTagBlockerIssues(): boolean }; + }[], + inSet: Set +): string | undefined { + const names = new Set(); + for (const entry of componentsWithIssues) { + if (!inSet.has(entry.id.toStringWithoutVersion())) continue; + if (!entry.issues.hasTagBlockerIssues()) continue; + entry.issues.getAllIssueNames().forEach((n) => names.add(n)); + } + return names.size ? [...names].join(',') : undefined; +} From deaff85c722c0182790e9b0287146f14f7ceaf82 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 15:02:16 -0400 Subject: [PATCH 03/14] feat(ci): lane sync snaps pending minus dependency-context drift; verification scopes to the snap set Co-Authored-By: Claude Fable 5 --- e2e/harmony/ci-sync.e2e.ts | 49 ++++ scopes/git/ci/ci.main.runtime.ts | 292 ++++--------------- scopes/git/ci/sync/context-drift-detector.ts | 50 ++++ scopes/git/ci/sync/lane-sync-executor.ts | 19 ++ scopes/git/ci/sync/main-config-sync.ts | 246 ++++++++++++++++ 5 files changed, 416 insertions(+), 240 deletions(-) create mode 100644 scopes/git/ci/sync/context-drift-detector.ts create mode 100644 scopes/git/ci/sync/main-config-sync.ts diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index 4f747382beab..7236750b0b61 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1154,6 +1154,55 @@ describe('bit ci sync', function () { }); }); + // The engine-bump analogue reproducible with one bit binary: the committed root policy moves a + // recorded package range. The lane run must snap only the git-authored change and report the + // drifted component instead of sweeping it into the dev's snap. + describe('dependency-context drift is excluded from the lane snap', () => { + const LANE = 'drift-lane'; + let defaultBranch: string; + let devPath: string; + + before(() => { + ({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] })); + helper.fs.outputFile('comp2/index.js', `require('is-odd');\nmodule.exports = () => 'comp2: with-pkg';\n`); + helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '1.0.0' } }); + helper.command.install(); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + helper.command.runCmd('git add -A'); + helper.command.runCmd('git commit -m "comp2 records is-odd 1.0.0"'); + helper.command.runCmd(`git push origin ${defaultBranch}`); + // Lane creation must happen while the policy still matches comp2's recorded range — otherwise + // the dev's own (unscoped) `bit snap` would sweep the drift in too, and there'd be nothing left + // for `bit ci sync` to exclude. + devPath = createLaneWithSnap(LANE, { 'comp1/index.js': comp1Src('lane-snap-1') }, 'lane snap 1'); + seedSync(LANE); + branchSideCommit(LANE, defaultBranch, 'comp1/index.js', comp1Src('dev-commit-1'), 'dev commit on comp1'); + // The default branch's own resolution context moves AFTER the lane forked — the analogue of an + // engine bump: `bit ci sync` boots on the default branch, and a workspace-level policy/engine + // aggregate is resolved once at that boot (mid-run branch checkouts don't re-read it — see + // `Workspace._reloadConsumer`, which reloads the consumer/bitmap but not this). So the run's + // *actual* resolution context is whatever is in effect here, regardless of which branch it + // later checks out — exactly the drift a real engine bump produces on an untouched component. + helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '3.0.1' } }); + helper.command.install(); + helper.command.runCmd('git add -A'); + helper.command.runCmd('git commit -m "bump is-odd policy (engine-bump analogue)"'); + helper.command.runCmd(`git push origin ${defaultBranch}`); + }); + + it('snaps the dev commit, reports the drifted component, and keeps it off the lane', () => { + const before = remoteLaneFingerprint(LANE); + expect(before).to.not.include('comp2'); + const { output, exitCode } = syncRun(LANE); + expect(exitCode, `bit ci sync output:\n${output}`).to.equal(0); + expect(output).to.include('dependency-context drift'); + expect(output).to.include('comp2'); + expect(laneTipFile(devPath, 'comp1/index.js')).to.include('dev-commit-1'); + expect(remoteLaneFingerprint(LANE)).to.not.include('comp2'); + }); + }); + describe('a stale bit-sync/main that conflicts with the default branch', () => { const SYNC_BRANCH = 'bit-sync/main'; let defaultBranch: string; diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index 0aa50b2607fc..51a05bda1e50 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -13,9 +13,6 @@ import { ExportAspect, type ExportMain } from '@teambit/export'; import { ImporterAspect, type ImporterMain } from '@teambit/importer'; import { CheckoutAspect, checkoutOutput, type CheckoutMain } from '@teambit/checkout'; import type { MergeStrategy } from '@teambit/component.modules.merge-helper'; -import { getDivergeData } from '@teambit/component.snap-distance'; -import { ComponentConfigMerger } from '@teambit/config-merger'; -import { DependencyResolverAspect } from '@teambit/dependency-resolver'; import execa from 'execa'; import chalk from 'chalk'; import type { ReleaseType } from 'semver'; @@ -28,18 +25,20 @@ import { CiSyncCmd } from './commands/sync.cmd'; import { git } from './git'; import { ComponentIdList } from '@teambit/component-id'; import type { ComponentID } from '@teambit/component-id'; -import { compact, isEqual } from 'lodash'; +import { isEqual } from 'lodash'; import type { Version, LaneComponent, Lane } from '@teambit/objects'; import { Ref } from '@teambit/objects'; import type { LaneId } from '@teambit/lane-id'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import { SourceBranchDetector } from './source-branch-detector'; import { generateRandomStr } from '@teambit/toolbox.string.random'; -import { pMapPool } from '@teambit/toolbox.promise.map-pool'; -import { concurrentComponentsLimit } from '@teambit/harmony.modules.concurrency'; import { extractSkipTasksFromMessage } from './skip-tasks-from-message'; import { isPullRequestRef } from './pull-request-ref'; import { adoptAndRetrySwitch, isLaneMissingComponentError } from './sync/adopt-lane-new-components'; +import { getBitVersion } from '@teambit/bit.get-bit-version'; +import { detectContextDrift as detectContextDriftImpl } from './sync/context-drift-detector'; +import type { ContextDriftReport } from './sync/context-drift-detector'; +import { syncConfigFromMain as syncConfigFromMainImpl } from './sync/main-config-sync'; export type CiSwitchLaneOptions = SwitchLaneOptions & { /** after an adoption retry, write the adopted components' files (`checkout --reset`) */ @@ -58,6 +57,8 @@ import { isValidGitBranchName } from './sync/sync-config'; */ export type GitHostProviderSlot = SlotRegistry; +export type { ContextDriftReport }; + // Two distinct conflicts can surface from the remote on a concurrent `bit ci pr` race. // LANE_HASH_MISMATCH fires when both runners called `Lane.create` (the lane didn't exist on // the remote yet), so they each minted a random `sha1(v4())` hash — `sources.mergeLane` then @@ -435,15 +436,12 @@ export class CiMain { } /** - * `scopeToPendingComponents`: fail only on issues in the components a snap/tag would include - * (`listTagPendingIds`). A real scope carries components with tag blockers (e.g. circular - * dependencies), and a global failure would block every snap in the repo — including snaps that - * never touch the blocked components. The snap itself still refuses its own components' blockers. + * `snapIds`: fail only on issues in the components this run actually snaps. A real scope carries + * components with tag blockers (e.g. circular dependencies), and a global failure would block every + * snap in the repo — including snaps that never touch the blocked components. The snap itself still + * refuses its own components' blockers. */ - private async verifyWorkspaceStatusInternal( - strict: boolean = false, - { scopeToPendingComponents = false }: { scopeToPendingComponents?: boolean } = {} - ) { + private async verifyWorkspaceStatusInternal(strict: boolean = false, { snapIds }: { snapIds?: ComponentID[] } = {}) { this.logger.console('📊 Workspace Status'); this.logger.console(chalk.blue('Verifying status of workspace')); @@ -457,18 +455,16 @@ export class CiMain { this.logger.console(statusOutput); let effectiveCode = code; - if (code !== 0 && scopeToPendingComponents) { - const pending = ComponentIdList.fromArray(await this.workspace.listTagPendingIds()); + if (code !== 0 && snapIds) { + const inSet = ComponentIdList.fromArray(snapIds); const scoped = { ...status, - componentsWithIssues: status.componentsWithIssues.filter((c) => pending.hasWithoutVersion(c.id)), + componentsWithIssues: status.componentsWithIssues.filter((c) => inSet.hasWithoutVersion(c.id)), }; ({ code: effectiveCode } = await this.status.formatStatusOutput(scoped, formatOptions)); if (effectiveCode === 0) { this.logger.console( - formatWarningSummary( - 'The issues above are on components this run does not snap or tag — they do not block it' - ) + formatWarningSummary('The issues above are on components this run does not snap — they do not block it') ); } } @@ -556,226 +552,12 @@ export class CiMain { }).sync(opts); } - /** - * Sync *config-only* changes from main onto the lane — without a full `bit lane merge`. - * - * In this workflow git is the source of truth for files: the PR author merges the default branch - * into their PR branch, so source changes arrive via git. The one thing git can't carry is - * config that's already been *tagged into objects* on main — e.g. another PR ran `bit env set` / - * `bit deps set`; those records lived in `.bitmap`, rode git into main, and `bit ci merge` baked - * them into the component's Version (clearing them from `.bitmap`). A long-running PR's lane - * would otherwise miss them. - * - * A full lane merge is the wrong tool here: it does a 3-way *file* merge and refuses to run while - * the workspace has modified components — but in `bit ci pr` the workspace is always dirty (the - * PR's changes, not yet snapped). So instead we do a per-component 3-way merge of the aspect - * *config only* (base = common ancestor, ours = lane, theirs = main), keeping the PR's config on - * conflict, and stash the result on an `unmergedComponents` entry's `mergedConfig`. The - * subsequent `snap` reads it (via the aspects-merger on component load) and bakes main's config - * into the new snap, while the snap's files still come from the workspace (git). No file - * checkout, so no clean-workspace requirement. - */ + /** Config-only sync from main onto the lane; see `sync/main-config-sync.ts`. */ private async syncConfigFromMain(laneId: LaneId) { - const legacyScope = this.workspace.scope.legacyScope; - const repo = legacyScope.objects; - const mainLaneId = this.lanes.getDefaultLaneId(); - const currentLane = await this.lanes.getCurrentLane(); - if (!currentLane) return; - const workspaceIds = this.workspace.listIds(); - - this.logger.console(chalk.blue(`Syncing config changes from ${mainLaneId.toString()} into ${laneId.toString()}`)); - - // Resolve each lane component's head on main once, keeping only those that are on main and whose - // lane head differs from it (the rest have nothing to sync). This single pass feeds both the - // pre-fetch below and the merge loop, so we never load the same ModelComponent twice. - const componentsToSync = compact( - await Promise.all( - currentLane.components.map(async (laneComp) => { - try { - const modelComponent = await legacyScope.getModelComponentIfExist(laneComp.id); - const mainHead = modelComponent?.head; // the component's head on main - if (!modelComponent || !mainHead || mainHead.isEqual(laneComp.head)) return undefined; - return { laneComp, modelComponent, mainHead }; - } catch (e: any) { - // Best-effort per component (same contract as the merge loop below): one component's - // load failure shouldn't reject Promise.all and abort the whole config sync. - this.logger.console( - chalk.yellow( - ` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})` - ) - ); - return undefined; - } - }) - ) + await syncConfigFromMainImpl( + { workspace: this.workspace, lanes: this.lanes, importer: this.importer, logger: this.logger }, + laneId ); - - // The lane import (switchToLane) brought each component's lane history plus the lightweight - // version-history (the parent graph) — that's enough for the diverge check below to see that - // main is ahead — but NOT the full Version object for main's head wherever main advanced past - // the lane's fork point. Those objects live only on main and were never fetched. Without them - // `loadVersion(mainHead)` throws VersionNotFoundOnFS, the per-component catch swallows it as - // "skipping config sync from main", and the sync silently degrades to a no-op for every - // diverged component. Pre-fetch main's head objects in one batched remote call (mirroring the - // lane-merge flows — see merge-status-provider / merge-lanes). Pass the *specific* main-head - // version so `cache: true` still fetches it: the component already exists locally at its lane - // version, so a version-less id would look satisfied and skip the remote. - const mainHeadIds = componentsToSync.map(({ laneComp, mainHead }) => - laneComp.id.changeVersion(mainHead.toString()) - ); - await this.prefetchFromMainForConfigSync(mainHeadIds, 'head objects'); - - // Resolve each component's diverge state up front — before the merge loop — so we can also - // pre-fetch the common-ancestor objects below. getDivergeData only walks the parent graph, - // which is already local (switchToLane brings the version-history, and the head pre-fetch above - // reinforced it), so no full Version object is needed yet. Keep only components where main is - // actually ahead or diverged; the rest have nothing to bring in from main. Bound the fan-out - // (getDivergeData traverses each component's version graph) so a lane with many components - // doesn't spawn one unbounded burst of concurrent graph walks. - const componentsToMerge = compact( - await pMapPool( - componentsToSync, - async (item) => { - try { - const divergeData = await getDivergeData({ - repo, - modelComponent: item.modelComponent, - sourceHead: item.laneComp.head, - targetHead: item.mainHead, - throws: false, - }); - if (!divergeData.isTargetAhead() && !divergeData.isDiverged()) return undefined; - return { ...item, divergeData }; - } catch (e: any) { - // Best-effort per component (same contract as the merge loop below). - this.logger.console( - chalk.yellow( - ` ${item.laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})` - ) - ); - return undefined; - } - }, - { concurrency: concurrentComponentsLimit() } - ) - ); - - // The head pre-fetch above brought main's head Version plus the version-history (parent graph), - // but NOT the full Version object of the common ancestor (the lane's fork point) for components - // where the lane and main have BOTH snapped since the fork. The 3-way config merge below loads - // that base Version (`baseVersion.extensions`); without it, `loadVersion(baseSnap)` throws - // VersionNotFoundOnFS, the per-component catch swallows it as "skipping config sync from main", - // and the sync silently no-ops for every diverged component. The fork point lives on main and, - // like the head, was never fetched (includeVersionHistory carries the graph, not each ancestor's - // Version). Batch-fetch the bases in one call — same best-effort contract as the head pre-fetch. - const baseIds = compact( - componentsToMerge.map(({ laneComp, divergeData }) => { - const baseSnap = divergeData.commonSnapBeforeDiverge; - return baseSnap ? laneComp.id.changeVersion(baseSnap.toString()) : undefined; - }) - ); - await this.prefetchFromMainForConfigSync(baseIds, 'common-ancestor objects'); - - const syncedIds: ComponentID[] = []; - for (const { laneComp, modelComponent, mainHead, divergeData } of componentsToMerge) { - try { - const laneHead = laneComp.head; - const currentVersion = await modelComponent.loadVersion(laneHead.toString(), repo); - const otherVersion = await modelComponent.loadVersion(mainHead.toString(), repo); - // base = common ancestor. When the lane is strictly behind main (no divergence) the common - // ancestor IS the lane head, so the lane's own aspects serve as the base. - const baseSnap = divergeData.commonSnapBeforeDiverge; - const baseVersion = baseSnap ? await modelComponent.loadVersion(baseSnap.toString(), repo) : currentVersion; - - const configMerger = new ComponentConfigMerger( - laneComp.id.toStringWithoutVersion(), - workspaceIds, - undefined, // merging from main (the default lane) — there's no Lane object for it - currentVersion.extensions, - baseVersion.extensions, - otherVersion.extensions, - laneId.toString(), - mainLaneId.toString(), - this.logger, - 'ours' as MergeStrategy // keep the PR's config on a genuine conflict - ); - const mergedConfig = configMerger.merge().getSuccessfullyMergedConfig(); - if (!mergedConfig || !Object.keys(mergedConfig).length) continue; - - // Strip dependency deletion markers (version: '-'); the aspects-merger applies mergedConfig - // as-is, so a leftover '-' would land in the policy. - this.filterDeletedDependenciesFromConfig(mergedConfig); - - // Upsert: addEntry throws if an entry for this component already exists. A prior - // --keep-lane run that crashed mid-snap (or otherwise left unmerged.json entries behind) - // would otherwise make every later run throw here, skip the component, and keep serving - // stale config. Remove any existing entry first so repeated runs converge on main's latest. - legacyScope.objects.unmergedComponents.removeComponent(laneComp.id); - legacyScope.objects.unmergedComponents.addEntry({ - id: { scope: laneComp.id.scope, name: laneComp.id.fullName }, - head: mainHead, - laneId: mainLaneId, - mergedConfig, - }); - syncedIds.push(laneComp.id); - this.logger.console( - chalk.blue( - ` ${laneComp.id.toStringWithoutVersion()}: applying main's config (${Object.keys(mergedConfig).join(', ')})` - ) - ); - } catch (e: any) { - // Best-effort per component: one component's config-merge quirk shouldn't abort the whole - // `bit ci pr`. Log and move on — the build just won't reflect that component's main-side - // config this run. - this.logger.console( - chalk.yellow(` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})`) - ); - } - } - - if (!syncedIds.length) { - this.logger.console(chalk.blue('No config changes from main to sync')); - return; - } - await legacyScope.objects.unmergedComponents.write(); - // The components were already loaded (and their aspects cached) earlier in this run, before the - // unmergedComponents entries existed. Clear the cache so the upcoming `snap` reloads them and - // the aspects-merger folds in the synced `mergedConfig`. - this.workspace.clearAllComponentsCache(); - this.logger.console(chalk.green(`Synced config from main for ${syncedIds.length} component(s)`)); - } - - /** - * Batch-fetch main-side Version objects the config merge needs (heads, then common ancestors), - * mirroring the lane-merge flows (merge-status-provider / merge-lanes). Best-effort: a fetch - * hiccup shouldn't abort `bit ci pr` — the merge loop still runs and any component whose object is - * still missing just logs the existing per-component skip. `label` names which objects for the log. - */ - private async prefetchFromMainForConfigSync(ids: ComponentID[], label: string) { - if (!ids.length) return; - try { - await this.importer.importObjectsFromMainIfExist(ids, { cache: true }); - } catch (e: any) { - this.logger.console( - chalk.yellow(`Could not pre-fetch main's ${label} for config sync (continuing): ${e?.message || e}`) - ); - } - } - - /** - * Copied from `merging.main.runtime` (`filterDeletedDependenciesFromConfig`): the config merge - * can emit deletion markers (`version: '-'`) for deps removed on main. The aspects-merger applies - * `mergedConfig` verbatim, so strip those here to avoid writing a policy entry with version '-'. - */ - private filterDeletedDependenciesFromConfig(mergeConfig?: Record): void { - const policy: Record> | undefined = - mergeConfig?.[DependencyResolverAspect.id]?.policy; - if (!policy) return; - Object.keys(policy).forEach((depType) => { - const filtered = policy[depType].filter((dep) => dep.version !== '-'); - if (filtered.length === 0) delete policy[depType]; - else policy[depType] = filtered; - }); } /** @@ -816,6 +598,19 @@ export class CiMain { } } + /** The bit binary this process runs — compared against a drifted component's `recordedBitVersion`. */ + getRunningBitVersion(): string { + return getBitVersion(); + } + + /** + * Split the tag-pending set into git-authored changes and dependency-context drift. + * See `sync/context-drift-detector.ts` for what counts as drift. + */ + async detectContextDrift(): Promise { + return detectContextDriftImpl(this.workspace, this.logger); + } + async verifyWorkspaceStatus() { await this.verifyWorkspaceStatusInternal(); @@ -843,6 +638,7 @@ export class CiMain { skipCleanup, skipTasks, noDestructiveRecovery, + snapIds, }: { laneIdStr: string; message: string; @@ -860,6 +656,8 @@ export class CiMain { * stale-lane case throws instead, which the sync executor surfaces as a halt for a human. */ noDestructiveRecovery?: boolean; + /** Snap only these ids (no version), not every tag-pending component; unset for `bit ci pr` (global). */ + snapIds?: string[]; }) { // The post-export cleanup switches the workspace back to main, which re-checks-out main's HEAD // and re-imports every workspace component — pointless when the workspace is about to be @@ -892,7 +690,13 @@ export class CiMain { const laneId = await this.lanes.parseLaneId(laneIdStr); - await this.verifyWorkspaceStatusInternal(strict, { scopeToPendingComponents: true }); + const resolvedSnapIds = snapIds ? await this.workspace.resolveMultipleComponentIds(snapIds) : undefined; + if (resolvedSnapIds && !resolvedSnapIds.length) { + this.logger.console(chalk.yellow('No git-authored changes to snap (only dependency-context drift is pending)')); + return 'No changes detected, nothing to snap'; + } + + await this.verifyWorkspaceStatusInternal(strict, { snapIds: resolvedSnapIds }); await this.importer .import({ @@ -920,6 +724,7 @@ export class CiMain { skipCleanup: resolvedSkipCleanup, skipTasks: resolvedSkipTasks, noDestructiveRecovery, + snapIds: resolvedSnapIds, }); } return this.snapAndExportWithTempLane({ @@ -930,6 +735,7 @@ export class CiMain { dryRun, skipCleanup: resolvedSkipCleanup, skipTasks: resolvedSkipTasks, + snapIds: resolvedSnapIds, }); } @@ -992,6 +798,7 @@ export class CiMain { skipCleanup, skipTasks, noDestructiveRecovery, + snapIds, }: { laneId: LaneId; originalLane: Lane | undefined; @@ -1001,6 +808,7 @@ export class CiMain { skipCleanup: boolean; skipTasks?: string; noDestructiveRecovery?: boolean; + snapIds?: ComponentID[]; }) { // Query the remote (by name, to avoid fetching all lanes) so we know whether to reuse or create const existingLanes = await this.lanes.getLanes({ remote: laneId.scope, name: laneId.name }).catch((e) => { @@ -1163,6 +971,7 @@ export class CiMain { build, exitOnFirstFailedTask: true, skipTasks, + legacyBitIds: snapIds ? ComponentIdList.fromArray(snapIds) : undefined, }); if (!results) { @@ -1209,6 +1018,7 @@ export class CiMain { dryRun, skipCleanup, skipTasks, + snapIds, }: { laneId: LaneId; originalLane: Lane | undefined; @@ -1217,6 +1027,7 @@ export class CiMain { dryRun?: boolean; skipCleanup: boolean; skipTasks?: string; + snapIds?: ComponentID[]; }) { // Use unique temp lane name to avoid race conditions when multiple CI jobs run concurrently const tempLaneName = `${laneId.name}-${generateRandomStr(5)}`; @@ -1248,6 +1059,7 @@ export class CiMain { build, exitOnFirstFailedTask: true, skipTasks, + legacyBitIds: snapIds ? ComponentIdList.fromArray(snapIds) : undefined, }); if (!results) { @@ -1676,7 +1488,7 @@ export class CiMain { ); } - const { status } = await this.verifyWorkspaceStatusInternal(strict, { scopeToPendingComponents: true }); + const { status } = await this.verifyWorkspaceStatusInternal(strict); const hasSoftTaggedComponents = status.softTaggedComponents.length > 0; diff --git a/scopes/git/ci/sync/context-drift-detector.ts b/scopes/git/ci/sync/context-drift-detector.ts new file mode 100644 index 000000000000..8fa6158d9c82 --- /dev/null +++ b/scopes/git/ci/sync/context-drift-detector.ts @@ -0,0 +1,50 @@ +import chalk from 'chalk'; +import type { ComponentID } from '@teambit/component-id'; +import type { Workspace } from '@teambit/workspace'; +import type { Logger } from '@teambit/logger'; +import { classifyPayloadDiff } from './context-drift'; + +export type ContextDriftReport = { + /** dep-only diff vs the recorded version — never snapped by a lane run */ + drift: { id: ComponentID; recordedBitVersion?: string; changedKeys: string[] }[]; + /** pending minus drift: new components and file/config-diff components */ + gitAuthored: ComponentID[]; +}; + +/** + * Split the tag-pending set into git-authored changes and dependency-context drift. + * Drift = the diff against the recorded version is confined to dependency data; on a + * pristine checkout that means git did not touch the component — the resolution + * context (env template of the pinned engine, root policy) moved instead. + */ +export async function detectContextDrift(workspace: Workspace, logger: Logger): Promise { + const pending = await workspace.listTagPendingIds(); + const legacyScope = workspace.scope.legacyScope; + const repo = legacyScope.objects; + const drift: ContextDriftReport['drift'] = []; + const gitAuthored: ComponentID[] = []; + for (const id of pending) { + if (!id.hasVersion()) { + gitAuthored.push(id); // new component: git-authored by definition + continue; + } + try { + const modelComponent = await legacyScope.getModelComponent(id); + const recorded = await modelComponent.loadVersion(id.version as string, repo); + const comp = await workspace.get(id); + const consumerComp = comp.state._consumer.clone(); + consumerComp.log = recorded.log; // same normalization as consumer.isComponentModified + const { version: fromFs } = await legacyScope.sources.consumerComponentToVersion(consumerComp); + // Version.id() serializes to a JSON string (used for hashing) — parse both sides so the pure + // helper gets plain objects. + const { depOnly, changedKeys } = classifyPayloadDiff(JSON.parse(recorded.id()), JSON.parse(fromFs.id())); + if (depOnly) drift.push({ id, recordedBitVersion: recorded.bitVersion, changedKeys }); + else gitAuthored.push(id); + } catch (e: any) { + // best-effort per component: an unreadable model must not kill the run — treat as git-authored + logger.console(chalk.yellow(` ${id.toStringWithoutVersion()}: drift check skipped (${e?.message || e})`)); + gitAuthored.push(id); + } + } + return { drift, gitAuthored }; +} diff --git a/scopes/git/ci/sync/lane-sync-executor.ts b/scopes/git/ci/sync/lane-sync-executor.ts index 4be0260411b0..056fa99f49bd 100644 --- a/scopes/git/ci/sync/lane-sync-executor.ts +++ b/scopes/git/ci/sync/lane-sync-executor.ts @@ -766,10 +766,28 @@ export class LaneSyncExecutor { * stale-lane recovery (delete + re-fork the remote lane) into a throw. The lane object must be * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any * fetch, so it never warms a cold scope. + * + * Pending components are split into git-authored changes and dependency-context drift (a recorded + * dep range moved under the workspace's current resolution context, not under a dev's commit) before + * snapping: only the git-authored subset is passed as `snapIds`, so drift is never swept into a lane + * snap it never touched. Main-side convergence consumes drift separately (not this run's job). */ private async snapAndExportOntoLane(laneIdStr: string, message: string): Promise { try { await ensureCurrentLaneObject(this.deps.lanes); + const { drift, gitAuthored } = await this.deps.ci.detectContextDrift(); + if (drift.length) { + const running = this.deps.ci.getRunningBitVersion(); + const recorded = [...new Set(drift.map((d) => d.recordedBitVersion).filter(Boolean))].join(', '); + this.deps.logger.console( + `${drift.length} component(s) carry dependency-context drift` + + `${recorded ? ` (recorded with bit ${recorded}, running bit ${running})` : ''} — ` + + `main convergence consumes this; not snapped here:` + ); + drift.forEach((d) => + this.deps.logger.console(` ${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})`) + ); + } await this.deps.ci.snapPrCommit({ laneIdStr, message, @@ -778,6 +796,7 @@ export class LaneSyncExecutor { keepLane: true, skipCleanup: true, noDestructiveRecovery: true, + snapIds: gitAuthored.map((id) => id.toStringWithoutVersion()), }); return undefined; } catch (e: any) { diff --git a/scopes/git/ci/sync/main-config-sync.ts b/scopes/git/ci/sync/main-config-sync.ts new file mode 100644 index 000000000000..e1ba2d64e83e --- /dev/null +++ b/scopes/git/ci/sync/main-config-sync.ts @@ -0,0 +1,246 @@ +import chalk from 'chalk'; +import { compact } from 'lodash'; +import type { ComponentID } from '@teambit/component-id'; +import type { Workspace } from '@teambit/workspace'; +import type { LanesMain } from '@teambit/lanes'; +import type { ImporterMain } from '@teambit/importer'; +import type { Logger } from '@teambit/logger'; +import type { LaneId } from '@teambit/lane-id'; +import type { MergeStrategy } from '@teambit/component.modules.merge-helper'; +import { getDivergeData } from '@teambit/component.snap-distance'; +import { ComponentConfigMerger } from '@teambit/config-merger'; +import { DependencyResolverAspect } from '@teambit/dependency-resolver'; +import { pMapPool } from '@teambit/toolbox.promise.map-pool'; +import { concurrentComponentsLimit } from '@teambit/harmony.modules.concurrency'; + +export type MainConfigSyncDeps = { + workspace: Workspace; + lanes: LanesMain; + importer: ImporterMain; + logger: Logger; +}; + +/** + * Batch-fetch main-side Version objects the config merge needs (heads, then common ancestors), + * mirroring the lane-merge flows (merge-status-provider / merge-lanes). Best-effort: a fetch + * hiccup shouldn't abort `bit ci pr` — the merge loop still runs and any component whose object is + * still missing just logs the existing per-component skip. `label` names which objects for the log. + */ +async function prefetchFromMainForConfigSync( + { importer, logger }: MainConfigSyncDeps, + ids: ComponentID[], + label: string +) { + if (!ids.length) return; + try { + await importer.importObjectsFromMainIfExist(ids, { cache: true }); + } catch (e: any) { + logger.console( + chalk.yellow(`Could not pre-fetch main's ${label} for config sync (continuing): ${e?.message || e}`) + ); + } +} + +/** + * Copied from `merging.main.runtime` (`filterDeletedDependenciesFromConfig`): the config merge + * can emit deletion markers (`version: '-'`) for deps removed on main. The aspects-merger applies + * `mergedConfig` verbatim, so strip those here to avoid writing a policy entry with version '-'. + */ +function filterDeletedDependenciesFromConfig(mergeConfig?: Record): void { + const policy: Record> | undefined = + mergeConfig?.[DependencyResolverAspect.id]?.policy; + if (!policy) return; + Object.keys(policy).forEach((depType) => { + const filtered = policy[depType].filter((dep) => dep.version !== '-'); + if (filtered.length === 0) delete policy[depType]; + else policy[depType] = filtered; + }); +} + +/** + * Sync *config-only* changes from main onto the lane — without a full `bit lane merge`. + * + * In this workflow git is the source of truth for files: the PR author merges the default branch + * into their PR branch, so source changes arrive via git. The one thing git can't carry is + * config that's already been *tagged into objects* on main — e.g. another PR ran `bit env set` / + * `bit deps set`; those records lived in `.bitmap`, rode git into main, and `bit ci merge` baked + * them into the component's Version (clearing them from `.bitmap`). A long-running PR's lane + * would otherwise miss them. + * + * A full lane merge is the wrong tool here: it does a 3-way *file* merge and refuses to run while + * the workspace has modified components — but in `bit ci pr` the workspace is always dirty (the + * PR's changes, not yet snapped). So instead we do a per-component 3-way merge of the aspect + * *config only* (base = common ancestor, ours = lane, theirs = main), keeping the PR's config on + * conflict, and stash the result on an `unmergedComponents` entry's `mergedConfig`. The + * subsequent `snap` reads it (via the aspects-merger on component load) and bakes main's config + * into the new snap, while the snap's files still come from the workspace (git). No file + * checkout, so no clean-workspace requirement. + */ +export async function syncConfigFromMain(deps: MainConfigSyncDeps, laneId: LaneId) { + const { workspace, lanes, logger } = deps; + const legacyScope = workspace.scope.legacyScope; + const repo = legacyScope.objects; + const mainLaneId = lanes.getDefaultLaneId(); + const currentLane = await lanes.getCurrentLane(); + if (!currentLane) return; + const workspaceIds = workspace.listIds(); + + logger.console(chalk.blue(`Syncing config changes from ${mainLaneId.toString()} into ${laneId.toString()}`)); + + // Resolve each lane component's head on main once, keeping only those that are on main and whose + // lane head differs from it (the rest have nothing to sync). This single pass feeds both the + // pre-fetch below and the merge loop, so we never load the same ModelComponent twice. + const componentsToSync = compact( + await Promise.all( + currentLane.components.map(async (laneComp) => { + try { + const modelComponent = await legacyScope.getModelComponentIfExist(laneComp.id); + const mainHead = modelComponent?.head; // the component's head on main + if (!modelComponent || !mainHead || mainHead.isEqual(laneComp.head)) return undefined; + return { laneComp, modelComponent, mainHead }; + } catch (e: any) { + // Best-effort per component (same contract as the merge loop below): one component's + // load failure shouldn't reject Promise.all and abort the whole config sync. + logger.console( + chalk.yellow( + ` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})` + ) + ); + return undefined; + } + }) + ) + ); + + // The lane import (switchToLane) brought each component's lane history plus the lightweight + // version-history (the parent graph) — that's enough for the diverge check below to see that + // main is ahead — but NOT the full Version object for main's head wherever main advanced past + // the lane's fork point. Those objects live only on main and were never fetched. Without them + // `loadVersion(mainHead)` throws VersionNotFoundOnFS, the per-component catch swallows it as + // "skipping config sync from main", and the sync silently degrades to a no-op for every + // diverged component. Pre-fetch main's head objects in one batched remote call (mirroring the + // lane-merge flows — see merge-status-provider / merge-lanes). Pass the *specific* main-head + // version so `cache: true` still fetches it: the component already exists locally at its lane + // version, so a version-less id would look satisfied and skip the remote. + const mainHeadIds = componentsToSync.map(({ laneComp, mainHead }) => laneComp.id.changeVersion(mainHead.toString())); + await prefetchFromMainForConfigSync(deps, mainHeadIds, 'head objects'); + + // Resolve each component's diverge state up front — before the merge loop — so we can also + // pre-fetch the common-ancestor objects below. getDivergeData only walks the parent graph, + // which is already local (switchToLane brings the version-history, and the head pre-fetch above + // reinforced it), so no full Version object is needed yet. Keep only components where main is + // actually ahead or diverged; the rest have nothing to bring in from main. Bound the fan-out + // (getDivergeData traverses each component's version graph) so a lane with many components + // doesn't spawn one unbounded burst of concurrent graph walks. + const componentsToMerge = compact( + await pMapPool( + componentsToSync, + async (item) => { + try { + const divergeData = await getDivergeData({ + repo, + modelComponent: item.modelComponent, + sourceHead: item.laneComp.head, + targetHead: item.mainHead, + throws: false, + }); + if (!divergeData.isTargetAhead() && !divergeData.isDiverged()) return undefined; + return { ...item, divergeData }; + } catch (e: any) { + // Best-effort per component (same contract as the merge loop below). + logger.console( + chalk.yellow( + ` ${item.laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})` + ) + ); + return undefined; + } + }, + { concurrency: concurrentComponentsLimit() } + ) + ); + + // The head pre-fetch above brought main's head Version plus the version-history (parent graph), + // but NOT the full Version object of the common ancestor (the lane's fork point) for components + // where the lane and main have BOTH snapped since the fork. The 3-way config merge below loads + // that base Version (`baseVersion.extensions`); without it, `loadVersion(baseSnap)` throws + // VersionNotFoundOnFS, the per-component catch swallows it as "skipping config sync from main", + // and the sync silently no-ops for every diverged component. The fork point lives on main and, + // like the head, was never fetched (includeVersionHistory carries the graph, not each ancestor's + // Version). Batch-fetch the bases in one call — same best-effort contract as the head pre-fetch. + const baseIds = compact( + componentsToMerge.map(({ laneComp, divergeData }) => { + const baseSnap = divergeData.commonSnapBeforeDiverge; + return baseSnap ? laneComp.id.changeVersion(baseSnap.toString()) : undefined; + }) + ); + await prefetchFromMainForConfigSync(deps, baseIds, 'common-ancestor objects'); + + const syncedIds: ComponentID[] = []; + for (const { laneComp, modelComponent, mainHead, divergeData } of componentsToMerge) { + try { + const laneHead = laneComp.head; + const currentVersion = await modelComponent.loadVersion(laneHead.toString(), repo); + const otherVersion = await modelComponent.loadVersion(mainHead.toString(), repo); + // base = common ancestor. When the lane is strictly behind main (no divergence) the common + // ancestor IS the lane head, so the lane's own aspects serve as the base. + const baseSnap = divergeData.commonSnapBeforeDiverge; + const baseVersion = baseSnap ? await modelComponent.loadVersion(baseSnap.toString(), repo) : currentVersion; + + const configMerger = new ComponentConfigMerger( + laneComp.id.toStringWithoutVersion(), + workspaceIds, + undefined, // merging from main (the default lane) — there's no Lane object for it + currentVersion.extensions, + baseVersion.extensions, + otherVersion.extensions, + laneId.toString(), + mainLaneId.toString(), + logger, + 'ours' as MergeStrategy // keep the PR's config on a genuine conflict + ); + const mergedConfig = configMerger.merge().getSuccessfullyMergedConfig(); + if (!mergedConfig || !Object.keys(mergedConfig).length) continue; + + // Strip dependency deletion markers (version: '-'); the aspects-merger applies mergedConfig + // as-is, so a leftover '-' would land in the policy. + filterDeletedDependenciesFromConfig(mergedConfig); + + // Upsert: addEntry throws if an entry for this component already exists. A prior + // --keep-lane run that crashed mid-snap (or otherwise left unmerged.json entries behind) + // would otherwise make every later run throw here, skip the component, and keep serving + // stale config. Remove any existing entry first so repeated runs converge on main's latest. + legacyScope.objects.unmergedComponents.removeComponent(laneComp.id); + legacyScope.objects.unmergedComponents.addEntry({ + id: { scope: laneComp.id.scope, name: laneComp.id.fullName }, + head: mainHead, + laneId: mainLaneId, + mergedConfig, + }); + syncedIds.push(laneComp.id); + logger.console( + chalk.blue( + ` ${laneComp.id.toStringWithoutVersion()}: applying main's config (${Object.keys(mergedConfig).join(', ')})` + ) + ); + } catch (e: any) { + // Best-effort per component: one component's config-merge quirk shouldn't abort the whole + // `bit ci pr`. Log and move on — the build just won't reflect that component's main-side + // config this run. + logger.console( + chalk.yellow(` ${laneComp.id.toStringWithoutVersion()}: skipping config sync from main (${e?.message || e})`) + ); + } + } + + if (!syncedIds.length) { + logger.console(chalk.blue('No config changes from main to sync')); + return; + } + await legacyScope.objects.unmergedComponents.write(); + // The components were already loaded (and their aspects cached) earlier in this run, before the + // unmergedComponents entries existed. Clear the cache so the upcoming `snap` reloads them and + // the aspects-merger folds in the synced `mergedConfig`. + workspace.clearAllComponentsCache(); + logger.console(chalk.green(`Synced config from main for ${syncedIds.length} component(s)`)); +} From 6ac980a941b47387a15ebe69197db71814e63920 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 15:18:21 -0400 Subject: [PATCH 04/14] fix(ci): exclude local-only components from dependency-context drift detection legacyBitIds bypasses Snapping own local-only filtering; the detector must subtract workspace.filter.byLocalOnly itself or a local-only dev edit gets snapped via snapIds and then fails at export. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/sync/context-drift-detector.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scopes/git/ci/sync/context-drift-detector.ts b/scopes/git/ci/sync/context-drift-detector.ts index 8fa6158d9c82..6674b235bd2a 100644 --- a/scopes/git/ci/sync/context-drift-detector.ts +++ b/scopes/git/ci/sync/context-drift-detector.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { ComponentIdList } from '@teambit/component-id'; import type { ComponentID } from '@teambit/component-id'; import type { Workspace } from '@teambit/workspace'; import type { Logger } from '@teambit/logger'; @@ -18,7 +19,12 @@ export type ContextDriftReport = { * context (env template of the pinned engine, root policy) moved instead. */ export async function detectContextDrift(workspace: Workspace, logger: Logger): Promise { - const pending = await workspace.listTagPendingIds(); + const pendingIds = await workspace.listTagPendingIds(); + // Local-only components are excluded from the pending set everywhere a snap would run (mirrors + // Snapping.getTagPendingComponentsIds) — `export` refuses them, and a bare `legacyBitIds` snap + // (this run's `snapIds` path) skips the pending-list computation that normally does this filtering. + const localOnly = ComponentIdList.fromArray(workspace.filter.byLocalOnly(pendingIds)); + const pending = pendingIds.filter((id) => !localOnly.hasWithoutVersion(id)); const legacyScope = workspace.scope.legacyScope; const repo = legacyScope.objects; const drift: ContextDriftReport['drift'] = []; From 9af6dff254183972af128738189e0e1b2f29f6b3 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 15:41:29 -0400 Subject: [PATCH 05/14] feat(ci): main sync converges dependency-context drift with a patch tag Adds convergeContextDrift on CiMain: tags exactly the drifted set with an explicit id list, patch release, ignoreIssues scoped to blockers already on the recorded heads, then exports. Wired into syncMain between the checkoutByCLIValues step and driftFiles() so the .bitmap/lockfile bump rides the existing commit + bit-sync/main flow. Dry-run detects and reports but tags nothing. --- e2e/harmony/ci-sync.e2e.ts | 56 ++++++++++++++++++++++++ scopes/git/ci/ci.main.runtime.ts | 46 +++++++++++++++++++ scopes/git/ci/sync/main-sync-executor.ts | 8 ++++ 3 files changed, 110 insertions(+) diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index 7236750b0b61..e9d8318e6ac0 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1203,6 +1203,62 @@ describe('bit ci sync', function () { }); }); + // Convergence consumes the drift on main: one patch tag, exported, .bitmap bump riding the + // bit-sync/main flow. The circular pair also drifts, so the tag must tolerate the blocker that + // already exists on the recorded heads (it was tagged with --ignore-issues originally). + describe('main reconcile converges dependency-context drift', () => { + const SYNC_BRANCH = 'bit-sync/main'; + let defaultBranch: string; + + before(() => { + ({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] })); + helper.fs.outputFile('comp2/index.js', `require('is-odd');\nmodule.exports = () => 'comp2: with-pkg';\n`); + helper.fs.outputFile('comp3/index.js', `require('is-odd');\nrequire('@${helper.scopes.remote}/comp4');`); + helper.fs.outputFile('comp4/index.js', `require('@${helper.scopes.remote}/comp3');`); + helper.command.addComponent('comp3'); + helper.command.addComponent('comp4'); + helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '1.0.0' } }); + helper.command.install(); + helper.command.tagAllWithoutBuild('--ignore-issues="CircularDependencies"'); + helper.command.export(); + helper.command.runCmd('git add -A'); + helper.command.runCmd('git commit -m "record deps under is-odd 1.0.0"'); + helper.command.runCmd(`git push origin ${defaultBranch}`); + helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '3.0.1' } }); + // Task 2 finding: a bare workspace.jsonc edit is invisible to a running process — only a real + // `install()` re-run actually moves what gets resolved from disk (node_modules/lockfile). + helper.command.install(); + helper.command.runCmd('git add -A'); + helper.command.runCmd('git commit -m "bump is-odd policy"'); + helper.command.runCmd(`git push origin ${defaultBranch}`); + }); + + it('dry-run reports the convergence and tags nothing', () => { + const { output, exitCode } = syncRun('--main --dry-run'); + expect(exitCode, output).to.equal(0); + expect(output).to.include('dependency-context drift'); + expect(output).to.include('dry-run'); + const list = helper.command.listRemoteScopeParsed(); + const comp2 = list.find((c: any) => c.id.includes('comp2')); + // comp2 was already recorded at 0.0.2 by the setup's own tag (is-odd 1.0.0) — the dry-run's + // job is to NOT advance it any further, not to leave it below 0.0.2. + expect(comp2.localVersion || comp2.currentVersion).to.equal('0.0.2'); + }); + + it('converges: one patch tag with the alignment message, exported, .bitmap bump on the sync branch', () => { + const { output, exitCode } = syncRun('--main'); + expect(exitCode, output).to.equal(0); + expect(output).to.include('align dependency context'); + expect(fileOnBranch(SYNC_BRANCH, '.bitmap')).to.include('0.0.2'); + }); + + it('the next run finds a converged pair and no-ops', () => { + const { output, exitCode } = syncRun('--main'); + expect(exitCode, output).to.equal(0); + expect(output).to.match(/converged/i); + }); + }); + describe('a stale bit-sync/main that conflicts with the default branch', () => { const SYNC_BRANCH = 'bit-sync/main'; let defaultBranch: string; diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index 51a05bda1e50..e191f0b0b3ee 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -38,6 +38,7 @@ import { adoptAndRetrySwitch, isLaneMissingComponentError } from './sync/adopt-l import { getBitVersion } from '@teambit/bit.get-bit-version'; import { detectContextDrift as detectContextDriftImpl } from './sync/context-drift-detector'; import type { ContextDriftReport } from './sync/context-drift-detector'; +import { convergenceMessage, blockerNamesUnion } from './sync/context-drift'; import { syncConfigFromMain as syncConfigFromMainImpl } from './sync/main-config-sync'; export type CiSwitchLaneOptions = SwitchLaneOptions & { @@ -611,6 +612,51 @@ export class CiMain { return detectContextDriftImpl(this.workspace, this.logger); } + /** + * Consume dependency-context drift on main: one patch tag of exactly the drifted set, + * tolerating only blockers that already exist on the recorded heads, then export. + * The .bitmap/lockfile updates are left in the working tree for the caller's + * mainSync commit flow to pick up. + */ + async convergeContextDrift({ dryRun }: { dryRun?: boolean } = {}): Promise<{ converged: number; summary: string }> { + const { drift } = await this.detectContextDrift(); + if (!drift.length) return { converged: 0, summary: 'no dependency-context drift' }; + const running = this.getRunningBitVersion(); + this.logger.console(chalk.blue(`${drift.length} component(s) carry dependency-context drift:`)); + drift.forEach((d) => + this.logger.console( + ` ${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})` + + `${d.recordedBitVersion && d.recordedBitVersion !== running ? ` recorded with bit ${d.recordedBitVersion}` : ''}` + ) + ); + const idStrs = drift.map((d) => d.id.toStringWithoutVersion()); + if (dryRun) { + return { converged: 0, summary: `dry-run: would converge ${drift.length} component(s)` }; + } + const message = convergenceMessage( + drift.map((d) => d.recordedBitVersion), + running + ); + const status = await this.status.status({ lanes: true }); + const ignoreIssues = blockerNamesUnion(status.componentsWithIssues, new Set(idStrs)); + const results = await this.snapping.tag({ + ids: idStrs, + message, + releaseType: 'patch', + autoTagReleaseType: 'patch', + ignoreIssues, + build: undefined, + persist: false, + failFast: true, + }); + if (!results) return { converged: 0, summary: 'no dependency-context drift' }; + this.logger.console(chalk.blue(message)); + await this.exporter.export(); + const count = results.taggedComponents.length; + this.logger.console(chalk.green(`Converged ${count} component(s)`)); + return { converged: count, summary: `converged ${count} component(s)` }; + } + async verifyWorkspaceStatus() { await this.verifyWorkspaceStatusInternal(); diff --git a/scopes/git/ci/sync/main-sync-executor.ts b/scopes/git/ci/sync/main-sync-executor.ts index 2198ea023ffd..0c70265b1934 100644 --- a/scopes/git/ci/sync/main-sync-executor.ts +++ b/scopes/git/ci/sync/main-sync-executor.ts @@ -129,6 +129,14 @@ export class MainSyncExecutor { ); } + // Consume dependency-context drift before diffing: the tag's .bitmap/lockfile writes then + // ride the same file-diff `driftFiles()` computes below, with no separate commit path. + await this.deps.ci.reloadWorkspaceFromDisk(); + const convergence = await this.deps.ci.convergeContextDrift({ dryRun: opts.dryRun }); + // `converged` alone misses the dry-run case (it never tags, so it's always 0) — the no-op + // case is the only one that shouldn't print. + if (convergence.summary !== 'no dependency-context drift') logger.console(convergence.summary); + const drift = await this.driftFiles(); // Direct-push stays bare: asking the host about `mainSyncBranch`'s PR would be the one // interaction with it this mode promises not to make. From 3cd57a87270c960c70dce5de368ee6afcf52b28c Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 16:02:07 -0400 Subject: [PATCH 06/14] fix(ci): distinguish detected-but-not-taggable drift, fix misleading dry-run summary, tighten e2e convergence assertion convergeContextDrift returns an additive detected flag so callers stop string-matching a summary sentinel across the module boundary, and the tag-returned-null anomaly gets its own distinguishable summary instead of being reported as "no dependency-context drift". syncMain dry-run now returns the would-converge line instead of a contradictory converged summary when driftFiles sees no file diff. The main-convergence e2e cell now asserts the actual convergence bump and the real push summary string, instead of a check that was already true before any run. --- e2e/harmony/ci-sync.e2e.ts | 5 ++++- scopes/git/ci/ci.main.runtime.ts | 22 +++++++++++++++++----- scopes/git/ci/sync/main-sync-executor.ts | 11 +++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index e9d8318e6ac0..4097fb76c136 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1249,7 +1249,10 @@ describe('bit ci sync', function () { const { output, exitCode } = syncRun('--main'); expect(exitCode, output).to.equal(0); expect(output).to.include('align dependency context'); - expect(fileOnBranch(SYNC_BRANCH, '.bitmap')).to.include('0.0.2'); + expect(output).to.include('main -> pushed sync commit to'); + // comp2's own convergence bump (0.0.2 -> 0.0.3) — 0.0.2 alone is already true at the fork + // point and would pass whether or not this run converged anything. + expect(fileOnBranch(SYNC_BRANCH, '.bitmap')).to.include('0.0.3'); }); it('the next run finds a converged pair and no-ops', () => { diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index e191f0b0b3ee..0a5d8c76a8c0 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -618,9 +618,13 @@ export class CiMain { * The .bitmap/lockfile updates are left in the working tree for the caller's * mainSync commit flow to pick up. */ - async convergeContextDrift({ dryRun }: { dryRun?: boolean } = {}): Promise<{ converged: number; summary: string }> { + async convergeContextDrift({ dryRun }: { dryRun?: boolean } = {}): Promise<{ + converged: number; + detected: boolean; + summary: string; + }> { const { drift } = await this.detectContextDrift(); - if (!drift.length) return { converged: 0, summary: 'no dependency-context drift' }; + if (!drift.length) return { converged: 0, detected: false, summary: 'no dependency-context drift' }; const running = this.getRunningBitVersion(); this.logger.console(chalk.blue(`${drift.length} component(s) carry dependency-context drift:`)); drift.forEach((d) => @@ -631,7 +635,7 @@ export class CiMain { ); const idStrs = drift.map((d) => d.id.toStringWithoutVersion()); if (dryRun) { - return { converged: 0, summary: `dry-run: would converge ${drift.length} component(s)` }; + return { converged: 0, detected: true, summary: `dry-run: would converge ${drift.length} component(s)` }; } const message = convergenceMessage( drift.map((d) => d.recordedBitVersion), @@ -649,12 +653,20 @@ export class CiMain { persist: false, failFast: true, }); - if (!results) return { converged: 0, summary: 'no dependency-context drift' }; + // Drift was detected but the tag call produced nothing to export — detector and tag disagree. + // Distinct from "no dependency-context drift" (drift.length === 0): here `detected` stays true. + if (!results) { + return { + converged: 0, + detected: true, + summary: `drift detected but nothing was taggable (${drift.length} component(s))`, + }; + } this.logger.console(chalk.blue(message)); await this.exporter.export(); const count = results.taggedComponents.length; this.logger.console(chalk.green(`Converged ${count} component(s)`)); - return { converged: count, summary: `converged ${count} component(s)` }; + return { converged: count, detected: true, summary: `converged ${count} component(s)` }; } async verifyWorkspaceStatus() { diff --git a/scopes/git/ci/sync/main-sync-executor.ts b/scopes/git/ci/sync/main-sync-executor.ts index 0c70265b1934..61cf1db665e4 100644 --- a/scopes/git/ci/sync/main-sync-executor.ts +++ b/scopes/git/ci/sync/main-sync-executor.ts @@ -133,14 +133,17 @@ export class MainSyncExecutor { // ride the same file-diff `driftFiles()` computes below, with no separate commit path. await this.deps.ci.reloadWorkspaceFromDisk(); const convergence = await this.deps.ci.convergeContextDrift({ dryRun: opts.dryRun }); - // `converged` alone misses the dry-run case (it never tags, so it's always 0) — the no-op - // case is the only one that shouldn't print. - if (convergence.summary !== 'no dependency-context drift') logger.console(convergence.summary); + if (convergence.detected) logger.console(convergence.summary); const drift = await this.driftFiles(); // Direct-push stays bare: asking the host about `mainSyncBranch`'s PR would be the one // interaction with it this mode promises not to make. - if (!drift.length) return directPush ? CONVERGED_SUMMARY : await this.convergedSummary(branch); + if (!drift.length) { + // A dry-run tags nothing, so `driftFiles()` sees no file diff even when convergence was + // detected — the CONVERGED summary would contradict the "would converge" line just logged. + if (opts.dryRun && convergence.detected) return `main -> ${convergence.summary}`; + return directPush ? CONVERGED_SUMMARY : await this.convergedSummary(branch); + } logger.console( formatWarningSummary(`main -> drift in ${drift.length} file(s): ${drift.slice(0, 20).join(', ')}`) From 275b65ab8fca62509fbb44027bcc75f1b62d4455 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 16:10:09 -0400 Subject: [PATCH 07/14] test(ci): pin the dry-run summary return value, not just the mid-run log line The mid-run log for detected drift passes with either summary branch syncMain returns through, so round 1s fix to the actual returned would-converge line had no regression coverage until now. --- e2e/harmony/ci-sync.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index 4097fb76c136..19637e17880a 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1238,6 +1238,9 @@ describe('bit ci sync', function () { expect(exitCode, output).to.equal(0); expect(output).to.include('dependency-context drift'); expect(output).to.include('dry-run'); + // Pin the actual returned summary line (not just the mid-run log, which would pass either + // way) — the count is left out since it's not the stable part. + expect(output).to.include('main -> dry-run: would converge'); const list = helper.command.listRemoteScopeParsed(); const comp2 = list.find((c: any) => c.id.includes('comp2')); // comp2 was already recorded at 0.0.2 by the setup's own tag (is-odd 1.0.0) — the dry-run's From ee688fdc0360dd352f05b719976913d5a0b896bf Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 17:19:19 -0400 Subject: [PATCH 08/14] docs(ci): dependency-context drift and how sync consumes it --- scopes/git/ci/ci.docs.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scopes/git/ci/ci.docs.mdx b/scopes/git/ci/ci.docs.mdx index 42177b3c1a03..ee0c116f50b6 100644 --- a/scopes/git/ci/ci.docs.mdx +++ b/scopes/git/ci/ci.docs.mdx @@ -395,6 +395,20 @@ pull-request diff, and a person rejects it: close the pull request. With `mainSy command commits the same drift on the default branch, and uses no sync branch and no pull request. The push is a plain push, so the run stops if the default branch moved during the run. +### Dependency-context drift + +A mirror workspace can show modified components when git has no changes. The +cause is a moved resolution context: the pinned bit engine ships new env +dependency templates, or a committed root policy changes a recorded range. +This is a real dependency change that the repository introduces. + +`bit ci sync` consumes it. A main run tags the drifted components (patch bump) +with the message `align dependency context`, and exports. A lane run never +snaps drifted components; it snaps only the components with git-authored +changes and reports the drift. Pin the engine in `workspace.jsonc` +(`"teambit.harmony/bit": { "engine": "" }`) so the context moves only +when a commit moves it. + ### Git host providers and credentials The command uses plain git for every git operation. The command uses a `GitHostProvider` for every From b71df2b9d9ca6fe4cdecb5c2558c218d512f2389 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 18:07:24 -0400 Subject: [PATCH 09/14] fix(ci): classify overrides as drift, fix stale snapIds after config sync, report auto-snapped drift dependents - add overrides (env-computed dep data) to DRIFT_FIELDS so a real engine bump classifies as drift instead of no-oping the feature; unit case added - recompute drift after syncConfigFromMain and extend snapIds with any newly git-authored ids, so a component the config sync just changed is not missed - report a drifted id that auto-snaps as a dependent of a snapped component, and fix the lane log line's wording to match - fix the detected-but-nothing-taggable dry-run summary, neutral snapIds log wording, and the convergeContextDrift docstring's actual blocker-tolerance behavior - correct the docs' tag message shape and lane auto-snap behavior; add a noop-cell assertion and drop leftover process vocabulary from an e2e comment - trim comment narration added by this branch to ASD-STE100 style Co-Authored-By: Claude Fable 5 --- e2e/harmony/ci-sync.e2e.ts | 51 ++++++++-------- scopes/git/ci/ci.docs.mdx | 11 ++-- scopes/git/ci/ci.main.runtime.ts | 61 ++++++++++++++++---- scopes/git/ci/sync/context-drift-detector.ts | 18 +++--- scopes/git/ci/sync/context-drift.spec.ts | 6 ++ scopes/git/ci/sync/context-drift.ts | 4 ++ scopes/git/ci/sync/lane-sync-executor.ts | 13 +++-- scopes/git/ci/sync/main-sync-executor.ts | 12 ++-- 8 files changed, 116 insertions(+), 60 deletions(-) diff --git a/e2e/harmony/ci-sync.e2e.ts b/e2e/harmony/ci-sync.e2e.ts index 19637e17880a..69e731e55e80 100644 --- a/e2e/harmony/ci-sync.e2e.ts +++ b/e2e/harmony/ci-sync.e2e.ts @@ -1120,8 +1120,8 @@ describe('bit ci sync', function () { }); }); - // A real scope carries components with tag blockers (e.g. circular dependencies). The snap only - // includes the lane's pending components, so a blocker on an untouched component must not halt it. + // A real scope carries components with tag blockers (e.g. circular dependencies). The snap + // covers only the lane's pending components. A blocker on an untouched component must not halt it. describe('a snap-blocking issue on a component the lane never touches', () => { const LANE = 'clean-lane'; let defaultBranch: string; @@ -1154,9 +1154,9 @@ describe('bit ci sync', function () { }); }); - // The engine-bump analogue reproducible with one bit binary: the committed root policy moves a - // recorded package range. The lane run must snap only the git-authored change and report the - // drifted component instead of sweeping it into the dev's snap. + // This reproduces an engine bump with one bit binary: a committed root policy moves a recorded + // package range. The lane run snaps only the git-authored change, and reports the drifted + // component instead of sweeping it into the dev's snap. describe('dependency-context drift is excluded from the lane snap', () => { const LANE = 'drift-lane'; let defaultBranch: string; @@ -1172,18 +1172,18 @@ describe('bit ci sync', function () { helper.command.runCmd('git add -A'); helper.command.runCmd('git commit -m "comp2 records is-odd 1.0.0"'); helper.command.runCmd(`git push origin ${defaultBranch}`); - // Lane creation must happen while the policy still matches comp2's recorded range — otherwise - // the dev's own (unscoped) `bit snap` would sweep the drift in too, and there'd be nothing left - // for `bit ci sync` to exclude. + // Create the lane while the policy still matches comp2's recorded range. Otherwise the dev's + // own (unscoped) `bit snap` sweeps the drift in too, and leaves nothing for `bit ci sync` to + // exclude. devPath = createLaneWithSnap(LANE, { 'comp1/index.js': comp1Src('lane-snap-1') }, 'lane snap 1'); seedSync(LANE); branchSideCommit(LANE, defaultBranch, 'comp1/index.js', comp1Src('dev-commit-1'), 'dev commit on comp1'); - // The default branch's own resolution context moves AFTER the lane forked — the analogue of an - // engine bump: `bit ci sync` boots on the default branch, and a workspace-level policy/engine - // aggregate is resolved once at that boot (mid-run branch checkouts don't re-read it — see - // `Workspace._reloadConsumer`, which reloads the consumer/bitmap but not this). So the run's - // *actual* resolution context is whatever is in effect here, regardless of which branch it - // later checks out — exactly the drift a real engine bump produces on an untouched component. + // The default branch's resolution context moves after the lane forks — the engine-bump + // analogue: `bit ci sync` boots on the default branch and resolves the workspace policy/engine + // aggregate once, at boot. Mid-run branch checkouts do not re-read it (`Workspace._reloadConsumer` + // reloads the consumer and bitmap, not this). The run's resolution context is fixed at boot, + // regardless of which branch it later checks out — the same drift a real engine bump produces + // on an untouched component. helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '3.0.1' } }); helper.command.install(); helper.command.runCmd('git add -A'); @@ -1203,9 +1203,9 @@ describe('bit ci sync', function () { }); }); - // Convergence consumes the drift on main: one patch tag, exported, .bitmap bump riding the - // bit-sync/main flow. The circular pair also drifts, so the tag must tolerate the blocker that - // already exists on the recorded heads (it was tagged with --ignore-issues originally). + // Convergence on main consumes the drift: one patch tag, exported, with the .bitmap bump riding + // the bit-sync/main flow. The circular pair also drifts. The tag must tolerate the blocker + // already present on the recorded heads (tagged with --ignore-issues originally). describe('main reconcile converges dependency-context drift', () => { const SYNC_BRANCH = 'bit-sync/main'; let defaultBranch: string; @@ -1225,8 +1225,8 @@ describe('bit ci sync', function () { helper.command.runCmd('git commit -m "record deps under is-odd 1.0.0"'); helper.command.runCmd(`git push origin ${defaultBranch}`); helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { 'is-odd': '3.0.1' } }); - // Task 2 finding: a bare workspace.jsonc edit is invisible to a running process — only a real - // `install()` re-run actually moves what gets resolved from disk (node_modules/lockfile). + // A bare workspace.jsonc edit is invisible to a running process. Only a real `install()` + // re-run moves what gets resolved from disk (node_modules/lockfile). helper.command.install(); helper.command.runCmd('git add -A'); helper.command.runCmd('git commit -m "bump is-odd policy"'); @@ -1238,13 +1238,13 @@ describe('bit ci sync', function () { expect(exitCode, output).to.equal(0); expect(output).to.include('dependency-context drift'); expect(output).to.include('dry-run'); - // Pin the actual returned summary line (not just the mid-run log, which would pass either - // way) — the count is left out since it's not the stable part. + // Pin the returned summary line, not just the mid-run log — that would pass either way. + // The count is left out; it is not the stable part. expect(output).to.include('main -> dry-run: would converge'); const list = helper.command.listRemoteScopeParsed(); const comp2 = list.find((c: any) => c.id.includes('comp2')); - // comp2 was already recorded at 0.0.2 by the setup's own tag (is-odd 1.0.0) — the dry-run's - // job is to NOT advance it any further, not to leave it below 0.0.2. + // comp2 is already recorded at 0.0.2 from the setup's own tag (is-odd 1.0.0). The dry-run + // must not advance it further; it need not leave it below 0.0.2. expect(comp2.localVersion || comp2.currentVersion).to.equal('0.0.2'); }); @@ -1253,8 +1253,8 @@ describe('bit ci sync', function () { expect(exitCode, output).to.equal(0); expect(output).to.include('align dependency context'); expect(output).to.include('main -> pushed sync commit to'); - // comp2's own convergence bump (0.0.2 -> 0.0.3) — 0.0.2 alone is already true at the fork - // point and would pass whether or not this run converged anything. + // Checks comp2's convergence bump (0.0.2 -> 0.0.3). 0.0.2 alone is already true at the fork + // point, so it would pass regardless of convergence. expect(fileOnBranch(SYNC_BRANCH, '.bitmap')).to.include('0.0.3'); }); @@ -1262,6 +1262,7 @@ describe('bit ci sync', function () { const { output, exitCode } = syncRun('--main'); expect(exitCode, output).to.equal(0); expect(output).to.match(/converged/i); + expect(output).to.not.include('align dependency context'); }); }); diff --git a/scopes/git/ci/ci.docs.mdx b/scopes/git/ci/ci.docs.mdx index ee0c116f50b6..6f31e3423959 100644 --- a/scopes/git/ci/ci.docs.mdx +++ b/scopes/git/ci/ci.docs.mdx @@ -402,10 +402,13 @@ cause is a moved resolution context: the pinned bit engine ships new env dependency templates, or a committed root policy changes a recorded range. This is a real dependency change that the repository introduces. -`bit ci sync` consumes it. A main run tags the drifted components (patch bump) -with the message `align dependency context`, and exports. A lane run never -snaps drifted components; it snaps only the components with git-authored -changes and reports the drift. Pin the engine in `workspace.jsonc` +`bit ci sync` consumes it. A main run tags the drifted components with a +patch bump and a message of the shape +`chore: align dependency context (recorded with bit X, workspace runs bit Y)`, +then exports. A lane run snaps only the components with git-authored changes +and reports the drift; it does not snap a drifted component directly, but a +drifted component that a snapped component depends on is auto-snapped as +that dependent. Pin the engine in `workspace.jsonc` (`"teambit.harmony/bit": { "engine": "" }`) so the context moves only when a commit moves it. diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index 0a5d8c76a8c0..ef68905a1407 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -437,10 +437,9 @@ export class CiMain { } /** - * `snapIds`: fail only on issues in the components this run actually snaps. A real scope carries - * components with tag blockers (e.g. circular dependencies), and a global failure would block every - * snap in the repo — including snaps that never touch the blocked components. The snap itself still - * refuses its own components' blockers. + * `snapIds` scopes the status failure to the components this run snaps. A global failure would + * block every snap in the repo, including one that never touches a blocked component (e.g. a + * circular dependency). The snap itself still enforces blockers on its own components. */ private async verifyWorkspaceStatusInternal(strict: boolean = false, { snapIds }: { snapIds?: ComponentID[] } = {}) { this.logger.console('📊 Workspace Status'); @@ -613,10 +612,12 @@ export class CiMain { } /** - * Consume dependency-context drift on main: one patch tag of exactly the drifted set, - * tolerating only blockers that already exist on the recorded heads, then export. - * The .bitmap/lockfile updates are left in the working tree for the caller's - * mainSync commit flow to pick up. + * Consume dependency-context drift on main. Tag exactly the drifted set with one patch + * bump, then export. The tag ignores blockers on the drifted components: their files and + * config match the recorded head, so a blocker reflects the recorded content under the + * current context. A blocker type the context itself introduces is tolerated too. The + * .bitmap/lockfile updates stay in the working tree for the caller's mainSync commit flow + * to pick up. */ async convergeContextDrift({ dryRun }: { dryRun?: boolean } = {}): Promise<{ converged: number; @@ -653,8 +654,8 @@ export class CiMain { persist: false, failFast: true, }); - // Drift was detected but the tag call produced nothing to export — detector and tag disagree. - // Distinct from "no dependency-context drift" (drift.length === 0): here `detected` stays true. + // The tag call produced nothing, though drift was detected — detector and tag disagree. This + // differs from "no dependency-context drift" (drift.length === 0): here `detected` stays true. if (!results) { return { converged: 0, @@ -697,6 +698,7 @@ export class CiMain { skipTasks, noDestructiveRecovery, snapIds, + driftIds, }: { laneIdStr: string; message: string; @@ -716,6 +718,8 @@ export class CiMain { noDestructiveRecovery?: boolean; /** Snap only these ids (no version), not every tag-pending component; unset for `bit ci pr` (global). */ snapIds?: string[]; + /** Ids excluded from `snapIds` as dependency-context drift; used only to report a dependent auto-snap. */ + driftIds?: string[]; }) { // The post-export cleanup switches the workspace back to main, which re-checks-out main's HEAD // and re-imports every workspace component — pointless when the workspace is about to be @@ -750,7 +754,9 @@ export class CiMain { const resolvedSnapIds = snapIds ? await this.workspace.resolveMultipleComponentIds(snapIds) : undefined; if (resolvedSnapIds && !resolvedSnapIds.length) { - this.logger.console(chalk.yellow('No git-authored changes to snap (only dependency-context drift is pending)')); + // Neutral wording: this method does not know whether drift caused the empty set or nothing + // was pending at all — the caller (e.g. the lane sync executor) reports drift separately. + this.logger.console(chalk.yellow('No git-authored changes to snap')); return 'No changes detected, nothing to snap'; } @@ -783,6 +789,7 @@ export class CiMain { skipTasks: resolvedSkipTasks, noDestructiveRecovery, snapIds: resolvedSnapIds, + driftIds, }); } return this.snapAndExportWithTempLane({ @@ -857,6 +864,7 @@ export class CiMain { skipTasks, noDestructiveRecovery, snapIds, + driftIds, }: { laneId: LaneId; originalLane: Lane | undefined; @@ -867,6 +875,7 @@ export class CiMain { skipTasks?: string; noDestructiveRecovery?: boolean; snapIds?: ComponentID[]; + driftIds?: string[]; }) { // Query the remote (by name, to avoid fetching all lanes) so we know whether to reuse or create const existingLanes = await this.lanes.getLanes({ remote: laneId.scope, name: laneId.name }).catch((e) => { @@ -908,6 +917,15 @@ export class CiMain { ); } else { await this.syncConfigFromMain(laneId); + // `snapIds` was resolved before this call. `syncConfigFromMain` clears the component + // cache, so a component it just re-configured can become git-authored only now — add + // any such id, or this run's snap would miss it. Never add a drift id. + if (snapIds) { + const { gitAuthored } = await this.detectContextDrift(); + const known = new Set(snapIds.map((id) => id.toStringWithoutVersion())); + const missing = gitAuthored.filter((id) => !known.has(id.toStringWithoutVersion())); + if (missing.length) snapIds = [...snapIds, ...missing]; + } } } else { // Switch failed even though the remote lane exists. The destructive recovery below @@ -1037,7 +1055,26 @@ export class CiMain { return 'No changes detected, nothing to snap'; } - const { snappedComponents }: SnapResults = results; + const { snappedComponents, autoSnappedResults }: SnapResults = results; + + // A drifted id excluded from `snapIds` can still be auto-snapped, as a dependent of a + // component this run did snap — that auto-snap consumes its drift. Report it; the caller + // logged the drift as "not snapped here" before this run knew the outcome. + if (driftIds?.length) { + const driftSet = new Set(driftIds); + const autoSnappedDrift = [ + ...new Set( + autoSnappedResults + .filter((r) => driftSet.has(r.component.id.toStringWithoutVersion())) + .map((r) => r.component.id.toStringWithoutVersion()) + ), + ]; + if (autoSnappedDrift.length) { + this.logger.console( + chalk.blue(`Auto-snapped as a dependent, consuming its drift: ${autoSnappedDrift.join(', ')}`) + ); + } + } const snapOutput = snapResultOutput(results); this.logger.console(snapOutput); diff --git a/scopes/git/ci/sync/context-drift-detector.ts b/scopes/git/ci/sync/context-drift-detector.ts index 6674b235bd2a..474b99d4e762 100644 --- a/scopes/git/ci/sync/context-drift-detector.ts +++ b/scopes/git/ci/sync/context-drift-detector.ts @@ -13,16 +13,16 @@ export type ContextDriftReport = { }; /** - * Split the tag-pending set into git-authored changes and dependency-context drift. - * Drift = the diff against the recorded version is confined to dependency data; on a - * pristine checkout that means git did not touch the component — the resolution - * context (env template of the pinned engine, root policy) moved instead. + * Split the tag-pending set into git-authored changes and dependency-context drift. Drift means + * the diff against the recorded version is confined to dependency data. On a pristine checkout, + * git did not touch the component; the resolution context (env template of the pinned engine, + * root policy) moved instead. */ export async function detectContextDrift(workspace: Workspace, logger: Logger): Promise { const pendingIds = await workspace.listTagPendingIds(); - // Local-only components are excluded from the pending set everywhere a snap would run (mirrors - // Snapping.getTagPendingComponentsIds) — `export` refuses them, and a bare `legacyBitIds` snap - // (this run's `snapIds` path) skips the pending-list computation that normally does this filtering. + // Exclude local-only components, matching every snap path (mirrors + // Snapping.getTagPendingComponentsIds). `export` refuses them, and a bare `legacyBitIds` snap + // (this run's `snapIds` path) skips the pending-list computation that normally filters them out. const localOnly = ComponentIdList.fromArray(workspace.filter.byLocalOnly(pendingIds)); const pending = pendingIds.filter((id) => !localOnly.hasWithoutVersion(id)); const legacyScope = workspace.scope.legacyScope; @@ -41,8 +41,8 @@ export async function detectContextDrift(workspace: Workspace, logger: Logger): const consumerComp = comp.state._consumer.clone(); consumerComp.log = recorded.log; // same normalization as consumer.isComponentModified const { version: fromFs } = await legacyScope.sources.consumerComponentToVersion(consumerComp); - // Version.id() serializes to a JSON string (used for hashing) — parse both sides so the pure - // helper gets plain objects. + // Version.id() serializes to a JSON string for hashing. Parse both sides so the pure helper + // gets plain objects. const { depOnly, changedKeys } = classifyPayloadDiff(JSON.parse(recorded.id()), JSON.parse(fromFs.id())); if (depOnly) drift.push({ id, recordedBitVersion: recorded.bitVersion, changedKeys }); else gitAuthored.push(id); diff --git a/scopes/git/ci/sync/context-drift.spec.ts b/scopes/git/ci/sync/context-drift.spec.ts index ae8167c4ea91..dfe92129b0c0 100644 --- a/scopes/git/ci/sync/context-drift.spec.ts +++ b/scopes/git/ci/sync/context-drift.spec.ts @@ -39,6 +39,12 @@ describe('classifyPayloadDiff', () => { const fromFs = { ...base, extensions: [{ name: 'teambit.envs/envs', config: { env: 'x' } }] }; expect(classifyPayloadDiff(base, fromFs).depOnly).to.equal(false); }); + + it('classifies an overrides-only change (env-computed dep data) as depOnly', () => { + const recorded = { ...base, overrides: { devDependencies: { '@types/react': '^17.0.0' } } }; + const fromFs = { ...base, overrides: { devDependencies: { '@types/react': '^19.0.0' } } }; + expect(classifyPayloadDiff(recorded, fromFs).depOnly).to.equal(true); + }); }); describe('convergenceMessage', () => { diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts index 35bffcd2598d..bd5e9364a5c3 100644 --- a/scopes/git/ci/sync/context-drift.ts +++ b/scopes/git/ci/sync/context-drift.ts @@ -9,6 +9,10 @@ export const DRIFT_FIELDS = [ 'packageDependencies', 'devPackageDependencies', 'peerPackageDependencies', + // env-computed dependency data (force:true env policies, e.g. the core react env's dependency + // template). Keep `extensions` OUT of this list: a git-side policy source that reaches + // `overrides` without also touching `extensions` would otherwise go undetected as drift. + 'overrides', ] as const; // Keys that legitimately differ between a recorded Version and one rebuilt diff --git a/scopes/git/ci/sync/lane-sync-executor.ts b/scopes/git/ci/sync/lane-sync-executor.ts index 056fa99f49bd..ba70f023f7fb 100644 --- a/scopes/git/ci/sync/lane-sync-executor.ts +++ b/scopes/git/ci/sync/lane-sync-executor.ts @@ -767,10 +767,12 @@ export class LaneSyncExecutor { * imported BEFORE delegating: a switch onto the lane the workspace is already on no-ops before any * fetch, so it never warms a cold scope. * - * Pending components are split into git-authored changes and dependency-context drift (a recorded - * dep range moved under the workspace's current resolution context, not under a dev's commit) before - * snapping: only the git-authored subset is passed as `snapIds`, so drift is never swept into a lane - * snap it never touched. Main-side convergence consumes drift separately (not this run's job). + * Pending components split into git-authored changes and dependency-context drift before snapping + * (a recorded dep range moved under the workspace's current resolution context, not under a dev's + * commit). Only the git-authored subset passes as `snapIds`; drift never rides into a lane snap it + * did not touch directly. A drifted component that a snapped component depends on can still be + * auto-snapped as that dependent — `snapPrCommit` reports that case. Main-side convergence consumes + * drift not auto-snapped this way. */ private async snapAndExportOntoLane(laneIdStr: string, message: string): Promise { try { @@ -782,7 +784,7 @@ export class LaneSyncExecutor { this.deps.logger.console( `${drift.length} component(s) carry dependency-context drift` + `${recorded ? ` (recorded with bit ${recorded}, running bit ${running})` : ''} — ` + - `main convergence consumes this; not snapped here:` + `not snapped directly by this run (a dependent may auto-snap it):` ); drift.forEach((d) => this.deps.logger.console(` ${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})`) @@ -797,6 +799,7 @@ export class LaneSyncExecutor { skipCleanup: true, noDestructiveRecovery: true, snapIds: gitAuthored.map((id) => id.toStringWithoutVersion()), + driftIds: drift.map((d) => d.id.toStringWithoutVersion()), }); return undefined; } catch (e: any) { diff --git a/scopes/git/ci/sync/main-sync-executor.ts b/scopes/git/ci/sync/main-sync-executor.ts index 61cf1db665e4..2ebb41914f9e 100644 --- a/scopes/git/ci/sync/main-sync-executor.ts +++ b/scopes/git/ci/sync/main-sync-executor.ts @@ -129,8 +129,8 @@ export class MainSyncExecutor { ); } - // Consume dependency-context drift before diffing: the tag's .bitmap/lockfile writes then - // ride the same file-diff `driftFiles()` computes below, with no separate commit path. + // Consume dependency-context drift before diffing. The tag's .bitmap/lockfile writes then + // ride the same file diff `driftFiles()` computes below; there is no separate commit path. await this.deps.ci.reloadWorkspaceFromDisk(); const convergence = await this.deps.ci.convergeContextDrift({ dryRun: opts.dryRun }); if (convergence.detected) logger.console(convergence.summary); @@ -139,9 +139,11 @@ export class MainSyncExecutor { // Direct-push stays bare: asking the host about `mainSyncBranch`'s PR would be the one // interaction with it this mode promises not to make. if (!drift.length) { - // A dry-run tags nothing, so `driftFiles()` sees no file diff even when convergence was - // detected — the CONVERGED summary would contradict the "would converge" line just logged. - if (opts.dryRun && convergence.detected) return `main -> ${convergence.summary}`; + // Two cases produce no file diff even though convergence was detected: a dry-run tags + // nothing, and a detected-but-nothing-taggable convergence (converged: 0) exports nothing + // either. Report `convergence.summary` in both, or the CONVERGED summary would contradict + // the line just logged. + if (convergence.detected && (opts.dryRun || !convergence.converged)) return `main -> ${convergence.summary}`; return directPush ? CONVERGED_SUMMARY : await this.convergedSummary(branch); } From c5a397e5e05be7a4a81da773a9ab92812a5ffd52 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 18:24:30 -0400 Subject: [PATCH 10/14] docs(ci): fix inverted auto-snap direction, state the real DRIFT_FIELDS/extensions rationale - auto-snap runs on a component that depends on a snapped one, not the reverse; fix the docs and the lane-sync-executor comment to say so (ci.main.runtime.ts's comment was already correct) - replace the vacuous DRIFT_FIELDS/extensions rationale with the load-bearing case: bit deps set writes both extensions and overrides, so extensions must stay comparable for that change to classify as git-authored Co-Authored-By: Claude Fable 5 --- scopes/git/ci/ci.docs.mdx | 4 ++-- scopes/git/ci/sync/context-drift.ts | 5 +++-- scopes/git/ci/sync/lane-sync-executor.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scopes/git/ci/ci.docs.mdx b/scopes/git/ci/ci.docs.mdx index 6f31e3423959..c08c808b060a 100644 --- a/scopes/git/ci/ci.docs.mdx +++ b/scopes/git/ci/ci.docs.mdx @@ -407,8 +407,8 @@ patch bump and a message of the shape `chore: align dependency context (recorded with bit X, workspace runs bit Y)`, then exports. A lane run snaps only the components with git-authored changes and reports the drift; it does not snap a drifted component directly, but a -drifted component that a snapped component depends on is auto-snapped as -that dependent. Pin the engine in `workspace.jsonc` +drifted component that depends on a snapped component is auto-snapped as +its dependent. Pin the engine in `workspace.jsonc` (`"teambit.harmony/bit": { "engine": "" }`) so the context moves only when a commit moves it. diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts index bd5e9364a5c3..50a965a737c5 100644 --- a/scopes/git/ci/sync/context-drift.ts +++ b/scopes/git/ci/sync/context-drift.ts @@ -10,8 +10,9 @@ export const DRIFT_FIELDS = [ 'devPackageDependencies', 'peerPackageDependencies', // env-computed dependency data (force:true env policies, e.g. the core react env's dependency - // template). Keep `extensions` OUT of this list: a git-side policy source that reaches - // `overrides` without also touching `extensions` would otherwise go undetected as drift. + // template). Keep `extensions` out of this list: a git-side `bit deps set` writes both the + // component's aspect config (extensions) and the computed overrides — extensions must stay + // comparable so that change classifies as git-authored. 'overrides', ] as const; diff --git a/scopes/git/ci/sync/lane-sync-executor.ts b/scopes/git/ci/sync/lane-sync-executor.ts index ba70f023f7fb..f0260d636be2 100644 --- a/scopes/git/ci/sync/lane-sync-executor.ts +++ b/scopes/git/ci/sync/lane-sync-executor.ts @@ -770,8 +770,8 @@ export class LaneSyncExecutor { * Pending components split into git-authored changes and dependency-context drift before snapping * (a recorded dep range moved under the workspace's current resolution context, not under a dev's * commit). Only the git-authored subset passes as `snapIds`; drift never rides into a lane snap it - * did not touch directly. A drifted component that a snapped component depends on can still be - * auto-snapped as that dependent — `snapPrCommit` reports that case. Main-side convergence consumes + * did not touch directly. A drifted component that depends on a snapped component can still be + * auto-snapped as its dependent — `snapPrCommit` reports that case. Main-side convergence consumes * drift not auto-snapped this way. */ private async snapAndExportOntoLane(laneIdStr: string, message: string): Promise { From ef7036f2b38093bc52d8b91e325b4997f3d69ad6 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 19:04:29 -0400 Subject: [PATCH 11/14] fix(ci): scope drift export to tagged ids, normalize file order, pool drift checks Qodo review fixes for #10574: export() no longer sweeps every staged component, a file-order-only diff no longer misclassifies as drift, the per-component drift check runs with bounded concurrency, and the new drift-report lines use the shared CLI formatting toolkit. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/ci.main.runtime.ts | 46 +++++++++---- scopes/git/ci/sync/context-drift-detector.ts | 69 ++++++++++++------- scopes/git/ci/sync/context-drift.spec.ts | 71 +++++++++++++++++++- scopes/git/ci/sync/context-drift.ts | 18 ++++- scopes/git/ci/sync/lane-sync-executor.ts | 14 ++-- 5 files changed, 173 insertions(+), 45 deletions(-) diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index ef68905a1407..5a22662edbba 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -1,6 +1,14 @@ import type { RuntimeDefinition, SlotRegistry } from '@teambit/harmony'; import { Slot } from '@teambit/harmony'; -import { CLIAspect, type CLIMain, MainRuntime, formatWarningSummary } from '@teambit/cli'; +import { + CLIAspect, + type CLIMain, + MainRuntime, + formatWarningSummary, + formatSuccessSummary, + formatSection, + formatItem, +} from '@teambit/cli'; import { LoggerAspect, type LoggerMain, type Logger } from '@teambit/logger'; import { WorkspaceAspect, type Workspace } from '@teambit/workspace'; import { BuilderAspect, type BuilderMain } from '@teambit/builder'; @@ -612,10 +620,13 @@ export class CiMain { } /** - * Consume dependency-context drift on main. Tag exactly the drifted set with one patch - * bump, then export. The tag ignores blockers on the drifted components: their files and - * config match the recorded head, so a blocker reflects the recorded content under the - * current context. A blocker type the context itself introduces is tolerated too. The + * Consume dependency-context drift on main. The tag seeds exactly the drifted set with one + * patch bump; bit's auto-tag then bumps each drifted component's dependents so their + * recorded dependencies follow. Skipping auto-tag would leave those dependents' recorded + * deps stale, and the next run would re-detect the same drift — a convergence cascade that + * never settles. The tag ignores blockers on the drifted components: their files and config + * match the recorded head, so a blocker reflects the recorded content under the current + * context. A blocker type the context itself introduces is tolerated too. The * .bitmap/lockfile updates stay in the working tree for the caller's mainSync commit flow * to pick up. */ @@ -627,11 +638,16 @@ export class CiMain { const { drift } = await this.detectContextDrift(); if (!drift.length) return { converged: 0, detected: false, summary: 'no dependency-context drift' }; const running = this.getRunningBitVersion(); - this.logger.console(chalk.blue(`${drift.length} component(s) carry dependency-context drift:`)); - drift.forEach((d) => - this.logger.console( - ` ${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})` + - `${d.recordedBitVersion && d.recordedBitVersion !== running ? ` recorded with bit ${d.recordedBitVersion}` : ''}` + this.logger.console( + formatSection( + 'dependency-context drift', + '', + drift.map((d) => + formatItem( + `${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})` + + `${d.recordedBitVersion && d.recordedBitVersion !== running ? ` recorded with bit ${d.recordedBitVersion}` : ''}` + ) + ) ) ); const idStrs = drift.map((d) => d.id.toStringWithoutVersion()); @@ -664,9 +680,13 @@ export class CiMain { }; } this.logger.console(chalk.blue(message)); - await this.exporter.export(); + const exportIds = [ + ...results.taggedComponents.map((c) => c.id.toString()), + ...results.autoTaggedResults.map((r) => r.component.id.toString()), + ]; + await this.exporter.export({ ids: exportIds }); const count = results.taggedComponents.length; - this.logger.console(chalk.green(`Converged ${count} component(s)`)); + this.logger.console(formatSuccessSummary(`Converged ${count} component(s)`)); return { converged: count, detected: true, summary: `converged ${count} component(s)` }; } @@ -756,7 +776,7 @@ export class CiMain { if (resolvedSnapIds && !resolvedSnapIds.length) { // Neutral wording: this method does not know whether drift caused the empty set or nothing // was pending at all — the caller (e.g. the lane sync executor) reports drift separately. - this.logger.console(chalk.yellow('No git-authored changes to snap')); + this.logger.console(formatWarningSummary('No git-authored changes to snap')); return 'No changes detected, nothing to snap'; } diff --git a/scopes/git/ci/sync/context-drift-detector.ts b/scopes/git/ci/sync/context-drift-detector.ts index 474b99d4e762..9291cf53bc59 100644 --- a/scopes/git/ci/sync/context-drift-detector.ts +++ b/scopes/git/ci/sync/context-drift-detector.ts @@ -3,7 +3,13 @@ import { ComponentIdList } from '@teambit/component-id'; import type { ComponentID } from '@teambit/component-id'; import type { Workspace } from '@teambit/workspace'; import type { Logger } from '@teambit/logger'; -import { classifyPayloadDiff } from './context-drift'; +import { pMapPool } from '@teambit/toolbox.promise.map-pool'; +import { concurrentComponentsLimit } from '@teambit/harmony.modules.concurrency'; +import { classifyPayloadDiff, normalizePayload } from './context-drift'; + +type DriftCheckResult = + | { id: ComponentID; kind: 'git-authored' } + | { id: ComponentID; kind: 'drift'; recordedBitVersion?: string; changedKeys: string[] }; export type ContextDriftReport = { /** dep-only diff vs the recorded version — never snapped by a lane run */ @@ -27,30 +33,47 @@ export async function detectContextDrift(workspace: Workspace, logger: Logger): const pending = pendingIds.filter((id) => !localOnly.hasWithoutVersion(id)); const legacyScope = workspace.scope.legacyScope; const repo = legacyScope.objects; + // Bounded concurrency (same pattern as sync/main-config-sync.ts): each component's check loads + // its recorded Version and rebuilds it from the filesystem, which a large pending set shouldn't + // fire off unbounded. pMapPool preserves input order in its results, so the split below stays + // deterministic regardless of which component's check resolves first. + const results = await pMapPool( + pending, + async (id) => { + if (!id.hasVersion()) { + return { id, kind: 'git-authored' }; // new component: git-authored by definition + } + try { + const modelComponent = await legacyScope.getModelComponent(id); + const recorded = await modelComponent.loadVersion(id.version as string, repo); + const comp = await workspace.get(id); + const consumerComp = comp.state._consumer.clone(); + consumerComp.log = recorded.log; // same normalization as consumer.isComponentModified + const { version: fromFs } = await legacyScope.sources.consumerComponentToVersion(consumerComp); + // Version.id() serializes to a JSON string for hashing. Parse both sides so the pure helper + // gets plain objects, then normalize file order — the recorded Version was sorted by + // consumer.ts's sortProperties at persist time, but consumerComponentToVersion's output + // here is not, so a pure ordering difference would otherwise misclassify as drift. + const { depOnly, changedKeys } = classifyPayloadDiff( + normalizePayload(JSON.parse(recorded.id())), + normalizePayload(JSON.parse(fromFs.id())) + ); + if (depOnly) return { id, kind: 'drift', recordedBitVersion: recorded.bitVersion, changedKeys }; + return { id, kind: 'git-authored' }; + } catch (e: any) { + // best-effort per component: an unreadable model must not kill the run — treat as git-authored + logger.console(chalk.yellow(` ${id.toStringWithoutVersion()}: drift check skipped (${e?.message || e})`)); + return { id, kind: 'git-authored' }; + } + }, + { concurrency: concurrentComponentsLimit() } + ); const drift: ContextDriftReport['drift'] = []; const gitAuthored: ComponentID[] = []; - for (const id of pending) { - if (!id.hasVersion()) { - gitAuthored.push(id); // new component: git-authored by definition - continue; - } - try { - const modelComponent = await legacyScope.getModelComponent(id); - const recorded = await modelComponent.loadVersion(id.version as string, repo); - const comp = await workspace.get(id); - const consumerComp = comp.state._consumer.clone(); - consumerComp.log = recorded.log; // same normalization as consumer.isComponentModified - const { version: fromFs } = await legacyScope.sources.consumerComponentToVersion(consumerComp); - // Version.id() serializes to a JSON string for hashing. Parse both sides so the pure helper - // gets plain objects. - const { depOnly, changedKeys } = classifyPayloadDiff(JSON.parse(recorded.id()), JSON.parse(fromFs.id())); - if (depOnly) drift.push({ id, recordedBitVersion: recorded.bitVersion, changedKeys }); - else gitAuthored.push(id); - } catch (e: any) { - // best-effort per component: an unreadable model must not kill the run — treat as git-authored - logger.console(chalk.yellow(` ${id.toStringWithoutVersion()}: drift check skipped (${e?.message || e})`)); - gitAuthored.push(id); - } + for (const r of results) { + if (r.kind === 'drift') + drift.push({ id: r.id, recordedBitVersion: r.recordedBitVersion, changedKeys: r.changedKeys }); + else gitAuthored.push(r.id); } return { drift, gitAuthored }; } diff --git a/scopes/git/ci/sync/context-drift.spec.ts b/scopes/git/ci/sync/context-drift.spec.ts index dfe92129b0c0..a49be8adec96 100644 --- a/scopes/git/ci/sync/context-drift.spec.ts +++ b/scopes/git/ci/sync/context-drift.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { classifyPayloadDiff, convergenceMessage, blockerNamesUnion } from './context-drift'; +import { classifyPayloadDiff, convergenceMessage, blockerNamesUnion, normalizePayload } from './context-drift'; describe('classifyPayloadDiff', () => { const base = { @@ -45,6 +45,75 @@ describe('classifyPayloadDiff', () => { const fromFs = { ...base, overrides: { devDependencies: { '@types/react': '^19.0.0' } } }; expect(classifyPayloadDiff(recorded, fromFs).depOnly).to.equal(true); }); + + it('normalizing file order before comparing does not mask a real file change', () => { + const recorded = { + ...base, + files: [ + { file: 'aaa', relativePath: 'index.js' }, + { file: 'bbb', relativePath: 'utils.js' }, + ], + }; + const fromFs = { + ...base, + // same files, reversed order, plus a dep change + files: [ + { file: 'bbb', relativePath: 'utils.js' }, + { file: 'aaa', relativePath: 'index.js' }, + ], + packageDependencies: { 'is-odd': '3.0.1' }, + }; + const res = classifyPayloadDiff(normalizePayload(recorded), normalizePayload(fromFs)); + expect(res.depOnly).to.equal(true); + + const fromFsWithFileChange = { + ...fromFs, + files: [ + { file: 'bbb', relativePath: 'utils.js' }, + { file: 'ccc', relativePath: 'index.js' }, + ], + }; + const resWithFileChange = classifyPayloadDiff(normalizePayload(recorded), normalizePayload(fromFsWithFileChange)); + expect(resWithFileChange.depOnly).to.equal(false); + }); +}); + +describe('normalizePayload', () => { + it('sorts files by relativePath', () => { + const payload = { + files: [ + { file: 'bbb', relativePath: 'z.js' }, + { file: 'aaa', relativePath: 'a.js' }, + ], + }; + expect(normalizePayload(payload).files).to.deep.equal([ + { file: 'aaa', relativePath: 'a.js' }, + { file: 'bbb', relativePath: 'z.js' }, + ]); + }); + + it("sorts each file's dists by relativePath when present", () => { + const payload = { + files: [ + { + relativePath: 'a.js', + dists: [ + { relativePath: 'z.js.map', file: 'x' }, + { relativePath: 'a.js.map', file: 'y' }, + ], + }, + ], + }; + expect(normalizePayload(payload).files[0].dists).to.deep.equal([ + { relativePath: 'a.js.map', file: 'y' }, + { relativePath: 'z.js.map', file: 'x' }, + ]); + }); + + it('leaves a payload without a files array untouched', () => { + const payload = { mainFile: 'index.js' }; + expect(normalizePayload(payload)).to.deep.equal(payload); + }); }); describe('convergenceMessage', () => { diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts index 50a965a737c5..1621470560f0 100644 --- a/scopes/git/ci/sync/context-drift.ts +++ b/scopes/git/ci/sync/context-drift.ts @@ -1,4 +1,4 @@ -import { isEqual, omit } from 'lodash'; +import { isEqual, omit, sortBy } from 'lodash'; export const DRIFT_FIELDS = [ 'dependencies', @@ -22,6 +22,22 @@ const VOLATILE_FIELDS = ['log', 'parents', 'squashed', 'origin'] as const; const EXCLUDED = [...DRIFT_FIELDS, ...VOLATILE_FIELDS]; +/** + * Sort a `Version.id()` payload's `files` (and each file's `dists`, if present) by + * `relativePath`. Mirrors `sortProperties` in consumer.ts (the recorded-vs-filesystem + * modified check), so a pure ordering difference does not read as drift or as a + * git-authored change. + */ +export function normalizePayload(payload: Record): Record { + if (!Array.isArray(payload.files)) return payload; + return { + ...payload, + files: sortBy(payload.files, 'relativePath').map((file: Record) => + Array.isArray(file.dists) ? { ...file, dists: sortBy(file.dists, 'relativePath') } : file + ), + }; +} + export function classifyPayloadDiff( recorded: Record, fromFs: Record diff --git a/scopes/git/ci/sync/lane-sync-executor.ts b/scopes/git/ci/sync/lane-sync-executor.ts index f0260d636be2..6317020a3f9f 100644 --- a/scopes/git/ci/sync/lane-sync-executor.ts +++ b/scopes/git/ci/sync/lane-sync-executor.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { formatWarningSummary } from '@teambit/cli'; +import { formatWarningSummary, formatSection, formatItem } from '@teambit/cli'; import type { Logger } from '@teambit/logger'; import type { LanesMain } from '@teambit/lanes'; import type { LaneData } from '@teambit/legacy.scope'; @@ -782,12 +782,12 @@ export class LaneSyncExecutor { const running = this.deps.ci.getRunningBitVersion(); const recorded = [...new Set(drift.map((d) => d.recordedBitVersion).filter(Boolean))].join(', '); this.deps.logger.console( - `${drift.length} component(s) carry dependency-context drift` + - `${recorded ? ` (recorded with bit ${recorded}, running bit ${running})` : ''} — ` + - `not snapped directly by this run (a dependent may auto-snap it):` - ); - drift.forEach((d) => - this.deps.logger.console(` ${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})`) + formatSection( + 'dependency-context drift', + `not snapped directly by this run (a dependent may auto-snap it)` + + `${recorded ? ` — recorded with bit ${recorded}, running bit ${running}` : ''}`, + drift.map((d) => formatItem(`${d.id.toStringWithoutVersion()} (${d.changedKeys.join(', ')})`)) + ) ); } await this.deps.ci.snapPrCommit({ From 09dc3a5f5a1acf2a314c602bd3ccb992d2733e71 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 19:18:16 -0400 Subject: [PATCH 12/14] fix(ci): strip deprecated file name/test props in drift normalization consumer.isComponentModified aligns these before comparing; normalizePayload must too, or a stale value on an old recorded Version reads as a file change. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/sync/context-drift.spec.ts | 19 +++++++++++++++++++ scopes/git/ci/sync/context-drift.ts | 20 +++++++++++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/scopes/git/ci/sync/context-drift.spec.ts b/scopes/git/ci/sync/context-drift.spec.ts index a49be8adec96..31887eec3285 100644 --- a/scopes/git/ci/sync/context-drift.spec.ts +++ b/scopes/git/ci/sync/context-drift.spec.ts @@ -76,6 +76,25 @@ describe('classifyPayloadDiff', () => { const resWithFileChange = classifyPayloadDiff(normalizePayload(recorded), normalizePayload(fromFsWithFileChange)); expect(resWithFileChange.depOnly).to.equal(false); }); + + it('stripping the deprecated name/test file props does not mask a real file change', () => { + const recorded = { + ...base, + files: [{ file: 'aaa', relativePath: 'index.js', name: 'index.js', test: false }], + }; + const fromFs = { + ...base, + // same file content, stale deprecated props, plus a dep change + files: [{ file: 'aaa', relativePath: 'index.js', name: 'old-name.js', test: true }], + packageDependencies: { 'is-odd': '3.0.1' }, + }; + const res = classifyPayloadDiff(normalizePayload(recorded), normalizePayload(fromFs)); + expect(res.depOnly).to.equal(true); + + const fromFsWithFileChange = { ...fromFs, files: [{ ...fromFs.files[0], file: 'ccc' }] }; + const resWithFileChange = classifyPayloadDiff(normalizePayload(recorded), normalizePayload(fromFsWithFileChange)); + expect(resWithFileChange.depOnly).to.equal(false); + }); }); describe('normalizePayload', () => { diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts index 1621470560f0..d67c0f883680 100644 --- a/scopes/git/ci/sync/context-drift.ts +++ b/scopes/git/ci/sync/context-drift.ts @@ -1,5 +1,10 @@ import { isEqual, omit, sortBy } from 'lodash'; +// Deprecated per-file props. consumer.isComponentModified copies them from the model onto the +// filesystem side before comparing, so they must not classify as a file change here either — an +// old recorded Version's `name`/`test` can differ from a rebuild for reasons unrelated to drift. +const DEPRECATED_FILE_PROPS = ['name', 'test'] as const; + export const DRIFT_FIELDS = [ 'dependencies', 'devDependencies', @@ -24,17 +29,22 @@ const EXCLUDED = [...DRIFT_FIELDS, ...VOLATILE_FIELDS]; /** * Sort a `Version.id()` payload's `files` (and each file's `dists`, if present) by - * `relativePath`. Mirrors `sortProperties` in consumer.ts (the recorded-vs-filesystem - * modified check), so a pure ordering difference does not read as drift or as a + * `relativePath`, and strip the deprecated `name`/`test` file props. Mirrors `sortProperties` / + * the deprecated-prop alignment in consumer.ts's recorded-vs-filesystem modified check, so + * neither a pure ordering difference nor a stale `name`/`test` value reads as drift or as a * git-authored change. */ export function normalizePayload(payload: Record): Record { if (!Array.isArray(payload.files)) return payload; + const stripDeprecated = (file: Record) => omit(file, DEPRECATED_FILE_PROPS); return { ...payload, - files: sortBy(payload.files, 'relativePath').map((file: Record) => - Array.isArray(file.dists) ? { ...file, dists: sortBy(file.dists, 'relativePath') } : file - ), + files: sortBy(payload.files, 'relativePath').map((file: Record) => { + const stripped = stripDeprecated(file); + return Array.isArray(file.dists) + ? { ...stripped, dists: sortBy(file.dists, 'relativePath').map(stripDeprecated) } + : stripped; + }), }; } From 31ae69b1da35e08b8f522ae2f9e26f9448001a5c Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 19:33:34 -0400 Subject: [PATCH 13/14] fix(ci): re-verify status when config sync expands the snap set An id added after syncConfigFromMain never passed the scoped status gate that ran before the expansion; re-run it over the final set. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/ci.main.runtime.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scopes/git/ci/ci.main.runtime.ts b/scopes/git/ci/ci.main.runtime.ts index 5a22662edbba..df1c2ee8dbad 100644 --- a/scopes/git/ci/ci.main.runtime.ts +++ b/scopes/git/ci/ci.main.runtime.ts @@ -804,6 +804,7 @@ export class CiMain { originalLane, message: resolvedMessage, build, + strict, dryRun, skipCleanup: resolvedSkipCleanup, skipTasks: resolvedSkipTasks, @@ -879,6 +880,7 @@ export class CiMain { originalLane, message, build, + strict, dryRun, skipCleanup, skipTasks, @@ -890,6 +892,7 @@ export class CiMain { originalLane: Lane | undefined; message: string; build: boolean | undefined; + strict: boolean | undefined; dryRun?: boolean; skipCleanup: boolean; skipTasks?: string; @@ -944,7 +947,13 @@ export class CiMain { const { gitAuthored } = await this.detectContextDrift(); const known = new Set(snapIds.map((id) => id.toStringWithoutVersion())); const missing = gitAuthored.filter((id) => !known.has(id.toStringWithoutVersion())); - if (missing.length) snapIds = [...snapIds, ...missing]; + if (missing.length) { + snapIds = [...snapIds, ...missing]; + // The added ids never went through `snapPrCommit`'s scoped verify — that ran before + // this expansion, over the pre-expansion set. Re-run it over the final set, or an + // added id's blocker surfaces later at snap instead of at this gate. + await this.verifyWorkspaceStatusInternal(strict, { snapIds }); + } } } } else { From 62d8e76deddd128b33445f34edeed568157c828b Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Thu, 6 Aug 2026 19:44:31 -0400 Subject: [PATCH 14/14] fix(ci): blockerNamesUnion carries only tag-blocker issue names A non-blocker issue on an in-set component leaked its name into the ignore union; scope the union to issues where isTagBlocker is true. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/sync/context-drift.spec.ts | 19 +++++++++++++++---- scopes/git/ci/sync/context-drift.ts | 12 ++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/scopes/git/ci/sync/context-drift.spec.ts b/scopes/git/ci/sync/context-drift.spec.ts index 31887eec3285..fe17a1cea2ad 100644 --- a/scopes/git/ci/sync/context-drift.spec.ts +++ b/scopes/git/ci/sync/context-drift.spec.ts @@ -152,19 +152,30 @@ describe('convergenceMessage', () => { }); describe('blockerNamesUnion', () => { - const entry = (idStr: string, names: string[], blocker: boolean) => ({ + const issue = (name: string, isTagBlocker: boolean) => ({ isTagBlocker, constructor: { name } }); + const entry = (idStr: string, issues: { isTagBlocker: boolean; constructor: { name: string } }[]) => ({ id: { toStringWithoutVersion: () => idStr }, - issues: { getAllIssueNames: () => names, hasTagBlockerIssues: () => blocker }, + issues: { + getAllIssues: () => issues, + hasTagBlockerIssues: () => issues.some((i) => i.isTagBlocker), + }, }); it('unions blocker issue names of in-set components only', () => { const res = blockerNamesUnion( - [entry('s/a', ['CircularDependencies'], true), entry('s/b', ['MissingDists'], true)], + [entry('s/a', [issue('CircularDependencies', true)]), entry('s/b', [issue('MissingDists', true)])], new Set(['s/a']) ); expect(res).to.equal('CircularDependencies'); }); it('returns undefined when no in-set component has blockers', () => { - expect(blockerNamesUnion([entry('s/a', ['X'], false)], new Set(['s/a']))).to.equal(undefined); + expect(blockerNamesUnion([entry('s/a', [issue('X', false)])], new Set(['s/a']))).to.equal(undefined); + }); + it('carries only the blocker issue name, not a non-blocker issue on the same component', () => { + const res = blockerNamesUnion( + [entry('s/a', [issue('CircularDependencies', true), issue('MissingDists', false)])], + new Set(['s/a']) + ); + expect(res).to.equal('CircularDependencies'); }); }); diff --git a/scopes/git/ci/sync/context-drift.ts b/scopes/git/ci/sync/context-drift.ts index d67c0f883680..846fccf05644 100644 --- a/scopes/git/ci/sync/context-drift.ts +++ b/scopes/git/ci/sync/context-drift.ts @@ -69,7 +69,10 @@ export function convergenceMessage(recordedBitVersions: (string | undefined)[], export function blockerNamesUnion( componentsWithIssues: { id: { toStringWithoutVersion(): string }; - issues: { getAllIssueNames(): string[]; hasTagBlockerIssues(): boolean }; + issues: { + getAllIssues(): { isTagBlocker: boolean; constructor: { name: string } }[]; + hasTagBlockerIssues(): boolean; + }; }[], inSet: Set ): string | undefined { @@ -77,7 +80,12 @@ export function blockerNamesUnion( for (const entry of componentsWithIssues) { if (!inSet.has(entry.id.toStringWithoutVersion())) continue; if (!entry.issues.hasTagBlockerIssues()) continue; - entry.issues.getAllIssueNames().forEach((n) => names.add(n)); + // Only the tag-blocker issues need ignoring — a non-blocker issue name in `ignoreIssues` is a + // no-op, so the union should carry exactly what it's there to suppress. + entry.issues + .getAllIssues() + .filter((issue) => issue.isTagBlocker) + .forEach((issue) => names.add(issue.constructor.name)); } return names.size ? [...names].join(',') : undefined; }