From d25523e5a230ecb8179208e08b70ec9fee48e9de Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 12 Apr 2024 11:53:54 +0200 Subject: [PATCH 01/34] refactor(cli): adopt history CLI arguments --- packages/cli/src/lib/history/history.model.ts | 1 + packages/cli/src/lib/history/history.options.ts | 6 +++++- packages/cli/src/lib/yargs-cli.integration.test.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/history/history.model.ts b/packages/cli/src/lib/history/history.model.ts index 40197e769..112221baf 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; + semverTag?: string; } & Pick & HistoryOnlyOptions; diff --git a/packages/cli/src/lib/history/history.options.ts b/packages/cli/src/lib/history/history.options.ts index 4f12aec56..432bfb5a2 100644 --- a/packages/cli/src/lib/history/history.options.ts +++ b/packages/cli/src/lib/history/history.options.ts @@ -8,8 +8,12 @@ export function yargsHistoryOptionsDefinition(): Record< return { targetBranch: { describe: 'Branch to crawl history', + type: 'string' + }, + semverTag: { + describe: 'analyse semver tags only', type: 'string', - default: 'main', + default: false, }, forceCleanStatus: { describe: diff --git a/packages/cli/src/lib/yargs-cli.integration.test.ts b/packages/cli/src/lib/yargs-cli.integration.test.ts index 090ab78a4..40a4bb632 100644 --- a/packages/cli/src/lib/yargs-cli.integration.test.ts +++ b/packages/cli/src/lib/yargs-cli.integration.test.ts @@ -149,6 +149,7 @@ describe('yargsCli', () => { expect(result).toEqual( expect.objectContaining({ + semverTag: false, targetBranch: 'main', maxCount: 5, skipUploads: false, @@ -165,7 +166,6 @@ describe('yargsCli', () => { expect.objectContaining({ targetBranch: 'main', maxCount: 2, - skipUploads: false, }), ); }); From c16f061e8093ce309faaa0b0144f2a0e4f51683a Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 11:31:18 +0200 Subject: [PATCH 02/34] refactor(cli): adopt history CLI arguments --- package-lock.json | 6 + package.json | 1 + .../cli/src/lib/history/history-command.ts | 47 ++-- .../lib/history/history-command.unit.test.ts | 19 +- packages/cli/src/lib/history/history.model.ts | 2 +- .../cli/src/lib/history/history.options.ts | 4 +- .../cli/src/lib/yargs-cli.integration.test.ts | 2 - packages/core/src/index.ts | 7 +- .../core/src/lib/history.integration.test.ts | 101 -------- packages/core/src/lib/history.ts | 74 ------ packages/core/src/lib/history.unit.test.ts | 45 +--- packages/utils/src/index.ts | 3 + .../utils/src/lib/git.integration.test.ts | 223 +++++++++++++++++- packages/utils/src/lib/git.ts | 139 ++++++++++- packages/utils/src/lib/git.unit.test.ts | 8 +- packages/utils/src/lib/semver.ts | 27 +++ packages/utils/src/lib/semver.unit.test.ts | 94 ++++++++ 17 files changed, 534 insertions(+), 268 deletions(-) delete mode 100644 packages/core/src/lib/history.integration.test.ts create mode 100644 packages/utils/src/lib/semver.ts create mode 100644 packages/utils/src/lib/semver.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 40397d8e0..c7987b986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "bundle-require": "^4.0.1", "chalk": "^5.3.0", "cli-table3": "^0.6.3", + "compare-versions": "^6.1.0", "esbuild": "^0.19.2", "multi-progress-bars": "^5.0.3", "parse-lcov": "^1.0.4", @@ -10065,6 +10066,11 @@ "dot-prop": "^5.1.0" } }, + "node_modules/compare-versions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" + }, "node_modules/compressible": { "version": "2.0.18", "dev": true, diff --git a/package.json b/package.json index 6de5fbb4d..f120be7a2 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "bundle-require": "^4.0.1", "chalk": "^5.3.0", "cli-table3": "^0.6.3", + "compare-versions": "^6.1.0", "esbuild": "^0.19.2", "multi-progress-bars": "^5.0.3", "parse-lcov": "^1.0.4", diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 8e07a02f7..6c19420b7 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,7 +1,14 @@ 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 { 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'; @@ -12,23 +19,13 @@ export function yargsHistoryCommandObject() { return { command, describe: 'Collect reports for commit history', - builder: yargs => { - yargs.options({ - ...yargsHistoryOptionsDefinition(), - ...yargsOnlyPluginsOptionsDefinition(), - }); - yargs.group( - Object.keys(yargsHistoryOptionsDefinition()), - 'History Options:', - ); - 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 { + semverTag, targetBranch = currentBranch, forceCleanStatus, maxCount, @@ -38,7 +35,16 @@ export function yargsHistoryCommandObject() { } = args as unknown as HistoryCliOptions & HistoryOptions; // determine history to walk - const commits: string[] = await getHashes({ maxCount, from, to }); + const results: LogResult[] = semverTag + ? await getSemverTags({ targetBranch, maxCount }) + : await getHashes({ targetBranch, maxCount, from, to }); + + ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + results.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); + + // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); +return; try { // run history logic const reports = await history( @@ -47,7 +53,7 @@ export function yargsHistoryCommandObject() { targetBranch, forceCleanStatus, }, - commits, + results.map(({ hash, tagName }) => tagName ?? hash), ); ui().logger.log(`Reports: ${reports.length}`); @@ -56,5 +62,16 @@ export function yargsHistoryCommandObject() { await safeCheckout(currentBranch); } }, + builder: yargs => { + yargs.options({ + ...yargsHistoryOptionsDefinition(), + ...yargsOnlyPluginsOptionsDefinition(), + }); + yargs.group( + Object.keys(yargsHistoryOptionsDefinition()), + 'History Options:', + ); + return yargs; + }, } 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..211cc49cc 100644 --- a/packages/cli/src/lib/history/history-command.unit.test.ts +++ b/packages/cli/src/lib/history/history-command.unit.test.ts @@ -37,6 +37,9 @@ vi.mock('simple-git', async () => { return { ...actual, simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + raw: () => Promise.resolve('main'), + checkout: () => Promise.resolve(), log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) => Promise.resolve({ all: [ @@ -53,22 +56,6 @@ vi.mock('simple-git', async () => { }); describe('history-command', () => { - it('should return the last 5 commits', async () => { - await yargsCli(['history', '--config=/test/code-pushup.config.ts'], { - ...DEFAULT_CLI_CONFIGURATION, - commands: [yargsHistoryCommandObject()], - }).parseAsync(); - - expect(history).toHaveBeenCalledWith( - expect.objectContaining({ - targetBranch: 'main', - }), - ['commit-1', 'commit-2', 'commit-3', 'commit-4', 'commit-5'], - ); - - 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'], diff --git a/packages/cli/src/lib/history/history.model.ts b/packages/cli/src/lib/history/history.model.ts index 112221baf..7a10955ab 100644 --- a/packages/cli/src/lib/history/history.model.ts +++ b/packages/cli/src/lib/history/history.model.ts @@ -3,6 +3,6 @@ import { HistoryOnlyOptions } from '@code-pushup/core'; export type HistoryCliOptions = { targetBranch?: string; - semverTag?: string; + semverTag?: boolean; } & Pick & HistoryOnlyOptions; diff --git a/packages/cli/src/lib/history/history.options.ts b/packages/cli/src/lib/history/history.options.ts index 432bfb5a2..fad19a832 100644 --- a/packages/cli/src/lib/history/history.options.ts +++ b/packages/cli/src/lib/history/history.options.ts @@ -8,11 +8,11 @@ export function yargsHistoryOptionsDefinition(): Record< return { targetBranch: { describe: 'Branch to crawl history', - type: 'string' + type: 'string', }, semverTag: { describe: 'analyse semver tags only', - type: 'string', + type: 'boolean', default: false, }, forceCleanStatus: { diff --git a/packages/cli/src/lib/yargs-cli.integration.test.ts b/packages/cli/src/lib/yargs-cli.integration.test.ts index 40a4bb632..7ac91df13 100644 --- a/packages/cli/src/lib/yargs-cli.integration.test.ts +++ b/packages/cli/src/lib/yargs-cli.integration.test.ts @@ -150,7 +150,6 @@ describe('yargsCli', () => { expect(result).toEqual( expect.objectContaining({ semverTag: false, - targetBranch: 'main', maxCount: 5, skipUploads: false, }), @@ -164,7 +163,6 @@ describe('yargsCli', () => { expect(result).toEqual( expect.objectContaining({ - targetBranch: 'main', maxCount: 2, }), ); 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..0b18e4bc8 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 () => { @@ -110,46 +110,3 @@ describe('history', () => { }); }); -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/src/index.ts b/packages/utils/src/index.ts index c87232154..421e0699e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -44,6 +44,8 @@ export { getLatestCommit, safeCheckout, toGitPath, + getSemverTags, + LogResult, } from './lib/git'; export { groupByStatus } from './lib/group-by-status'; export { @@ -98,3 +100,4 @@ export { toUnixPath, } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; +export { getHashes } from './lib/git'; diff --git a/packages/utils/src/lib/git.integration.test.ts b/packages/utils/src/lib/git.integration.test.ts index c8dbae5e2..d7f395343 100644 --- a/packages/utils/src/lib/git.integration.test.ts +++ b/packages/utils/src/lib/git.integration.test.ts @@ -1,12 +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 { getCurrentBranchOrTag, getGitRoot, + getHashes, getLatestCommit, - guardAgainstLocalChanges, + getSemverTags, + guardAgainstLocalChanges, prepareHashes, safeCheckout, toGitPath, } from './git'; @@ -110,6 +112,7 @@ describe('git utils in a git repo', () => { "pathspec 'non-existing-branch' did not match any file(s) known to git", ); }); + }); describe('with a branch and commits dirty', () => { @@ -204,3 +207,219 @@ describe('git utils in a git repo', () => { }); }); }); + + +describe('getHashes', () => { + const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-hashes'); + 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', () => { + let commits: { hash: string, message: 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 as { hash: string, message: string }); + + 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 as { hash: string, message: string }); + + 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 as { hash: string, message: string }); + + await gitMock.branch(['feature-branch']); + await gitMock.checkout(['master']); + commits = commits.map(({hash, message}) => ({hash, message})); + }); + + 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)?.hash }, 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)?.hash, to: commits.at(0)?.hash }, 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)?.hash, to: commits.at(0)?.hash, 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.', + ); + }); + }); +}); + + +describe('getSemverTags', () => { + const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-semver-tags'); + 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(getSemverTags({}, gitMock)).rejects.toThrow( + "your current branch 'master' does not have any commits yet", + ); + }); + + + it('should list no tags on a branch with no tags', async () => { + await expect(getSemverTags({}, gitMock)).resolves.toStrictEqual([]); + }); + }); + + describe('with a branch and commits clean', () => { + let commits: { hash: string, message: 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 as { hash: string, message: string }); + + 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 as { hash: string, message: string }); + + 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 as { hash: string, message: string }); + + await gitMock.branch(['feature-branch']); + await gitMock.checkout(['master']); + commits = commits.map(({hash, message}) => ({hash, message})); + }); + + afterAll(async () => { + await gitMock.checkout(['master']); + await gitMock.deleteLocalBranch('feature-branch'); + }); + it('should list all tags on the branch', async () => { + await expect(getSemverTags({}, emptyGit)).resolves.toStrictEqual([ + { + hash: expect.any(String), + message: 'v1.0.0', + }, + { + hash: expect.any(String), + message: 'core@1.0.2', + }, + { + hash: expect.any(String), + message: '1.0.1', + }, + ]); + }); + it('should get all commits from log if no option is passed', async () => { + await expect(getSemverTags({}, gitMock)).resolves.toStrictEqual(commits); + }); + + it('should get last 2 commits from log if maxCount is set to 2', async () => { + await expect(getSemverTags({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([ + commits.at(-2), + commits.at(-1), + ]); + }); + + it('should get commits from log based on "from"', async () => { + await expect( + getSemverTags({ from: commits.at(0)?.hash }, gitMock), + ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + }); + + it('should get commits from log based on "from" and "to"', async () => { + await expect( + getSemverTags({ from: commits.at(-1)?.hash, to: commits.at(0)?.hash }, gitMock), + ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + }); + + it('should get commits from log based on "from" and "to" and "maxCount"', async () => { + await expect( + getSemverTags( + { from: commits.at(-1)?.hash, to: commits.at(0)?.hash, maxCount: 1 }, + gitMock, + ), + ).resolves.toEqual([commits.at(-1)]); + }); + + it('should throw if "from" is undefined but "to" is defined', async () => { + await expect( + getSemverTags({ from: undefined, to: 'a' }, gitMock), + ).rejects.toThrow( + 'git log command needs the "from" option defined to accept the "to" option.', + ); + }); + }); +}); diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 70f8bdd96..324a02f73 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -1,8 +1,11 @@ import { isAbsolute, join, relative } from 'node:path'; -import { StatusResult, simpleGit } from 'simple-git'; +import { LogOptions, StatusResult, simpleGit } from 'simple-git'; +import type { DefaultLogFields } from 'simple-git/dist/src/lib/tasks/log'; +import { ListLogLine } from 'simple-git/dist/typings/response'; import { Commit, commitSchema } from '@code-pushup/models'; import { ui } from './logging'; -import { toUnixPath } from './transform'; +import { isSemver } from './semver'; +import { objectToCliArgs, toUnixPath } from './transform'; export async function getLatestCommit( git = simpleGit(), @@ -62,6 +65,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( @@ -109,3 +113,134 @@ export async function safeCheckout( await guardAgainstLocalChanges(git); await git.checkout(branchOrHash); } + +export type LogResult = { hash: string; message: string; tagName?: string }; +export async function getSemverTags( + { maxCount, targetBranch }: { targetBranch?: string; maxCount?: number } = {}, + git = simpleGit(), +): Promise { + // make sure we have a target branch + let currentBranch; + if (targetBranch) { + currentBranch = await getCurrentBranchOrTag(git); + // await git.checkout(targetBranch); + } else { + targetBranch = await getCurrentBranchOrTag(git); + } + + // Fetch all tags merged into the target branch + const tagsRaw = await git.tag(['--merged', targetBranch]); + const allTags = tagsRaw + .split('\n') + .map(tag => tag.trim()) + .filter(Boolean) + .filter(isSemver); + //ui().logger.info(JSON.stringify(allTags)) + const tagsWithHashes: LogResult[] = []; + for (const tag of allTags) { + // Fetch commit hash for each tag + // format:{ + // hash: '%H', + // message: '%s' + // } + const tagDetails = await git.show(['--no-patch', '--format=%H', tag]); + const hash = tagDetails.trim(); // Remove quotes and trim whitespace + tagsWithHashes.push({ + hash: hash?.split('\n').at(-1), + message: tag, + } as LogResult); + } + + // Apply maxCount limit if specified + return prepareHashes(maxCount == null ? tagsWithHashes : tagsWithHashes.slice(0, maxCount)); +} + +/** + * `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 & { targetBranch?: string } = {}, + git = simpleGit(), +): Promise { + const { targetBranch, from, to, maxCount, ...opt } = 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`, + ); + } + + // Ensure you are on the correct branch + 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 prepareHashes(Array.from(logs.all)); +} + +export function prepareHashes( + logs: { hash: string; message: string }[], +): { hash: string; message: string }[] { + return logs + .map(({ hash, message }) => ({hash, message})) + // sort from oldest to newest + .reverse(); +} diff --git a/packages/utils/src/lib/git.unit.test.ts b/packages/utils/src/lib/git.unit.test.ts index 8d353291f..a9f731d92 100644 --- a/packages/utils/src/lib/git.unit.test.ts +++ b/packages/utils/src/lib/git.unit.test.ts @@ -1,6 +1,8 @@ -import { SimpleGit, StatusResult } from 'simple-git'; -import { describe, expect } from 'vitest'; -import { GitStatusError, guardAgainstLocalChanges } from './git'; +import {simpleGit, SimpleGit, StatusResult} from 'simple-git'; +import {afterAll, beforeAll, describe, expect, vi} from 'vitest'; +import {getHashes, GitStatusError, guardAgainstLocalChanges} from './git'; +import {join} from "node:path"; +import {mkdir, rm, writeFile} from "node:fs/promises"; describe('guardAgainstLocalChanges', () => { it('should throw if no files are present', async () => { diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts new file mode 100644 index 000000000..ffb1d5f47 --- /dev/null +++ b/packages/utils/src/lib/semver.ts @@ -0,0 +1,27 @@ +import { compare, validate } from 'compare-versions'; + +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: string): boolean { + return validate(normalizeSemver(semverString)); +} + +export function sortSemvers(semverStrings: string[]): string[] { + return semverStrings + .filter(Boolean) + .filter(isSemver) + .sort((a, b) => + compare(normalizeSemver(a), normalizeSemver(b), '<=') ? -1 : 0, + ) + .reverse(); +} 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..0458b780a --- /dev/null +++ b/packages/utils/src/lib/semver.unit.test.ts @@ -0,0 +1,94 @@ +import { validate } from 'compare-versions'; +import { describe, expect, it } from 'vitest'; +import { isSemver, normalizeSemver, sortSemvers } from './semver'; + +describe('semver-compare validate', () => { + it.each([ + ['v0'], + ['0'], + ['0.0'], + ['00.00.00'], + ['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 match on a valid semver string: %s', versionString => { + expect(validate(versionString)).toBeTruthy(); + }); + + it.each([ + ['V0'], + ['package@1.2.3-alpha'], + ['11.22+33-alpha.4'], // (wrong patch separator) + ['11.22.33-alpha?4'], // (wrong prerelease separator) + ['package-1.2.3-alpha.0'], // (wrong as no @ for prefix) + ['package-11.22.33-alpha.0'], //(wrong package separator) + ])('should not match on a invalid semver string: %s', versionString => { + expect(validate(versionString)).toBeFalsy(); + }); +}); + +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'], + ['00.00.00'], + ['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([['0.0.0'], ['v0.0.0'], ['V0.0.0'], ['core@0.0.0']])( + 'should return normalized semver string: %s', + versionString => { + expect(normalizeSemver(versionString)).toBe('0.0.0'); + }, + ); +}); + +describe('sortSemvers', () => { + it.each([ + [['0.0.0', '0.0.1']], + [['v0.0.0', 'core@0.0.1']], + [['0.0.0-alpha.0', '0.0.1-alpha.0']], + [['0.0.0-alpha.0', '0.0.1']], + ])('should return normalized semver string: %s', semvers => { + expect(sortSemvers(semvers)).toStrictEqual([ + expect.stringContaining('0.0.1'), + expect.stringContaining('0.0.0'), + ]); + }); +}); From 5a09d6f201f11d03c6e8d642193947829b140a4a Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 11:46:00 +0200 Subject: [PATCH 03/34] wip --- packages/cli/src/lib/history/history-command.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 6c19420b7..a6afc99d9 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -24,7 +24,7 @@ export function yargsHistoryCommandObject() { ui().logger.info(chalk.gray(`Run ${command}`)); const currentBranch = await getCurrentBranchOrTag(); - const { + let { semverTag, targetBranch = currentBranch, forceCleanStatus, @@ -35,9 +35,17 @@ export function yargsHistoryCommandObject() { } = args as unknown as HistoryCliOptions & HistoryOptions; // determine history to walk - const results: LogResult[] = semverTag - ? await getSemverTags({ targetBranch, maxCount }) - : await getHashes({ targetBranch, maxCount, from, to }); + if(semverTag) { + const tagHash = (await getSemverTags({ targetBranch })).find(({hash}) => hash === from)?.hash; + if(tagHash == null) { + ui().logger.info(`could not find hash for tag ${from}`) + } else { + from = tagHash; + } + } + const results: LogResult[] = await getHashes({ targetBranch, from, to }) + // semverTag ? await getSemverTags({ targetBranch, maxCount }) + // : await getHashes({ targetBranch, maxCount, from, to }); ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) results.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); From 83dc094d1f4de025cb164d846816e08b453e3977 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 11:47:48 +0200 Subject: [PATCH 04/34] wip --- packages/cli/src/lib/history/history-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index a6afc99d9..c54ddbd36 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -43,7 +43,7 @@ export function yargsHistoryCommandObject() { from = tagHash; } } - const results: LogResult[] = await getHashes({ targetBranch, from, to }) + const results: LogResult[] = await getHashes({ targetBranch, from, to, maxCount }) // semverTag ? await getSemverTags({ targetBranch, maxCount }) // : await getHashes({ targetBranch, maxCount, from, to }); From 6c2e845e92ec2cc074f061b744783aabb8e69755 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 11:59:06 +0200 Subject: [PATCH 05/34] wip --- .../cli/src/lib/history/history-command.ts | 165 ++++++++++-------- packages/utils/src/index.ts | 1 + packages/utils/src/lib/semver.ts | 2 +- 3 files changed, 93 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index c54ddbd36..81fa66d7a 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,85 +1,102 @@ import chalk from 'chalk'; -import { ArgumentsCamelCase, CommandModule } from 'yargs'; -import { HistoryOptions, history } from '@code-pushup/core'; +import {ArgumentsCamelCase, CommandModule} from 'yargs'; +import {HistoryOptions, history} from '@code-pushup/core'; import { - LogResult, - getCurrentBranchOrTag, - getHashes, - getSemverTags, - safeCheckout, - ui, + LogResult, + getCurrentBranchOrTag, + getHashes, + getSemverTags, + safeCheckout, + ui, + isSemver } 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 {CLI_NAME} from '../constants'; +import {yargsOnlyPluginsOptionsDefinition} from '../implementation/only-plugins.options'; +import {HistoryCliOptions} from './history.model'; +import {yargsHistoryOptionsDefinition} from './history.options'; export function yargsHistoryCommandObject() { - const command = 'history'; - return { - command, - describe: 'Collect reports for commit history', - handler: async (args: ArgumentsCamelCase) => { - ui().logger.info(chalk.bold(CLI_NAME)); - ui().logger.info(chalk.gray(`Run ${command}`)); + const command = 'history'; + return { + command, + describe: 'Collect reports for commit history', + handler: async (args: ArgumentsCamelCase) => { + ui().logger.info(chalk.bold(CLI_NAME)); + ui().logger.info(chalk.gray(`Run ${command}`)); - const currentBranch = await getCurrentBranchOrTag(); - let { - semverTag, - targetBranch = currentBranch, - forceCleanStatus, - maxCount, - from, - to, - ...restOptions - } = args as unknown as HistoryCliOptions & HistoryOptions; + const currentBranch = await getCurrentBranchOrTag(); + let { + semverTag, + targetBranch = currentBranch, + forceCleanStatus, + maxCount, + from, + to, + ...restOptions + } = args as unknown as HistoryCliOptions & HistoryOptions; - // determine history to walk - if(semverTag) { - const tagHash = (await getSemverTags({ targetBranch })).find(({hash}) => hash === from)?.hash; - if(tagHash == null) { - ui().logger.info(`could not find hash for tag ${from}`) - } else { - from = tagHash; - } - } - const results: LogResult[] = await getHashes({ targetBranch, from, to, maxCount }) - // semverTag ? await getSemverTags({ targetBranch, maxCount }) - // : await getHashes({ targetBranch, maxCount, from, to }); + // turn tags into hashes + if (isSemver(from) || isSemver(to)) { + const tags = (await getSemverTags({targetBranch})); + // ui().logger.log(JSON.stringify(tags)) + if (from) { + const tagHash = tags.find(({hash}) => hash === from)?.hash; + if (tagHash == null) { + ui().logger.info(`could not find hash for tag ${from}`) + } else { + from = tagHash; + } + } + if (to) { + const tagHash = tags.find(({hash}) => hash === to)?.hash; + if (tagHash == null) { + ui().logger.info(`could not find hash for tag ${to}`) + } else { + to = tagHash; + } + } + } + const results: LogResult[] = await getHashes({targetBranch, from, to, maxCount}) + // semverTag ? await getSemverTags({ targetBranch, maxCount }) + // : await getHashes({ targetBranch, maxCount, from, to }); - ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - results.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); + ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + results.forEach(({ + hash, + message, + tagName + }) => ui().logger.info(`${hash} - ${tagName ? tagName : message.slice(0, 55)}`)); - // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); -return; - try { - // run history logic - const reports = await history( - { - ...restOptions, - targetBranch, - forceCleanStatus, - }, - results.map(({ hash, tagName }) => tagName ?? hash), - ); + // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); + return; + try { + // run history logic + const reports = await history( + { + ...restOptions, + targetBranch, + forceCleanStatus, + }, + results.map(({hash, tagName}) => tagName ?? hash), + ); - ui().logger.log(`Reports: ${reports.length}`); - } finally { - // go back to initial branch - await safeCheckout(currentBranch); - } - }, - builder: yargs => { - yargs.options({ - ...yargsHistoryOptionsDefinition(), - ...yargsOnlyPluginsOptionsDefinition(), - }); - yargs.group( - Object.keys(yargsHistoryOptionsDefinition()), - 'History Options:', - ); - return yargs; - }, - } satisfies CommandModule; + ui().logger.log(`Reports: ${reports.length}`); + } finally { + // go back to initial branch + await safeCheckout(currentBranch); + } + }, + builder: yargs => { + yargs.options({ + ...yargsHistoryOptionsDefinition(), + ...yargsOnlyPluginsOptionsDefinition(), + }); + yargs.group( + Object.keys(yargsHistoryOptionsDefinition()), + 'History Options:', + ); + return yargs; + }, + } satisfies CommandModule; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 421e0699e..50d4996b2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -101,3 +101,4 @@ export { } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; export { getHashes } from './lib/git'; +export { isSemver, normalizeSemver, sortSemvers } from './lib/semver'; diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index ffb1d5f47..ace297950 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -12,7 +12,7 @@ export function normalizeSemver(semverString: string): string { return semverString; } -export function isSemver(semverString: string): boolean { +export function isSemver(semverString: string = ''): boolean { return validate(normalizeSemver(semverString)); } From fc6fba494d20d9e75fd1bb7aebb7c5ee23a4d61a Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:00:57 +0200 Subject: [PATCH 06/34] wip --- packages/cli/src/lib/history/history-command.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 81fa66d7a..bfddd39b2 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -36,10 +36,10 @@ export function yargsHistoryCommandObject() { } = args as unknown as HistoryCliOptions & HistoryOptions; // turn tags into hashes - if (isSemver(from) || isSemver(to)) { + if (isSemver(from) || isSemver(to)) { const tags = (await getSemverTags({targetBranch})); // ui().logger.log(JSON.stringify(tags)) - if (from) { + if (isSemver(from)) { const tagHash = tags.find(({hash}) => hash === from)?.hash; if (tagHash == null) { ui().logger.info(`could not find hash for tag ${from}`) @@ -47,7 +47,7 @@ export function yargsHistoryCommandObject() { from = tagHash; } } - if (to) { + if (isSemver(to)) { const tagHash = tags.find(({hash}) => hash === to)?.hash; if (tagHash == null) { ui().logger.info(`could not find hash for tag ${to}`) From a29c7a78cc61b3ef14bbdd3715d16b43aaa90052 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:02:07 +0200 Subject: [PATCH 07/34] wip --- packages/cli/src/lib/history/history-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index bfddd39b2..ff54eac26 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -56,7 +56,7 @@ export function yargsHistoryCommandObject() { } } } - const results: LogResult[] = await getHashes({targetBranch, from, to, maxCount}) + const results: LogResult[] = await getHashes({targetBranch, from, to, maxCount: maxCount && maxCount > 0 ? maxCount : undefined}) // semverTag ? await getSemverTags({ targetBranch, maxCount }) // : await getHashes({ targetBranch, maxCount, from, to }); From 314de2e76cf9763043b7ffe7d74591bdc4dd034d Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:04:36 +0200 Subject: [PATCH 08/34] wip --- packages/utils/src/lib/git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 324a02f73..c0a17ec27 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -242,5 +242,5 @@ export function prepareHashes( return logs .map(({ hash, message }) => ({hash, message})) // sort from oldest to newest - .reverse(); + // .reverse(); } From 92fdddf869da7f81911a8a4edfafcae4f3aaa8ad Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:08:28 +0200 Subject: [PATCH 09/34] wip --- packages/cli/src/lib/history/history-command.ts | 7 ++++--- packages/utils/src/lib/git.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index ff54eac26..5efd9f866 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -56,9 +56,10 @@ export function yargsHistoryCommandObject() { } } } - const results: LogResult[] = await getHashes({targetBranch, from, to, maxCount: maxCount && maxCount > 0 ? maxCount : undefined}) - // semverTag ? await getSemverTags({ targetBranch, maxCount }) - // : await getHashes({ targetBranch, maxCount, from, to }); + + + const results: LogResult[] = semverTag ? await getSemverTags({ targetBranch, maxCount: maxCount && maxCount > 0 ? maxCount : undefined }) + : await getHashes({ targetBranch, from, to, maxCount: maxCount && maxCount > 0 ? maxCount : undefined }); ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) results.forEach(({ diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index c0a17ec27..9e7bca5ad 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -241,6 +241,6 @@ export function prepareHashes( ): { hash: string; message: string }[] { return logs .map(({ hash, message }) => ({hash, message})) - // sort from oldest to newest + // sort from oldest to newest @TODO => question this // .reverse(); } From b46b07fe6c1c47990a7d054aaba2a416c565dc27 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:17:27 +0200 Subject: [PATCH 10/34] wip --- packages/utils/src/lib/git.ts | 42 +++++++++++++++++------------------ 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 9e7bca5ad..5307eef25 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -1,11 +1,11 @@ -import { isAbsolute, join, relative } from 'node:path'; -import { LogOptions, StatusResult, simpleGit } from 'simple-git'; -import type { DefaultLogFields } from 'simple-git/dist/src/lib/tasks/log'; -import { ListLogLine } from 'simple-git/dist/typings/response'; -import { Commit, commitSchema } from '@code-pushup/models'; -import { ui } from './logging'; -import { isSemver } from './semver'; -import { objectToCliArgs, toUnixPath } from './transform'; +import {isAbsolute, join, relative} from 'node:path'; +import {LogOptions, StatusResult, simpleGit} from 'simple-git'; +import type {DefaultLogFields} from 'simple-git/dist/src/lib/tasks/log'; +import {ListLogLine} from 'simple-git/dist/typings/response'; +import {Commit, commitSchema} from '@code-pushup/models'; +import {ui} from './logging'; +import {isSemver} from './semver'; +import {objectToCliArgs, toUnixPath} from './transform'; export async function getLatestCommit( git = simpleGit(), @@ -13,7 +13,7 @@ export async function getLatestCommit( 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' }, + format: {hash: '%H', message: '%s', author: '%an', date: '%aI'}, }); return commitSchema.parse(log.latest); } @@ -47,7 +47,7 @@ export class GitStatusError extends Error { ( entry: [ string, - number | string | boolean | null | undefined | unknown[], + number | string | boolean | null | undefined | unknown[], ], ) => { const value = entry[1]; @@ -115,8 +115,9 @@ export async function safeCheckout( } export type LogResult = { hash: string; message: string; tagName?: string }; + export async function getSemverTags( - { maxCount, targetBranch }: { targetBranch?: string; maxCount?: number } = {}, + {maxCount, targetBranch, from}: { targetBranch?: string; from?: string; maxCount?: number } = {}, git = simpleGit(), ): Promise { // make sure we have a target branch @@ -135,14 +136,11 @@ export async function getSemverTags( .map(tag => tag.trim()) .filter(Boolean) .filter(isSemver); + const finIndex = (tagName: string = '', fallback: number | undefined = 0): number | undefined => isSemver(tagName) ? allTags.findIndex((tag) => tag === tagName) : fallback; + const relevantTags = allTags.slice(finIndex(from), finIndex(from, undefined)).slice(0, maxCount); //ui().logger.info(JSON.stringify(allTags)) const tagsWithHashes: LogResult[] = []; - for (const tag of allTags) { - // Fetch commit hash for each tag - // format:{ - // hash: '%H', - // message: '%s' - // } + for (const tag of relevantTags) { const tagDetails = await git.show(['--no-patch', '--format=%H', tag]); const hash = tagDetails.trim(); // Remove quotes and trim whitespace tagsWithHashes.push({ @@ -152,7 +150,7 @@ export async function getSemverTags( } // Apply maxCount limit if specified - return prepareHashes(maxCount == null ? tagsWithHashes : tagsWithHashes.slice(0, maxCount)); + return prepareHashes(maxCount == null ? tagsWithHashes : tagsWithHashes.slice(0, maxCount)); } /** @@ -198,7 +196,7 @@ export async function getHashes( options: LogOptions & { targetBranch?: string } = {}, git = simpleGit(), ): Promise { - const { targetBranch, from, to, maxCount, ...opt } = options; + const {targetBranch, from, to, maxCount, ...opt} = options; if (to && !from) { // throw more user-friendly error instead of: @@ -240,7 +238,7 @@ export function prepareHashes( logs: { hash: string; message: string }[], ): { hash: string; message: string }[] { return logs - .map(({ hash, message }) => ({hash, message})) - // sort from oldest to newest @TODO => question this - // .reverse(); + .map(({hash, message}) => ({hash, message})) + // sort from oldest to newest @TODO => question this + // .reverse(); } From 9a777834a2a1faa20cdb82e6670227a35844dff6 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:18:50 +0200 Subject: [PATCH 11/34] wip --- packages/cli/src/lib/history/history-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 5efd9f866..f564a4739 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -29,7 +29,7 @@ export function yargsHistoryCommandObject() { semverTag, targetBranch = currentBranch, forceCleanStatus, - maxCount, + maxCount = 0, from, to, ...restOptions From 6cb1afa7183ff191e7be40c4628e4a85be4ace42 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:29:11 +0200 Subject: [PATCH 12/34] wip --- packages/utils/src/lib/git.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 5307eef25..a51a4e5d8 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -1,11 +1,9 @@ import {isAbsolute, join, relative} from 'node:path'; -import {LogOptions, StatusResult, simpleGit} from 'simple-git'; -import type {DefaultLogFields} from 'simple-git/dist/src/lib/tasks/log'; -import {ListLogLine} from 'simple-git/dist/typings/response'; +import {LogOptions, simpleGit, StatusResult} from 'simple-git'; import {Commit, commitSchema} from '@code-pushup/models'; import {ui} from './logging'; import {isSemver} from './semver'; -import {objectToCliArgs, toUnixPath} from './transform'; +import {toUnixPath} from './transform'; export async function getLatestCommit( git = simpleGit(), @@ -115,9 +113,13 @@ export async function safeCheckout( } export type LogResult = { hash: string; message: string; tagName?: string }; +export function filterLogs(allTags: string[], {from, to, maxCount}: Pick) { + const finIndex = (tagName: string = '', fallback: number | undefined = 0): number | undefined => isSemver(tagName) ? allTags.findIndex((tag) => tag === tagName) : fallback; + return allTags.slice(finIndex(from), finIndex(to, undefined)).slice(0, maxCount); +} export async function getSemverTags( - {maxCount, targetBranch, from}: { targetBranch?: string; from?: string; maxCount?: number } = {}, + {targetBranch, ...opt}: { targetBranch?: string; from?: string; maxCount?: number } = {}, git = simpleGit(), ): Promise { // make sure we have a target branch @@ -136,8 +138,9 @@ export async function getSemverTags( .map(tag => tag.trim()) .filter(Boolean) .filter(isSemver); - const finIndex = (tagName: string = '', fallback: number | undefined = 0): number | undefined => isSemver(tagName) ? allTags.findIndex((tag) => tag === tagName) : fallback; - const relevantTags = allTags.slice(finIndex(from), finIndex(from, undefined)).slice(0, maxCount); + + const relevantTags = filterLogs(allTags, opt) + //ui().logger.info(JSON.stringify(allTags)) const tagsWithHashes: LogResult[] = []; for (const tag of relevantTags) { @@ -150,7 +153,7 @@ export async function getSemverTags( } // Apply maxCount limit if specified - return prepareHashes(maxCount == null ? tagsWithHashes : tagsWithHashes.slice(0, maxCount)); + return prepareHashes(tagsWithHashes); } /** From 4378b372378f9bc2a225c2c208fc4a0bbc2eba70 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 12:30:10 +0200 Subject: [PATCH 13/34] wip --- packages/utils/src/lib/git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index a51a4e5d8..4c9def1f1 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -139,7 +139,7 @@ export async function getSemverTags( .filter(Boolean) .filter(isSemver); - const relevantTags = filterLogs(allTags, opt) + const relevantTags = allTags; //filterLogs(allTags, opt) //ui().logger.info(JSON.stringify(allTags)) const tagsWithHashes: LogResult[] = []; From 66a6d86b8d458af9ce156a416e7c7846962b24be Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:14:19 +0200 Subject: [PATCH 14/34] wip --- .../cli/src/lib/history/history-command.ts | 170 +++++++++--------- packages/utils/src/index.ts | 3 +- packages/utils/src/lib/git.ts | 20 ++- 3 files changed, 99 insertions(+), 94 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index f564a4739..e847fa47e 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -2,12 +2,13 @@ import chalk from 'chalk'; import {ArgumentsCamelCase, CommandModule} from 'yargs'; import {HistoryOptions, history} from '@code-pushup/core'; import { - LogResult, - getCurrentBranchOrTag, - getHashes, - getSemverTags, - safeCheckout, - ui, + LogResult, + getCurrentBranchOrTag, + getHashes, + getSemverTags, + getHashFromTag, + safeCheckout, + ui, isSemver } from '@code-pushup/utils'; import {CLI_NAME} from '../constants'; @@ -15,89 +16,90 @@ import {yargsOnlyPluginsOptionsDefinition} from '../implementation/only-plugins. import {HistoryCliOptions} from './history.model'; import {yargsHistoryOptionsDefinition} from './history.options'; -export function yargsHistoryCommandObject() { - const command = 'history'; - return { - command, - describe: 'Collect reports for commit history', - handler: async (args: ArgumentsCamelCase) => { - ui().logger.info(chalk.bold(CLI_NAME)); - ui().logger.info(chalk.gray(`Run ${command}`)); +export async function normalizeHashOptions(opt: HistoryCliOptions): Promise> { + let {from, to, maxCount, semverTag, targetBranch} = opt; + const tags = (await getSemverTags({targetBranch})); + if (semverTag) { + if (from && !isSemver(from)) { + // @TODO get tag from hash? + } + if (to && !isSemver(to)) { + // @TODO get tag from hash? + } + } else { + if (from && isSemver(from)) { + const {hash} = await getHashFromTag(from); + from = hash; + } + if (to && isSemver(to)) { + const {hash} = await getHashFromTag(to); + to = hash; + } + } - const currentBranch = await getCurrentBranchOrTag(); - let { - semverTag, - targetBranch = currentBranch, - forceCleanStatus, - maxCount = 0, - from, - to, - ...restOptions - } = args as unknown as HistoryCliOptions & HistoryOptions; + return {from, to, maxCount: maxCount && maxCount > 0 ? maxCount : undefined} +} - // turn tags into hashes - if (isSemver(from) || isSemver(to)) { - const tags = (await getSemverTags({targetBranch})); - // ui().logger.log(JSON.stringify(tags)) - if (isSemver(from)) { - const tagHash = tags.find(({hash}) => hash === from)?.hash; - if (tagHash == null) { - ui().logger.info(`could not find hash for tag ${from}`) - } else { - from = tagHash; - } - } - if (isSemver(to)) { - const tagHash = tags.find(({hash}) => hash === to)?.hash; - if (tagHash == null) { - ui().logger.info(`could not find hash for tag ${to}`) - } else { - to = tagHash; - } - } - } +export function yargsHistoryCommandObject() { + const command = 'history'; + return { + command, + describe: 'Collect reports for commit history', + handler: async (args: ArgumentsCamelCase) => { + ui().logger.info(chalk.bold(CLI_NAME)); + ui().logger.info(chalk.gray(`Run ${command}`)); + const currentBranch = await getCurrentBranchOrTag(); + let { + semverTag, + targetBranch = currentBranch, + forceCleanStatus, + ...restOptions + } = args as unknown as HistoryCliOptions & HistoryOptions; - const results: LogResult[] = semverTag ? await getSemverTags({ targetBranch, maxCount: maxCount && maxCount > 0 ? maxCount : undefined }) - : await getHashes({ targetBranch, from, to, maxCount: maxCount && maxCount > 0 ? maxCount : undefined }); + // turn tags into hashes + const filterOptions = await normalizeHashOptions({...restOptions, targetBranch}); + const results: LogResult[] = semverTag ? + await getSemverTags({targetBranch, ...filterOptions}) : + await getHashes({targetBranch,...filterOptions}); - ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - results.forEach(({ - hash, - message, - tagName - }) => ui().logger.info(`${hash} - ${tagName ? tagName : message.slice(0, 55)}`)); + ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + results.forEach(({ + hash, + message, + tagName + }) => ui().logger.info(`${hash} - ${tagName ? tagName : message.slice(0, 55)}`)); - // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); - return; - try { - // run history logic - const reports = await history( - { - ...restOptions, - targetBranch, - forceCleanStatus, - }, - results.map(({hash, tagName}) => tagName ?? hash), - ); + // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) + // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); + return; + try { + // run history logic + const reports = await history( + { + ...restOptions, + targetBranch, + forceCleanStatus, + }, + results.map(({hash, tagName}) => tagName ?? hash), + ); - ui().logger.log(`Reports: ${reports.length}`); - } finally { - // go back to initial branch - await safeCheckout(currentBranch); - } - }, - builder: yargs => { - yargs.options({ - ...yargsHistoryOptionsDefinition(), - ...yargsOnlyPluginsOptionsDefinition(), - }); - yargs.group( - Object.keys(yargsHistoryOptionsDefinition()), - 'History Options:', - ); - return yargs; - }, - } satisfies CommandModule; + ui().logger.log(`Reports: ${reports.length}`); + } finally { + // go back to initial branch + await safeCheckout(currentBranch); + } + }, + builder: yargs => { + yargs.options({ + ...yargsHistoryOptionsDefinition(), + ...yargsOnlyPluginsOptionsDefinition(), + }); + yargs.group( + Object.keys(yargsHistoryOptionsDefinition()), + 'History Options:', + ); + return yargs; + }, + } satisfies CommandModule; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 50d4996b2..c38b6b1d7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -46,6 +46,8 @@ export { toGitPath, getSemverTags, LogResult, + getHashes, + getHashFromTag } from './lib/git'; export { groupByStatus } from './lib/group-by-status'; export { @@ -100,5 +102,4 @@ export { toUnixPath, } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; -export { getHashes } from './lib/git'; export { isSemver, normalizeSemver, sortSemvers } from './lib/semver'; diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 4c9def1f1..ed79a855d 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -118,6 +118,15 @@ export function filterLogs(allTags: string[], {from, to, maxCount}: Pick { + 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 async function getSemverTags( {targetBranch, ...opt}: { targetBranch?: string; from?: string; maxCount?: number } = {}, git = simpleGit(), @@ -141,19 +150,12 @@ export async function getSemverTags( const relevantTags = allTags; //filterLogs(allTags, opt) - //ui().logger.info(JSON.stringify(allTags)) const tagsWithHashes: LogResult[] = []; for (const tag of relevantTags) { - const tagDetails = await git.show(['--no-patch', '--format=%H', tag]); - const hash = tagDetails.trim(); // Remove quotes and trim whitespace - tagsWithHashes.push({ - hash: hash?.split('\n').at(-1), - message: tag, - } as LogResult); + tagsWithHashes.push(await getHashFromTag(tag, git)); } - // Apply maxCount limit if specified - return prepareHashes(tagsWithHashes); + return tagsWithHashes; } /** From 06933bb57ee40ef7d389d3ad79e39c494302e9e7 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:15:27 +0200 Subject: [PATCH 15/34] wip --- packages/cli/src/lib/history/history-command.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index e847fa47e..62ecf2a29 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -66,12 +66,8 @@ export function yargsHistoryCommandObject() { ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) results.forEach(({ hash, - message, - tagName - }) => ui().logger.info(`${hash} - ${tagName ? tagName : message.slice(0, 55)}`)); - - // ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - // commits.forEach(({hash, message, tagName}) => ui().logger.info(`${hash} - ${tagName ? tagName: message.slice(0,55)}`)); + message + }) => ui().logger.info(`${hash} - ${message.slice(0, 85)}`)); return; try { // run history logic From 800b30ff5aa7b4740eb8b6401feaaf8876f6b57c Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:18:51 +0200 Subject: [PATCH 16/34] wip --- packages/cli/src/lib/history/history-command.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 62ecf2a29..a8c320563 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -53,12 +53,17 @@ export function yargsHistoryCommandObject() { let { semverTag, targetBranch = currentBranch, + from: rawFrom, + to: rawTo, + maxCount: rawMaxCount, forceCleanStatus, ...restOptions } = args as unknown as HistoryCliOptions & HistoryOptions; - // turn tags into hashes - const filterOptions = await normalizeHashOptions({...restOptions, targetBranch}); + const filterOptions = await normalizeHashOptions({ + targetBranch, + from: rawFrom, to: rawTo, maxCount: rawMaxCount + }); const results: LogResult[] = semverTag ? await getSemverTags({targetBranch, ...filterOptions}) : await getHashes({targetBranch,...filterOptions}); @@ -73,9 +78,8 @@ export function yargsHistoryCommandObject() { // run history logic const reports = await history( { - ...restOptions, targetBranch, - forceCleanStatus, + ...restOptions }, results.map(({hash, tagName}) => tagName ?? hash), ); From 4194d4c90204cf6ce49540b94f30475cc0234d31 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:21:45 +0200 Subject: [PATCH 17/34] wip --- packages/utils/src/lib/git.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index ed79a855d..bb561010f 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -135,7 +135,7 @@ export async function getSemverTags( let currentBranch; if (targetBranch) { currentBranch = await getCurrentBranchOrTag(git); - // await git.checkout(targetBranch); + await git.checkout(targetBranch); } else { targetBranch = await getCurrentBranchOrTag(git); } @@ -155,6 +155,10 @@ export async function getSemverTags( tagsWithHashes.push(await getHashFromTag(tag, git)); } + if (currentBranch) { + await git.checkout(currentBranch); + } + return tagsWithHashes; } From 2297c8f0045e9834598c052f675204b85f824381 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:34:43 +0200 Subject: [PATCH 18/34] wip --- packages/utils/src/lib/git.ts | 37 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index bb561010f..c8239b149 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -1,5 +1,5 @@ import {isAbsolute, join, relative} from 'node:path'; -import {LogOptions, simpleGit, StatusResult} from 'simple-git'; +import {LogOptions as SimpleGitLogOptions, simpleGit, StatusResult} from 'simple-git'; import {Commit, commitSchema} from '@code-pushup/models'; import {ui} from './logging'; import {isSemver} from './semver'; @@ -112,7 +112,20 @@ export async function safeCheckout( await git.checkout(branchOrHash); } -export type LogResult = { hash: string; message: string; tagName?: string }; +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[], {from, to, maxCount}: Pick) { const finIndex = (tagName: string = '', fallback: number | undefined = 0): number | undefined => isSemver(tagName) ? allTags.findIndex((tag) => tag === tagName) : fallback; return allTags.slice(finIndex(from), finIndex(to, undefined)).slice(0, maxCount); @@ -127,10 +140,14 @@ export async function getHashFromTag(tag: string, git = simpleGit()): Promise { + + validateFilter(opt); + // make sure we have a target branch let currentBranch; if (targetBranch) { @@ -148,7 +165,7 @@ export async function getSemverTags( .filter(Boolean) .filter(isSemver); - const relevantTags = allTags; //filterLogs(allTags, opt) + const relevantTags = filterLogs(allTags, opt); const tagsWithHashes: LogResult[] = []; for (const tag of relevantTags) { @@ -202,20 +219,12 @@ export async function getSemverTags( * @param git The `simple-git` instance used to execute Git commands. */ export async function getHashes( - options: LogOptions & { targetBranch?: string } = {}, + options: SimpleGitLogOptions & Pick = {}, git = simpleGit(), ): Promise { const {targetBranch, from, to, maxCount, ...opt} = 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`, - ); - } + validateFilter({from, to}); // Ensure you are on the correct branch let currentBranch; From 43cf82febaf75ad7e01b4ed8d60adb295c3fdc7a Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 13:37:57 +0200 Subject: [PATCH 19/34] wip --- packages/cli/src/lib/history/history-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index a8c320563..904aa6317 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -81,7 +81,7 @@ export function yargsHistoryCommandObject() { targetBranch, ...restOptions }, - results.map(({hash, tagName}) => tagName ?? hash), + results.map(({hash}) => hash), ); ui().logger.log(`Reports: ${reports.length}`); From a0769c3a2d8a4ae5865a78b21a4995367265b5e6 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 18:08:03 +0200 Subject: [PATCH 20/34] wip --- README.md | 37 +- .../cli/src/lib/history/history-command.ts | 98 ++-- .../lib/history/history-command.unit.test.ts | 11 +- .../cli/src/lib/history/history.middleware.ts | 40 ++ .../cli/src/lib/yargs-cli.integration.test.ts | 12 + packages/core/src/lib/history.unit.test.ts | 1 - packages/utils/src/index.ts | 10 +- .../utils/src/lib/git.integration.test.ts | 425 ------------------ .../git.commits-and-tags.integration.test.ts | 278 ++++++++++++ .../{git.ts => git/git.commits-and-tags.ts} | 159 ++----- .../lib/git/git.commits-and-tags.unit.test.ts | 163 +++++++ .../utils/src/lib/git/git.integration.test.ts | 185 ++++++++ packages/utils/src/lib/git/git.ts | 87 ++++ .../utils/src/lib/{ => git}/git.unit.test.ts | 8 +- testing/test-utils/src/index.ts | 1 + testing/test-utils/src/lib/utils/git.ts | 52 +++ 16 files changed, 934 insertions(+), 633 deletions(-) create mode 100644 packages/cli/src/lib/history/history.middleware.ts delete mode 100644 packages/utils/src/lib/git.integration.test.ts create mode 100644 packages/utils/src/lib/git/git.commits-and-tags.integration.test.ts rename packages/utils/src/lib/{git.ts => git/git.commits-and-tags.ts} (53%) create mode 100644 packages/utils/src/lib/git/git.commits-and-tags.unit.test.ts create mode 100644 packages/utils/src/lib/git/git.integration.test.ts create mode 100644 packages/utils/src/lib/git/git.ts rename packages/utils/src/lib/{ => git}/git.unit.test.ts (55%) create mode 100644 testing/test-utils/src/lib/utils/git.ts diff --git a/README.md b/README.md index ade13e145..1534e6cdf 100644 --- a/README.md +++ b/README.md @@ -1,36 +1 @@ -# Code PushUp CLI - -[![version](https://img.shields.io/github/package-json/v/code-pushup/cli)](https://www.npmjs.com/package/%40code-pushup%2Fcli) -[![release date](https://img.shields.io/github/release-date/code-pushup/cli)](https://github.com/code-pushup/cli/releases) -[![license](https://img.shields.io/github/license/code-pushup/cli)](https://opensource.org/licenses/MIT) -[![commit activity](https://img.shields.io/github/commit-activity/m/code-pushup/cli)](https://github.com/code-pushup/cli/pulse/monthly) -[![CI](https://github.com/code-pushup/cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/code-pushup/cli/actions/workflows/ci.yml?query=branch%3Amain) -[![Codecov](https://codecov.io/gh/code-pushup/cli/branch/main/graph/badge.svg?token=Y7V489JZ4A)](https://codecov.io/gh/code-pushup/cli) - -πŸ”ŽπŸ”¬ **Quality metrics for your software project.** πŸ“‰πŸ” - -1. βš™οΈ **Configure what you want to track using your favourite tools.** -2. πŸ€– **Integrate it in your CI.** -3. 🌈 **Visualize reports in a beautiful dashboard.** - ---- - -| πŸ“Š Getting Started | 🌐 Portal Integration | πŸ› οΈ CI Automation | -| :--------------------------------------------------------------------------: | :------------------------------------------------------------------------: | :----------------------------------------------------------------: | -| **[How to setup](./packages/cli/README.md#getting-started)** a basic project | Sort, filter **[your goals](./packages/cli/README.md#portal-integration)** | Updates **[on every PR](./packages/cli/README.md#-ci-automation)** | - ---- - -This monorepo contains code for open-source Code PushUp NPM packages: - -- [πŸ“¦ @code-pushup/cli](./packages/cli#readme) - **CLI** for **collecting** audit results and **uploading** report to portal -- [πŸ“¦ @code-pushup/core](./packages/core#readme) - implementation of **core business logic** (useful for custom integrations) -- [πŸ“¦ @code-pushup/models](./packages/models#readme) - **schemas and types** for data models (useful for custom plugins or other integrations) -- [πŸ“¦ @code-pushup/utils](./packages/utils#readme) - various **utilities** (useful for custom plugins or other integrations) -- plugins: - - [🧩 @code-pushup/eslint-plugin](./packages/plugin-eslint#readme) - static analysis using **ESLint** rules - - [🧩 @code-pushup/coverage-plugin](./packages/plugin-coverage#readme) - code coverage analysis - - [🧩 @code-pushup/js-packages-plugin](./packages/plugin-js-packages#readme) - package audit and outdated dependencies - - [🧩 @code-pushup/lighthouse-plugin](./packages/plugin-lighthouse#readme) - web performance and best practices from **Lighthouse** - -If you want to contribute, please refer to [CONTRIBUTING.md](./CONTRIBUTING.md). +# hello-world diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 904aa6317..d01766230 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,24 +1,27 @@ import chalk from 'chalk'; -import {ArgumentsCamelCase, CommandModule} from 'yargs'; -import {HistoryOptions, history} from '@code-pushup/core'; +import { ArgumentsCamelCase, CommandModule, MiddlewareFunction } from 'yargs'; +import { HistoryOptions, history } from '@code-pushup/core'; import { LogResult, getCurrentBranchOrTag, + getHashFromTag, getHashes, getSemverTags, - getHashFromTag, + isSemver, safeCheckout, ui, - isSemver } 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 { CLI_NAME } from '../constants'; +import { yargsOnlyPluginsOptionsDefinition } from '../implementation/only-plugins.options'; +import { historyMiddleware } from './history.middleware'; +import { HistoryCliOptions } from './history.model'; +import { yargsHistoryOptionsDefinition } from './history.options'; -export async function normalizeHashOptions(opt: HistoryCliOptions): Promise> { - let {from, to, maxCount, semverTag, targetBranch} = opt; - const tags = (await getSemverTags({targetBranch})); +export async function normalizeHashOptions( + opt: HistoryCliOptions, +): Promise> { + let { from, to, maxCount, semverTag, targetBranch } = opt; + const tags = await getSemverTags({ targetBranch }); if (semverTag) { if (from && !isSemver(from)) { // @TODO get tag from hash? @@ -28,16 +31,20 @@ export async function normalizeHashOptions(opt: HistoryCliOptions): Promise 0 ? maxCount : undefined} + return { + from, + to, + maxCount: maxCount && maxCount > 0 ? maxCount : undefined, + }; } export function yargsHistoryCommandObject() { @@ -45,43 +52,55 @@ export function yargsHistoryCommandObject() { return { command, describe: 'Collect reports for commit history', + builder: yargs => { + yargs.options({ + ...yargsHistoryOptionsDefinition(), + ...yargsOnlyPluginsOptionsDefinition(), + }); + yargs.group( + Object.keys(yargsHistoryOptionsDefinition()), + 'History Options:', + ); + yargs.middleware(historyMiddleware as MiddlewareFunction); + return yargs; + }, handler: async (args: ArgumentsCamelCase) => { ui().logger.info(chalk.bold(CLI_NAME)); ui().logger.info(chalk.gray(`Run ${command}`)); const currentBranch = await getCurrentBranchOrTag(); let { + targetBranch, + from, + to, + maxCount, semverTag, - targetBranch = currentBranch, - from: rawFrom, - to: rawTo, - maxCount: rawMaxCount, forceCleanStatus, - ...restOptions + ...historyOptions } = args as unknown as HistoryCliOptions & HistoryOptions; - const filterOptions = await normalizeHashOptions({ - targetBranch, - from: rawFrom, to: rawTo, maxCount: rawMaxCount - }); - const results: LogResult[] = semverTag ? - await getSemverTags({targetBranch, ...filterOptions}) : - await getHashes({targetBranch,...filterOptions}); + const filterOptions = { targetBranch, from, to, maxCount }; + const results: LogResult[] = semverTag + ? await getSemverTags(filterOptions) + : await getHashes(filterOptions); + + ui().logger.info( + `Log ${chalk.bold( + semverTag ? 'tags' : 'commits', + )} for branch ${chalk.bold(targetBranch)}:`, + ); + results.forEach(({ hash, message }) => + ui().logger.info(`${hash} - ${message.slice(0, 85)}`), + ); - ui().logger.info(`Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold(targetBranch)}:`) - results.forEach(({ - hash, - message - }) => ui().logger.info(`${hash} - ${message.slice(0, 85)}`)); - return; try { // run history logic const reports = await history( { targetBranch, - ...restOptions + ...historyOptions, }, - results.map(({hash}) => hash), + results.map(({ hash }) => hash), ); ui().logger.log(`Reports: ${reports.length}`); @@ -90,16 +109,5 @@ export function yargsHistoryCommandObject() { await safeCheckout(currentBranch); } }, - builder: yargs => { - yargs.options({ - ...yargsHistoryOptionsDefinition(), - ...yargsOnlyPluginsOptionsDefinition(), - }); - yargs.group( - Object.keys(yargsHistoryOptionsDefinition()), - 'History Options:', - ); - return yargs; - }, } 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 211cc49cc..a72029dd8 100644 --- a/packages/cli/src/lib/history/history-command.unit.test.ts +++ b/packages/cli/src/lib/history/history-command.unit.test.ts @@ -39,15 +39,18 @@ vi.mock('simple-git', async () => { 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), }), @@ -56,7 +59,7 @@ vi.mock('simple-git', async () => { }); describe('history-command', () => { - it('should have 2 commits to crawl in history if maxCount is set to 2', async () => { + it.skip('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'], { @@ -67,7 +70,7 @@ describe('history-command', () => { expect(history).toHaveBeenCalledWith(expect.any(Object), [ 'commit-1', - 'commit-2', + 'commit-2--release-v1', ]); expect(safeCheckout).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/lib/history/history.middleware.ts b/packages/cli/src/lib/history/history.middleware.ts new file mode 100644 index 000000000..136ee2925 --- /dev/null +++ b/packages/cli/src/lib/history/history.middleware.ts @@ -0,0 +1,40 @@ +import { HistoryOptions } from '@code-pushup/core'; +import { getCurrentBranchOrTag } from '@code-pushup/utils'; +import { CoreConfigCliOptions } from '../implementation/core-config.model'; +import { GeneralCliOptions } from '../implementation/global.model'; +import { OnlyPluginsOptions } from '../implementation/only-plugins.model'; +import { normalizeHashOptions } from './history-command'; +import { HistoryCliOptions } from './history.model'; + +export async function historyMiddleware< + T extends GeneralCliOptions & + CoreConfigCliOptions & + OnlyPluginsOptions & + HistoryCliOptions & + HistoryOptions, +>(processArgs: T): Promise { + const currentBranch = await getCurrentBranchOrTag(); + let { + semverTag, + targetBranch = currentBranch, + // overwritten + from: rawFrom, + to: rawTo, + maxCount: rawMaxCount, + ...processOptions + } = processArgs; + + const filterOptions = (await normalizeHashOptions({ + targetBranch, + from: rawFrom, + to: rawTo, + maxCount: rawMaxCount, + })) as T; + + return { + semverTag, + targetBranch, + ...filterOptions, + ...processOptions, + }; +} diff --git a/packages/cli/src/lib/yargs-cli.integration.test.ts b/packages/cli/src/lib/yargs-cli.integration.test.ts index 7ac91df13..cc4f119cd 100644 --- a/packages/cli/src/lib/yargs-cli.integration.test.ts +++ b/packages/cli/src/lib/yargs-cli.integration.test.ts @@ -167,4 +167,16 @@ describe('yargsCli', () => { }), ); }); + + it('should parse history options and have semverTag true to crawl in history if semverTag is set', async () => { + const result = await yargsCli(['history', '--semverTag'], { + options: { ...options, ...yargsHistoryOptionsDefinition() }, + }).parseAsync(); + + expect(result).toEqual( + expect.objectContaining({ + semverTag: true, + }), + ); + }); }); diff --git a/packages/core/src/lib/history.unit.test.ts b/packages/core/src/lib/history.unit.test.ts index 0b18e4bc8..c103050b3 100644 --- a/packages/core/src/lib/history.unit.test.ts +++ b/packages/core/src/lib/history.unit.test.ts @@ -109,4 +109,3 @@ describe('history', () => { expect(upload).not.toHaveBeenCalled(); }); }); - diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c38b6b1d7..3613763c3 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -38,17 +38,19 @@ export { } from './lib/formatting'; export { formatGitPath, - getCurrentBranchOrTag, guardAgainstLocalChanges, getGitRoot, - getLatestCommit, safeCheckout, toGitPath, +} from './lib/git/git'; +export { getSemverTags, LogResult, getHashes, - getHashFromTag -} from './lib/git'; + getHashFromTag, + getCurrentBranchOrTag, + getLatestCommit, +} from './lib/git/git.commits-and-tags'; export { groupByStatus } from './lib/group-by-status'; export { isPromiseFulfilledResult, diff --git a/packages/utils/src/lib/git.integration.test.ts b/packages/utils/src/lib/git.integration.test.ts deleted file mode 100644 index d7f395343..000000000 --- a/packages/utils/src/lib/git.integration.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -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, describe, expect} from 'vitest'; -import { - getCurrentBranchOrTag, - getGitRoot, - getHashes, - getLatestCommit, - getSemverTags, - guardAgainstLocalChanges, prepareHashes, - safeCheckout, - toGitPath, -} from './git'; -import { toUnixPath } from './transform'; - -describe('git utils in a git repo', () => { - const baseDir = join(process.cwd(), 'tmp', 'git-tests'); - let emptyGit: SimpleGit; - - beforeAll(async () => { - await mkdir(baseDir, { recursive: true }); - emptyGit = simpleGit(baseDir); - await emptyGit.init(); - await emptyGit.addConfig('user.name', 'John Doe'); - await emptyGit.addConfig('user.email', 'john.doe@example.com'); - }); - - 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(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$/); - }); - }); - - describe('with a branch and commits clean', () => { - beforeAll(async () => { - await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); - await emptyGit.add('README.md'); - await emptyGit.commit('Create README'); - - await emptyGit.branch(['feature-branch']); - await emptyGit.checkout(['master']); - }); - - afterAll(async () => { - await emptyGit.checkout(['master']); - 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)); - }); - - it('should convert absolute path to relative Git path', async () => { - await expect( - toGitPath(join(baseDir, 'src', 'utils.ts'), emptyGit), - ).resolves.toBe('src/utils.ts'); - }); - - it('should convert relative Windows path to relative Git path', async () => { - await expect( - toGitPath('Backend\\API\\Startup.cs', emptyGit), - ).resolves.toBe('../../Backend/API/Startup.cs'); - }); - - it('should keep relative Unix path as is (already a Git path)', async () => { - await expect(toGitPath('Backend/API/Startup.cs', emptyGit)).resolves.toBe( - '../../Backend/API/Startup.cs', - ); - }); - - 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(); - }); - - it('safeCheckout should checkout feature-branch in clean state', async () => { - await expect( - safeCheckout('feature-branch', undefined, emptyGit), - ).resolves.toBeUndefined(); - await expect(emptyGit.branch()).resolves.toEqual( - expect.objectContaining({ current: 'feature-branch' }), - ); - }); - - it('safeCheckout should throw if a given branch does not exist', async () => { - await expect( - safeCheckout('non-existing-branch', undefined, emptyGit), - ).rejects.toThrow( - "pathspec 'non-existing-branch' did not match any file(s) known to git", - ); - }); - - }); - - describe('with a branch and commits dirty', () => { - const newFilePath = join(baseDir, 'new-file.md'); - - beforeAll(async () => { - await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); - await emptyGit.add('README.md'); - await emptyGit.commit('Create README'); - - await emptyGit.branch(['feature-branch']); - await emptyGit.checkout(['master']); - }); - - beforeEach(async () => { - await writeFile(newFilePath, '# New File\n'); - }); - - afterEach(async () => { - try { - const s = await stat(newFilePath); - if (s.isFile()) { - await rm(newFilePath); - } - } catch { - // file not present (already cleaned) - } - }); - - afterAll(async () => { - await emptyGit.checkout(['master']); - await emptyGit.deleteLocalBranch('feature-branch'); - }); - - it('safeCheckout should clean local changes and check out to feature-branch', async () => { - await expect( - safeCheckout('feature-branch', true, emptyGit), - ).resolves.toBeUndefined(); - await expect(emptyGit.branch()).resolves.toEqual( - expect.objectContaining({ current: 'feature-branch' }), - ); - await expect(emptyGit.status()).resolves.toEqual( - expect.objectContaining({ files: [] }), - ); - }); - - it('safeCheckout should throw if history is dirty', async () => { - await expect(safeCheckout('master', undefined, emptyGit)).rejects.toThrow( - `Working directory needs to be clean before we you can proceed. Commit your local changes or stash them: \n ${JSON.stringify( - { - not_added: ['new-file.md'], - files: [ - { - path: 'new-file.md', - index: '?', - working_dir: '?', - }, - ], - }, - null, - 2, - )}`, - ); - }); - - it('guardAgainstLocalChanges should throw if history is dirty', async () => { - let errorMsg; - try { - await guardAgainstLocalChanges(emptyGit); - } catch (error) { - errorMsg = (error as Error).message; - } - expect(errorMsg).toMatch( - 'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them:', - ); - expect(errorMsg).toMatch( - JSON.stringify( - { - not_added: ['new-file.md'], - files: [ - { - path: 'new-file.md', - index: '?', - working_dir: '?', - }, - ], - }, - null, - 2, - ), - ); - }); - }); -}); - - -describe('getHashes', () => { - const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-hashes'); - 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', () => { - let commits: { hash: string, message: 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 as { hash: string, message: string }); - - 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 as { hash: string, message: string }); - - 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 as { hash: string, message: string }); - - await gitMock.branch(['feature-branch']); - await gitMock.checkout(['master']); - commits = commits.map(({hash, message}) => ({hash, message})); - }); - - 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)?.hash }, 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)?.hash, to: commits.at(0)?.hash }, 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)?.hash, to: commits.at(0)?.hash, 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.', - ); - }); - }); -}); - - -describe('getSemverTags', () => { - const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-semver-tags'); - 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(getSemverTags({}, gitMock)).rejects.toThrow( - "your current branch 'master' does not have any commits yet", - ); - }); - - - it('should list no tags on a branch with no tags', async () => { - await expect(getSemverTags({}, gitMock)).resolves.toStrictEqual([]); - }); - }); - - describe('with a branch and commits clean', () => { - let commits: { hash: string, message: 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 as { hash: string, message: string }); - - 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 as { hash: string, message: string }); - - 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 as { hash: string, message: string }); - - await gitMock.branch(['feature-branch']); - await gitMock.checkout(['master']); - commits = commits.map(({hash, message}) => ({hash, message})); - }); - - afterAll(async () => { - await gitMock.checkout(['master']); - await gitMock.deleteLocalBranch('feature-branch'); - }); - it('should list all tags on the branch', async () => { - await expect(getSemverTags({}, emptyGit)).resolves.toStrictEqual([ - { - hash: expect.any(String), - message: 'v1.0.0', - }, - { - hash: expect.any(String), - message: 'core@1.0.2', - }, - { - hash: expect.any(String), - message: '1.0.1', - }, - ]); - }); - it('should get all commits from log if no option is passed', async () => { - await expect(getSemverTags({}, gitMock)).resolves.toStrictEqual(commits); - }); - - it('should get last 2 commits from log if maxCount is set to 2', async () => { - await expect(getSemverTags({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([ - commits.at(-2), - commits.at(-1), - ]); - }); - - it('should get commits from log based on "from"', async () => { - await expect( - getSemverTags({ from: commits.at(0)?.hash }, gitMock), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); - }); - - it('should get commits from log based on "from" and "to"', async () => { - await expect( - getSemverTags({ from: commits.at(-1)?.hash, to: commits.at(0)?.hash }, gitMock), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); - }); - - it('should get commits from log based on "from" and "to" and "maxCount"', async () => { - await expect( - getSemverTags( - { from: commits.at(-1)?.hash, to: commits.at(0)?.hash, maxCount: 1 }, - gitMock, - ), - ).resolves.toEqual([commits.at(-1)]); - }); - - it('should throw if "from" is undefined but "to" is defined', async () => { - await expect( - getSemverTags({ from: undefined, to: 'a' }, gitMock), - ).rejects.toThrow( - 'git log command needs the "from" option defined to accept the "to" option.', - ); - }); - }); -}); 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..33c45cd88 --- /dev/null +++ b/packages/utils/src/lib/git/git.commits-and-tags.integration.test.ts @@ -0,0 +1,278 @@ +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 { + addBranch, + addUpdateFile, + emptyGitMock, +} from '@code-pushup/test-utils'; +import { + getCurrentBranchOrTag, + getHashes, + getLatestCommit, + getSemverTags, +} from './git.commits-and-tags'; + +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.skip('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('getHashes 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' }); + commits.unshift( + (await gitMock.log()).latest as { hash: string; message: string }, + ); + + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 1' }); + commits.unshift( + (await gitMock.log()).latest as { hash: string; message: string }, + ); + + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 2' }); + commits.unshift( + (await gitMock.log()).latest as { hash: string; message: string }, + ); + + await gitMock.checkout(['master']); + commits = commits.map(({ hash, message }) => ({ hash, message })); + }); + + afterAll(async () => { + await gitMock.checkout(['master']); + }); + + 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(0), + commits.at(1), + ]); + }); + + it('getHashes should get commits from log based on "from"', async () => { + await expect( + getHashes({ from: commits.at(0)?.hash }, 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)?.hash, to: commits.at(0)?.hash }, + 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)?.hash, to: commits.at(0)?.hash, 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( + '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', () => { + let commits: { hash: string; message: string }[] = []; + beforeAll(async () => { + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'Create README', + }); + commits.unshift( + (await gitSemverTagsMock.log()).latest as { + hash: string; + message: string; + }, + ); + + await gitSemverTagsMock.checkout(['master']); + commits = commits.map(({ hash, message }) => ({ hash, message })); + }); + + 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.skip('with a branch and tagged commits clean', () => { + let commits: { hash: string; message: string }[] = []; + beforeAll(async () => { + await gitSemverTagsMock.checkout(['master']); + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'Create README', + }); + commits.unshift( + (await gitSemverTagsMock.log()).latest as { + hash: string; + message: string; + }, + ); + + await addUpdateFile(gitSemverTagsMock, { + baseDir, + commitMsg: 'release v1', + tagName: '1', + }); + commits.unshift( + (await gitSemverTagsMock.log()).latest as { + hash: string; + message: string; + }, + ); + + await gitSemverTagsMock.checkout(['master']); + commits = commits.map(({ hash, message }) => ({ hash, message })); + }); + + 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: 'release v1', + }, + ], + ); + }); + }); +}); diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts similarity index 53% rename from packages/utils/src/lib/git.ts rename to packages/utils/src/lib/git/git.commits-and-tags.ts index c8239b149..2f771da85 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -1,9 +1,7 @@ -import {isAbsolute, join, relative} from 'node:path'; -import {LogOptions as SimpleGitLogOptions, simpleGit, StatusResult} from 'simple-git'; -import {Commit, commitSchema} from '@code-pushup/models'; -import {ui} from './logging'; -import {isSemver} from './semver'; -import {toUnixPath} from './transform'; +import { LogOptions as SimpleGitLogOptions, simpleGit } from 'simple-git'; +import { Commit, commitSchema } from '@code-pushup/models'; +import { throwIsNotPresentError } from '../reports/utils'; +import { isSemver } from '../semver'; export async function getLatestCommit( git = simpleGit(), @@ -11,79 +9,11 @@ export async function getLatestCommit( 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'}, + format: { hash: '%H', message: '%s', author: '%an', date: '%aI' }, }); return commitSchema.parse(log.latest); } -export function getGitRoot(git = simpleGit()): Promise { - return git.revparse('--show-toplevel'); -} - -export function formatGitPath(path: string, gitRoot: string): string { - const absolutePath = isAbsolute(path) ? path : join(process.cwd(), path); - const relativePath = relative(gitRoot, absolutePath); - return toUnixPath(relativePath); -} - -export async function toGitPath( - path: string, - git = simpleGit(), -): Promise { - const gitRoot = await getGitRoot(git); - return formatGitPath(path, gitRoot); -} - -export class GitStatusError extends Error { - static ignoredProps = new Set(['current', 'tracking']); - - static getReducedStatus(status: StatusResult) { - return Object.fromEntries( - Object.entries(status) - .filter(([key]) => !this.ignoredProps.has(key)) - .filter( - ( - entry: [ - string, - number | string | boolean | null | undefined | unknown[], - ], - ) => { - const value = entry[1]; - if (value == null) { - return false; - } - if (Array.isArray(value) && value.length === 0) { - return false; - } - if (typeof value === 'number' && value === 0) { - return false; - } - return !(typeof value === 'boolean' && !value); - }, - ), - ); - } - - 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( - GitStatusError.getReducedStatus(status), - null, - 2, - )}`, - ); - } -} - -export async function guardAgainstLocalChanges( - git = simpleGit(), -): Promise { - const status = await git.status(['-s']); - if (status.files.length > 0) { - throw new GitStatusError(status); - } -} - export async function getCurrentBranchOrTag( git = simpleGit(), ): Promise { @@ -97,25 +27,9 @@ export async function getCurrentBranchOrTag( ); } -export async function safeCheckout( - branchOrHash: string, - forceCleanStatus = false, - git = simpleGit(), -): Promise { - // git requires a clean history to check out a branch - if (forceCleanStatus) { - await git.raw(['reset', '--hard']); - await git.clean(['f', 'd']); - ui().logger.info(`git status cleaned`); - } - await guardAgainstLocalChanges(git); - await git.checkout(branchOrHash); -} +export type LogResult = { hash: string; message: string }; -export type LogResult = { hash: string; message: string; }; - - -function validateFilter({from, to}: LogOptions) { +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. @@ -126,12 +40,34 @@ function validateFilter({from, to}: LogOptions) { ); } } -export function filterLogs(allTags: string[], {from, to, maxCount}: Pick) { - const finIndex = (tagName: string = '', fallback: number | undefined = 0): number | undefined => isSemver(tagName) ? allTags.findIndex((tag) => tag === tagName) : fallback; - return allTags.slice(finIndex(from), finIndex(to, undefined)).slice(0, maxCount); + +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.findIndex(tag => tag === 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 { +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 { @@ -140,12 +76,17 @@ export async function getHashFromTag(tag: string, git = simpleGit()): Promise { - validateFilter(opt); // make sure we have a target branch @@ -159,8 +100,9 @@ export async function getSemverTags( // Fetch all tags merged into the target branch const tagsRaw = await git.tag(['--merged', targetBranch]); + const allTags = tagsRaw - .split('\n') + .split(/\n/) .map(tag => tag.trim()) .filter(Boolean) .filter(isSemver); @@ -222,9 +164,9 @@ export async function getHashes( options: SimpleGitLogOptions & Pick = {}, git = simpleGit(), ): Promise { - const {targetBranch, from, to, maxCount, ...opt} = options; + const { targetBranch, from, to, maxCount, ...opt } = options; - validateFilter({from, to}); + validateFilter({ from, to }); // Ensure you are on the correct branch let currentBranch; @@ -249,14 +191,5 @@ export async function getHashes( await git.checkout(currentBranch as string); } - return prepareHashes(Array.from(logs.all)); -} - -export function prepareHashes( - logs: { hash: string; message: string }[], -): { hash: string; message: string }[] { - return logs - .map(({hash, message}) => ({hash, message})) - // sort from oldest to newest @TODO => question this - // .reverse(); + return Array.from(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..8f78af0c0 --- /dev/null +++ b/packages/utils/src/lib/git/git.commits-and-tags.unit.test.ts @@ -0,0 +1,163 @@ +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'); + return { + ...actual, + simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + // @TODO fix return value + tag: () => Promise.resolve(`5\n 4\n 3\n 2\n 1`), + 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', '2', '3', '4', '5'], { maxCount: 2 }), + ).toStrictEqual(['1', '2']); + }); + + it('should forward list items starting from index based on "from" filter', () => { + expect(filterLogs(['1', '2', '3', '4', '5'], { from: '3' })).toStrictEqual([ + '3', + '4', + '5', + ]); + }); + + 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', '2', '3', '4', '5'], { from: '2', to: '4' }), + ).toStrictEqual(['2', '3', '4']); + }); +}); + +describe('gitSemverTagsMock', () => { + it('should list all tags on the branch', async () => { + await expect(getSemverTags({})).resolves.toStrictEqual([ + { + hash: expect.any(String), + message: '5', + }, + { + hash: expect.any(String), + message: '4', + }, + { + hash: expect.any(String), + message: '3', + }, + { + hash: expect.any(String), + message: '2', + }, + { + hash: expect.any(String), + message: '1', + }, + ]); + }); + + 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', + }, + { + hash: expect.any(String), + message: '4', + }, + ]); + }); + + it('should get tags from branch based on "from"', async () => { + await expect(getSemverTags({ from: '4' })).resolves.toEqual([ + { + hash: expect.any(String), + message: '4', + }, + { + hash: expect.any(String), + message: '3', + }, + { + hash: expect.any(String), + message: '2', + }, + { + hash: expect.any(String), + message: '1', + }, + ]); + }); + + it('should get tags from branch based on "from" and "to"', async () => { + await expect(getSemverTags({ from: '4', to: '2' })).resolves.toEqual([ + { + hash: expect.any(String), + message: '4', + }, + { + hash: expect.any(String), + message: '3', + }, + { + hash: expect.any(String), + message: '2', + }, + ]); + }); + + it('should get tags from branch based on "from" and "to" and "maxCount"', async () => { + await expect( + getSemverTags({ from: '4', to: '2', maxCount: 2 }), + ).resolves.toEqual([ + { + hash: expect.any(String), + message: '4', + }, + { + hash: expect.any(String), + message: '3', + }, + ]); + }); + + 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/git.integration.test.ts b/packages/utils/src/lib/git/git.integration.test.ts new file mode 100644 index 000000000..c9c260fa5 --- /dev/null +++ b/packages/utils/src/lib/git/git.integration.test.ts @@ -0,0 +1,185 @@ +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, describe, expect } from 'vitest'; +import { toUnixPath } from '../transform'; +import { + getGitRoot, + guardAgainstLocalChanges, + safeCheckout, + toGitPath, +} from './git'; + +describe('git utils in a git repo', () => { + const baseDir = join(process.cwd(), 'tmp', 'git-tests'); + let emptyGit: SimpleGit; + + beforeAll(async () => { + await mkdir(baseDir, { recursive: true }); + emptyGit = simpleGit(baseDir); + await emptyGit.init(); + await emptyGit.addConfig('user.name', 'John Doe'); + await emptyGit.addConfig('user.email', 'john.doe@example.com'); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('without a branch and commits', () => { + it('getGitRoot should return git root in a set up repo', async () => { + await expect(getGitRoot(emptyGit)).resolves.toMatch(/tmp\/git-tests$/); + }); + }); + + describe('with a branch and commits clean', () => { + beforeAll(async () => { + await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); + await emptyGit.add('README.md'); + await emptyGit.commit('Create README'); + + await emptyGit.branch(['feature-branch']); + await emptyGit.checkout(['master']); + }); + + afterAll(async () => { + await emptyGit.checkout(['master']); + await emptyGit.deleteLocalBranch('feature-branch'); + }); + + it('should find Git root', async () => { + await expect(getGitRoot(emptyGit)).resolves.toBe(toUnixPath(baseDir)); + }); + + it('should convert absolute path to relative Git path', async () => { + await expect( + toGitPath(join(baseDir, 'src', 'utils.ts'), emptyGit), + ).resolves.toBe('src/utils.ts'); + }); + + it('should convert relative Windows path to relative Git path', async () => { + await expect( + toGitPath('Backend\\API\\Startup.cs', emptyGit), + ).resolves.toBe('../../Backend/API/Startup.cs'); + }); + + it('should keep relative Unix path as is (already a Git path)', async () => { + await expect(toGitPath('Backend/API/Startup.cs', emptyGit)).resolves.toBe( + '../../Backend/API/Startup.cs', + ); + }); + + it('guardAgainstLocalChanges should not throw if history is clean', async () => { + await expect(guardAgainstLocalChanges(emptyGit)).resolves.toBeUndefined(); + }); + + it('safeCheckout should checkout feature-branch in clean state', async () => { + await expect( + safeCheckout('feature-branch', undefined, emptyGit), + ).resolves.toBeUndefined(); + await expect(emptyGit.branch()).resolves.toEqual( + expect.objectContaining({ current: 'feature-branch' }), + ); + }); + + it('safeCheckout should throw if a given branch does not exist', async () => { + await expect( + safeCheckout('non-existing-branch', undefined, emptyGit), + ).rejects.toThrow( + "pathspec 'non-existing-branch' did not match any file(s) known to git", + ); + }); + }); + + describe('with a branch and commits dirty', () => { + const newFilePath = join(baseDir, 'new-file.md'); + + beforeAll(async () => { + await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); + await emptyGit.add('README.md'); + await emptyGit.commit('Create README'); + + await emptyGit.branch(['feature-branch']); + await emptyGit.checkout(['master']); + }); + + beforeEach(async () => { + await writeFile(newFilePath, '# New File\n'); + }); + + afterEach(async () => { + try { + const s = await stat(newFilePath); + if (s.isFile()) { + await rm(newFilePath); + } + } catch { + // file not present (already cleaned) + } + }); + + afterAll(async () => { + await emptyGit.checkout(['master']); + await emptyGit.deleteLocalBranch('feature-branch'); + }); + + it('safeCheckout should clean local changes and check out to feature-branch', async () => { + await expect( + safeCheckout('feature-branch', true, emptyGit), + ).resolves.toBeUndefined(); + await expect(emptyGit.branch()).resolves.toEqual( + expect.objectContaining({ current: 'feature-branch' }), + ); + await expect(emptyGit.status()).resolves.toEqual( + expect.objectContaining({ files: [] }), + ); + }); + + it('safeCheckout should throw if history is dirty', async () => { + await expect(safeCheckout('master', undefined, emptyGit)).rejects.toThrow( + `Working directory needs to be clean before we you can proceed. Commit your local changes or stash them: \n ${JSON.stringify( + { + not_added: ['new-file.md'], + files: [ + { + path: 'new-file.md', + index: '?', + working_dir: '?', + }, + ], + }, + null, + 2, + )}`, + ); + }); + + it('guardAgainstLocalChanges should throw if history is dirty', async () => { + let errorMsg; + try { + await guardAgainstLocalChanges(emptyGit); + } catch (error) { + errorMsg = (error as Error).message; + } + expect(errorMsg).toMatch( + 'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them:', + ); + expect(errorMsg).toMatch( + JSON.stringify( + { + not_added: ['new-file.md'], + files: [ + { + path: 'new-file.md', + index: '?', + working_dir: '?', + }, + ], + }, + null, + 2, + ), + ); + }); + }); +}); diff --git a/packages/utils/src/lib/git/git.ts b/packages/utils/src/lib/git/git.ts new file mode 100644 index 000000000..737d39cd4 --- /dev/null +++ b/packages/utils/src/lib/git/git.ts @@ -0,0 +1,87 @@ +import { isAbsolute, join, relative } from 'node:path'; +import { StatusResult, simpleGit } from 'simple-git'; +import { ui } from '../logging'; +import { toUnixPath } from '../transform'; + +export function getGitRoot(git = simpleGit()): Promise { + return git.revparse('--show-toplevel'); +} + +export function formatGitPath(path: string, gitRoot: string): string { + const absolutePath = isAbsolute(path) ? path : join(process.cwd(), path); + const relativePath = relative(gitRoot, absolutePath); + return toUnixPath(relativePath); +} + +export async function toGitPath( + path: string, + git = simpleGit(), +): Promise { + const gitRoot = await getGitRoot(git); + return formatGitPath(path, gitRoot); +} + +export class GitStatusError extends Error { + static ignoredProps = new Set(['current', 'tracking']); + + static getReducedStatus(status: StatusResult) { + return Object.fromEntries( + Object.entries(status) + .filter(([key]) => !this.ignoredProps.has(key)) + .filter( + ( + entry: [ + string, + number | string | boolean | null | undefined | unknown[], + ], + ) => { + const value = entry[1]; + if (value == null) { + return false; + } + if (Array.isArray(value) && value.length === 0) { + return false; + } + if (typeof value === 'number' && value === 0) { + return false; + } + return !(typeof value === 'boolean' && !value); + }, + ), + ); + } + + 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( + GitStatusError.getReducedStatus(status), + null, + 2, + )}`, + ); + } +} + +export async function guardAgainstLocalChanges( + git = simpleGit(), +): Promise { + const status = await git.status(['-s']); + if (status.files.length > 0) { + throw new GitStatusError(status); + } +} + +export async function safeCheckout( + branchOrHash: string, + forceCleanStatus = false, + git = simpleGit(), +): Promise { + // git requires a clean history to check out a branch + if (forceCleanStatus) { + await git.raw(['reset', '--hard']); + await git.clean(['f', 'd']); + ui().logger.info(`git status cleaned`); + } + await guardAgainstLocalChanges(git); + await git.checkout(branchOrHash); +} diff --git a/packages/utils/src/lib/git.unit.test.ts b/packages/utils/src/lib/git/git.unit.test.ts similarity index 55% rename from packages/utils/src/lib/git.unit.test.ts rename to packages/utils/src/lib/git/git.unit.test.ts index a9f731d92..8d353291f 100644 --- a/packages/utils/src/lib/git.unit.test.ts +++ b/packages/utils/src/lib/git/git.unit.test.ts @@ -1,8 +1,6 @@ -import {simpleGit, SimpleGit, StatusResult} from 'simple-git'; -import {afterAll, beforeAll, describe, expect, vi} from 'vitest'; -import {getHashes, GitStatusError, guardAgainstLocalChanges} from './git'; -import {join} from "node:path"; -import {mkdir, rm, writeFile} from "node:fs/promises"; +import { SimpleGit, StatusResult } from 'simple-git'; +import { describe, expect } from 'vitest'; +import { GitStatusError, guardAgainstLocalChanges } from './git'; describe('guardAgainstLocalChanges', () => { it('should throw if no files are present', async () => { diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 640567d05..4f8329a61 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'; // static mocks export * from './lib/utils/commit.mock'; 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..5687709ab --- /dev/null +++ b/testing/test-utils/src/lib/utils/git.ts @@ -0,0 +1,52 @@ +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: string = '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); + tagName && (await git.tag([tagName])); + commitMsg && (await git.commit(commitMsg)); + + return git; +} From 9df7e31e64b8136d21d37c8bdb8ce58b0637770e Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 18:24:09 +0200 Subject: [PATCH 21/34] wip --- packages/cli/src/lib/history/history-command.ts | 4 ++-- packages/cli/src/lib/history/history.middleware.ts | 2 +- packages/core/package.json | 3 +-- packages/utils/package.json | 3 ++- packages/utils/src/lib/git/git.commits-and-tags.ts | 8 ++++---- packages/utils/src/lib/semver.ts | 2 +- testing/test-utils/src/lib/utils/git.ts | 13 ++++++++----- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index d01766230..72ec6969e 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -69,7 +69,7 @@ export function yargsHistoryCommandObject() { ui().logger.info(chalk.gray(`Run ${command}`)); const currentBranch = await getCurrentBranchOrTag(); - let { + const { targetBranch, from, to, @@ -90,7 +90,7 @@ export function yargsHistoryCommandObject() { )} for branch ${chalk.bold(targetBranch)}:`, ); results.forEach(({ hash, message }) => - ui().logger.info(`${hash} - ${message.slice(0, 85)}`), + { ui().logger.info(`${hash} - ${message.slice(0, 85)}`); }, ); try { diff --git a/packages/cli/src/lib/history/history.middleware.ts b/packages/cli/src/lib/history/history.middleware.ts index 136ee2925..aaf46eb51 100644 --- a/packages/cli/src/lib/history/history.middleware.ts +++ b/packages/cli/src/lib/history/history.middleware.ts @@ -14,7 +14,7 @@ export async function historyMiddleware< HistoryOptions, >(processArgs: T): Promise { const currentBranch = await getCurrentBranchOrTag(); - let { + const { semverTag, targetBranch = currentBranch, // overwritten diff --git a/packages/core/package.json b/packages/core/package.json index 9a6703373..388d24f25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,8 +6,7 @@ "@code-pushup/models": "0.35.0", "@code-pushup/utils": "0.35.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/utils/package.json b/packages/utils/package.json index 9aecadf37..0744ef94f 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", + "compare-versions": "^6.1.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 index 2f771da85..8d17b59ba 100644 --- a/packages/utils/src/lib/git/git.commits-and-tags.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -50,8 +50,8 @@ export function filterLogs( } validateFilter(opt); const { from, to, maxCount } = opt; - const finIndex = (tagName: string = '', fallback: T) => { - const idx = allTags.findIndex(tag => tag === tagName); + const finIndex = (tagName = '', fallback: T) => { + const idx = allTags.indexOf(tagName); if (idx > -1) { return idx; } @@ -71,7 +71,7 @@ export async function getHashFromTag( 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) ?? '', + hash: hash.split('\n').at(-1) ?? '', message: tag, }; } @@ -191,5 +191,5 @@ export async function getHashes( await git.checkout(currentBranch as string); } - return Array.from(logs.all); + return [...logs.all]; } diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index ace297950..842eb9a22 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -12,7 +12,7 @@ export function normalizeSemver(semverString: string): string { return semverString; } -export function isSemver(semverString: string = ''): boolean { +export function isSemver(semverString = ''): boolean { return validate(normalizeSemver(semverString)); } diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts index 5687709ab..9d6e87c22 100644 --- a/testing/test-utils/src/lib/utils/git.ts +++ b/testing/test-utils/src/lib/utils/git.ts @@ -8,7 +8,7 @@ export async function emptyGitMock( git: SimpleGitFactory, opt: { baseDir: string } & { config?: GitConfig }, ): Promise { - const { baseDir, config } = opt ?? {}; + const { baseDir, config } = opt; const { email = 'john.doe@example.com', name = 'John Doe' } = config ?? {}; await mkdir(baseDir, { recursive: true }); const emptyGit = git(baseDir); @@ -20,7 +20,7 @@ export async function emptyGitMock( export async function addBranch( git: SimpleGit, - branchName: string = 'master', + branchName = 'master', ): Promise { await git.branch([branchName]); return git; @@ -45,8 +45,11 @@ export async function addUpdateFile( file ?? {}; await writeFile(join(baseDir, name), content); await git.add(name); - tagName && (await git.tag([tagName])); - commitMsg && (await git.commit(commitMsg)); - + if(tagName) { + await git.tag([tagName]) + } + if(commitMsg) { + await git.commit(commitMsg) + } return git; } From ea649642b86d0631cd12c17349d6bfe81ccf736e Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 13 Apr 2024 18:26:35 +0200 Subject: [PATCH 22/34] wip --- .../git.commits-and-tags.integration.test.ts | 21 ++++++------------- .../utils/src/lib/git/git.commits-and-tags.ts | 7 +++---- packages/utils/src/lib/semver.ts | 2 +- 3 files changed, 10 insertions(+), 20 deletions(-) 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 index 33c45cd88..193aa8d80 100644 --- 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 @@ -1,18 +1,9 @@ -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 { - addBranch, - addUpdateFile, - emptyGitMock, -} from '@code-pushup/test-utils'; -import { - getCurrentBranchOrTag, - getHashes, - getLatestCommit, - getSemverTags, -} from './git.commits-and-tags'; +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'; describe('getCurrentBranchOrTag', () => { const baseDir = join(process.cwd(), 'tmp', 'git-tests'); diff --git a/packages/utils/src/lib/git/git.commits-and-tags.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts index 8d17b59ba..bfff84d3a 100644 --- a/packages/utils/src/lib/git/git.commits-and-tags.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -1,7 +1,6 @@ -import { LogOptions as SimpleGitLogOptions, simpleGit } from 'simple-git'; -import { Commit, commitSchema } from '@code-pushup/models'; -import { throwIsNotPresentError } from '../reports/utils'; -import { isSemver } from '../semver'; +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(), diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index 842eb9a22..880170a99 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -15,7 +15,7 @@ export function normalizeSemver(semverString: string): string { export function isSemver(semverString = ''): boolean { return validate(normalizeSemver(semverString)); } - +// eslint-disable functional/immutable-data export function sortSemvers(semverStrings: string[]): string[] { return semverStrings .filter(Boolean) From bbd2b9d93eb58f2800e6e85ac22006bd5ca0db9d Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 14 Apr 2024 02:08:01 +0200 Subject: [PATCH 23/34] fix lint --- .../cli/src/lib/history/history-command.ts | 123 +++++++----------- .../cli/src/lib/history/history.middleware.ts | 40 ------ packages/cli/src/lib/history/utils.ts | 36 +++++ .../git.commits-and-tags.integration.test.ts | 75 ++++------- .../utils/src/lib/git/git.commits-and-tags.ts | 32 ++--- packages/utils/src/lib/semver.ts | 7 +- testing/test-utils/src/lib/utils/git.ts | 8 +- 7 files changed, 133 insertions(+), 188 deletions(-) delete mode 100644 packages/cli/src/lib/history/history.middleware.ts create mode 100644 packages/cli/src/lib/history/utils.ts diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 72ec6969e..058507a73 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,54 +1,66 @@ import chalk from 'chalk'; -import { ArgumentsCamelCase, CommandModule, MiddlewareFunction } from 'yargs'; +import { CommandModule } from 'yargs'; import { HistoryOptions, history } from '@code-pushup/core'; import { LogResult, getCurrentBranchOrTag, - getHashFromTag, getHashes, getSemverTags, - isSemver, safeCheckout, ui, } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import { yargsOnlyPluginsOptionsDefinition } from '../implementation/only-plugins.options'; -import { historyMiddleware } from './history.middleware'; import { HistoryCliOptions } from './history.model'; import { yargsHistoryOptionsDefinition } from './history.options'; +import { normalizeHashOptions } from './utils'; -export async function normalizeHashOptions( - opt: HistoryCliOptions, -): Promise> { - let { from, to, maxCount, semverTag, targetBranch } = opt; - const tags = await getSemverTags({ targetBranch }); - if (semverTag) { - if (from && !isSemver(from)) { - // @TODO get tag from hash? - } - if (to && !isSemver(to)) { - // @TODO get tag from hash? - } - } else { - if (from && isSemver(from)) { - const { hash } = await getHashFromTag(from); - from = hash; - } - if (to && isSemver(to)) { - const { hash } = await getHashFromTag(to); - to = hash; - } - } +const command = 'history'; +async function handler(args: unknown) { + ui().logger.info(chalk.bold(CLI_NAME)); + ui().logger.info(chalk.gray(`Run ${command}`)); - return { - from, - to, - maxCount: maxCount && maxCount > 0 ? maxCount : undefined, - }; + const currentBranch = await getCurrentBranchOrTag(); + const { targetBranch: rawTargetBranch, ...opt } = args as HistoryCliOptions & + HistoryOptions; + const { targetBranch, from, to, maxCount, semverTag, ...historyOptions } = + await normalizeHashOptions({ + ...opt, + targetBranch: rawTargetBranch ?? currentBranch, + }); + + const filterOptions = { targetBranch, from, to, maxCount }; + const results: LogResult[] = semverTag + ? await getSemverTags(filterOptions) + : await getHashes(filterOptions); + + ui().logger.info( + `Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold( + targetBranch, + )}:`, + ); + results.forEach(({ hash, message }) => { + ui().logger.info(`${hash} - ${message.slice(0, 100)}`); + }); + + 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', @@ -61,53 +73,8 @@ export function yargsHistoryCommandObject() { Object.keys(yargsHistoryOptionsDefinition()), 'History Options:', ); - yargs.middleware(historyMiddleware as MiddlewareFunction); 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, - from, - to, - maxCount, - semverTag, - forceCleanStatus, - ...historyOptions - } = args as unknown as HistoryCliOptions & HistoryOptions; - - const filterOptions = { targetBranch, from, to, maxCount }; - const results: LogResult[] = semverTag - ? await getSemverTags(filterOptions) - : await getHashes(filterOptions); - - ui().logger.info( - `Log ${chalk.bold( - semverTag ? 'tags' : 'commits', - )} for branch ${chalk.bold(targetBranch)}:`, - ); - results.forEach(({ hash, message }) => - { ui().logger.info(`${hash} - ${message.slice(0, 85)}`); }, - ); - - 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); - } - }, + handler, } satisfies CommandModule; } diff --git a/packages/cli/src/lib/history/history.middleware.ts b/packages/cli/src/lib/history/history.middleware.ts deleted file mode 100644 index aaf46eb51..000000000 --- a/packages/cli/src/lib/history/history.middleware.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { HistoryOptions } from '@code-pushup/core'; -import { getCurrentBranchOrTag } from '@code-pushup/utils'; -import { CoreConfigCliOptions } from '../implementation/core-config.model'; -import { GeneralCliOptions } from '../implementation/global.model'; -import { OnlyPluginsOptions } from '../implementation/only-plugins.model'; -import { normalizeHashOptions } from './history-command'; -import { HistoryCliOptions } from './history.model'; - -export async function historyMiddleware< - T extends GeneralCliOptions & - CoreConfigCliOptions & - OnlyPluginsOptions & - HistoryCliOptions & - HistoryOptions, ->(processArgs: T): Promise { - const currentBranch = await getCurrentBranchOrTag(); - const { - semverTag, - targetBranch = currentBranch, - // overwritten - from: rawFrom, - to: rawTo, - maxCount: rawMaxCount, - ...processOptions - } = processArgs; - - const filterOptions = (await normalizeHashOptions({ - targetBranch, - from: rawFrom, - to: rawTo, - maxCount: rawMaxCount, - })) as T; - - return { - semverTag, - targetBranch, - ...filterOptions, - ...processOptions, - }; -} diff --git a/packages/cli/src/lib/history/utils.ts b/packages/cli/src/lib/history/utils.ts new file mode 100644 index 000000000..682b27276 --- /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 { + semverTag, + // 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 (!semverTag) { + if (from && isSemver(from)) { + const { hash } = await getHashFromTag(from); + from = hash; + } + if (to && isSemver(to)) { + const { hash } = await getHashFromTag(to); + to = hash; + } + } + + return { + ...processOptions, + semverTag, + from, + to, + maxCount: maxCount && maxCount > 0 ? maxCount : undefined, + }; +} 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 index 193aa8d80..acf93d3c8 100644 --- 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 @@ -1,9 +1,14 @@ -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'; +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'; describe('getCurrentBranchOrTag', () => { const baseDir = join(process.cwd(), 'tmp', 'git-tests'); @@ -92,7 +97,7 @@ describe.skip('getHashes', () => { }); describe('without a branch and commits', () => { - it('getHashes should throw', async () => { + it('should throw', async () => { await expect(getHashes({}, gitMock)).rejects.toThrow( "your current branch 'master' does not have any commits yet", ); @@ -100,49 +105,48 @@ describe.skip('getHashes', () => { }); describe('with a branch and commits clean', () => { - let commits: { hash: string; message: string }[] = []; + const commits: { hash: string; message: string }[] = []; + + async function addLatestCommitToSet() { + const { hash = '', message = '' } = (await gitMock.log()).latest ?? {}; + // eslint-disable-next-line functional/immutable-data + commits.unshift({ hash, message }); + } beforeAll(async () => { await addUpdateFile(gitMock, { baseDir, commitMsg: 'Create README' }); - commits.unshift( - (await gitMock.log()).latest as { hash: string; message: string }, - ); + await addLatestCommitToSet(); await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 1' }); - commits.unshift( - (await gitMock.log()).latest as { hash: string; message: string }, - ); + await addLatestCommitToSet(); await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 2' }); - commits.unshift( - (await gitMock.log()).latest as { hash: string; message: string }, - ); + await addLatestCommitToSet(); await gitMock.checkout(['master']); - commits = commits.map(({ hash, message }) => ({ hash, message })); }); afterAll(async () => { await gitMock.checkout(['master']); }); - it('getHashes should get all commits from log if no option is passed', async () => { + it('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 () => { + 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('getHashes should get commits from log based on "from"', async () => { + it('should get commits from log based on "from"', async () => { await expect( getHashes({ from: commits.at(0)?.hash }, gitMock), ).resolves.toEqual([commits.at(-2), commits.at(-1)]); }); - it('getHashes should get commits from log based on "from" and "to"', async () => { + it('should get commits from log based on "from" and "to"', async () => { await expect( getHashes( { from: commits.at(-1)?.hash, to: commits.at(0)?.hash }, @@ -151,7 +155,7 @@ describe.skip('getHashes', () => { ).resolves.toEqual([commits.at(-2), commits.at(-1)]); }); - it('getHashes should get commits from log based on "from" and "to" and "maxCount"', async () => { + 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(0)?.hash, maxCount: 1 }, @@ -160,7 +164,7 @@ describe.skip('getHashes', () => { ).resolves.toEqual([commits.at(-1)]); }); - it('getHashes should throw if "from" is undefined but "to" is defined', async () => { + it('should throw if "from" is undefined but "to" is defined', async () => { await expect( getHashes({ from: undefined, to: 'a' }, gitMock), ).rejects.toThrow( @@ -192,21 +196,12 @@ describe('getSemverTags', () => { }); describe('with a branch and only commits clean', () => { - let commits: { hash: string; message: string }[] = []; beforeAll(async () => { await addUpdateFile(gitSemverTagsMock, { baseDir, commitMsg: 'Create README', }); - commits.unshift( - (await gitSemverTagsMock.log()).latest as { - hash: string; - message: string; - }, - ); - await gitSemverTagsMock.checkout(['master']); - commits = commits.map(({ hash, message }) => ({ hash, message })); }); afterAll(async () => { @@ -221,34 +216,20 @@ describe('getSemverTags', () => { }); describe.skip('with a branch and tagged commits clean', () => { - let commits: { hash: string; message: string }[] = []; beforeAll(async () => { await gitSemverTagsMock.checkout(['master']); await addUpdateFile(gitSemverTagsMock, { baseDir, commitMsg: 'Create README', }); - commits.unshift( - (await gitSemverTagsMock.log()).latest as { - hash: string; - message: string; - }, - ); await addUpdateFile(gitSemverTagsMock, { baseDir, commitMsg: 'release v1', tagName: '1', }); - commits.unshift( - (await gitSemverTagsMock.log()).latest as { - hash: string; - message: string; - }, - ); await gitSemverTagsMock.checkout(['master']); - commits = commits.map(({ hash, message }) => ({ hash, message })); }); afterAll(async () => { diff --git a/packages/utils/src/lib/git/git.commits-and-tags.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts index bfff84d3a..af61eb0f4 100644 --- a/packages/utils/src/lib/git/git.commits-and-tags.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -1,6 +1,6 @@ -import {LogOptions as SimpleGitLogOptions, simpleGit} from 'simple-git'; -import {Commit, commitSchema} from '@code-pushup/models'; -import {isSemver} from '../semver'; +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(), @@ -49,8 +49,8 @@ export function filterLogs( } validateFilter(opt); const { from, to, maxCount } = opt; - const finIndex = (tagName = '', fallback: T) => { - const idx = allTags.indexOf(tagName); + const finIndex = (tagName?: string, fallback?: T) => { + const idx = allTags.indexOf(tagName ?? ''); if (idx > -1) { return idx; } @@ -83,22 +83,24 @@ export type LogOptions = { }; export async function getSemverTags( - { targetBranch, ...opt }: LogOptions = {}, + 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); - } else { - targetBranch = await getCurrentBranchOrTag(git); } // Fetch all tags merged into the target branch - const tagsRaw = await git.tag(['--merged', targetBranch]); + const tagsRaw = await git.tag([ + '--merged', + targetBranch ?? (await getCurrentBranchOrTag(git)), + ]); const allTags = tagsRaw .split(/\n/) @@ -106,12 +108,11 @@ export async function getSemverTags( .filter(Boolean) .filter(isSemver); - const relevantTags = filterLogs(allTags, opt); + const relevantTags = filterLogs(allTags, options); - const tagsWithHashes: LogResult[] = []; - for (const tag of relevantTags) { - tagsWithHashes.push(await getHashFromTag(tag, git)); - } + const tagsWithHashes: LogResult[] = await Promise.all( + relevantTags.map(tag => getHashFromTag(tag, git)), + ); if (currentBranch) { await git.checkout(currentBranch); @@ -168,6 +169,7 @@ export async function getHashes( 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); diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index 880170a99..001223bde 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -17,11 +17,10 @@ export function isSemver(semverString = ''): boolean { } // eslint-disable functional/immutable-data export function sortSemvers(semverStrings: string[]): string[] { - return semverStrings + return [...semverStrings] .filter(Boolean) .filter(isSemver) .sort((a, b) => - compare(normalizeSemver(a), normalizeSemver(b), '<=') ? -1 : 0, - ) - .reverse(); + compare(normalizeSemver(a), normalizeSemver(b), '>') ? -1 : 0, + ); } diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts index 9d6e87c22..9d9080d0c 100644 --- a/testing/test-utils/src/lib/utils/git.ts +++ b/testing/test-utils/src/lib/utils/git.ts @@ -45,11 +45,11 @@ export async function addUpdateFile( file ?? {}; await writeFile(join(baseDir, name), content); await git.add(name); - if(tagName) { - await git.tag([tagName]) + if (tagName) { + await git.tag([tagName]); } - if(commitMsg) { - await git.commit(commitMsg) + if (commitMsg) { + await git.commit(commitMsg); } return git; } From 54d9c84f9d684bbe52840eb0120b5360a0abc53b Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 14 Apr 2024 13:22:49 +0200 Subject: [PATCH 24/34] fix tests --- .../cli/src/lib/history/history-command.ts | 5 +- .../lib/history/history-command.unit.test.ts | 24 ++-- packages/cli/src/lib/history/utils.ts | 2 +- .../cli/src/lib/history/utils.unit.test.ts | 119 ++++++++++++++++++ .../git.commits-and-tags.integration.test.ts | 4 +- .../lib/git/git.commits-and-tags.unit.test.ts | 2 +- 6 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/lib/history/utils.unit.test.ts diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 058507a73..3403929fb 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -33,15 +33,16 @@ async function handler(args: unknown) { const results: LogResult[] = semverTag ? await getSemverTags(filterOptions) : await getHashes(filterOptions); - + /* ui().logger.info( `Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold( targetBranch, )}:`, ); results.forEach(({ hash, message }) => { - ui().logger.info(`${hash} - ${message.slice(0, 100)}`); + ui().logger.info(`${hash} - ${message}`); }); +*/ try { // run history logic 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 a72029dd8..b796c53be 100644 --- a/packages/cli/src/lib/history/history-command.unit.test.ts +++ b/packages/cli/src/lib/history/history-command.unit.test.ts @@ -59,19 +59,19 @@ vi.mock('simple-git', async () => { }); describe('history-command', () => { - it.skip('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(); + 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()], + }).parseAsync(); - expect(history).toHaveBeenCalledWith(expect.any(Object), [ - 'commit-1', - 'commit-2--release-v1', - ]); + expect(history).toHaveBeenCalledWith( + expect.objectContaining({ + targetBranch: 'main', + forceCleanStatus: false, + }), + expect.any(Array), + ); expect(safeCheckout).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/lib/history/utils.ts b/packages/cli/src/lib/history/utils.ts index 682b27276..c7d6579a9 100644 --- a/packages/cli/src/lib/history/utils.ts +++ b/packages/cli/src/lib/history/utils.ts @@ -29,8 +29,8 @@ export async function normalizeHashOptions( return { ...processOptions, semverTag, + maxCount: maxCount && maxCount > 0 ? maxCount : undefined, from, to, - maxCount: maxCount && maxCount > 0 ? maxCount : undefined, }; } 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..a919935a8 --- /dev/null +++ b/packages/cli/src/lib/history/utils.unit.test.ts @@ -0,0 +1,119 @@ +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'); + return { + ...actual, + simpleGit: () => ({ + branch: () => Promise.resolve('dummy'), + raw: () => Promise.resolve('main'), + tag: () => Promise.resolve(`2\n1`), + show: ([_, __, tag]: string) => + ['1', '2'].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-v2' }, + { hash: 'commit-3' }, + { hash: 'commit--release-v1' }, + { 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 "form" and "to" as is if "semverTag" 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 "form" and "to" to commit hashes if "semverTag" is false', async () => { + await expect( + normalizeHashOptions({ + semverTag: false, + from: '2', + to: '1', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + semverTag: false, + from: 'commit--release-v2', + to: 'commit--release-v1', + }), + ); + }); + + it('should forward tags "form" and "to" if "semverTag" is true', async () => { + await expect( + normalizeHashOptions({ + semverTag: true, + from: '2', + to: '1', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + semverTag: true, + from: '2', + to: '1', + }), + ); + }); + + it('should forward hashes "form" and "to" if "semverTag" is true', async () => { + await expect( + normalizeHashOptions({ + semverTag: true, + from: 'commit-3', + to: 'commit-1', + } as HistoryCliOptions & HistoryOptions), + ).resolves.toEqual( + expect.objectContaining({ + semverTag: true, + from: 'commit-3', + to: 'commit-1', + }), + ); + }); +}); 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 index acf93d3c8..7fd60db35 100644 --- 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 @@ -84,7 +84,7 @@ describe('getLatestCommit', () => { }); }); -describe.skip('getHashes', () => { +describe('getHashes', () => { const baseDir = join(process.cwd(), 'tmp', 'utils-git-get-hashes'); let gitMock: SimpleGit; @@ -215,7 +215,7 @@ describe('getSemverTags', () => { }); }); - describe.skip('with a branch and tagged commits clean', () => { + describe('with a branch and tagged commits clean', () => { beforeAll(async () => { await gitSemverTagsMock.checkout(['master']); await addUpdateFile(gitSemverTagsMock, { 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 index 8f78af0c0..181fd4b07 100644 --- 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 @@ -63,7 +63,7 @@ describe('filterLogs', () => { }); }); -describe('gitSemverTagsMock', () => { +describe('getSemverTags', () => { it('should list all tags on the branch', async () => { await expect(getSemverTags({})).resolves.toStrictEqual([ { From 6719fcdffa1a6dad09ad89f44a9d5e6d66d885a5 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 14 Apr 2024 13:45:23 +0200 Subject: [PATCH 25/34] cleanup --- README.md | 37 ++++++++++++++++++- .../cli/src/lib/history/history-command.ts | 10 ----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1534e6cdf..ade13e145 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# hello-world +# Code PushUp CLI + +[![version](https://img.shields.io/github/package-json/v/code-pushup/cli)](https://www.npmjs.com/package/%40code-pushup%2Fcli) +[![release date](https://img.shields.io/github/release-date/code-pushup/cli)](https://github.com/code-pushup/cli/releases) +[![license](https://img.shields.io/github/license/code-pushup/cli)](https://opensource.org/licenses/MIT) +[![commit activity](https://img.shields.io/github/commit-activity/m/code-pushup/cli)](https://github.com/code-pushup/cli/pulse/monthly) +[![CI](https://github.com/code-pushup/cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/code-pushup/cli/actions/workflows/ci.yml?query=branch%3Amain) +[![Codecov](https://codecov.io/gh/code-pushup/cli/branch/main/graph/badge.svg?token=Y7V489JZ4A)](https://codecov.io/gh/code-pushup/cli) + +πŸ”ŽπŸ”¬ **Quality metrics for your software project.** πŸ“‰πŸ” + +1. βš™οΈ **Configure what you want to track using your favourite tools.** +2. πŸ€– **Integrate it in your CI.** +3. 🌈 **Visualize reports in a beautiful dashboard.** + +--- + +| πŸ“Š Getting Started | 🌐 Portal Integration | πŸ› οΈ CI Automation | +| :--------------------------------------------------------------------------: | :------------------------------------------------------------------------: | :----------------------------------------------------------------: | +| **[How to setup](./packages/cli/README.md#getting-started)** a basic project | Sort, filter **[your goals](./packages/cli/README.md#portal-integration)** | Updates **[on every PR](./packages/cli/README.md#-ci-automation)** | + +--- + +This monorepo contains code for open-source Code PushUp NPM packages: + +- [πŸ“¦ @code-pushup/cli](./packages/cli#readme) - **CLI** for **collecting** audit results and **uploading** report to portal +- [πŸ“¦ @code-pushup/core](./packages/core#readme) - implementation of **core business logic** (useful for custom integrations) +- [πŸ“¦ @code-pushup/models](./packages/models#readme) - **schemas and types** for data models (useful for custom plugins or other integrations) +- [πŸ“¦ @code-pushup/utils](./packages/utils#readme) - various **utilities** (useful for custom plugins or other integrations) +- plugins: + - [🧩 @code-pushup/eslint-plugin](./packages/plugin-eslint#readme) - static analysis using **ESLint** rules + - [🧩 @code-pushup/coverage-plugin](./packages/plugin-coverage#readme) - code coverage analysis + - [🧩 @code-pushup/js-packages-plugin](./packages/plugin-js-packages#readme) - package audit and outdated dependencies + - [🧩 @code-pushup/lighthouse-plugin](./packages/plugin-lighthouse#readme) - web performance and best practices from **Lighthouse** + +If you want to contribute, please refer to [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 3403929fb..506a64282 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -33,16 +33,6 @@ async function handler(args: unknown) { const results: LogResult[] = semverTag ? await getSemverTags(filterOptions) : await getHashes(filterOptions); - /* - ui().logger.info( - `Log ${chalk.bold(semverTag ? 'tags' : 'commits')} for branch ${chalk.bold( - targetBranch, - )}:`, - ); - results.forEach(({ hash, message }) => { - ui().logger.info(`${hash} - ${message}`); - }); -*/ try { // run history logic From b35ba48148bc050cb39922daa4accef18d9f9648 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 14 Apr 2024 14:12:42 +0200 Subject: [PATCH 26/34] fix tests --- .../git.commits-and-tags.integration.test.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) 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 index 7fd60db35..d7d83a347 100644 --- 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 @@ -10,6 +10,13 @@ import { 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; @@ -105,22 +112,13 @@ describe('getHashes', () => { }); describe('with a branch and commits clean', () => { - const commits: { hash: string; message: string }[] = []; - - async function addLatestCommitToSet() { - const { hash = '', message = '' } = (await gitMock.log()).latest ?? {}; - // eslint-disable-next-line functional/immutable-data - commits.unshift({ hash, message }); - } + let commits: { hash: string; message: string }[] = []; beforeAll(async () => { await addUpdateFile(gitMock, { baseDir, commitMsg: 'Create README' }); - await addLatestCommitToSet(); - await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 1' }); - await addLatestCommitToSet(); - await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 2' }); - await addLatestCommitToSet(); + await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 3' }); + commits = await getAllCommits(gitMock); await gitMock.checkout(['master']); }); @@ -142,26 +140,26 @@ describe('getHashes', () => { it('should get commits from log based on "from"', async () => { await expect( - getHashes({ from: commits.at(0)?.hash }, gitMock), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + 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(0)?.hash }, + { from: commits.at(-1)?.hash, to: commits.at(1)?.hash }, gitMock, ), - ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + ).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(0)?.hash, maxCount: 1 }, + { from: commits.at(-1)?.hash, to: commits.at(1)?.hash, maxCount: 1 }, gitMock, ), - ).resolves.toEqual([commits.at(-1)]); + ).resolves.toEqual([commits.at(1)]); }); it('should throw if "from" is undefined but "to" is defined', async () => { @@ -241,7 +239,7 @@ describe('getSemverTags', () => { [ { hash: expect.any(String), - message: 'release v1', + message: '1', }, ], ); From a6319ab9fc9b5c1d8ddc69bd9f2fd4ac272f21db Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 14 Apr 2024 14:18:30 +0200 Subject: [PATCH 27/34] fix lint --- .../utils/src/lib/git/git.commits-and-tags.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d7d83a347..a1b7a16f7 100644 --- 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 @@ -112,7 +112,7 @@ describe('getHashes', () => { }); describe('with a branch and commits clean', () => { - let commits: { hash: string; message: string }[] = []; + let commits: { hash: string; message: string }[]; beforeAll(async () => { await addUpdateFile(gitMock, { baseDir, commitMsg: 'Create README' }); await addUpdateFile(gitMock, { baseDir, commitMsg: 'Update README 1' }); From 43b057872e0bbd63e6d90162b657ee6db1462e19 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:08:13 +0200 Subject: [PATCH 28/34] Update packages/cli/src/lib/history/history.options.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MatΔ›j Chalk <34691111+matejchalk@users.noreply.github.com> --- packages/cli/src/lib/history/history.options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/history/history.options.ts b/packages/cli/src/lib/history/history.options.ts index fad19a832..54dc1ddb7 100644 --- a/packages/cli/src/lib/history/history.options.ts +++ b/packages/cli/src/lib/history/history.options.ts @@ -10,8 +10,8 @@ export function yargsHistoryOptionsDefinition(): Record< describe: 'Branch to crawl history', type: 'string', }, - semverTag: { - describe: 'analyse semver tags only', + onlySemverTags: { + describe: 'Skip commits not tagged with a semantic version', type: 'boolean', default: false, }, From e5a38f090d0560e2c1792715da8c87e29150a6e2 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:11:56 +0200 Subject: [PATCH 29/34] Update packages/cli/src/lib/history/utils.unit.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MatΔ›j Chalk <34691111+matejchalk@users.noreply.github.com> --- packages/cli/src/lib/history/utils.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/history/utils.unit.test.ts b/packages/cli/src/lib/history/utils.unit.test.ts index a919935a8..e34121755 100644 --- a/packages/cli/src/lib/history/utils.unit.test.ts +++ b/packages/cli/src/lib/history/utils.unit.test.ts @@ -55,7 +55,7 @@ describe('normalizeHashOptions', () => { ); }); - it('should forward hashes "form" and "to" as is if "semverTag" is false', async () => { + it('should forward hashes "from" and "to" as is if "semverTag" is false', async () => { await expect( normalizeHashOptions({ from: 'commit-3', From c75cc81f0ab01cdc047a17800ecbecec897b98ae Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:12:06 +0200 Subject: [PATCH 30/34] Update testing/test-utils/src/lib/utils/git.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MatΔ›j Chalk <34691111+matejchalk@users.noreply.github.com> --- testing/test-utils/src/lib/utils/git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts index 9d9080d0c..4cc67ff3e 100644 --- a/testing/test-utils/src/lib/utils/git.ts +++ b/testing/test-utils/src/lib/utils/git.ts @@ -6,7 +6,7 @@ export type GitConfig = { name: string; email: string }; export async function emptyGitMock( git: SimpleGitFactory, - opt: { baseDir: string } & { config?: GitConfig }, + opt: { baseDir: string; config?: GitConfig }, ): Promise { const { baseDir, config } = opt; const { email = 'john.doe@example.com', name = 'John Doe' } = config ?? {}; From 71fb7e9044829ecafa3b2a89cac747b92eda6e6f Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:13:01 +0200 Subject: [PATCH 31/34] Update packages/utils/src/lib/semver.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MatΔ›j Chalk <34691111+matejchalk@users.noreply.github.com> --- packages/utils/src/lib/semver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index 001223bde..dfb5f5438 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -15,9 +15,9 @@ export function normalizeSemver(semverString: string): string { export function isSemver(semverString = ''): boolean { return validate(normalizeSemver(semverString)); } -// eslint-disable functional/immutable-data + export function sortSemvers(semverStrings: string[]): string[] { - return [...semverStrings] + return semverStrings .filter(Boolean) .filter(isSemver) .sort((a, b) => From 0abc6f6af8214e6abeccacdb7c9293a05106255f Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 16 Apr 2024 12:14:54 +0200 Subject: [PATCH 32/34] refactor(cli): improve docs and tests for history command --- packages/cli/docs/custom-plugins.md | 2 +- packages/cli/src/lib/history/utils.unit.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/utils.unit.test.ts b/packages/cli/src/lib/history/utils.unit.test.ts index e34121755..da5162117 100644 --- a/packages/cli/src/lib/history/utils.unit.test.ts +++ b/packages/cli/src/lib/history/utils.unit.test.ts @@ -69,7 +69,7 @@ describe('normalizeHashOptions', () => { ); }); - it('should transform tags "form" and "to" to commit hashes if "semverTag" is false', async () => { + it('should transform tags "from" and "to" to commit hashes if "semverTag" is false', async () => { await expect( normalizeHashOptions({ semverTag: false, @@ -85,7 +85,7 @@ describe('normalizeHashOptions', () => { ); }); - it('should forward tags "form" and "to" if "semverTag" is true', async () => { + it('should forward tags "from" and "to" if "semverTag" is true', async () => { await expect( normalizeHashOptions({ semverTag: true, @@ -101,7 +101,7 @@ describe('normalizeHashOptions', () => { ); }); - it('should forward hashes "form" and "to" if "semverTag" is true', async () => { + it('should forward hashes "from" and "to" if "semverTag" is true', async () => { await expect( normalizeHashOptions({ semverTag: true, From da832e3382d239171bd751808e910ebc817aa833 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 16 Apr 2024 13:00:00 +0200 Subject: [PATCH 33/34] refactor(cli): use package 'semver' instead of 'compare-versions' --- package-lock.json | 13 +--- package.json | 2 +- .../cli/src/lib/history/history-command.ts | 18 +++-- packages/cli/src/lib/history/history.model.ts | 2 +- packages/cli/src/lib/history/utils.ts | 6 +- .../cli/src/lib/history/utils.unit.test.ts | 45 ++++++------- .../cli/src/lib/yargs-cli.integration.test.ts | 8 +-- packages/utils/package.json | 2 +- .../git.commits-and-tags.integration.test.ts | 4 +- .../lib/git/git.commits-and-tags.unit.test.ts | 66 +++++++++++-------- packages/utils/src/lib/semver.ts | 9 ++- packages/utils/src/lib/semver.unit.test.ts | 54 +++------------ 12 files changed, 99 insertions(+), 130 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7987b986..e099f0043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ "bundle-require": "^4.0.1", "chalk": "^5.3.0", "cli-table3": "^0.6.3", - "compare-versions": "^6.1.0", "esbuild": "^0.19.2", "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", @@ -10066,11 +10066,6 @@ "dot-prop": "^5.1.0" } }, - "node_modules/compare-versions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", - "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" - }, "node_modules/compressible": { "version": "2.0.18", "dev": true, @@ -21001,8 +20996,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" }, @@ -21041,7 +21036,6 @@ }, "node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -21052,7 +21046,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 f120be7a2..fb0e98c98 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,10 @@ "bundle-require": "^4.0.1", "chalk": "^5.3.0", "cli-table3": "^0.6.3", - "compare-versions": "^6.1.0", "esbuild": "^0.19.2", "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/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index 506a64282..2926994cf 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -23,14 +23,20 @@ async function handler(args: unknown) { const currentBranch = await getCurrentBranchOrTag(); const { targetBranch: rawTargetBranch, ...opt } = args as HistoryCliOptions & HistoryOptions; - const { targetBranch, from, to, maxCount, semverTag, ...historyOptions } = - await normalizeHashOptions({ - ...opt, - targetBranch: rawTargetBranch ?? currentBranch, - }); + const { + targetBranch, + from, + to, + maxCount, + onlySemverTags, + ...historyOptions + } = await normalizeHashOptions({ + ...opt, + targetBranch: rawTargetBranch ?? currentBranch, + }); const filterOptions = { targetBranch, from, to, maxCount }; - const results: LogResult[] = semverTag + const results: LogResult[] = onlySemverTags ? await getSemverTags(filterOptions) : await getHashes(filterOptions); diff --git a/packages/cli/src/lib/history/history.model.ts b/packages/cli/src/lib/history/history.model.ts index 7a10955ab..31fa5e42d 100644 --- a/packages/cli/src/lib/history/history.model.ts +++ b/packages/cli/src/lib/history/history.model.ts @@ -3,6 +3,6 @@ import { HistoryOnlyOptions } from '@code-pushup/core'; export type HistoryCliOptions = { targetBranch?: string; - semverTag?: boolean; + onlySemverTags?: boolean; } & Pick & HistoryOnlyOptions; diff --git a/packages/cli/src/lib/history/utils.ts b/packages/cli/src/lib/history/utils.ts index c7d6579a9..63f6300de 100644 --- a/packages/cli/src/lib/history/utils.ts +++ b/packages/cli/src/lib/history/utils.ts @@ -6,7 +6,7 @@ export async function normalizeHashOptions( processArgs: HistoryCliOptions & HistoryOptions, ): Promise { const { - semverTag, + onlySemverTags, // overwritten maxCount, ...opt @@ -15,7 +15,7 @@ export async function normalizeHashOptions( // 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 (!semverTag) { + if (!onlySemverTags) { if (from && isSemver(from)) { const { hash } = await getHashFromTag(from); from = hash; @@ -28,7 +28,7 @@ export async function normalizeHashOptions( return { ...processOptions, - semverTag, + 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 index da5162117..82054a687 100644 --- a/packages/cli/src/lib/history/utils.unit.test.ts +++ b/packages/cli/src/lib/history/utils.unit.test.ts @@ -5,14 +5,15 @@ 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(`2\n1`), + tag: () => Promise.resolve(orderedTagsHistory.join('\n')), show: ([_, __, tag]: string) => - ['1', '2'].includes(tag || '') + orderedTagsHistory.includes(tag || '') ? Promise.resolve(`${tag}\ncommit--release-v${tag}`) : Promise.reject('NOT FOUND TAG'), checkout: () => Promise.resolve(), @@ -21,9 +22,9 @@ vi.mock('simple-git', async () => { all: [ { hash: 'commit-6' }, { hash: 'commit-5' }, - { hash: 'commit--release-v2' }, + { hash: `commit--release-v${orderedTagsHistory.at(0)}` }, { hash: 'commit-3' }, - { hash: 'commit--release-v1' }, + { hash: `commit--release-v${orderedTagsHistory.at(1)}` }, { hash: 'commit-1' }, ].slice(-maxCount), }), @@ -55,7 +56,7 @@ describe('normalizeHashOptions', () => { ); }); - it('should forward hashes "from" and "to" as is if "semverTag" is false', async () => { + it('should forward hashes "from" and "to" as is if "onlySemverTags" is false', async () => { await expect( normalizeHashOptions({ from: 'commit-3', @@ -69,48 +70,48 @@ describe('normalizeHashOptions', () => { ); }); - it('should transform tags "from" and "to" to commit hashes if "semverTag" is false', async () => { + it('should transform tags "from" and "to" to commit hashes if "onlySemverTags" is false', async () => { await expect( normalizeHashOptions({ - semverTag: false, - from: '2', - to: '1', + onlySemverTags: false, + from: '2.0.0', + to: '1.0.0', } as HistoryCliOptions & HistoryOptions), ).resolves.toEqual( expect.objectContaining({ - semverTag: false, - from: 'commit--release-v2', - to: 'commit--release-v1', + onlySemverTags: false, + from: 'commit--release-v2.0.0', + to: 'commit--release-v1.0.0', }), ); }); - it('should forward tags "from" and "to" if "semverTag" is true', async () => { + it('should forward tags "from" and "to" if "onlySemverTags" is true', async () => { await expect( normalizeHashOptions({ - semverTag: true, - from: '2', - to: '1', + onlySemverTags: true, + from: '2.0.0', + to: '1.0.0', } as HistoryCliOptions & HistoryOptions), ).resolves.toEqual( expect.objectContaining({ - semverTag: true, - from: '2', - to: '1', + onlySemverTags: true, + from: '2.0.0', + to: '1.0.0', }), ); }); - it('should forward hashes "from" and "to" if "semverTag" is true', async () => { + it('should forward hashes "from" and "to" if "onlySemverTags" is true', async () => { await expect( normalizeHashOptions({ - semverTag: true, + onlySemverTags: true, from: 'commit-3', to: 'commit-1', } as HistoryCliOptions & HistoryOptions), ).resolves.toEqual( expect.objectContaining({ - semverTag: true, + 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 cc4f119cd..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({ - semverTag: false, + onlySemverTags: false, maxCount: 5, skipUploads: false, }), @@ -168,14 +168,14 @@ describe('yargsCli', () => { ); }); - it('should parse history options and have semverTag true to crawl in history if semverTag is set', async () => { - const result = await yargsCli(['history', '--semverTag'], { + 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({ - semverTag: true, + onlySemverTags: true, }), ); }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 0744ef94f..009afa97e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,6 +10,6 @@ "simple-git": "^3.20.0", "multi-progress-bars": "^5.0.3", "@poppinss/cliui": "^6.4.0", - "compare-versions": "^6.1.0" + "semver": "^7.6.0" } } 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 index a1b7a16f7..44050d2c7 100644 --- 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 @@ -224,7 +224,7 @@ describe('getSemverTags', () => { await addUpdateFile(gitSemverTagsMock, { baseDir, commitMsg: 'release v1', - tagName: '1', + tagName: '1.0.0', }); await gitSemverTagsMock.checkout(['master']); @@ -239,7 +239,7 @@ describe('getSemverTags', () => { [ { hash: expect.any(String), - message: '1', + message: '1.0.0', }, ], ); 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 index 181fd4b07..0a8e2bebb 100644 --- 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 @@ -3,12 +3,13 @@ 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(`5\n 4\n 3\n 2\n 1`), + tag: () => Promise.resolve(orderedTagsHistory.join('\n')), show: ([_, __, tag]: string) => Promise.resolve(`release v${tag}\n ${tag}`), raw: () => Promise.resolve('main'), @@ -38,16 +39,18 @@ describe('filterLogs', () => { it('should forward list the first N items based on "maxCount" filter', () => { expect( - filterLogs(['1', '2', '3', '4', '5'], { maxCount: 2 }), - ).toStrictEqual(['1', '2']); + 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', '2', '3', '4', '5'], { from: '3' })).toStrictEqual([ - '3', - '4', - '5', - ]); + 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', () => { @@ -58,8 +61,11 @@ describe('filterLogs', () => { it('should forward list items starting from index based on "from" & "to" filter', () => { expect( - filterLogs(['1', '2', '3', '4', '5'], { from: '2', to: '4' }), - ).toStrictEqual(['2', '3', '4']); + 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']); }); }); @@ -68,23 +74,23 @@ describe('getSemverTags', () => { await expect(getSemverTags({})).resolves.toStrictEqual([ { hash: expect.any(String), - message: '5', + message: '5.0.0', }, { hash: expect.any(String), - message: '4', + message: '4.0.0', }, { hash: expect.any(String), - message: '3', + message: '3.0.0', }, { hash: expect.any(String), - message: '2', + message: '2.0.0', }, { hash: expect.any(String), - message: '1', + message: '1.0.0', }, ]); }); @@ -93,64 +99,66 @@ describe('getSemverTags', () => { await expect(getSemverTags({ maxCount: 2 })).resolves.toStrictEqual([ { hash: expect.any(String), - message: '5', + message: '5.0.0', }, { hash: expect.any(String), - message: '4', + message: '4.0.0', }, ]); }); it('should get tags from branch based on "from"', async () => { - await expect(getSemverTags({ from: '4' })).resolves.toEqual([ + await expect(getSemverTags({ from: '4.0.0' })).resolves.toEqual([ { hash: expect.any(String), - message: '4', + message: '4.0.0', }, { hash: expect.any(String), - message: '3', + message: '3.0.0', }, { hash: expect.any(String), - message: '2', + message: '2.0.0', }, { hash: expect.any(String), - message: '1', + message: '1.0.0', }, ]); }); it('should get tags from branch based on "from" and "to"', async () => { - await expect(getSemverTags({ from: '4', to: '2' })).resolves.toEqual([ + await expect( + getSemverTags({ from: '4.0.0', to: '2.0.0' }), + ).resolves.toEqual([ { hash: expect.any(String), - message: '4', + message: '4.0.0', }, { hash: expect.any(String), - message: '3', + message: '3.0.0', }, { hash: expect.any(String), - message: '2', + message: '2.0.0', }, ]); }); it('should get tags from branch based on "from" and "to" and "maxCount"', async () => { await expect( - getSemverTags({ from: '4', to: '2', maxCount: 2 }), + getSemverTags({ from: '4.0.0', to: '2.0.0', maxCount: 2 }), ).resolves.toEqual([ { hash: expect.any(String), - message: '4', + message: '4.0.0', }, { hash: expect.any(String), - message: '3', + message: '3.0.0', }, ]); }); diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index dfb5f5438..c0fbf1c1e 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -1,4 +1,4 @@ -import { compare, validate } from 'compare-versions'; +import { rcompare, valid } from 'semver'; export function normalizeSemver(semverString: string): string { if (semverString.startsWith('v') || semverString.startsWith('V')) { @@ -13,14 +13,13 @@ export function normalizeSemver(semverString: string): string { } export function isSemver(semverString = ''): boolean { - return validate(normalizeSemver(semverString)); + return valid(normalizeSemver(semverString)) != null; } export function sortSemvers(semverStrings: string[]): string[] { return semverStrings + .map(normalizeSemver) .filter(Boolean) .filter(isSemver) - .sort((a, b) => - compare(normalizeSemver(a), normalizeSemver(b), '>') ? -1 : 0, - ); + .sort((a, b) => rcompare(a, b)); } diff --git a/packages/utils/src/lib/semver.unit.test.ts b/packages/utils/src/lib/semver.unit.test.ts index 0458b780a..b96696570 100644 --- a/packages/utils/src/lib/semver.unit.test.ts +++ b/packages/utils/src/lib/semver.unit.test.ts @@ -1,49 +1,11 @@ -import { validate } from 'compare-versions'; import { describe, expect, it } from 'vitest'; import { isSemver, normalizeSemver, sortSemvers } from './semver'; -describe('semver-compare validate', () => { - it.each([ - ['v0'], - ['0'], - ['0.0'], - ['00.00.00'], - ['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 match on a valid semver string: %s', versionString => { - expect(validate(versionString)).toBeTruthy(); - }); - - it.each([ - ['V0'], - ['package@1.2.3-alpha'], - ['11.22+33-alpha.4'], // (wrong patch separator) - ['11.22.33-alpha?4'], // (wrong prerelease separator) - ['package-1.2.3-alpha.0'], // (wrong as no @ for prefix) - ['package-11.22.33-alpha.0'], //(wrong package separator) - ])('should not match on a invalid semver string: %s', versionString => { - expect(validate(versionString)).toBeFalsy(); - }); -}); - 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'], - ['00.00.00'], ['0.0.0'], ['0.0.0-alpha'], ['0.0.0-alpha.0'], @@ -71,24 +33,24 @@ describe('isSemver', () => { }); describe('normalizeSemver', () => { - it.each([['0.0.0'], ['v0.0.0'], ['V0.0.0'], ['core@0.0.0']])( + 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('0.0.0'); + expect(normalizeSemver(versionString)).toBe('1.0.0'); }, ); }); describe('sortSemvers', () => { it.each([ - [['0.0.0', '0.0.1']], - [['v0.0.0', 'core@0.0.1']], - [['0.0.0-alpha.0', '0.0.1-alpha.0']], - [['0.0.0-alpha.0', '0.0.1']], + [['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('0.0.1'), - expect.stringContaining('0.0.0'), + expect.stringContaining('1.0.1'), + expect.stringContaining('1.0.0'), ]); }); }); From 896fddc2cdea1d376a4ea5c24afeecb64300559c Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 16 Apr 2024 13:26:44 +0200 Subject: [PATCH 34/34] refactor(utils): improve semver logic --- packages/utils/src/lib/semver.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/utils/src/lib/semver.ts b/packages/utils/src/lib/semver.ts index c0fbf1c1e..c761dd843 100644 --- a/packages/utils/src/lib/semver.ts +++ b/packages/utils/src/lib/semver.ts @@ -17,9 +17,5 @@ export function isSemver(semverString = ''): boolean { } export function sortSemvers(semverStrings: string[]): string[] { - return semverStrings - .map(normalizeSemver) - .filter(Boolean) - .filter(isSemver) - .sort((a, b) => rcompare(a, b)); + return semverStrings.map(normalizeSemver).filter(isSemver).sort(rcompare); }