Skip to content

Commit

Permalink
Merge pull request semantic-release#9 from jive/release-allow-outdate…
Browse files Browse the repository at this point in the history
…d-head
  • Loading branch information
agaudreault committed Apr 26, 2021
2 parents 44b4f0c + 758fc68 commit 2b7c810
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 111 deletions.
50 changes: 33 additions & 17 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ const getBranches = require('./lib/branches');
const getLogger = require('./lib/get-logger');
const {
verifyAuth,
isBranchUpToDate,
isRemoteHead,
isAncestor,
getNoMergeTags,
getGitHead,
getGitRemoteHead,
tag,
push,
pushNotes,
Expand Down Expand Up @@ -84,28 +85,17 @@ async function run(context, plugins) {

try {
await verifyAuth(options.repositoryUrl, context.branch.name, {cwd, env});

if (!(await isRemoteHead(options.repositoryUrl, context.branch.name, {cwd, env}))) {
logger.log(`The branch ${context.branch.name} has local commit, therefore a new version won't be published.`);
return false;
}

if (
!options.allowOutdatedBranch &&
!(await isBranchUpToDate(options.repositoryUrl, context.branch.name, {cwd, env}))
) {
logger.log(
`The local branch ${context.branch.name} is behind the remote one, therefore a new version won't be published.`
);
return false;
}
} catch (error) {
logger.error(`The command "${error.command}" failed with the error message ${error.stderr}.`);
throw getError('EGITNOPERMISSION', context);
}

logger.success(`Allowed to push to the Git repository`);

if (!(await validateBranch(context, {cwd, env}))) {
return false;
}

await plugins.verifyConditions(context);

const errors = [];
Expand Down Expand Up @@ -253,6 +243,32 @@ async function callFail(context, plugins, err) {
}
}

async function validateBranch(context, execaOptions) {
const localHead = await getGitHead(execaOptions);
const remoteHead = await getGitRemoteHead(context.options.repositoryUrl, context.branch.name, execaOptions);

if (!(await isAncestor(localHead, remoteHead, execaOptions))) {
throw getError('ELOCALCOMMIT', context);
}

if (!context.options.allowOutdatedBranch && localHead !== remoteHead) {
context.logger.log(
`The local branch ${context.branch.name} is behind the remote one, therefore a new version won't be published.`
);
return false;
}

const tagsNotLocal = await getNoMergeTags(localHead, execaOptions);
const tagsNotRemote = await getNoMergeTags(remoteHead, execaOptions);
const localMissingTags = tagsNotLocal.filter((value) => !tagsNotRemote.includes(value));

if (localMissingTags.length !== 0) {
throw getError('EREMOTETAG', context);
}

return true;
}

module.exports = async (cliOptions = {}, {cwd = process.cwd(), env = process.env, stdout, stderr} = {}) => {
const {unhook} = hookStd(
{silent: false, streams: [process.stdout, process.stderr, stdout, stderr].filter(Boolean)},
Expand Down
12 changes: 12 additions & 0 deletions lib/definitions/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,16 @@ The branch \`${name}\` head should be [reset](https://git-scm.com/docs/git-reset
See the [workflow configuration documentation](${linkify('docs/usage/workflow-configuration.md')}) for more details.`,
}),
EREMOTETAG: ({branch: {name}}) => ({
message: `The local branch \`${name}\` is missing remote tags.`,
details: `Only local branch that contains the same tags present on the remote can be released.
The branch \`${name}\` should fetch the remote commits.`,
}),
ELOCALCOMMIT: ({branch: {name}}) => ({
message: `The branch \`${name}\` has local commit.`,
details: `Only local branch with their head present on the remote can be released.
The branch \`${name}\` should be pushed to the remote.`,
}),
};
68 changes: 40 additions & 28 deletions lib/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ async function getTags(branch, execaOptions) {
.filter(Boolean);
}

/**
* Get all the tags that are not merged for a given branch.
*
* @param {String} branch The branch for which to retrieve the tags.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Array<String>} List of git tags.
* @throws {Error} If the `git` command fails.
*/
async function getNoMergeTags(branch, execaOptions) {
return (await execa('git', ['tag', '--no-merged', branch], execaOptions)).stdout
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean);
}

