diff --git a/README.md b/README.md index d694397..8be4cfb 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,7 @@ You can look up about git hooks on the [Pro Git book](https://git-scm.com/book/e `simple-git-hooks` works well for small-sized projects when you need quickly set up hooks and forget about it. -However, this package requires you to manually apply the changes to git hooks. If you update them often, this is probably not the best choice. - -Also, this package allows you to set only one command per git hook. +This package allows you to set only one command per git hook. If you need multiple verbose commands per git hook, flexible configuration or automatic update of git hooks, please check out the other packages: @@ -93,13 +91,13 @@ If you need multiple verbose commands per git hook, flexible configuration or au > There are more ways to configure the package. Check out [Additional configuration options](#additional-configuration-options). -3. Run the CLI script to update the git hooks with the commands from the config: +3. Run the CLI script to initialize the git hooks with the commands from the config: ```sh # [Optional] These 2 steps can be skipped for non-husky users git config core.hooksPath .git/hooks/ rm -rf .git/hooks - + # Update ./git/hooks npx simple-git-hooks ``` @@ -114,7 +112,9 @@ Now all the git hooks are created. Note for **yarn2** users: Please run `yarn dlx simple-git-hooks` instead of the command above. More info on [dlx](https://yarnpkg.com/cli/dlx) -Note that you should manually run `npx simple-git-hooks` **every time you change a command**. +The package will automatically update this command for you every time you commit. This works by hijacking the pre-commit hook and running `npx simple-git-hooks` behind the scenes. In addition, to make sure you run the newest pre-commit command, it will use the command from `package.json` and only leave a fallback command in the `.git/hooks` folder for usage with `preserveUnused`. + +Note that you should manually run `npx simple-git-hooks` if you want to apply changes from non-commit related hooks. This command will also run initially when running `yarn` using a `postinstall` hook. So ideally you will only call the command once when you setup simple-git-hooks, and never having to worry about it again. ### Additional configuration options diff --git a/_tests/project_with_configuration_in_package_json_with_yarn/package.json b/_tests/project_with_configuration_in_package_json_with_yarn/package.json new file mode 100644 index 0000000..a90ce3d --- /dev/null +++ b/_tests/project_with_configuration_in_package_json_with_yarn/package.json @@ -0,0 +1,10 @@ +{ + "name": "simple-pre-commit-test-package", + "version": "1.0.0", + "simple-git-hooks": { + "pre-commit": "exit 1" + }, + "devDependencies": { + "simple-pre-commit": "1.0.0" + } +} diff --git a/_tests/project_with_configuration_in_package_json_with_yarn/yarn.lock b/_tests/project_with_configuration_in_package_json_with_yarn/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/cli.js b/cli.js index 78b9865..c385d27 100644 --- a/cli.js +++ b/cli.js @@ -7,8 +7,15 @@ const {setHooksFromConfig} = require('./simple-git-hooks') try { - setHooksFromConfig(process.cwd(), process.argv) - console.log('[INFO] Successfully set all git hooks') + const argv = process.argv.slice(2); + const args = { + silent: argv.includes('--silent'), + auto: argv.includes('--auto'), + } + setHooksFromConfig(process.cwd(), process.argv, args) + if (!args.silent) { + console.log('[INFO] Successfully set all git hooks') + } } catch (e) { console.log('[ERROR], Was not able to set git hooks. Error: ' + e) } diff --git a/simple-git-hooks.js b/simple-git-hooks.js index 03a5723..2540459 100644 --- a/simple-git-hooks.js +++ b/simple-git-hooks.js @@ -1,5 +1,6 @@ const fs = require('fs') const path = require('path') +const { spawn } = require('child_process') const VALID_GIT_HOOKS = [ 'applypatch-msg', @@ -84,7 +85,7 @@ function getProjectRootDirectoryFromNodeModules(projectPath) { const indexOfPnpmDir = projDir.indexOf('.pnpm') if (indexOfPnpmDir > -1) { - return projDir.slice(0, indexOfPnpmDir - 1).join('/'); + return projDir.slice(0, indexOfPnpmDir - 1).join('/') } // A yarn2 STAB @@ -108,7 +109,7 @@ function getProjectRootDirectoryFromNodeModules(projectPath) { * Checks the 'simple-git-hooks' in dependencies of the project * @param {string} projectRootPath * @throws TypeError if packageJsonData not an object - * @return {Boolean} + * @return {boolean} */ function checkSimpleGitHooksInDependencies(projectRootPath) { if (typeof projectRootPath !== 'string') { @@ -132,8 +133,9 @@ function checkSimpleGitHooksInDependencies(projectRootPath) { * Parses the config and sets git hooks * @param {string} projectRootPath * @param {string[]} [argv] + * @param {object} [args] */ -function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.argv) { +function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.argv, args={}) { const customConfigPath = _getCustomConfigPath(argv) const config = _getConfig(projectRootPath, customConfigPath) @@ -145,10 +147,14 @@ function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.argv) { for (let hook of VALID_GIT_HOOKS) { if (Object.prototype.hasOwnProperty.call(config, hook)) { - _setHook(hook, config[hook], projectRootPath) + _setHook(hook, config[hook], projectRootPath, args) } else if (!preserveUnused.includes(hook)) { _removeHook(hook, projectRootPath) } + + if (hook === 'pre-commit' && args.auto) { + _runPreCommitCommand(config[hook]) + } } } @@ -157,12 +163,14 @@ function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.argv) { * @param {string} hook * @param {string} command * @param {string} projectRoot + * @param {object} args * @private */ -function _setHook(hook, command, projectRoot=process.cwd()) { +function _setHook(hook, command, projectRoot=process.cwd(), args) { const gitRoot = getGitProjectRoot(projectRoot) - const hookCommand = "#!/bin/sh\n" + command + const updateHooksComamnd = `${_getPackageManagerRunCommand(projectRoot)} simple-git-hooks --auto --silent` + const hookCommand = "#!/bin/sh\n" + (hook === 'pre-commit' ? updateHooksComamnd : command) const hookDirectory = gitRoot + '/hooks/' const hookPath = path.normalize(hookDirectory + hook) @@ -174,7 +182,9 @@ function _setHook(hook, command, projectRoot=process.cwd()) { fs.writeFileSync(hookPath, hookCommand) fs.chmodSync(hookPath, 0o0755) - console.log(`[INFO] Successfully set the ${hook} with command: ${command}`) + if (!args.silent) { + console.log(`[INFO] Successfully set the ${hook} with command: ${command}`) + } } /** @@ -202,6 +212,20 @@ function _removeHook(hook, projectRoot=process.cwd()) { } } +/** + * Runs the configured pre-commit if it exists, or exit with an error to propagate to hook command + * @param {string} command + * @private + */ +function _runPreCommitCommand(command) { + if (command) { + spawn(command, { + shell: true, + stdio: [process.stdin, process.stdout, process.stderr, 'pipe'] + }).on('exit', code => process.exit(code)) + } +} + /** Reads package.json file, returns package.json content and path * @param {string} projectPath - a path to the project, defaults to process.cwd * @return {{packageJsonContent: any, packageJsonPath: string}} @@ -224,11 +248,25 @@ function _getPackageJson(projectPath = process.cwd()) { return { packageJsonContent: JSON.parse(packageJsonDataRaw), packageJsonPath: targetPackageJson } } +/** + * Gets a run command based on the current package manager + * @param {string} projectRoot + * @return {string} + * @private + */ +function _getPackageManagerRunCommand(projectRoot=process.cwd()) { + const usesYarn = fs.existsSync(path.resolve(projectRoot, 'yarn.lock')) + const usesYarn2 = fs.existsSync(path.resolve(projectRoot, '.yarn')) + + return usesYarn ? 'yarn run --silent' : usesYarn2 ? 'yarn dxl' : 'npx' +} + /** * Takes the first argument from current process argv and returns it * Returns empty string when argument wasn't passed * @param {string[]} [argv] * @returns {string} + * @private */ function _getCustomConfigPath(argv=[]) { // We'll run as one of the following: @@ -287,6 +325,7 @@ function _getConfig(projectRootPath, configFileName='') { * @throws TypeError if packageJsonPath is not a string * @throws Error if package.json couldn't be read or was not validated * @return {{string: string} | undefined} + * @private */ function _getConfigFromPackageJson(projectRootPath = process.cwd()) { const {packageJsonContent} = _getPackageJson(projectRootPath) @@ -299,6 +338,7 @@ function _getConfigFromPackageJson(projectRootPath = process.cwd()) { * @param {string} projectRootPath * @param {string} fileName * @return {{string: string} | undefined} + * @private */ function _getConfigFromFile(projectRootPath, fileName) { if (typeof projectRootPath !== "string") { @@ -326,6 +366,7 @@ function _getConfigFromFile(projectRootPath, fileName) { * Validates the config, checks that every git hook or option is named correctly * @return {boolean} * @param {{string: string}} config + * @private */ function _validateHooks(config) { diff --git a/simple-git-hooks.test.js b/simple-git-hooks.test.js index 137891b..09d700c 100644 --- a/simple-git-hooks.test.js +++ b/simple-git-hooks.test.js @@ -66,10 +66,12 @@ test('returns false if simple pre commit isn`t in deps', () => { // Set and remove git hooks const testsFolder = path.normalize(path.join(process.cwd(), '_tests')) +const updateHookCommand = `npx simple-git-hooks --auto --silent`; // Correct configurations const projectWithConfigurationInPackageJsonPath = path.normalize(path.join(testsFolder, 'project_with_configuration_in_package_json')) +const projectWithConfigurationInPackageJsonPathUsingYarn = path.normalize(path.join(testsFolder, 'project_with_configuration_in_package_json_with_yarn')) const projectWithConfigurationInSeparateCjsPath = path.normalize(path.join(testsFolder, 'project_with_configuration_in_separate_cjs')) const projectWithConfigurationInSeparateJsPath = path.normalize(path.join(testsFolder, 'project_with_configuration_in_separate_js')) const projectWithConfigurationInAlternativeSeparateCjsPath = path.normalize(path.join(testsFolder, 'project_with_configuration_in_alternative_separate_cjs')) @@ -127,7 +129,7 @@ test('creates git hooks if configuration is correct from .simple-git-hooks.js', spc.setHooksFromConfig(projectWithConfigurationInAlternativeSeparateJsPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInAlternativeSeparateJsPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInAlternativeSeparateJsPath) }) @@ -137,7 +139,7 @@ test('creates git hooks if configuration is correct from .simple-git-hooks.cjs', spc.setHooksFromConfig(projectWithConfigurationInAlternativeSeparateCjsPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInAlternativeSeparateCjsPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInAlternativeSeparateCjsPath) }) @@ -147,7 +149,7 @@ test('creates git hooks if configuration is correct from simple-git-hooks.cjs', spc.setHooksFromConfig(projectWithConfigurationInSeparateCjsPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInSeparateCjsPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInSeparateCjsPath) }) @@ -157,7 +159,7 @@ test('creates git hooks if configuration is correct from simple-git-hooks.js', ( spc.setHooksFromConfig(projectWithConfigurationInSeparateJsPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInSeparateJsPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInSeparateJsPath) }) @@ -167,7 +169,7 @@ test('creates git hooks if configuration is correct from .simple-git-hooks.json' spc.setHooksFromConfig(projectWithConfigurationInAlternativeSeparateJsonPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInAlternativeSeparateJsonPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInAlternativeSeparateJsonPath) }) @@ -177,7 +179,7 @@ test('creates git hooks if configuration is correct from simple-git-hooks.json', spc.setHooksFromConfig(projectWithConfigurationInSeparateJsonPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInSeparateJsonPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithConfigurationInSeparateJsonPath) }) @@ -187,11 +189,21 @@ test('creates git hooks if configuration is correct from package.json', () => { spc.setHooksFromConfig(projectWithConfigurationInPackageJsonPath) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInPackageJsonPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`})) removeGitHooksFolder(projectWithConfigurationInPackageJsonPath) }) +test('updates hooks using Yarn when a yarn.lock is present', () => { + createGitHooksFolder(projectWithConfigurationInPackageJsonPathUsingYarn) + + spc.setHooksFromConfig(projectWithConfigurationInPackageJsonPathUsingYarn) + const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInPackageJsonPathUsingYarn, '.git', 'hooks'))) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand.replace('npx', 'yarn run --silent')} || exit 1`})) + + removeGitHooksFolder(projectWithConfigurationInPackageJsonPathUsingYarn) +}) + test('fails to create git hooks if configuration contains bad git hooks', () => { createGitHooksFolder(projectWithIncorrectConfigurationInPackageJson) @@ -214,7 +226,7 @@ test('removes git hooks', () => { spc.setHooksFromConfig(projectWithConfigurationInPackageJsonPath) let installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithConfigurationInPackageJsonPath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`})) spc.removeHooks(projectWithConfigurationInPackageJsonPath) @@ -237,7 +249,7 @@ test('creates git hooks and removes unused git hooks', () => { spc.setHooksFromConfig(projectWithConfigurationInPackageJsonPath) installedHooks = getInstalledGitHooks(installedHooksDir); - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`})) removeGitHooksFolder(projectWithConfigurationInPackageJsonPath) }) @@ -257,7 +269,7 @@ test('creates git hooks and removes unused but preserves specific git hooks', () spc.setHooksFromConfig(projectWithUnusedConfigurationInPackageJsonPath) installedHooks = getInstalledGitHooks(installedHooksDir); - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'commit-msg': '# do nothing', 'pre-commit':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'commit-msg': '# do nothing', 'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`})) removeGitHooksFolder(projectWithUnusedConfigurationInPackageJsonPath) }) @@ -271,7 +283,7 @@ test.each([ spc.setHooksFromConfig(projectWithCustomConfigurationFilePath, args) const installedHooks = getInstalledGitHooks(path.normalize(path.join(projectWithCustomConfigurationFilePath, '.git', 'hooks'))) - expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\nexit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) + expect(JSON.stringify(installedHooks)).toBe(JSON.stringify({'pre-commit':`#!/bin/sh\n${updateHookCommand} || exit 1`, 'pre-push':`#!/bin/sh\nexit 1`})) removeGitHooksFolder(projectWithCustomConfigurationFilePath) })