Skip to content

Commit 7fab4f1

Browse files
authored
feat(nm): Add support for user-defined <workspace>/node_modules symlinks (#6416)
## What's the problem this PR addresses? <!-- Describe the rationale of your PR. --> <!-- Link all issues that it closes. (Closes/Resolves #xxxx.) --> Fixes: #6415 ## How did you fix it? <!-- A detailed description of your implementation. --> Now the `node-modules` linker do not try to delete or recreate `<any_workspace>/node_modules` directories if they are symlinks and the underlying dependencies were removed or newly added. ## Checklist <!--- Don't worry if you miss something, chores are automatically tested. --> <!--- This checklist exists to help you remember doing the chores when you submit a PR. --> <!--- Put an `x` in all the boxes that apply. --> - [x] I have read the [Contributing Guide](https://yarnpkg.com/advanced/contributing). <!-- See https://yarnpkg.com/advanced/contributing#preparing-your-pr-to-be-released for more details. --> <!-- Check with `yarn version check` and fix with `yarn version check -i` --> - [x] I have set the packages that need to be released for my changes to be effective. <!-- The "Testing chores" workflow validates that your PR follows our guidelines. --> <!-- If it doesn't pass, click on it to see details as to what your PR might be missing. --> - [x] I will check that all automated PR checks pass before the PR gets reviewed.
1 parent 7495114 commit 7fab4f1

File tree

4 files changed

+76
-9
lines changed

4 files changed

+76
-9
lines changed

Diff for: .yarn/versions/b764a694.yml

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
releases:
2+
"@yarnpkg/cli": patch
3+
"@yarnpkg/plugin-nm": patch
4+
5+
declined:
6+
- "@yarnpkg/plugin-compat"
7+
- "@yarnpkg/plugin-constraints"
8+
- "@yarnpkg/plugin-dlx"
9+
- "@yarnpkg/plugin-essentials"
10+
- "@yarnpkg/plugin-init"
11+
- "@yarnpkg/plugin-interactive-tools"
12+
- "@yarnpkg/plugin-npm-cli"
13+
- "@yarnpkg/plugin-pack"
14+
- "@yarnpkg/plugin-patch"
15+
- "@yarnpkg/plugin-pnp"
16+
- "@yarnpkg/plugin-pnpm"
17+
- "@yarnpkg/plugin-stage"
18+
- "@yarnpkg/plugin-typescript"
19+
- "@yarnpkg/plugin-version"
20+
- "@yarnpkg/plugin-workspace-tools"
21+
- "@yarnpkg/builder"
22+
- "@yarnpkg/core"
23+
- "@yarnpkg/doctor"

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Features in `master` can be tried out by running `yarn set version from sources`
99
:::
1010

1111
- Fixes `preferInteractive` forcing interactive mode in non-TTY environments.
12+
- `node-modules` linker now honors user-defined symlinks for `<workspace>/node_modules` directories
1213

1314
## 4.1.0
1415

Diff for: packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts

+41
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,47 @@ describe(`Node_Modules`, () => {
18551855
}),
18561856
);
18571857

