From 7d044e981606f9f1fcc3e7da87ee7c80dfbf2e41 Mon Sep 17 00:00:00 2001 From: Northword <44738481+northword@users.noreply.github.com> Date: Sat, 25 Jan 2025 18:24:52 +0800 Subject: [PATCH] feat: inferring the semver version according to Conventional Commit (#71) --- eslint.config.js | 4 +- package.json | 2 + pnpm-lock.yaml | 8 ++ src/get-new-version.ts | 51 ++++++---- src/print-commits.ts | 132 +++++--------------------- src/release-type.ts | 4 +- src/version-bump.ts | 12 ++- test/parse-commits.test.ts | 186 ------------------------------------- test/update-files.test.ts | 53 +++++------ tsconfig.json | 2 +- 10 files changed, 109 insertions(+), 345 deletions(-) delete mode 100644 test/parse-commits.test.ts diff --git a/eslint.config.js b/eslint.config.js index e0cb5f0..3d762b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/package.json b/package.json index e54aa56..ebe0eaf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "bumpp", + "type": "module", "version": "9.10.2", "packageManager": "pnpm@9.15.4", "description": "Bump version, commit changes, tag, and push to Git", @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b61f136..ca6ea0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: semver: specifier: ^7.6.3 version: 7.6.3 + tiny-conventional-commits-parser: + specifier: ^0.0.1 + version: 0.0.1 tinyexec: specifier: ^0.3.2 version: 0.3.2 @@ -3074,6 +3077,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-conventional-commits-parser@0.0.1: + resolution: {integrity: sha512-N5+AZWdBeHNSgTIaxvx0+9mFrnW4H1BbjQ84H7i3TuWSkno8Hju886hLaHZhE/hYEKrfrfl/uHurqpZJHDuYGQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6548,6 +6554,8 @@ snapshots: through@2.3.8: {} + tiny-conventional-commits-parser@0.0.1: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/src/get-new-version.ts b/src/get-new-version.ts index 27fc9da..a2e6c16 100644 --- a/src/get-new-version.ts +++ b/src/get-new-version.ts @@ -1,3 +1,4 @@ +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' @@ -5,19 +6,18 @@ 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 { +export async function getNewVersion(operation: Operation, commits: GitCommit[]): Promise { 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({ @@ -27,7 +27,7 @@ export async function getNewVersion(operation: Operation): Promise { default: return operation.update({ release: release.type, - newVersion: getNextVersion(currentVersion, release), + newVersion: getNextVersion(currentVersion, release, commits), }) } } @@ -35,12 +35,19 @@ export async function getNewVersion(operation: Operation): Promise { /** * 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) @@ -61,10 +68,24 @@ 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 { +function getNextVersions(currentVersion: string, preid: string, commits: GitCommit[]): Record { const next: Record = {} const parse = semver.parse(currentVersion) @@ -72,7 +93,7 @@ function getNextVersions(currentVersion: string, preid: string): Record { +async function promptForNewVersion(operation: Operation, commits: GitCommit[]): Promise { 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([ { @@ -105,6 +122,7 @@ async function promptForNewVersion(operation: Operation): Promise { { 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)}` }, @@ -146,6 +164,7 @@ async function promptForNewVersion(operation: Operation): Promise { case 'custom': case 'config': case 'next': + case 'conventional': case 'none': return operation.update({ newVersion }) diff --git a/src/print-commits.ts b/src/print-commits.ts index 29d49ba..a41f657 100644 --- a/src/print-commits.ts +++ b/src/print-commits.ts @@ -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> = { feat: c.green, @@ -29,135 +28,48 @@ const messageColorMap: Record 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 { - 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() diff --git a/src/release-type.ts b/src/release-type.ts index 8152a7d..36a6714 100644 --- a/src/release-type.ts +++ b/src/release-type.ts @@ -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. @@ -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. diff --git a/src/version-bump.ts b/src/version-bump.ts index ef3319e..a176e10 100644 --- a/src/version-bump.ts +++ b/src/version-bump.ts @@ -5,11 +5,13 @@ import { tokenizeArgs } from 'args-tokenizer' import symbols from 'log-symbols' import c from 'picocolors' import prompts from 'prompts' +import { getRecentCommits } from 'tiny-conventional-commits-parser' import { x } from 'tinyexec' import { getCurrentVersion } from './get-current-version' import { getNewVersion } from './get-new-version' import { formatVersionString, gitCommit, gitPush, gitTag } from './git' import { Operation } from './operation' +import { printRecentCommits } from './print-commits' import { runNpmScript } from './run-npm-script' import { NpmScript } from './types/version-bump-progress' import { updateFiles } from './update-files' @@ -49,9 +51,14 @@ export async function versionBump(arg: (VersionBumpOptions) | string = {}): Prom const operation = await Operation.start(arg) + const commits = getRecentCommits() + if (operation.options.printCommits) { + printRecentCommits(commits) + } + // Get the old and new version numbers await getCurrentVersion(operation) - await getNewVersion(operation) + await getNewVersion(operation, commits) if (arg.confirm) { printSummary(operation) @@ -156,9 +163,10 @@ export async function versionBumpInfo(arg: VersionBumpOptions | string = {}): Pr arg = { release: arg } const operation = await Operation.start(arg) + const commits = getRecentCommits() // Get the old and new version numbers await getCurrentVersion(operation) - await getNewVersion(operation) + await getNewVersion(operation, commits) return operation } diff --git a/test/parse-commits.test.ts b/test/parse-commits.test.ts deleted file mode 100644 index bd00b6c..0000000 --- a/test/parse-commits.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { expect, it } from 'vitest' -import { parseCommits } from '../src/print-commits' - -const fixture = ` -3b93ca405 feat: update deps \`unconfig\` \`jiti\` -568bb4fff feat(vite): apply transformers to preflights during build (#4168) -65d775436 feat(svelte-scoped): optional theme() parsing (#4171) -9ed349ddd feat(transformer-directive): support \`icon()\` directive (#4113) -f38197553 fix(webpack): resolve config before processing (#4174) -6a882da21 feat(webpack): support rspack/rsbuild (#4173) -d8bf879f3 fix(preset-mini): data attributes with named groups (#4165) -19bc9c7e6 fix(postcss): postcss dependency should always be added (#4161) -f21efd539 fix(nuxt): resolve config in advance (#4163) -320dfef4e feat(preset-web-fonts): \`fontsource\` font provider (#4156) -bfad9f238 fix!(extractor-arbitrary-variants): skip extracting encoded html entities (#4162) -3f2e7f631 docs: add tutorial links update contributors (#4159) -31e6709c4 ci: use \`--only-templates\` (#4170) -3de433122 feat(preset-mini): support \`bg-[image:*]\` (#4160) -35297359b docs(rules): explain symbols.layer in symbols docs (#4145) -9be7b299d feat(core): add symbols.layer (#4143) -bd4d8e998 docs(config): layers using variants (#4144) -67f3237dc refactor!: make arbitrary variants extractor callable (#4239) -5420b1316 feat(core)!: deprecate \`new UnoGenerator\`, make \`createGenerator()\` async (#4268) -` - -it('parseCommits', async () => { - const parsed = parseCommits(fixture) - // console.log(formatParsedCommits(parsed).join('\n')) - expect(parsed) - .toMatchInlineSnapshot(` - [ - { - "breaking": true, - "color": [Function], - "hash": "5420b1316", - "message": "deprecate \`new UnoGenerator\`, make \`createGenerator()\` async (#4268)", - "scope": "(core)", - "tag": "feat!", - }, - { - "breaking": true, - "color": [Function], - "hash": "67f3237dc", - "message": "make arbitrary variants extractor callable (#4239)", - "scope": "", - "tag": "refactor!", - }, - { - "breaking": false, - "color": [Function], - "hash": "bd4d8e998", - "message": "layers using variants (#4144)", - "scope": "(config)", - "tag": "docs", - }, - { - "breaking": false, - "color": [Function], - "hash": "9be7b299d", - "message": "add symbols.layer (#4143)", - "scope": "(core)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "35297359b", - "message": "explain symbols.layer in symbols docs (#4145)", - "scope": "(rules)", - "tag": "docs", - }, - { - "breaking": false, - "color": [Function], - "hash": "3de433122", - "message": "support \`bg-[image:*]\` (#4160)", - "scope": "(preset-mini)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "31e6709c4", - "message": "use \`--only-templates\` (#4170)", - "scope": "", - "tag": "ci", - }, - { - "breaking": false, - "color": [Function], - "hash": "3f2e7f631", - "message": "add tutorial links update contributors (#4159)", - "scope": "", - "tag": "docs", - }, - { - "breaking": true, - "color": [Function], - "hash": "bfad9f238", - "message": "skip extracting encoded html entities (#4162)", - "scope": "(extractor-arbitrary-variants)", - "tag": "fix!", - }, - { - "breaking": false, - "color": [Function], - "hash": "320dfef4e", - "message": "\`fontsource\` font provider (#4156)", - "scope": "(preset-web-fonts)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "f21efd539", - "message": "resolve config in advance (#4163)", - "scope": "(nuxt)", - "tag": "fix", - }, - { - "breaking": false, - "color": [Function], - "hash": "19bc9c7e6", - "message": "postcss dependency should always be added (#4161)", - "scope": "(postcss)", - "tag": "fix", - }, - { - "breaking": false, - "color": [Function], - "hash": "d8bf879f3", - "message": "data attributes with named groups (#4165)", - "scope": "(preset-mini)", - "tag": "fix", - }, - { - "breaking": false, - "color": [Function], - "hash": "6a882da21", - "message": "support rspack/rsbuild (#4173)", - "scope": "(webpack)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "f38197553", - "message": "resolve config before processing (#4174)", - "scope": "(webpack)", - "tag": "fix", - }, - { - "breaking": false, - "color": [Function], - "hash": "9ed349ddd", - "message": "support \`icon()\` directive (#4113)", - "scope": "(transformer-directive)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "65d775436", - "message": "optional theme() parsing (#4171)", - "scope": "(svelte-scoped)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "568bb4fff", - "message": "apply transformers to preflights during build (#4168)", - "scope": "(vite)", - "tag": "feat", - }, - { - "breaking": false, - "color": [Function], - "hash": "3b93ca405", - "message": "update deps \`unconfig\` \`jiti\`", - "scope": "", - "tag": "feat", - }, - ] - `) -}) diff --git a/test/update-files.test.ts b/test/update-files.test.ts index 4699e71..5275216 100644 --- a/test/update-files.test.ts +++ b/test/update-files.test.ts @@ -33,19 +33,21 @@ it('should skip to modify the manifest file if version field is not specified', it('should update the manifest file correctly', async () => { await writeFile(join(cwd(), 'test', 'update-files', 'testdata', 'package-lock.json'), JSON.stringify( { - "name": "example", - "version": "1.0.43", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "example", - "version": "1.0.43", - "hasInstallScript": true, - "dependencies": {} - } - } - }, null, 2 + name: 'example', + version: '1.0.43', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'example', + version: '1.0.43', + hasInstallScript: true, + dependencies: {}, + }, + }, + }, + null, + 2, ), 'utf8') const operation = await Operation.start({ @@ -60,18 +62,17 @@ it('should update the manifest file correctly', async () => { await updateFiles(operation) const updatedPackageJSON = await readFile(join(cwd(), 'test', 'update-files', 'testdata', 'package-lock.json'), 'utf8') expect(JSON.parse(updatedPackageJSON)).toMatchObject({ - "name": "example", - "version": "2.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "example", - "version": "2.0.0", - "hasInstallScript": true, - "dependencies": {} - } - } + name: 'example', + version: '2.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'example', + version: '2.0.0', + hasInstallScript: true, + dependencies: {}, + }, + }, }) }) - diff --git a/tsconfig.json b/tsconfig.json index af3b33c..8362dc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2017", + "target": "ESNext", "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true,