Skip to content

Commit 7d044e9

Browse files
authoredJan 25, 2025
feat: inferring the semver version according to Conventional Commit (#71)
1 parent db6e8dd commit 7d044e9

10 files changed

+109
-345
lines changed
 

‎eslint.config.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
const antfu = require('@antfu/eslint-config').default
1+
import { antfu } from '@antfu/eslint-config'
22

3-
module.exports = antfu({
3+
export default antfu({
44
rules: {
55
'no-console': 'off',
66
'no-restricted-syntax': 'off',

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "bumpp",
3+
"type": "module",
34
"version": "9.10.2",
45
"packageManager": "pnpm@9.15.4",
56
"description": "Bump version, commit changes, tag, and push to Git",
@@ -68,6 +69,7 @@
6869
"package-manager-detector": "^0.2.8",
6970
"prompts": "^2.4.2",
7071
"semver": "^7.6.3",
72+
"tiny-conventional-commits-parser": "^0.0.1",
7173
"tinyexec": "^0.3.2",
7274
"tinyglobby": "^0.2.10"
7375
},

‎pnpm-lock.yaml

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/get-new-version.ts

+35-16
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1+
import type { GitCommit } from 'tiny-conventional-commits-parser'
12
import type { BumpRelease, PromptRelease } from './normalize-options'
23
import type { Operation } from './operation'
34
import type { ReleaseType } from './release-type'
45
import process from 'node:process'
56
import c from 'picocolors'
67
import prompts from 'prompts'
78
import semver, { clean as cleanVersion, valid as isValidVersion, SemVer } from 'semver'
8-
import { printRecentCommits } from './print-commits'
99
import { isPrerelease, releaseTypes } from './release-type'
1010

1111
/**
1212
* Determines the new version number, possibly by prompting the user for it.
1313
*/
14-
export async function getNewVersion(operation: Operation): Promise<Operation> {
14+
export async function getNewVersion(operation: Operation, commits: GitCommit[]): Promise<Operation> {
1515
const { release } = operation.options
1616
const { currentVersion } = operation.state
1717

1818
switch (release.type) {
1919
case 'prompt':
20-
return promptForNewVersion(operation)
20+
return promptForNewVersion(operation, commits)
2121

2222
case 'version':
2323
return operation.update({
@@ -27,20 +27,27 @@ export async function getNewVersion(operation: Operation): Promise<Operation> {
2727
default:
2828
return operation.update({
2929
release: release.type,
30-
newVersion: getNextVersion(currentVersion, release),
30+
newVersion: getNextVersion(currentVersion, release, commits),
3131
})
3232
}
3333
}
3434

3535
/**
3636
* Returns the next version number of the specified type.
3737
*/
38-
function getNextVersion(currentVersion: string, bump: BumpRelease): string {
38+
function getNextVersion(currentVersion: string, bump: BumpRelease, commits: GitCommit[]): string {
3939
const oldSemVer = new SemVer(currentVersion)
4040

41-
const type = bump.type === 'next'
42-
? oldSemVer.prerelease.length ? 'prerelease' : 'patch'
43-
: bump.type
41+
let type: ReleaseType
42+
if (bump.type === 'next') {
43+
type = oldSemVer.prerelease.length ? 'prerelease' : 'patch'
44+
}
45+
else if (bump.type === 'conventional') {
46+
type = oldSemVer.prerelease.length ? 'prerelease' : determineSemverChange(commits)
47+
}
48+
else {
49+
type = bump.type
50+
}
4451

4552
const newSemVer = oldSemVer.inc(type, bump.preid)
4653

@@ -61,18 +68,32 @@ function getNextVersion(currentVersion: string, bump: BumpRelease): string {
6168
return newSemVer.version
6269
}
6370

71+
function determineSemverChange(commits: GitCommit[]) {
72+
let [hasMajor, hasMinor] = [false, false]
73+
for (const commit of commits) {
74+
if (commit.isBreaking) {
75+
hasMajor = true
76+
}
77+
else if (commit.type === 'feat') {
78+
hasMinor = true
79+
}
80+
}
81+
82+
return hasMajor ? 'major' : hasMinor ? 'minor' : 'patch'
83+
}
84+
6485
/**
6586
* Returns the next version number for all release types.
6687
*/
67-
function getNextVersions(currentVersion: string, preid: string): Record<ReleaseType, string> {
88+
function getNextVersions(currentVersion: string, preid: string, commits: GitCommit[]): Record<ReleaseType, string> {
6889
const next: Record<string, string> = {}
6990

7091
const parse = semver.parse(currentVersion)
7192
if (typeof parse?.prerelease[0] === 'string')
7293
preid = parse?.prerelease[0] || 'preid'
7394

7495
for (const type of releaseTypes)
75-
next[type] = getNextVersion(currentVersion, { type, preid })
96+
next[type] = getNextVersion(currentVersion, { type, preid }, commits)
7697

7798
return next
7899
}
@@ -82,17 +103,13 @@ function getNextVersions(currentVersion: string, preid: string): Record<ReleaseT
82103
*
83104
* @returns - A tuple containing the new version number and the release type (if any)
84105
*/
85-
async function promptForNewVersion(operation: Operation): Promise<Operation> {
106+
async function promptForNewVersion(operation: Operation, commits: GitCommit[]): Promise<Operation> {
86107
const { currentVersion } = operation.state
87108
const release = operation.options.release as PromptRelease
88109

89-
const next = getNextVersions(currentVersion, release.preid)
110+
const next = getNextVersions(currentVersion, release.preid, commits)
90111
const configCustomVersion = await operation.options.customVersion?.(currentVersion, semver)
91112

92-
if (operation.options.printCommits) {
93-
await printRecentCommits(operation)
94-
}
95-
96113
const PADDING = 13
97114
const answers = await prompts([
98115
{
@@ -105,6 +122,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
105122
{ value: 'minor', title: `${'minor'.padStart(PADDING, ' ')} ${c.bold(next.minor)}` },
106123
{ value: 'patch', title: `${'patch'.padStart(PADDING, ' ')} ${c.bold(next.patch)}` },
107124
{ value: 'next', title: `${'next'.padStart(PADDING, ' ')} ${c.bold(next.next)}` },
125+
{ value: 'conventional', title: `${'conventional'.padStart(PADDING, ' ')} ${c.bold(next.conventional)}` },
108126
...configCustomVersion
109127
? [
110128
{ value: 'config', title: `${'from config'.padStart(PADDING, ' ')} ${c.bold(configCustomVersion)}` },
@@ -146,6 +164,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
146164
case 'custom':
147165
case 'config':
148166
case 'next':
167+
case 'conventional':
149168
case 'none':
150169
return operation.update({ newVersion })
151170

‎src/print-commits.ts

+22-110
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { Operation } from './operation'
1+
import type { GitCommit } from 'tiny-conventional-commits-parser'
22
import c from 'picocolors'
3-
import { x } from 'tinyexec'
43

54
const messageColorMap: Record<string, (c: string) => string> = {
65
feat: c.green,
@@ -29,135 +28,48 @@ const messageColorMap: Record<string, (c: string) => string> = {
2928
breaking: c.red,
3029
}
3130

32-
interface ParsedCommit {
33-
hash: string
34-
message: string
35-
tag: string
36-
breaking?: boolean
37-
scope: string
38-
color: (c: string) => string
39-
}
40-
41-
export function parseCommits(raw: string) {
42-
const lines = raw
43-
.toString()
44-
.trim()
45-
.split(/\n/g)
46-
47-
if (!lines.length) {
48-
return []
49-
}
31+
export function formatParsedCommits(commits: GitCommit[]) {
32+
const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0)
33+
const scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0)
5034

51-
return lines
52-
.map((line): ParsedCommit => {
53-
const [hash, ...parts] = line.split(' ')
54-
const message = parts.join(' ')
55-
const match = message.match(/^(\w+)(!)?(\([^)]+\))?(!)?:(.*)$/)
56-
if (match) {
57-
let color = messageColorMap[match[1].toLowerCase()] || ((c: string) => c)
58-
const breaking = match[2] === '!' || match[4] === '!'
59-
if (breaking) {
60-
color = s => c.inverse(c.red(s))
61-
}
62-
const tag = [match[1], match[2], match[4]].filter(Boolean).join('')
63-
const scope = match[3] || ''
64-
return {
65-
hash,
66-
tag,
67-
message: match[5].trim(),
68-
scope,
69-
breaking,
70-
color,
71-
}
72-
}
73-
return {
74-
hash,
75-
tag: '',
76-
message,
77-
scope: '',
78-
color: c => c,
79-
}
80-
})
81-
.reverse()
82-
}
83-
84-
export function formatParsedCommits(commits: ParsedCommit[]) {
85-
const tagLength = commits.map(({ tag }) => tag.length).reduce((a, b) => Math.max(a, b), 0)
86-
let scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0)
87-
if (scopeLength)
88-
scopeLength += 2
35+
return commits.map((commit) => {
36+
let color = messageColorMap[commit.type] || ((c: string) => c)
37+
if (commit.isBreaking) {
38+
color = s => c.inverse(c.red(s))
39+
}
8940

90-
return commits.map(({ hash, tag, message, scope, color }) => {
91-
const paddedTag = tag.padStart(tagLength + 1, ' ')
92-
const paddedScope = !scope
93-
? ' '.repeat(scopeLength)
94-
: c.dim('(') + scope.slice(1, -1) + c.dim(')') + ' '.repeat(scopeLength - scope.length)
41+
const paddedType = commit.type.padStart(typeLength + 1, ' ')
42+
const paddedScope = !commit.scope
43+
? ' '.repeat(scopeLength ? scopeLength + 2 : 0)
44+
: c.dim('(') + commit.scope + c.dim(')') + ' '.repeat(scopeLength - commit.scope.length)
9545

9646
return [
97-
c.dim(hash),
47+
c.dim(commit.shortHash),
9848
' ',
99-
color === c.gray ? color(paddedTag) : c.bold(color(paddedTag)),
49+
color === c.gray ? color(paddedType) : c.bold(color(paddedType)),
10050
' ',
10151
paddedScope,
10252
c.dim(':'),
10353
' ',
104-
color === c.gray ? color(message) : message,
54+
color === c.gray ? color(commit.description) : commit.description,
10555
].join('')
10656
})
10757
}
10858

109-
export async function printRecentCommits(operation: Operation): Promise<void> {
110-
let sha: string | undefined
111-
sha ||= await x(
112-
'git',
113-
['rev-list', '-n', '1', `v${operation.state.currentVersion}`],
114-
{ nodeOptions: { stdio: 'pipe' }, throwOnError: false },
115-
)
116-
.then(res => res.stdout.trim())
117-
sha ||= await x(
118-
'git',
119-
['rev-list', '-n', '1', operation.state.currentVersion],
120-
{ nodeOptions: { stdio: 'pipe' }, throwOnError: false },
121-
)
122-
.then(res => res.stdout.trim())
123-
124-
if (!sha) {
125-
console.log(
126-
c.blue(`i`)
127-
+ c.gray(` Failed to locate the previous tag ${c.yellow(`v${operation.state.currentVersion}`)}`),
128-
)
129-
return
130-
}
131-
132-
const { stdout } = await x(
133-
'git',
134-
[
135-
'--no-pager',
136-
'log',
137-
`${sha}..HEAD`,
138-
'--oneline',
139-
],
140-
{
141-
nodeOptions: {
142-
stdio: 'pipe',
143-
},
144-
},
145-
)
146-
147-
const parsed = parseCommits(stdout.toString().trim())
148-
const prettified = formatParsedCommits(parsed)
149-
150-
if (!parsed.length) {
59+
export function printRecentCommits(commits: GitCommit[]): void {
60+
if (!commits.length) {
15161
console.log()
152-
console.log(c.blue(`i`) + c.gray(` No commits since ${operation.state.currentVersion}`))
62+
console.log(c.blue(`i`) + c.gray(` No commits since the last version`))
15363
console.log()
15464
return
15565
}
15666

67+
const prettified = formatParsedCommits(commits)
68+
15769
console.log()
15870
console.log(
15971
c.bold(
160-
`${c.green(parsed.length)} Commits since ${c.gray(sha.slice(0, 7))}:`,
72+
`${c.green(commits.length)} Commits since the last version:`,
16173
),
16274
)
16375
console.log()

‎src/release-type.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReleaseType as SemverReleaseType } from 'semver'
22

3-
export type ReleaseType = SemverReleaseType | 'next'
3+
export type ReleaseType = SemverReleaseType | 'next' | 'conventional'
44

55
/**
66
* The different types of pre-releases.
@@ -10,7 +10,7 @@ export const prereleaseTypes: ReleaseType[] = ['premajor', 'preminor', 'prepatch
1010
/**
1111
* All possible release types.
1212
*/
13-
export const releaseTypes: ReleaseType[] = prereleaseTypes.concat(['major', 'minor', 'patch', 'next'])
13+
export const releaseTypes: ReleaseType[] = prereleaseTypes.concat(['major', 'minor', 'patch', 'next', 'conventional'])
1414

1515
/**
1616
* Determines whether the specified value is a pre-release.

‎src/version-bump.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { tokenizeArgs } from 'args-tokenizer'
55
import symbols from 'log-symbols'
66
import c from 'picocolors'
77
import prompts from 'prompts'
8+
import { getRecentCommits } from 'tiny-conventional-commits-parser'
89
import { x } from 'tinyexec'
910
import { getCurrentVersion } from './get-current-version'
1011
import { getNewVersion } from './get-new-version'
1112
import { formatVersionString, gitCommit, gitPush, gitTag } from './git'
1213
import { Operation } from './operation'
14+
import { printRecentCommits } from './print-commits'
1315
import { runNpmScript } from './run-npm-script'
1416
import { NpmScript } from './types/version-bump-progress'
1517
import { updateFiles } from './update-files'
@@ -49,9 +51,14 @@ export async function versionBump(arg: (VersionBumpOptions) | string = {}): Prom
4951

5052
const operation = await Operation.start(arg)
5153

54+
const commits = getRecentCommits()
55+
if (operation.options.printCommits) {
56+
printRecentCommits(commits)
57+
}
58+
5259
// Get the old and new version numbers
5360
await getCurrentVersion(operation)
54-
await getNewVersion(operation)
61+
await getNewVersion(operation, commits)
5562

5663
if (arg.confirm) {
5764
printSummary(operation)
@@ -156,9 +163,10 @@ export async function versionBumpInfo(arg: VersionBumpOptions | string = {}): Pr
156163
arg = { release: arg }
157164

158165
const operation = await Operation.start(arg)
166+
const commits = getRecentCommits()
159167

160168
// Get the old and new version numbers
161169
await getCurrentVersion(operation)
162-
await getNewVersion(operation)
170+
await getNewVersion(operation, commits)
163171
return operation
164172
}

‎test/parse-commits.test.ts

-186
This file was deleted.

‎test/update-files.test.ts

+27-26
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,21 @@ it('should skip to modify the manifest file if version field is not specified',
3333
it('should update the manifest file correctly', async () => {
3434
await writeFile(join(cwd(), 'test', 'update-files', 'testdata', 'package-lock.json'), JSON.stringify(
3535
{
36-
"name": "example",
37-
"version": "1.0.43",
38-
"lockfileVersion": 2,
39-
"requires": true,
40-
"packages": {
41-
"": {
42-
"name": "example",
43-
"version": "1.0.43",
44-
"hasInstallScript": true,
45-
"dependencies": {}
46-
}
47-
}
48-
}, null, 2
36+
name: 'example',
37+
version: '1.0.43',
38+
lockfileVersion: 2,
39+
requires: true,
40+
packages: {
41+
'': {
42+
name: 'example',
43+
version: '1.0.43',
44+
hasInstallScript: true,
45+
dependencies: {},
46+
},
47+
},
48+
},
49+
null,
50+
2,
4951
), 'utf8')
5052

5153
const operation = await Operation.start({
@@ -60,18 +62,17 @@ it('should update the manifest file correctly', async () => {
6062
await updateFiles(operation)
6163
const updatedPackageJSON = await readFile(join(cwd(), 'test', 'update-files', 'testdata', 'package-lock.json'), 'utf8')
6264
expect(JSON.parse(updatedPackageJSON)).toMatchObject({
63-
"name": "example",
64-
"version": "2.0.0",
65-
"lockfileVersion": 2,
66-
"requires": true,
67-
"packages": {
68-
"": {
69-
"name": "example",
70-
"version": "2.0.0",
71-
"hasInstallScript": true,
72-
"dependencies": {}
73-
}
74-
}
65+
name: 'example',
66+
version: '2.0.0',
67+
lockfileVersion: 2,
68+
requires: true,
69+
packages: {
70+
'': {
71+
name: 'example',
72+
version: '2.0.0',
73+
hasInstallScript: true,
74+
dependencies: {},
75+
},
76+
},
7577
})
7678
})
77-

‎tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"compilerOptions": {
3-
"target": "es2017",
3+
"target": "ESNext",
44
"module": "esnext",
55
"moduleResolution": "node",
66
"resolveJsonModule": true,

0 commit comments

Comments
 (0)
Please sign in to comment.