Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .bumpy/generate-catalog-consumers.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
67 changes: 5 additions & 62 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<catalogName, Set<depName>>. Empty if no catalog files changed.
*/
async function getChangedCatalogEntries(
rootDir: string,
baseBranch: string,
changedFiles: string[],
): Promise<Map<string, Set<string>>> {
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);
}
27 changes: 23 additions & 4 deletions packages/bumpy/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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++;
Expand Down Expand Up @@ -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<string, WorkspacePackage>,
rootDir: string,
): Promise<string[]> {
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[],
Expand Down
80 changes: 80 additions & 0 deletions packages/bumpy/src/core/catalog-changes.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>;

/** 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<CatalogMap> {
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<CatalogChanges> {
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<string, WorkspacePackage>,
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;
}
19 changes: 3 additions & 16 deletions packages/bumpy/src/core/dep-bump-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<CatalogMap> {
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
Expand Down
30 changes: 18 additions & 12 deletions packages/bumpy/src/utils/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,25 @@ export async function detectWorkspaces(rootDir: string): Promise<WorkspaceInfo>
}

export async function detectPackageManager(rootDir: string): Promise<PackageManager> {
// 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<string | null>,
): Promise<PackageManager> {
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<Record<string, unknown>>(resolve(rootDir, 'package.json'));
const pkg = JSON.parse((await read('package.json')) ?? '') as Record<string, unknown>;
if (typeof pkg.packageManager === 'string') {
const name = pkg.packageManager.split('@')[0];
if (name === 'pnpm' || name === 'yarn' || name === 'bun') return name;
Expand Down
Loading
Loading