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/catalog-change-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': minor
---

Detect catalog entry changes as package changes. When a catalog version in `pnpm-workspace.yaml` (pnpm) or root `package.json` (bun/yarn `catalog`/`catalogs`, plus `workspaces.catalog`/`workspaces.catalogs`) is modified, `bumpy add` and `bumpy check` now flag every package that references the changed entry via `catalog:` / `catalog:<name>` as changed. Closes #92.
33 changes: 6 additions & 27 deletions packages/bumpy/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { relative, resolve } from 'node:path';
import { resolve } from 'node:path';
import pc from 'picocolors';
import { log } from '../utils/logger.ts';
import { p, unwrap } from '../utils/clack.ts';
import { ensureDir, exists } from '../utils/fs.ts';
import { randomName, slugify } from '../utils/names.ts';
import { writeBumpFile, readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
import picomatch from 'picomatch';
import { getBumpyDir, loadConfig, loadPackageConfig } from '../core/config.ts';
import { discoverPackages, discoverWorkspace } from '../core/workspace.ts';
import { getBumpyDir, loadConfig } from '../core/config.ts';
import { discoverWorkspace } from '../core/workspace.ts';
import { findChangedPackages } from './check.ts';
import { getChangedFiles } from '../core/git.ts';
import { bumpSelectPrompt } from '../prompts/bump-select.ts';
Expand Down Expand Up @@ -78,35 +77,15 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
// Interactive mode
p.intro(pc.bgCyan(pc.black(' bumpy add ')));

const pkgs = await discoverPackages(rootDir, config);
const { packages: pkgs } = await discoverWorkspace(rootDir, config);
if (pkgs.size === 0) {
p.cancel('No managed packages found in this workspace.');
process.exit(1);
}

// Detect which packages have changed on this branch
const baseBranch = config.baseBranch;
const changedFiles = getChangedFiles(rootDir, baseBranch);
// Build per-package matchers (per-package patterns override root patterns)
const matchers = new Map<string, picomatch.Matcher>();
for (const [name, pkg] of pkgs) {
const pkgConfig = await loadPackageConfig(pkg.dir, config, name);
const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns;
matchers.set(name, picomatch(patterns));
}

const changedPackageNames = new Set<string>();
for (const file of changedFiles) {
for (const [name, pkg] of pkgs) {
const pkgRelDir = relative(rootDir, pkg.dir);
if (file.startsWith(pkgRelDir + '/')) {
const relToPackage = file.slice(pkgRelDir.length + 1);
if (matchers.get(name)!(relToPackage)) {
changedPackageNames.add(name);
}
}
}
}
const changedFiles = getChangedFiles(rootDir, config.baseBranch);
const changedPackageNames = new Set(await findChangedPackages(changedFiles, pkgs, rootDir, config));

// Load existing bump files on this branch to avoid re-defaulting already-covered packages
const { bumpFiles: allBumpFiles } = await readBumpFiles(rootDir);
Expand Down
68 changes: 66 additions & 2 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { relative } from 'node:path';
import { relative, resolve } from 'node:path';
import picomatch from 'picomatch';
import { log, colorize } from '../utils/logger.ts';
import { loadConfig, loadPackageConfig, getBumpyDir } from '../core/config.ts';
import { discoverWorkspace } from '../core/workspace.ts';
import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
import { getChangedFiles, getFileStatuses } from '../core/git.ts';
import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts';
import {
detectPackageManager,
parseCatalogs,
diffCatalogMaps,
isCatalogRefAffected,
CATALOG_FILES,
} from '../utils/package-manager.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 @@ -238,5 +247,60 @@ 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, config.baseBranch, 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;
}
}
}

return [...changed];
}

