Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,18 @@ cd ../<repo>-<slug> # do ALL work for the task here
# ... commit + push from this worktree; after merge: git worktree remove ../<repo>-<slug>
```

**A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`. Fix by installing in the worktree (`npm install`) or symlinking the primary checkout's modules (`ln -s ../<primary-checkout>/node_modules node_modules`). When only a subset of `@webjsdev/*` packages was edited, link those from the worktree and the rest from the primary checkout so a built `dist/` (e.g. `@webjsdev/core`) still resolves.
**A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`.

Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Two things beyond the root tree are needed, and the script handles both:

- **Every NESTED `node_modules`, not just the root.** npm hoists what it can, but a workspace whose range conflicts with the hoisted copy keeps its own nested tree. `packages/server` is the live example: the root carries `ws@7` (hoisted for another dependent) while `packages/server` declares `^8.20.0` and keeps `ws@8` nested. Link only the root and `WebSocketServer`, a ws@8-only named export, resolves up to ws@7 and throws at module load. That single miss failed hundreds of assertions across the server, integration, and smoke suites, none of them naming ws.
- **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it.

The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`.

**Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking makes the suite RUNNABLE, not self-testing: if you are editing `packages/core/src` or `packages/server/src` and need a bare-specifier consumer to exercise YOUR copy, run a real `npm install` in the worktree, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
Comment thread
vivek7405 marked this conversation as resolved.

Note the `webjs doctor` / `webjs dev` remedy message still suggests the root-only symlink. That advice is correct for a scaffolded APP worktree, which has no nested trees and no built `dist/`, and wrong only for this monorepo.

Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. There is NO lone-agent exception: every task cuts a worktree, and the primary checkout stays an untouched mirror of main (tracked-file edits there are hook-blocked). The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"prepare": "node scripts/git-worktree-safe.mjs 2>/dev/null || true",
"fix:git": "node scripts/git-worktree-safe.mjs",
"check:git": "node scripts/git-worktree-safe.mjs --check",
"worktree:link": "node scripts/link-worktree-deps.mjs",
"dev": "node scripts/dev-all.js",
"pretest": "node website/scripts/copy-registry.mjs",
"test": "node scripts/run-node-tests.js",
Expand Down
154 changes: 154 additions & 0 deletions scripts/link-worktree-deps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Link a fresh git worktree's dependencies to the primary checkout's, so the
* worktree can run the test suite without a full `npm install`.
*
* A git worktree does not copy `node_modules`, and this repo needs MORE than
* the root one:
*
* - **Every nested `node_modules`**, not just the root. npm hoists what it can,
* but a workspace whose range conflicts with the hoisted copy keeps its own
* nested tree. `packages/server` is the live example: the root has `ws@7`
* (hoisted for another dependent) while `packages/server` declares `^8.20.0`
* and carries `ws@8` nested. Link only the root and `WebSocketServer`, a
* ws@8-only named export, resolves up to ws@7 and throws at module load,
* which surfaces as dozens of unrelated-looking failures across the server,
* integration, and smoke suites.
* - **`packages/core/dist`**, which is gitignored and built rather than
* committed. Tests that import the built bundle cannot resolve it in a fresh
* worktree.
*
* The `node_modules` set is discovered from the primary checkout rather than
* hardcoded, because it changes whenever a package gains a nested tree. The
* `packages/core/dist` entry is an explicit one-off, since it is the only built
* output the suite imports.
*
* ## What this does NOT give you
Comment thread
vivek7405 marked this conversation as resolved.
*
* The worktree runs the PRIMARY checkout's framework source through every bare
* `@webjsdev/*` specifier. `<primary>/node_modules/@webjsdev/core` is a relative
* symlink into `<primary>/packages/core`, so resolving through the linked root
* lands in the primary, not here. Relative imports (`../../../src/x.js`) and the
* browser suite, which web-test-runner serves from this worktree, do use the
* worktree's own files.
*
* So this makes the suite RUNNABLE, not self-testing. If you are editing
* `packages/core/src` or `packages/server/src` and need a bare-specifier
* consumer to exercise YOUR copy, run a real `npm install` in the worktree, or
* point the individual `@webjsdev/<pkg>` entries at this worktree instead. CI
* always builds from the branch, so it is unaffected either way.
*
* Safety rules, all of which exist because the naive version of this script
* broke a worktree while it was being written:
*
* - Never delete or overwrite anything. A path that already exists is left
* alone, so a worktree with a real `npm install` is untouched and re-running
* is a no-op.
* - Never create a dangling link. A source that does not exist is skipped,
* because a dangling `node_modules` resolves more confusingly than a missing
* one.
* - Never treat `<primary>/node_modules` itself as a search root. Descending
* into it yields thousands of nested `node_modules` belonging to third-party
* packages, none of which should be linked.
*
* Usage, from inside the worktree:
*
* node scripts/link-worktree-deps.mjs # link from the default primary
* node scripts/link-worktree-deps.mjs <primary> # or name it explicitly
* npm run worktree:link
*/
import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';

