diff --git a/package-lock.json b/package-lock.json index fdc9af935..7a04779a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "esbuild": "^0.19.12", "multi-progress-bars": "^5.0.3", "parse-lcov": "^1.0.4", + "semver": "^7.6.0", "simple-git": "^3.20.0", "vscode-material-icons": "^0.1.0", "yargs": "^17.7.2", @@ -22511,8 +22512,8 @@ }, "node_modules/semver": { "version": "7.6.0", - "dev": true, - "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -22551,7 +22552,6 @@ }, "node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -22562,7 +22562,6 @@ }, "node_modules/semver/node_modules/yallist": { "version": "4.0.0", - "dev": true, "license": "ISC" }, "node_modules/send": { diff --git a/package.json b/package.json index 949d60d37..5793fc766 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "esbuild": "^0.19.12", "multi-progress-bars": "^5.0.3", "parse-lcov": "^1.0.4", + "semver": "^7.6.0", "simple-git": "^3.20.0", "vscode-material-icons": "^0.1.0", "yargs": "^17.7.2", diff --git a/packages/cli/docs/custom-plugins.md b/packages/cli/docs/custom-plugins.md index 488bd65de..6aedfecf8 100644 --- a/packages/cli/docs/custom-plugins.md +++ b/packages/cli/docs/custom-plugins.md @@ -450,7 +450,7 @@ We will extend the file-size example from above to calculate the score based on Let's extend the options object with a `budget` property and use it in the runner config: -**file-size plugin form section [RunnerFunction](#RunnerFunction)** +**file-size plugin from section [RunnerFunction](#RunnerFunction)** ```typescript // file-size.plugin.ts diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 8e07a02f7..2926994cf 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,14 +1,63 @@ import chalk from 'chalk'; -import { ArgumentsCamelCase, CommandModule } from 'yargs'; -import { HistoryOptions, getHashes, history } from '@code-pushup/core'; -import { getCurrentBranchOrTag, safeCheckout, ui } from '@code-pushup/utils'; +import { CommandModule } from 'yargs'; +import { HistoryOptions, history } from '@code-pushup/core'; +import { + LogResult, + getCurrentBranchOrTag, + getHashes, + getSemverTags, + safeCheckout, + ui, +} from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import { yargsOnlyPluginsOptionsDefinition } from '../implementation/only-plugins.options'; import { HistoryCliOptions } from './history.model'; import { yargsHistoryOptionsDefinition } from './history.options'; +import { normalizeHashOptions } from './utils'; + +const command = 'history'; +async function handler(args: unknown) { + ui().logger.info(chalk.bold(CLI_NAME)); + ui().logger.info(chalk.gray(`Run ${command}`)); + + const currentBranch = await getCurrentBranchOrTag(); + const { targetBranch: rawTargetBranch, ...opt } = args as HistoryCliOptions & + HistoryOptions; + const { + targetBranch, + from, + to, + maxCount, + onlySemverTags, + ...historyOptions + } = await normalizeHashOptions({ + ...opt, + targetBranch: rawTargetBranch ?? currentBranch, + }); + + const filterOptions = { targetBranch, from, to, maxCount }; + const results: LogResult[] = onlySemverTags + ? await getSemverTags(filterOptions) + : await getHashes(filterOptions); + + try { + // run history logic + const reports = await history( + { + targetBranch, + ...historyOptions, + }, + results.map(({ hash }) => hash), + ); + + ui().logger.log(`Reports: ${reports.length}`); + } finally { + // go back to initial branch + await safeCheckout(currentBranch); + } +} export function yargsHistoryCommandObject() { - const command = 'history'; return { command, describe: 'Collect reports for commit history', @@ -23,38 +72,6 @@ export function yargsHistoryCommandObject() { ); return yargs; }, - handler: async (args: ArgumentsCamelCase) => { - ui().logger.info(chalk.bold(CLI_NAME)); - ui().logger.info(chalk.gray(`Run ${command}`)); - - const currentBranch = await getCurrentBranchOrTag(); - const { - targetBranch = currentBranch, - forceCleanStatus, - maxCount, - from, - to, - ...restOptions - } = args as unknown as HistoryCliOptions & HistoryOptions; - - // determine history to walk - const commits: string[] = await getHashes({ maxCount, from, to }); - try { - // run history logic - const reports = await history( - { - ...restOptions, - targetBranch, - forceCleanStatus, - }, - commits, - ); - - ui().logger.log(`Reports: ${reports.length}`); - } finally { - // go back to initial branch - await safeCheckout(currentBranch); - } - }, + handler, } satisfies CommandModule; } diff --git a/packages/cli/src/lib/history/history-command.unit.test.ts b/packages/cli/src/lib/history/history-command.unit.test.ts index d7779770f..b796c53be 100644 --- a/packages/cli/src/lib/history/history-command.unit.test.ts +++ b/packages/cli/src/lib/history/history-command.unit.test.ts @@ -37,14 +37,20 @@ vi.mock('simple-git', async () => { return { ...actual, simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + raw: () => Promise.resolve('main'), + tag: () => Promise.resolve(`5\n 4\n 3\n 2\n 1`), + show: ([_, __, tag]: string) => + Promise.resolve(`release v${tag}\n ${tag}`), + checkout: () => Promise.resolve(), log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) => Promise.resolve({ all: [ { hash: 'commit-6' }, { hash: 'commit-5' }, - { hash: 'commit-4' }, + { hash: 'commit-4--release-v2' }, { hash: 'commit-3' }, - { hash: 'commit-2' }, + { hash: 'commit-2--release-v1' }, { hash: 'commit-1' }, ].slice(-maxCount), }), @@ -53,7 +59,7 @@ vi.mock('simple-git', async () => { }); describe('history-command', () => { - it('should return the last 5 commits', async () => { + it('should pass targetBranch and forceCleanStatus to core history logic', async () => { await yargsCli(['history', '--config=/test/code-pushup.config.ts'], { ...DEFAULT_CLI_CONFIGURATION, commands: [yargsHistoryCommandObject()], @@ -62,27 +68,11 @@ describe('history-command', () => { expect(history).toHaveBeenCalledWith( expect.objectContaining({ targetBranch: 'main', + forceCleanStatus: false, }), - ['commit-1', 'commit-2', 'commit-3', 'commit-4', 'commit-5'], + expect.any(Array), ); expect(safeCheckout).toHaveBeenCalledTimes(1); }); - - it('should have 2 commits to crawl in history if maxCount is set to 2', async () => { - await yargsCli( - ['history', '--config=/test/code-pushup.config.ts', '--maxCount=2'], - { - ...DEFAULT_CLI_CONFIGURATION, - commands: [yargsHistoryCommandObject()], - }, - ).parseAsync(); - - expect(history).toHaveBeenCalledWith(expect.any(Object), [ - 'commit-1', - 'commit-2', - ]); - - expect(safeCheckout).toHaveBeenCalledTimes(1); - }); }); diff --git a/packages/cli/src/lib/history/history.model.ts b/packages/cli/src/lib/history/history.model.ts index 40197e769..31fa5e42d 100644 --- a/packages/cli/src/lib/history/history.model.ts +++ b/packages/cli/src/lib/history/history.model.ts @@ -3,5 +3,6 @@ import { HistoryOnlyOptions } from '@code-pushup/core'; export type HistoryCliOptions = { targetBranch?: string; + onlySemverTags?: boolean; } & Pick & HistoryOnlyOptions; diff --git a/packages/cli/src/lib/history/history.options.ts b/packages/cli/src/lib/history/history.options.ts index 4f12aec56..54dc1ddb7 100644 --- a/packages/cli/src/lib/history/history.options.ts +++ b/packages/cli/src/lib/history/history.options.ts @@ -9,7 +9,11 @@ export function yargsHistoryOptionsDefinition(): Record< targetBranch: { describe: 'Branch to crawl history', type: 'string', - default: 'main', + }, + onlySemverTags: { + describe: 'Skip commits not tagged with a semantic version', + type: 'boolean', + default: false, }, forceCleanStatus: { describe: diff --git a/packages/cli/src/lib/history/utils.ts b/packages/cli/src/lib/history/utils.ts new file mode 100644 index 000000000..63f6300de --- /dev/null +++ b/packages/cli/src/lib/history/utils.ts @@ -0,0 +1,36 @@ +import { HistoryOptions } from '@code-pushup/core'; +import { getHashFromTag, isSemver } from '@code-pushup/utils'; +import { HistoryCliOptions } from './history.model'; + +export async function normalizeHashOptions( + processArgs: HistoryCliOptions & HistoryOptions, +): Promise { + const { + onlySemverTags, + // overwritten + maxCount, + ...opt + } = processArgs; + + // eslint-disable-next-line functional/no-let, prefer-const + let { from, to, ...processOptions } = opt; + // if no semver filter is used resolve hash of tags, as hashes are used to collect history + if (!onlySemverTags) { + if (from && isSemver(from)) { + const { hash } = await getHashFromTag(from); + from = hash; + } + if (to && isSemver(to)) { + const { hash } = await getHashFromTag(to); + to = hash; + } + } + + return { + ...processOptions, + onlySemverTags, + maxCount: maxCount && maxCount > 0 ? maxCount : undefined, + from, + to, + }; +} diff --git a/packages/cli/src/lib/history/utils.unit.test.ts b/packages/cli/src/lib/history/utils.unit.test.ts new file mode 100644 index 000000000..82054a687 --- /dev/null +++ b/packages/cli/src/lib/history/utils.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, vi } from 'vitest'; +import { type HistoryOptions } from '@code-pushup/core'; +import { HistoryCliOptions } from './history.model'; +import { normalizeHashOptions } from './utils'; + +vi.mock('simple-git', async () => { + const actual = await vi.importActual('simple-git'); + const orderedTagsHistory = ['2.0.0', '1.0.0']; + return { + ...actual, + simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + raw: () => Promise.resolve('main'), + tag: () => Promise.resolve(orderedTagsHistory.join('\n')), + show: ([_, __, tag]: string) => + orderedTagsHistory.includes(tag || '') + ? Promise.resolve(`${tag}\ncommit--release-v${tag}`) + : Promise.reject('NOT FOUND TAG'), + checkout: () => Promise.resolve(), + log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) => + Promise.resolve({ + all: [ + { hash: 'commit-6' }, + { hash: 'commit-5' }, + { hash: `commit--release-v${orderedTagsHistory.at(0)}` }, + { hash: 'commit-3' }, + { hash: `commit--release-v${orderedTagsHistory.at(1)}` }, + { hash: 'commit-1' }, + ].slice(-maxCount), + }), + }), + }; +}); + +describe('normalizeHashOptions', () => { + it('should forwards other options', async () => { + await expect( + normalizeHashOptions({ + test: 42, + } as unknown as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + test: 42, + }), + ); + }); + + it('should set "maxCount" to undefined if "0" is passed', async () => { + await expect( + normalizeHashOptions({ maxCount: 0 } as HistoryCliOptions & + HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + maxCount: undefined, + }), + ); + }); + + it('should forward hashes "from" and "to" as is if "onlySemverTags" is false', async () => { + await expect( + normalizeHashOptions({ + from: 'commit-3', + to: 'commit-1', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + from: 'commit-3', + to: 'commit-1', + }), + ); + }); + + it('should transform tags "from" and "to" to commit hashes if "onlySemverTags" is false', async () => { + await expect( + normalizeHashOptions({ + onlySemverTags: false, + from: '2.0.0', + to: '1.0.0', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + onlySemverTags: false, + from: 'commit--release-v2.0.0', + to: 'commit--release-v1.0.0', + }), + ); + }); + + it('should forward tags "from" and "to" if "onlySemverTags" is true', async () => { + await expect( + normalizeHashOptions({ + onlySemverTags: true, + from: '2.0.0', + to: '1.0.0', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + onlySemverTags: true, + from: '2.0.0', + to: '1.0.0', + }), + ); + }); + + it('should forward hashes "from" and "to" if "onlySemverTags" is true', async () => { + await expect( + normalizeHashOptions({ + onlySemverTags: true, + from: 'commit-3', + to: 'commit-1', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + onlySemverTags: true, + from: 'commit-3', + to: 'commit-1', + }), + ); + }); +}); diff --git a/packages/cli/src/lib/yargs-cli.integration.test.ts b/packages/cli/src/lib/yargs-cli.integration.test.ts index 090ab78a4..4102ab850 100644 --- a/packages/cli/src/lib/yargs-cli.integration.test.ts +++ b/packages/cli/src/lib/yargs-cli.integration.test.ts @@ -149,7 +149,7 @@ describe('yargsCli', () => { expect(result).toEqual( expect.objectContaining({ - targetBranch: 'main', + onlySemverTags: false, maxCount: 5, skipUploads: false, }), @@ -163,9 +163,19 @@ describe('yargsCli', () => { expect(result).toEqual( expect.objectContaining({ - targetBranch: 'main', maxCount: 2, - skipUploads: false, + }), + ); + }); + + it('should parse history options and have onlySemverTags true to crawl in history if onlySemverTags is set', async () => { + const result = await yargsCli(['history', '--onlySemverTags'], { + options: { ...options, ...yargsHistoryOptionsDefinition() }, + }).parseAsync(); + + expect(result).toEqual( + expect.objectContaining({ + onlySemverTags: true, }), ); }); diff --git a/packages/core/package.json b/packages/core/package.json index 679222b82..09113a1c1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,8 +6,7 @@ "@code-pushup/models": "0.39.0", "@code-pushup/utils": "0.39.0", "@code-pushup/portal-client": "^0.6.1", - "chalk": "^5.3.0", - "simple-git": "^3.20.0" + "chalk": "^5.3.0" }, "type": "commonjs", "main": "./index.cjs" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ac9b7011e..bc828e5f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,12 +15,7 @@ export { PersistError, persistReport, } from './lib/implementation/persist'; -export { - history, - HistoryOptions, - HistoryOnlyOptions, - getHashes, -} from './lib/history'; +export { history, HistoryOptions, HistoryOnlyOptions } from './lib/history'; export { ConfigPathError, autoloadRc, diff --git a/packages/core/src/lib/history.integration.test.ts b/packages/core/src/lib/history.integration.test.ts deleted file mode 100644 index d31d412fb..000000000 --- a/packages/core/src/lib/history.integration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { type SimpleGit, simpleGit } from 'simple-git'; -import { afterAll, beforeAll, describe, expect } from 'vitest'; -import { getHashes } from './history'; - -describe('getHashes', () => { - const baseDir = join(process.cwd(), 'tmp', 'core-history-git-test'); - let gitMock: SimpleGit; - - beforeAll(async () => { - await mkdir(baseDir, { recursive: true }); - gitMock = simpleGit(baseDir); - await gitMock.init(); - await gitMock.addConfig('user.name', 'John Doe'); - await gitMock.addConfig('user.email', 'john.doe@example.com'); - }); - - afterAll(async () => { - await rm(baseDir, { recursive: true, force: true }); - }); - - describe('without a branch and commits', () => { - it('should throw', async () => { - await expect(getHashes({}, gitMock)).rejects.toThrow( - "your current branch 'master' does not have any commits yet", - ); - }); - }); - - describe('with a branch and commits clean', () => { - const commits: string[] = []; - beforeAll(async () => { - await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); - await gitMock.add('README.md'); - await gitMock.commit('Create README'); - // eslint-disable-next-line functional/immutable-data - commits.push((await gitMock.log()).latest!.hash); - - await writeFile(join(baseDir, 'README.md'), '# hello-world-1\n'); - await gitMock.add('README.md'); - await gitMock.commit('Update README 1'); - // eslint-disable-next-line functional/immutable-data - commits.push((await gitMock.log()).latest!.hash); - - await writeFile(join(baseDir, 'README.md'), '# hello-world-2\n'); - await gitMock.add('README.md'); - await gitMock.commit('Update README 2'); - // eslint-disable-next-line functional/immutable-data - commits.push((await gitMock.log()).latest!.hash); - - await gitMock.branch(['feature-branch']); - await gitMock.checkout(['master']); - }); - - afterAll(async () => { - await gitMock.checkout(['master']); - await gitMock.deleteLocalBranch('feature-branch'); - }); - - it('getHashes should get all commits from log if no option is passed', async () => { - await expect(getHashes({}, gitMock)).resolves.toStrictEqual(commits); - }); - - it('getHashes should get last 2 commits from log if maxCount is set to 2', async () => { - await expect(getHashes({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([ - commits.at(-2), - commits.at(-1), - ]); - }); - - it('getHashes should get commits from log based on "from"', async () => { - await expect( - getHashes({ from: commits.at(0) }, gitMock), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); - }); - - it('getHashes should get commits from log based on "from" and "to"', async () => { - await expect( - getHashes({ from: commits.at(-1), to: commits.at(0) }, gitMock), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); - }); - - it('getHashes should get commits from log based on "from" and "to" and "maxCount"', async () => { - await expect( - getHashes( - { from: commits.at(-1), to: commits.at(0), maxCount: 1 }, - gitMock, - ), - ).resolves.toEqual([commits.at(-1)]); - }); - - it('getHashes should throw if "from" is undefined but "to" is defined', async () => { - await expect( - getHashes({ from: undefined, to: 'a' }, gitMock), - ).rejects.toThrow( - 'git log command needs the "from" option defined to accept the "to" option.', - ); - }); - }); -}); diff --git a/packages/core/src/lib/history.ts b/packages/core/src/lib/history.ts index 23ff813f0..76200cdc0 100644 --- a/packages/core/src/lib/history.ts +++ b/packages/core/src/lib/history.ts @@ -1,4 +1,3 @@ -import { LogOptions, LogResult, simpleGit } from 'simple-git'; import { CoreConfig, PersistConfig, UploadConfig } from '@code-pushup/models'; import { getCurrentBranchOrTag, safeCheckout, ui } from '@code-pushup/utils'; import { collectAndPersistReports } from './collect-and-persist'; @@ -63,76 +62,3 @@ export async function history( return reports; } - -/** - * `getHashes` returns a list of commit hashes. Internally it uses `git.log()` to determine the commits within a range. - * The amount can be limited to a maximum number of commits specified by `maxCount`. - * With `from` and `to`, you can specify a range of commits. - * - * **NOTE:** - * In Git, specifying a range with two dots (`from..to`) selects commits that are reachable from `to` but not from `from`. - * Essentially, it shows the commits that are in `to` but not in `from`, excluding the commits unique to `from`. - * - * Example: - * - * Let's consider the following commit history: - * - * A---B---C---D---E (main) - * - * Using `git log B..D`, you would get the commits C and D: - * - * C---D - * - * This is because these commits are reachable from D but not from B. - * - * ASCII Representation: - * - * Main Branch: A---B---C---D---E - * \ \ - * \ +--- Commits included in `git log B..D` - * \ - * +--- Excluded by the `from` parameter - * - * With `simple-git`, when you specify a `from` and `to` range like this: - * - * git.log({ from: 'B', to: 'D' }); - * - * It interprets it similarly, selecting commits between B and D, inclusive of D but exclusive of B. - * For `git.log({ from: 'B', to: 'D' })` or `git log B..D`, commits C and D are selected. - * - * @param options Object containing `from`, `to`, and optionally `maxCount` to specify the commit range and limit. - * @param git The `simple-git` instance used to execute Git commands. - */ -export async function getHashes( - options: LogOptions, - git = simpleGit(), -): Promise { - const { from, to } = options; - - if (to && !from) { - // throw more user-friendly error instead of: - // fatal: ambiguous argument '...a': unknown revision or path not in the working tree. - // Use '--' to separate paths from revisions, like this: - // 'git [...] -- [...]' - throw new Error( - `git log command needs the "from" option defined to accept the "to" option.\n`, - ); - } - - const logs = await git.log({ - ...options, - from, - to, - }); - - return prepareHashes(logs); -} - -export function prepareHashes(logs: LogResult): string[] { - return ( - logs.all - .map(({ hash }) => hash) - // sort from oldest to newest - .reverse() - ); -} diff --git a/packages/core/src/lib/history.unit.test.ts b/packages/core/src/lib/history.unit.test.ts index 2df0ab3ac..c103050b3 100644 --- a/packages/core/src/lib/history.unit.test.ts +++ b/packages/core/src/lib/history.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, vi } from 'vitest'; import { MINIMAL_PLUGIN_CONFIG_MOCK } from '@code-pushup/test-utils'; import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils'; import { collectAndPersistReports } from './collect-and-persist'; -import { HistoryOptions, history, prepareHashes } from './history'; +import { HistoryOptions, history } from './history'; import { upload } from './upload'; vi.mock('@code-pushup/utils', async () => { @@ -109,47 +109,3 @@ describe('history', () => { expect(upload).not.toHaveBeenCalled(); }); }); - -describe('prepareHashes', () => { - it('should return commit hashes in reverse order', () => { - expect( - prepareHashes({ - all: [ - { - hash: '22287eb716a84f82b5d59e7238ffcae7147f707a', - date: 'Thu Mar 7 20:13:33 2024 +0100', - message: - 'test: change test reported to basic in order to work on Windows', - refs: 'string', - body: '', - author_name: 'John Doe', - author_email: 'john.doe@gmail.com', - }, - { - hash: '111b284e48ddf464a498dcf22426a9ce65e2c01c', - date: 'Thu Mar 7 20:13:34 2024 +0100', - message: 'chore: exclude fixtures from ESLint', - refs: 'string', - body: '', - author_name: 'Jane Doe', - author_email: 'jane.doe@gmail.com', - }, - ], - total: 2, - latest: { - hash: '22287eb716a84f82b5d59e7238ffcae7147f707a', - date: 'Thu Mar 7 20:13:33 2024 +0100', - message: - 'test: change test reported to basic in order to work on Windows', - refs: 'string', - body: '', - author_name: 'John Doe', - author_email: 'john.doe@gmail.com', - }, - }), - ).toStrictEqual([ - '111b284e48ddf464a498dcf22426a9ce65e2c01c', - '22287eb716a84f82b5d59e7238ffcae7147f707a', - ]); - }); -}); diff --git a/packages/utils/package.json b/packages/utils/package.json index 39b1638c7..5279a1016 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -9,6 +9,7 @@ "@isaacs/cliui": "^8.0.2", "simple-git": "^3.20.0", "multi-progress-bars": "^5.0.3", - "@poppinss/cliui": "^6.4.0" + "@poppinss/cliui": "^6.4.0", + "semver": "^7.6.0" } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c87232154..3613763c3 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -38,13 +38,19 @@ export { } from './lib/formatting'; export { formatGitPath, - getCurrentBranchOrTag, guardAgainstLocalChanges, getGitRoot, - getLatestCommit, safeCheckout, toGitPath, -} from './lib/git'; +} from './lib/git/git'; +export { + getSemverTags, + LogResult, + getHashes, + getHashFromTag, + getCurrentBranchOrTag, + getLatestCommit, +} from './lib/git/git.commits-and-tags'; export { groupByStatus } from './lib/group-by-status'; export { isPromiseFulfilledResult, @@ -98,3 +104,4 @@ export { toUnixPath, } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; +export { isSemver, normalizeSemver, sortSemvers } from './lib/semver'; diff --git a/packages/utils/src/lib/git/git.commits-and-tags.integration.test.ts b/packages/utils/src/lib/git/git.commits-and-tags.integration.test.ts new file mode 100644 index 000000000..44050d2c7 --- /dev/null +++ b/packages/utils/src/lib/git/git.commits-and-tags.integration.test.ts @@ -0,0 +1,248 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type SimpleGit, simpleGit } from 'simple-git'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { addUpdateFile, emptyGitMock } from '@code-pushup/test-utils'; +import { + getCurrentBranchOrTag, + getHashes, + getLatestCommit, + getSemverTags, +} from './git.commits-and-tags'; + +async function getAllCommits(git: SimpleGit) { + return (await git.log()).all.map(({ hash, message }) => ({ + hash, + message, + })); +} + +describe('getCurrentBranchOrTag', () => { + const baseDir = join(process.cwd(), 'tmp', 'git-tests'); + let currentBranchOrTagGitMock: SimpleGit; + + beforeAll(async () => { + currentBranchOrTagGitMock = await emptyGitMock(simpleGit, { baseDir }); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('without a branch and commits', () => { + it('getCurrentBranchOrTag should throw if no branch or tag is given', async () => { + await expect( + getCurrentBranchOrTag(currentBranchOrTagGitMock), + ).rejects.toThrow('No names found, cannot describe anything'); + }); + }); + + describe('with a branch and commits clean', () => { + beforeAll(async () => { + await addUpdateFile(currentBranchOrTagGitMock, { + baseDir, + commitMsg: 'init commit msg', + }); + await currentBranchOrTagGitMock.checkout(['master']); + }); + + afterAll(async () => { + await currentBranchOrTagGitMock.checkout(['master']); + }); + + it('getCurrentBranchOrTag should log current branch', async () => { + await expect( + getCurrentBranchOrTag(currentBranchOrTagGitMock), + ).resolves.toBe('master'); + }); + }); +}); + +describe('getLatestCommit', () => { + const baseDir = join(process.cwd(), 'tmp', 'git', 'latest-commit'); + let emptyGit: SimpleGit; + + beforeAll(async () => { + emptyGit = await emptyGitMock(simpleGit, { baseDir }); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('with a branch and commits clean', () => { + beforeAll(async () => { + await addUpdateFile(emptyGit, { baseDir, commitMsg: 'Create README' }); + await emptyGit.checkout(['master']); + }); + + afterAll(async () => { + await emptyGit.checkout(['master']); + }); + + it('should log latest commit', async () => { + await expect(getLatestCommit(emptyGit)).resolves.toEqual({ + hash: expect.stringMatching(/^[\da-f]{40}$/), + message: 'Create README', + author: 'John Doe', + date: expect.any(Date), + }); + }); + }); +}); + +describe('getHashes', () => { + const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-hashes'); + let gitMock: SimpleGit; + + beforeAll(async () => { + gitMock = await emptyGitMock(simpleGit, { baseDir }); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('without a branch and commits', () => { + it('should throw', async () => { + await expect(getHashes({}, gitMock)).rejects.toThrow( + "your current branch 'master' does not have any commits yet", + ); + }); + }); + + describe('with a branch and commits clean', () => { + let commits: { hash: string; message: string }[]; + beforeAll(async () => { + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Create README' }); + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 1' }); + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 2' }); + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 3' }); + commits = await getAllCommits(gitMock); + + await gitMock.checkout(['master']); + }); + + afterAll(async () => { + await gitMock.checkout(['master']); + }); + + it('should get all commits from log if no option is passed', async () => { + await expect(getHashes({}, gitMock)).resolves.toStrictEqual(commits); + }); + + it('should get last 2 commits from log if maxCount is set to 2', async () => { + await expect(getHashes({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([ + commits.at(0), + commits.at(1), + ]); + }); + + it('should get commits from log based on "from"', async () => { + await expect( + getHashes({ from: commits.at(-1)?.hash }, gitMock), + ).resolves.toEqual([commits.at(0), commits.at(1), commits.at(2)]); + }); + + it('should get commits from log based on "from" and "to"', async () => { + await expect( + getHashes( + { from: commits.at(-1)?.hash, to: commits.at(1)?.hash }, + gitMock, + ), + ).resolves.toEqual([commits.at(1), commits.at(2)]); + }); + + it('should get commits from log based on "from" and "to" and "maxCount"', async () => { + await expect( + getHashes( + { from: commits.at(-1)?.hash, to: commits.at(1)?.hash, maxCount: 1 }, + gitMock, + ), + ).resolves.toEqual([commits.at(1)]); + }); + + it('should throw if "from" is undefined but "to" is defined', async () => { + await expect( + getHashes({ from: undefined, to: 'a' }, gitMock), + ).rejects.toThrow( + 'filter needs the "from" option defined to accept the "to" option.', + ); + }); + }); +}); + +describe('getSemverTags', () => { + const baseDir = join(process.cwd(), 'tmp', 'git', 'get-semver-tags'); + let gitSemverTagsMock: SimpleGit; + + beforeAll(async () => { + await mkdir(baseDir, { recursive: true }); + gitSemverTagsMock = await emptyGitMock(simpleGit, { baseDir }); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('without a branch and commits', () => { + it('should list no tags on a branch with no tags', async () => { + await expect(getSemverTags({}, gitSemverTagsMock)).rejects.toMatch( + /No names found/, + ); + }); + }); + + describe('with a branch and only commits clean', () => { + beforeAll(async () => { + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'Create README', + }); + await gitSemverTagsMock.checkout(['master']); + }); + + afterAll(async () => { + await gitSemverTagsMock.checkout(['master']); + }); + + it('should list no tags on a branch with no tags', async () => { + await expect(getSemverTags({}, gitSemverTagsMock)).resolves.toStrictEqual( + [], + ); + }); + }); + + describe('with a branch and tagged commits clean', () => { + beforeAll(async () => { + await gitSemverTagsMock.checkout(['master']); + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'Create README', + }); + + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'release v1', + tagName: '1.0.0', + }); + + await gitSemverTagsMock.checkout(['master']); + }); + + afterAll(async () => { + await gitSemverTagsMock.checkout(['master']); + }); + + it('should list all tags on the branch', async () => { + await expect(getSemverTags({}, gitSemverTagsMock)).resolves.toStrictEqual( + [ + { + hash: expect.any(String), + message: '1.0.0', + }, + ], + ); + }); + }); +}); diff --git a/packages/utils/src/lib/git/git.commits-and-tags.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts new file mode 100644 index 000000000..af61eb0f4 --- /dev/null +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -0,0 +1,196 @@ +import { LogOptions as SimpleGitLogOptions, simpleGit } from 'simple-git'; +import { Commit, commitSchema } from '@code-pushup/models'; +import { isSemver } from '../semver'; + +export async function getLatestCommit( + git = simpleGit(), +): Promise { + const log = await git.log({ + maxCount: 1, + // git log -1 --pretty=format:"%H %s %an %aI" - See: https://git-scm.com/docs/pretty-formats + format: { hash: '%H', message: '%s', author: '%an', date: '%aI' }, + }); + return commitSchema.parse(log.latest); +} + +export async function getCurrentBranchOrTag( + git = simpleGit(), +): Promise { + return ( + (await git.branch().then(r => r.current)) || + // If no current branch, try to get the tag + // @TODO use simple git + (await git + .raw(['describe', '--tags', '--exact-match']) + .then(out => out.trim())) + ); +} + +export type LogResult = { hash: string; message: string }; + +function validateFilter({ from, to }: LogOptions) { + if (to && !from) { + // throw more user-friendly error instead of: + // fatal: ambiguous argument '...a': unknown revision or path not in the working tree. + // Use '--' to separate paths from revisions, like this: + // 'git [...] -- [...]' + throw new Error( + `filter needs the "from" option defined to accept the "to" option.\n`, + ); + } +} + +export function filterLogs( + allTags: string[], + opt?: Pick, +) { + if (!opt) { + return allTags; + } + validateFilter(opt); + const { from, to, maxCount } = opt; + const finIndex = (tagName?: string, fallback?: T) => { + const idx = allTags.indexOf(tagName ?? ''); + if (idx > -1) { + return idx; + } + return fallback; + }; + const fromIndex = finIndex(from, 0); + const toIndex = finIndex(to, undefined); + return allTags + .slice(fromIndex, toIndex ? toIndex + 1 : toIndex) + .slice(0, maxCount ?? undefined); +} + +export async function getHashFromTag( + tag: string, + git = simpleGit(), +): Promise { + const tagDetails = await git.show(['--no-patch', '--format=%H', tag]); + const hash = tagDetails.trim(); // Remove quotes and trim whitespace + return { + hash: hash.split('\n').at(-1) ?? '', + message: tag, + }; +} + +export type LogOptions = { + targetBranch?: string; + from?: string; + to?: string; + maxCount?: number; +}; + +export async function getSemverTags( + opt: LogOptions = {}, + git = simpleGit(), +): Promise { + validateFilter(opt); + const { targetBranch, ...options } = opt; + // make sure we have a target branch + // eslint-disable-next-line functional/no-let + let currentBranch; + if (targetBranch) { + currentBranch = await getCurrentBranchOrTag(git); + await git.checkout(targetBranch); + } + + // Fetch all tags merged into the target branch + const tagsRaw = await git.tag([ + '--merged', + targetBranch ?? (await getCurrentBranchOrTag(git)), + ]); + + const allTags = tagsRaw + .split(/\n/) + .map(tag => tag.trim()) + .filter(Boolean) + .filter(isSemver); + + const relevantTags = filterLogs(allTags, options); + + const tagsWithHashes: LogResult[] = await Promise.all( + relevantTags.map(tag => getHashFromTag(tag, git)), + ); + + if (currentBranch) { + await git.checkout(currentBranch); + } + + return tagsWithHashes; +} + +/** + * `getHashes` returns a list of commit hashes. Internally it uses `git.log()` to determine the commits within a range. + * The amount can be limited to a maximum number of commits specified by `maxCount`. + * With `from` and `to`, you can specify a range of commits. + * + * **NOTE:** + * In Git, specifying a range with two dots (`from..to`) selects commits that are reachable from `to` but not from `from`. + * Essentially, it shows the commits that are in `to` but not in `from`, excluding the commits unique to `from`. + * + * Example: + * + * Let's consider the following commit history: + * + * A---B---C---D---E (main) + * + * Using `git log B..D`, you would get the commits C and D: + * + * C---D + * + * This is because these commits are reachable from D but not from B. + * + * ASCII Representation: + * + * Main Branch: A---B---C---D---E + * \ \ + * \ +--- Commits included in `git log B..D` + * \ + * +--- Excluded by the `from` parameter + * + * With `simple-git`, when you specify a `from` and `to` range like this: + * + * git.log({ from: 'B', to: 'D' }); + * + * It interprets it similarly, selecting commits between B and D, inclusive of D but exclusive of B. + * For `git.log({ from: 'B', to: 'D' })` or `git log B..D`, commits C and D are selected. + * + * @param options Object containing `from`, `to`, and optionally `maxCount` to specify the commit range and limit. + * @param git The `simple-git` instance used to execute Git commands. + */ +export async function getHashes( + options: SimpleGitLogOptions & Pick = {}, + git = simpleGit(), +): Promise { + const { targetBranch, from, to, maxCount, ...opt } = options; + + validateFilter({ from, to }); + + // Ensure you are on the correct branch + // eslint-disable-next-line functional/no-let + let currentBranch; + if (targetBranch) { + currentBranch = await getCurrentBranchOrTag(git); + await git.checkout(targetBranch); + } + + const logs = await git.log({ + ...opt, + format: { + hash: '%H', + message: '%s', + }, + from, + to, + maxCount, + }); + + // Ensure you are back to the initial branch + if (targetBranch) { + await git.checkout(currentBranch as string); + } + + return [...logs.all]; +} diff --git a/packages/utils/src/lib/git/git.commits-and-tags.unit.test.ts b/packages/utils/src/lib/git/git.commits-and-tags.unit.test.ts new file mode 100644 index 000000000..0a8e2bebb --- /dev/null +++ b/packages/utils/src/lib/git/git.commits-and-tags.unit.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, vi } from 'vitest'; +import { filterLogs, getSemverTags } from './git.commits-and-tags'; + +vi.mock('simple-git', async () => { + const actual = await vi.importActual('simple-git'); + const orderedTagsHistory = ['5.0.0', '4.0.0', '3.0.0', '2.0.0', '1.0.0']; + return { + ...actual, + simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + // @TODO fix return value + tag: () => Promise.resolve(orderedTagsHistory.join('\n')), + show: ([_, __, tag]: string) => + Promise.resolve(`release v${tag}\n ${tag}`), + raw: () => Promise.resolve('main'), + checkout: () => Promise.resolve(), + log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) => + Promise.resolve({ + all: [ + { hash: 'commit-6' }, + { hash: 'commit-5' }, + { hash: 'commit-4' }, + { hash: 'commit-3' }, + { hash: 'commit-2' }, + { hash: 'commit-1' }, + ].slice(-maxCount), + }), + }), + }; +}); + +describe('filterLogs', () => { + it('should forward list if no filter are given', () => { + const tags = ['cli@0.1.0', 'utils@0.1.0', 'v0.1.0']; + expect( + filterLogs(tags, { from: undefined, to: undefined, maxCount: undefined }), + ).toStrictEqual(tags); + }); + + it('should forward list the first N items based on "maxCount" filter', () => { + expect( + filterLogs(['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0'], { + maxCount: 2, + }), + ).toStrictEqual(['1.0.0', '2.0.0']); + }); + + it('should forward list items starting from index based on "from" filter', () => { + expect( + filterLogs(['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0'], { + from: '3.0.0', + }), + ).toStrictEqual(['3.0.0', '4.0.0', '5.0.0']); + }); + + it('should throw for "to" without "from" filter', () => { + expect(() => filterLogs([], { to: 'e' })).toThrow( + 'filter needs the "from" option defined to accept the "to" option.', + ); + }); + + it('should forward list items starting from index based on "from" & "to" filter', () => { + expect( + filterLogs(['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0'], { + from: '2.0.0', + to: '4.0.0', + }), + ).toStrictEqual(['2.0.0', '3.0.0', '4.0.0']); + }); +}); + +describe('getSemverTags', () => { + it('should list all tags on the branch', async () => { + await expect(getSemverTags({})).resolves.toStrictEqual([ + { + hash: expect.any(String), + message: '5.0.0', + }, + { + hash: expect.any(String), + message: '4.0.0', + }, + { + hash: expect.any(String), + message: '3.0.0', + }, + { + hash: expect.any(String), + message: '2.0.0', + }, + { + hash: expect.any(String), + message: '1.0.0', + }, + ]); + }); + + it('should get last 2 tags from branch if maxCount is set to 2', async () => { + await expect(getSemverTags({ maxCount: 2 })).resolves.toStrictEqual([ + { + hash: expect.any(String), + message: '5.0.0', + }, + { + hash: expect.any(String), + message: '4.0.0', + }, + ]); + }); + + it('should get tags from branch based on "from"', async () => { + await expect(getSemverTags({ from: '4.0.0' })).resolves.toEqual([ + { + hash: expect.any(String), + message: '4.0.0', + }, + { + hash: expect.any(String), + message: '3.0.0', + }, + { + hash: expect.any(String), + message: '2.0.0', + }, + { + hash: expect.any(String), + message: '1.0.0', + }, + ]); + }); + + it('should get tags from branch based on "from" and "to"', async () => { + await expect( + getSemverTags({ from: '4.0.0', to: '2.0.0' }), + ).resolves.toEqual([ + { + hash: expect.any(String), + message: '4.0.0', + }, + { + hash: expect.any(String), + message: '3.0.0', + }, + { + hash: expect.any(String), + message: '2.0.0', + }, + ]); + }); + + it('should get tags from branch based on "from" and "to" and "maxCount"', async () => { + await expect( + getSemverTags({ from: '4.0.0', to: '2.0.0', maxCount: 2 }), + ).resolves.toEqual([ + { + hash: expect.any(String), + message: '4.0.0', + }, + { + hash: expect.any(String), + message: '3.0.0', + }, + ]); + }); + + it('should throw if "from" is undefined but "to" is defined', async () => { + await expect(getSemverTags({ from: undefined, to: 'a' })).rejects.toThrow( + 'filter needs the "from" option defined to accept the "to" option', + ); + }); +}); diff --git a/packages/utils/src/lib/git.integration.test.ts b/packages/utils/src/lib/git/git.integration.test.ts similarity index 87% rename from packages/utils/src/lib/git.integration.test.ts rename to packages/utils/src/lib/git/git.integration.test.ts index c8dbae5e2..c9c260fa5 100644 --- a/packages/utils/src/lib/git.integration.test.ts +++ b/packages/utils/src/lib/git/git.integration.test.ts @@ -1,16 +1,14 @@ import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { type SimpleGit, simpleGit } from 'simple-git'; -import { afterAll, beforeAll, beforeEach, expect } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect } from 'vitest'; +import { toUnixPath } from '../transform'; import { - getCurrentBranchOrTag, getGitRoot, - getLatestCommit, guardAgainstLocalChanges, safeCheckout, toGitPath, } from './git'; -import { toUnixPath } from './transform'; describe('git utils in a git repo', () => { const baseDir = join(process.cwd(), 'tmp', 'git-tests'); @@ -29,12 +27,6 @@ describe('git utils in a git repo', () => { }); describe('without a branch and commits', () => { - it('getCurrentBranchOrTag should throw if no branch or tag is given', async () => { - await expect(getCurrentBranchOrTag(emptyGit)).rejects.toThrow( - 'No names found, cannot describe anything', - ); - }); - it('getGitRoot should return git root in a set up repo', async () => { await expect(getGitRoot(emptyGit)).resolves.toMatch(/tmp\/git-tests$/); }); @@ -55,15 +47,6 @@ describe('git utils in a git repo', () => { await emptyGit.deleteLocalBranch('feature-branch'); }); - it('should log latest commit', async () => { - await expect(getLatestCommit(emptyGit)).resolves.toEqual({ - hash: expect.stringMatching(/^[\da-f]{40}$/), - message: 'Create README', - author: 'John Doe', - date: expect.any(Date), - }); - }); - it('should find Git root', async () => { await expect(getGitRoot(emptyGit)).resolves.toBe(toUnixPath(baseDir)); }); @@ -86,10 +69,6 @@ describe('git utils in a git repo', () => { ); }); - it('getCurrentBranchOrTag should log current branch', async () => { - await expect(getCurrentBranchOrTag(emptyGit)).resolves.toBe('master'); - }); - it('guardAgainstLocalChanges should not throw if history is clean', async () => { await expect(guardAgainstLocalChanges(emptyGit)).resolves.toBeUndefined(); }); diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git/git.ts similarity index 74% rename from packages/utils/src/lib/git.ts rename to packages/utils/src/lib/git/git.ts index 70f8bdd96..737d39cd4 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git/git.ts @@ -1,19 +1,7 @@ import { isAbsolute, join, relative } from 'node:path'; import { StatusResult, simpleGit } from 'simple-git'; -import { Commit, commitSchema } from '@code-pushup/models'; -import { ui } from './logging'; -import { toUnixPath } from './transform'; - -export async function getLatestCommit( - git = simpleGit(), -): Promise { - const log = await git.log({ - maxCount: 1, - // git log -1 --pretty=format:"%H %s %an %aI" - See: https://git-scm.com/docs/pretty-formats - format: { hash: '%H', message: '%s', author: '%an', date: '%aI' }, - }); - return commitSchema.parse(log.latest); -} +import { ui } from '../logging'; +import { toUnixPath } from '../transform'; export function getGitRoot(git = simpleGit()): Promise { return git.revparse('--show-toplevel'); @@ -62,6 +50,7 @@ export class GitStatusError extends Error { ), ); } + constructor(status: StatusResult) { super( `Working directory needs to be clean before we you can proceed. Commit your local changes or stash them: \n ${JSON.stringify( @@ -82,19 +71,6 @@ export async function guardAgainstLocalChanges( } } -export async function getCurrentBranchOrTag( - git = simpleGit(), -): Promise { - return ( - (await git.branch().then(r => r.current)) || - // If no current branch, try to get the tag - // @TODO use simple git - (await git - .raw(['describe', '--tags', '--exact-match']) - .then(out => out.trim())) - ); -} - export async function safeCheckout( branchOrHash: string, forceCleanStatus = false, diff --git a/packages/utils/src/lib/git.unit.test.ts b/packages/utils/src/lib/git/git.unit.test.ts similarity index 100% rename from packages/utils/src/lib/git.unit.test.ts rename to packages/utils/src/lib/git/git.unit.test.ts diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts new file mode 100644 index 000000000..c761dd843 --- /dev/null +++ b/packages/utils/src/lib/semver.ts @@ -0,0 +1,21 @@ +import { rcompare, valid } from 'semver'; + +export function normalizeSemver(semverString: string): string { + if (semverString.startsWith('v') || semverString.startsWith('V')) { + return semverString.slice(1); + } + + if (semverString.includes('@')) { + return semverString.split('@').at(-1) ?? ''; + } + + return semverString; +} + +export function isSemver(semverString = ''): boolean { + return valid(normalizeSemver(semverString)) != null; +} + +export function sortSemvers(semverStrings: string[]): string[] { + return semverStrings.map(normalizeSemver).filter(isSemver).sort(rcompare); +} diff --git a/packages/utils/src/lib/semver.unit.test.ts b/packages/utils/src/lib/semver.unit.test.ts new file mode 100644 index 000000000..b96696570 --- /dev/null +++ b/packages/utils/src/lib/semver.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { isSemver, normalizeSemver, sortSemvers } from './semver'; + +describe('isSemver', () => { + it.each([ + ['v0.0.0'], // (valid as v is removed before check) + ['V0.0.0'], // (valid as V is removed before check) + ['package@1.2.3-alpha'], // (valid as everything before "@" is removed before check) + ['0.0.0'], + ['0.0.0-alpha'], + ['0.0.0-alpha.0'], + ['1.2.3'], + ['11.22.33'], + ['1.2.3-alpha'], + ['11.22.33-alpha'], + ['1.2.3-alpha.4'], + ['11.22.33-alpha.4'], + ['11.22.33-alpha-44'], + ['1.2.3-alpha-4'], + ['11.22.33+alpha.4'], + ])('should return true for a valid semver string: %s', versionString => { + expect(isSemver(versionString)).toBeTruthy(); + }); + + it.each([ + ['11.22+33-alpha.4'], + ['11.22.33-alpha?4'], + ['package-1.2.3-alpha.0'], // (wrong as no @ for prefix) + ['package-11.22.33-alpha.0'], //(wrong package separator) + ])('should return false for a invalid semver string: s%', versionString => { + expect(isSemver(versionString)).toBeFalsy(); + }); +}); + +describe('normalizeSemver', () => { + it.each([['1.0.0'], ['v1.0.0'], ['V1.0.0'], ['core@1.0.0']])( + 'should return normalized semver string: %s', + versionString => { + expect(normalizeSemver(versionString)).toBe('1.0.0'); + }, + ); +}); + +describe('sortSemvers', () => { + it.each([ + [['1.0.0', '1.0.1']], + [['v1.0.0', 'core@1.0.1']], + [['1.0.0-alpha.0', '1.0.1-alpha.0']], + [['1.0.0-alpha.0', '1.0.1']], + ])('should return normalized semver string: %s', semvers => { + expect(sortSemvers(semvers)).toStrictEqual([ + expect.stringContaining('1.0.1'), + expect.stringContaining('1.0.0'), + ]); + }); +}); diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 6e6e7a4db..ae88c8c93 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/utils/execute-process-helper.mock'; export * from './lib/utils/os-agnostic-paths'; export * from './lib/utils/logging'; export * from './lib/utils/env'; +export * from './lib/utils/git'; export * from './lib/utils/string'; // static mocks diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts new file mode 100644 index 000000000..4cc67ff3e --- /dev/null +++ b/testing/test-utils/src/lib/utils/git.ts @@ -0,0 +1,55 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { SimpleGit, SimpleGitFactory } from 'simple-git'; + +export type GitConfig = { name: string; email: string }; + +export async function emptyGitMock( + git: SimpleGitFactory, + opt: { baseDir: string; config?: GitConfig }, +): Promise { + const { baseDir, config } = opt; + const { email = 'john.doe@example.com', name = 'John Doe' } = config ?? {}; + await mkdir(baseDir, { recursive: true }); + const emptyGit = git(baseDir); + await emptyGit.init(); + await emptyGit.addConfig('user.name', name); + await emptyGit.addConfig('user.email', email); + return emptyGit; +} + +export async function addBranch( + git: SimpleGit, + branchName = 'master', +): Promise { + await git.branch([branchName]); + return git; +} + +export async function addUpdateFile( + git: SimpleGit, + opt?: { + file?: { name?: string; content?: string }; + baseDir?: string; + commitMsg?: string; + tagName?: string; + }, +): Promise { + const { + file, + baseDir = '', + commitMsg = 'Create README', + tagName, + } = opt ?? {}; + const { name = 'README.md', content = `# hello-world-${Math.random()}` } = + file ?? {}; + await writeFile(join(baseDir, name), content); + await git.add(name); + if (tagName) { + await git.tag([tagName]); + } + if (commitMsg) { + await git.commit(commitMsg); + } + return git; +}