/**
* 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);

// Load "after" (current working tree state)
const afterYaml =
pm === 'pnpm' && (await exists(resolve(rootDir, 'pnpm-workspace.yaml')))
? await readText(resolve(rootDir, 'pnpm-workspace.yaml'))
: 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). pnpm-workspace.yaml is only relevant for pnpm.
const beforeYaml = pm === 'pnpm' ? readFileAtRef(rootDir, baseRef, 'pnpm-workspace.yaml') : null;
const beforePkgJson = readFileAtRef(rootDir, baseRef, 'package.json');
const beforeCatalogs = parseCatalogs(beforeYaml, beforePkgJson);

return diffCatalogMaps(beforeCatalogs, afterCatalogs);
}
25 changes: 19 additions & 6 deletions packages/bumpy/src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,34 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean {
return tryRunArgs(['git', 'tag', '-l', tag], opts) === tag;
}

/** Get files changed on this branch compared to a base branch */
export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
// Ensure we have the base branch ref (may need fetching in shallow CI clones)
/**
* Resolve the ref to use as the comparison base for "this branch vs main".
* Prefers the merge-base, falls back to `origin/<baseBranch>`.
*/
export function getBaseCompareRef(rootDir: string, baseBranch: string): string {
if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) {
tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir });
}

// Try merge-base for the most accurate comparison
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
const ref = mergeBase || `origin/${baseBranch}`;
return mergeBase || `origin/${baseBranch}`;
}

/** Get files changed on this branch compared to a base branch */
export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
const ref = getBaseCompareRef(rootDir, baseBranch);
const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir });
if (!diff) return [];
return diff.split('\n').filter(Boolean);
}

/**
* Read a file's contents at a given git ref.
* Returns null if the file does not exist at that ref.
*/
export function readFileAtRef(rootDir: string, ref: string, path: string): string | null {
return tryRunArgs(['git', 'show', `${ref}:${path}`], { cwd: rootDir });
}

/** Get commits on the current branch since it diverged from baseBranch */
export function getBranchCommits(
rootDir: string,
Expand Down
152 changes: 116 additions & 36 deletions packages/bumpy/src/utils/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,35 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise<s
return [];
}

