Skip to content

Commit d2d73c4

Browse files
theoephraimclaude
andcommitted
Generate bump files from all branch commits, not just conventional commits
The generate command now scans all commits on the current branch (vs baseBranch) and uses file-path detection to map non-CC commits to packages with a patch bump. CC commits still get enhanced bump-level inference from type/scope. Also adds guidance to the AI skill for writing concise changelog summaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4210a25 commit d2d73c4

5 files changed

Lines changed: 152 additions & 59 deletions

File tree

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+
Generate command now detects bumps from all commits, not just conventional commits.

packages/bumpy/skills/add-change/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,16 @@ Use `none` in a bump file to suppress a bump on a package that would otherwise b
4747

4848
### 4. Write a clear summary
4949

50-
Write a concise summary (1-3 sentences) describing **what** changed and **why**. This becomes the CHANGELOG entry. Good summaries:
50+
Write a concise summary for the CHANGELOG entry. Keep it short — ideally a single sentence, at most two. Good summaries:
5151

5252
- Start with a verb: "Added...", "Fixed...", "Refactored..."
5353
- Focus on user-facing impact, not implementation details
5454
- Are specific enough to be useful months later
55+
- Avoid filler, jargon, or restating the bump level
56+
- Don't list every file changed — describe the logical change
57+
58+
Bad: "Updated the authentication module to fix an issue where the token refresh mechanism was not properly handling expired refresh tokens, causing silent failures in the auth flow."
59+
Good: "Fixed token refresh failing silently on expired refresh tokens."
5560

5661
### 5. Create the bump file
5762

