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

Fix changed-package detection in single-package (non-monorepo) repos. Both `findChangedPackages` (used by `check`/`ci check`) and `mapFilesToPackages` (used by `generate`) matched changed files against `pkgRelDir + '/'`, but for the root package the relative dir is empty, so the check became `file.startsWith('/')` — always false for git's relative paths. As a result `ci check` always reported "No managed packages have changed" (never requiring a bump file or posting a PR comment) and `generate` never attributed commits to the root package. The root package (empty relative dir) now treats every changed file as belonging to it, while still honoring `changedFilePatterns`.
16 changes: 12 additions & 4 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,22 @@ export async function findChangedPackages(

for (const [name, pkg] of packages) {
const pkgRelDir = relative(rootDir, pkg.dir);
if (!pkgRelDir) continue; // root-level package: not flagged by file globs (unchanged behavior)
// Root package (single-package repo) has an empty relative dir — every changed
// file belongs to it and is matched directly. Other packages only own files
// under their own directory.
const isRoot = pkgRelDir === '';
const prefix = `${pkgRelDir}/`;
const matcher = matchers.get(name)!;

let pkgJsonOnlyTrigger = false;
for (const file of changedFiles) {
if (!file.startsWith(prefix)) continue;
const relToPackage = file.slice(prefix.length);
let relToPackage: string;
if (isRoot) {
relToPackage = file;
} else {
if (!file.startsWith(prefix)) continue;
relToPackage = file.slice(prefix.length);
}
if (!matcher(relToPackage)) continue;
if (relToPackage === 'package.json') {
pkgJsonOnlyTrigger = true; // defer — refine by inspecting which fields changed
Expand Down Expand Up @@ -328,7 +336,7 @@ async function packageJsonAffectsRelease(
ignoredFields: string[],
releaseTriggeringDevDeps?: string[],
): Promise<boolean> {
const relPath = `${pkgRelDir}/package.json`;
const relPath = pkgRelDir ? `${pkgRelDir}/package.json` : 'package.json';
const beforeRaw = readFileAtRef(rootDir, baseRef, relPath);
if (beforeRaw == null) return true; // not present at base (new package) → release-affecting

Expand Down
10 changes: 8 additions & 2 deletions packages/bumpy/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,18 @@ function mergeRelease(
}

/** Map file paths to package names based on directory containment */
function mapFilesToPackages(files: string[], packages: Map<string, WorkspacePackage>, rootDir: string): string[] {
export function mapFilesToPackages(
files: string[],
packages: Map<string, WorkspacePackage>,
rootDir: string,
): string[] {
const matched = new Set<string>();
for (const file of files) {
for (const [name, pkg] of packages) {
const pkgRelDir = relative(rootDir, pkg.dir);
if (file.startsWith(pkgRelDir + '/')) {
// Root package (single-package repo) has an empty relative dir, so every
// file belongs to it. Otherwise the file must live under the package dir.
if (pkgRelDir === '' || file.startsWith(pkgRelDir + '/')) {
matched.add(name);
}
}
Expand Down
37 changes: 36 additions & 1 deletion packages/bumpy/test/core/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
import { resolve } from 'node:path';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { extractBumpFileIdsFromChangedFiles, filterBranchBumpFiles } from '../../src/core/bump-file.ts';
import { makeBumpFile } from '../helpers.ts';
import { findChangedPackages } from '../../src/commands/check.ts';
import { makeBumpFile, makePkg, makeConfig } from '../helpers.ts';

describe('extractBumpFileIdsFromChangedFiles', () => {
test('extracts bump file IDs from changed files', () => {
Expand Down Expand Up @@ -98,3 +99,37 @@ describe('filterBranchBumpFiles', () => {
});
});
});

describe('findChangedPackages', () => {
const rootDir = '/repo';

test('detects the root package in a single-package repo', async () => {
// Single-package repo: the only package IS the root, so pkg.dir === rootDir
// and its relative dir is empty. Every changed file belongs to it.
const packages = new Map([['fledgling', makePkg('fledgling', '1.0.0', { dir: rootDir })]]);
const changed = ['src/cli.ts', 'package.json', 'src/wizard.ts'];

const result = await findChangedPackages(changed, packages, rootDir, makeConfig());
expect(result).toEqual(['fledgling']);
});

test('respects changedFilePatterns for the root package', async () => {
const packages = new Map([['fledgling', makePkg('fledgling', '1.0.0', { dir: rootDir })]]);
const config = makeConfig({ changedFilePatterns: ['src/**'] });

expect(await findChangedPackages(['src/cli.ts'], packages, rootDir, config)).toEqual(['fledgling']);
// A file outside the pattern shouldn't count as a change
expect(await findChangedPackages(['README.md'], packages, rootDir, config)).toEqual([]);
});

test('still scopes changes by directory in a monorepo', async () => {
const packages = new Map([
['pkg-a', makePkg('pkg-a', '1.0.0', { dir: `${rootDir}/packages/pkg-a` })],
['pkg-b', makePkg('pkg-b', '1.0.0', { dir: `${rootDir}/packages/pkg-b` })],
]);
const changed = ['packages/pkg-a/src/index.ts'];

const result = await findChangedPackages(changed, packages, rootDir, makeConfig());
expect(result).toEqual(['pkg-a']);
});
});
22 changes: 22 additions & 0 deletions packages/bumpy/test/core/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect, describe } from 'bun:test';
import { mapFilesToPackages } from '../../src/commands/generate.ts';
import { makePkg } from '../helpers.ts';

describe('mapFilesToPackages', () => {
const rootDir = '/repo';

test('attributes every file to the root package in a single-package repo', () => {
const packages = new Map([['fledgling', makePkg('fledgling', '1.0.0', { dir: rootDir })]]);
const result = mapFilesToPackages(['src/cli.ts', 'package.json'], packages, rootDir);
expect(result).toEqual(['fledgling']);
});

test('scopes files by directory in a monorepo', () => {
const packages = new Map([
['pkg-a', makePkg('pkg-a', '1.0.0', { dir: `${rootDir}/packages/pkg-a` })],
['pkg-b', makePkg('pkg-b', '1.0.0', { dir: `${rootDir}/packages/pkg-b` })],
]);
const result = mapFilesToPackages(['packages/pkg-b/src/index.ts'], packages, rootDir);
expect(result).toEqual(['pkg-b']);
});
});