Skip to content

Commit 85dd69b

Browse files
committed
feat: Retrieve version gitHead from git tags and unshallow the repo if necessary
Add several fixes and improvements in the identification of the last release gitHead: - If there is no last release, unshallow the repo in order to retrieve all existing commits - If git head is not present in last release, try to retrieve it from git tag with format ‘v\<version\>’ or ‘\<version\>’ - If the last release git head cannot be determined and found in commit history, unshallow the repo and try again - Throw a ENOGITHEAD error if the gitHead for the last release cannot be found in the npm metadata nor in the git tags, preventing to make release based on the all the commits in the repo as before - Add integration test for the scenario with a packed repo from which `npm republish` fails to read the git head Fix semantic-release#447, Fix semantic-release#393, Fix semantic-release#280, Fix semantic-release#276
1 parent cbb51a4 commit 85dd69b

File tree

6 files changed

+390
-66
lines changed

6 files changed

+390
-66
lines changed

.travis.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ branches:
1313
- caribou
1414
- /^greenkeeper.*$/
1515

16-
# Retrieve 999 commits (default is 50) so semantic-release can analyze all commits when there is more than 50 on a PR
17-
git:
18-
depth: 999
19-
2016
# Retry install on fail to avoid failing a build on network/disk/external errors
2117
install:
2218
- travis_retry npm install

src/lib/get-commits.js

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,92 @@
11
const execa = require('execa');
22
const log = require('npmlog');
3-
const SemanticReleaseError = require('@semantic-release/error');
3+
const getVersionHead = require('./get-version-head');
44

5-
module.exports = async ({lastRelease, options}) => {
6-
let stdout;
7-
if (lastRelease.gitHead) {
5+
/**
6+
* Commit message.
7+
*
8+
* @typedef {Object} Commit
9+
* @property {string} hash The commit hash.
10+
* @property {string} message The commit message.
11+
*/
12+
13+
/**
14+
* 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.
15+
*
16+
* The commit correspoding to the last released version is determined as follow:
17+
* - Use `lastRelease.gitHead` is defined and present in `config.options.branch` history.
18+
* - Search for a tag named `v<version>` or `<version>` and it's associated commit sha if present in `config.options.branch` history.
19+
*
20+
* 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.
21+
*
22+
* @param {Object} config
23+
* @param {Object} config.lastRelease The lastRelease object obtained from the getLastRelease plugin.
24+
* @param {string} [config.lastRelease.version] The version number of the last release.
25+
* @param {string} [config.lastRelease.gitHead] The commit sha used to make the last release.
26+
* @param {Object} config.options The semantic-relese options.
27+
* @param {string} config.options.branch The branch to release from.
28+
*
29+
* @return {Promise<Array<Commit>>} The list of commits on the branch `config.options.branch` since the last release.
30+
*
31+
* @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `config.lastRelease.gitHead` or the commit sha derived from `config.lastRelease.version` is not in the direct history of `config.options.branch`.
32+
* @throws {SemanticReleaseError} with code `ENOGITHEAD` if `config.lastRelease.gitHead` is undefined and no commit sha can be found for the `config.lastRelease.version`.
33+
*/
34+
module.exports = async ({lastRelease: {version, gitHead}, options: {branch}}) => {
35+
if (gitHead || version) {
836
try {
9-
({stdout} = await execa('git', ['branch', '--no-color', '--contains', lastRelease.gitHead]));
37+
gitHead = await getVersionHead(version, branch, gitHead);
1038
} catch (err) {
11-
throw notInHistoryError(lastRelease.gitHead, options.branch);
39+
// Unshallow the repository if the gitHead cannot be found and the branch for the last release version
40+
await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false});
1241
}
13-
const branches = stdout
14-
.split('\n')
15-
.map(branch => branch.replace('*', '').trim())
16-
.filter(branch => !!branch);
1742

18-
if (!branches.includes(options.branch)) {
19-
throw notInHistoryError(lastRelease.gitHead, options.branch, branches);
43+
// Try to find the gitHead on the branch again with an unshallowed repository
44+
try {
45+
gitHead = await getVersionHead(version, branch, gitHead);
46+
} catch (err) {
47+
if (err.code === 'ENOTINHISTORY') {
48+
log.error('commits', notInHistoryMessage(gitHead, branch, version, err.branches));
49+
} else if (err.code === 'ENOGITHEAD') {
50+
log.error('commits', noGitHeadMessage());
51+
}
52+
throw err;
2053
}
54+
} else {
55+
// If there is no gitHead nor a version, there is no previous release. Unshallow the repo in order to retrieve all commits
56+
await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false});
2157
}
2258

