-
Notifications
You must be signed in to change notification settings - Fork 69
fix(docs): the fresh-worktree node_modules remedy produces a broken worktree #1288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+326
−1
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 | ||
|
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.`); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); } | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.