Skip to content

Commit f62b778

Browse files
committed
feat: add optional support to respect the git ignorefile
Per maintainer desire and to ensure backwards compatibility, this is disabled by default. Closes semantic-release#345 Closes semantic-release#347
1 parent 04381ca commit f62b778

File tree

11 files changed

+154
-26
lines changed

11 files changed

+154
-26
lines changed

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[**semantic-release**](https://github.com/semantic-release/semantic-release) plugin to commit release assets to the project's [git](https://git-scm.com/) repository.
44

55
> [!WARNING]
6-
> You likely _do not_ need this plugin to accomplish your goals with semantic-release.
6+
> You likely _do not_ need this plugin to accomplish your goals with semantic-release.
77
> Please consider our [recommendation against making commits during your release](https://semantic-release.gitbook.io/semantic-release/support/faq#making-commits-during-the-release-process-adds-significant-complexity) to avoid unnecessary headaches.
88
99
[![Build Status](https://github.com/semantic-release/git/workflows/Test/badge.svg)](https://github.com/semantic-release/git/actions?query=workflow%3ATest+branch%3Amaster) [![npm latest version](https://img.shields.io/npm/v/@semantic-release/git/latest.svg)](https://www.npmjs.com/package/@semantic-release/git)
@@ -69,10 +69,11 @@ When configuring branches permission on a Git hosting service (e.g. [GitHub prot
6969

7070
### Options
7171

72-
| Options | Description | Default |
73-
|-----------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
74-
| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` |
75-
| `assets` | Files to include in the release commit. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` |
72+
| Options | Description | Default |
73+
|---------------------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
74+
| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` |
75+
| `assets` | Files to include in the release commit. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` |
76+
| `respectIgnoreFile` | Whether or not added files should be filtered by your project's [gitignore](https://git-scm.com/docs/gitignore). | `false`
7677

7778
#### `message`
7879

@@ -107,7 +108,7 @@ Each entry in the `assets` `Array` is globbed individually. A [glob](https://git
107108

108109
If a directory is configured, all the files under this directory and its children will be included.
109110

110-
**Note**: If a file has a match in `assets` it will be included even if it also has a match in `.gitignore`.
111+
**Note**: If a file has a match in `assets` it will be included even if it also has a match in `.gitignore`, unless `respectIgnoreFile` is set to `true`.
111112

112113
##### `assets` examples
113114

index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ let verified;
66

77
function verifyConditions(pluginConfig, context) {
88
const {options} = context;
9-
// If the Git prepare plugin is used and has `assets` or `message` configured, validate them now in order to prevent any release if the configuration is wrong
9+
// If the Git prepare plugin is used and has `assets`, `message`, or `respectIgnoreFile` configured, validate them now in order to prevent any release if the configuration is wrong
1010
if (options.prepare) {
1111
const preparePlugin =
1212
castArray(options.prepare).find((config) => config.path && config.path === '@semantic-release/git') || {};
1313

1414
pluginConfig.assets = defaultTo(pluginConfig.assets, preparePlugin.assets);
1515
pluginConfig.message = defaultTo(pluginConfig.message, preparePlugin.message);
16+
pluginConfig.respectIgnoreFile = defaultTo(pluginConfig.respectIgnoreFile, preparePlugin.respectIgnoreFile);
1617
}
1718

1819
verifyGit(pluginConfig);

lib/definitions/errors.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,10 @@ Your configuration for the \`assets\` option is \`${assets}\`.`,
1818
1919
Your configuration for the \`successComment\` option is \`${message}\`.`,
2020
}),
21+
EINVALIDRESPECTIGNOREFILE: ({respectIgnoreFile}) => ({
22+
message: 'Invalid `respectIgnoreFile` option.',
23+
details: `The [respectIgnoreFile option](${linkify('README.md#options')}) option must be a \`boolean\`.
24+
25+
Your configuration for the \`respectIgnoreFile\` option is \`${respectIgnoreFile}\`.`,
26+
}),
2127
};

lib/git.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ const debug = require('debug')('semantic-release:git');
44
/**
55
* Retrieve the list of files modified on the local repository.
66
*
7+
* @param {Boolean} respectIgnoreFile
78
* @param {Object} [execaOpts] Options to pass to `execa`.
89
*
910
* @return {Array<String>} Array of modified files path.
1011
*/
11-
async function getModifiedFiles(execaOptions) {
12-
return (await execa('git', ['ls-files', '-m', '-o'], execaOptions)).stdout
12+
async function getModifiedFiles(respectIgnoreFile, execaOptions) {
13+
const extraGitArgs = respectIgnoreFile ? ['--exclude-standard'] : [];
14+
15+
return (await execa('git', ['ls-files', '-m', '-o', ...extraGitArgs], execaOptions)).stdout
1316
.split('\n')
1417
.map((file) => file.trim())
1518
.filter((file) => Boolean(file));
@@ -19,10 +22,16 @@ async function getModifiedFiles(execaOptions) {
1922
* Add a list of file to the Git index. `.gitignore` will be ignored.
2023
*
2124
* @param {Array<String>} files Array of files path to add to the index.
25+
* @param {Boolean} respectIgnoreFile
2226
* @param {Object} [execaOpts] Options to pass to `execa`.
2327
*/
24-
async function add(files, execaOptions) {
25-
const shell = await execa('git', ['add', '--force', '--ignore-errors', ...files], {...execaOptions, reject: false});
28+
async function add(files, respectIgnoreFile, execaOptions) {
29+
const extraGitArgs = respectIgnoreFile ? [] : ['--force'];
30+
31+
const shell = await execa('git', ['add', ...extraGitArgs, '--ignore-errors', ...files], {
32+
...execaOptions,
33+
reject: false,
34+
});
2635
debug('add file to git index', shell);
2736
}
2837

lib/prepare.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {getModifiedFiles, add, commit, push} = require('./git.js');
1212
* @param {Object} pluginConfig The plugin configuration.
1313
* @param {String|Array<String>} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs.
1414
* @param {String} [pluginConfig.message] The message for the release commit.
15+
* @param {Boolean} [pluginConfig.respectIgnoreFile] Whether or not to ignore files in `.gitignore`.
1516
* @param {Object} context semantic-release context.
1617
* @param {Object} context.options `semantic-release` configuration.
1718
* @param {Object} context.lastRelease The last release.
@@ -28,9 +29,9 @@ module.exports = async (pluginConfig, context) => {
2829
nextRelease,
2930
logger,
3031
} = context;
31-
const {message, assets} = resolveConfig(pluginConfig, logger);
32+
const {message, assets, respectIgnoreFile} = resolveConfig(pluginConfig, logger);
3233

33-
const modifiedFiles = await getModifiedFiles({env, cwd});
34+
const modifiedFiles = await getModifiedFiles(respectIgnoreFile, {env, cwd});
3435

3536
const filesToCommit = uniq(
3637
await pReduce(
@@ -58,7 +59,7 @@ module.exports = async (pluginConfig, context) => {
5859

5960
if (filesToCommit.length > 0) {
6061
logger.log('Found %d file(s) to commit', filesToCommit.length);
61-
await add(filesToCommit, {env, cwd});
62+
await add(filesToCommit, respectIgnoreFile, {env, cwd});
6263
debug('commited files: %o', filesToCommit);
6364
await commit(
6465
message

lib/resolve-config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
const {isNil, castArray} = require('lodash');
22

3-
module.exports = ({assets, message}) => ({
3+
module.exports = ({assets, message, respectIgnoreFile}) => ({
44
assets: isNil(assets)
55
? ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']
66
: assets
77
? castArray(assets)
88
: assets,
99
message,
10+
respectIgnoreFile: respectIgnoreFile ?? false,
1011
});

lib/verify.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const {isString, isNil, isArray, isPlainObject} = require('lodash');
1+
const {isString, isNil, isArray, isPlainObject, isBoolean} = require('lodash');
22
const AggregateError = require('aggregate-error');
33
const getError = require('./get-error.js');
44
const resolveConfig = require('./resolve-config.js');
@@ -16,16 +16,19 @@ const VALIDATORS = {
1616
isArrayOf((asset) => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path)))
1717
),
1818
message: isNonEmptyString,
19+
respectIgnoreFile: isBoolean,
1920
};
2021

2122
/**
2223
* Verify the commit `message` format and the `assets` option configuration:
2324
* - The commit `message`, is defined, must a non empty `String`.
2425
* - The `assets` configuration must be an `Array` of `String` (file path) or `false` (to disable).
26+
* - The `respectIgnoreFile`, if defined, must be a `Boolean`.
2527
*
2628
* @param {Object} pluginConfig The plugin configuration.
2729
* @param {String|Array<String|Object>} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs.
2830
* @param {String} [pluginConfig.message] The commit message for the release.
31+
* @param {Boolean} [pluginConfig.respectIgnoreFile] Whether or not to ignore files in `.gitignore`.
2932
*/
3033
module.exports = (pluginConfig) => {
3134
const options = resolveConfig(pluginConfig);

test/git.test.js

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,33 @@ test('Add file to index', async (t) => {
1010
// Create files
1111
await outputFile(path.resolve(cwd, 'file1.js'), '');
1212
// Add files and commit
13-
await add(['.'], {cwd});
13+
await add(['.'], false, {cwd});
1414

1515
await t.deepEqual(await gitStaged({cwd}), ['file1.js']);
1616
});
1717

18+
test('Get the modified files, excluding files in .gitignore but including untracked ones', async (t) => {
19+
// Create a git repository, set the current working directory at the root of the repo
20+
const {cwd} = await gitRepo();
21+
// Create files
22+
await outputFile(path.resolve(cwd, 'file1.js'), '');
23+
await outputFile(path.resolve(cwd, 'dir/file2.js'), '');
24+
await outputFile(path.resolve(cwd, 'file3.js'), '');
25+
// Create .gitignore to ignore file3.js
26+
await outputFile(path.resolve(cwd, '.gitignore'), 'file3.js');
27+
// Add files and commit
28+
await add(['.'], true, {cwd});
29+
await commit('Test commit', {cwd});
30+
// Update file1.js, dir/file2.js and file3.js
31+
await appendFile(path.resolve(cwd, 'file1.js'), 'Test content');
32+
await appendFile(path.resolve(cwd, 'dir/file2.js'), 'Test content');
33+
await appendFile(path.resolve(cwd, 'file3.js'), 'Test content');
34+
// Add untracked file
35+
await outputFile(path.resolve(cwd, 'file4.js'), 'Test content');
36+
37+
await t.deepEqual((await getModifiedFiles(true, {cwd})).sort(), ['file1.js', 'dir/file2.js', 'file4.js'].sort());
38+
});
39+
1840
test('Get the modified files, including files in .gitignore but including untracked ones', async (t) => {
1941
// Create a git repository, set the current working directory at the root of the repo
2042
const {cwd} = await gitRepo();
@@ -25,7 +47,7 @@ test('Get the modified files, including files in .gitignore but including untrac
2547
// Create .gitignore to ignore file3.js
2648
await outputFile(path.resolve(cwd, '.gitignore'), 'file3.js');
2749
// Add files and commit
28-
await add(['.'], {cwd});
50+
await add(['.'], false, {cwd});
2951
await commit('Test commit', {cwd});
3052
// Update file1.js, dir/file2.js and file3.js
3153
await appendFile(path.resolve(cwd, 'file1.js'), 'Test content');
@@ -35,7 +57,7 @@ test('Get the modified files, including files in .gitignore but including untrac
3557
await outputFile(path.resolve(cwd, 'file4.js'), 'Test content');
3658

3759
await t.deepEqual(
38-
(await getModifiedFiles({cwd})).sort(),
60+
(await getModifiedFiles(false, {cwd})).sort(),
3961
['file1.js', 'dir/file2.js', 'file3.js', 'file4.js'].sort()
4062
);
4163
});
@@ -44,7 +66,7 @@ test('Returns [] if there is no modified files', async (t) => {
4466
// Create a git repository, set the current working directory at the root of the repo
4567
const {cwd} = await gitRepo();
4668

47-
await t.deepEqual(await getModifiedFiles({cwd}), []);
69+
await t.deepEqual(await getModifiedFiles(false, {cwd}), []);
4870
});
4971

5072
test('Commit added files', async (t) => {
@@ -53,7 +75,7 @@ test('Commit added files', async (t) => {
5375
// Create files
5476
await outputFile(path.resolve(cwd, 'file1.js'), '');
5577
// Add files and commit
56-
await add(['.'], {cwd});
78+
await add(['.'], false, {cwd});
5779
await commit('Test commit', {cwd});
5880

5981
await t.true((await gitGetCommits(undefined, {cwd})).length === 1);

test/integration.test.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ test('Prepare from a shallow clone', async (t) => {
2929
await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}");
3030
await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content');
3131
await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content');
32-
await add('.', {cwd});
32+
await add('.', false, {cwd});
3333
await gitCommits(['First'], {cwd});
3434
await gitTagVersion('v1.0.0', undefined, {cwd});
3535
await push(repositoryUrl, branch.name, {cwd});
@@ -64,7 +64,7 @@ test('Prepare from a detached head repository', async (t) => {
6464
await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}");
6565
await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content');
6666
await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content');
67-
await add('.', {cwd});
67+
await add('.', false, {cwd});
6868
const [{hash}] = await gitCommits(['First'], {cwd});
6969
await gitTagVersion('v1.0.0', undefined, {cwd});
7070
await push(repositoryUrl, branch.name, {cwd});
@@ -106,26 +106,36 @@ test('Verify authentication only on the fist call', async (t) => {
106106
test('Throw SemanticReleaseError if prepare config is invalid', (t) => {
107107
const message = 42;
108108
const assets = true;
109-
const options = {prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets}]};
109+
const respectIgnoreFile = 'foo';
110+
const options = {
111+
prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets, respectIgnoreFile}],
112+
};
110113

111114
const errors = [...t.throws(() => t.context.m.verifyConditions({}, {options, logger: t.context.logger}))];
112115

113116
t.is(errors[0].name, 'SemanticReleaseError');
114117
t.is(errors[0].code, 'EINVALIDASSETS');
115118
t.is(errors[1].name, 'SemanticReleaseError');
116119
t.is(errors[1].code, 'EINVALIDMESSAGE');
120+
t.is(errors[2].name, 'SemanticReleaseError');
121+
t.is(errors[2].code, 'EINVALIDRESPECTIGNOREFILE');
117122
});
118123

119124
test('Throw SemanticReleaseError if config is invalid', (t) => {
120125
const message = 42;
121126
const assets = true;
127+
const respectIgnoreFile = 'foo';
122128

123129
const errors = [
124-
...t.throws(() => t.context.m.verifyConditions({message, assets}, {options: {}, logger: t.context.logger})),
130+
...t.throws(() =>
131+
t.context.m.verifyConditions({message, assets, respectIgnoreFile}, {options: {}, logger: t.context.logger})
132+
),
125133
];
126134

127135
t.is(errors[0].name, 'SemanticReleaseError');
128136
t.is(errors[0].code, 'EINVALIDASSETS');
129137
t.is(errors[1].name, 'SemanticReleaseError');
130138
t.is(errors[1].code, 'EINVALIDMESSAGE');
139+
t.is(errors[2].name, 'SemanticReleaseError');
140+
t.is(errors[2].code, 'EINVALIDRESPECTIGNOREFILE');
131141
});

test/prepare.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,47 @@ test('Include deleted files in release commit', async (t) => {
214214
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]);
215215
});
216216

217+
test('Include ignored files in release commit by default', async (t) => {
218+
const {cwd, repositoryUrl} = await gitRepo(true);
219+
const pluginConfig = {
220+
assets: ['*'],
221+
};
222+
const branch = {name: 'master'};
223+
const options = {repositoryUrl};
224+
const env = {};
225+
const lastRelease = {};
226+
const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'};
227+
await outputFile(path.resolve(cwd, 'file1.js'), 'Test content');
228+
await outputFile(path.resolve(cwd, 'file2.js'), 'Test content');
229+
await outputFile(path.resolve(cwd, '.gitignore'), 'file2.js');
230+
231+
await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger});
232+
233+
t.deepEqual((await gitCommitedFiles('HEAD', {cwd, env})).sort(), ['file1.js', 'file2.js', '.gitignore'].sort());
234+
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 3]);
235+
});
236+
237+
test('Exclude ignored files in release commit with respectIgnoreFile', async (t) => {
238+
const {cwd, repositoryUrl} = await gitRepo(true);
239+
const pluginConfig = {
240+
assets: ['*'],
241+
respectIgnoreFile: true,
242+
};
243+
const branch = {name: 'master'};
244+
const options = {repositoryUrl};
245+
const env = {};
246+
const lastRelease = {};
247+
const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'};
248+
await outputFile(path.resolve(cwd, 'file1.js'), 'Test content');
249+
await outputFile(path.resolve(cwd, 'file2.js'), 'Test content');
250+
await outputFile(path.resolve(cwd, '.gitignore'), 'file2.js');
251+
252+
await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger});
253+
254+
t.deepEqual((await gitCommitedFiles('HEAD', {cwd, env})).sort(), ['file1.js', '.gitignore'].sort());
255+
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 2]);
256+
});
257+
217258
test('Set the commit author and committer name/email based on environment variables', async (t) => {
218259
const {cwd, repositoryUrl} = await gitRepo(true);
219260
const branch = {name: 'master'};

test/verify.test.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,39 @@ test('Throw SemanticReleaseError if "message" option is a whitespace String', (t
8383
t.is(error.code, 'EINVALIDMESSAGE');
8484
});
8585

86-
test('Verify undefined "message" and "assets"', (t) => {
86+
test('Verify "respectIgnoreFile" is a Boolean', (t) => {
87+
t.notThrows(() => verify({respectIgnoreFile: true}));
88+
t.notThrows(() => verify({respectIgnoreFile: false}));
89+
});
90+
91+
test('Throw SemanticReleaseError if "respectIgnoreFile" option is a string', (t) => {
92+
const [error] = t.throws(() => verify({respectIgnoreFile: 'foo'}));
93+
94+
t.is(error.name, 'SemanticReleaseError');
95+
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
96+
});
97+
98+
test('Throw SemanticReleaseError if "respectIgnoreFile" option is a number', (t) => {
99+
const [error] = t.throws(() => verify({respectIgnoreFile: 10}));
100+
101+
t.is(error.name, 'SemanticReleaseError');
102+
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
103+
});
104+
105+
test('Throw SemanticReleaseError if "respectIgnoreFile" option is an array', (t) => {
106+
const [error] = t.throws(() => verify({respectIgnoreFile: []}));
107+
108+
t.is(error.name, 'SemanticReleaseError');
109+
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
110+
});
111+
112+
test('Throw SemanticReleaseError if "respectIgnoreFile" option is an object', (t) => {
113+
const [error] = t.throws(() => verify({respectIgnoreFile: {}}));
114+
115+
t.is(error.name, 'SemanticReleaseError');
116+
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
117+
});
118+
119+
test('Verify undefined "message", "assets", and "respectIgnoreFile"', (t) => {
87120
t.notThrows(() => verify({}));
88121
});

0 commit comments

Comments
 (0)