2359
try {
24-
({stdout} = await execa('git', [
60+
return (await execa('git', [
2561
'log',
26-
'--format=%H==SPLIT==%B==END==',
27-
`${lastRelease.gitHead ? lastRelease.gitHead + '..' : ''}HEAD`,
28-
]));
62+
'--format=format:%H==SPLIT==%B==END==',
63+
`${gitHead ? gitHead + '..' : ''}HEAD`,
64+
])).stdout
65+
.split('==END==')
66+
.filter(raw => !!raw.trim())
67+
.map(raw => {
68+
const [hash, message] = raw.trim().split('==SPLIT==');
69+
return {hash, message};
70+
});
2971
} catch (err) {
3072
return [];
3173
}
32-
33-
return String(stdout)
34-
.split('==END==')
35-
.filter(raw => !!raw.trim())
36-
.map(raw => {
37-
const [hash, message] = raw.trim().split('==SPLIT==');
38-
return {hash, message};
39-
});
4074
};
4175

42-
function notInHistoryError(gitHead, branch, branches) {
43-
log.error(
44-
'commits',
45-
`
46-
The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
47-
This means semantic-release can not extract the commits between now and then.
48-
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
49-
You can recover from this error by publishing manually or restoring the commit "${gitHead}".
50-
${branches && branches.length
51-
? `\nHere is a list of branches that still contain the commit in question: \n * ${branches.join('\n * ')}`
52-
: ''}
53-
`
54-
);
55-
return new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
76+
function noGitHeadMessage(version) {
77+
return `The commit the last release of this package was derived from cannot be determined from the release metadata not from the repository tags.
78+
This means semantic-release can not extract the commits between now and then.
79+
This is usually caused by releasing from outside the repository directory or with innaccessible git metadata.
80+
You can recover from this error by publishing manually.`;
81+
}
82+
83+
function notInHistoryMessage(gitHead, branch, version, branches) {
84+
return `The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
85+
This means semantic-release can not extract the commits between now and then.
86+
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
87+
You can recover from this error by publishing manually or restoring the commit "${gitHead}".
88+
89+
${branches && branches.length
90+
? `Here is a list of branches that still contain the commit in question: \n * ${branches.join('\n * ')}`
91+
: ''}`;
5692
}

src/lib/get-version-head.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const SemanticReleaseError = require('@semantic-release/error');
2+
const execa = require('execa');
3+
4+
/**
5+
* Get the commit sha for a given tag.
6+
*
7+
* @param {string} tagName Tag name for which to retrieve the commit sha.
8+
*
9+
* @return {string} The commit sha of the tag in parameter or `null`.
10+
*/
11+
async function gitTagHead(tagName) {
12+
try {
13+
return (await execa('git', ['rev-list', '-1', '--tags', tagName])).stdout;
14+
} catch (err) {
15+
return null;
16+
}
17+
}
18+
19+
/**
20+
* Get the list of branches that contains the given commit.
21+
*
22+
* @param {string} sha The sha of the commit to look for.
23+
*
24+
* @return {Array<string>} The list of branches that contains the commit sha in parameter.
25+
*/
26+
async function getCommitBranches(sha) {
27+
try {
28+
return (await execa('git', ['branch', '--no-color', '--contains', sha])).stdout
29+
.split('\n')
30+
.map(branch => branch.replace('*', '').trim())
31+
.filter(branch => !!branch);
32+
} catch (err) {
33+
return [];
34+
}
35+
}
36+
37+
/**
38+
* Get the commit sha for a given version, if it is contained in the given branch.
39+
*
40+
* @param {string} version The version corresponding to the commit sha to look for. Used to search in git tags.
41+
* @param {string} branch The branch that must have the commit in its direct history.
42+
* @param {string} gitHead The commit sha to verify.
43+
*
44+
* @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`.
45+
*
46+
* @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.
47+
* @throws {SemanticReleaseError} with code `ENOGITHEAD` if `gitHead` is undefined and no commit sha can be found for the `version`.
48+
*/
49+
module.exports = async (version, branch, gitHead) => {
50+
if (!gitHead && version) {
51+
// Look for the version tag only if no gitHead exists
52+
gitHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version));
53+
}
54+
55+
if (gitHead) {
56+
// Retrieve the branches containing the gitHead and verify one of them is the branch in param
57+
const branches = await getCommitBranches(gitHead);
58+
if (!branches.includes(branch)) {
59+
const error = new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
60+
error.branches = branches;
61+
throw error;
62+
}
63+
} else {
64+
throw new SemanticReleaseError('There is no commit associated with last release', 'ENOGITHEAD');
65+
}
66+
return gitHead;
67+
};

