Skip to content

Commit 6b65d60

Browse files
committed
Run Git preflight before prompting
Validate the branch, working tree, and remote before showing the release wizard, so obvious Git errors surface immediately rather than after the user has answered all the prompts. The full Git task still runs before publish in case the repo state changes during prompting or login. Fixes #784
1 parent d24cb95 commit 6b65d60

File tree

5 files changed

+237
-2
lines changed

5 files changed

+237
-2
lines changed

source/cli-implementation.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import config from './config.js';
1111
import * as util from './util.js';
1212
import * as git from './git-util.js';
1313
import * as npm from './npm/util.js';
14+
import {verifyGitTasks} from './git-tasks.js';
1415
import {getOidcProvider} from './npm/oidc.js';
1516
import {SEMVER_INCREMENTS} from './version.js';
1617
import ui from './ui.js';
@@ -177,6 +178,11 @@ async function getOptions() {
177178
const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0);
178179

179180
const branch = flags.branch ?? await git.defaultBranch();
181+
if (!flags.releaseDraftOnly) {
182+
// Keep obvious Git failures ahead of the wizard, but do not replace the later Git task.
183+
// The publish flow still needs a final check in case the repo changes while the user is prompting or logging in.
184+
await verifyGitTasks({anyBranch: flags.anyBranch, branch, remote: flags.remote});
185+
}
180186

