Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add optional support to respect the git ignorefile #527

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[**semantic-release**](https://github.com/semantic-release/semantic-release) plugin to commit release assets to the project's [git](https://git-scm.com/) repository.

> [!WARNING]
> You likely _do not_ need this plugin to accomplish your goals with semantic-release.
> You likely _do not_ need this plugin to accomplish your goals with semantic-release.
> 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.

[![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)
Expand Down Expand Up @@ -69,10 +69,11 @@ When configuring branches permission on a Git hosting service (e.g. [GitHub prot

### Options

| Options | Description | Default |
|-----------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` |
| `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']` |
| Options | Description | Default |
|---------------------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` |
| `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']` |
| `respectIgnoreFile` | Whether or not added files should be filtered by your project's [gitignore](https://git-scm.com/docs/gitignore). | `false`

#### `message`

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

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

**Note**: If a file has a match in `assets` it will be included even if it also has a match in `.gitignore`.
**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`.

##### `assets` examples

Expand Down
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ let verified;

function verifyConditions(pluginConfig, context) {
const {options} = context;
// 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
// 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
if (options.prepare) {
const preparePlugin =
castArray(options.prepare).find((config) => config.path && config.path === '@semantic-release/git') || {};

pluginConfig.assets = defaultTo(pluginConfig.assets, preparePlugin.assets);
pluginConfig.message = defaultTo(pluginConfig.message, preparePlugin.message);
pluginConfig.respectIgnoreFile = defaultTo(pluginConfig.respectIgnoreFile, preparePlugin.respectIgnoreFile);
}

verifyGit(pluginConfig);
Expand Down
6 changes: 6 additions & 0 deletions lib/definitions/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ Your configuration for the \`assets\` option is \`${assets}\`.`,

Your configuration for the \`successComment\` option is \`${message}\`.`,
}),
EINVALIDRESPECTIGNOREFILE: ({respectIgnoreFile}) => ({
message: 'Invalid `respectIgnoreFile` option.',
details: `The [respectIgnoreFile option](${linkify('README.md#options')}) option must be a \`boolean\`.

Your configuration for the \`respectIgnoreFile\` option is \`${respectIgnoreFile}\`.`,
}),
};
17 changes: 13 additions & 4 deletions lib/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ const debug = require('debug')('semantic-release:git');
/**
* Retrieve the list of files modified on the local repository.
*
* @param {Boolean} respectIgnoreFile
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Array<String>} Array of modified files path.
*/
async function getModifiedFiles(execaOptions) {
return (await execa('git', ['ls-files', '-m', '-o'], execaOptions)).stdout
async function getModifiedFiles(respectIgnoreFile, execaOptions) {
const extraGitArgs = respectIgnoreFile ? ['--exclude-standard'] : [];

return (await execa('git', ['ls-files', '-m', '-o', ...extraGitArgs], execaOptions)).stdout
.split('\n')
.map((file) => file.trim())
.filter((file) => Boolean(file));
Expand All @@ -19,10 +22,16 @@ async function getModifiedFiles(execaOptions) {
* Add a list of file to the Git index. `.gitignore` will be ignored.
*
* @param {Array<String>} files Array of files path to add to the index.
* @param {Boolean} respectIgnoreFile
* @param {Object} [execaOpts] Options to pass to `execa`.
*/
async function add(files, execaOptions) {
const shell = await execa('git', ['add', '--force', '--ignore-errors', ...files], {...execaOptions, reject: false});
async function add(files, respectIgnoreFile, execaOptions) {
const extraGitArgs = respectIgnoreFile ? [] : ['--force'];

const shell = await execa('git', ['add', ...extraGitArgs, '--ignore-errors', ...files], {
...execaOptions,
reject: false,
});
debug('add file to git index', shell);
}

Expand Down
7 changes: 4 additions & 3 deletions lib/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {getModifiedFiles, add, commit, push} = require('./git.js');
* @param {Object} pluginConfig The plugin configuration.
* @param {String|Array<String>} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs.
* @param {String} [pluginConfig.message] The message for the release commit.
* @param {Boolean} [pluginConfig.respectIgnoreFile] Whether or not to ignore files in `.gitignore`.
* @param {Object} context semantic-release context.
* @param {Object} context.options `semantic-release` configuration.
* @param {Object} context.lastRelease The last release.
Expand All @@ -28,9 +29,9 @@ module.exports = async (pluginConfig, context) => {
nextRelease,
logger,
} = context;
const {message, assets} = resolveConfig(pluginConfig, logger);
const {message, assets, respectIgnoreFile} = resolveConfig(pluginConfig, logger);

const modifiedFiles = await getModifiedFiles({env, cwd});
const modifiedFiles = await getModifiedFiles(respectIgnoreFile, {env, cwd});

const filesToCommit = uniq(
await pReduce(
Expand Down Expand Up @@ -58,7 +59,7 @@ module.exports = async (pluginConfig, context) => {

if (filesToCommit.length > 0) {
logger.log('Found %d file(s) to commit', filesToCommit.length);
await add(filesToCommit, {env, cwd});
await add(filesToCommit, respectIgnoreFile, {env, cwd});
debug('commited files: %o', filesToCommit);
await commit(
message
Expand Down
3 changes: 2 additions & 1 deletion lib/resolve-config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const {isNil, castArray} = require('lodash');

module.exports = ({assets, message}) => ({
module.exports = ({assets, message, respectIgnoreFile}) => ({
assets: isNil(assets)
? ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']
: assets
? castArray(assets)
: assets,
message,
respectIgnoreFile: respectIgnoreFile ?? false,
});
5 changes: 4 additions & 1 deletion lib/verify.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const {isString, isNil, isArray, isPlainObject} = require('lodash');
const {isString, isNil, isArray, isPlainObject, isBoolean} = require('lodash');
const AggregateError = require('aggregate-error');
const getError = require('./get-error.js');
const resolveConfig = require('./resolve-config.js');
Expand All @@ -16,16 +16,19 @@ const VALIDATORS = {
isArrayOf((asset) => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path)))
),
message: isNonEmptyString,
respectIgnoreFile: isBoolean,
};

/**
* Verify the commit `message` format and the `assets` option configuration:
* - The commit `message`, is defined, must a non empty `String`.
* - The `assets` configuration must be an `Array` of `String` (file path) or `false` (to disable).
* - The `respectIgnoreFile`, if defined, must be a `Boolean`.
*
* @param {Object} pluginConfig The plugin configuration.
* @param {String|Array<String|Object>} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs.
* @param {String} [pluginConfig.message] The commit message for the release.
* @param {Boolean} [pluginConfig.respectIgnoreFile] Whether or not to ignore files in `.gitignore`.
*/
module.exports = (pluginConfig) => {
const options = resolveConfig(pluginConfig);
Expand Down
34 changes: 28 additions & 6 deletions test/git.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,33 @@ test('Add file to index', async (t) => {
// Create files
await outputFile(path.resolve(cwd, 'file1.js'), '');
// Add files and commit
await add(['.'], {cwd});
await add(['.'], false, {cwd});

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

test('Get the modified files, excluding files in .gitignore but including untracked ones', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const {cwd} = await gitRepo();
// Create files
await outputFile(path.resolve(cwd, 'file1.js'), '');
await outputFile(path.resolve(cwd, 'dir/file2.js'), '');
await outputFile(path.resolve(cwd, 'file3.js'), '');
// Create .gitignore to ignore file3.js
await outputFile(path.resolve(cwd, '.gitignore'), 'file3.js');
// Add files and commit
await add(['.'], true, {cwd});
await commit('Test commit', {cwd});
// Update file1.js, dir/file2.js and file3.js
await appendFile(path.resolve(cwd, 'file1.js'), 'Test content');
await appendFile(path.resolve(cwd, 'dir/file2.js'), 'Test content');
await appendFile(path.resolve(cwd, 'file3.js'), 'Test content');
// Add untracked file
await outputFile(path.resolve(cwd, 'file4.js'), 'Test content');

await t.deepEqual((await getModifiedFiles(true, {cwd})).sort(), ['file1.js', 'dir/file2.js', 'file4.js'].sort());
});

test('Get the modified files, including files in .gitignore but including untracked ones', async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const {cwd} = await gitRepo();
Expand All @@ -23,9 +45,9 @@ test('Get the modified files, including files in .gitignore but including untrac
await outputFile(path.resolve(cwd, 'dir/file2.js'), '');
await outputFile(path.resolve(cwd, 'file3.js'), '');
// Create .gitignore to ignore file3.js
await outputFile(path.resolve(cwd, '.gitignore'), 'file.3.js');
await outputFile(path.resolve(cwd, '.gitignore'), 'file3.js');
// Add files and commit
await add(['.'], {cwd});
await add(['.'], false, {cwd});
await commit('Test commit', {cwd});
// Update file1.js, dir/file2.js and file3.js
await appendFile(path.resolve(cwd, 'file1.js'), 'Test content');
Expand All @@ -35,7 +57,7 @@ test('Get the modified files, including files in .gitignore but including untrac
await outputFile(path.resolve(cwd, 'file4.js'), 'Test content');

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

await t.deepEqual(await getModifiedFiles({cwd}), []);
await t.deepEqual(await getModifiedFiles(false, {cwd}), []);
});

test('Commit added files', async (t) => {
Expand All @@ -53,7 +75,7 @@ test('Commit added files', async (t) => {
// Create files
await outputFile(path.resolve(cwd, 'file1.js'), '');
// Add files and commit
await add(['.'], {cwd});
await add(['.'], false, {cwd});
await commit('Test commit', {cwd});

await t.true((await gitGetCommits(undefined, {cwd})).length === 1);
Expand Down
18 changes: 14 additions & 4 deletions test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test('Prepare from a shallow clone', async (t) => {
await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}");
await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content');
await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content');
await add('.', {cwd});
await add('.', false, {cwd});
await gitCommits(['First'], {cwd});
await gitTagVersion('v1.0.0', undefined, {cwd});
await push(repositoryUrl, branch.name, {cwd});
Expand Down Expand Up @@ -64,7 +64,7 @@ test('Prepare from a detached head repository', async (t) => {
await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}");
await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content');
await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content');
await add('.', {cwd});
await add('.', false, {cwd});
const [{hash}] = await gitCommits(['First'], {cwd});
await gitTagVersion('v1.0.0', undefined, {cwd});
await push(repositoryUrl, branch.name, {cwd});
Expand Down Expand Up @@ -106,26 +106,36 @@ test('Verify authentication only on the fist call', async (t) => {
test('Throw SemanticReleaseError if prepare config is invalid', (t) => {
const message = 42;
const assets = true;
const options = {prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets}]};
const respectIgnoreFile = 'foo';
const options = {
prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets, respectIgnoreFile}],
};

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

t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EINVALIDASSETS');
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].code, 'EINVALIDMESSAGE');
t.is(errors[2].name, 'SemanticReleaseError');
t.is(errors[2].code, 'EINVALIDRESPECTIGNOREFILE');
});

test('Throw SemanticReleaseError if config is invalid', (t) => {
const message = 42;
const assets = true;
const respectIgnoreFile = 'foo';

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

t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EINVALIDASSETS');
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].code, 'EINVALIDMESSAGE');
t.is(errors[2].name, 'SemanticReleaseError');
t.is(errors[2].code, 'EINVALIDRESPECTIGNOREFILE');
});
41 changes: 41 additions & 0 deletions test/prepare.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,47 @@ test('Include deleted files in release commit', async (t) => {
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]);
});

test('Include ignored files in release commit by default', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
const pluginConfig = {
assets: ['*'],
};
const branch = {name: 'master'};
const options = {repositoryUrl};
const env = {};
const lastRelease = {};
const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'};
await outputFile(path.resolve(cwd, 'file1.js'), 'Test content');
await outputFile(path.resolve(cwd, 'file2.js'), 'Test content');
await outputFile(path.resolve(cwd, '.gitignore'), 'file2.js');

await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger});

t.deepEqual((await gitCommitedFiles('HEAD', {cwd, env})).sort(), ['file1.js', 'file2.js', '.gitignore'].sort());
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 3]);
});

test('Exclude ignored files in release commit with respectIgnoreFile', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
const pluginConfig = {
assets: ['*'],
respectIgnoreFile: true,
};
const branch = {name: 'master'};
const options = {repositoryUrl};
const env = {};
const lastRelease = {};
const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'};
await outputFile(path.resolve(cwd, 'file1.js'), 'Test content');
await outputFile(path.resolve(cwd, 'file2.js'), 'Test content');
await outputFile(path.resolve(cwd, '.gitignore'), 'file2.js');

await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger});

t.deepEqual((await gitCommitedFiles('HEAD', {cwd, env})).sort(), ['file1.js', '.gitignore'].sort());
t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 2]);
});