test/get-commits.test.js

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import test from 'ava';
2-
import {gitRepo, gitCommits, gitCheckout} from './helpers/git-utils';
2+
import {gitRepo, gitCommits, gitCheckout, gitTagVersion, gitShallowClone, gitTags, gitLog} from './helpers/git-utils';
33
import proxyquire from 'proxyquire';
44
import {stub} from 'sinon';
55
import SemanticReleaseError from '@semantic-release/error';
@@ -30,15 +30,37 @@ test.serial('Get all commits when there is no last release', async t => {
3030
// Retrieve the commits with the commits module
3131
const result = await getCommits({lastRelease: {}, options: {branch: 'master'}});
3232

33-
// The commits created and and retrieved by the module are identical
33+
// Verify the commits created and retrieved by the module are identical
3434
t.is(result.length, 2);
3535
t.is(result[0].hash.substring(0, 7), commits[0].hash);
3636
t.is(result[0].message, commits[0].message);
3737
t.is(result[1].hash.substring(0, 7), commits[1].hash);
3838
t.is(result[1].message, commits[1].message);
3939
});
4040

41-
test.serial('Get all commits since lastRelease gitHead', async t => {
41+
test.serial('Get all commits when there is no last release, including the ones not in the shallow clone', async t => {
42+
// Create a git repository, set the current working directory at the root of the repo
43+
const repo = await gitRepo();
44+
// Add commits to the master branch
45+
const commits = await gitCommits(['fix: First fix', 'feat: Second feature']);
46+
// Create a shallow clone with only 1 commit
47+
await gitShallowClone(repo);
48+
49+
// Verify the shallow clone contains only one commit
50+
t.is((await gitLog()).length, 1);
51+
52+
// Retrieve the commits with the commits module
53+
const result = await getCommits({lastRelease: {}, options: {branch: 'master'}});
54+
55+
// Verify the commits created and retrieved by the module are identical
56+
t.is(result.length, 2);
57+
t.is(result[0].hash.substring(0, 7), commits[0].hash);
58+
t.is(result[0].message, commits[0].message);
59+
t.is(result[1].hash.substring(0, 7), commits[1].hash);
60+
t.is(result[1].message, commits[1].message);
61+
});
62+
63+
test.serial('Get all commits since gitHead (from lastRelease)', async t => {
4264
// Create a git repository, set the current working directory at the root of the repo
4365
await gitRepo();
4466
// Add commits to the master branch
@@ -49,7 +71,76 @@ test.serial('Get all commits since lastRelease gitHead', async t => {
4971
lastRelease: {gitHead: commits[commits.length - 1].hash},
5072
options: {branch: 'master'},
5173
});
52-
// The commits created and retrieved by the module are identical
74+
75+
// Verify the commits created and retrieved by the module are identical
76+
t.is(result.length, 2);
77+
t.is(result[0].hash.substring(0, 7), commits[0].hash);
78+
t.is(result[0].message, commits[0].message);
79+
t.is(result[1].hash.substring(0, 7), commits[1].hash);
80+
t.is(result[1].message, commits[1].message);
81+
});
82+
83+
test.serial('Get all commits since gitHead (from tag) ', async t => {
84+
// Create a git repository, set the current working directory at the root of the repo
85+
await gitRepo();
86+
// Add commits to the master branch
87+
let commits = await gitCommits(['fix: First fix']);
88+
// Create the tag corresponding to version 1.0.0
89+
await gitTagVersion('1.0.0');
90+
// Add new commits to the master branch
91+
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits);
92+
93+
// Retrieve the commits with the commits module
94+
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}});
95+
96+
// Verify the commits created and retrieved by the module are identical
97+
t.is(result.length, 2);
98+
t.is(result[0].hash.substring(0, 7), commits[0].hash);
99+
t.is(result[0].message, commits[0].message);
100+
t.is(result[1].hash.substring(0, 7), commits[1].hash);
101+
t.is(result[1].message, commits[1].message);
102+
});
103+
104+
test.serial('Get all commits since gitHead (from tag formatted like v<version>) ', async t => {
105+
// Create a git repository, set the current working directory at the root of the repo
106+
await gitRepo();
107+
// Add commits to the master branch
108+
let commits = await gitCommits(['fix: First fix']);
109+
// Create the tag corresponding to version 1.0.0
110+
await gitTagVersion('v1.0.0');
111+
// Add new commits to the master branch
112+
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits);
113+
114+
// Retrieve the commits with the commits module
115+
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}});
116+
117+
// Verify the commits created and retrieved by the module are identical
118+
t.is(result.length, 2);
119+
t.is(result[0].hash.substring(0, 7), commits[0].hash);
120+
t.is(result[0].message, commits[0].message);
121+
t.is(result[1].hash.substring(0, 7), commits[1].hash);
122+
t.is(result[1].message, commits[1].message);
123+
});
124+
125+
test.serial('Get all commits since gitHead from tag, when tags are mising from the shallow clone', async t => {
126+
// Create a git repository, set the current working directory at the root of the repo
127+
const repo = await gitRepo();
128+
// Add commits to the master branch
129+
let commits = await gitCommits(['fix: First fix']);
130+
// Create the tag corresponding to version 1.0.0
131+
await gitTagVersion('v1.0.0');
132+
// Add new commits to the master branch
133+
commits = (await gitCommits(['feat: Second feature', 'feat: Third feature'])).concat(commits);
134+
// Create a shallow clone with only 1 commit and no tags
135+
await gitShallowClone(repo);
136+
137+
// Verify the shallow clone does not contains any tags
138+
t.is((await gitTags()).length, 0);
139+
140+
// Retrieve the commits with the commits module
141+
const result = await getCommits({lastRelease: {version: `1.0.0`}, options: {branch: 'master'}});
142+
143+
// Verify the commits created and retrieved by the module are identical
53144
t.is(result.length, 2);
54145
t.is(result[0].hash.substring(0, 7), commits[0].hash);
55146
t.is(result[0].message, commits[0].message);
@@ -81,6 +172,25 @@ test.serial('Return empty array if lastRelease.gitHead is the last commit', asyn
81172
t.deepEqual(result, []);
82173
});
83174

175+
test.serial('Throws ENOGITHEAD error if the gitHead of the last release cannot be found', async t => {
176+
// Create a git repository, set the current working directory at the root of the repo
177+
await gitRepo();
178+
// Add commits to the master branch
179+
await gitCommits(['fix: First fix', 'feat: Second feature']);
180+
181+
// Retrieve the commits with the commits module
182+
const error = await t.throws(getCommits({lastRelease: {version: '1.0.0'}, options: {branch: 'master'}}));
183+
184+
// Verify error code and message
185+
t.is(error.code, 'ENOGITHEAD');
186+
t.true(error instanceof SemanticReleaseError);
187+
// Verify the log function has been called with a message explaining the error
188+
t.regex(
189+
errorLog.firstCall.args[1],
190+
/The commit the last release of this package was derived from cannot be determined from the release metadata not from the repository tags/
191+
);
192+
});
193+
84194
test.serial('Throws ENOTINHISTORY error if gitHead is not in history', async t => {
85195
// Create a git repository, set the current working directory at the root of the repo
86196
await gitRepo();
@@ -93,7 +203,6 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in history', async t =
93203
// Verify error code and message
94204
t.is(error.code, 'ENOTINHISTORY');
95205
t.true(error instanceof SemanticReleaseError);
96-
97206
// Verify the log function has been called with a message mentionning the branch
98207
t.regex(errorLog.firstCall.args[1], /history of the "master" branch/);
99208
// Verify the log function has been called with a message mentionning the missing gitHead
@@ -106,11 +215,11 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but
106215
// Add commits to the master branch
107216
await gitCommits(['First', 'Second']);
108217
// Create the new branch 'other-branch' from master
109-
await gitCheckout('other-branch', true);
218+
await gitCheckout('other-branch');
110219
// Add commits to the 'other-branch' branch
111220
const commitsBranch = await gitCommits(['Third', 'Fourth']);
112221
// Create the new branch 'another-branch' from 'other-branch'
113-
await gitCheckout('another-branch', true);
222+
await gitCheckout('another-branch');
114223

115224
// Retrieve the commits with the commits module
116225
const error = await t.throws(
@@ -120,7 +229,6 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but
120229
// Verify error code and message
121230
t.is(error.code, 'ENOTINHISTORY');
122231
t.true(error instanceof SemanticReleaseError);
123-
124232
// Verify the log function has been called with a message mentionning the branch
125233
t.regex(errorLog.firstCall.args[1], /history of the "master" branch/);
126234
// Verify the log function has been called with a message mentionning the missing gitHead

0 commit comments

Comments
 (0)