feat: Allow to recover from ENOTINHISTORY with a tag and handle detached head repo

- Tag sha will now be used also if there is a gitHead in last release and it's not in the history
- Use `git merge-base` to determine if a commit is in history, allowing to use CI creating detached head repo
- Mention recovery solution by creating a version tag in `ENOTINHISTORY` and `ENOGITHEAD` error messages
- Do not mention branches containing missing commit in `ENOTINHISTORY` and `ENOGITHEAD` error messages as it's not available by default on most CI
This commit is contained in:
Pierre-Denis Vanduynslager 2017-10-03 22:19:46 -04:00 committed by Gregor Martynus
parent 8e9d9f77f3
commit 580ad9c3d2
5 changed files with 365 additions and 110 deletions

View File

@ -14,10 +14,9 @@ const getVersionHead = require('./get-version-head');
* Retrieve the list of commits on the current branch since the last released version, or all the commits of the current branch if there is no last released version. * Retrieve the list of commits on the current branch since the last released version, or all the commits of the current branch if there is no last released version.
* *
* The commit correspoding to the last released version is determined as follow: * The commit correspoding to the last released version is determined as follow:
* - Use `lastRelease.gitHead` is defined and present in `config.options.branch` history. * - Use `lastRelease.gitHead` if defined and present in `config.options.branch` history.
* - Search for a tag named `v<version>` or `<version>` and it's associated commit sha if present in `config.options.branch` history. * - If `lastRelease.gitHead` is not in the `config.options.branch` history, unshallow the repository and try again.
* * - If `lastRelease.gitHead` is still not in the `config.options.branch` history, search for a tag named `v<version>` or `<version>` and verify if it's associated commit sha is present in `config.options.branch` history.
* If a commit corresponding to the last released is not found, unshallow the repository (as most CI create a shallow clone with limited number of commits and no tags) and try again.
* *
* @param {Object} config * @param {Object} config
* @param {Object} config.lastRelease The lastRelease object obtained from the getLastRelease plugin. * @param {Object} config.lastRelease The lastRelease object obtained from the getLastRelease plugin.
@ -34,20 +33,12 @@ const getVersionHead = require('./get-version-head');
module.exports = async ({lastRelease: {version, gitHead}, options: {branch}}) => { module.exports = async ({lastRelease: {version, gitHead}, options: {branch}}) => {
if (gitHead || version) { if (gitHead || version) {
try { try {
gitHead = await getVersionHead(version, branch, gitHead); gitHead = await getVersionHead(gitHead, version, branch);
} catch (err) {
// Unshallow the repository if the gitHead cannot be found and the branch for the last release version
await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false});
}
// Try to find the gitHead on the branch again with an unshallowed repository
try {
gitHead = await getVersionHead(version, branch, gitHead);
} catch (err) { } catch (err) {
if (err.code === 'ENOTINHISTORY') { if (err.code === 'ENOTINHISTORY') {
log.error('commits', notInHistoryMessage(gitHead, branch, version, err.branches)); log.error('commits', notInHistoryMessage(err.gitHead, branch, version));
} else if (err.code === 'ENOGITHEAD') { } else {
log.error('commits', noGitHeadMessage()); log.error('commits', noGitHeadMessage(branch, version));
} }
throw err; throw err;
} }
@ -73,20 +64,24 @@ module.exports = async ({lastRelease: {version, gitHead}, options: {branch}}) =>
} }
}; };
function noGitHeadMessage(version) { function noGitHeadMessage(branch, version) {
return `The commit the last release of this package was derived from cannot be determined from the release metadata not from the repository tags. return `The commit the last release of this package was derived from cannot be determined from the release metadata not from the repository tags.
This means semantic-release can not extract the commits between now and then. This means semantic-release can not extract the commits between now and then.
This is usually caused by releasing from outside the repository directory or with innaccessible git metadata. This is usually caused by releasing from outside the repository directory or with innaccessible git metadata.
You can recover from this error by publishing manually.`;
You can recover from this error by creating a tag for the version "${version}" on the commit corresponding to this release:
$ git tag -f v${version} <commit sha1 corresponding to last release>
$ git push -f --tags origin ${branch}
`;
} }
function notInHistoryMessage(gitHead, branch, version, branches) { function notInHistoryMessage(gitHead, branch, version) {
return `The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch. return `The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
This means semantic-release can not extract the commits between now and then. This means semantic-release can not extract the commits between now and then.
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name. This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
You can recover from this error by publishing manually or restoring the commit "${gitHead}".
${branches && branches.length You can recover from this error by restoring the commit "${gitHead}" or by creating a tag for the version "${version}" on the commit corresponding to this release:
? `Here is a list of branches that still contain the commit in question: \n * ${branches.join('\n * ')}` $ git tag -f v${version || '<version>'} <commit sha1 corresponding to last release>
: ''}`; $ git push -f --tags origin ${branch}
`;
} }