packages/bumpy/src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ function printHelp() {
187187
Commands:
188188
init Initialize .bumpy/ directory
189189
add Create a new bump file
190-
generate Generate bump file from conventional commits
190+
generate Generate bump file from branch commits
191191
status Show pending releases
192192
check Verify changed packages have bump files (for pre-push hooks)
193193
version Apply bump files and bump versions
@@ -205,7 +205,7 @@ function printHelp() {
205205
--empty Create an empty bump file
206206
207207
Generate options:
208-
--from <ref> Git ref to scan from (default: last version tag)
208+
--from <ref> Git ref to scan from (default: branch point from baseBranch)
209209
--dry-run Preview without creating a bump file
210210
--name <name> Bump file filename
211211

packages/bumpy/src/commands/generate.ts

Lines changed: 102 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { relative } from 'node:path';
12
import { log, colorize } from '../utils/logger.ts';
23
import { tryRunArgs } from '../utils/shell.ts';
34
import { loadConfig } from '../core/config.ts';
@@ -6,10 +7,11 @@ import { writeBumpFile } from '../core/bump-file.ts';
67
import { getBumpyDir } from '../core/config.ts';
78
import { ensureDir } from '../utils/fs.ts';
89
import { slugify, randomName } from '../utils/names.ts';
10+
import { getBranchCommits, getFilesChangedInCommit } from '../core/git.ts';
911
import type { BumpType, BumpTypeWithNone, BumpyConfig, BumpFileRelease, WorkspacePackage } from '../types.ts';
1012

1113
interface GenerateOptions {
12-
from?: string; // git ref to start from (default: auto-detect last version tag)
14+
from?: string; // git ref to start from (default: branch base)
1315
dryRun?: boolean;
1416
name?: string;
1517
}
@@ -40,74 +42,95 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
4042
const config = await loadConfig(rootDir);
4143
const packages = await discoverPackages(rootDir, config);
4244

43-
// Determine the starting ref
44-
const from = opts.from || findLastVersionTag(rootDir);
45-
if (!from) {
46-
log.error('Could not detect last version tag. Use --from <ref> to specify.');
47-
process.exit(1);
48-
}
49-
50-
log.step(`Scanning commits from ${colorize(from, 'cyan')}...`);
45+
// Get commits — either from explicit ref or from branch divergence point
46+
let commits: { hash: string; subject: string; body: string }[];
5147

52-
// Get commits since ref
53-
const rawLog = tryRunArgs(['git', 'log', `${from}..HEAD`, '--format=%H%n%s%n%b%n---END---'], { cwd: rootDir });
54-
55-
if (!rawLog) {
56-
log.info('No commits found since ' + from);
57-
return;
48+
if (opts.from) {
49+
log.step(`Scanning commits from ${colorize(opts.from, 'cyan')}...`);
50+
const rawLog = tryRunArgs(['git', 'log', `${opts.from}..HEAD`, '--format=%H%n%s%n%b%n---END---'], { cwd: rootDir });
51+
if (!rawLog) {
52+
log.info('No commits found since ' + opts.from);
53+
return;
54+
}
55+
commits = parseGitLog(rawLog);
56+
} else {
57+
log.step(`Scanning commits on this branch (vs ${colorize(config.baseBranch, 'cyan')})...`);
58+
commits = getBranchCommits(rootDir, config.baseBranch);
5859
}
5960

60-
const commits = parseGitLog(rawLog);
61-
const conventional = commits.map(parseConventionalCommit).filter((c): c is ConventionalCommit => c !== null);
62-
63-
if (conventional.length === 0) {
64-
log.info('No conventional commits found. Commits must follow the format: type(scope): description');
61+
if (commits.length === 0) {
62+
log.info('No commits found on this branch.');
6563
return;
6664
}
6765

68-
log.dim(` Found ${conventional.length} conventional commit(s)`);
66+
log.dim(` Found ${commits.length} commit(s)`);
6967

70-
// Build scope → package name mapping
68+
// Build scope → package name mapping for CC resolution
7169
const scopeMap = buildScopeMap(packages, config);
7270

73-
// Collect releases
71+
// Collect releases from all commits
7472
const releaseMap = new Map<string, { type: BumpType; messages: string[] }>();
7573

76-
for (const commit of conventional) {
77-
const bump: BumpType = commit.breaking ? 'major' : BUMP_MAP[commit.type] || 'patch';
74+
let ccCount = 0;
75+
let fileBasedCount = 0;
7876

79-
// Resolve scope to package name
80-
let pkgNames: string[] = [];
81-
if (commit.scope) {
82-
const resolved = resolveScope(commit.scope, scopeMap, packages);
83-
if (resolved.length > 0) {
84-
pkgNames = resolved;
85-
} else {
86-
log.dim(` Skipping: unknown scope "${commit.scope}" in: ${commit.description}`);
77+
for (const commit of commits) {
78+
const cc = parseConventionalCommit(commit);
79+
80+
if (cc) {
81+
// Conventional commit — use type/scope for bump level
82+
ccCount++;
83+
const bump: BumpType = cc.breaking ? 'major' : BUMP_MAP[cc.type] || 'patch';
84+
85+
let pkgNames: string[] = [];
86+
if (cc.scope) {
87+
const resolved = resolveScope(cc.scope, scopeMap, packages);
88+
if (resolved.length > 0) {
89+
pkgNames = resolved;
90+
}
91+
// If scope didn't resolve, fall through to file-based detection below
92+
}
93+
94+
if (pkgNames.length > 0) {
95+
for (const name of pkgNames) {
96+
mergeRelease(releaseMap, name, bump, cc.description);
97+
}
8798
continue;
8899
}
89-
} else {
90-
// No scope — skip (we're doing scope-based only for now)
91-
log.dim(` Skipping (no scope): ${commit.type}: ${commit.description}`);
92-
continue;
93-
}
94100

95-
for (const name of pkgNames) {
96-
const existing = releaseMap.get(name);
97-
if (existing) {
98-
// Upgrade bump if higher
99-
if (bumpPriority(bump) > bumpPriority(existing.type)) {
100-
existing.type = bump;
101+
// CC commit but scope didn't resolve (or no scope) — use file-based detection
102+
// with the CC-derived bump level
103+
const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir });
104+
const touchedPkgs = mapFilesToPackages(files, packages, rootDir);
105+
106+
if (touchedPkgs.length > 0) {
107+
for (const name of touchedPkgs) {
108+
mergeRelease(releaseMap, name, bump, cc.description);
101109
}
102-
existing.messages.push(commit.description);
103110
} else {
104-
releaseMap.set(name, { type: bump, messages: [commit.description] });
111+
log.dim(` Skipping CC (no matching packages): ${cc.type}: ${cc.description}`);
112+
}
113+
} else {
114+
// Non-conventional commit — use file paths to detect packages, default to patch
115+
const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir });
116+
const touchedPkgs = mapFilesToPackages(files, packages, rootDir);
117+
118+
if (touchedPkgs.length > 0) {
119+
fileBasedCount++;
120+
for (const name of touchedPkgs) {
121+
mergeRelease(releaseMap, name, 'patch', commit.subject);
122+
}
123+
} else {
124+
log.dim(` Skipping (no matching packages): ${commit.subject}`);
105125
}
106126
}
107127
}
108128

129+
if (ccCount > 0) log.dim(` ${ccCount} conventional commit(s)`);
130+
if (fileBasedCount > 0) log.dim(` ${fileBasedCount} commit(s) detected via changed files`);
131+
109132
if (releaseMap.size === 0) {
110-
log.info('No package bumps detected from conventional commits.');
133+
log.info('No package bumps detected from commits.');
111134
return;
112135
}
113136

@@ -149,6 +172,38 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
149172
}
150173
}
151174

175+
/** Merge a bump into the release map, keeping the highest bump level */
176+
function mergeRelease(
177+
releaseMap: Map<string, { type: BumpType; messages: string[] }>,
178+
name: string,
179+
bump: BumpType,
180+
message: string,
181+
): void {
182+
const existing = releaseMap.get(name);
183+
if (existing) {
184+
if (bumpPriority(bump) > bumpPriority(existing.type)) {
185+
existing.type = bump;
186+
}
187+
existing.messages.push(message);
188+
} else {
189+
releaseMap.set(name, { type: bump, messages: [message] });
190+
}
191+
}
192+
193+
/** Map file paths to package names based on directory containment */
194+
function mapFilesToPackages(files: string[], packages: Map<string, WorkspacePackage>, rootDir: string): string[] {
195+
const matched = new Set<string>();
196+
for (const file of files) {
197+
for (const [name, pkg] of packages) {
198+
const pkgRelDir = relative(rootDir, pkg.dir);
199+
if (file.startsWith(pkgRelDir + '/')) {
200+
matched.add(name);
201+
}
202+
}
203+
}
204+
return [...matched];
205+
}
206+
152207
/** Parse raw git log output into individual commits */
153208
function parseGitLog(raw: string): { hash: string; subject: string; body: string }[] {
154209
const commits: { hash: string; subject: string; body: string }[] = [];
@@ -235,12 +290,3 @@ function resolveScope(
235290
function bumpPriority(type: BumpType): number {
236291
return type === 'major' ? 2 : type === 'minor' ? 1 : 0;
237292
}
238-
239-
/** Find the most recent version tag in the repo */
240-
function findLastVersionTag(rootDir: string): string | null {
241-
// Look for tags matching common patterns: v1.2.3, pkg@1.2.3, etc.
242-
const tag =
243-
tryRunArgs(['git', 'describe', '--tags', '--abbrev=0', '--match', 'v*'], { cwd: rootDir }) ||
244-
tryRunArgs(['git', 'describe', '--tags', '--abbrev=0', '--match', '*@*'], { cwd: rootDir });
245-
return tag || null;
246-
}

packages/bumpy/src/core/git.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@ export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
5454
return diff.split('\n').filter(Boolean);
5555
}
5656

57+
/** Get commits on the current branch since it diverged from baseBranch */
58+
export function getBranchCommits(
59+
rootDir: string,
60+
baseBranch: string,
61+
): { hash: string; subject: string; body: string }[] {
62+
// Ensure we have the base branch ref
63+
if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) {
64+
tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir });
65+
}
66+
67+
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
68+
const ref = mergeBase || `origin/${baseBranch}`;
69+
70+
const rawLog = tryRunArgs(['git', 'log', `${ref}..HEAD`, '--format=%H%n%s%n%b%n---END---'], { cwd: rootDir });
71+
if (!rawLog) return [];
72+
73+
const commits: { hash: string; subject: string; body: string }[] = [];
74+
const entries = rawLog.split('---END---').filter((e) => e.trim());
75+
for (const entry of entries) {
76+
const lines = entry.trim().split('\n');
77+
if (lines.length < 2) continue;
78+
commits.push({
79+
hash: lines[0]!.trim(),
80+
subject: lines[1]!.trim(),
81+
body: lines.slice(2).join('\n').trim(),
82+
});
83+
}
84+
return commits;
85+
}
86+
87+
/** Get files changed in a specific commit */
88+
export function getFilesChangedInCommit(hash: string, opts?: { cwd?: string }): string[] {
89+
const result = tryRunArgs(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', hash], opts);
90+
if (!result) return [];
91+
return result.split('\n').filter(Boolean);
92+
}
93+
5794
/** Get all tags matching a pattern */
5895
export function listTags(pattern: string, opts?: { cwd?: string }): string[] {
5996
const result = tryRunArgs(['git', 'tag', '-l', pattern], opts);

0 commit comments

Comments
 (0)