/**
* Retrieve a range of commits.
*
Expand Down Expand Up @@ -163,6 +179,21 @@ async function getGitHead(execaOptions) {
return (await execa('git', ['rev-parse', 'HEAD'], execaOptions)).stdout;
}

/**
* Get the remote HEAD sha.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {String} branch The repository branch for which to get the remote.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {String} the sha of the remote HEAD commit.
*/
async function getGitRemoteHead(repositoryUrl, branch, execaOptions) {
return (await execa('git', ['ls-remote', '--heads', repositoryUrl, branch], execaOptions)).stdout.match(
/^(?<ref>\w+)?/
)[1];
}

/**
* Get the repository remote URL.
*
Expand Down Expand Up @@ -281,37 +312,17 @@ async function verifyBranchName(branch, execaOptions) {
}

/**
* Verify the local branch is up to date with the remote one.
* Check if the first commit is an ancestor of the second commit.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {String} branch The repository branch for which to verify status.
* @param {String} first The commit to validate.
* @param {String} second The commit that should contain the first one.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` is the HEAD of the current local branch is the same as the HEAD of the remote branch, falsy otherwise.
* @return {Boolean} `true` is the first ocommit is present in the second one, falsy otherwise.
*/
async function isBranchUpToDate(repositoryUrl, branch, execaOptions) {
return (
(await getGitHead(execaOptions)) ===
(await execa('git', ['ls-remote', '--heads', repositoryUrl, branch], execaOptions)).stdout.match(/^(?<ref>\w+)?/)[1]
);
}

