diff --git a/README.md b/README.md index e7294bdf..3da468b5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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` @@ -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 diff --git a/index.js b/index.js index a088dcff..7285ae6a 100644 --- a/index.js +++ b/index.js @@ -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); diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 14489a59..df3c5574 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -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}\`.`, + }), }; diff --git a/lib/git.js b/lib/git.js index 218e30b5..e90c4287 100644 --- a/lib/git.js +++ b/lib/git.js @@ -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} 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)); @@ -19,10 +22,16 @@ async function getModifiedFiles(execaOptions) { * Add a list of file to the Git index. `.gitignore` will be ignored. * * @param {Array} 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); } diff --git a/lib/prepare.js b/lib/prepare.js index cbac855e..0d42b552 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -12,6 +12,7 @@ const {getModifiedFiles, add, commit, push} = require('./git.js'); * @param {Object} pluginConfig The plugin configuration. * @param {String|Array} [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. @@ -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( @@ -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 diff --git a/lib/resolve-config.js b/lib/resolve-config.js index dd62abfe..e9318fe9 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -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, }); diff --git a/lib/verify.js b/lib/verify.js index fb1ae95a..187bc554 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -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'); @@ -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} [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); diff --git a/test/git.test.js b/test/git.test.js index 1d307390..b0cf36c7 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -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(); @@ -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'); @@ -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() ); }); @@ -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) => { @@ -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); diff --git a/test/integration.test.js b/test/integration.test.js index bc68a267..e3982ebb 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -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}); @@ -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}); @@ -106,7 +106,10 @@ 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}))]; @@ -114,18 +117,25 @@ test('Throw SemanticReleaseError if prepare config is invalid', (t) => { 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'); }); diff --git a/test/prepare.test.js b/test/prepare.test.js index 8e7d97a1..41d77b66 100644 --- a/test/prepare.test.js +++ b/test/prepare.test.js @@ -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'}; diff --git a/test/verify.test.js b/test/verify.test.js index 9dda9ac8..97c26f1f 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -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({})); });