Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions source/cli-implementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import config from './config.js';
import * as util from './util.js';
import * as git from './git-util.js';
import * as npm from './npm/util.js';
import {verifyGitTasks} from './git-tasks.js';
import {getOidcProvider} from './npm/oidc.js';
import {SEMVER_INCREMENTS} from './version.js';
import ui from './ui.js';
Expand Down Expand Up @@ -177,6 +178,11 @@ 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});
}

const options = await ui({
...flags,
Expand Down
27 changes: 25 additions & 2 deletions source/git-tasks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Listr from 'listr';
import * as git from './git-util.js';

const gitTasks = options => {
const createGitTasks = options => {
const tasks = [
{
title: 'Check current branch',
Expand All @@ -21,7 +21,30 @@ const gitTasks = options => {
tasks.shift();
}

return new Listr(tasks);
return tasks;
};

export const verifyGitTasks = async options => {
if (!options.anyBranch) {
await git.verifyCurrentBranchIsReleaseBranch(options.branch);
}

await git.verifyWorkingTreeIsClean();
if (options.remote) {
await git.verifyRemoteIsValid(options.remote);
} else if (
!(
options.anyBranch
&& await git.isHeadDetached()
)
&& await git.hasUpstream()
) {
await git.verifyRemoteIsValid(await git.getUpstreamRemote());
}

await git.verifyRemoteHistoryIsClean();
};

const gitTasks = options => new Listr(createGitTasks(options));

export default gitTasks;
6 changes: 6 additions & 0 deletions source/git-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export const getCurrentBranch = async () => {
return stdout;
};

export const getUpstreamRemote = async () => {
const currentBranch = await getCurrentBranch();
const {stdout} = await execa('git', ['config', `branch.${currentBranch}.remote`]);
return stdout;
};

export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => {
const currentBranch = await getCurrentBranch();
if (currentBranch !== releaseBranch) {
Expand Down
100 changes: 100 additions & 0 deletions test/cli.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import path from 'node:path';
import process from 'node:process';
import test from 'ava';
import sinon from 'sinon';
import esmock from 'esmock';
import {execa} from 'execa';
import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js';
import {cliPasses} from './_helpers/verify-cli.js';
Expand Down Expand Up @@ -69,3 +72,100 @@ test('flags: --preview remains an alias for --dry-run', async t => {
t.true(stderr.includes('--wat'));
t.false(stderr.includes('--preview'));
});

const loadCliImplementation = async overrides => esmock('../source/cli-implementation.js', {}, {
meow: {
default: sinon.stub().returns({
input: ['patch'],
flags: {
publish: false,
},
pkg: npPackage,
}),
},
'update-notifier': {default: sinon.stub().returns({notify: sinon.stub()})},
'../source/config.js': {default: sinon.stub().resolves({})},
'../source/util.js': {
readPackage: sinon.stub().resolves({
package_: {
name: 'test-package',
version: '1.0.0',
},
rootDirectory: process.cwd(),
}),
},
'../source/git-util.js': {
defaultBranch: sinon.stub().resolves('main'),
},
'../source/git-tasks.js': {
verifyGitTasks: sinon.stub().resolves(),
},
'../source/package-manager/index.js': {
getPackageManagerConfig: sinon.stub().returns({
id: 'npm',
cli: 'npm',
}),
},
'../source/npm/util.js': {
isExternalRegistry: sinon.stub().returns(false),
isPackageNameAvailable: sinon.stub(),
username: sinon.stub(),
login: sinon.stub(),
},
'../source/npm/oidc.js': {
getOidcProvider: sinon.stub().returns(undefined),
},
'../source/ui.js': {default: sinon.stub().callsFake(async options => ({...options, confirm: false, version: '1.0.1'}))},
'../source/index.js': {default: sinon.stub().resolves({name: 'test-package', version: '1.0.1'})},
'exit-hook': {
gracefulExit: sinon.stub(),
},
...overrides,
});

test.serial('cli runs git preflight before prompting', async t => {
const verifyGitTasksStub = sinon.stub().rejects(new Error('Not on `main` branch.'));
const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'}));
const gracefulExitStub = sinon.stub();
const consoleErrorStub = sinon.stub(console, 'error');

await loadCliImplementation({
'../source/git-tasks.js': {
verifyGitTasks: verifyGitTasksStub,
},
'../source/ui.js': {default: uiStub},
'exit-hook': {
gracefulExit: gracefulExitStub,
},
});

t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
t.true(uiStub.notCalled);
t.true(gracefulExitStub.calledOnceWithExactly(1));

consoleErrorStub.restore();
});

test.serial('cli continues to the publish flow after successful git preflight', async t => {
const verifyGitTasksStub = sinon.stub().resolves();
const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'}));
const npStub = sinon.stub().resolves({name: 'test-package', version: '1.0.1'});
const gracefulExitStub = sinon.stub();

await loadCliImplementation({
'../source/git-tasks.js': {
verifyGitTasks: verifyGitTasksStub,
},
'../source/ui.js': {default: uiStub},
'../source/index.js': {default: npStub},
'exit-hook': {
gracefulExit: gracefulExitStub,
},
});

t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
t.true(uiStub.calledOnce);
t.true(npStub.calledOnce);
t.false('skipGitTasks' in npStub.firstCall.args[1]);
t.true(gracefulExitStub.notCalled);
});
100 changes: 100 additions & 0 deletions test/tasks/git-tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,103 @@ test.serial('checks should pass when publishing from master, working tree is cle
], async ({t, testedModule: gitTasks}) => {
await t.notThrowsAsync(run(gitTasks({branch: 'master'})));
});

test.serial('preflight should validate remote before checking remote history', createFixture, [
{
command: 'git symbolic-ref --short HEAD',
stdout: 'master',
},
{
command: 'git status --porcelain',
stdout: '',
},
{
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: 1,
stderr: 'fatal: could not read from remote repository',
},
], async ({t, testedModule}) => {
await t.throwsAsync(
testedModule.verifyGitTasks({branch: 'master'}),
{message: 'Git fatal error: could not read from remote repository'},
);
});

test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [
{
command: 'git status --porcelain',
stdout: '',
},
{
command: 'git symbolic-ref --quiet HEAD',
exitCode: 1,
},
{
command: 'git rev-parse @{u}',
stderr: 'fatal: no upstream configured for HEAD',
},
], async ({t, testedModule}) => {
await t.notThrowsAsync(testedModule.verifyGitTasks({anyBranch: true}));
});

test.serial('preflight should validate explicit remote without upstream', createFixture, [
{
command: 'git status --porcelain',
stdout: '',
},
{
command: 'git ls-remote upstream HEAD',
exitCode: 1,
stderr: 'fatal: remote upstream not found',
},
], async ({t, testedModule}) => {
await t.throwsAsync(
testedModule.verifyGitTasks({anyBranch: true, remote: 'upstream'}),
{message: 'Git fatal error: remote upstream not found'},
);
});

test.serial('preflight should validate the tracked remote instead of origin', createFixture, [
{
command: 'git symbolic-ref --short HEAD',
stdout: 'main',
},
{
command: 'git status --porcelain',
stdout: '',
},
{
command: 'git status --short --branch --porcelain',
stdout: '## main...upstream/main',
},
{
command: 'git config branch.main.remote',
stdout: 'upstream',
},
{
command: 'git ls-remote upstream 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: 'main'}));
});