/** Directory names never worth descending into when hunting for nested trees. */
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.webjs', 'coverage']);

/**
* Collect every directory under `root` that has its own `node_modules`,
* returned as paths relative to `root`. Never descends INTO a `node_modules`.
*
* @param {string} root
* @param {number} [maxDepth] how many directory levels below root to search
* @returns {string[]} relative paths of the `node_modules` directories
*/
function findNodeModules(root, maxDepth = 4) {
/** @type {string[]} */
const out = [];
/** @param {string} dir @param {number} depth */
const walk = (dir, depth) => {
if (depth > maxDepth) return;
let entries;
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
if (e.name === 'node_modules') { out.push(relative(root, join(dir, e.name))); continue; }
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
walk(join(dir, e.name), depth + 1);
}
};
walk(root, 0);
return out;
}

/**
* Create one symlink, refusing every unsafe case.
*
* @param {string} src absolute path in the primary checkout
* @param {string} dst absolute path in this worktree
* @param {string} label what to print on success
* @returns {'linked' | 'exists' | 'missing-source'}
*/
function link(src, dst, label) {
if (!existsSync(src)) return 'missing-source';
// lstat, not existsSync: a dangling symlink left by an earlier run must count
// as present so we neither clobber it nor silently stack a second link.
try { lstatSync(dst); return 'exists'; } catch { /* not there, good */ }
mkdirSync(dirname(dst), { recursive: true });
symlinkSync(src, dst, 'junction');
console.log(` linked ${label}`);
return 'linked';
}

/** @returns {string} absolute path of the primary checkout */
function defaultPrimary() {
// `--git-common-dir` is `<primary>/.git` from ANY worktree, linked or not
Comment thread
vivek7405 marked this conversation as resolved.
// (the per-worktree `.git/worktrees/<name>` path is what `--git-dir` gives),
// so the primary checkout is its parent.
const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf8',
}).trim();
return resolve(dirname(common));
}

const here = process.cwd();
const primary = resolve(process.argv[2] || defaultPrimary());

if (primary === here) {
console.log('[link-worktree-deps] this IS the primary checkout, nothing to link.');
process.exit(0);
}
if (!existsSync(join(primary, 'package.json'))) {
console.error(`[link-worktree-deps] not a checkout: ${primary}`);
process.exit(1);
}

console.log(`[link-worktree-deps] linking from ${primary}`);

let linked = 0;
let skipped = 0;
for (const rel of findNodeModules(primary)) {
const r = link(join(primary, rel), join(here, rel), rel);
if (r === 'linked') linked += 1; else skipped += 1;
}

// `packages/core/dist` is built, not committed, so a fresh worktree has none
// and any test importing the built bundle fails to resolve it.
for (const rel of ['packages/core/dist']) {
const r = link(join(primary, rel), join(here, rel), rel);
if (r === 'linked') linked += 1;
else if (r === 'missing-source') console.log(` skipped ${rel} (not built in the primary; run npm run build there)`);
else skipped += 1;
}

console.log(`[link-worktree-deps] ${linked} linked, ${skipped} already present.`);
159 changes: 159 additions & 0 deletions test/repo-health/link-worktree-deps.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* `scripts/link-worktree-deps.mjs` links a fresh worktree's dependencies to the
* primary checkout's (#1287).
*
* The behaviours asserted here are the ones whose absence produced real
* breakage while the script was written: linking only the ROOT `node_modules`
* leaves a ws version skew that fails hundreds of assertions elsewhere, and a
* naive implementation clobbered a git-tracked directory that happened to be
* named `node_modules`.
*
* These drive the script as a subprocess against a synthetic "primary" and
* "worktree" pair in a temp dir, so nothing touches the real checkout.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, lstatSync, readlinkSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';

const SCRIPT = fileURLToPath(new URL('../../scripts/link-worktree-deps.mjs', import.meta.url));

/** Build a fake primary checkout with the nested-tree shape this repo has. */
function makePrimary() {
const root = mkdtempSync(join(tmpdir(), 'wjprimary-'));
writeFileSync(join(root, 'package.json'), '{"name":"fake-primary"}');
for (const d of [
'node_modules/ws',
'packages/server/node_modules/ws',
'packages/ui/node_modules',
'website/node_modules',
'packages/core/dist',
]) mkdirSync(join(root, d), { recursive: true });
// a third-party package with its OWN nested node_modules, which must never
// be treated as a link target
mkdirSync(join(root, 'node_modules/some-dep/node_modules/inner'), { recursive: true });
return root;
}

