├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── appveyor.yml ├── bin ├── install └── module-install ├── lib ├── hook.template.js ├── hook.template.raw ├── install.js └── runner.js ├── package-lock.json ├── package.json └── test ├── .eslintrc ├── hook.template.raw.test.js ├── hook.template.test.js ├── install.test.js ├── mocha.opts ├── runner.test.js └── setup.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = LF 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "kentcdodds", 3 | "rules": { 4 | "comma-dangle": 0, 5 | "complexity": [2, 2], 6 | "max-statements": [2, 5], 7 | "babel/no-invalid-this": 0, 8 | "global-require": 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: '🧩 Continuous Integration' 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | CI: 12 | runs-on: ${{ matrix.platform }} 13 | continue-on-error: true 14 | strategy: 15 | matrix: 16 | platform: 17 | - 'ubuntu-latest' 18 | - 'macos-latest' 19 | - 'windows-latest' 20 | node-version: 21 | - 'lts/*' 22 | - '18' 23 | - '17' 24 | - '16' 25 | - '15' 26 | - '14' 27 | - '13' 28 | - '12' 29 | - '11' 30 | - '10' 31 | - '9' 32 | - '8' 33 | - '7' 34 | - '6' 35 | - '5' 36 | - '4' 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - name: Set up Node ${{ matrix.node-version }} 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | cache: 'npm' 45 | 46 | - name: '🛠️ Install' 47 | run: npm install 48 | 49 | - name: '🧪 Test' 50 | run: npm run test 51 | 52 | - name: '☑️ Check Coverage' 53 | run: npm run check-coverage 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .nyc_output 5 | .idea 6 | .opt-in 7 | .opt-out 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .idea 5 | .opt-in 6 | .opt-out 7 | test 8 | .editorconfig 9 | .eslintrc 10 | .travis.yml 11 | appveyor.yml 12 | CONTRIBUTING.md 13 | LICENSE 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | branches: 7 | only: 8 | - master 9 | notifications: 10 | email: false 11 | node_js: 12 | - 'lts/*' 13 | - '12' 14 | - '11' 15 | - '10' 16 | - '9' 17 | - '8' 18 | - '7' 19 | - '6' 20 | - '5' 21 | - '4' 22 | before_install: 23 | # detect if the current build's node version is < 4 24 | # if yes, install latest npm version 2.x 25 | # if not, install latest npm version 3.x 26 | - if [[ `node -v | sed 's/[^0-9\.]//g'` < 4 ]]; then npm i -g npm@^2.0.0; else npm i -g npm@^3.0.0; fi 27 | before_script: 28 | - npm prune 29 | script: 30 | - npm run test 31 | - npm run check-coverage 32 | after_success: 33 | - 'curl -Lo travis_after_all.py https://git.io/travis_after_all' 34 | - python travis_after_all.py 35 | - 'export $(cat .to_export_back) &> /dev/null' 36 | - npm run report-coverage 37 | - npm run semantic-release 38 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing! Here's a few things to keep in mind when contributing to this project: 4 | 5 | This project uses `ghooks` to run `commit-msg` and `pre-commit` hooks. 6 | 7 | These hooks are `opt-in` only, so if you want to run them (recommended) then add an `.opt-in` file to the root of the project: 8 | 9 | ``` 10 | pre-commit 11 | commit-msg 12 | ``` 13 | 14 | We do this to make it easier for new comers to contribute and allow experienced contributors avoid pushing stuff that'll break the build. 15 | 16 | ## semantic-release 17 | 18 | We use [semantic-release](http://npm.im/semantic-release) to manage releases. This means we have a convention for our commit messages. 19 | **Please follow [our commit message convention](https://github.com/conventional-changelog/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md)** 20 | even if you're making a small change. This repository follows the 21 | [How to Write an Open Source JavaScript Library](https://egghead.io/series/how-to-write-an-open-source-javascript-library) 22 | series on egghead.io (by [@kentcdodds](https://github.com/kentcdodds)). See 23 | [this lesson](https://egghead.io/lessons/javascript-how-to-write-a-javascript-library-writing-conventional-commits-with-commitizen?series=how-to-write-an-open-source-javascript-library) 24 | and [this repository](https://github.com/ajoslin/conventional-changelog/blob/master/conventions/angular.md) 25 | to learn more about the commit message conventions. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghooks 2 | 3 | [![version](https://img.shields.io/npm/v/ghooks.svg?style=flat-square)](http://npm.im/ghooks) 4 | [![travis build](https://img.shields.io/travis/ghooks-org/ghooks.svg?style=flat-square)](https://travis-ci.org/ghooks-org/ghooks) 5 | [![AppVeyor](https://img.shields.io/appveyor/ci/gtramontina/ghooks.svg?style=flat-square)](https://ci.appveyor.com/project/gtramontina/ghooks) 6 | [![codecov coverage](https://img.shields.io/codecov/c/github/ghooks-org/ghooks.svg?style=flat-square)](https://codecov.io/github/ghooks-org/ghooks) 7 | [![Dependencies status](https://img.shields.io/david/ghooks-org/ghooks.svg?style=flat-square)](https://david-dm.org/ghooks-org/ghooks#info=dependencies) 8 | [![Dev Dependencies status](https://img.shields.io/david/dev/ghooks-org/ghooks.svg?style=flat-square)](https://david-dm.org/ghooks-org/ghooks#info=devDependencies) 9 | 10 | [![MIT License](https://img.shields.io/npm/l/ghooks.svg?style=flat-square)](http://opensource.org/licenses/MIT) 11 | [![downloads](https://img.shields.io/npm/dm/ghooks.svg?style=flat-square)](http://npm-stat.com/charts.html?package=ghooks&from=2014-04-01) 12 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](https://github.com/semantic-release/semantic-release) 13 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=flat-square)](http://commitizen.github.io/cz-cli/) 14 | 15 | Simple git hooks 16 | 17 | ## Installation 18 | 19 | ```shell 20 | npm install ghooks --save-dev 21 | ``` 22 | 23 | _It is not advised to install `ghooks` as a production dependency, as it will install git hooks in your production environment as well. Please install it under the `devDependencies` section of your `package.json`._ 24 | 25 | _Please also note, that it is absolutely **not advised** to install `ghooks` globally. To work as expected, make it a development dependency of your project(s)._ 26 | 27 | ## Setup 28 | 29 | Add a `config.ghooks` entry in your `package.json` and simply specify which git hooks you want and their corresponding commands, like the following: 30 | 31 | ```json 32 | { 33 | … 34 | "config": { 35 | "ghooks": { 36 | "pre-commit": "gulp lint", 37 | "commit-msg": "validate-commit-msg", 38 | "pre-push": "make test", 39 | "post-merge": "npm install", 40 | "post-rewrite": "npm install", 41 | … 42 | } 43 | } 44 | … 45 | } 46 | ``` 47 | 48 | **Note:** _The hooks' working directory is relative to the git root (where you have your `.git` directory). This means that if your package.json is in a subdirectory of your git repository, you'll need to cd into the directory before running any npm scripts. E.g.:_ 49 | 50 | ```json 51 | "pre-commit": "cd path/to/folder && npm run test" 52 | ``` 53 | 54 | ## opt-in/out 55 | 56 | One of the last things you want is to raise the barrier to contributing to your open source project. So [Andreas Windt](https://github.com/ta2edchimp) developed the [opt-cli](https://npmjs.com/package/opt-cli) package to allow you to turn your hooks into opt-in/out scripts. See this project's [`package.json`](package.json) for an example of how to do that. 57 | 58 | ## All [documented](http://git-scm.com/docs/githooks) hooks are available 59 | 60 | * applypatch-msg 61 | * pre-applypatch 62 | * post-applypatch 63 | * pre-commit 64 | * prepare-commit-msg 65 | * commit-msg 66 | * post-commit 67 | * pre-rebase 68 | * post-checkout 69 | * post-merge 70 | * pre-push 71 | * pre-receive 72 | * update 73 | * post-receive 74 | * post-update 75 | * pre-auto-gc 76 | * post-rewrite 77 | 78 | ## Common Issues 79 | 80 | * [Usage with git GUI clients](https://github.com/ghooks-org/ghooks/issues/18) – Thanks to [@JamieMason](https://github.com/JamieMason) 81 | 82 | ## Credits 83 | 84 | This module is heavily inspired by [__@nlf__](https://github.com/nlf)'s [precommit-hook](https://www.npmjs.org/package/precommit-hook) 85 | 86 | ## Contributors 87 | 88 | Huge thanks to everyone listed [here](https://github.com/ghooks-org/ghooks/graphs/contributors)! 89 | 90 | ## License 91 | 92 | This software is licensed under the MIT license 93 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: "lts" 4 | - nodejs_version: "12" 5 | - nodejs_version: "11" 6 | - nodejs_version: "10" 7 | - nodejs_version: "9" 8 | - nodejs_version: "8" 9 | - nodejs_version: "7" 10 | - nodejs_version: "6" 11 | - nodejs_version: "5" 12 | - nodejs_version: "4" 13 | 14 | cache: 15 | - node_modules 16 | 17 | branches: 18 | only: 19 | - master 20 | 21 | install: 22 | - ps: Install-Product node $env:nodejs_version 23 | - IF %nodejs_version% LEQ 5 npm -g install npm@3 24 | - npm install 25 | 26 | test_script: 27 | - node --version 28 | - npm --version 29 | - npm run test 30 | 31 | # Don't actually build. 32 | build: off 33 | -------------------------------------------------------------------------------- /bin/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../lib/install')() 3 | -------------------------------------------------------------------------------- /bin/module-install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable */ 3 | try { 4 | const opt = require('opt-cli') 5 | if (!opt.testOptOut('ghooks-install')) { 6 | require('./install') 7 | } 8 | } catch (e) { 9 | console.warn( 10 | '\nghooks postinstall could not be completed.\n' + 11 | '==========================================\n\n' + 12 | 'Usually this is due to using npm@2.15.x. Please consider updating npm\n' + 13 | 'to its latest version or run the following command manually afterwards:\n\n' + 14 | '\tnpm explore ghooks -- npm run install\n\n' + 15 | 'See https://github.com/gtramontina/ghooks/issues/71 for more information.\n' 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /lib/hook.template.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const resolve = require('path').resolve 3 | const join = require('path').join 4 | 5 | exports.generatedMessage = 'Generated by ghooks. Do not edit this file.' 6 | 7 | exports.content = fs 8 | .readFileSync(resolve(`${__dirname}/hook.template.raw`), 'UTF-8') 9 | .replace('{{generated_message}}', exports.generatedMessage) 10 | .replace('{{node_modules_path}}', join(process.cwd(), '..').replace(/\\/g, '\\\\')) 11 | -------------------------------------------------------------------------------- /lib/hook.template.raw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // {{generated_message}} 3 | /* eslint-disable import/no-dynamic-require,import/no-unassigned-import */ 4 | const fs = require('fs') 5 | const path = require('path') 6 | 7 | const nodeModulesPath = getNodeModulesAbsoluteEntryPoint() 8 | const ghooks = getGhooksEntryPoint() 9 | 10 | if (checkForGHooks(ghooks)) { 11 | require(ghooks)(nodeModulesPath, __filename) 12 | } 13 | 14 | function getGhooksEntryPoint() { 15 | try { 16 | require('ghooks') 17 | return 'ghooks' 18 | } catch (e) { 19 | return getGhooksAbsoluteEntryPoint() 20 | } 21 | } 22 | 23 | function getNodeModulesAbsoluteEntryPoint() { 24 | const _nodeModulesPath = path.resolve(__dirname, '../', '../', 'node_modules') 25 | 26 | try { 27 | fs.statSync(_nodeModulesPath) 28 | } catch (e) { 29 | return '{{node_modules_path}}' 30 | } 31 | 32 | return _nodeModulesPath 33 | } 34 | 35 | function getGhooksAbsoluteEntryPoint() { 36 | const worktree = getWorkTree() 37 | if (worktree) { 38 | return path.resolve(__dirname, '../', worktree, 'node_modules', 'ghooks') 39 | } 40 | return path.resolve(nodeModulesPath, 'ghooks') 41 | } 42 | 43 | function checkForGHooks(ghooksPath) { 44 | try { 45 | require(ghooksPath) 46 | } catch (e) { 47 | warnAboutGHooks() 48 | return false 49 | } 50 | return true 51 | } 52 | 53 | function getWorkTree() { 54 | try { 55 | return getWorkTreeFromConfig(getConfigFileContent()) 56 | } catch (e) { 57 | return null 58 | } 59 | } 60 | 61 | function getConfigFileContent() { 62 | const configFile = path.resolve(__dirname, '../config') 63 | const fileStat = fs.statSync(configFile) 64 | if (fileStat && fileStat.isFile()) { 65 | return fs.readFileSync(configFile, 'utf8') 66 | } 67 | return '' 68 | } 69 | 70 | function getWorkTreeFromConfig(configFileContent) { 71 | const worktreeRegEx = /\[core][^]{0,}worktree = ([^\n]{1,})[^]{0,}/ 72 | return worktreeRegEx.test(configFileContent) ? configFileContent.replace(worktreeRegEx, '$1') : '' 73 | } 74 | 75 | function warnAboutGHooks() { 76 | console.warn( // eslint-disable-line no-console 77 | 'ghooks not found!\n' + 78 | 'Make sure you have it installed on your "node_modules".\n' + 79 | 'Skipping git hooks.' 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /lib/install.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const resolve = require('path').resolve 3 | const findup = require('findup') 4 | const template = require('./hook.template') 5 | 6 | const hooks = [ 7 | 'applypatch-msg', 8 | 'pre-applypatch', 9 | 'post-applypatch', 10 | 'pre-commit', 11 | 'prepare-commit-msg', 12 | 'commit-msg', 13 | 'post-commit', 14 | 'pre-rebase', 15 | 'post-checkout', 16 | 'post-merge', 17 | 'pre-push', 18 | 'pre-receive', 19 | 'update', 20 | 'post-receive', 21 | 'post-update', 22 | 'pre-auto-gc', 23 | 'post-rewrite', 24 | ] 25 | 26 | function installHooks() { 27 | const gitRoot = findGitRoot() 28 | if (gitRoot) { 29 | const hooksDir = resolve(gitRoot, 'hooks') 30 | hooks.forEach(install.bind(null, hooksDir)) 31 | } else { 32 | warnAboutGit() 33 | } 34 | } 35 | 36 | function findGitRoot() { 37 | try { 38 | return getGitRoot() 39 | } catch (e) { 40 | return null 41 | } 42 | } 43 | 44 | function getGitRoot() { 45 | const gitRoot = findup.sync(process.cwd(), '.git') 46 | const gitPath = resolve(gitRoot, '.git') 47 | const fileStat = fs.statSync(gitPath) 48 | return gitPathDir(gitPath, fileStat) || gitPathFile(gitPath, fileStat, gitRoot) 49 | } 50 | 51 | function gitPathDir(gitPath, fileStat) { 52 | return fileStat.isDirectory() ? gitPath : null 53 | } 54 | 55 | function gitPathFile(gitPath, fileStat, gitRoot) { 56 | return fileStat.isFile() ? parseGitFile(fileStat, gitPath, gitRoot) : null 57 | } 58 | 59 | function parseGitFile(fileStat, gitPath, gitRoot) { 60 | const gitDirRegex = /[^]{0,}gitdir: ([^\n]{1,})[^]{0,}/ 61 | const gitFileContents = fs.readFileSync(gitPath, 'utf8') 62 | if (gitDirRegex.test(gitFileContents)) { 63 | return resolve(gitRoot, gitFileContents.replace(gitDirRegex, '$1')) 64 | } 65 | return null 66 | } 67 | 68 | function warnAboutGit() { 69 | console.warn( // eslint-disable-line no-console 70 | 'This does not seem to be a git project.\n' + 71 | 'Although ghooks was installed, the actual git hooks have not.\n' + 72 | 'Run "git init" and then "npm explore ghooks -- npm run install".\n\n' + 73 | 'Please ignore this message if you are not using ghooks directly.' 74 | ) 75 | } 76 | 77 | function install(dir, hook) { 78 | ensureDirExists(dir) 79 | const file = resolve(dir, hook) 80 | needsBackup(file) && backup(file) 81 | createExecutableHook(file) 82 | } 83 | 84 | function ensureDirExists(dir) { 85 | fs.existsSync(dir) || fs.mkdirSync(dir) 86 | } 87 | 88 | function needsBackup(file) { 89 | return fs.existsSync(file) && !generatedByGHooks(file) 90 | } 91 | 92 | function generatedByGHooks(file) { 93 | return !!fs.readFileSync(file, 'UTF-8').match(template.generatedMessage) 94 | } 95 | 96 | function backup(file) { 97 | fs.renameSync(file, `${file}.bkp`) 98 | } 99 | 100 | function createExecutableHook(file) { 101 | fs.writeFileSync(file, template.content) 102 | fs.chmodSync(file, '755') 103 | } 104 | 105 | module.exports = installHooks 106 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | const resolve = require('path').resolve 2 | const basename = require('path').basename 3 | const fs = require('fs') 4 | const clone = require('lodash.clone') 5 | const managePath = require('manage-path') 6 | const spawn = require('spawn-command') 7 | const findup = require('findup') 8 | 9 | module.exports = function run(nodeModulesPath, filename, env) { 10 | const command = commandFor(nodeModulesPath, hook(filename)) 11 | if (command) { 12 | runCommand(command, env) 13 | } 14 | } 15 | 16 | function hook(filename) { 17 | return basename(filename) 18 | } 19 | 20 | // replace any instance of $1 or $2 etc. to that item as an process.argv 21 | function replacePositionalVariables(command) { 22 | return command.replace(/\$(\d)/g, (match, number) => { 23 | return process.argv[number] 24 | }) 25 | } 26 | 27 | function commandFromPackage(packagePath, hookName) { 28 | const pkg = JSON.parse(fs.readFileSync(packagePath)) 29 | if (pkg.config && pkg.config.ghooks && pkg.config.ghooks[hookName]) { 30 | return replacePositionalVariables(pkg.config.ghooks[hookName]) 31 | } else { 32 | return null 33 | } 34 | } 35 | 36 | function commandFor(nodeModulesPath, hookName) { 37 | const pkgFile = findup.sync(nodeModulesPath, 'package.json') 38 | return commandFromPackage(resolve(pkgFile, 'package.json'), hookName) 39 | } 40 | 41 | function runCommand(command, env) { 42 | env = clone(env || process.env) 43 | const alterPath = managePath(env) 44 | alterPath.unshift(getNpmBin(process.cwd())) 45 | spawn(command, {stdio: 'inherit', env}).on('exit', process.exit) 46 | } 47 | 48 | function getNpmBin(dirname) { 49 | return resolve(dirname, 'node_modules', '.bin') 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghooks", 3 | "description": "Simple git hooks", 4 | "version": "0.0.0-semantically-released", 5 | "main": "./lib/runner.js", 6 | "keywords": [ 7 | "git", 8 | "hooks", 9 | "hook" 10 | ], 11 | "author": "Guilherme Tramontina ", 12 | "homepage": "https://github.com/gtramontina/ghooks", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/gtramontina/ghooks.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/gtramontina/ghooks/issues" 20 | }, 21 | "scripts": { 22 | "lint": "if-node-version \">=6\" eslint bin/* lib/* test/", 23 | "test:unit": "mocha", 24 | "test": "npm run lint && npm run coverage", 25 | "coverage": "nyc --reporter=lcov --reporter=text mocha", 26 | "check-coverage": "nyc check-coverage --statements 100 --branches 100 --functions 100 --lines 100", 27 | "report-coverage": "cat ./coverage/lcov.info | node_modules/.bin/codecov", 28 | "validate": "npm t && npm run check-coverage", 29 | "commit": "git-cz", 30 | "install": "node ./bin/module-install", 31 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 32 | }, 33 | "dependencies": { 34 | "findup": "0.1.5", 35 | "lodash.clone": "4.5.0", 36 | "manage-path": "2.0.0", 37 | "opt-cli": "1.6.0", 38 | "path-exists": "4.0.0", 39 | "spawn-command": "0.0.2" 40 | }, 41 | "devDependencies": { 42 | "babel-eslint": "9.0.0", 43 | "chai": "4.2.0", 44 | "chai-string": "1.5.0", 45 | "codecov": "3.5.0", 46 | "commitizen": "3.1.1", 47 | "cz-conventional-changelog": "2.1.0", 48 | "eslint": "3.19.0", 49 | "eslint-config-kentcdodds": "11.1.0", 50 | "eslint-plugin-babel": "4.1.2", 51 | "eslint-plugin-import": "2.18.0", 52 | "eslint-plugin-mocha": "4.12.1", 53 | "ghooks": "*", 54 | "if-node-version": "1.1.1", 55 | "mocha": "3.2.0", 56 | "mock-fs": "4.10.1", 57 | "nyc": "10.0.0", 58 | "proxyquire": "2.1.0", 59 | "semantic-release": "6.3.2", 60 | "sinon": "7.3.2", 61 | "sinon-chai": "3.3.0", 62 | "sinon-test": "2.4.0", 63 | "travis-after-all": "1.4.5", 64 | "validate-commit-msg": "2.14.0" 65 | }, 66 | "nyc": { 67 | "extension": [ 68 | ".raw" 69 | ], 70 | "include": [ 71 | "lib/**" 72 | ] 73 | }, 74 | "config": { 75 | "ghooks": { 76 | "pre-commit": "opt --in pre-commit --exec \"npm run validate\"", 77 | "commit-msg": "opt --in commit-msg --exec validate-commit-msg" 78 | }, 79 | "commitizen": { 80 | "path": "node_modules/cz-conventional-changelog" 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true 5 | }, 6 | "rules": { 7 | "no-invalid-this": 0, 8 | "max-statements": 0 9 | }, 10 | "globals": { 11 | "proxyquire": false, 12 | "fsStub": false, 13 | "path": false, 14 | "expect": false, 15 | "sinon": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/hook.template.raw.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | require('./setup')() 3 | 4 | describe('hook.template.raw', function describeHookTemplateRaw() { 5 | 6 | describe('when ghooks is installed', () => { 7 | 8 | beforeEach(() => { 9 | this.ghooks = sinon.stub() 10 | const stub = { 11 | ghooks: this.ghooks, 12 | fs: { 13 | statSync: () => { 14 | return {isFile: () => true} 15 | }, 16 | }, 17 | } 18 | proxyquire('../lib/hook.template.raw', stub) 19 | }) 20 | 21 | it('delegates the hook execution to ghooks', () => { 22 | const filename = path.resolve(process.cwd(), 'lib', 'hook.template.raw') 23 | const calledWith = path.resolve(__dirname, '../', '../', 'node_modules') 24 | expect(this.ghooks).to.have.been.calledWith(calledWith, filename) 25 | }) 26 | 27 | it('works when node modules cannot be found', () => { 28 | const ghooks = sinon.stub() 29 | this.throwException = true 30 | proxyquire('../lib/hook.template.raw', { 31 | ghooks, 32 | fs: { 33 | statSync: () => { 34 | if (this.throwException) { 35 | this.throwException = false 36 | throw new Error('path missing') 37 | } 38 | } 39 | }, 40 | path: { 41 | resolve: () => { 42 | if (!this.throwException) { 43 | return '{{node_modules_path}}' 44 | } 45 | 46 | return '' 47 | }, 48 | }, 49 | }) 50 | 51 | const filename = path.resolve(process.cwd(), 'lib', 'hook.template.raw') 52 | expect(ghooks).to.have.been.calledWith('{{node_modules_path}}', filename) 53 | }) 54 | 55 | }) 56 | 57 | describe('when ghooks is not found', () => { 58 | it('warns about ghooks not being present', sinon.test(function test() { 59 | const warn = this.stub(console, 'warn') 60 | proxyquire('../lib/hook.template.raw', {ghooks: null}) 61 | expect(warn).to.have.been.calledWithMatch(/ghooks not found!/i) 62 | })) 63 | 64 | }) 65 | 66 | describe('when ghooks is installed, but the node working dir is below the project dir', () => { 67 | 68 | beforeEach(() => { 69 | const ghooksEntryPoint = path.resolve(__dirname, '../', '../', 'node_modules', 'ghooks') 70 | 71 | this.ghooks = sinon.stub() 72 | const stub = { 73 | ghooks: null, 74 | [ghooksEntryPoint]: this.ghooks, 75 | fs: { 76 | statSync: () => { 77 | return {isFile: () => true} 78 | }, 79 | }, 80 | } 81 | proxyquire('../lib/hook.template.raw', stub) 82 | }) 83 | 84 | it('delegates the hook execution to ghooks', () => { 85 | const filename = path.resolve(process.cwd(), 'lib', 'hook.template.raw') 86 | const calledWith = path.resolve(__dirname, '../', '../', 'node_modules') 87 | expect(this.ghooks).to.have.been.calledWith(calledWith, filename) 88 | }) 89 | 90 | }) 91 | 92 | describe('when ghooks is installed, using worktree / in a submodule', () => { 93 | 94 | beforeEach(() => { 95 | const worktree = '../../a/path/somewhere/else' 96 | const ghooksResolved = path.resolve(process.cwd(), worktree, 'node_modules', 'ghooks') 97 | const stub = { 98 | ghooks: null, 99 | fs: { 100 | statSync: () => { 101 | return {isFile: () => true} 102 | }, 103 | readFileSync: () => `[core]\n\tworktree = ${worktree}`, 104 | }, 105 | } 106 | stub[ghooksResolved] = this.ghooks = sinon.stub() 107 | proxyquire('../lib/hook.template.raw', stub) 108 | }) 109 | 110 | it('delegates the hook execution to ghooks', () => { 111 | const filename = path.resolve(process.cwd(), 'lib', 'hook.template.raw') 112 | const calledWith = path.resolve(__dirname, '../', '../', 'node_modules') 113 | expect(this.ghooks).to.have.been.calledWith(calledWith, filename) 114 | }) 115 | 116 | }) 117 | 118 | describe('when ghooks is not found, using worktree / in a submodule', () => { 119 | 120 | it('warns about ghooks not being found in gitdir', sinon.test(function test() { 121 | const stub = { 122 | ghooks: null, 123 | fs: { 124 | statSync: () => { 125 | return {isFile: () => true} 126 | }, 127 | readFileSync: () => '[core]\n\tworktree = ../../a/path/somewhere/else', 128 | }, 129 | } 130 | const warn = this.stub(console, 'warn') 131 | proxyquire('../lib/hook.template.raw', stub) 132 | expect(warn).to.have.been.calledWithMatch(/ghooks not found!/i) 133 | })) 134 | 135 | it('warns about ghooks not being found due to no gitdir being present', sinon.test(function test() { 136 | const stub = { 137 | ghooks: null, 138 | fs: { 139 | statSync: () => { 140 | return {isFile: () => true} 141 | }, 142 | readFileSync: () => '[anything]\n\tsomething = else', 143 | }, 144 | } 145 | const warn = this.stub(console, 'warn') 146 | proxyquire('../lib/hook.template.raw', stub) 147 | expect(warn).to.have.been.calledWithMatch(/ghooks not found!/i) 148 | })) 149 | 150 | it('warns about ghooks not being found due to no valid git config being present', sinon.test(function test() { 151 | const stub = { 152 | ghooks: null, 153 | fs: { 154 | statSync: () => { 155 | return {isFile: () => false} 156 | }, 157 | }, 158 | } 159 | const warn = this.stub(console, 'warn') 160 | proxyquire('../lib/hook.template.raw', stub) 161 | expect(warn).to.have.been.calledWithMatch(/ghooks not found!/i) 162 | })) 163 | 164 | }) 165 | 166 | }) 167 | -------------------------------------------------------------------------------- /test/hook.template.test.js: -------------------------------------------------------------------------------- 1 | require('./setup')() 2 | 3 | describe('hook.template', () => { 4 | const template = require('../lib/hook.template') 5 | 6 | it('replaces the {{generated_message}} token', () => { 7 | expect(template.content).to.match(new RegExp(template.generatedMessage)) 8 | }) 9 | 10 | }) 11 | -------------------------------------------------------------------------------- /test/install.test.js: -------------------------------------------------------------------------------- 1 | require('./setup')() 2 | const fs = require('fs') 3 | 4 | describe('install', function describeInstall() { 5 | const install = require('../lib/install') 6 | 7 | it('warns when the target is not a git project', sinon.test(function test() { 8 | fsStub({}) 9 | const warn = this.stub(console, 'warn') 10 | install() 11 | expect(warn).to.have.been.calledWithMatch(/this does not seem to be a git project/i) 12 | })) 13 | 14 | it('creates hooks directory', () => { 15 | fsStub({'.git': {}}) 16 | install() 17 | expect(fs.existsSync('.git/hooks')).to.be.true 18 | }) 19 | 20 | it('creates hook files', () => { 21 | fsStub({'.git/hooks': {}}) 22 | install() 23 | 24 | const hooks = fs.readdirSync('.git/hooks') 25 | const hookContent = require('../lib/hook.template').content 26 | function expectHook(hook, filename, permission) { 27 | expect(hooks).to.include(hook) 28 | expect(fs.readFileSync(filename, 'UTF-8')).to.equal(hookContent) 29 | expect(fileMode(filename)).to.equal(permission) 30 | } 31 | 32 | // expectHook(, , ) 33 | expectHook('applypatch-msg', '.git/hooks/applypatch-msg', '755') 34 | expectHook('pre-applypatch', '.git/hooks/pre-applypatch', '755') 35 | expectHook('post-applypatch', '.git/hooks/post-applypatch', '755') 36 | expectHook('pre-commit', '.git/hooks/pre-commit', '755') 37 | expectHook('prepare-commit-msg', '.git/hooks/prepare-commit-msg', '755') 38 | expectHook('commit-msg', '.git/hooks/commit-msg', '755') 39 | expectHook('post-commit', '.git/hooks/post-commit', '755') 40 | expectHook('pre-rebase', '.git/hooks/pre-rebase', '755') 41 | expectHook('post-checkout', '.git/hooks/post-checkout', '755') 42 | expectHook('post-merge', '.git/hooks/post-merge', '755') 43 | expectHook('pre-push', '.git/hooks/pre-push', '755') 44 | expectHook('pre-receive', '.git/hooks/pre-receive', '755') 45 | expectHook('update', '.git/hooks/update', '755') 46 | expectHook('post-receive', '.git/hooks/post-receive', '755') 47 | expectHook('post-update', '.git/hooks/post-update', '755') 48 | expectHook('pre-auto-gc', '.git/hooks/pre-auto-gc', '755') 49 | expectHook('post-rewrite', '.git/hooks/post-rewrite', '755') 50 | }) 51 | 52 | describe('backing up existing hooks', () => { 53 | 54 | const existingGHook = '// Generated by ghooks. Do not edit this file.' 55 | const existingUserHook = '# existing content' 56 | 57 | beforeEach(() => { 58 | fsStub({'.git/hooks': { 59 | 'pre-commit': existingGHook, 60 | 'pre-push': existingUserHook, 61 | }}) 62 | 63 | install() 64 | this.files = fs.readdirSync('.git/hooks') 65 | }) 66 | 67 | it('does not keep a copy of an existing GHook', () => { 68 | expect(this.files).to.not.include('pre-commit.bkp') 69 | expect(this.files).to.include('pre-commit') 70 | }) 71 | 72 | it('backs up an existing user hook', () => { 73 | expect(this.files).to.include('pre-push') 74 | expect(this.files).to.include('pre-push.bkp') 75 | expect(fs.readFileSync('.git/hooks/pre-push.bkp', 'UTF-8')).to.equal(existingUserHook) 76 | }) 77 | 78 | }) 79 | 80 | }) 81 | 82 | describe('install using worktree / as a submodule', function describeInstall() { 83 | const install = require('../lib/install') 84 | 85 | it('warns when no gitdir specified for worktree / submodule', sinon.test(function test() { 86 | fsStub({'.git': ''}) 87 | const warn = this.stub(console, 'warn') 88 | install() 89 | expect(warn).to.have.been.calledWithMatch(/this does not seem to be a git project/i) 90 | })) 91 | 92 | it('creates hooks directory using gitdir', () => { 93 | fsStub({ 94 | '.git': 'gitdir: ../../a/path/somewhere/else', 95 | '../../a/path/somewhere/else': {}, 96 | }) 97 | install() 98 | expect(fs.existsSync('../../a/path/somewhere/else/hooks')).to.be.true 99 | }) 100 | 101 | }) 102 | 103 | describe('install (ensure 100% code coverage)', function describeInstall() { 104 | const install = require('proxyquire')('../lib/install', { 105 | fs: { 106 | statSync() { 107 | // to provoke the case where a '.git' entry on the filesystem 108 | // is neither a directory nor a file 109 | return {isDirectory: () => false, isFile: () => false} 110 | }, 111 | }, 112 | }) 113 | 114 | it('warns when no gitdir specified for worktree / submodule', sinon.test(function test() { 115 | const warn = this.stub(console, 'warn') 116 | fsStub({'.git': ''}) 117 | install() 118 | expect(warn).to.have.been.calledWithMatch(/this does not seem to be a git project/i) 119 | })) 120 | 121 | }) 122 | 123 | function fileMode(file) { 124 | const allOn = 4095 // == 07777 (octal) 125 | return (fs.statSync(file).mode & allOn) // eslint-disable-line no-bitwise 126 | .toString(8) 127 | } 128 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --reporter dot 3 | -------------------------------------------------------------------------------- /test/runner.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const getPathVar = require('manage-path/dist/get-path-var') 3 | require('./setup')() 4 | 5 | describe('runner', function describeRunner() { 6 | beforeEach(() => { 7 | const on = this.spawnOn = sinon.stub() 8 | this.spawn = sinon.spy(() => { 9 | return {on} 10 | }) 11 | this.run = proxyquire('../lib/runner', {'spawn-command': this.spawn}) 12 | }) 13 | 14 | beforeEach(setupPackageJsonWith({config: { 15 | ghooks: { 16 | 'pre-commit': 'make pre-commit', 17 | 'pre-push': 'make pre-push', 18 | 'commit-msg': 'make commit-msg $1', 19 | 'post-merge': 'echo $PATH', 20 | }, 21 | }})) 22 | 23 | it('executes the command specified on the ghooks config', () => { 24 | this.run(process.cwd(), '/pre-commit') 25 | expect(this.spawn).to 26 | .have.been.calledWithMatch('make pre-commit', {stdio: 'inherit'}) 27 | 28 | this.run(process.cwd(), '/pre-push') 29 | expect(this.spawn) 30 | .to.have.been.calledWithMatch('make pre-push', {stdio: 'inherit'}) 31 | }) 32 | 33 | it('exits as the hook commands exits', () => { 34 | this.run(process.cwd(), '/pre-commit') 35 | expect(this.spawnOn).to 36 | .have.been.calledWith('exit') 37 | }) 38 | 39 | it('does not execute anything if the hook is not configured', () => { 40 | this.run(process.cwd(), '/whatever-hook') 41 | expect(this.spawn).to.not.have.been.called 42 | }) 43 | 44 | it('converts argument indicators to arguments from the githook', () => { 45 | const oldProcessArgv = process.argv 46 | process.argv = [path.join(process.cwd(), '.git/hooks/commit-msg'), './.git/COMMIT_EDITMSG'] 47 | this.run(process.cwd(), '/commit-msg') 48 | expect(this.spawn) 49 | .to.have.been.calledWithMatch('make commit-msg ./.git/COMMIT_EDITMSG', {stdio: 'inherit'}) 50 | process.argv = oldProcessArgv 51 | }) 52 | 53 | it('should alter the path', () => { 54 | this.run(process.cwd(), '/pre-push') 55 | const prefixPath = path.resolve(process.cwd(), 'node_modules', '.bin') 56 | const calledOptions = this.spawn.firstCall.args[1] 57 | expect(calledOptions.env[getPathVar(process.env, process.platform)]) 58 | .to.startWith(prefixPath) 59 | }) 60 | }) 61 | 62 | function setupPackageJsonWith(content) { 63 | return () => { 64 | fsStub({'package.json': JSON.stringify(content)}) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai') 2 | chai.use(require('sinon-chai')) 3 | chai.use(require('chai-string')) 4 | 5 | global.expect = chai.expect 6 | global.sinon = require('sinon') 7 | global.sinon.test = require('sinon-test')(global.sinon) 8 | global.fsStub = require('mock-fs') 9 | global.proxyquire = require('proxyquire').noCallThru() 10 | global.path = require('path') 11 | 12 | module.exports = function restore() { 13 | afterEach(fsStub.restore) 14 | } 15 | --------------------------------------------------------------------------------