181187
const options = await ui({
182188
...flags,

source/git-tasks.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Listr from 'listr';
22
import * as git from './git-util.js';
33

4-
const gitTasks = options => {
4+
const createGitTasks = options => {
55
const tasks = [
66
{
77
title: 'Check current branch',
@@ -21,7 +21,30 @@ const gitTasks = options => {
2121
tasks.shift();
2222
}
2323

24-
return new Listr(tasks);
24+
return tasks;
2525
};
2626

27+
export const verifyGitTasks = async options => {
28+
if (!options.anyBranch) {
29+
await git.verifyCurrentBranchIsReleaseBranch(options.branch);
30+
}
31+
32+
await git.verifyWorkingTreeIsClean();
33+
if (options.remote) {
34+
await git.verifyRemoteIsValid(options.remote);
35+
} else if (
36+
!(
37+
options.anyBranch
38+
&& await git.isHeadDetached()
39+
)
40+
&& await git.hasUpstream()
41+
) {
42+
await git.verifyRemoteIsValid(await git.getUpstreamRemote());
43+
}
44+
45+
await git.verifyRemoteHistoryIsClean();
46+
};
47+
48+
const gitTasks = options => new Listr(createGitTasks(options));
49+
2750
export default gitTasks;

source/git-util.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ export const getCurrentBranch = async () => {
115115
return stdout;
116116
};
117117

118+
export const getUpstreamRemote = async () => {
119+
const currentBranch = await getCurrentBranch();
120+
const {stdout} = await execa('git', ['config', `branch.${currentBranch}.remote`]);
121+
return stdout;
122+
};
123+
118124
export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => {
119125
const currentBranch = await getCurrentBranch();
120126
if (currentBranch !== releaseBranch) {

test/cli.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import path from 'node:path';
2+
import process from 'node:process';
23
import test from 'ava';
4+
import sinon from 'sinon';
5+
import esmock from 'esmock';
36
import {execa} from 'execa';
47
import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js';
58
import {cliPasses} from './_helpers/verify-cli.js';
@@ -69,3 +72,100 @@ test('flags: --preview remains an alias for --dry-run', async t => {
6972
t.true(stderr.includes('--wat'));
7073
t.false(stderr.includes('--preview'));
7174
});
75+
76+
const loadCliImplementation = async overrides => esmock('../source/cli-implementation.js', {}, {
77+
meow: {
78+
default: sinon.stub().returns({
79+
input: ['patch'],
80+
flags: {
81+
publish: false,
82+
},
83+
pkg: npPackage,
84+
}),
85+
},
86+
'update-notifier': {default: sinon.stub().returns({notify: sinon.stub()})},
87+
'../source/config.js': {default: sinon.stub().resolves({})},
88+
'../source/util.js': {
89+
readPackage: sinon.stub().resolves({
90+
package_: {
91+
name: 'test-package',
92+
version: '1.0.0',
93+
},
94+
rootDirectory: process.cwd(),
95+
}),
96+
},
97+
'../source/git-util.js': {
98+
defaultBranch: sinon.stub().resolves('main'),
99+
},
100+
'../source/git-tasks.js': {
101+
verifyGitTasks: sinon.stub().resolves(),
102+
},
103+
'../source/package-manager/index.js': {
104+
getPackageManagerConfig: sinon.stub().returns({
105+
id: 'npm',
106+
cli: 'npm',
107+
}),
108+
},
109+
'../source/npm/util.js': {
110+
isExternalRegistry: sinon.stub().returns(false),
111+
isPackageNameAvailable: sinon.stub(),
112+
username: sinon.stub(),
113+
login: sinon.stub(),
114+
},
115+
'../source/npm/oidc.js': {
116+
getOidcProvider: sinon.stub().returns(undefined),
117+
},
118+
'../source/ui.js': {default: sinon.stub().callsFake(async options => ({...options, confirm: false, version: '1.0.1'}))},
119+
'../source/index.js': {default: sinon.stub().resolves({name: 'test-package', version: '1.0.1'})},
120+
'exit-hook': {
121+
gracefulExit: sinon.stub(),
122+
},
123+
...overrides,
124+
});
125+
126+
test.serial('cli runs git preflight before prompting', async t => {
127+
const verifyGitTasksStub = sinon.stub().rejects(new Error('Not on `main` branch.'));
128+
const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'}));
129+
const gracefulExitStub = sinon.stub();
130+
const consoleErrorStub = sinon.stub(console, 'error');
131+
132+
await loadCliImplementation({
133+
'../source/git-tasks.js': {
134+
verifyGitTasks: verifyGitTasksStub,
135+
},
136+
'../source/ui.js': {default: uiStub},
137+
'exit-hook': {
138+
gracefulExit: gracefulExitStub,
139+
},
140+
});
141+
142+
t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
143+
t.true(uiStub.notCalled);
144+
t.true(gracefulExitStub.calledOnceWithExactly(1));
145+
146+
consoleErrorStub.restore();
147+
});
148+
149+
test.serial('cli continues to the publish flow after successful git preflight', async t => {
150+
const verifyGitTasksStub = sinon.stub().resolves();
151+
const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'}));
152+
const npStub = sinon.stub().resolves({name: 'test-package', version: '1.0.1'});
153+
const gracefulExitStub = sinon.stub();
154+
155+
await loadCliImplementation({
156+
'../source/git-tasks.js': {
157+
verifyGitTasks: verifyGitTasksStub,
158+
},
159+
'../source/ui.js': {default: uiStub},
160+
'../source/index.js': {default: npStub},
161+
'exit-hook': {
162+
gracefulExit: gracefulExitStub,
163+
},
164+
});
165+
166+
t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined}));
167+
t.true(uiStub.calledOnce);
168+
t.true(npStub.calledOnce);
169+
t.false('skipGitTasks' in npStub.firstCall.args[1]);
170+
t.true(gracefulExitStub.notCalled);
171+
});

test/tasks/git-tasks.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,103 @@ test.serial('checks should pass when publishing from master, working tree is cle
176176
], async ({t, testedModule: gitTasks}) => {
177177
await t.notThrowsAsync(run(gitTasks({branch: 'master'})));
178178
});
179+
180+
test.serial('preflight should validate remote before checking remote history', createFixture, [
181+
{
182+
command: 'git symbolic-ref --short HEAD',
183+
stdout: 'master',
184+
},
185+
{
186+
command: 'git status --porcelain',
187+
stdout: '',
188+
},
189+
{
190+
command: 'git status --short --branch --porcelain',
191+
stdout: '## master...origin/master',
192+
},
193+
{
194+
command: 'git config branch.master.remote',
195+
stdout: 'origin',
196+
},
197+
{
198+
command: 'git ls-remote origin HEAD',
199+
exitCode: 1,
200+
stderr: 'fatal: could not read from remote repository',
201+
},
202+
], async ({t, testedModule}) => {
203+
await t.throwsAsync(
204+
testedModule.verifyGitTasks({branch: 'master'}),
205+
{message: 'Git fatal error: could not read from remote repository'},
206+
);
207+
});
208+
209+
test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [
210+
{
211+
command: 'git status --porcelain',
212+
stdout: '',
213+
},
214+
{
215+
command: 'git symbolic-ref --quiet HEAD',
216+
exitCode: 1,
217+
},
218+
{
219+
command: 'git rev-parse @{u}',
220+
stderr: 'fatal: no upstream configured for HEAD',
221+
},
222+
], async ({t, testedModule}) => {
223+
await t.notThrowsAsync(testedModule.verifyGitTasks({anyBranch: true}));
224+
});
225+
226+
test.serial('preflight should validate explicit remote without upstream', createFixture, [
227+
{
228+
command: 'git status --porcelain',
229+
stdout: '',
230+
},
231+
{
232+
command: 'git ls-remote upstream HEAD',
233+
exitCode: 1,
234+
stderr: 'fatal: remote upstream not found',
235+
},
236+
], async ({t, testedModule}) => {
237+
await t.throwsAsync(
238+
testedModule.verifyGitTasks({anyBranch: true, remote: 'upstream'}),
239+
{message: 'Git fatal error: remote upstream not found'},
240+
);
241+
});
242+
243+
test.serial('preflight should validate the tracked remote instead of origin', createFixture, [
244+
{
245+
command: 'git symbolic-ref --short HEAD',
246+
stdout: 'main',
247+
},
248+
{
249+
command: 'git status --porcelain',
250+
stdout: '',
251+
},
252+
{
253+
command: 'git status --short --branch --porcelain',
254+
stdout: '## main...upstream/main',
255+
},
256+
{
257+
command: 'git config branch.main.remote',
258+
stdout: 'upstream',
259+
},
260+
{
261+
command: 'git ls-remote upstream HEAD',
262+
exitCode: 0,
263+
},
264+
{
265+
command: 'git rev-parse @{u}',
266+
exitCode: 0,
267+
},
268+
{
269+
command: 'git fetch --dry-run',
270+
exitCode: 0,
271+
},
272+
{
273+
command: 'git rev-list --count --left-only @{u}...HEAD',
274+
stdout: '0',
275+
},
276+
], async ({t, testedModule}) => {
277+
await t.notThrowsAsync(testedModule.verifyGitTasks({branch: 'main'}));
278+
});

0 commit comments

Comments
 (0)