/** Load catalog definitions from pnpm-workspace.yaml or root package.json */
async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<CatalogMap> {
/**
* Files that may contain catalog definitions, in the order they're applied.
* Later entries override earlier ones (matching loadCatalogs behavior).
*/
export const CATALOG_FILES = ['pnpm-workspace.yaml', 'package.json'] as const;

/**
* Normalize a catalog name to its canonical form.
* pnpm/bun treat "default" and the unnamed top-level catalog interchangeably,
* so we store and look up the default catalog under "" regardless of which alias
* the user wrote.
*/
function normalizeCatalogName(name: string): string {
return name === 'default' ? '' : name;
}

/** Parse catalog definitions from the raw contents of pnpm-workspace.yaml and root package.json */
export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: string | null): CatalogMap {
const catalogs: CatalogMap = new Map();

if (pm === 'pnpm') {
// pnpm: catalogs live in pnpm-workspace.yaml
const wsFile = resolve(rootDir, 'pnpm-workspace.yaml');
if (await exists(wsFile)) {
const content = await readText(wsFile);
const parsed = yaml.load(content) as {
const addNamed = (raw: Record<string, Record<string, string>>): void => {
for (const [name, deps] of Object.entries(raw)) {
catalogs.set(normalizeCatalogName(name), deps);
}
};

if (pnpmWorkspaceYaml) {
try {
const parsed = yaml.load(pnpmWorkspaceYaml) as {
catalog?: Record<string, string>;
catalogs?: Record<string, Record<string, string>>;
} | null;
Expand All @@ -90,52 +109,113 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<Catalo
catalogs.set('', parsed.catalog); // default catalog
}
if (parsed?.catalogs) {
for (const [name, deps] of Object.entries(parsed.catalogs)) {
catalogs.set(name, deps);
}
addNamed(parsed.catalogs);
}
} catch {
// ignore malformed yaml
}
}

// bun/npm/yarn + pnpm fallback: catalogs in root package.json
try {
const pkg = await readJson<Record<string, unknown>>(resolve(rootDir, 'package.json'));
if (rootPackageJson) {
try {
const pkg = JSON.parse(rootPackageJson) as Record<string, unknown>;

// Check top-level catalog/catalogs
if (pkg.catalog && typeof pkg.catalog === 'object') {
catalogs.set('', pkg.catalog as Record<string, string>);
}
if (pkg.catalogs && typeof pkg.catalogs === 'object') {
for (const [name, deps] of Object.entries(pkg.catalogs as Record<string, Record<string, string>>)) {
catalogs.set(name, deps);
// Top-level catalog/catalogs (used by bun, yarn, and proposed npm)
if (pkg.catalog && typeof pkg.catalog === 'object') {
catalogs.set('', pkg.catalog as Record<string, string>);
}
}

// Also check inside workspaces object (bun style)
const workspaces = pkg.workspaces;
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
const ws = workspaces as Record<string, unknown>;
if (ws.catalog && typeof ws.catalog === 'object') {
catalogs.set('', ws.catalog as Record<string, string>);
if (pkg.catalogs && typeof pkg.catalogs === 'object') {
addNamed(pkg.catalogs as Record<string, Record<string, string>>);
}
if (ws.catalogs && typeof ws.catalogs === 'object') {
for (const [name, deps] of Object.entries(ws.catalogs as Record<string, Record<string, string>>)) {
catalogs.set(name, deps);

// Inside workspaces object (bun style)
const workspaces = pkg.workspaces;
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
const ws = workspaces as Record<string, unknown>;
if (ws.catalog && typeof ws.catalog === 'object') {
catalogs.set('', ws.catalog as Record<string, string>);
}
if (ws.catalogs && typeof ws.catalogs === 'object') {
addNamed(ws.catalogs as Record<string, Record<string, string>>);
}
}
} catch {
// ignore malformed json
}
} catch {
// ignore
}

return catalogs;
}

/** Load catalog definitions from pnpm-workspace.yaml or root package.json */
async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<CatalogMap> {
// pnpm-workspace.yaml is only read for pnpm — other PMs don't recognize it
let pnpmYaml: string | null = null;
if (pm === 'pnpm') {
const wsFile = resolve(rootDir, 'pnpm-workspace.yaml');
if (await exists(wsFile)) {
pnpmYaml = await readText(wsFile);
}
}

let pkgJsonText: string | null = null;
const pkgJsonPath = resolve(rootDir, 'package.json');
if (await exists(pkgJsonPath)) {
pkgJsonText = await readText(pkgJsonPath);
}

return parseCatalogs(pnpmYaml, pkgJsonText);
}

/** Extract the catalog name from a `catalog:` / `catalog:<name>` range, normalizing the default alias */
function catalogNameFromRange(range: string): string {
return normalizeCatalogName(range.slice('catalog:'.length).trim());
}

/** Resolve a specific dependency's catalog: reference */
export function resolveCatalogDep(depName: string, range: string, catalogs: CatalogMap): string | null {
if (!range.startsWith('catalog:')) return null;
const catalogName = range.slice('catalog:'.length).trim() || '';
const catalog = catalogs.get(catalogName);
const catalog = catalogs.get(catalogNameFromRange(range));
if (!catalog) return null;
return catalog[depName] ?? null;
}

/**
* Diff two catalog states and return the set of (catalogName → changed depNames).
* Includes added, removed, and version-changed entries.
*/
export function diffCatalogMaps(before: CatalogMap, after: CatalogMap): Map<string, Set<string>> {
const changes = new Map<string, Set<string>>();
const catalogNames = new Set([...before.keys(), ...after.keys()]);

for (const catalogName of catalogNames) {
const beforeDeps = before.get(catalogName) ?? {};
const afterDeps = after.get(catalogName) ?? {};
const depNames = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]);
const changedDeps = new Set<string>();
for (const depName of depNames) {
if (beforeDeps[depName] !== afterDeps[depName]) {
changedDeps.add(depName);
}
}
if (changedDeps.size > 0) {
changes.set(catalogName, changedDeps);
}
}

return changes;
}

/**
* Given a set of catalog entries that have changed, return the set of catalog
* references (e.g. "catalog:" or "catalog:testing") that affect those entries.
* Used to match package.json dep ranges against changed catalog entries.
*/
export function isCatalogRefAffected(
range: string,
depName: string,
catalogChanges: Map<string, Set<string>>,
): boolean {
if (!range.startsWith('catalog:')) return false;
return catalogChanges.get(catalogNameFromRange(range))?.has(depName) ?? false;
}
Loading