From 09d3193e3b353be2dea3931c40298c348a76abd5 Mon Sep 17 00:00:00 2001 From: Bruce Spang Date: Thu, 9 Apr 2026 09:56:53 -0700 Subject: [PATCH 1/2] Add a way to specify additional paths a package build depends on --- src/__test__/additionalPaths.test.ts | 163 ++++++++++++++++++ .../external-src/main.c | 1 + .../letsVersion.config.mjs | 10 ++ .../multiNPMWithAdditionalPaths/package.json | 6 + .../packages/another-wasm-pkg/package.json | 5 + .../packages/regular-pkg/package.json | 5 + .../packages/wasm-pkg/package.json | 5 + src/getPackages.ts | 46 ++++- src/git.ts | 19 +- src/readUserConfig.ts | 53 ++++++ src/types.ts | 17 +- src/util.ts | 10 ++ 12 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 src/__test__/additionalPaths.test.ts create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/external-src/main.c create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/letsVersion.config.mjs create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/package.json create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/another-wasm-pkg/package.json create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/regular-pkg/package.json create mode 100644 src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/wasm-pkg/package.json diff --git a/src/__test__/additionalPaths.test.ts b/src/__test__/additionalPaths.test.ts new file mode 100644 index 0000000..82ac89a --- /dev/null +++ b/src/__test__/additionalPaths.test.ts @@ -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']); + }); +}); diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/external-src/main.c b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/external-src/main.c new file mode 100644 index 0000000..76e8197 --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/external-src/main.c @@ -0,0 +1 @@ +int main() { return 0; } diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/letsVersion.config.mjs b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/letsVersion.config.mjs new file mode 100644 index 0000000..a007598 --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/letsVersion.config.mjs @@ -0,0 +1,10 @@ +export default { + packages: { + "wasm-pkg": { + additionalPaths: ["../../external-src"], + }, + "another-wasm-pkg": { + additionalPaths: ["../../external-src"], + }, + }, +}; diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/package.json b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/package.json new file mode 100644 index 0000000..cd04acd --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "name": "multi-package-additional-paths", + "workspaces": ["./packages/*"], + "version": "0.0.0" +} diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/another-wasm-pkg/package.json b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/another-wasm-pkg/package.json new file mode 100644 index 0000000..aca2b96 --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/another-wasm-pkg/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "another-wasm-pkg", + "version": "1.0.0" +} diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/regular-pkg/package.json b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/regular-pkg/package.json new file mode 100644 index 0000000..219d218 --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/regular-pkg/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "regular-pkg", + "version": "1.0.0" +} diff --git a/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/wasm-pkg/package.json b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/wasm-pkg/package.json new file mode 100644 index 0000000..53d5e78 --- /dev/null +++ b/src/__test__/dummyProjects/multiNPMWithAdditionalPaths/packages/wasm-pkg/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "wasm-pkg", + "version": "1.0.0" +} diff --git a/src/getPackages.ts b/src/getPackages.ts index 7ea6198..1d46f1c 100644 --- a/src/getPackages.ts +++ b/src/getPackages.ts @@ -1,15 +1,36 @@ 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 @@ -17,9 +38,10 @@ import { detectIfMonorepo } from './workspaces.js'; * 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'); @@ -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 || '', @@ -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, @@ -101,8 +137,8 @@ export async function getAllPackagesChangedBasedOnFilesModified( const out = new Map(); 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, diff --git a/src/git.ts b/src/git.ts index 2725b8e..8e43ead 100644 --- a/src/git.ts +++ b/src/git.ts @@ -8,7 +8,7 @@ import { fixCWD } from './cwd.js'; import { exec } from './exec.js'; import { parseToConventional } from './parser.js'; import { GitCommit, GitCommitWithConventionalAndPackageInfo, PackageInfo, PublishTagInfo } from './types.js'; -import { chunkArray } from './util.js'; +import { chunkArray, isUnderPath } from './util.js'; let didFetchAll = false; /** @@ -52,7 +52,7 @@ export interface GitCommitsSinceOpts { commitDateFormat?: string; cwd?: string; since?: string; - relPath?: string; + relPath?: string | string[]; } /** * Returns commits since a particular git SHA or tag. @@ -60,7 +60,7 @@ export interface GitCommitsSinceOpts { * from the dawn of man are returned */ export async function gitCommitsSince(opts?: GitCommitsSinceOpts): Promise { - const { cwd = appRootPath.toString(), commitDateFormat = 'iso-strict', relPath = '', since = '' } = opts ?? {}; + const { cwd = appRootPath.toString(), commitDateFormat = 'iso-strict', relPath = [], since = '' } = opts ?? {}; const fixedCWD = fixCWD(cwd); let cmd = 'git --no-pager log'; @@ -71,7 +71,10 @@ export async function gitCommitsSince(opts?: GitCommitsSinceOpts): Promise `"${p}"`).join(' ')}`; const stdout = await exec(cmd, { cwd: fixedCWD, stdio: 'pipe' }); @@ -271,7 +274,7 @@ export async function getAllFilesChangedSinceTagInfos( // we'll guard just to silence the compiler if (!pkg) return results; - return results.filter(fp => fp.startsWith(pkg.packagePath)); + return results.filter(fp => pkg.allPaths.some(p => isUnderPath(fp, p))); }), ) ).flat(); @@ -293,7 +296,7 @@ export async function getAllFilesChangedSinceBranch( const results = filteredPackages .map(pkg => { - return allFiles.filter(fp => fp.startsWith(pkg.packagePath)); + return allFiles.filter(fp => pkg.allPaths.some(p => isUnderPath(fp, p))); }) .flat(); @@ -316,7 +319,7 @@ export async function gitConventionalForPackage( if (!noFetchAll) gitFetchAll(fixedCWD); const taginfo = await gitLastKnownPublishTagInfoForPackage(packageInfo, fixedCWD); - const relPackagePath = path.relative(cwd, packageInfo.packagePath); + const relPaths = packageInfo.allPaths.map(p => path.relative(cwd, p)); // in a prior version of lets-version, we used to error out if there wasn't a previous publish at all, // which wasn't great, as it meant that you needed at least one publish to use this library in your repo. @@ -325,7 +328,7 @@ export async function gitConventionalForPackage( const results = await gitCommitsSince({ ...rest, cwd: fixedCWD, - relPath: relPackagePath, + relPath: relPaths, since: taginfo?.sha ?? undefined, }); const conventional = parseToConventional(results); diff --git a/src/readUserConfig.ts b/src/readUserConfig.ts index f6ebb84..8e578d8 100644 --- a/src/readUserConfig.ts +++ b/src/readUserConfig.ts @@ -10,8 +10,61 @@ export interface ChangelogConfig { changeLogRollupFormatter?: ChangeLogRollupFormatter; } +/** + * Per-package configuration options in letsVersion.config.mjs. + */ +export interface LetsVersionPackageConfig { + /** + * Additional filesystem paths whose changes should trigger a version bump + * for this package. Useful when a package depends on source files that live + * outside its own directory (e.g. native C/C++ sources consumed by a WASM + * package). + * + * Paths are resolved **relative to the package directory**, not the + * repository root. For example, if your repo looks like: + * + * ``` + * / + * c-src/ + * js/ + * packages/ + * wasm-pkg/ <-- package directory + * ``` + * + * To watch `c-src/` for changes to `wasm-pkg`, you would configure: + * + * ```js + * // letsVersion.config.mjs + * export default { + * packages: { + * "wasm-pkg": { + * additionalPaths: ["../../../c-src"], + * }, + * }, + * }; + * ``` + * + * Multiple packages may reference the same additional path — a change in + * that path will trigger a version bump for all of them. + * + * Each path must point to an existing directory or file on disk; an error + * is thrown at startup if a resolved path does not exist. + */ + additionalPaths?: string[]; +} + +/** + * Root configuration for letsVersion.config.mjs. + */ export interface LetsVersionConfig { + /** Changelog formatting overrides. */ changelog?: ChangelogConfig; + + /** + * Per-package overrides, keyed by the package `name` field from its + * package.json. See {@link LetsVersionPackageConfig} for available options. + */ + packages?: Record; } /** diff --git a/src/types.ts b/src/types.ts index 6e58b80..0228ceb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -191,6 +191,7 @@ export class GitCommitWithConventionalAndPackageInfo extends GitCommitWithConven } export interface PackageInfoOpts { + additionalPaths?: string[]; filesChanged?: string[]; isPrivate: boolean; name: string; @@ -202,6 +203,8 @@ export interface PackageInfoOpts { } export class PackageInfo { + additionalPaths: string[]; + allPaths: string[]; isPrivate: boolean; name: string; packageJSONPath: string; @@ -211,7 +214,19 @@ export class PackageInfo { version: string; filesChanged?: string[]; - constructor({ filesChanged, isPrivate, name, packageJSONPath, packagePath, pkg, root, version }: PackageInfoOpts) { + constructor({ + additionalPaths, + filesChanged, + isPrivate, + name, + packageJSONPath, + packagePath, + pkg, + root, + version, + }: PackageInfoOpts) { + this.additionalPaths = additionalPaths ?? []; + this.allPaths = [packagePath, ...this.additionalPaths]; this.isPrivate = isPrivate; this.name = name; this.packageJSONPath = packageJSONPath; diff --git a/src/util.ts b/src/util.ts index 7cea2f0..9875b29 100644 --- a/src/util.ts +++ b/src/util.ts @@ -57,6 +57,16 @@ export function isPackageJSONDependencyKeySupported( return false; } +/** + * Returns true if `filePath` is located inside `basePath`. + * Uses a trailing path separator to avoid false prefix matches + * (e.g. `/foo/bar-baz/file` should NOT match base `/foo/bar`). + */ +export function isUnderPath(filePath: string, basePath: string): boolean { + const normalized = basePath.endsWith(path.sep) ? basePath : basePath + path.sep; + return filePath.startsWith(normalized) || filePath === basePath; +} + /** * Left-indents content to a certain depth */ From 33bf115008caf5b7d2effc146071abde69c98a7f Mon Sep 17 00:00:00 2001 From: Bruce Spang Date: Wed, 22 Apr 2026 12:44:29 -0700 Subject: [PATCH 2/2] fix: resolve diff paths against git repo top-level and drop pathspec quoting Two related bugs surfaced when packages declare additionalPaths that live outside the npm/yarn/pnpm workspace root (e.g. a workspace nested inside a larger git repo with sibling source directories): 1. gitAllFilesChangedSinceSha resolved the output of `git diff --name-only` against `cwd`, but git always emits paths relative to the repo top-level regardless of cwd. When the workspace root is not the repo root, this produced paths like `//...` that don't exist on disk, so the downstream isUnderPath filter rejected them and the additionalPaths feature appeared to silently do nothing. 2. gitCommitsSince wrapped each pathspec in `"..."`, but the local exec helper splits commands on whitespace via `command.split(/\s+/)` rather than parsing shell quoting. The literal quote characters end up in argv and git matches no files. With `relPath` of a single, unquoted path (the pre-additionalPaths shape) this never tripped, but the new multi-path form was always quoted. Fix (1) by querying `git rev-parse --show-toplevel` once and resolving against that. Fix (2) by joining pathspecs unquoted; users who need whitespace in additionalPaths can fix the exec layer separately. Made-with: Cursor --- src/git.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/git.ts b/src/git.ts index 8e43ead..e298a07 100644 --- a/src/git.ts +++ b/src/git.ts @@ -74,7 +74,14 @@ export async function gitCommitsSince(opts?: GitCommitsSinceOpts): Promise `"${p}"`).join(' ')}`; + // Pathspecs are passed unquoted because the local `exec` helper splits + // commands on whitespace via `command.split(/\s+/)` rather than parsing + // shell quoting. If we wrap each path in `"..."`, the literal quote chars + // end up in argv and git sees a pathspec like `"packages/foo"` which + // matches no files. Paths produced by `path.relative()` are safe to leave + // unquoted in practice; users who need whitespace in paths should fix the + // exec layer. + if (nonEmptyPaths.length) cmd += ` -- ${nonEmptyPaths.join(' ')}`; const stdout = await exec(cmd, { cwd: fixedCWD, stdio: 'pipe' }); @@ -232,6 +239,21 @@ export async function getLastKnownPublishTagInfoForAllPackages( ); } +/** + * Returns the absolute path to the git repository's top-level directory. + * `git diff --name-only` always emits paths relative to the repo root, + * regardless of the cwd it was invoked from. When the npm/yarn/pnpm + * workspace root and the git repo root differ (e.g. a workspace nested + * inside a larger git repo), naively joining the output with `cwd` produces + * paths that don't exist on disk. This is essential for correct attribution + * when packages declare `additionalPaths` that live outside the workspace + * root. + */ +async function gitRepoTopLevel(cwd: string): Promise { + const out = await exec('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }); + return (out ?? '').trim(); +} + /** * Given a specific git sha, finds all files that have been modified * since the sha and returns the absolute filepaths @@ -240,12 +262,15 @@ export async function gitAllFilesChangedSinceSha(sha: string, cwd = appRootPath. const fixedCWD = fixCWD(cwd); const stdout = await exec(`git --no-pager diff --name-only ${sha}..`, { cwd: fixedCWD, stdio: 'pipe' }); + // Resolve git's repo-root-relative output against the actual git top-level, + // not `cwd`. See `gitRepoTopLevel` above. + const repoTop = await gitRepoTopLevel(fixedCWD); return ( stdout ?.trim() .split(os.EOL) .filter(Boolean) - .map(fp => path.resolve(path.join(cwd, fp))) ?? [] + .map(fp => path.resolve(repoTop, fp)) ?? [] ); }