From f3b2b63ddb1e948e4bcbef1e809a042443829010 Mon Sep 17 00:00:00 2001 From: "Jan T. Sott" Date: Sat, 16 May 2026 21:10:45 +0200 Subject: [PATCH] feat: add --allow-dirty flag --- readme.md | 1 + source/cli-implementation.js | 45 ++++++++++++++++++----------- source/git-tasks.js | 12 +++++++- source/index.js | 10 ++++--- test/cli.js | 15 ++++++++-- test/tasks/git-tasks.js | 56 ++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 23 deletions(-) diff --git a/readme.md b/readme.md index 3c0a7a74..a67b0d01 100644 --- a/readme.md +++ b/readme.md @@ -58,6 +58,7 @@ $ np --help Options --any-branch Allow publishing from any branch --branch Name of the release branch (default: main | master) + --allow-dirty Allow publishing when the working tree is dirty --no-cleanup Skips np's node_modules cleanup step before install --no-tests Skips tests --yolo Skips cleanup and testing diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 829b0cbf..e07272e3 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -29,6 +29,7 @@ const cli = meow(` Options --any-branch Allow publishing from any branch --branch Name of the release branch (default: main | master) + --allow-dirty Allow publishing when the working tree is dirty --no-cleanup Skips np's node_modules cleanup step before install --no-tests Skips tests --yolo Skips cleanup and testing @@ -64,6 +65,9 @@ const cli = meow(` branch: { type: 'string', }, + allowDirty: { + type: 'boolean', + }, cleanup: { type: 'boolean', }, @@ -178,10 +182,16 @@ async function getOptions() { const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0); const branch = flags.branch ?? await git.defaultBranch(); + if (!flags.releaseDraftOnly) { // Keep obvious Git failures ahead of the wizard, but do not replace the later Git task. // The publish flow still needs a final check in case the repo changes while the user is prompting or logging in. - await verifyGitTasks({anyBranch: flags.anyBranch, branch, remote: flags.remote}); + await verifyGitTasks({ + anyBranch: flags.anyBranch, + allowDirty: flags.allowDirty, + branch, + remote: flags.remote, + }); } const options = await ui({ @@ -201,6 +211,23 @@ async function getOptions() { }; } +async function verifyPublishAuth(package_) { + const externalRegistry = npm.isExternalRegistry(package_) + ? package_.publishConfig.registry + : false; + + try { + await npm.username({externalRegistry}); + } catch (error) { + if (error.isNotLoggedIn && isInteractive()) { + console.log('\nYou must be logged in to publish. Running `npm login`...\n'); + await npm.login({externalRegistry}); + } else { + throw error; + } + } +} + try { const {options, projectDirectory, rootDirectory, package_} = await getOptions(); @@ -210,24 +237,10 @@ try { // Check authentication early, before Listr starts (so login can be interactive) if (options.runPublish) { - // Skip auth check if OIDC is available (will be handled by npm publish itself) if (getOidcProvider()) { console.log('OIDC authentication detected - skipping auth check'); } else { - const externalRegistry = npm.isExternalRegistry(package_) - ? package_.publishConfig.registry - : false; - - try { - await npm.username({externalRegistry}); - } catch (error) { - if (error.isNotLoggedIn && isInteractive()) { - console.log('\nYou must be logged in to publish. Running `npm login`...\n'); - await npm.login({externalRegistry}); - } else { - throw error; - } - } + await verifyPublishAuth(package_); } } diff --git a/source/git-tasks.js b/source/git-tasks.js index 4e426136..72861da9 100644 --- a/source/git-tasks.js +++ b/source/git-tasks.js @@ -21,6 +21,13 @@ const createGitTasks = options => { tasks.shift(); } + if (options.allowDirty) { + const index = tasks.findIndex(task => task.title === 'Check local working tree'); + if (index !== -1) { + tasks.splice(index, 1); + } + } + return tasks; }; @@ -29,7 +36,10 @@ export const verifyGitTasks = async options => { await git.verifyCurrentBranchIsReleaseBranch(options.branch); } - await git.verifyWorkingTreeIsClean(); + if (!options.allowDirty) { + await git.verifyWorkingTreeIsClean(); + } + if (options.remote) { await git.verifyRemoteIsValid(options.remote); } else if ( diff --git a/source/index.js b/source/index.js index ff74c6b6..b49d7bb6 100644 --- a/source/index.js +++ b/source/index.js @@ -177,10 +177,12 @@ const np = async (input = 'patch', {packageManager, ...rawOptions}, {package_, p return exec(...getInstallCommand(), {cwd: projectDirectory}); }, }, - { - title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail - task: () => git.verifyWorkingTreeIsClean(), - }, + ...options.allowDirty + ? [] + : [{ + title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail + task: () => git.verifyWorkingTreeIsClean(), + }], ]), }, { diff --git a/test/cli.js b/test/cli.js index 1893e66d..35046757 100644 --- a/test/cli.js +++ b/test/cli.js @@ -22,6 +22,7 @@ test('flags: --help', cliPasses, cli, '--help', [ 'Options', '--any-branch Allow publishing from any branch', '--branch Name of the release branch (default: main | master)', + '--allow-dirty Allow publishing when the working tree is dirty', '--no-cleanup Skips np\'s node_modules cleanup step before install', '--no-tests Skips tests', '--yolo Skips cleanup and testing', @@ -139,7 +140,12 @@ test.serial('cli runs git preflight before prompting', async t => { }, }); - t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(verifyGitTasksStub.calledOnceWithExactly({ + anyBranch: undefined, + allowDirty: undefined, + branch: 'main', + remote: undefined, + })); t.true(uiStub.notCalled); t.true(gracefulExitStub.calledOnceWithExactly(1)); @@ -163,7 +169,12 @@ test.serial('cli continues to the publish flow after successful git preflight', }, }); - t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(verifyGitTasksStub.calledOnceWithExactly({ + anyBranch: undefined, + allowDirty: undefined, + branch: 'main', + remote: undefined, + })); t.true(uiStub.calledOnce); t.true(npStub.calledOnce); t.false('skipGitTasks' in npStub.firstCall.args[1]); diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js index 1990a363..655fa909 100644 --- a/test/tasks/git-tasks.js +++ b/test/tasks/git-tasks.js @@ -79,6 +79,29 @@ test.serial('should fail when local working tree modified', createFixture, [ assertTaskFailed(t, 'Check local working tree'); }); +test.serial('should not fail when local working tree modified and allow-dirty is enabled', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync(run(gitTasks({branch: 'master', allowDirty: true}))); + + assertTaskDoesntExist(t, 'Check local working tree'); +}); + test.serial('should not fail when no remote set up', createFixture, [ { command: 'git symbolic-ref --short HEAD', @@ -206,6 +229,39 @@ test.serial('preflight should validate remote before checking remote history', c ); }); +test.serial('preflight should skip working tree check with allowDirty', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --short --branch --porcelain', + stdout: '## master...origin/master', + }, + { + command: 'git config branch.master.remote', + stdout: 'origin', + }, + { + command: 'git ls-remote origin HEAD', + exitCode: 0, + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule}) => { + await t.notThrowsAsync(testedModule.verifyGitTasks({branch: 'master', allowDirty: true})); +}); + test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [ { command: 'git status --porcelain',