1+ import { relative } from 'node:path' ;
12import { log , colorize } from '../utils/logger.ts' ;
23import { tryRunArgs } from '../utils/shell.ts' ;
34import { loadConfig } from '../core/config.ts' ;
@@ -6,10 +7,11 @@ import { writeBumpFile } from '../core/bump-file.ts';
67import { getBumpyDir } from '../core/config.ts' ;
78import { ensureDir } from '../utils/fs.ts' ;
89import { slugify , randomName } from '../utils/names.ts' ;
10+ import { getBranchCommits , getFilesChangedInCommit } from '../core/git.ts' ;
911import type { BumpType , BumpTypeWithNone , BumpyConfig , BumpFileRelease , WorkspacePackage } from '../types.ts' ;
1012
1113interface 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 */
153208function parseGitLog ( raw : string ) : { hash : string ; subject : string ; body : string } [ ] {
154209 const commits : { hash : string ; subject : string ; body : string } [ ] = [ ] ;
@@ -235,12 +290,3 @@ function resolveScope(
235290function 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- }
0 commit comments