diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index d2ed758f..ca9a893b 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -30,7 +30,7 @@ import { handleGlobalError, reportError, } from '../utils/errors'; -import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; +import { getGitClient, getDefaultBranch, getLatestTag, isRepoDirty } from '../utils/git'; import { getChangelogWithBumpType, calculateNextVersion, @@ -335,14 +335,7 @@ function checkGitStatus(repoStatus: StatusResult, rev: string) { logger.debug('Repository status:', formatJson(repoStatus)); - if ( - repoStatus.conflicted.length || - repoStatus.created.length || - repoStatus.deleted.length || - repoStatus.modified.length || - repoStatus.renamed.length || - repoStatus.staged.length - ) { + if (isRepoDirty(repoStatus)) { reportError( 'Your repository is in a dirty state. ' + 'Please stash or commit the pending changes.', diff --git a/src/commands/publish.ts b/src/commands/publish.ts index b24a01a5..07ab1355 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -36,7 +36,7 @@ import { isValidVersion } from '../utils/version'; import { BaseStatusProvider } from '../status_providers/base'; import { BaseArtifactProvider } from '../artifact_providers/base'; import { SimpleGit } from 'simple-git'; -import { getGitClient, getDefaultBranch } from '../utils/git'; +import { getGitClient, getDefaultBranch, isRepoDirty } from '../utils/git'; /** Default path to post-release script, relative to project root */ const DEFAULT_POST_RELEASE_SCRIPT_PATH = join('scripts', 'post-release.sh'); @@ -118,6 +118,11 @@ export const builder: CommandBuilder = (yargs: Argv) => { description: 'Do not check for build status', type: 'boolean', }) + .option('no-git-checks', { + default: false, + description: 'Ignore local git changes and unsynchronized remotes', + type: 'boolean', + }) .check(checkVersion) .demandOption('new-version', 'Please specify the version to publish'); }; @@ -142,6 +147,8 @@ export interface PublishOptions { noStatusCheck: boolean; /** Do not remove release branch after publishing */ keepBranch: boolean; + /** Do not perform basic git checks */ + noGitChecks: boolean; } export interface PublishState { @@ -453,6 +460,20 @@ export async function publishMain(argv: PublishOptions): Promise { const git = await getGitClient(); + // Check for dirty repository state before any git operations + if (argv.noGitChecks) { + logger.info('Not checking the status of the local repository'); + } else { + const repoStatus = await git.status(); + if (isRepoDirty(repoStatus)) { + reportError( + 'Your repository is in a dirty state. ' + + 'Please stash or commit the pending changes.', + logger + ); + } + } + const rev = argv.rev; let checkoutTarget; let branchName; diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index c4e1ce23..f3994dea 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -1,6 +1,7 @@ import { vi, type Mock, type MockInstance, type Mocked, type MockedFunction } from 'vitest'; -import { getLatestTag } from '../git'; +import { getLatestTag, isRepoDirty } from '../git'; import * as loggerModule from '../../logger'; +import type { StatusResult } from 'simple-git'; describe('getLatestTag', () => { it('returns latest tag in the repo by calling `git describe`', async () => { @@ -26,3 +27,64 @@ describe('getLatestTag', () => { expect(latestTag).toBe(''); }); }); + +describe('isRepoDirty', () => { + const createCleanStatus = (): StatusResult => ({ + not_added: [], + conflicted: [], + created: [], + deleted: [], + ignored: [], + modified: [], + renamed: [], + staged: [], + files: [], + ahead: 0, + behind: 0, + current: 'main', + tracking: 'origin/main', + detached: false, + isClean: () => true, + }); + + it('returns false for clean repository', () => { + const status = createCleanStatus(); + expect(isRepoDirty(status)).toBe(false); + }); + + it('returns true when there are modified files', () => { + const status = createCleanStatus(); + status.modified = ['file.txt']; + expect(isRepoDirty(status)).toBe(true); + }); + + it('returns true when there are created files', () => { + const status = createCleanStatus(); + status.created = ['newfile.txt']; + expect(isRepoDirty(status)).toBe(true); + }); + + it('returns true when there are deleted files', () => { + const status = createCleanStatus(); + status.deleted = ['removed.txt']; + expect(isRepoDirty(status)).toBe(true); + }); + + it('returns true when there are staged files', () => { + const status = createCleanStatus(); + status.staged = ['staged.txt']; + expect(isRepoDirty(status)).toBe(true); + }); + + it('returns true when there are renamed files', () => { + const status = createCleanStatus(); + status.renamed = [{ from: 'old.txt', to: 'new.txt' }]; + expect(isRepoDirty(status)).toBe(true); + }); + + it('returns true when there are conflicted files', () => { + const status = createCleanStatus(); + status.conflicted = ['conflict.txt']; + expect(isRepoDirty(status)).toBe(true); + }); +}); diff --git a/src/utils/git.ts b/src/utils/git.ts index e74273dc..0237182a 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,4 +1,4 @@ -import simpleGit, { type SimpleGit, type LogOptions, type Options } from 'simple-git'; +import simpleGit, { type SimpleGit, type LogOptions, type Options, type StatusResult } from 'simple-git'; import { getConfigFileDir } from '../config'; import { ConfigurationError } from './errors'; @@ -111,3 +111,20 @@ export async function getGitClient(): Promise { } return git; } + +/** + * Checks if the git repository has uncommitted changes + * + * @param repoStatus Result of git.status() + * @returns true if the repository has uncommitted changes + */ +export function isRepoDirty(repoStatus: StatusResult): boolean { + return !!( + repoStatus.conflicted.length || + repoStatus.created.length || + repoStatus.deleted.length || + repoStatus.modified.length || + repoStatus.renamed.length || + repoStatus.staged.length + ); +}