Skip to content

Commit a5d4c3d

Browse files
Fix remote history check - check if git fetch needs to be run (#685)
1 parent a6ce792 commit a5d4c3d

File tree

4 files changed

+146
-66
lines changed

4 files changed

+146
-66
lines changed

Diff for: source/git-util.js

+26-8
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,39 @@ export const verifyWorkingTreeIsClean = async () => {
133133
}
134134
};
135135

136-
export const isRemoteHistoryClean = async () => {
137-
let history;
138-
try { // Gracefully handle no remote set up.
139-
const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']);
140-
history = stdout;
141-
} catch {}
142-
143-
if (history && history !== '0') {
136+
const hasRemote = async () => {
137+
try {
138+
await execa('git', ['rev-parse', '@{u}']);
139+
} catch { // Has no remote if command fails
144140
return false;
145141
}
146142

147143
return true;
148144
};
149145

146+
const hasUnfetchedChangesFromRemote = async () => {
147+
const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run']);
148+
149+
// There are no unfetched changes if output is empty.
150+
return !possibleNewChanges || possibleNewChanges === '';
151+
};
152+
153+
const isRemoteHistoryClean = async () => {
154+
const {stdout: history} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']);
155+
156+
// Remote history is clean if there are 0 revisions.
157+
return history === '0';
158+
};
159+
150160
export const verifyRemoteHistoryIsClean = async () => {
161+
if (!(await hasRemote())) {
162+
return;
163+
}
164+
165+
if (!(await hasUnfetchedChangesFromRemote())) {
166+
throw new Error('Remote history differs. Please run `git fetch` and pull changes.');
167+
}
168+
151169
if (!(await isRemoteHistoryClean())) {
152170
throw new Error('Remote history differs. Please pull changes.');
153171
}

Diff for: test/_utils.js

+23-21
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,36 @@
11
import esmock from 'esmock';
2+
import sinon from 'sinon';
23
import {execa} from 'execa';
34
import {SilentRenderer} from './fixtures/listr-renderer.js';
45

5-
export const _stubExeca = source => async (t, commands) => esmock(source, {}, {
6-
execa: {
7-
async execa(...args) {
8-
const results = await Promise.all(commands.map(async result => {
9-
const argsMatch = await t.try(tt => {
10-
const [command, ...commandArgs] = result.command.split(' ');
11-
tt.deepEqual(args, [command, commandArgs]);
12-
});
6+
const makeExecaStub = commands => {
7+
const stub = sinon.stub();
138

14-
if (argsMatch.passed) {
15-
argsMatch.discard();
9+
for (const result of commands) {
10+
const [command, ...commandArgs] = result.command.split(' ');
1611

17-
if (!result.exitCode || result.exitCode === 0) {
18-
return result;
19-
}
12+
// Command passes if the exit code is 0, or if there's no exit code and no stderr.
13+
const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr);
2014

21-
throw result;
22-
}
15+
if (passes) {
16+
stub.withArgs(command, commandArgs).resolves(result);
17+
} else {
18+
stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message
19+
}
20+
}
21+
22+
return stub;
23+
};
2324

24-
argsMatch.discard();
25-
}));
25+
export const _stubExeca = source => async commands => {
26+
const execaStub = makeExecaStub(commands);
2627

27-
const result = results.filter(Boolean).at(0);
28-
return result ?? execa(...args);
28+
return esmock(source, {}, {
29+
execa: {
30+
execa: async (...args) => execaStub.resolves(execa(...args))(...args),
2931
},
30-
},
31-
});
32+
});
33+
};
3234

3335
export const run = async listr => {
3436
listr.setRenderer(SilentRenderer);

Diff for: test/git-tasks.js

+83-23
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ test.afterEach(() => {
1515
});
1616

1717
test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => {
18-
const gitTasks = await stubExeca(t, [{
18+
const gitTasks = await stubExeca([{
1919
command: 'git symbolic-ref --short HEAD',
20-
exitCode: 0,
2120
stdout: 'feature',
2221
}]);
2322

@@ -30,9 +29,8 @@ test.serial('should fail when release branch is not specified, current branch is
3029
});
3130

3231
test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => {
33-
const gitTasks = await stubExeca(t, [{
32+
const gitTasks = await stubExeca([{
3433
command: 'git symbolic-ref --short HEAD',
35-
exitCode: 0,
3634
stdout: 'feature',
3735
}]);
3836

@@ -45,21 +43,26 @@ test.serial('should fail when current branch is not the specified release branch
4543
});
4644

4745
test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => {
48-
const gitTasks = await stubExeca(t, [
46+
const gitTasks = await stubExeca([
4947
{
5048
command: 'git symbolic-ref --short HEAD',
51-
exitCode: 0,
5249
stdout: 'feature',
5350
},
5451
{
5552
command: 'git status --porcelain',
56-
exitCode: 0,
5753
stdout: '',
5854
},
5955
{
60-
command: 'git rev-list --count --left-only @{u}...HEAD',
56+
command: 'git rev-parse @{u}',
6157
exitCode: 0,
62-
stdout: '',
58+
},
59+
{
60+
command: 'git fetch --dry-run',
61+
exitCode: 0,
62+
},
63+
{
64+
command: 'git rev-list --count --left-only @{u}...HEAD',
65+
stdout: '0',
6366
},
6467
]);
6568

@@ -71,15 +74,13 @@ test.serial('should not fail when current branch not master and publishing from
7174
});
7275

7376
test.serial('should fail when local working tree modified', async t => {
74-
const gitTasks = await stubExeca(t, [
77+
const gitTasks = await stubExeca([
7578
{
7679
command: 'git symbolic-ref --short HEAD',
77-
exitCode: 0,
7880
stdout: 'master',
7981
},
8082
{
8183
command: 'git status --porcelain',
82-
exitCode: 0,
8384
stdout: 'M source/git-tasks.js',
8485
},
8586
]);
@@ -92,22 +93,48 @@ test.serial('should fail when local working tree modified', async t => {
9293
assertTaskFailed(t, 'Check local working tree');
9394
});
9495

95-
test.serial('should fail when remote history differs', async t => {
96-
const gitTasks = await stubExeca(t, [
96+
test.serial('should not fail when no remote set up', async t => {
97+
const gitTasks = await stubExeca([
9798
{
9899
command: 'git symbolic-ref --short HEAD',
99-
exitCode: 0,
100100
stdout: 'master',
101101
},
102102
{
103103
command: 'git status --porcelain',
104-
exitCode: 0,
105104
stdout: '',
106105
},
107106
{
108-
command: 'git rev-list --count --left-only @{u}...HEAD',
107+
command: 'git rev-parse @{u}',
108+
stderr: 'fatal: no upstream configured for branch \'master\'',
109+
},
110+
]);
111+
112+
await t.notThrowsAsync(
113+
run(gitTasks({branch: 'master'})),
114+
);
115+
});
116+
117+
test.serial('should fail when remote history differs and changes are fetched', async t => {
118+
const gitTasks = await stubExeca([
119+
{
120+
command: 'git symbolic-ref --short HEAD',
121+
stdout: 'master',
122+
},
123+
{
124+
command: 'git status --porcelain',
125+
stdout: '',
126+
},
127+
{
128+
command: 'git rev-parse @{u}',
129+
exitCode: 0,
130+
},
131+
{
132+
command: 'git fetch --dry-run',
109133
exitCode: 0,
110-
stdout: '1',
134+
},
135+
{
136+
command: 'git rev-list --count --left-only @{u}...HEAD',
137+
stdout: '1', // Has unpulled changes
111138
},
112139
]);
113140

@@ -119,23 +146,56 @@ test.serial('should fail when remote history differs', async t => {
119146
assertTaskFailed(t, 'Check remote history');
120147
});
121148

122-
test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => {
123-
const gitTasks = await stubExeca(t, [
149+
test.serial('should fail when remote has unfetched changes', async t => {
150+
const gitTasks = await stubExeca([
124151
{
125152
command: 'git symbolic-ref --short HEAD',
126-
exitCode: 0,
127153
stdout: 'master',
128154
},
129155
{
130156
command: 'git status --porcelain',
131-
exitCode: 0,
132157
stdout: '',
133158
},
134159
{
135-
command: 'git rev-list --count --left-only @{u}...HEAD',
160+
command: 'git rev-parse @{u}',
136161
exitCode: 0,
162+
},
163+
{
164+
command: 'git fetch --dry-run',
165+
stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes
166+
},
167+
]);
168+
169+
await t.throwsAsync(
170+
run(gitTasks({branch: 'master'})),
171+
{message: 'Remote history differs. Please run `git fetch` and pull changes.'},
172+
);
173+
174+
assertTaskFailed(t, 'Check remote history');
175+
});
176+
177+
test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => {
178+
const gitTasks = await stubExeca([
179+
{
180+
command: 'git symbolic-ref --short HEAD',
181+
stdout: 'master',
182+
},
183+
{
184+
command: 'git status --porcelain',
137185
stdout: '',
138186
},
187+
{
188+
command: 'git rev-parse @{u}',
189+
exitCode: 0,
190+
},
191+
{
192+
command: 'git fetch --dry-run',
193+
exitCode: 0,
194+
},
195+
{
196+
command: 'git rev-list --count --left-only @{u}...HEAD',
197+
stdout: '0',
198+
},
139199
]);
140200

141201
await t.notThrowsAsync(

0 commit comments

Comments
 (0)