test('Set the commit author and committer name/email based on environment variables', async (t) => {
const {cwd, repositoryUrl} = await gitRepo(true);
const branch = {name: 'master'};
Expand Down
35 changes: 34 additions & 1 deletion test/verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ test('Throw SemanticReleaseError if "message" option is a whitespace String', (t
t.is(error.code, 'EINVALIDMESSAGE');
});

test('Verify undefined "message" and "assets"', (t) => {
test('Verify "respectIgnoreFile" is a Boolean', (t) => {
t.notThrows(() => verify({respectIgnoreFile: true}));
t.notThrows(() => verify({respectIgnoreFile: false}));
});

test('Throw SemanticReleaseError if "respectIgnoreFile" option is a string', (t) => {
const [error] = t.throws(() => verify({respectIgnoreFile: 'foo'}));

t.is(error.name, 'SemanticReleaseError');
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
});

test('Throw SemanticReleaseError if "respectIgnoreFile" option is a number', (t) => {
const [error] = t.throws(() => verify({respectIgnoreFile: 10}));

t.is(error.name, 'SemanticReleaseError');
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
});

test('Throw SemanticReleaseError if "respectIgnoreFile" option is an array', (t) => {
const [error] = t.throws(() => verify({respectIgnoreFile: []}));

t.is(error.name, 'SemanticReleaseError');
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
});

test('Throw SemanticReleaseError if "respectIgnoreFile" option is an object', (t) => {
const [error] = t.throws(() => verify({respectIgnoreFile: {}}));

t.is(error.name, 'SemanticReleaseError');
t.is(error.code, 'EINVALIDRESPECTIGNOREFILE');
});

test('Verify undefined "message", "assets", and "respectIgnoreFile"', (t) => {
t.notThrows(() => verify({}));
});