View File

@ -17,51 +17,59 @@ async function gitTagHead(tagName) {
} }
/** /**
* Get the list of branches that contains the given commit. * Verify if the commist `sha` is in the direct history of the current branch.
* *
* @param {string} sha The sha of the commit to look for. * @param {string} sha The sha of the commit to look for.
* *
* @return {Array<string>} The list of branches that contains the commit sha in parameter. * @return {boolean} `true` if the commit `sha` is in the history of the current branch, `false` otherwise.
*/ */
async function getCommitBranches(sha) { async function isCommitInHistory(sha) {
try { return (await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false})).code === 0;
return (await execa('git', ['branch', '--no-color', '--contains', sha])).stdout
.split('\n')
.map(branch => branch.replace('*', '').trim())
.filter(branch => !!branch);
} catch (err) {
return [];
}
} }
/** /**
* Get the commit sha for a given version, if it is contained in the given branch. * Get the commit sha for a given version, if it's contained in the given branch.
* *
* @param {string} gitHead The commit sha to look for.
* @param {string} version The version corresponding to the commit sha to look for. Used to search in git tags. * @param {string} version The version corresponding to the commit sha to look for. Used to search in git tags.
* @param {string} branch The branch that must have the commit in its direct history.
* @param {string} gitHead The commit sha to verify.
* *
* @return {Promise<string>} A Promise that resolves to `gitHead` if defined and if present in branch direct history or the commit sha corresponding to `version`. * @return {Promise<string>} A Promise that resolves to the commit sha of the version, either `gitHead` of the commit associated with the `version` tag.
* *
* @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `gitHead` or the commit sha dereived from `version` is not in the direct history of `branch`. The Error will have a `branches` attributes with the list of branches containing the commit. * @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `gitHead` or the commit sha dereived from `version` is not in the direct history of `branch`.
* @throws {SemanticReleaseError} with code `ENOGITHEAD` if `gitHead` is undefined and no commit sha can be found for the `version`. * @throws {SemanticReleaseError} with code `ENOGITHEAD` if `gitHead` is undefined and no commit sha can be found for the `version`.
*/ */
module.exports = async (version, branch, gitHead) => { module.exports = async (gitHead, version) => {
if (!gitHead && version) { // Check if gitHead is defined and exists in release branch
// Look for the version tag only if no gitHead exists if (gitHead && (await isCommitInHistory(gitHead))) {
gitHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version)); return gitHead;
} }
if (gitHead) { // Ushallow the repository
// Retrieve the branches containing the gitHead and verify one of them is the branch in param await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false});
const branches = await getCommitBranches(gitHead);
if (!branches.includes(branch)) { // Check if gitHead is defined and exists in release branch again
if (gitHead && (await isCommitInHistory(gitHead))) {
return gitHead;
}
let tagHead;
if (version) {
// If a version is defined search a corresponding tag
tagHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version));
// Check if tagHead is found and exists in release branch again
if (tagHead && (await isCommitInHistory(tagHead))) {
return tagHead;
}
}
// Either gitHead is defined or a tagHead has been found but none is in the branch history
if (gitHead || tagHead) {
const error = new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY'); const error = new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
error.branches = branches; error.gitHead = gitHead || tagHead;
throw error; throw error;
} }
} else {
// There is no gitHead in the last release and there is no tags correponsing to the last release version
throw new SemanticReleaseError('There is no commit associated with last release', 'ENOGITHEAD'); throw new SemanticReleaseError('There is no commit associated with last release', 'ENOGITHEAD');
}
return gitHead;
}; };

