Skip to content

Commit

Permalink
feat: inferring the semver version according to Conventional Commit (#71
Browse files Browse the repository at this point in the history
)
  • Loading branch information
northword authored Jan 25, 2025
1 parent db6e8dd commit 7d044e9
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 345 deletions.
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const antfu = require('@antfu/eslint-config').default
import { antfu } from '@antfu/eslint-config'

module.exports = antfu({
export default antfu({
rules: {
'no-console': 'off',
'no-restricted-syntax': 'off',
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "bumpp",
"type": "module",
"version": "9.10.2",
"packageManager": "[email protected]",
"description": "Bump version, commit changes, tag, and push to Git",
Expand Down Expand Up @@ -68,6 +69,7 @@
"package-manager-detector": "^0.2.8",
"prompts": "^2.4.2",
"semver": "^7.6.3",
"tiny-conventional-commits-parser": "^0.0.1",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.10"
},
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 35 additions & 16 deletions src/get-new-version.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import type { GitCommit } from 'tiny-conventional-commits-parser'
import type { BumpRelease, PromptRelease } from './normalize-options'
import type { Operation } from './operation'
import type { ReleaseType } from './release-type'
import process from 'node:process'
import c from 'picocolors'
import prompts from 'prompts'
import semver, { clean as cleanVersion, valid as isValidVersion, SemVer } from 'semver'
import { printRecentCommits } from './print-commits'
import { isPrerelease, releaseTypes } from './release-type'

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

switch (release.type) {
case 'prompt':
return promptForNewVersion(operation)
return promptForNewVersion(operation, commits)

case 'version':
return operation.update({
Expand All @@ -27,20 +27,27 @@ export async function getNewVersion(operation: Operation): Promise<Operation> {
default:
return operation.update({
release: release.type,
newVersion: getNextVersion(currentVersion, release),
newVersion: getNextVersion(currentVersion, release, commits),
})
}
}

/**
* Returns the next version number of the specified type.
*/
function getNextVersion(currentVersion: string, bump: BumpRelease): string {
function getNextVersion(currentVersion: string, bump: BumpRelease, commits: GitCommit[]): string {
const oldSemVer = new SemVer(currentVersion)

const type = bump.type === 'next'
? oldSemVer.prerelease.length ? 'prerelease' : 'patch'
: bump.type
let type: ReleaseType
if (bump.type === 'next') {
type = oldSemVer.prerelease.length ? 'prerelease' : 'patch'
}
else if (bump.type === 'conventional') {
type = oldSemVer.prerelease.length ? 'prerelease' : determineSemverChange(commits)
}
else {
type = bump.type
}

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

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

function determineSemverChange(commits: GitCommit[]) {
let [hasMajor, hasMinor] = [false, false]
for (const commit of commits) {
if (commit.isBreaking) {
hasMajor = true
}
else if (commit.type === 'feat') {
hasMinor = true
}
}

return hasMajor ? 'major' : hasMinor ? 'minor' : 'patch'
}

/**
* Returns the next version number for all release types.
*/
function getNextVersions(currentVersion: string, preid: string): Record<ReleaseType, string> {
function getNextVersions(currentVersion: string, preid: string, commits: GitCommit[]): Record<ReleaseType, string> {
const next: Record<string, string> = {}

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

for (const type of releaseTypes)
next[type] = getNextVersion(currentVersion, { type, preid })
next[type] = getNextVersion(currentVersion, { type, preid }, commits)

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

const next = getNextVersions(currentVersion, release.preid)
const next = getNextVersions(currentVersion, release.preid, commits)
const configCustomVersion = await operation.options.customVersion?.(currentVersion, semver)

if (operation.options.printCommits) {
await printRecentCommits(operation)
}

const PADDING = 13
const answers = await prompts([
{
Expand All @@ -105,6 +122,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
{ value: 'minor', title: `${'minor'.padStart(PADDING, ' ')} ${c.bold(next.minor)}` },
{ value: 'patch', title: `${'patch'.padStart(PADDING, ' ')} ${c.bold(next.patch)}` },
{ value: 'next', title: `${'next'.padStart(PADDING, ' ')} ${c.bold(next.next)}` },
{ value: 'conventional', title: `${'conventional'.padStart(PADDING, ' ')} ${c.bold(next.conventional)}` },
...configCustomVersion
? [
{ value: 'config', title: `${'from config'.padStart(PADDING, ' ')} ${c.bold(configCustomVersion)}` },
Expand Down Expand Up @@ -146,6 +164,7 @@ async function promptForNewVersion(operation: Operation): Promise<Operation> {
case 'custom':
case 'config':
case 'next':
case 'conventional':
case 'none':
return operation.update({ newVersion })

Expand Down
132 changes: 22 additions & 110 deletions src/print-commits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Operation } from './operation'
import type { GitCommit } from 'tiny-conventional-commits-parser'
import c from 'picocolors'
import { x } from 'tinyexec'

const messageColorMap: Record<string, (c: string) => string> = {
feat: c.green,
Expand Down Expand Up @@ -29,135 +28,48 @@ const messageColorMap: Record<string, (c: string) => string> = {
breaking: c.red,
}

interface ParsedCommit {
hash: string
message: string
tag: string
breaking?: boolean
scope: string
color: (c: string) => string
}

export function parseCommits(raw: string) {
const lines = raw
.toString()
.trim()
.split(/\n/g)

if (!lines.length) {
return []
}
export function formatParsedCommits(commits: GitCommit[]) {
const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0)
const scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0)

return lines
.map((line): ParsedCommit => {
const [hash, ...parts] = line.split(' ')
const message = parts.join(' ')
const match = message.match(/^(\w+)(!)?(\([^)]+\))?(!)?:(.*)$/)
if (match) {
let color = messageColorMap[match[1].toLowerCase()] || ((c: string) => c)
const breaking = match[2] === '!' || match[4] === '!'
if (breaking) {
color = s => c.inverse(c.red(s))
}
const tag = [match[1], match[2], match[4]].filter(Boolean).join('')
const scope = match[3] || ''
return {
hash,
tag,
message: match[5].trim(),
scope,
breaking,
color,
}
}
return {
hash,
tag: '',
message,
scope: '',
color: c => c,
}
})
.reverse()
}

export function formatParsedCommits(commits: ParsedCommit[]) {
const tagLength = commits.map(({ tag }) => tag.length).reduce((a, b) => Math.max(a, b), 0)
let scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0)
if (scopeLength)
scopeLength += 2
return commits.map((commit) => {
let color = messageColorMap[commit.type] || ((c: string) => c)
if (commit.isBreaking) {
color = s => c.inverse(c.red(s))
}

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

return [
c.dim(hash),
c.dim(commit.shortHash),
' ',
color === c.gray ? color(paddedTag) : c.bold(color(paddedTag)),
color === c.gray ? color(paddedType) : c.bold(color(paddedType)),
' ',
paddedScope,
c.dim(':'),
' ',
color === c.gray ? color(message) : message,
color === c.gray ? color(commit.description) : commit.description,
].join('')
})
}

export async function printRecentCommits(operation: Operation): Promise<void> {
let sha: string | undefined
sha ||= await x(
'git',
['rev-list', '-n', '1', `v${operation.state.currentVersion}`],
{ nodeOptions: { stdio: 'pipe' }, throwOnError: false },
)
.then(res => res.stdout.trim())
sha ||= await x(
'git',
['rev-list', '-n', '1', operation.state.currentVersion],
{ nodeOptions: { stdio: 'pipe' }, throwOnError: false },
)
.then(res => res.stdout.trim())

if (!sha) {
console.log(
c.blue(`i`)
+ c.gray(` Failed to locate the previous tag ${c.yellow(`v${operation.state.currentVersion}`)}`),
)
return
}

const { stdout } = await x(
'git',
[
'--no-pager',
'log',
`${sha}..HEAD`,
'--oneline',
],
{
nodeOptions: {
stdio: 'pipe',
},
},
)

const parsed = parseCommits(stdout.toString().trim())
const prettified = formatParsedCommits(parsed)

if (!parsed.length) {
export function printRecentCommits(commits: GitCommit[]): void {
if (!commits.length) {
console.log()
console.log(c.blue(`i`) + c.gray(` No commits since ${operation.state.currentVersion}`))
console.log(c.blue(`i`) + c.gray(` No commits since the last version`))
console.log()
return
}

const prettified = formatParsedCommits(commits)

console.log()
console.log(
c.bold(
`${c.green(parsed.length)} Commits since ${c.gray(sha.slice(0, 7))}:`,
`${c.green(commits.length)} Commits since the last version:`,
),
)
console.log()
Expand Down
4 changes: 2 additions & 2 deletions src/release-type.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReleaseType as SemverReleaseType } from 'semver'

export type ReleaseType = SemverReleaseType | 'next'
export type ReleaseType = SemverReleaseType | 'next' | 'conventional'

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

/**
* Determines whether the specified value is a pre-release.
Expand Down
Loading

0 comments on commit 7d044e9

Please sign in to comment.