Skip to content

Commit 5e6acec

Browse files
committed
feat: detect catalog entry changes as package changes
When a catalog version in pnpm-workspace.yaml or root package.json (bun/yarn catalog/catalogs, plus workspaces.catalog/catalogs) changes, flag every package that references the changed entry via catalog: / catalog:<name> as changed in `bumpy add` and `bumpy check`. Closes #92
1 parent dabcc73 commit 5e6acec

7 files changed

Lines changed: 598 additions & 66 deletions

File tree

.bumpy/catalog-change-detection.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': minor
3+
---
4+
5+
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.

packages/bumpy/src/commands/add.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { relative, resolve } from 'node:path';
1+
import { resolve } from 'node:path';
22
import pc from 'picocolors';
33
import { log } from '../utils/logger.ts';
44
import { p, unwrap } from '../utils/clack.ts';
55
import { ensureDir, exists } from '../utils/fs.ts';
66
import { randomName, slugify } from '../utils/names.ts';
77
import { writeBumpFile, readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
8-
import picomatch from 'picomatch';
9-
import { getBumpyDir, loadConfig, loadPackageConfig } from '../core/config.ts';
10-
import { discoverPackages, discoverWorkspace } from '../core/workspace.ts';
8+
import { getBumpyDir, loadConfig } from '../core/config.ts';
9+
import { discoverWorkspace } from '../core/workspace.ts';
1110
import { findChangedPackages } from './check.ts';
1211
import { getChangedFiles } from '../core/git.ts';
1312
import { bumpSelectPrompt } from '../prompts/bump-select.ts';
@@ -78,35 +77,15 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
7877
// Interactive mode
7978
p.intro(pc.bgCyan(pc.black(' bumpy add ')));
8079

81-
const pkgs = await discoverPackages(rootDir, config);
80+
const { packages: pkgs } = await discoverWorkspace(rootDir, config);
8281
if (pkgs.size === 0) {
8382
p.cancel('No managed packages found in this workspace.');
8483
process.exit(1);
8584
}
8685

8786
// Detect which packages have changed on this branch
88-
const baseBranch = config.baseBranch;
89-
const changedFiles = getChangedFiles(rootDir, baseBranch);
90-
// Build per-package matchers (per-package patterns override root patterns)
91-
const matchers = new Map<string, picomatch.Matcher>();
92-
for (const [name, pkg] of pkgs) {
93-
const pkgConfig = await loadPackageConfig(pkg.dir, config, name);
94-
const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns;
95-
matchers.set(name, picomatch(patterns));
96-
}
97-
98-
const changedPackageNames = new Set<string>();
99-
for (const file of changedFiles) {
100-
for (const [name, pkg] of pkgs) {
101-
const pkgRelDir = relative(rootDir, pkg.dir);
102-
if (file.startsWith(pkgRelDir + '/')) {
103-
const relToPackage = file.slice(pkgRelDir.length + 1);
104-
if (matchers.get(name)!(relToPackage)) {
105-
changedPackageNames.add(name);
106-
}
107-
}
108-
}
109-
}
87+
const changedFiles = getChangedFiles(rootDir, config.baseBranch);
88+
const changedPackageNames = new Set(await findChangedPackages(changedFiles, pkgs, rootDir, config));
11089

11190
// Load existing bump files on this branch to avoid re-defaulting already-covered packages
11291
const { bumpFiles: allBumpFiles } = await readBumpFiles(rootDir);

packages/bumpy/src/commands/check.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
import { relative } from 'node:path';
1+
import { relative, resolve } from 'node:path';
22
import picomatch from 'picomatch';
33
import { log, colorize } from '../utils/logger.ts';
44
import { loadConfig, loadPackageConfig, getBumpyDir } from '../core/config.ts';
55
import { discoverWorkspace } from '../core/workspace.ts';
66
import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
7-
import { getChangedFiles, getFileStatuses } from '../core/git.ts';
7+
import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts';
8+
import {
9+
detectPackageManager,
10+
parseCatalogs,
11+
diffCatalogMaps,
12+
isCatalogRefAffected,
13+
CATALOG_FILES,
14+
} from '../utils/package-manager.ts';
15+
import { readText, exists } from '../utils/fs.ts';
16+
import { DEP_TYPES } from '../types.ts';
817
import type { BumpyConfig, WorkspacePackage } from '../types.ts';
918

1019
export type HookContext = 'pre-commit' | 'pre-push';
@@ -238,5 +247,60 @@ export async function findChangedPackages(
238247
}
239248
}
240249