View File

@ -1,5 +1,14 @@
import test from 'ava'; import test from 'ava';
import {gitRepo, gitCommits, gitCheckout, gitTagVersion, gitShallowClone, gitTags, gitLog} from './helpers/git-utils'; import {
gitRepo,
gitCommits,
gitCheckout,
gitTagVersion,
gitShallowClone,
gitTags,
gitLog,
gitDetachedHead,
} from './helpers/git-utils';
import proxyquire from 'proxyquire'; import proxyquire from 'proxyquire';
import {stub} from 'sinon'; import {stub} from 'sinon';
import SemanticReleaseError from '@semantic-release/error'; import SemanticReleaseError from '@semantic-release/error';
@ -25,7 +34,7 @@ test.serial('Get all commits when there is no last release', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
const commits = await gitCommits(['fix: First fix', 'feat: Second feature']); const commits = await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module
const result = await getCommits({lastRelease: {}, options: {branch: 'master'}}); const result = await getCommits({lastRelease: {}, options: {branch: 'master'}});
@ -42,7 +51,7 @@ test.serial('Get all commits when there is no last release, including the ones n
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo(); const repo = await gitRepo();
// Add commits to the master branch // Add commits to the master branch
const commits = await gitCommits(['fix: First fix', 'feat: Second feature']); const commits = await gitCommits(['First', 'Second']);
// Create a shallow clone with only 1 commit // Create a shallow clone with only 1 commit
await gitShallowClone(repo); await gitShallowClone(repo);
@ -64,9 +73,9 @@ test.serial('Get all commits since gitHead (from lastRelease)', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
const commits = await gitCommits(['fix: First fix', 'feat: Second feature', 'feat: Third feature']); const commits = await gitCommits(['First', 'Second', 'Third']);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits({ const result = await getCommits({
lastRelease: {gitHead: commits[commits.length - 1].hash}, lastRelease: {gitHead: commits[commits.length - 1].hash},
options: {branch: 'master'}, options: {branch: 'master'},
@ -80,18 +89,38 @@ test.serial('Get all commits since gitHead (from lastRelease)', async t => {
t.is(result[1].message, commits[1].message); t.is(result[1].message, commits[1].message);
}); });
test.serial('Get all commits since gitHead (from lastRelease) on a detached head repo', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(['First', 'Second', 'Third']);
// Create a detached head repo at commit 'feat: Second'
await gitDetachedHead(repo, commits[1].hash);
// Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits({
lastRelease: {gitHead: commits[commits.length - 1].hash},
options: {branch: 'master'},
});
// Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First')
t.is(result.length, 1);
t.is(result[0].hash.substring(0, 7), commits[1].hash);
t.is(result[0].message, commits[1].message);
});
test.serial('Get all commits since gitHead (from tag) ', async t => { test.serial('Get all commits since gitHead (from tag) ', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
let commits = await gitCommits(['fix: First fix']); let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0 // Create the tag corresponding to version 1.0.0
await gitTagVersion('1.0.0'); await gitTagVersion('1.0.0');
// Add new commits to the master branch // Add new commits to the master branch
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits); commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); const result = await getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}});
// Verify the commits created and retrieved by the module are identical // Verify the commits created and retrieved by the module are identical
t.is(result.length, 2); t.is(result.length, 2);
@ -101,18 +130,82 @@ test.serial('Get all commits since gitHead (from tag) ', async t => {
t.is(result[1].message, commits[1].message); t.is(result[1].message, commits[1].message);
}); });
test.serial('Get all commits since gitHead (from tag) on a detached head repo', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Create a detached head repo at commit 'feat: Second'
await gitDetachedHead(repo, commits[1].hash);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag 1.0.0)
const result = await getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}});
// Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First')
t.is(result.length, 1);
t.is(result[0].hash.substring(0, 7), commits[1].hash);
t.is(result[0].message, commits[1].message);
});
test.serial('Get all commits since gitHead (from tag formatted like v<version>) ', async t => { test.serial('Get all commits since gitHead (from tag formatted like v<version>) ', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
let commits = await gitCommits(['fix: First fix']); let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0 // Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0'); await gitTagVersion('v1.0.0');
// Add new commits to the master branch // Add new commits to the master branch
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits); commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); const result = await getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}});
// Verify the commits created and retrieved by the module are identical
t.is(result.length, 2);
t.is(result[0].hash.substring(0, 7), commits[0].hash);
t.is(result[0].message, commits[0].message);
t.is(result[1].hash.substring(0, 7), commits[1].hash);
t.is(result[1].message, commits[1].message);
});
test.serial('Get commits when last release gitHead is missing but a tag match the version', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({lastRelease: {version: '1.0.0', gitHead: 'missing'}, options: {branch: 'master'}});
// Verify the commits created and retrieved by the module are identical
t.is(result.length, 2);
t.is(result[0].hash.substring(0, 7), commits[0].hash);
t.is(result[0].message, commits[0].message);
t.is(result[1].hash.substring(0, 7), commits[1].hash);
t.is(result[1].message, commits[1].message);
});
test.serial('Get all commits since gitHead, when gitHead are mising from the shallow clone', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(['First', 'Second', 'Third']);
// Create a shallow clone with only 1 commit and no tags
await gitShallowClone(repo);
// Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits({
lastRelease: {version: '1.0.0', gitHead: commits[commits.length - 1].hash},
options: {branch: 'master'},
});
// Verify the commits created and retrieved by the module are identical // Verify the commits created and retrieved by the module are identical
t.is(result.length, 2); t.is(result.length, 2);
@ -126,19 +219,19 @@ test.serial('Get all commits since gitHead from tag, when tags are mising from t
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo(); const repo = await gitRepo();
// Add commits to the master branch // Add commits to the master branch
let commits = await gitCommits(['fix: First fix']); let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0 // Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0'); await gitTagVersion('v1.0.0');
// Add new commits to the master branch // Add new commits to the master branch
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits); commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Create a shallow clone with only 1 commit and no tags // Create a shallow clone with only 1 commit and no tags
await gitShallowClone(repo); await gitShallowClone(repo);
// Verify the shallow clone does not contains any tags // Verify the shallow clone does not contains any tags
t.is((await gitTags()).length, 0); t.is((await gitTags()).length, 0);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); const result = await getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}});
// Verify the commits created and retrieved by the module are identical // Verify the commits created and retrieved by the module are identical
t.is(result.length, 2); t.is(result.length, 2);
@ -148,20 +241,20 @@ test.serial('Get all commits since gitHead from tag, when tags are mising from t
t.is(result[1].message, commits[1].message); t.is(result[1].message, commits[1].message);
}); });
test.serial('Return empty array if there is no commits', async t => { test.serial('Return empty array if lastRelease.gitHead is the last commit', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
const commits = await gitCommits(['fix: First fix', 'feat: Second feature']); const commits = await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module, since commit 'Second' (therefore none)
const result = await getCommits({lastRelease: {gitHead: commits[0].hash}, options: {branch: 'master'}}); const result = await getCommits({lastRelease: {gitHead: commits[0].hash}, options: {branch: 'master'}});
// Verify no commit is retrieved // Verify no commit is retrieved
t.deepEqual(result, []); t.deepEqual(result, []);
}); });
test.serial('Return empty array if lastRelease.gitHead is the last commit', async t => { test.serial('Return empty array if there is no commits', async t => {
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
@ -176,7 +269,7 @@ test.serial('Throws ENOGITHEAD error if the gitHead of the last release cannot b
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
await gitCommits(['fix: First fix', 'feat: Second feature']); await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module
const error = await t.throws(getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}})); const error = await t.throws(getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}}));
@ -195,7 +288,7 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in history', async t =
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
await gitRepo(); await gitRepo();
// Add commits to the master branch // Add commits to the master branch
await gitCommits(['fix: First fix', 'feat: Second feature']); await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module // Retrieve the commits with the commits module
const error = await t.throws(getCommits({lastRelease: {gitHead: 'notinhistory'}, options: {branch: 'master'}})); const error = await t.throws(getCommits({lastRelease: {gitHead: 'notinhistory'}, options: {branch: 'master'}}));
@ -218,8 +311,7 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but
await gitCheckout('other-branch'); await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch // Add commits to the 'other-branch' branch
const commitsBranch = await gitCommits(['Third', 'Fourth']); const commitsBranch = await gitCommits(['Third', 'Fourth']);
// Create the new branch 'another-branch' from 'other-branch' await gitCheckout('master', false);
await gitCheckout('another-branch');
// Retrieve the commits with the commits module // Retrieve the commits with the commits module
const error = await t.throws( const error = await t.throws(
@ -233,6 +325,59 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but
t.regex(errorLog.firstCall.args[1], /history of the "master" branch/); t.regex(errorLog.firstCall.args[1], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead // Verify the log function has been called with a message mentionning the missing gitHead
t.regex(errorLog.firstCall.args[1], new RegExp(`restoring the commit "${commitsBranch[0].hash}"`)); t.regex(errorLog.firstCall.args[1], new RegExp(`restoring the commit "${commitsBranch[0].hash}"`));
// Verify the log function has been called with a message mentionning the branches that contains the gitHead });
t.regex(errorLog.firstCall.args[1], /\* another-branch\s+\* other-branch/);
test.serial('Throws ENOTINHISTORY error if gitHead is not in detached head but present in other branch', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commit to the master branch
await gitCommits(['First']);
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch
const commitsBranch = await gitCommits(['Second', 'Third']);
await gitCheckout('master', false);
// Add new commit to master branch
const commitsMaster = await gitCommits(['Fourth']);
// Create a detached head repo at commit 'Fourth'
await gitDetachedHead(repo, commitsMaster[0].hash);
// Retrieve the commits with the commits module, since commit 'Second'
const error = await t.throws(
getCommits({lastRelease: {version: '1.0.1', gitHead: commitsBranch[0].hash}, options: {branch: 'master'}})
);
// Verify error code and message
t.is(error.code, 'ENOTINHISTORY');
t.true(error instanceof SemanticReleaseError);
// Verify the log function has been called with a message mentionning the branch
t.regex(errorLog.firstCall.args[1], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(errorLog.firstCall.args[1], new RegExp(`restoring the commit "${commitsBranch[0].hash}"`));
});
test.serial('Throws ENOTINHISTORY error when a tag is not in branch history but present in others', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
await gitCommits(['First', 'Second']);
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch
await gitCommits(['Third']);
// Create the tag corresponding to version 1.0.0
const shaTag = await gitTagVersion('v1.0.0');
await gitCheckout('master', false);
// Add new commit to the master branch
await gitCommits(['Forth']);
// Retrieve the commits with the commits module
const error = await t.throws(getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}}));
// Verify error code and message
t.is(error.code, 'ENOTINHISTORY');
t.true(error instanceof SemanticReleaseError);
// Verify the log function has been called with a message mentionning the branch
t.regex(errorLog.firstCall.args[1], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(errorLog.firstCall.args[1], new RegExp(`restoring the commit "${shaTag}"`));
}); });

