Skip to content

Commit 7d044e9

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

File tree

10 files changed

+109
-345
lines changed

10 files changed

+109
-345
lines changed

eslint.config.js

Lines changed: 2 additions & 2 deletions
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

Lines changed: 2 additions & 0 deletions
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": "[email protected]",
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

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/get-new-version.ts

Lines changed: 35 additions & 16 deletions
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

Lines changed: 22 additions & 110 deletions
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

Lines changed: 2 additions & 2 deletions
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.

0 commit comments

Comments
 (0)