From a5d4c3db97304f799a053704dba626857b9f5ca3 Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 6 Apr 2023 02:18:41 -0500 Subject: [PATCH] Fix remote history check - check if `git fetch` needs to be run (#685) --- source/git-util.js | 34 +++++++++--- test/_utils.js | 44 +++++++-------- test/git-tasks.js | 106 +++++++++++++++++++++++++++++-------- test/prerequisite-tasks.js | 28 +++++----- 4 files changed, 146 insertions(+), 66 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index 2f76d561..207a145f 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -133,21 +133,39 @@ export const verifyWorkingTreeIsClean = async () => { } }; -export const isRemoteHistoryClean = async () => { - let history; - try { // Gracefully handle no remote set up. - const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); - history = stdout; - } catch {} - - if (history && history !== '0') { +const hasRemote = async () => { + try { + await execa('git', ['rev-parse', '@{u}']); + } catch { // Has no remote if command fails return false; } return true; }; +const hasUnfetchedChangesFromRemote = async () => { + const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run']); + + // There are no unfetched changes if output is empty. + return !possibleNewChanges || possibleNewChanges === ''; +}; + +const isRemoteHistoryClean = async () => { + const {stdout: history} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); + + // Remote history is clean if there are 0 revisions. + return history === '0'; +}; + export const verifyRemoteHistoryIsClean = async () => { + if (!(await hasRemote())) { + return; + } + + if (!(await hasUnfetchedChangesFromRemote())) { + throw new Error('Remote history differs. Please run `git fetch` and pull changes.'); + } + if (!(await isRemoteHistoryClean())) { throw new Error('Remote history differs. Please pull changes.'); } diff --git a/test/_utils.js b/test/_utils.js index f91da530..c87b122f 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -1,34 +1,36 @@ import esmock from 'esmock'; +import sinon from 'sinon'; import {execa} from 'execa'; import {SilentRenderer} from './fixtures/listr-renderer.js'; -export const _stubExeca = source => async (t, commands) => esmock(source, {}, { - execa: { - async execa(...args) { - const results = await Promise.all(commands.map(async result => { - const argsMatch = await t.try(tt => { - const [command, ...commandArgs] = result.command.split(' '); - tt.deepEqual(args, [command, commandArgs]); - }); +const makeExecaStub = commands => { + const stub = sinon.stub(); - if (argsMatch.passed) { - argsMatch.discard(); + for (const result of commands) { + const [command, ...commandArgs] = result.command.split(' '); - if (!result.exitCode || result.exitCode === 0) { - return result; - } + // Command passes if the exit code is 0, or if there's no exit code and no stderr. + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); - throw result; - } + if (passes) { + stub.withArgs(command, commandArgs).resolves(result); + } else { + stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + } + } + + return stub; +}; - argsMatch.discard(); - })); +export const _stubExeca = source => async commands => { + const execaStub = makeExecaStub(commands); - const result = results.filter(Boolean).at(0); - return result ?? execa(...args); + return esmock(source, {}, { + execa: { + execa: async (...args) => execaStub.resolves(execa(...args))(...args), }, - }, -}); + }); +}; export const run = async listr => { listr.setRenderer(SilentRenderer); diff --git a/test/git-tasks.js b/test/git-tasks.js index d390d0c5..c39963d3 100644 --- a/test/git-tasks.js +++ b/test/git-tasks.js @@ -15,9 +15,8 @@ test.afterEach(() => { }); 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 => { - const gitTasks = await stubExeca(t, [{ + const gitTasks = await stubExeca([{ command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }]); @@ -30,9 +29,8 @@ test.serial('should fail when release branch is not specified, current branch is }); test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca(t, [{ + const gitTasks = await stubExeca([{ command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }]); @@ -45,21 +43,26 @@ test.serial('should fail when current branch is not the specified release branch }); test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - const gitTasks = await stubExeca(t, [ + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', exitCode: 0, - stdout: '', + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', }, ]); @@ -71,15 +74,13 @@ test.serial('should not fail when current branch not master and publishing from }); test.serial('should fail when local working tree modified', async t => { - const gitTasks = await stubExeca(t, [ + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: 'M source/git-tasks.js', }, ]); @@ -92,22 +93,48 @@ test.serial('should fail when local working tree modified', async t => { assertTaskFailed(t, 'Check local working tree'); }); -test.serial('should fail when remote history differs', async t => { - const gitTasks = await stubExeca(t, [ +test.serial('should not fail when no remote set up', async t => { + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for branch \'master\'', + }, + ]); + + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); + +test.serial('should fail when remote history differs and changes are fetched', async t => { + const gitTasks = await stubExeca([ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', exitCode: 0, - stdout: '1', + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes }, ]); @@ -119,23 +146,56 @@ test.serial('should fail when remote history differs', async t => { assertTaskFailed(t, 'Check remote history'); }); -test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - const gitTasks = await stubExeca(t, [ +test.serial('should fail when remote has unfetched changes', async t => { + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, + ]); + + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { + const gitTasks = await stubExeca([ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', stdout: '', }, + { + 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', + }, ]); await t.notThrowsAsync( diff --git a/test/prerequisite-tasks.js b/test/prerequisite-tasks.js index e693e6f8..9be09523 100644 --- a/test/prerequisite-tasks.js +++ b/test/prerequisite-tasks.js @@ -20,7 +20,7 @@ test.afterEach(() => { }); test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'npm ping', exitCode: 1, exitCodeName: 'EPERM', @@ -37,7 +37,7 @@ test.serial('public-package published on npm registry: should fail when npm regi }); test.serial('private package: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -51,7 +51,7 @@ test.serial('private package: should disable task pinging npm registry', async t }); test.serial('external registry: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -65,7 +65,7 @@ test.serial('external registry: should disable task pinging npm registry', async }); test.serial('should fail when npm version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm --version', exitCode: 0, @@ -89,7 +89,7 @@ test.serial('should fail when npm version does not match range in `package.json` }); test.serial('should fail when yarn version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'yarn --version', exitCode: 0, @@ -113,7 +113,7 @@ test.serial('should fail when yarn version does not match range in `package.json }); test.serial('should fail when user is not authenticated at npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm whoami', exitCode: 0, @@ -139,7 +139,7 @@ test.serial('should fail when user is not authenticated at npm registry', async }); test.serial('should fail when user is not authenticated at external registry', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm whoami --registry http://my.io', exitCode: 0, @@ -170,7 +170,7 @@ test.serial('should fail when user is not authenticated at external registry', a }); test.serial('private package: should disable task `verify user is authenticated`', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -188,7 +188,7 @@ test.serial('private package: should disable task `verify user is authenticated` }); test.serial('should fail when git version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git version', exitCode: 0, stdout: 'git version 1.0.0', @@ -205,7 +205,7 @@ test.serial('should fail when git version does not match range in `package.json` }); test.serial('should fail when git remote does not exist', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git ls-remote origin HEAD', exitCode: 1, exitCodeName: 'EPERM', @@ -248,7 +248,7 @@ test.serial('should fail when prerelease version of public package without dist }); test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]); @@ -259,7 +259,7 @@ test.serial('should not fail when prerelease version of public package with dist }); test.serial('should not fail when prerelease version of private package without dist tag given', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]); @@ -270,7 +270,7 @@ test.serial('should not fail when prerelease version of private package without }); test.serial('should fail when git tag already exists', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: 'vvb', }]); @@ -284,7 +284,7 @@ test.serial('should fail when git tag already exists', async t => { }); test.serial('checks should pass', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]);