View File

@ -35,7 +35,7 @@ export async function gitRepo() {
* *
* @param {Array<string>} messages commit messages. * @param {Array<string>} messages commit messages.
* *
* @returns {Array<Commit>} commits the created commits, in reverse order (to match `git log` order). * @returns {Array<Commit>} The created commits, in reverse order (to match `git log` order).
*/ */
export async function gitCommits(messages) { export async function gitCommits(messages) {
return (await pMapSeries(messages, async msg => { return (await pMapSeries(messages, async msg => {
@ -45,6 +45,19 @@ export async function gitCommits(messages) {
})).reverse(); })).reverse();
} }
/**
* Amend a commit (rewriting the sha) on the current git repository.
*
* @param {string} messages commit message.
*
* @returns {Array<Commit>} the created commits.
*/
export async function gitAmmendCommit(msg) {
const {stdout} = await execa('git', ['commit', '--amend', '-m', msg, '--allow-empty']);
const [, branch, hash, message] = /^\[(\w+)\(?.*?\)?(\w+)\] (.+)(.|\s)+$/.exec(stdout);
return {branch, hash, message};
}
/** /**
* Checkout a branch on the current git repository. * Checkout a branch on the current git repository.
* *
@ -52,7 +65,7 @@ export async function gitCommits(messages) {
* @param {boolean} create `true` to create the branche ans switch, `false` to only switch. * @param {boolean} create `true` to create the branche ans switch, `false` to only switch.
*/ */
export async function gitCheckout(branch, create = true) { export async function gitCheckout(branch, create = true) {
await execa('git', ['checkout', create ? '-b' : null, branch]); await execa('git', create ? ['checkout', '-b', branch] : ['checkout', branch]);
} }
/** /**
@ -66,9 +79,13 @@ export async function gitHead() {
* Create a tag on the head commit in the current git repository. * Create a tag on the head commit in the current git repository.
* *
* @param {string} tagName The tag name to create. * @param {string} tagName The tag name to create.
* @param {string} [sha] The commit on which to create the tag. If undefined the tag is created on the last commit.
*
* @return {string} The commit sha of the created tag.
*/ */
export async function gitTagVersion(tagName) { export async function gitTagVersion(tagName, sha) {
await execa('git', ['tag', tagName]); await execa('git', sha ? ['tag', '-f', tagName, sha] : ['tag', tagName]);
return (await execa('git', ['rev-list', '-1', '--tags', tagName])).stdout;
} }
/** /**
@ -93,11 +110,29 @@ export async function gitLog() {
* @param {number} [depth=1] The number of commit to clone. * @param {number} [depth=1] The number of commit to clone.
* @return {string} The path of the cloned repository. * @return {string} The path of the cloned repository.
*/ */
export async function gitShallowClone(origin, depth = 1) { export async function gitShallowClone(origin, branch = 'master', depth = 1) {
const dir = tempy.directory(); const dir = tempy.directory();
process.chdir(dir); process.chdir(dir);
await execa('git', ['clone', '--no-hardlinks', '--no-tags', '--depth', depth, `file://${origin}`, dir]); await execa('git', ['clone', '--no-hardlinks', '--no-tags', '-b', branch, '--depth', depth, `file://${origin}`, dir]);
return dir;
}
/**
* Create a git repo with a detached head from another git repository and change the current working directory to the new repository root.
*
* @param {string} origin The path of the repository to clone.
* @param {number} head A commit sha of the origin repo that will become the detached head of the new one.
* @return {string} The path of the new repository.
*/
export async function gitDetachedHead(origin, head) {
const dir = tempy.directory();
process.chdir(dir);
await execa('git', ['init']);
await execa('git', ['remote', 'add', 'origin', origin]);
await execa('git', ['fetch']);
await execa('git', ['checkout', head]);
return dir; return dir;
} }

View File

@ -1,9 +1,18 @@
import test from 'ava'; import test from 'ava';
import {writeJson, readJson} from 'fs-extra'; import {writeJson, readJson} from 'fs-extra';
import {start, stop, uri} from './helpers/registry'; import {start, stop, uri} from './helpers/registry';
import {gitRepo, gitCommits, gitHead, gitTagVersion, gitPackRefs} from './helpers/git-utils'; import {gitRepo, gitCommits, gitHead, gitTagVersion, gitPackRefs, gitAmmendCommit} from './helpers/git-utils';
import execa from 'execa'; import execa from 'execa';
// Environment variables used with cli
const env = {
CI: true,
npm_config_registry: uri,
GH_TOKEN: 'github_token',
NPM_OLD_TOKEN: 'aW50ZWdyYXRpb246c3VjaHNlY3VyZQ==',
NPM_EMAIL: 'integration@test.com',
};
test.before(async t => { test.before(async t => {
// Start the local NPM registry // Start the local NPM registry
await start(); await start();
@ -25,14 +34,6 @@ test.after.always(async t => {
}); });
test.serial('Release patch, minor and major versions', async t => { test.serial('Release patch, minor and major versions', async t => {
// Environment variables used with cli
const env = {
CI: true,
npm_config_registry: uri,
GH_TOKEN: 'github_token',
NPM_OLD_TOKEN: 'aW50ZWdyYXRpb246c3VjaHNlY3VyZQ==',
NPM_EMAIL: 'integration@test.com',
};
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository'); t.log('Create git repository');
await gitRepo(); await gitRepo();
@ -146,14 +147,6 @@ test.serial('Release patch, minor and major versions', async t => {
}); });
test.serial('Release versions from a packed git repository, using tags to determine last release gitHead', async t => { test.serial('Release versions from a packed git repository, using tags to determine last release gitHead', async t => {
// Environment variables used with cli
const env = {
CI: true,
npm_config_registry: uri,
GH_TOKEN: 'github_token',
NPM_OLD_TOKEN: 'aW50ZWdyYXRpb246c3VjaHNlY3VyZQ==',
NPM_EMAIL: 'integration@test.com',
};
// Create a git repository, set the current working directory at the root of the repo // Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository'); t.log('Create git repository');
await gitRepo(); await gitRepo();
@ -230,3 +223,82 @@ test.serial('Exit with 1 in a plugin is not found', async t => {
const {code} = await t.throws(execa(require.resolve('../bin/semantic-release'), ['pre'], {env})); const {code} = await t.throws(execa(require.resolve('../bin/semantic-release'), ['pre'], {env}));
t.is(code, 1); t.is(code, 1);
}); });
test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', async t => {
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository');
await gitRepo();
// Create package.json in repository root
await writeJson('./package.json', {
name: 'test-module-4',
version: '0.0.0-dev',
repository: {url: 'git+https://github.com/semantic-release/test-module-2'},
release: {verifyConditions: require.resolve('../src/lib/plugin-noop')},
});
/** Minor release **/
t.log('Commit a feature');
await gitCommits(['feat: Initial commit']);
t.log('$ semantic-release pre');
let {stdout, stderr, code} = await execa(require.resolve('../bin/semantic-release'), ['pre'], {env});
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, '1.0.0');
t.log('$ npm publish');
({stdout, code} = await execa('npm', ['publish'], {env}));
// Verify output of npm publish
t.regex(stdout, /test-module-4@1.0.0/);
t.is(code, 0);
// Retrieve the published package from the registry and check version and gitHead
let [, version, releaseGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', 'test-module-4', 'version', 'gitHead'], {env})).stdout
);
const head = await gitHead();
t.is(releaseGitHead, head);
t.log(`+ released ${version}`);
t.is(version, '1.0.0');
// Create a tag version so the tag can be used later to determine the commit associated with the version
await gitTagVersion('v1.0.0');
t.log('Create git tag v1.0.0');
/** Rewrite sha of commit used for release **/
t.log('Amend release commit');
const {hash} = await gitAmmendCommit('feat: Initial commit');
/** Patch release **/
t.log('Commit a fix');
await gitCommits(['fix: bar']);
t.log('$ semantic-release pre');
({stderr, stdout, code} = await execa(require.resolve('../bin/semantic-release'), ['pre'], {env, reject: false}));
t.log('Fail with "ENOTINHISTORY" error');
t.is(code, 1);
t.regex(
stderr,
new RegExp(
`You can recover from this error by restoring the commit "${head}" or by creating a tag for the version "1.0.0" on the commit corresponding to this release`
)
);
/** Create a tag to recover and redo release **/
t.log('Create git tag v1.0.0 to recover');
await gitTagVersion('v1.0.0', hash);
t.log('$ semantic-release pre');
({stderr, stdout, code} = await execa(require.resolve('../bin/semantic-release'), ['pre'], {env}));
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, '1.0.1');
t.log('$ npm publish');
({stdout, code} = await execa('npm', ['publish'], {env}));
// Verify output of npm publish
t.regex(stdout, /test-module-4@1.0.1/);
t.is(code, 0);
// Retrieve the published package from the registry and check version and gitHead
version = (await execa('npm', ['show', 'test-module-4', 'version'], {env})).stdout;
t.is(version, '1.0.1');
t.log(`+ released ${version}`);
});