/**
* Verify the local branch HEAD is a commit on the remote.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {String} branch The repository branch for which to verify status.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` is the HEAD of the current local branch is present on the remote branch, falsy otherwise.
*/
async function isRemoteHead(repositoryUrl, branch, execaOptions) {
const localHead = await getGitHead(execaOptions);
const remoteHead = (await execa('git', ['ls-remote', '--heads', repositoryUrl, branch], execaOptions)).stdout.match(
/^(?<ref>\w+)?/
)[1];
async function isAncestor(first, second, execaOptions) {
try {
return (await execa('git', ['merge-base', '--is-ancestor', localHead, remoteHead], execaOptions)).exitCode === 0;
return (await execa('git', ['merge-base', '--is-ancestor', first, second], execaOptions)).exitCode === 0;
} catch (error) {
if (error.exitCode === 1) {
return false;
Expand Down Expand Up @@ -357,21 +368,22 @@ async function addNote(note, ref, execaOptions) {
module.exports = {
getTagHead,
getTags,
getNoMergeTags,
getCommits,
getBranches,
isRefExists,
fetch,
fetchNotes,
getGitHead,
getGitRemoteHead,
repoUrl,
isGitRepo,
verifyAuth,
tag,
push,
pushNotes,
verifyTagName,
isBranchUpToDate,
isRemoteHead,
isAncestor,
verifyBranchName,
getNote,
addNote,
Expand Down
93 changes: 34 additions & 59 deletions test/git.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ const {
isRefExists,
fetch,
getGitHead,
getGitRemoteHead,
repoUrl,
tag,
push,
getTags,
getNoMergeTags,
getBranches,
isGitRepo,
verifyTagName,
isBranchUpToDate,
isRemoteHead,
isAncestor,
getNote,
addNote,
fetchNotes,
Expand Down Expand Up @@ -55,6 +56,19 @@ test('Throw error if the last commit sha cannot be found', async (t) => {
await t.throwsAsync(getGitHead({cwd}));
});

test('Get the head commit on the remote', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const {cwd, repositoryUrl} = await gitRepo(true);
// Add commits to the master branch
const [commit] = await gitCommits(['First'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});
await gitCommits(['Second'], {cwd});

const result = await getGitRemoteHead(repositoryUrl, 'master', {cwd});

t.is(result, commit.hash);
});

test('Unshallow and fetch repository', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
let {cwd, repositoryUrl} = await gitRepo();
Expand Down Expand Up @@ -155,6 +169,19 @@ test('Fetch all tags on a detached head repository with outdated cached repo (Gi
t.deepEqual((await getTags('master', {cwd: cloneCwd})).sort(), ['v1.0.0', 'v1.0.1', 'v1.1.0', 'v1.2.0'].sort());
});

test('Fetch all tags not present on the local branch', async (t) => {
const {cwd, repositoryUrl} = await gitRepo();

await gitCommits(['First'], {cwd});
await gitTagVersion('v1.0.0', undefined, {cwd});
const [commit] = await gitCommits(['Second'], {cwd});
await gitCommits(['Third'], {cwd});
await gitTagVersion('v1.1.0', undefined, {cwd});
await gitPush(repositoryUrl, 'master', {cwd});

t.deepEqual((await getNoMergeTags(commit.hash, {cwd})).sort(), ['v1.1.0'].sort());
});

test('Verify if a branch exists', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const {cwd} = await gitRepo();
Expand Down Expand Up @@ -294,41 +321,6 @@ test('Throws error if obtaining the tags fails', async (t) => {
await t.throwsAsync(getTags('master', {cwd}));
});

test('Return "true" if repository is up to date', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
await gitCommits(['First'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});

t.true(await isBranchUpToDate(repositoryUrl, 'master', {cwd}));
});

test('Return falsy if repository is not up to date', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
await gitCommits(['First'], {cwd});
await gitCommits(['Second'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});

t.true(await isBranchUpToDate(repositoryUrl, 'master', {cwd}));

const temporaryRepo = await gitShallowClone(repositoryUrl);
await gitCommits(['Third'], {cwd: temporaryRepo});
await gitPush('origin', 'master', {cwd: temporaryRepo});

t.falsy(await isBranchUpToDate(repositoryUrl, 'master', {cwd}));
});

test('Return falsy if detached head repository is not up to date', async (t) => {
let {cwd, repositoryUrl} = await gitRepo();

const [commit] = await gitCommits(['First'], {cwd});
await gitCommits(['Second'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});
cwd = await gitDetachedHead(repositoryUrl, commit.hash);
await fetch(repositoryUrl, 'master', 'master', {cwd});

t.falsy(await isBranchUpToDate(repositoryUrl, 'master', {cwd}));
});

test('Get a commit note', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const {cwd} = await gitRepo();
Expand Down Expand Up @@ -417,29 +409,12 @@ test('Fetch all notes on a detached head repository', async (t) => {
t.is(await gitGetNote(commit.hash, {cwd}), '{"note":"note"}');
});

test('Return "true" if local and remote head are the same', async (t) => {
test('Validate that first commit is ancestor of second', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
await gitCommits(['First'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});

t.true(await isRemoteHead(repositoryUrl, 'master', {cwd}));
});

test('Return "true" if local head in remote', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
const [commit] = await gitCommits(['First'], {cwd});
await gitCommits(['Second'], {cwd});
const [first] = await gitCommits(['First'], {cwd});
const [second] = await gitCommits(['Second'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});
await gitDetachedHeadFromBranch(repositoryUrl, 'master', commit.hash);

t.true(await isRemoteHead(repositoryUrl, 'master', {cwd}));
});

test('Return falsy if branch has local commit', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
await gitCommits(['First'], {cwd});
await gitPush(repositoryUrl, 'master', {cwd});
await gitCommits(['Second'], {cwd});

t.false(await isRemoteHead(repositoryUrl, 'master', {cwd}));
t.true(await isAncestor(first.hash, second.hash, {cwd}));
t.false(await isAncestor(second.hash, first.hash, {cwd}));
});
11 changes: 11 additions & 0 deletions test/helpers/git-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ async function gitFetch(repositoryUrl, execaOptions) {
await execa('git', ['fetch', repositoryUrl], execaOptions);
}

/**
* Reset the current branch.
*
* @param {String} head A commit sha of the remote repo that will become the detached head of the new one.
* @param {Object} [execaOpts] Options to pass to `execa`.
*/
async function gitReset(head, execaOptions) {
await execa('git', ['reset', '--hard', head], execaOptions);
}

/**
* Get the HEAD sha.
*
Expand Down Expand Up @@ -331,6 +341,7 @@ module.exports = {
gitGetCommits,
gitCheckout,
gitFetch,
gitReset,
gitHead,
gitTagVersion,
gitShallowClone,
Expand Down
Loading

0 comments on commit 2b7c810

Please sign in to comment.