250+
// Catalog change detection: if a catalog file changed, find packages whose
251+
// catalog: dep references resolve to a changed catalog entry
252+
const catalogChanges = await getChangedCatalogEntries(rootDir, config.baseBranch, changedFiles);
253+
if (catalogChanges.size > 0) {
254+
for (const [name, pkg] of packages) {
255+
if (changed.has(name)) continue;
256+
for (const depType of DEP_TYPES) {
257+
const deps = pkg[depType];
258+
let matched = false;
259+
for (const [depName, range] of Object.entries(deps)) {
260+
if (isCatalogRefAffected(range, depName, catalogChanges)) {
261+
changed.add(name);
262+
matched = true;
263+
break;
264+
}
265+
}
266+
if (matched) break;
267+
}
268+
}
269+
}
270+
241271
return [...changed];
242272
}
273+
274+
/**
275+
* Compute which catalog entries changed between the base ref and HEAD.
276+
* Returns Map<catalogName, Set<depName>>. Empty if no catalog files changed.
277+
*/
278+
async function getChangedCatalogEntries(
279+
rootDir: string,
280+
baseBranch: string,
281+
changedFiles: string[],
282+
): Promise<Map<string, Set<string>>> {
283+
const catalogFileChanged = changedFiles.some((f) => (CATALOG_FILES as readonly string[]).includes(f));
284+
if (!catalogFileChanged) return new Map();
285+
286+
const baseRef = getBaseCompareRef(rootDir, baseBranch);
287+
288+
const pm = await detectPackageManager(rootDir);
289+
290+
// Load "after" (current working tree state)
291+
const afterYaml =
292+
pm === 'pnpm' && (await exists(resolve(rootDir, 'pnpm-workspace.yaml')))
293+
? await readText(resolve(rootDir, 'pnpm-workspace.yaml'))
294+
: null;
295+
const afterPkgJson = (await exists(resolve(rootDir, 'package.json')))
296+
? await readText(resolve(rootDir, 'package.json'))
297+
: null;
298+
const afterCatalogs = parseCatalogs(afterYaml, afterPkgJson);
299+
300+
// Load "before" (state at base ref). pnpm-workspace.yaml is only relevant for pnpm.
301+
const beforeYaml = pm === 'pnpm' ? readFileAtRef(rootDir, baseRef, 'pnpm-workspace.yaml') : null;
302+
const beforePkgJson = readFileAtRef(rootDir, baseRef, 'package.json');
303+
const beforeCatalogs = parseCatalogs(beforeYaml, beforePkgJson);
304+
305+
return diffCatalogMaps(beforeCatalogs, afterCatalogs);
306+
}

packages/bumpy/src/core/git.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,21 +113,34 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean {
113113
return tryRunArgs(['git', 'tag', '-l', tag], opts) === tag;
114114
}
115115

116-
/** Get files changed on this branch compared to a base branch */
117-
export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
118-
// Ensure we have the base branch ref (may need fetching in shallow CI clones)
116+
/**
117+
* Resolve the ref to use as the comparison base for "this branch vs main".
118+
* Prefers the merge-base, falls back to `origin/<baseBranch>`.
119+
*/
120+
export function getBaseCompareRef(rootDir: string, baseBranch: string): string {
119121
if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) {
120122
tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir });
121123
}
122-
123-
// Try merge-base for the most accurate comparison
124124
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
125-
const ref = mergeBase || `origin/${baseBranch}`;
125+
return mergeBase || `origin/${baseBranch}`;
126+
}
127+
128+
/** Get files changed on this branch compared to a base branch */
129+
export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
130+
const ref = getBaseCompareRef(rootDir, baseBranch);
126131
const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir });
127132
if (!diff) return [];
128133
return diff.split('\n').filter(Boolean);
129134
}
130135

