diff --git a/action.yml b/action.yml index 5919b2ef..80e48f1d 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,8 @@ inputs: version: description: > Version to release. Can be a semver string (e.g., "1.2.3"), - a bump type ("major", "minor", "patch"), or "auto" for automatic detection. + a bump type ("major", "minor", "patch"), "auto" for automatic detection, + or "pre" for the next prerelease. required: false merge_target: description: Target branch to merge into. Uses the default branch as a fallback. diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 3d7cda29..ce6d71ad 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -61,6 +61,7 @@ projects keep their behavior when Craft is upgraded: | `2.21.0` | [Automatic version bumping](../versioning/#automatic-version-bumping) when no `preReleaseCommand` is set. | | `2.29.0` | [Release workspaces](../workspaces/). | | `3.8.0` | [Extra version files](../versioning/#extra-version-files) with `versioning.files`. | +| `3.9.0` | The [`pre` version](../versioning/#choosing-the-next-version) for the next prerelease. | ## github diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 9638eddf..9a319d00 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -52,7 +52,8 @@ Pass the version to `craft prepare` in one of these forms: | --- | --- | | `1.2.3` | Release exactly this version. | | `major`, `minor`, `patch` | Increment the latest tag. | -| `auto` | Pick the bump from the commits since the latest tag, using the `semver` field of each [changelog category](../changelogs/#craft-extensions-to-releaseyml). | +| `auto` | Pick the bump from the commits since the latest stable tag, using the `semver` field of each [changelog category](../changelogs/#craft-extensions-to-releaseyml). | +| `pre` | Cut a prerelease of the version `auto` would release. | | `calver` | Compute a [calendar version](#calendar-versioning-calver). | | *(omitted)* | Use `versioning.policy`. | @@ -62,7 +63,31 @@ Craft reads it from the files of the first configured target that [supports version bumping](#supported-targets) and declares a semver version above `0.0.0`. Without one, they increment `0.0.0`. -Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later. +After a prerelease, bump types and `auto` promote it to a stable release: +`2.1.0-alpha.3` becomes `2.1.0`, or a higher version when the bump needs one. +To be safe, `auto` doesn't promote a prerelease to a major release: after +`2.0.0-alpha.3` it cuts `2.0.0-alpha.4`. Pass `2.0.0` or `major` to release +`2.0.0`. + +`pre` takes the version `auto` would release and cuts a prerelease of it. +After `1.9.0`, it cuts `1.9.1-alpha.0` when `auto` picks a patch bump, +`1.10.0-alpha.0` for a minor bump, and `2.0.0-alpha.0` for a major bump. When +the latest tag is already a prerelease of that version, `pre` cuts the next one +instead: `1.10.0-alpha.1` after `1.10.0-alpha.0`. + +Bump types, `auto`, and `calver` require `minVersion: '2.14.0'` or later, and +`pre` requires `minVersion: '3.9.0'` or later. + +### Changelog base + +The changelog of a release lists the changes since: + +- **Stable release**: the latest stable tag, including the changes already in + the prereleases since then. `2.0.0` lists the changes since `1.9.0`. +- **Prerelease**: the previous prerelease of the same `major.minor.patch`, or + the latest stable tag when there is none. `2.0.0-alpha.4` lists the changes + since `2.0.0-alpha.3`, and `2.1.0-alpha.0` lists the changes since `2.0.4`, + not since `2.0.0-alpha.3`. ### Versioning policies diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts index 4a61446c..e08d970e 100644 --- a/src/__tests__/prepare-dry-run.e2e.test.ts +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -701,6 +701,83 @@ targets: [] expect(combinedOutput).toContain('release/1.1.0'); }, 60000); + test('auto releases 1.1.0 after 1.1.0-alpha.1 with the changes since 1.0.0', async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + await writeFile( + join(tempDir, '.craft.yml'), + `minVersion: "2.14.0" +github: + owner: test-owner + repo: test-repo +versioning: + policy: auto +changelog: + policy: auto +preReleaseCommand: "" +targets: [] +`, + ); + await git.add('.'); + await git.commit('feat: Enable automatic versioning'); + await git.addTag('1.1.0-alpha.1'); + await git.push('origin', (await git.status()).current!); + + // No commits since the prerelease, so only the changes since the last + // stable release can determine the version. + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '--dry-run', '--no-input'], + { cwd: tempDir, env: { ...CLI_ENV, GITHUB_TOKEN: '' } }, + ); + + const output = stdout + stderr; + expect(output).toContain('Analyzing commits since 1.0.0'); + expect(output).toContain('Version bump: 1.1.0-alpha.1 -> 1.1.0'); + expect(output).toContain('## 1.1.0'); + expect(output).toContain('Add foo feature in'); + }, 60000); + + test('auto releases 2.0.0-alpha.1 instead of 2.0.0 after 2.0.0-alpha.0', async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + await writeFile( + join(tempDir, '.craft.yml'), + `minVersion: "2.14.0" +github: + owner: test-owner + repo: test-repo +versioning: + policy: auto +changelog: + policy: auto +preReleaseCommand: "" +targets: [] +`, + ); + await git.add('.'); + await git.commit('feat!: Enable automatic versioning'); + await git.addTag('2.0.0-alpha.0'); + await writeFile(join(tempDir, 'baz.ts'), 'export const baz = 3;'); + await git.add('.'); + await git.commit('fix: Fix baz issue'); + await git.push('origin', (await git.status()).current!); + + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '--dry-run', '--no-input'], + { cwd: tempDir, env: { ...CLI_ENV, GITHUB_TOKEN: '' } }, + ); + + const output = stdout + stderr; + expect(output).toContain('Version bump: 2.0.0-alpha.0 -> 2.0.0-alpha.1'); + expect(output).toContain('## 2.0.0-alpha.1'); + expect(output).toContain('Fix baz issue in'); + expect(output).not.toContain('Add foo feature in'); + }, 60000); + test('prepares a workspace changelog without changing the root changelog', async () => { tempDir = await createTestRepo(); // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index 90d25c3b..de400a24 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -292,6 +292,10 @@ describe('checkVersionOrPart', () => { ).toBe(true); }); + test('return true for pre version', () => { + expect(checkVersionOrPart({ newVersion: 'pre' }, null)).toBe(true); + }); + test('return true for version bump types', () => { const bumpTypes = ['major', 'minor', 'patch']; for (const bumpType of bumpTypes) { diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index ee129d79..235ae6e2 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fsPromises } from 'fs'; import { relative } from 'path'; +import * as semver from 'semver'; import type { SimpleGit, StatusResult } from 'simple-git'; import type { Arguments, Argv, CommandBuilder } from 'yargs'; import { createDryRunIsolation, safeFs } from '../utils/dryRun'; @@ -22,6 +23,7 @@ import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; import { sleep } from '../utils/async'; import { type BumpType, + calculatePrerelease, calculateNextVersion, getChangelogWithBumpType, isBumpType, @@ -44,6 +46,8 @@ import { import { getDefaultBranch, getGitClient, + getChangelogBaseTag, + getLatestStableTag, getLatestTag, isRepoDirty, } from '../utils/git'; @@ -58,7 +62,7 @@ import { import { formatJson } from '../utils/strings'; import { runPreReleaseCommand } from '../utils/preReleaseCommand'; import { withTracing } from '../utils/tracing'; -import { getVersion, isValidVersion } from '../utils/version'; +import { getVersion, isValidVersion, parseVersion } from '../utils/version'; import { readProjectVersion } from '../utils/versionBump'; import { type PublishOptions, handler as publishMainHandler } from './publish'; @@ -71,13 +75,17 @@ export const description = '🚢 Prepare a new release branch'; /** Minimum craft version required for auto-versioning */ const AUTO_VERSION_MIN_VERSION = '2.14.0'; +/** Minimum craft version required for the `pre` version */ +const PRERELEASE_VERSION_MIN_VERSION = '3.9.0'; + export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { description: 'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' + 'a bump type ("major", "minor", or "patch"), "auto" to determine automatically ' + - 'from conventional commits, or "calver" for calendar versioning. ' + + 'from conventional commits, "pre" for the next prerelease, or "calver" ' + + 'for calendar versioning. ' + 'If omitted, uses the versioning.policy from .craft.yml', type: 'string', }) @@ -156,7 +164,8 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; * Checks the provided version argument for validity * * We check that the argument is either a valid version string, 'auto' for - * automatic version detection, 'calver' for calendar versioning, a version + * automatic version detection, 'pre' for the next prerelease, 'calver' for + * calendar versioning, a version * bump type (major/minor/patch), or a valid semantic version. * Empty/undefined is also allowed (will use versioning.policy from config). * @@ -176,6 +185,11 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { return true; } + // Allow 'pre' for the next prerelease + if (version === 'pre') { + return true; + } + // Allow 'calver' for calendar versioning if (version === 'calver') { return true; @@ -603,7 +617,7 @@ async function switchToDefaultBranch( } interface ResolveVersionOptions { - /** The raw version input from CLI (may be undefined, 'auto', 'calver', bump type, or semver) */ + /** The raw version input from CLI (may be undefined, 'auto', 'pre', 'calver', bump type, or semver) */ versionArg?: string; /** Override for CalVer offset (days to go back) */ calverOffset?: number; @@ -616,10 +630,11 @@ interface ResolveVersionOptions { * - No input: uses versioning.policy from config * - 'calver': calculates calendar version * - 'auto': analyzes commits to determine bump type + * - 'pre': analyzes commits to determine the next prerelease * - 'major'/'minor'/'patch': applies bump to latest tag * - Explicit semver: returns as-is * - * Before the first tag, 'auto' and bump types start from the version in the + * Before the first tag, 'auto', 'pre', and bump types start from the version in the * release unit's project files, read by its targets (see readProjectVersion). * * @param git Local git client @@ -677,7 +692,7 @@ async function resolveVersion( } // Handle automatic version detection or version bump types - if (version === 'auto' || isBumpType(version)) { + if (version === 'auto' || version === 'pre' || isBumpType(version)) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { const featureName = isBumpType(version) ? 'Version bump types' @@ -688,12 +703,27 @@ async function resolveVersion( ); } + if ( + version === 'pre' && + !requiresMinVersion(PRERELEASE_VERSION_MIN_VERSION) + ) { + throw new ConfigurationError( + `The "pre" version requires minVersion >= ${PRERELEASE_VERSION_MIN_VERSION} in .craft.yml. ` + + 'Please update your configuration or specify the version explicitly.', + ); + } + const latestTag = await getLatestTag(git, getGitTagPrefix()); // Determine bump type - either from arg or from commit analysis let bumpType: BumpType; - if (version === 'auto') { - const changelogResult = await getChangelogWithBumpType(git, latestTag); + if (version === 'auto' || version === 'pre') { + // The next release ships every change since the last stable release, + // including those already in prereleases. + const changelogResult = await getChangelogWithBumpType( + git, + await getLatestStableTag(git, getGitTagPrefix()), + ); validateBumpType(changelogResult); bumpType = changelogResult.bumpType; } else { @@ -702,13 +732,34 @@ async function resolveVersion( // Calculate new version from the latest tag. Before the first tag, start // from the version in the release unit's project files, so a workspace at - // `1.0.0-dev.0` releases `1.0.0` instead of a bump of `0.0.0`. + // `1.0.0-dev.0` releases `1.0.0` instead of a bump of `0.0.0`. Bumping a + // prerelease promotes it, so `2.0.0-alpha.3` releases `2.0.0`. const currentVersion = (latestTag && getVersion(latestTag)) || (await readProjectVersion(config.targets || [], getReleaseUnitDir())) || '0.0.0'; - const newVersion = calculateNextVersion(currentVersion, bumpType); + let newVersion = calculateNextVersion(currentVersion, bumpType); + if (version === 'pre') { + // `pre` cuts a prerelease of the version `auto` would release. + newVersion = calculatePrerelease(newVersion, currentVersion); + } else if ( + version === 'auto' && + latestTag && + parseVersion(currentVersion)?.pre && + semver.minor(newVersion) === 0 && + semver.patch(newVersion) === 0 + ) { + // A major release after prereleases is cut explicitly, so `auto` keeps + // cutting prereleases until someone asks for the stable release. + const prerelease = calculatePrerelease(newVersion, currentVersion); + logger.info( + `The latest release ${currentVersion} is a prerelease and ${newVersion} ` + + `is a major release, so releasing ${prerelease} instead. ` + + `Run "craft prepare ${newVersion}" to release ${newVersion}.`, + ); + newVersion = prerelease; + } logger.info( `Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`, ); @@ -802,13 +853,18 @@ export async function prepareMain(argv: PrepareOptions): Promise { // TL;DR - WARNING: // The order matters here, do not move this command above createReleaseBranch! const oldVersion = await getLatestTag(git, getGitTagPrefix()); + const changelogBase = await getChangelogBaseTag( + git, + newVersion, + getGitTagPrefix(), + ); // Check & update the changelog with the selected workspace's default path. const { filePath: changelogPath, policy: changelogPolicy } = getChangelogConfig(); const changelogBody = await prepareChangelog( git, - oldVersion, + changelogBase, newVersion, argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, changelogPath, @@ -852,7 +908,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { const releaseSha = await git.revparse(['HEAD']); setGitHubActionsOutput('branch', branchName); setGitHubActionsOutput('sha', releaseSha); - setGitHubActionsOutput('previous_tag', oldVersion || ''); + setGitHubActionsOutput('previous_tag', changelogBase || ''); setGitHubActionsOutput('targets', JSON.stringify(targetIds)); if (changelogBody) { // For CalVer releases, replace @-mentions with bold formatting in the diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index 4d531f77..4054d708 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -25,6 +25,7 @@ import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { + calculatePrerelease, calculateNextVersion, getChangelogWithBumpType, validateBumpType, @@ -70,6 +71,23 @@ describe('calculateNextVersion', () => { }); }); +describe('calculatePrerelease', () => { + test('cuts the first prerelease of the version after a stable release', () => { + expect(calculatePrerelease('1.9.1', '1.9.0')).toBe('1.9.1-alpha.0'); + expect(calculatePrerelease('1.10.0', '1.9.0')).toBe('1.10.0-alpha.0'); + expect(calculatePrerelease('2.0.0', '1.9.0')).toBe('2.0.0-alpha.0'); + }); + + test('cuts the next prerelease after a prerelease of the same version', () => { + expect(calculatePrerelease('2.0.0', '2.0.0-alpha.3')).toBe('2.0.0-alpha.4'); + expect(calculatePrerelease('2.0.0', '2.0.0-rc.1')).toBe('2.0.0-rc.2'); + }); + + test('cuts the first prerelease of the version after a prerelease of another version', () => { + expect(calculatePrerelease('3.0.0', '2.1.0-alpha.0')).toBe('3.0.0-alpha.0'); + }); +}); + describe('validateBumpType', () => { test('throws error when no commits found', () => { const result = { diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index efab3d4d..b8c06714 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -7,6 +7,8 @@ import { createGitClient, getGitClient, getChangesSince, + getChangelogBaseTag, + getLatestStableTag, getLatestTag, isRepoDirty, findReleaseBranches, @@ -77,6 +79,82 @@ describe('getLatestTag', () => { }); }); +describe('release base tags', () => { + let dir: string; + let client: ReturnType; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'craft-release-tags-')); + const git = (...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { + env: { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + }, + }); + git('init', '--quiet'); + git('config', 'user.name', 'Craft Test'); + git('config', 'user.email', 'craft-test@example.com'); + // Each release gets its own commit, so the tags form a linear history. + // Unprefixed lookups match every tag, so the prefixed workspace tags come + // before the unprefixed ones. + for (const tag of [ + 'cli@1.0.0', + 'cli@1.1.0-alpha.0', + 'mcp@1.0.0-alpha.0', + '1.9.0', + '2.0.0-alpha.0', + '2.0.0-alpha.1', + '', + ]) { + git( + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '--allow-empty', + '-m', + tag ? `release: ${tag}` : 'Unreleased change', + ); + if (tag) { + git('tag', tag); + } + } + client = createGitClient(dir); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('finds the latest stable tag past newer prereleases', async () => { + expect(await getLatestStableTag(client)).toBe('1.9.0'); + expect(await getLatestStableTag(client, 'cli@')).toBe('cli@1.0.0'); + }); + + it('finds no stable tag when only prereleases match the prefix', async () => { + expect(await getLatestStableTag(client, 'mcp@')).toBe(''); + }); + + it('bases a stable release on the latest stable tag', async () => { + expect(await getChangelogBaseTag(client, '2.0.0')).toBe('1.9.0'); + }); + + it('bases a prerelease on the previous prerelease of its version', async () => { + expect(await getChangelogBaseTag(client, '2.0.0-alpha.2')).toBe( + '2.0.0-alpha.1', + ); + }); + + it('bases the first prerelease of a version on the latest stable tag', async () => { + expect(await getChangelogBaseTag(client, '2.1.0-alpha.0')).toBe('1.9.0'); + expect(await getChangelogBaseTag(client, '1.2.0-alpha.0', 'cli@')).toBe( + 'cli@1.0.0', + ); + }); +}); + describe('getGitClient', () => { it('preserves Git identity environment variables for commits', async () => { const dir = mkdtempSync(join(tmpdir(), 'craft-git-identity-')); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index df6054b6..73768039 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -39,6 +39,40 @@ export function calculateNextVersion( return newVersion; } +/** Prerelease identifier for a new prerelease series, e.g. `2.0.0-alpha.0`. */ +const NEW_PRERELEASE_ID = 'alpha'; + +/** + * Returns the next prerelease of `version`. + * + * Starts at `-alpha.0` (`1.10.0` -> `1.10.0-alpha.0`). When the + * current version is already a prerelease of `version`, continues its series + * instead (`1.10.0` after `1.10.0-alpha.0` -> `1.10.0-alpha.1`). + * + * @param version The stable version to cut a prerelease of (e.g., "1.10.0") + * @param currentVersion The current version string (e.g., "1.10.0-alpha.0") + * @returns The new prerelease version string + */ +export function calculatePrerelease( + version: string, + currentVersion: string, +): string { + const current = semver.parse(currentVersion); + if ( + current?.prerelease.length && + version === `${current.major}.${current.minor}.${current.patch}` + ) { + const newVersion = semver.inc(current, 'prerelease'); + if (!newVersion) { + throw new Error( + `Failed to increment prerelease version "${currentVersion}"`, + ); + } + return newVersion; + } + return `${version}-${NEW_PRERELEASE_ID}.0`; +} + /** * Generates changelog and determines version bump type from commits. * This is a convenience wrapper around generateChangesetFromGit that logs progress. diff --git a/src/utils/git.ts b/src/utils/git.ts index 9e30c7cc..698f5d50 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -25,6 +25,7 @@ import { logger } from '../logger'; import { distance as levenshtein } from 'fastest-levenshtein'; import path from 'node:path'; import { discoverWorkspaces } from './workspaces'; +import { parseVersion, type SemVer } from './version'; export interface GitChange { hash: string; @@ -70,6 +71,72 @@ export async function getDefaultBranch( export async function getLatestTag( git: SimpleGit, tagPrefix = '', +): Promise { + return describeLatestTag(git, tagPrefix, []); +} + +/** + * Returns the latest tag whose version is not a prerelease, or an empty string + * when there is none. + * + * The next release contains every change since the latest stable release, + * including those already in prereleases, so that release is the base for + * picking its version bump. + */ +export async function getLatestStableTag( + git: SimpleGit, + tagPrefix = '', +): Promise { + return findLatestTag(git, tagPrefix, tagVersion => !tagVersion?.pre); +} + +/** + * Returns the tag whose changes a release of `version` follows, or an empty + * string when there is none. + * + * A stable release follows the latest stable tag. A prerelease follows the + * latest prerelease of the same `major.minor.patch` (`2.0.0-alpha.3` follows + * `2.0.0-alpha.2`), or the latest stable tag when it starts a new series + * (`2.1.0-alpha.0` follows `2.0.4`, not `2.0.0-alpha.3`). + */ +export async function getChangelogBaseTag( + git: SimpleGit, + version: string, + tagPrefix = '', +): Promise { + const release = parseVersion(version); + return findLatestTag( + git, + tagPrefix, + tagVersion => + !tagVersion?.pre || + (!!release?.pre && + tagVersion.major === release.major && + tagVersion.minor === release.minor && + tagVersion.patch === release.patch), + ); +} + +/** Returns the latest tag whose version `accept` accepts. */ +async function findLatestTag( + git: SimpleGit, + tagPrefix: string, + accept: (tagVersion: SemVer | null) => boolean, +): Promise { + const skippedTags: string[] = []; + for (;;) { + const tag = await describeLatestTag(git, tagPrefix, skippedTags); + if (!tag || accept(parseVersion(tag.slice(tagPrefix.length)))) { + return tag; + } + skippedTags.push(tag); + } +} + +async function describeLatestTag( + git: SimpleGit, + tagPrefix: string, + excludedTags: string[], ): Promise { try { // This part is courtesy of https://stackoverflow.com/a/7261049/90297 @@ -81,12 +148,16 @@ export async function getLatestTag( // is resolved per-product instead of picking whatever is newest overall. args.push('--match', `${tagPrefix}*`); } + for (const tag of excludedTags) { + args.push('--exclude', tag); + } return (await git.raw(args)).trim(); } catch (err) { // If there are no tags, return an empty string if ( err instanceof Error && (err.message.startsWith('fatal: No names found') || + err.message.startsWith('fatal: No tags can describe') || err.message.startsWith('Nothing to describe')) ) { return '';