Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5f20bfb
fix(ci): fail the status verification only on components the run snap…
luvkapur Aug 6, 2026
4540f7f
feat(ci): pure classification helpers for dependency-context drift
luvkapur Aug 6, 2026
deaff85
feat(ci): lane sync snaps pending minus dependency-context drift; ver…
luvkapur Aug 6, 2026
6ac980a
fix(ci): exclude local-only components from dependency-context drift …
luvkapur Aug 6, 2026
9af6dff
feat(ci): main sync converges dependency-context drift with a patch tag
luvkapur Aug 6, 2026
3cd57a8
fix(ci): distinguish detected-but-not-taggable drift, fix misleading …
luvkapur Aug 6, 2026
275b65a
test(ci): pin the dry-run summary return value, not just the mid-run …
luvkapur Aug 6, 2026
ee688fd
docs(ci): dependency-context drift and how sync consumes it
luvkapur Aug 6, 2026
b71df2b
fix(ci): classify overrides as drift, fix stale snapIds after config …
luvkapur Aug 6, 2026
c5a397e
docs(ci): fix inverted auto-snap direction, state the real DRIFT_FIEL…
luvkapur Aug 6, 2026
ef7036f
fix(ci): scope drift export to tagged ids, normalize file order, pool…
luvkapur Aug 6, 2026
09dc3a5
fix(ci): strip deprecated file name/test props in drift normalization
luvkapur Aug 6, 2026
31ae69b
fix(ci): re-verify status when config sync expands the snap set
luvkapur Aug 6, 2026
62d8e76
fix(ci): blockerNamesUnion carries only tag-blocker issue names
luvkapur Aug 6, 2026
558f451
docs(ci): trim narration from this branch's comments
luvkapur Aug 7, 2026
1a0484d
refactor(ci): drift detector diffs via the diff engine, not a hand-ro…
luvkapur Aug 7, 2026
74dfd79
fix(ci): drift detector reads SourceFile.relative, not a nonexistent …
luvkapur Aug 7, 2026
96de0d8
fix(ci): drop the pointless clone before diffing, correct the comment
luvkapur Aug 7, 2026
1e98903
feat(ci): lanes carry the dependency-context fan-out
luvkapur Aug 7, 2026
71eb04b
fix(ci): drift-report pagination, comment anchors, and the noop short…
luvkapur Aug 7, 2026
d39f360
fix(ci): settle the docs-only-commit loop, cap pagination pages
luvkapur Aug 7, 2026
1c2261e
test(ci): pin that a real dev commit clears the settled-tip withhold
luvkapur Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions e2e/harmony/ci-sync.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,258 @@ describe('bit ci sync', function () {
});
});

// A real scope carries components with tag blockers (e.g. circular dependencies). This pair is
// untouched in the strongest sense: no diff at all, so it is never part of the run's tag-pending
// set, and its blocker must not halt the run. Contrast the drift cell below, where a pair IS
// pending (a dep-only diff) and its blocker is tolerated instead of avoided.
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');
});
});

// The nothing-pending short-circuit (executeExportBranch) still writes the sync-ledger commit, or
// a docs-only commit (touches no bit-tracked file) would leave `stateCommit` (sync-state.ts, derived
// from `.bitmap`'s content, never commit messages) stuck behind it forever — `hasDevCommits` would
// stay true on every future run. The reconciler settles instead by recognizing its OWN tip: once
// that ledger commit is the branch's tip, a later run stops before redoing any export-branch work.
describe('a commit that touches no bit-tracked file settles instead of looping', () => {
const LANE = 'docs-only-lane';
let defaultBranch: string;
let devPath: string;

before(() => {
({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] }));
devPath = createLaneWithSnap(LANE, { 'comp1/index.js': comp1Src('lane-snap-1') }, 'lane snap 1');
seedSync(LANE);
branchSideCommit(LANE, defaultBranch, 'NOTES.md', '# notes\n', 'docs: add notes');
});

it('reports nothing to snap once, then settles — the second run does not redo export-branch work', () => {
const first = syncRun(LANE);
expect(first.exitCode, `bit ci sync output:\n${first.output}`).to.equal(0);
// pins the noop summary's exact wording
expect(first.output).to.include(
`${LANE} -> export-branch (branch ${LANE} has no bit-tracked change; nothing snapped onto lane`
);

const second = syncRun(LANE);
expect(second.exitCode, `bit ci sync output:\n${second.output}`).to.equal(0);
// pins the settled summary's exact wording
expect(second.output).to.include(
`${LANE} -> noop (converged; branch tip is already this reconciler's own sync commit)`
);
// executeExportBranch's own work (the checkout, the snap attempt) never ran a second time
expect(second.output).to.not.include('Exporting branch');
});

