diff --git a/src/lib/get-commits.js b/src/lib/get-commits.js index d5d139f2..920d1278 100644 --- a/src/lib/get-commits.js +++ b/src/lib/get-commits.js @@ -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. * * The commit correspoding to the last released version is determined as follow: - * - Use `lastRelease.gitHead` is defined and present in `config.options.branch` history. - * - Search for a tag named `v` or `` and it's associated commit sha if 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. + * - Use `lastRelease.gitHead` if defined and 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` or `` and verify if it's associated commit sha is present in `config.options.branch` history. * * @param {Object} config * @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}}) => { if (gitHead || version) { try { - gitHead = await getVersionHead(version, branch, gitHead); - } 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); + gitHead = await getVersionHead(gitHead, version, branch); } catch (err) { if (err.code === 'ENOTINHISTORY') { - log.error('commits', notInHistoryMessage(gitHead, branch, version, err.branches)); - } else if (err.code === 'ENOGITHEAD') { - log.error('commits', noGitHeadMessage()); + log.error('commits', notInHistoryMessage(err.gitHead, branch, version)); + } else { + log.error('commits', noGitHeadMessage(branch, version)); } 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. - 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. - You can recover from this error by publishing manually.`; +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. + +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} +$ 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. - 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. - You can recover from this error by publishing manually or restoring the commit "${gitHead}". - - ${branches && branches.length - ? `Here is a list of branches that still contain the commit in question: \n * ${branches.join('\n * ')}` - : ''}`; +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. + +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: +$ git tag -f v${version || ''} +$ git push -f --tags origin ${branch} +`; } diff --git a/src/lib/get-version-head.js b/src/lib/get-version-head.js index 8fe77939..8954d5e8 100644 --- a/src/lib/get-version-head.js +++ b/src/lib/get-version-head.js @@ -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. * - * @return {Array} 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) { - try { - return (await execa('git', ['branch', '--no-color', '--contains', sha])).stdout - .split('\n') - .map(branch => branch.replace('*', '').trim()) - .filter(branch => !!branch); - } catch (err) { - return []; - } +async function isCommitInHistory(sha) { + return (await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false})).code === 0; } /** - * 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} branch The branch that must have the commit in its direct history. - * @param {string} gitHead The commit sha to verify. * - * @return {Promise} A Promise that resolves to `gitHead` if defined and if present in branch direct history or the commit sha corresponding to `version`. + * @return {Promise} 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`. */ -module.exports = async (version, branch, gitHead) => { - if (!gitHead && version) { - // Look for the version tag only if no gitHead exists - gitHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version)); +module.exports = async (gitHead, version) => { + // Check if gitHead is defined and exists in release branch + if (gitHead && (await isCommitInHistory(gitHead))) { + return gitHead; } - if (gitHead) { - // Retrieve the branches containing the gitHead and verify one of them is the branch in param - const branches = await getCommitBranches(gitHead); - if (!branches.includes(branch)) { - const error = new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY'); - error.branches = branches; - throw error; - } - } else { - throw new SemanticReleaseError('There is no commit associated with last release', 'ENOGITHEAD'); + // Ushallow the repository + await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false}); + + // Check if gitHead is defined and exists in release branch again + if (gitHead && (await isCommitInHistory(gitHead))) { + return 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'); + error.gitHead = gitHead || tagHead; + throw error; + } + + // 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'); }; diff --git a/test/get-commits.test.js b/test/get-commits.test.js index 15dda7f6..1d83dfae 100644 --- a/test/get-commits.test.js +++ b/test/get-commits.test.js @@ -1,5 +1,14 @@ 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 {stub} from 'sinon'; 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 await gitRepo(); // 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 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 const repo = await gitRepo(); // 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 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 await gitRepo(); // 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({ lastRelease: {gitHead: commits[commits.length - 1].hash}, 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); }); +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 => { // 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(['fix: First fix']); + 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(['feat: Second feature', 'feat: Third feature'])).concat(commits); + commits = (await gitCommits(['Second', 'Third'])).concat(commits); - // Retrieve the commits with the commits module - const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); + // 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'}}); // Verify the commits created and retrieved by the module are identical 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); }); +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) ', 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(['fix: First fix']); + 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(['feat: Second feature', 'feat: Third feature'])).concat(commits); + commits = (await gitCommits(['Second', 'Third'])).concat(commits); - // Retrieve the commits with the commits module - const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); + // 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'}}); + + // 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 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 const repo = await gitRepo(); // 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 await gitTagVersion('v1.0.0'); // 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 await gitShallowClone(repo); // Verify the shallow clone does not contains any tags t.is((await gitTags()).length, 0); - // Retrieve the commits with the commits module - const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}}); + // 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'}}); // Verify the commits created and retrieved by the module are identical 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); }); -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 await gitRepo(); // 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'}}); // Verify no commit is retrieved 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 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 await gitRepo(); // 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 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 await gitRepo(); // 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 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'); // Add commits to the 'other-branch' branch const commitsBranch = await gitCommits(['Third', 'Fourth']); - // Create the new branch 'another-branch' from 'other-branch' - await gitCheckout('another-branch'); + await gitCheckout('master', false); // Retrieve the commits with the commits module 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/); // 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}"`)); - // 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}"`)); }); diff --git a/test/helpers/git-utils.js b/test/helpers/git-utils.js index f2811e19..d01a0d38 100644 --- a/test/helpers/git-utils.js +++ b/test/helpers/git-utils.js @@ -35,7 +35,7 @@ export async function gitRepo() { * * @param {Array} messages commit messages. * - * @returns {Array} commits the created commits, in reverse order (to match `git log` order). + * @returns {Array} The created commits, in reverse order (to match `git log` order). */ export async function gitCommits(messages) { return (await pMapSeries(messages, async msg => { @@ -45,6 +45,19 @@ export async function gitCommits(messages) { })).reverse(); } +/** + * Amend a commit (rewriting the sha) on the current git repository. + * + * @param {string} messages commit message. + * + * @returns {Array} 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. * @@ -52,7 +65,7 @@ export async function gitCommits(messages) { * @param {boolean} create `true` to create the branche ans switch, `false` to only switch. */ 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. * * @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) { - await execa('git', ['tag', tagName]); +export async function gitTagVersion(tagName, sha) { + 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. * @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(); 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; } diff --git a/test/integration.test.js b/test/integration.test.js index 6d8b5e71..595a91e0 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -1,9 +1,18 @@ import test from 'ava'; import {writeJson, readJson} from 'fs-extra'; 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'; +// 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 => { // Start the local NPM registry await start(); @@ -25,14 +34,6 @@ test.after.always(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 t.log('Create git repository'); 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 => { - // 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 t.log('Create git repository'); 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})); 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}`); +});