function makeWorktree() {
const root = mkdtempSync(join(tmpdir(), 'wjworktree-'));
writeFileSync(join(root, 'package.json'), '{"name":"fake-worktree"}');
mkdirSync(join(root, 'packages/server'), { recursive: true });
return root;
}

/** @returns {string} combined stdout of the script run inside `cwd` */
function run(cwd, primary) {
return execFileSync(process.execPath, [SCRIPT, primary], { cwd, encoding: 'utf8' });
}

describe('link-worktree-deps (#1287)', () => {
test('links the nested node_modules, not just the root', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
run(wt, primary);
// The root alone is the trap this whole script exists to close.
assert.ok(lstatSync(join(wt, 'node_modules')).isSymbolicLink(), 'root linked');
assert.ok(
lstatSync(join(wt, 'packages/server/node_modules')).isSymbolicLink(),
'packages/server nested node_modules linked (the ws@7 vs ws@8 skew)',
);
assert.ok(lstatSync(join(wt, 'packages/ui/node_modules')).isSymbolicLink(), 'packages/ui linked');
assert.ok(lstatSync(join(wt, 'website/node_modules')).isSymbolicLink(), 'website linked');
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});

test('links packages/core/dist, which is built and not committed', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
run(wt, primary);
assert.ok(lstatSync(join(wt, 'packages/core/dist')).isSymbolicLink(), 'core dist linked');
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});

test('never descends into node_modules looking for more node_modules', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
const out = run(wt, primary);
// Assert on what the script CLAIMS to have linked, not on the filesystem:
// the worktree's `node_modules` is a symlink to the primary's, so a
// third-party nested tree is visible THROUGH it either way, and an
// existsSync check here passes for the wrong reason.
const linkedPaths = out.split('\n').filter((l) => l.includes('linked ')).map((l) => l.trim());
for (const l of linkedPaths) {
assert.equal(
(l.match(/node_modules/g) || []).length <= 1,
true,
`must not link a tree nested inside node_modules, got: ${l}`,
);
}
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});

test('is idempotent and never clobbers a real install', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
// a REAL directory where a link would otherwise go, as if npm install ran
mkdirSync(join(wt, 'node_modules/already-here'), { recursive: true });
const out = run(wt, primary);
assert.ok(!lstatSync(join(wt, 'node_modules')).isSymbolicLink(), 'real node_modules left as a directory');
assert.ok(existsSync(join(wt, 'node_modules/already-here')), 'existing contents untouched');
assert.match(out, /already present/, 'reports it skipped something');
// second run changes nothing
const out2 = run(wt, primary);
assert.match(out2, /0 linked/, 're-running links nothing new');
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});

test('skips a source that does not exist rather than creating a dangling link', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
rmSync(join(primary, 'packages/core/dist'), { recursive: true, force: true });
const out = run(wt, primary);
assert.ok(!existsSync(join(wt, 'packages/core/dist')), 'no dist link created');
let dangling = false;
try { dangling = lstatSync(join(wt, 'packages/core/dist')).isSymbolicLink(); } catch { /* absent, good */ }
assert.equal(dangling, false, 'a dangling link resolves more confusingly than a missing one');
assert.match(out, /not built in the primary/, 'says why it skipped');
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});

test('defaultPrimary() resolves the real primary from this checkout', () => {
// Every other test passes `primary` as argv[2], so without this the
// git-derived default branch never executes at all.
const out = execFileSync(process.execPath, [SCRIPT], {
cwd: process.cwd(), encoding: 'utf8',
});
// Run from the repo itself, so it must recognise the primary and no-op
// rather than linking anything.
assert.match(out, /this IS the primary checkout|linking from \//);
});

test('refuses to link a checkout to itself', () => {
const primary = makePrimary();
try {
const out = run(primary, primary);
assert.match(out, /this IS the primary checkout/);
assert.ok(!lstatSync(join(primary, 'node_modules')).isSymbolicLink(), 'root untouched');
} finally { rmSync(primary, { recursive: true, force: true }); }
});

test('leaves an existing symlink alone instead of stacking a second one', () => {
const primary = makePrimary();
const wt = makeWorktree();
try {
symlinkSync(join(primary, 'node_modules'), join(wt, 'node_modules'));
run(wt, primary);
assert.equal(readlinkSync(join(wt, 'node_modules')), join(primary, 'node_modules'));
assert.ok(!existsSync(join(wt, 'node_modules/node_modules')), 'no link nested inside the existing one');
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
});
});
Loading