// The withhold settles; it does not trap. A real dev commit on top of the recognized ledger tip
// must still clear it and export normally — the tip is no longer the reconciler's own commit.
it('a real dev commit on top of the settled tip clears the withhold and exports again', () => {
branchSideCommit(
LANE,
defaultBranch,
'comp1/index.js',
comp1Src('dev-commit-after-settle'),
'dev commit after settling'
);
const { output, exitCode } = syncRun(LANE);
expect(exitCode, `bit ci sync output:\n${output}`).to.equal(0);
expect(output).to.include('Exporting branch');
expect(output).to.include(`${LANE} -> export-branch`);
expect(output).to.not.include('branch tip is already this reconciler');
expect(laneTipFile(devPath, 'comp1/index.js')).to.include('dev-commit-after-settle');
});
});

// This reproduces an engine bump with one bit binary: a committed root policy moves a recorded
// package range. Lanes carry the fan-out (spec decision 6): the lane run snaps the drifted
// component together with the git-authored change, in one snap under the developer's own
// message, and reports it as a surfaced side effect — git does not show this change, the run
// log and the PR report do.
describe('dependency-context drift rides the lane snap as a surfaced side effect', () => {
const LANE = 'drift-lane';
let defaultBranch: string;
let devPath: string;

before(() => {
({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] }));
// A second file makes comp2 a multi-file component: the drift run's file-content compare
// must hash every file, not just one, to see that neither one changed.
helper.fs.outputFile('comp2/utils.js', `module.exports = () => 'comp2-util';\n`);
helper.fs.outputFile(
'comp2/index.js',
`require('is-odd');\nrequire('./utils');\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}`);
// Create the lane before the policy bump, or the dev's own (unscoped) `bit snap` sweeps the
// change in too, leaving nothing for `bit ci sync` to detect as drift.
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');
// `bit ci sync` resolves the workspace policy/engine aggregate once, at boot on the default
// branch; `Workspace._reloadConsumer` (used on later branch checkouts) reloads the consumer
// and bitmap but not this. Bumping the policy here after the lane forks is the engine-bump
// analogue: the same drift on an untouched component that a real engine bump produces.
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, carries the drifted component onto the lane, and reports it', () => {
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.include('comp2');
});
});

// The case that used to halt production: a drifted component's pre-existing tag blocker must not
// block a lane run now that lanes carry the whole fan-out in one snap. Reuses the circular-pair +
// policy-bump recipe from the main-convergence describe below, on a lane instead of main.
describe('a drifted component with a pre-existing blocker rides the lane snap without halting', () => {
const LANE = 'drift-with-blocker-lane';
let defaultBranch: string;
let devPath: string;

before(() => {
({ defaultBranch } = setupSyncWorkspace({ lanes: ['*'] }));
// A circular pair, recorded under a tag-blocker override — the blocker the drifted set's
// tolerance (`snapIgnoreIssues`) must carry through the lane snap.
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 circular pair under is-odd 1.0.0"');
helper.command.runCmd(`git push origin ${defaultBranch}`);
// Create the lane before the policy bump, for the same reason as the drift cell above.
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');
// engine-bump analogue: drifts the circular pair, blocker and all
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('exits 0, snaps the dev commit, and carries the drifted pair onto the lane', () => {
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('dependency-context drift');
expect(laneTipFile(devPath, 'comp1/index.js')).to.include('dev-commit-1');
const laneFingerprint = remoteLaneFingerprint(LANE);
expect(laneFingerprint).to.include('comp3');
expect(laneFingerprint).to.include('comp4');
});
});

// 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;

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' } });
// 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"');
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');
// Asserts on the returned summary, not just the mid-run log line.
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 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.
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(output).to.include('main -> pushed sync commit to');
// 0.0.3, not 0.0.2: 0.0.2 is already true at the fork point and would pass regardless.
expect(fileOnBranch(SYNC_BRANCH, '.bitmap')).to.include('0.0.3');
});

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);
expect(output).to.not.include('align dependency context');
});
});

describe('a stale bit-sync/main that conflicts with the default branch', () => {
const SYNC_BRANCH = 'bit-sync/main';
let defaultBranch: string;
Expand Down
17 changes: 17 additions & 0 deletions scopes/git/ci/ci.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,23 @@ 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 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 the drift together with the git-authored
change, in one snap under the developer's own message, and reports the
drifted components on the pull request — main convergence is for context
drift that reaches main without a pull request. Pin the engine in
`workspace.jsonc` (`"teambit.harmony/bit": { "engine": "<version>" }`) 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
Expand Down
Loading