1858+
it(`should work with user-created <workspace>/node_modules symlinks`,
1859+
makeTemporaryEnv(
1860+
{
1861+
workspaces: [`ws`],
1862+
dependencies: {
1863+
},
1864+
},
1865+
{
1866+
nodeLinker: `node-modules`,
1867+
nmHoistingLimits: `workspaces`,
1868+
},
1869+
async ({path, run}) => {
1870+
await xfs.mkdirpPromise(ppath.join(path, `ws`));
1871+
const trueInstallDir = ppath.resolve(path, `target`);
1872+
await xfs.mkdirPromise(trueInstallDir);
1873+
1874+
await xfs.writeJsonPromise(ppath.join(path, `ws/${Filename.manifest}`), {
1875+
name: `ws`,
1876+
devDependencies: {
1877+
[`no-deps`]: `1.0.0`,
1878+
},
1879+
});
1880+
1881+
await xfs.symlinkPromise(trueInstallDir, ppath.join(path, `ws/node_modules`));
1882+
1883+
await run(`install`);
1884+
1885+
expect(xfs.existsSync(ppath.join(trueInstallDir, `no-deps`))).toBeTruthy();
1886+
expect(xfs.lstatSync(ppath.join(path, `ws/node_modules`)).isSymbolicLink()).toBeTruthy();
1887+
1888+
await xfs.writeJsonPromise(ppath.join(path, `ws/${Filename.manifest}`), {
1889+
name: `ws`,
1890+
});
1891+
1892+
await run(`install`);
1893+
1894+
expect(xfs.existsSync(ppath.join(trueInstallDir, `no-deps`))).toBeFalsy();
1895+
expect(xfs.lstatSync(ppath.join(path, `ws/node_modules`)).isSymbolicLink()).toBeTruthy();
1896+
}),
1897+
);
1898+
18581899
it(`should support supportedArchitectures`,
18591900
makeTemporaryEnv(
18601901
{

Diff for: packages/plugin-nm/sources/NodeModulesLinker.ts

+11-9
Original file line numberDiff line numberDiff line change
@@ -525,15 +525,15 @@ async function findInstallState(project: Project, {unrollAliases = false}: {unro
525525
return {locatorMap, binSymlinks, locationTree: buildLocationTree(locatorMap, {skipPrefix: project.cwd}), nmMode, mtimeMs: stats.mtimeMs};
526526
}
527527

528-
const removeDir = async (dir: PortablePath, options: {contentsOnly: boolean, innerLoop?: boolean, allowSymlink?: boolean}): Promise<any> => {
528+
const removeDir = async (dir: PortablePath, options: {contentsOnly: boolean, innerLoop?: boolean, isWorkspaceDir?: boolean}): Promise<any> => {
529529
if (dir.split(ppath.sep).indexOf(NODE_MODULES) < 0)
530530
throw new Error(`Assertion failed: trying to remove dir that doesn't contain node_modules: ${dir}`);
531531

532532
try {
533+
let dirStats;
533534
if (!options.innerLoop) {
534-
const stats = options.allowSymlink ? await xfs.statPromise(dir) : await xfs.lstatPromise(dir);
535-
if (options.allowSymlink && !stats.isDirectory() ||
536-
(!options.allowSymlink && stats.isSymbolicLink())) {
535+
dirStats = await xfs.lstatPromise(dir);
536+
if ((!dirStats.isDirectory() && !dirStats.isSymbolicLink()) || (dirStats.isSymbolicLink() && !options.isWorkspaceDir)) {
537537
await xfs.unlinkPromise(dir);
538538
return;
539539
}
@@ -549,7 +549,9 @@ const removeDir = async (dir: PortablePath, options: {contentsOnly: boolean, inn
549549
await xfs.unlinkPromise(targetPath);
550550
}
551551
}
552-
if (!options.contentsOnly) {
552+
553+
const isExternalWorkspaceSymlink = !options.innerLoop && options.isWorkspaceDir && dirStats?.isSymbolicLink();
554+
if (!options.contentsOnly && !isExternalWorkspaceSymlink) {
553555
await xfs.rmdirPromise(dir);
554556
}
555557
} catch (e) {
@@ -1133,8 +1135,8 @@ async function persistNodeModules(preinstallState: InstallState, installState: N
11331135
if (prevNode.children.has(NODE_MODULES))
11341136
await removeDir(ppath.join(location, NODE_MODULES), {contentsOnly: false});
11351137

1136-
const isRootNmLocation = ppath.basename(location) === NODE_MODULES && locationTree.has(ppath.join(ppath.dirname(location), ppath.sep));
1137-
await removeDir(location, {contentsOnly: location === rootNmDirPath, allowSymlink: isRootNmLocation});
1138+
const isWorkspaceNmLocation = ppath.basename(location) === NODE_MODULES && prevLocationTree.has(ppath.join(ppath.dirname(location)));
1139+
await removeDir(location, {contentsOnly: location === rootNmDirPath, isWorkspaceDir: isWorkspaceNmLocation});
11381140
} else {
11391141
for (const [segment, prevChildNode] of prevNode.children) {
11401142
const childNode = node.children.get(segment);
@@ -1164,8 +1166,8 @@ async function persistNodeModules(preinstallState: InstallState, installState: N
11641166

11651167
// 1. If new directory is a symlink, we need to remove it fully
11661168
// 2. If new directory is a hardlink - we just need to clean it up
1167-
const isRootNmLocation = ppath.basename(location) === NODE_MODULES && locationTree.has(ppath.join(ppath.dirname(location), ppath.sep));
1168-
await removeDir(location, {contentsOnly: node.linkType === LinkType.HARD, allowSymlink: isRootNmLocation});
1169+
const isWorkspaceNmLocation = ppath.basename(location) === NODE_MODULES && locationTree.has(ppath.join(ppath.dirname(location)));
1170+
await removeDir(location, {contentsOnly: node.linkType === LinkType.HARD, isWorkspaceDir: isWorkspaceNmLocation});
11691171
} else {
11701172
if (!areRealLocatorsEqual(node.locator, prevNode.locator))
11711173
await removeDir(location, {contentsOnly: node.linkType === LinkType.HARD});

0 commit comments

Comments
 (0)