Skip to content
Open
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
163 changes: 163 additions & 0 deletions src/__test__/additionalPaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { describe, expect, it } from 'vitest';

import { getAllPackagesChangedBasedOnFilesModified } from '../getPackages.js';
import { listPackages } from '../lets-version.js';
import { PackageInfo } from '../types.js';
import { isUnderPath } from '../util.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

describe('isUnderPath', () => {
it('should match files directly inside the base path', () => {
expect(isUnderPath('/repo/src/file.ts', '/repo/src')).toBe(true);
});

it('should match files in nested subdirectories', () => {
expect(isUnderPath('/repo/src/deep/nested/file.ts', '/repo/src')).toBe(true);
});

it('should not match paths that share a prefix but differ at a directory boundary', () => {
expect(isUnderPath('/repo/src-other/file.ts', '/repo/src')).toBe(false);
});

it('should handle base paths with trailing separators', () => {
expect(isUnderPath('/repo/src/file.ts', '/repo/src/')).toBe(true);
});

it('should match when filePath equals basePath exactly', () => {
expect(isUnderPath('/repo/src', '/repo/src')).toBe(true);
});

it('should not match unrelated paths', () => {
expect(isUnderPath('/other/path/file.ts', '/repo/src')).toBe(false);
});
});

describe('additionalPaths support', () => {
const projectPath = path.join(__dirname, './dummyProjects/multiNPMWithAdditionalPaths');

it('should read additionalPaths from letsVersion.config.mjs and populate allPaths', async () => {
const packages = await listPackages({ cwd: projectPath });

const wasmPkg = packages.find(p => p.name === 'wasm-pkg');
const anotherWasmPkg = packages.find(p => p.name === 'another-wasm-pkg');
const regularPkg = packages.find(p => p.name === 'regular-pkg');

expect(wasmPkg).toBeDefined();
expect(anotherWasmPkg).toBeDefined();
expect(regularPkg).toBeDefined();

const expectedExternalPath = path.resolve(projectPath, 'external-src');

expect(wasmPkg!.additionalPaths).toEqual([expectedExternalPath]);
expect(wasmPkg!.allPaths).toEqual([wasmPkg!.packagePath, expectedExternalPath]);

expect(anotherWasmPkg!.additionalPaths).toEqual([expectedExternalPath]);
expect(anotherWasmPkg!.allPaths).toEqual([anotherWasmPkg!.packagePath, expectedExternalPath]);

expect(regularPkg!.additionalPaths).toEqual([]);
expect(regularPkg!.allPaths).toEqual([regularPkg!.packagePath]);
});

it('should detect changed packages when files in additionalPaths are modified', async () => {
const packages = await listPackages({ cwd: projectPath });
const externalFilePath = path.resolve(projectPath, 'external-src/main.c');

const changedPackages = await getAllPackagesChangedBasedOnFilesModified([externalFilePath], packages, projectPath);

const changedNames = changedPackages.map(p => p.name).sort();
expect(changedNames).toEqual(['another-wasm-pkg', 'wasm-pkg']);
for (const pkg of changedPackages) {
expect(pkg.filesChanged).toContain(externalFilePath);
}
});

it('should not attribute external file changes to packages without additionalPaths', async () => {
const packages = await listPackages({ cwd: projectPath });
const externalFilePath = path.resolve(projectPath, 'external-src/main.c');

const changedPackages = await getAllPackagesChangedBasedOnFilesModified([externalFilePath], packages, projectPath);

const regularPkg = changedPackages.find(p => p.name === 'regular-pkg');
expect(regularPkg).toBeUndefined();
});

it('should still detect changes to files within the package directory itself', async () => {
const packages = await listPackages({ cwd: projectPath });
const wasmPkg = packages.find(p => p.name === 'wasm-pkg')!;
const internalFilePath = path.join(wasmPkg.packagePath, 'src/index.ts');

const changedPackages = await getAllPackagesChangedBasedOnFilesModified([internalFilePath], packages, projectPath);

expect(changedPackages.length).toBe(1);
expect(changedPackages[0]!.name).toBe('wasm-pkg');
});

it('should handle both internal and external file changes for the same package', async () => {
const packages = await listPackages({ cwd: projectPath });
const wasmPkg = packages.find(p => p.name === 'wasm-pkg')!;
const internalFilePath = path.join(wasmPkg.packagePath, 'src/index.ts');
const externalFilePath = path.resolve(projectPath, 'external-src/main.c');

const changedPackages = await getAllPackagesChangedBasedOnFilesModified(
[internalFilePath, externalFilePath],
packages,
projectPath,
);

const wasmChanged = changedPackages.find(p => p.name === 'wasm-pkg');
expect(wasmChanged).toBeDefined();
expect(wasmChanged!.filesChanged).toContain(internalFilePath);
expect(wasmChanged!.filesChanged).toContain(externalFilePath);

const anotherChanged = changedPackages.find(p => p.name === 'another-wasm-pkg');
expect(anotherChanged).toBeDefined();
expect(anotherChanged!.filesChanged).toContain(externalFilePath);
expect(anotherChanged!.filesChanged).not.toContain(internalFilePath);
});

it('should bump multiple packages that share the same additionalPath', async () => {
const packages = await listPackages({ cwd: projectPath });
const externalFilePath = path.resolve(projectPath, 'external-src/main.c');

const changedPackages = await getAllPackagesChangedBasedOnFilesModified([externalFilePath], packages, projectPath);

const changedNames = changedPackages.map(p => p.name).sort();
expect(changedNames).toEqual(['another-wasm-pkg', 'wasm-pkg']);
expect(changedPackages.find(p => p.name === 'regular-pkg')).toBeUndefined();
});

it('should default allPaths to [packagePath] when no additionalPaths configured', () => {
const pkg = new PackageInfo({
isPrivate: false,
name: 'test-pkg',
packagePath: '/some/path',
packageJSONPath: '/some/path/package.json',
pkg: { name: 'test-pkg', version: '1.0.0' },
root: false,
version: '1.0.0',
});

expect(pkg.additionalPaths).toEqual([]);
expect(pkg.allPaths).toEqual(['/some/path']);
});

it('should include additionalPaths in allPaths when provided', () => {
const pkg = new PackageInfo({
additionalPaths: ['/external/a', '/external/b'],
isPrivate: false,
name: 'test-pkg',
packagePath: '/some/path',
packageJSONPath: '/some/path/package.json',
pkg: { name: 'test-pkg', version: '1.0.0' },
root: false,
version: '1.0.0',
});

expect(pkg.additionalPaths).toEqual(['/external/a', '/external/b']);
expect(pkg.allPaths).toEqual(['/some/path', '/external/a', '/external/b']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
int main() { return 0; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default {
packages: {
"wasm-pkg": {
additionalPaths: ["../../external-src"],
},
"another-wasm-pkg": {
additionalPaths: ["../../external-src"],
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"private": true,
"name": "multi-package-additional-paths",
"workspaces": ["./packages/*"],
"version": "0.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "another-wasm-pkg",
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "regular-pkg",
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "wasm-pkg",
"version": "1.0.0"
}
46 changes: 41 additions & 5 deletions src/getPackages.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
import mapWorkspaces from '@npmcli/map-workspaces';
import appRootPath from 'app-root-path';
import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import { PackageJson } from 'type-fest';

import { fixCWD } from './cwd.js';
import { exec } from './exec.js';
import { getPackageManager } from './getPackageManager.js';
import { LetsVersionConfig, readLetsVersionConfig } from './readUserConfig.js';
import { PackageInfo } from './types.js';
import { isUnderPath } from './util.js';
import { detectIfMonorepo } from './workspaces.js';

/**
* Resolves and validates additionalPaths for a package.
* Paths are resolved relative to the package directory. Throws if any
* resolved path does not exist on disk.
*/
function resolveAdditionalPaths(packageName: string, packagePath: string, rawPaths: string[]): string[] {
return rawPaths.map(p => {
const resolved = path.resolve(packagePath, p);
if (!fsSync.existsSync(resolved)) {
throw new Error(
`additionalPaths entry "${p}" for package "${packageName}" resolved to "${resolved}" which does not exist. ` +
`Paths in letsVersion.config.mjs are resolved relative to the package directory ("${packagePath}").`,
);
}
return resolved;
});
}

/**
* Tries to figure out what all packages live in repository.
* The repository may be a multi-package monorepo, or a single package
* repo. Either way, we want to support both.
* We will leave the responsibilty of updating the "root" monorepo package
* to the user. They can use this library, but it will be opt-in.
*/
export async function getPackages(cwd = appRootPath.toString()) {
export async function getPackages(cwd = appRootPath.toString(), config?: LetsVersionConfig | null) {
const fixedCWD = fixCWD(cwd);
const pm = await getPackageManager(fixedCWD);
const resolvedConfig = config ?? (await readLetsVersionConfig(fixedCWD));

const rootPJSONPath = path.join(fixedCWD, 'package.json');

Expand Down Expand Up @@ -56,11 +78,21 @@ export async function getPackages(cwd = appRootPath.toString()) {
});
}

const rootPackagePath = path.dirname(rootPJSONPath);
const rootName = rootPJSON.name || '';
const rootPkgConfig = resolvedConfig?.packages?.[rootName];
const rootAdditionalPaths = resolveAdditionalPaths(
rootName,
rootPackagePath,
rootPkgConfig?.additionalPaths ?? [],
);

const rootPackage = new PackageInfo({
additionalPaths: rootAdditionalPaths,
isPrivate: rootPJSON.private || false,
name: rootPJSON.name || '',
name: rootName,
packageJSONPath: rootPJSONPath,
packagePath: path.dirname(rootPJSONPath),
packagePath: rootPackagePath,
pkg: rootPJSON,
root: true,
version: rootPJSON.version || '',
Expand All @@ -70,7 +102,11 @@ export async function getPackages(cwd = appRootPath.toString()) {
Array.from(workspaces.entries()).map(async ([name, packagePath]) => {
const pjson = JSON.parse(await fs.readFile(path.join(packagePath, 'package.json'), 'utf8')) as PackageJson;

const pkgConfig = resolvedConfig?.packages?.[name];
const additionalPaths = resolveAdditionalPaths(name, packagePath, pkgConfig?.additionalPaths ?? []);

return new PackageInfo({
additionalPaths,
isPrivate: pjson.private ?? false,
name,
packagePath,
Expand Down Expand Up @@ -101,8 +137,8 @@ export async function getAllPackagesChangedBasedOnFilesModified(
const out = new Map<string, PackageInfo>();

for (const filePath of filesModified) {
const touchedPackage = packagesToCheck.find(p => filePath.includes(p.packagePath));
if (touchedPackage) {
const touchedPackages = packagesToCheck.filter(p => p.allPaths.some(ap => isUnderPath(filePath, ap)));
for (const touchedPackage of touchedPackages) {
const prevTrackedTouchedPackage = out.get(touchedPackage.name);
const updatedPackage = new PackageInfo({
...touchedPackage,
Expand Down
Loading