136+
/**
137+
* Read a file's contents at a given git ref.
138+
* Returns null if the file does not exist at that ref.
139+
*/
140+
export function readFileAtRef(rootDir: string, ref: string, path: string): string | null {
141+
return tryRunArgs(['git', 'show', `${ref}:${path}`], { cwd: rootDir });
142+
}
143+
131144
/** Get commits on the current branch since it diverged from baseBranch */
132145
export function getBranchCommits(
133146
rootDir: string,

packages/bumpy/src/utils/package-manager.ts

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,19 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise<s
7272
return [];
7373
}
7474

75-
/** Load catalog definitions from pnpm-workspace.yaml or root package.json */
76-
async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<CatalogMap> {
75+
/**
76+
* Files that may contain catalog definitions, in the order they're applied.
77+
* Later entries override earlier ones (matching loadCatalogs behavior).
78+
*/
79+
export const CATALOG_FILES = ['pnpm-workspace.yaml', 'package.json'] as const;
80+
81+
/** Parse catalog definitions from the raw contents of pnpm-workspace.yaml and root package.json */
82+
export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: string | null): CatalogMap {
7783
const catalogs: CatalogMap = new Map();
7884

79-
if (pm === 'pnpm') {
80-
// pnpm: catalogs live in pnpm-workspace.yaml
81-
const wsFile = resolve(rootDir, 'pnpm-workspace.yaml');
82-
if (await exists(wsFile)) {
83-
const content = await readText(wsFile);
84-
const parsed = yaml.load(content) as {
85+
if (pnpmWorkspaceYaml) {
86+
try {
87+
const parsed = yaml.load(pnpmWorkspaceYaml) as {
8588
catalog?: Record<string, string>;
8689
catalogs?: Record<string, Record<string, string>>;
8790
} | null;
@@ -94,43 +97,66 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<Catalo
9497
catalogs.set(name, deps);
9598
}
9699
}
100+
} catch {
101+
// ignore malformed yaml
97102
}
98103
}
99104

100-
// bun/npm/yarn + pnpm fallback: catalogs in root package.json
101-
try {
102-
const pkg = await readJson<Record<string, unknown>>(resolve(rootDir, 'package.json'));
103-
104-
// Check top-level catalog/catalogs
105-
if (pkg.catalog && typeof pkg.catalog === 'object') {
106-
catalogs.set('', pkg.catalog as Record<string, string>);
107-
}
108-
if (pkg.catalogs && typeof pkg.catalogs === 'object') {
109-
for (const [name, deps] of Object.entries(pkg.catalogs as Record<string, Record<string, string>>)) {
110-
catalogs.set(name, deps);
111-
}
112-
}
105+
if (rootPackageJson) {
106+
try {
107+
const pkg = JSON.parse(rootPackageJson) as Record<string, unknown>;
113108

114-
// Also check inside workspaces object (bun style)
115-
const workspaces = pkg.workspaces;
116-
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
117-
const ws = workspaces as Record<string, unknown>;
118-
if (ws.catalog && typeof ws.catalog === 'object') {
119-
catalogs.set('', ws.catalog as Record<string, string>);
109+
// Top-level catalog/catalogs (used by bun, yarn, and proposed npm)
110+
if (pkg.catalog && typeof pkg.catalog === 'object') {
111+
catalogs.set('', pkg.catalog as Record<string, string>);
120112
}
121-
if (ws.catalogs && typeof ws.catalogs === 'object') {
122-
for (const [name, deps] of Object.entries(ws.catalogs as Record<string, Record<string, string>>)) {
113+
if (pkg.catalogs && typeof pkg.catalogs === 'object') {
114+
for (const [name, deps] of Object.entries(pkg.catalogs as Record<string, Record<string, string>>)) {
123115
catalogs.set(name, deps);
124116
}
125117
}
118+
119+
// Inside workspaces object (bun style)
120+
const workspaces = pkg.workspaces;
121+
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
122+
const ws = workspaces as Record<string, unknown>;
123+
if (ws.catalog && typeof ws.catalog === 'object') {
124+
catalogs.set('', ws.catalog as Record<string, string>);
125+
}
126+
if (ws.catalogs && typeof ws.catalogs === 'object') {
127+
for (const [name, deps] of Object.entries(ws.catalogs as Record<string, Record<string, string>>)) {
128+
catalogs.set(name, deps);
129+
}
130+
}
131+
}
132+
} catch {
133+
// ignore malformed json
126134
}
127-
} catch {
128-
// ignore
129135
}
130136

131137
return catalogs;
132138
}
133139

140+
/** Load catalog definitions from pnpm-workspace.yaml or root package.json */
141+
async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<CatalogMap> {
142+
// pnpm-workspace.yaml is only read for pnpm — other PMs don't recognize it
143+
let pnpmYaml: string | null = null;
144+
if (pm === 'pnpm') {
145+
const wsFile = resolve(rootDir, 'pnpm-workspace.yaml');
146+
if (await exists(wsFile)) {
147+
pnpmYaml = await readText(wsFile);
148+
}
149+
}
150+
151+
let pkgJsonText: string | null = null;
152+
const pkgJsonPath = resolve(rootDir, 'package.json');
153+
if (await exists(pkgJsonPath)) {
154+
pkgJsonText = await readText(pkgJsonPath);
155+
}
156+
157+
return parseCatalogs(pnpmYaml, pkgJsonText);
158+
}
159+
134160
/** Resolve a specific dependency's catalog: reference */
135161
export function resolveCatalogDep(depName: string, range: string, catalogs: CatalogMap): string | null {
136162
if (!range.startsWith('catalog:')) return null;
@@ -139,3 +165,44 @@ export function resolveCatalogDep(depName: string, range: string, catalogs: Cata
139165
if (!catalog) return null;
140166
return catalog[depName] ?? null;
141167
}
168+
169+
/**
170+
* Diff two catalog states and return the set of (catalogName → changed depNames).
171+
* Includes added, removed, and version-changed entries.
172+
*/
173+
export function diffCatalogMaps(before: CatalogMap, after: CatalogMap): Map<string, Set<string>> {
174+
const changes = new Map<string, Set<string>>();
175+
const catalogNames = new Set([...before.keys(), ...after.keys()]);
176+
177+
for (const catalogName of catalogNames) {
178+
const beforeDeps = before.get(catalogName) ?? {};
179+
const afterDeps = after.get(catalogName) ?? {};
180+
const depNames = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]);
181+
const changedDeps = new Set<string>();
182+
for (const depName of depNames) {
183+
if (beforeDeps[depName] !== afterDeps[depName]) {
184+
changedDeps.add(depName);
185+
}
186+
}
187+
if (changedDeps.size > 0) {
188+
changes.set(catalogName, changedDeps);
189+
}
190+
}
191+
192+
return changes;
193+
}
194+
195+
/**
196+
* Given a set of catalog entries that have changed, return the set of catalog
197+
* references (e.g. "catalog:" or "catalog:testing") that affect those entries.
198+
* Used to match package.json dep ranges against changed catalog entries.
199+
*/
200+
export function isCatalogRefAffected(
201+
range: string,
202+
depName: string,
203+
catalogChanges: Map<string, Set<string>>,
204+
): boolean {
205+
if (!range.startsWith('catalog:')) return false;
206+
const catalogName = range.slice('catalog:'.length).trim() || '';
207+
return catalogChanges.get(catalogName)?.has(depName) ?? false;
208+
}

0 commit comments

Comments
 (0)