diff --git a/.bumpy/generate-catalog-consumers.md b/.bumpy/generate-catalog-consumers.md new file mode 100644 index 0000000..6f5edd6 --- /dev/null +++ b/.bumpy/generate-catalog-consumers.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +`bumpy generate` now maps commits that change a catalog entry (`pnpm-workspace.yaml`, `.yarnrc.yml`, root `package.json`) to the packages consuming it via `catalog:`, so a catalog bump produces the bump file `bumpy check` asks for. diff --git a/docs/cli.md b/docs/cli.md index 8472567..7dd6fa0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -175,6 +175,7 @@ bumpy generate --deps # one patch bump file per package whose dependenc - `fix:`, `perf:`, `refactor:`, `docs:`, `style:`, `test:`, `build:`, `ci:`, `chore:` → patch - `feat!:` or `BREAKING CHANGE:` → major 2. **All other commits** (including scopeless conventional commits) are mapped to packages by detecting which files changed in the commit and matching them to package directories. These default to `patch`. + - If the commit changes a **catalog entry** (in `pnpm-workspace.yaml`, `.yarnrc.yml`, or the root `package.json`), every package that references that entry via `catalog:` is included — so a Renovate/Dependabot catalog bump maps to its consumers, matching what `bumpy check` requires. When multiple commits affect the same package, the highest bump level wins. diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index e2fe9ee..c65d2c5 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -5,16 +5,8 @@ import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob, resolveFixedGrou import { discoverWorkspace } from '../core/workspace.ts'; import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts'; -import { - detectPackageManager, - parseCatalogs, - diffCatalogMaps, - isCatalogRefAffected, - catalogYamlFile, - CATALOG_FILES, -} from '../utils/package-manager.ts'; +import { getChangedCatalogEntries, findCatalogConsumers, touchesCatalogFiles } from '../core/catalog-changes.ts'; import { readText, exists } from '../utils/fs.ts'; -import { DEP_TYPES } from '../types.ts'; import type { BumpyConfig, WorkspacePackage } from '../types.ts'; export type HookContext = 'pre-commit' | 'pre-push'; @@ -347,23 +339,10 @@ export async function findChangedPackages( // Catalog change detection: if a catalog file changed, find packages whose // catalog: dep references resolve to a changed catalog entry - const catalogChanges = await getChangedCatalogEntries(rootDir, compareBranch, changedFiles); - if (catalogChanges.size > 0) { - for (const [name, pkg] of packages) { - if (changed.has(name)) continue; - for (const depType of DEP_TYPES) { - const deps = pkg[depType]; - let matched = false; - for (const [depName, range] of Object.entries(deps)) { - if (isCatalogRefAffected(range, depName, catalogChanges)) { - changed.add(name); - matched = true; - break; - } - } - if (matched) break; - } - } + if (touchesCatalogFiles(changedFiles)) { + baseRef ??= getBaseCompareRef(rootDir, compareBranch); + const catalogChanges = await getChangedCatalogEntries(rootDir, baseRef, null, changedFiles); + for (const name of findCatalogConsumers(packages, catalogChanges)) changed.add(name); } return [...changed]; @@ -454,39 +433,3 @@ function deepEqual(a: unknown, b: unknown): boolean { if (ak.length !== bk.length) return false; return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k])); } - -/** - * Compute which catalog entries changed between the base ref and HEAD. - * Returns Map>. Empty if no catalog files changed. - */ -async function getChangedCatalogEntries( - rootDir: string, - baseBranch: string, - changedFiles: string[], -): Promise>> { - const catalogFileChanged = changedFiles.some((f) => (CATALOG_FILES as readonly string[]).includes(f)); - if (!catalogFileChanged) return new Map(); - - const baseRef = getBaseCompareRef(rootDir, baseBranch); - - const pm = await detectPackageManager(rootDir); - - // The workspace YAML that may hold catalogs: pnpm-workspace.yaml for pnpm, - // .yarnrc.yml for Yarn, and none for npm/bun (which use package.json). - const yamlFile = catalogYamlFile(pm); - - // Load "after" (current working tree state) - const afterYaml = - yamlFile && (await exists(resolve(rootDir, yamlFile))) ? await readText(resolve(rootDir, yamlFile)) : null; - const afterPkgJson = (await exists(resolve(rootDir, 'package.json'))) - ? await readText(resolve(rootDir, 'package.json')) - : null; - const afterCatalogs = parseCatalogs(afterYaml, afterPkgJson); - - // Load "before" (state at base ref). - const beforeYaml = yamlFile ? readFileAtRef(rootDir, baseRef, yamlFile) : null; - const beforePkgJson = readFileAtRef(rootDir, baseRef, 'package.json'); - const beforeCatalogs = parseCatalogs(beforeYaml, beforePkgJson); - - return diffCatalogMaps(beforeCatalogs, afterCatalogs); -} diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index 3631585..a509832 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -8,6 +8,7 @@ import { getBumpyDir } from '../core/config.ts'; import { ensureDir } from '../utils/fs.ts'; import { slugify, randomName } from '../utils/names.ts'; import { getBaseCompareRef, getBranchCommits, getCurrentBranch, getFilesChangedInCommit } from '../core/git.ts'; +import { getChangedCatalogEntries, findCatalogConsumers } from '../core/catalog-changes.ts'; import { detectDependencyChanges, planDependencyBumpFiles, @@ -115,8 +116,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P // CC commit but scope didn't resolve (or no scope) — use file-based detection // with the CC-derived bump level - const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir }); - const touchedPkgs = mapFilesToPackages(files, packages, rootDir).filter(isDirectlyBumpable); + const touchedPkgs = (await mapCommitToPackages(commit.hash, packages, rootDir)).filter(isDirectlyBumpable); if (touchedPkgs.length > 0) { for (const name of touchedPkgs) { @@ -127,8 +127,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P } } else { // Non-conventional commit — use file paths to detect packages, default to patch - const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir }); - const touchedPkgs = mapFilesToPackages(files, packages, rootDir).filter(isDirectlyBumpable); + const touchedPkgs = (await mapCommitToPackages(commit.hash, packages, rootDir)).filter(isDirectlyBumpable); if (touchedPkgs.length > 0) { fileBasedCount++; @@ -245,6 +244,26 @@ function mergeRelease( } } +/** + * Packages affected by a commit: those owning a changed file (directory containment), + * plus — when the commit touches a catalog file — every package with a `catalog:` + * dependency whose entry changed in that commit. Catalog files live at the repo root, + * so without the second pass a catalog-only bump (e.g. from Renovate) matches nothing. + */ +export async function mapCommitToPackages( + hash: string, + packages: Map, + rootDir: string, +): Promise { + const files = getFilesChangedInCommit(hash, { cwd: rootDir }); + const matched = new Set(mapFilesToPackages(files, packages, rootDir)); + // `hash^` doesn't exist for a root commit — readFileAtRef then yields no catalogs + // at "before", so every entry counts as added. That is the right answer. + const catalogChanges = await getChangedCatalogEntries(rootDir, `${hash}^`, hash, files); + for (const name of findCatalogConsumers(packages, catalogChanges)) matched.add(name); + return [...matched]; +} + /** Map file paths to package names based on directory containment */ export function mapFilesToPackages( files: string[], diff --git a/packages/bumpy/src/core/catalog-changes.ts b/packages/bumpy/src/core/catalog-changes.ts new file mode 100644 index 0000000..5f4318e --- /dev/null +++ b/packages/bumpy/src/core/catalog-changes.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path'; +import { readFileAtRef } from './git.ts'; +import { + CATALOG_FILES, + catalogYamlFile, + detectPackageManagerFrom, + diffCatalogMaps, + isCatalogRefAffected, + parseCatalogs, +} from '../utils/package-manager.ts'; +import type { CatalogMap } from '../utils/package-manager.ts'; +import { exists, readText } from '../utils/fs.ts'; +import { DEP_TYPES } from '../types.ts'; +import type { WorkspacePackage } from '../types.ts'; + +/** + * Catalog change detection shared by `bumpy check` and `bumpy generate`. + * + * Catalog definitions live in root-level files (`pnpm-workspace.yaml`, `.yarnrc.yml`, + * `package.json`), so a commit that only bumps a catalog entry touches no package + * directory. To attribute such a change to the packages it actually affects, diff the + * catalogs between two refs and look up which packages reference the changed entries + * via the `catalog:` protocol. + */ + +/** Map of catalogName → set of depNames whose entries changed */ +export type CatalogChanges = Map>; + +/** Whether any of `changedFiles` may define catalogs */ +export function touchesCatalogFiles(changedFiles: string[]): boolean { + return changedFiles.some((f) => (CATALOG_FILES as readonly string[]).includes(f)); +} + +/** + * Load catalog definitions at a git ref, or from the working tree when `ref` is null. + * The package manager (and so the YAML file that may hold catalogs: pnpm-workspace.yaml + * for pnpm, .yarnrc.yml for Yarn, none for npm/bun) is detected at that same ref, so a + * catalog commit from before a package-manager migration is still read correctly. + */ +export async function loadCatalogsAt(rootDir: string, ref: string | null): Promise { + const read = ref + ? async (file: string) => readFileAtRef(rootDir, ref, file) + : async (file: string) => ((await exists(resolve(rootDir, file))) ? readText(resolve(rootDir, file)) : null); + const yamlFile = catalogYamlFile(await detectPackageManagerFrom(read)); + return parseCatalogs(yamlFile ? await read(yamlFile) : null, await read('package.json')); +} + +/** + * Compute which catalog entries changed between `beforeRef` and `afterRef` (the working + * tree when null). Returns an empty map when none of `changedFiles` is a catalog file, + * so the git reads are skipped in the common case. + */ +export async function getChangedCatalogEntries( + rootDir: string, + beforeRef: string, + afterRef: string | null, + changedFiles: string[], +): Promise { + if (!touchesCatalogFiles(changedFiles)) return new Map(); + const [before, after] = await Promise.all([loadCatalogsAt(rootDir, beforeRef), loadCatalogsAt(rootDir, afterRef)]); + return diffCatalogMaps(before, after); +} + +/** Packages with a `catalog:` dependency (in any dep field) that resolves to a changed catalog entry */ +export function findCatalogConsumers( + packages: Map, + catalogChanges: CatalogChanges, +): string[] { + if (catalogChanges.size === 0) return []; + const consumers: string[] = []; + for (const [name, pkg] of packages) { + const affected = DEP_TYPES.some((depType) => + Object.entries(pkg[depType] ?? {}).some(([depName, range]) => + isCatalogRefAffected(range, depName, catalogChanges), + ), + ); + if (affected) consumers.push(name); + } + return consumers; +} diff --git a/packages/bumpy/src/core/dep-bump-files.ts b/packages/bumpy/src/core/dep-bump-files.ts index ad9e8c1..916bf6b 100644 --- a/packages/bumpy/src/core/dep-bump-files.ts +++ b/packages/bumpy/src/core/dep-bump-files.ts @@ -4,9 +4,10 @@ import { relative, resolve } from 'node:path'; import { getBumpyDir, matchGlob, resolveFixedGroups } from './config.ts'; import { writeBumpFile } from './bump-file.ts'; import { readFileAtRef } from './git.ts'; -import { catalogYamlFile, detectPackageManager, parseCatalogs, resolveCatalogDep } from '../utils/package-manager.ts'; +import { resolveCatalogDep } from '../utils/package-manager.ts'; import type { CatalogMap } from '../utils/package-manager.ts'; -import { ensureDir, exists, readText } from '../utils/fs.ts'; +import { loadCatalogsAt } from './catalog-changes.ts'; +import { ensureDir } from '../utils/fs.ts'; import { slugify } from '../utils/names.ts'; import { DEP_TYPES } from '../types.ts'; import type { BumpyConfig, DepType, WorkspacePackage } from '../types.ts'; @@ -70,20 +71,6 @@ function resolveRange(depName: string, range: string | undefined, catalogs: Cata return resolveCatalogDep(depName, range, catalogs) ?? range; } -async function loadCatalogsAt(rootDir: string, ref: string | null): Promise { - const pm = await detectPackageManager(rootDir); - const yamlFile = catalogYamlFile(pm); - if (ref) { - return parseCatalogs( - yamlFile ? readFileAtRef(rootDir, ref, yamlFile) : null, - readFileAtRef(rootDir, ref, 'package.json'), - ); - } - const read = async (file: string) => - (await exists(resolve(rootDir, file))) ? readText(resolve(rootDir, file)) : null; - return parseCatalogs(yamlFile ? await read(yamlFile) : null, await read('package.json')); -} - /** * Diff the release-relevant dependencies of every managed package between `baseRef` and * the working tree — mirroring what `bumpy check` treats as release-affecting, so the diff --git a/packages/bumpy/src/utils/package-manager.ts b/packages/bumpy/src/utils/package-manager.ts index 0488f23..45c58dc 100644 --- a/packages/bumpy/src/utils/package-manager.ts +++ b/packages/bumpy/src/utils/package-manager.ts @@ -21,19 +21,25 @@ export async function detectWorkspaces(rootDir: string): Promise } export async function detectPackageManager(rootDir: string): Promise { - // Check lockfiles in priority order - if ((await exists(resolve(rootDir, 'bun.lock'))) || (await exists(resolve(rootDir, 'bun.lockb')))) { - return 'bun'; - } - if (await exists(resolve(rootDir, 'pnpm-lock.yaml'))) { - return 'pnpm'; - } - if (await exists(resolve(rootDir, 'yarn.lock'))) { - return 'yarn'; - } - // Fallback: check packageManager field in package.json + return detectPackageManagerFrom(async (file) => { + const path = resolve(rootDir, file); + return (await exists(path)) ? readText(path) : null; + }); +} + +/** + * Package manager detection over an abstract file reader (`null` = file absent), so the + * same rules apply to the working tree and to a historical git ref. Lockfiles are checked + * in priority order, then the `packageManager` field in package.json, defaulting to npm. + */ +export async function detectPackageManagerFrom( + read: (file: string) => Promise, +): Promise { + if ((await read('bun.lock')) != null || (await read('bun.lockb')) != null) return 'bun'; + if ((await read('pnpm-lock.yaml')) != null) return 'pnpm'; + if ((await read('yarn.lock')) != null) return 'yarn'; try { - const pkg = await readJson>(resolve(rootDir, 'package.json')); + const pkg = JSON.parse((await read('package.json')) ?? '') as Record; if (typeof pkg.packageManager === 'string') { const name = pkg.packageManager.split('@')[0]; if (name === 'pnpm' || name === 'yarn' || name === 'bun') return name; diff --git a/packages/bumpy/test/core/generate.test.ts b/packages/bumpy/test/core/generate.test.ts index eb1c177..251a4a3 100644 --- a/packages/bumpy/test/core/generate.test.ts +++ b/packages/bumpy/test/core/generate.test.ts @@ -1,6 +1,10 @@ -import { test, expect, describe } from 'bun:test'; -import { mapFilesToPackages } from '../../src/commands/generate.ts'; -import { makePkg } from '../helpers.ts'; +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { mapFilesToPackages, mapCommitToPackages } from '../../src/commands/generate.ts'; +import { discoverWorkspace } from '../../src/core/workspace.ts'; +import { loadConfig } from '../../src/core/config.ts'; +import { makePkg, createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; describe('mapFilesToPackages', () => { const rootDir = '/repo'; @@ -20,3 +24,139 @@ describe('mapFilesToPackages', () => { expect(result).toEqual(['pkg-b']); }); }); + +describe('mapCommitToPackages — catalog changes', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + gitInDir(['config', 'user.email', 'test@example.com'], tmpDir); + gitInDir(['config', 'user.name', 'Test'], tmpDir); + }); + + afterEach(async () => { + await cleanupTempDir(tmpDir); + }); + + async function writeFileAt(path: string, content: string): Promise { + await mkdir(resolve(tmpDir, path, '..'), { recursive: true }); + await writeFile(resolve(tmpDir, path), content); + } + async function writeJson(path: string, data: unknown): Promise { + await writeFileAt(path, JSON.stringify(data, null, 2)); + } + + test('a commit that only bumps a catalog entry maps to the packages consuming it', async () => { + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalog:\n react: ^18.2.0\n lodash: ^4.0.0\n", + ); + await writeFileAt('pnpm-lock.yaml', ''); + await writeJson('packages/foo/package.json', { + name: 'foo', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }); + await writeJson('packages/bar/package.json', { + name: 'bar', + version: '1.0.0', + dependencies: { lodash: 'catalog:' }, + }); + await writeJson('packages/baz/package.json', { name: 'baz', version: '1.0.0', dependencies: { react: '^18.0.0' } }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init monorepo'], tmpDir); + + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalog:\n react: ^18.3.1\n lodash: ^4.0.0\n", + ); + gitInDir(['commit', '-am', 'Bump react catalog entry to ^18.3.1'], tmpDir); + const hash = gitInDir(['rev-parse', 'HEAD'], tmpDir); + + const { packages } = await discoverWorkspace(tmpDir, await loadConfig(tmpDir)); + expect(await mapCommitToPackages(hash, packages, tmpDir)).toEqual(['foo']); + }); + + test('root package.json catalog (bun/npm) and named catalogs are detected too', async () => { + await writeJson('package.json', { + name: 'root', + private: true, + workspaces: { packages: ['packages/*'], catalog: { react: '^18.2.0' }, catalogs: { tools: { vite: '^5.0.0' } } }, + }); + await writeFileAt('bun.lock', ''); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { vite: 'catalog:tools' }, + }); + await writeJson('packages/ui/package.json', { + name: 'ui', + version: '1.0.0', + peerDependencies: { react: 'catalog:' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init monorepo'], tmpDir); + + await writeJson('package.json', { + name: 'root', + private: true, + workspaces: { packages: ['packages/*'], catalog: { react: '^18.2.0' }, catalogs: { tools: { vite: '^6.0.0' } } }, + }); + gitInDir(['commit', '-am', 'chore: bump vite'], tmpDir); + const hash = gitInDir(['rev-parse', 'HEAD'], tmpDir); + + const { packages } = await discoverWorkspace(tmpDir, await loadConfig(tmpDir)); + expect(await mapCommitToPackages(hash, packages, tmpDir)).toEqual(['app']); + }); + + test('a catalog commit from before a package-manager migration is still detected', async () => { + // pnpm repo: catalog bump commit… + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n react: ^18.2.0\n"); + await writeFileAt('pnpm-lock.yaml', ''); + await writeJson('packages/foo/package.json', { + name: 'foo', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init monorepo'], tmpDir); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n react: ^18.3.1\n"); + gitInDir(['commit', '-am', 'bump react catalog entry'], tmpDir); + const catalogCommit = gitInDir(['rev-parse', 'HEAD'], tmpDir); + + // …then a later commit migrates the repo to Yarn, moving the catalog to .yarnrc.yml + gitInDir(['rm', '-q', 'pnpm-workspace.yaml', 'pnpm-lock.yaml'], tmpDir); + await writeFileAt('yarn.lock', ''); + await writeFileAt('.yarnrc.yml', 'catalog:\n react: ^18.3.1\n'); + await writeJson('package.json', { name: 'root', private: true, workspaces: ['packages/*'] }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'migrate to yarn'], tmpDir); + + const { packages } = await discoverWorkspace(tmpDir, await loadConfig(tmpDir)); + expect(await mapCommitToPackages(catalogCommit, packages, tmpDir)).toEqual(['foo']); + }); + + test('a root-level commit that changes no catalog entries maps to nothing', async () => { + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n react: ^18.2.0\n"); + await writeFileAt('pnpm-lock.yaml', ''); + await writeJson('packages/foo/package.json', { + name: 'foo', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init monorepo'], tmpDir); + + // Reorder keys in pnpm-workspace.yaml and touch the root package.json — no entry changes + await writeFileAt('pnpm-workspace.yaml', "catalog:\n react: ^18.2.0\npackages:\n - 'packages/*'\n"); + await writeJson('package.json', { name: 'root', private: true, description: 'hi' }); + gitInDir(['commit', '-am', 'tidy root files'], tmpDir); + const hash = gitInDir(['rev-parse', 'HEAD'], tmpDir); + + const { packages } = await discoverWorkspace(tmpDir, await loadConfig(tmpDir)); + expect(await mapCommitToPackages(hash, packages, tmpDir)).toEqual([]); + }); +});