├── .editorconfig ├── .gitattributes ├── .gitignore ├── .travis.yml ├── README.md ├── commit-msg-hook.js ├── commitplease.js ├── index.js ├── install.js ├── lib ├── defaults.js ├── sanitize.js ├── styles │ ├── angular.js │ └── jquery.js ├── utils │ ├── limits.js │ └── tickets.js └── validate.js ├── package-lock.json ├── package.json ├── prepare-commit-msg-hook.js ├── test.js └── uninstall.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '4' # LTS Argon 5 | - '6' # LTS Boron 6 | - 'stable' 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commitplease 2 | 3 | [![Travis](https://img.shields.io/travis/jzaefferer/commitplease.svg?maxAge=2592000)](http://travis-ci.org/jzaefferer/commitplease) 4 | [![npm](https://img.shields.io/npm/dm/commitplease.svg?maxAge=2592000)](https://www.npmjs.com/package/commitplease) 5 | [![npm](https://img.shields.io/npm/v/commitplease.svg?maxAge=2592000)](https://www.npmjs.com/package/commitplease) 6 | [![npm](https://img.shields.io/npm/l/commitplease.svg?maxAge=2592000)](https://www.npmjs.com/package/commitplease) 7 | 8 | This [node.js](http://nodejs.org/) module makes sure your git commit messages consistently follow one of these style guides: 9 | 1. [jQuery Commit Guidelines][1] 10 | 2. [AngularJS Commit Guidelines][2] 11 | 12 | You can also make customized validation rules based on those styles. 13 | 14 | ## Installation 15 | 16 | Commitplease can be installed locally or globally (or both): 17 | 18 | Repo-local install (adds a git hook that runs automatically upon `git commit`): 19 | ```js 20 | cd path/to/your/repo 21 | npm install commitplease --save-dev 22 | ``` 23 | 24 | Global install (adds a system-wide executable to be run manually): 25 | ```js 26 | npm install -g commitplease 27 | ``` 28 | 29 | A git version of 1.8.5 or newer is recommended. If you use `git commit --verbose`, it is required. Also, currently we do not support custom `core.commentchar`, so let us know if you set one. 30 | 31 | You could also install a global commitplease executable and put it into a `package.json` script or as a git hook of your choice. Here is an example with a `pre-push` hook: 32 | 33 | ``` 34 | #!/bin/sh 35 | 36 | npm run commitplease --silent 37 | ``` 38 | 39 | And `chmod +x .git/hooks/pre-push`. Now each time you do a `git push`, the hook will be checking **all** commits on current branch. 40 | 41 | ## Usage 42 | 43 | The following ways to begin a commit message are special and always valid: 44 | 45 | 1. `0.0.1` or any other semantic version 46 | 1. `WIP`, `Wip` or `wip` which means "work in progress" 47 | 1. `Merge branch [...]` or `Merge into ` 48 | 1. `fixup!` or `squash!`, as generated by `git commit --fixup` and `--squash`, but any content afterwards is accepted 49 | 50 | Other ways to make your commit messages special and bypass style checks are [described below](#skip-style-check). 51 | 52 | Non-special commit messages must follow one of the style guides ([jQuery Commit Guidelines][1] by default) 53 | 54 | ### Repo-local install 55 | 56 | Commit as usual. Git will trigger commitplease to check your commit message for errors. Invalid messages will prevent the commit, with details about what went wrong and a copy of the input. 57 | 58 | ### Global install 59 | 60 | Navigate to your repository and run the global commitplease executable. By default, it will check all the commit messages. Other examples include (just anything you can pass to `git log` really): 61 | 62 | | Use case | command | 63 | |----------|---------| 64 | | Check all commits on branch `master` | `commitplease master` | 65 | | Check all commits on branch `feature` that are not on `master` | `commitplease master..feature` | 66 | | Check all commits on current branch that are not on `master` | `commitplease master..HEAD` | 67 | | Check the latest 1 commit | `commitplease -1` | 68 | | Check all commits between `84991d` and `2021ce` | `commitplease 84991d..2021ce` | 69 | | Check all commits starting with `84991d` | `commitplease 84991d..` | 70 | 71 | [Here you can read][4] more about git commit ranges 72 | 73 | ## Setup 74 | 75 | You can configure commitplease from `package.json` of your project. Here are the options common for all style guidelines: 76 | 77 | ```json 78 | { 79 | "commitplease": { 80 | "limits": { 81 | "firstLine": "72", 82 | "otherLine": "80" 83 | }, 84 | "nohook": false, 85 | "markerPattern": "^(clos|fix|resolv)(e[sd]|ing)", 86 | "actionPattern": "^([Cc]los|[Ff]ix|[Rr]esolv)(e[sd]|ing)\\s+[^\\s\\d]+(\\s|$)", 87 | "ticketPattern": "^(Closes|Fixes) (.*#|gh-|[A-Z]{2,}-)[0-9]+", 88 | } 89 | } 90 | ``` 91 | 92 | * `limits.firstLine` and `limits.otherLine` are the hard limits for the number of symbols on the first line and on other lines of the commit message, respectively. 93 | * `"nohook": false` tells commitplease to install its `commit-msg` hook. Setting `"nohook": true` makes commitplease skip installing the hook or skip running the hook if it has already been installed. This can be used when wrapping the commitplease validation API into another module, like a [grunt plugin](https://github.com/rxaviers/grunt-commitplease/) or [husky](#husky). This setting does not affect the global commitplease executable, only repo-local. 94 | 95 | The following options are experimental and are subject to change: 96 | 97 | * `markerPattern`: A (intentionally loose) RegExp that indicates that the line might be a ticket reference. Case insensitive. 98 | * `actionPattern`: A RegExp that makes a line marked by `markerPattern` valid even if the line does not fit `ticketPattern` 99 | * `ticketPattern`: A RegExp that detects ticket references: `Closes gh-1`, `Fixes gh-42`, `WEB-451` and similar. 100 | 101 | The ticket reference match will fail only if `markerPattern` succeeds and __both__ `ticketPattern` and `actionPattern` fail. 102 | 103 | When overwriting these patterns in `package.json`, remember to escape special characters. 104 | 105 | ### jQuery 106 | 107 | Here is how to configure validation for [jQuery Commit Guidelines][1]: 108 | 109 | ```json 110 | { 111 | "commitplease": { 112 | "style": "jquery", 113 | "component": true, 114 | "components": [] 115 | } 116 | } 117 | ``` 118 | 119 | * `"style": "jquery"` selects [jQuery Commit Guidelines][1] 120 | * `"component": true` requires a component followed by a colon, like `Test:` or `Docs:` 121 | * `"components": []` is a list of valid components. Example: `"components": ["Test", "Docs"]`. Members of this list are surrounded by `^` and `$` and are treated as a regular expression. When this list is empty, anything followed by a colon is considered to be a valid component name. 122 | 123 | ### AngularJS 124 | 125 | Here is how to configure validation for [AngularJS Commit Guidelines][2] 126 | 127 | ```json 128 | { 129 | "commitplease": { 130 | "style": "angular", 131 | "types": [ 132 | "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore" 133 | ], 134 | "scope": "\\S+.*" 135 | } 136 | } 137 | ``` 138 | 139 | * `"style": "angular"` selects [AngularJS Commit Guidelines][2] 140 | * `"types"` is an array of allowed types 141 | * `"scope": "\\S+.*"` is a string that is the regexp for scope. By default it means "at least one non-space character" 142 | 143 | ## Skip style check 144 | 145 | This paragraph assumes that you would like to skip the style check that happens during `git commit`. One way to do so is to type `git commit --no-verify` that will skip a few git hooks, including the one used by commitplease. If skipping many hooks is not what you want or you find yourself doing it too many times, just set the `nohook` option. You could set that inside `package.json` as described at the beginning of the [setup section](#setup). However, if modifying `package.json` is not possible, just set it in `.npmrc` (it will overwrite `package.json`) like so: 146 | 147 | ``` 148 | [commitplease] 149 | nohook = true 150 | ``` 151 | 152 | ## Husky 153 | 154 | When using commitplease together with [husky][3], the following will let husky manage all the hooks and trigger commitplease: 155 | 156 | ```json 157 | { 158 | "scripts": { 159 | "commitmsg": "commitplease .git/COMMIT_EDITMSG" 160 | }, 161 | "commitplease": { 162 | "nohook": true 163 | } 164 | } 165 | ``` 166 | 167 | However, since husky does not use npm in silent mode (and there is [no easy way](https://github.com/typicode/husky/pull/47) to make it [do so](https://github.com/npm/npm/issues/5452)), there will be a lot of additional output when a message fails validation. Moreover, husky will run your `scripts` entry and nothing more, so you have to [specify everything yourself](https://github.com/jzaefferer/commitplease/issues/100) (e.g. the path to the commit message file). Therefore, using commitplease alone is recommended. 168 | 169 | ## API 170 | 171 | ```js 172 | var validate = require('commitplease/lib/validate'); 173 | var errors = validate(commit.message); 174 | if (errors.length) { 175 | postComment('This commit has ' + errors.length + ' problems!'); 176 | } 177 | ``` 178 | 179 | `validate(message[, options])`, returns `Array` 180 | 181 | * `message` (`String`): the commit message to validate. Must use LF (`\n`) as line breaks. 182 | * `options` (`Object`, optional): use this to override the default settings 183 | * returns `Array`: empty for valid messages, one or more items as `String` for each problem found 184 | 185 | ## Examples 186 | 187 | ```json 188 | { 189 | "name": "awesomeproject", 190 | "description": "described", 191 | "devDependencies": { 192 | "commitplease": "latest", 193 | }, 194 | "commitplease": { 195 | "style": "jquery", 196 | "components": ["Docs", "Tests", "Build", "..."], 197 | "markerPattern": "^((clos|fix|resolv)(e[sd]|ing))|(refs?)", 198 | "ticketPattern": "^((Closes|Fixes) ([a-zA-Z]{2,}-)[0-9]+)|(Refs? [^#])" 199 | } 200 | } 201 | ``` 202 | 203 | ```json 204 | { 205 | "name": "awesomeproject", 206 | "description": "described", 207 | "devDependencies": { 208 | "commitplease": "latest", 209 | }, 210 | "commitplease": { 211 | "style": "angular", 212 | "markerPattern": "^((clos|fix|resolv)(e[sd]|ing))|(refs?)", 213 | "ticketPattern": "^((Closes|Fixes) ([a-zA-Z]{2,}-)[0-9]+)|(Refs? [^#])" 214 | } 215 | } 216 | ``` 217 | 218 | ## Uninstall 219 | 220 | Remove your configurations of commitplease from your package.json, if any. 221 | 222 | If you are running `npm 2.x`, then: 223 | 224 | ``` 225 | npm uninstall commitplease --save-dev 226 | ``` 227 | 228 | If you are running `npm 3.x`, you will have to remove the hook manually: 229 | 230 | ``` 231 | rm .git/hooks/commit-msg 232 | npm uninstall commitplease --save-dev 233 | ``` 234 | 235 | There is [an open issue](https://github.com/npm/npm/issues/13381) to npm about this. 236 | 237 | 238 | ## License 239 | Copyright Jörn Zaefferer 240 | Released under the terms of the MIT license. 241 | 242 | --- 243 | 244 | Support this project by [donating on Gratipay](https://gratipay.com/jzaefferer/). 245 | 246 | [1]: http://contribute.jquery.org/commits-and-pull-requests/#commit-guidelines 247 | [2]: https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits 248 | [3]: https://github.com/typicode/husky 249 | [4]: https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection#Commit-Ranges 250 | -------------------------------------------------------------------------------- /commit-msg-hook.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var commitplease = require('commitplease') 4 | 5 | var options = commitplease.getOptions() 6 | 7 | if (options && options.nohook === true) { 8 | console.log('commitplease: package.json or .npmrc set to skip check') 9 | 10 | process.exit(0) 11 | } 12 | 13 | commitplease() 14 | -------------------------------------------------------------------------------- /commitplease.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('commitplease')() 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var ini = require('ini') 3 | var path = require('path') 4 | var chalk = require('chalk') 5 | var Git = require('git-tools') 6 | 7 | var validate = require('./lib/validate') 8 | var sanitize = require('./lib/sanitize') 9 | var defaults = require('./lib/defaults') 10 | 11 | // If there is a path in process.env.PATH that looks like this: 12 | // path = prefix + suffix (where suffix is the function argument) 13 | // then slice off the suffix and return the prefix, otherwise undefiend 14 | function sliceEnvPath (suffix) { 15 | var p = process.env.PATH.split(':').filter( 16 | function (p) {return p.endsWith(suffix)} 17 | ) 18 | 19 | if (p.length === 1) { 20 | p = p[0].split(path.sep) 21 | 22 | p = p.slice(0, p.length - suffix.split(path.sep).length) 23 | return p.join(path.sep) 24 | } 25 | 26 | return undefined 27 | } 28 | 29 | // Need to find the path to the project that is installing or running 30 | // commitplease. Previously, process.cwd() made the job easy but its 31 | // output changed with node v8.1.2 (at least compared to 7.10.0) 32 | function getProjectPath () { 33 | // Rely on npm to inject some path into PATH; However, the injected 34 | // path can both be relative or absolute, so add extra path.resolve() 35 | 36 | // During npm install, npm will inject a path that ends with 37 | // commitplease/node_modules/.bin into process.env.PATH 38 | var p = sliceEnvPath( 39 | path.join('node_modules', 'commitplease', 'node_modules', '.bin') 40 | ) 41 | 42 | if (p !== undefined) { 43 | return path.resolve(p) 44 | } 45 | 46 | // During npm run, npm will inject a path that ends with 47 | // node_modules/.bin into process.env.PATH 48 | p = sliceEnvPath(path.join('node_modules', '.bin')) 49 | 50 | if (p !== undefined) { 51 | return path.resolve(p) 52 | } 53 | 54 | // During git commit there will be no process.env.PATH modifications 55 | // So, assume we are being run by git which will set process.cwd() 56 | // to the root of the project as described in the manual: 57 | // https://git-scm.com/docs/githooks/2.9.0 58 | return path.resolve(process.cwd()) 59 | } 60 | 61 | function getOptions () { 62 | var projectPath = getProjectPath() 63 | 64 | var pkg = path.join(projectPath, 'package.json') 65 | var npm = path.join(projectPath, '.npmrc') 66 | 67 | pkg = fs.existsSync(pkg) && require(pkg) || {} 68 | npm = fs.existsSync(npm) && ini.parse(fs.readFileSync(npm, 'utf8')) || {} 69 | 70 | pkg = pkg.commitplease || {} 71 | npm = npm.commitplease || {} 72 | 73 | var options = Object.assign(pkg, npm) 74 | 75 | var base = { 76 | 'projectPath': projectPath, 77 | 'oldMessagePath': defaults.oldMessagePath, 78 | 'oldMessageSeconds': defaults.oldMessageSeconds 79 | } 80 | 81 | if (options === undefined || 82 | options.style === undefined || 83 | options.style === 'jquery') { 84 | return Object.assign(base, defaults.jquery, options) 85 | } else if (options.style === 'angular') { 86 | return Object.assign(base, defaults.angular, options) 87 | } 88 | 89 | console.error(chalk.red( 90 | 'Style ' + options.style + ' is not recognised\n' + 91 | 'Did you mistype it in package.json?' 92 | )) 93 | 94 | process.exit(1) 95 | } 96 | 97 | function runValidate (message, options) { 98 | var errors = validate(sanitize(message), options) 99 | 100 | if (errors.length) { 101 | console.error('Invalid commit message, please fix:\n') 102 | console.error(chalk.red('- ' + errors.join('\n- '))) 103 | console.error() 104 | console.error('Commit message was:') 105 | console.error() 106 | console.error(chalk.green(sanitize(message))) 107 | 108 | console.error('\nSee ' + options.guidelinesUrl) 109 | 110 | // save a poorly formatted message and reuse it at a later commit 111 | fs.writeFileSync(defaults.oldMessagePath, message) 112 | 113 | process.exit(1) 114 | } 115 | } 116 | 117 | module.exports = function () { 118 | var argv = process.argv.slice(2) 119 | var help = argv.some(function (value) { 120 | if (value === '-h' || value === '--help') { 121 | return true 122 | } 123 | }) 124 | 125 | if (argv.length > 1 || help) { 126 | console.log( 127 | 'Usage: commitplease [committish]\n\n' + 128 | 'committish a commit range passed to git log\n\n' + 129 | 'Examples:\n\n' + 130 | '1. Check all commits on branch master:\n' + 131 | 'commitplease master\n\n' + 132 | '2. Check all commits on branch feature but not on master:\n' + 133 | 'commitplease master..feature\n\n' + 134 | '3. Check the latest 1 commit (n works too):\n' + 135 | 'commitplease -1\n\n' + 136 | '4. Check all commits between 84991d and 2021ce\n' + 137 | 'commitplease 84991d..2021ce\n\n' + 138 | '5. Check all commits starting with 84991d\n' + 139 | 'commitplease 84991d..\n\n' + 140 | 'Docs on git commit ranges: https://bit.ly/commit-range' 141 | ) 142 | 143 | process.exit(0) 144 | } 145 | 146 | var options = getOptions() 147 | var message = path.join('.git', 'COMMIT_EDITMSG') 148 | 149 | if (path.normalize(argv[0]) === message) { 150 | runValidate(fs.readFileSync(message, 'utf8').toString(), options) 151 | 152 | process.exit(0) 153 | } 154 | 155 | var committish = 'HEAD' 156 | if (argv.length !== 0) { 157 | committish = argv[0] 158 | } 159 | 160 | var repo = new Git(process.cwd()) 161 | 162 | var secret = '--++== CoMMiTPLeaSe ==++--' 163 | var format = '--format=%B' + secret 164 | 165 | repo.exec('log', format, committish, function (error, messages) { 166 | if (error) { 167 | if (/Not a git repository/.test(error.message)) { 168 | console.log(error.message) 169 | 170 | process.exit(0) 171 | } 172 | 173 | if (/does not have any commits yet/.test(error.message)) { 174 | console.log(error.message) 175 | 176 | process.exit(0) 177 | } 178 | 179 | console.error(error) 180 | 181 | process.exit(1) 182 | } 183 | 184 | messages = messages.trim().split(secret) 185 | messages.pop() 186 | 187 | for (var i = 0; i < messages.length; ++i) { 188 | runValidate(messages[i], options) 189 | } 190 | }) 191 | } 192 | 193 | module.exports.defaults = defaults 194 | module.exports.getOptions = getOptions 195 | -------------------------------------------------------------------------------- /install.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var chalk = require('chalk') 4 | 5 | var options = require('./index.js').getOptions() 6 | 7 | // Is this a self-install? If so, do not copy hooks and quit early 8 | var pkgPath = path.join(options.projectPath, 'package.json') 9 | var pkg = fs.existsSync(pkgPath) && require(pkgPath) 10 | 11 | if (pkg && pkg.name && pkg.name === 'commitplease') { 12 | console.log('commitplease: self-install detected, skipping hooks') 13 | process.exit(0) 14 | } 15 | 16 | if (options.nohook) { 17 | console.log('commitplease: package.json or .npmrc set to skip hooks') 18 | process.exit(0) 19 | } 20 | 21 | function xmkdirSync (path, mode) { 22 | try { 23 | fs.mkdirSync(path, mode) 24 | } catch (err) { 25 | if (/EPERM/.test(err.message)) { 26 | console.error('Failed to create: ' + path) 27 | console.error('Make sure you have the necessary permissions') 28 | process.exit(1) 29 | } else if (/EEXIST/.test(err.message)) { 30 | // do nothing, but this might be a file, not a directory 31 | } else if (/ENOTDIR/.test(err.message)) { 32 | console.log('Will not install in a git submodule') 33 | process.exit(0) 34 | } else { 35 | console.error(err) 36 | process.exit(1) 37 | } 38 | } 39 | } 40 | 41 | var git = path.join(options.projectPath, '.git') 42 | 43 | var dstHooksPath = path.join(options.projectPath, '.git', 'hooks') 44 | var srcHooksPath = path.join(options.projectPath, 'node_modules', 'commitplease') 45 | 46 | xmkdirSync(git, parseInt('755', 8)) 47 | xmkdirSync(dstHooksPath, parseInt('755', 8)) 48 | 49 | var dstCommitHook = path.join(dstHooksPath, 'commit-msg') 50 | var srcCommitHook = path.join(srcHooksPath, 'commit-msg-hook.js') 51 | 52 | var dstPrepareHook = path.join(dstHooksPath, 'prepare-commit-msg') 53 | var srcPrepareHook = path.join(srcHooksPath, 'prepare-commit-msg-hook.js') 54 | 55 | var dstHooks = [dstCommitHook, dstPrepareHook] 56 | var srcHooks = [srcCommitHook, srcPrepareHook] 57 | 58 | // loop twice, try to avoid getting partially installed 59 | 60 | dstHooks.forEach(function (dstHook) { 61 | if (fs.existsSync(dstHook)) { 62 | console.log(chalk.red('The following hook already exists:\n' + dstHook)) 63 | console.log(chalk.red('Remove it and install this package again to install commitplease properly')) 64 | 65 | process.exit(0) 66 | } 67 | }) 68 | 69 | dstHooks.forEach(function (dstHook, i) { 70 | var srcHook = srcHooks[i] 71 | 72 | try { 73 | fs.writeFileSync(dstHook, fs.readFileSync(srcHook)) 74 | fs.chmodSync(dstHook, '755') 75 | } catch (e) { 76 | if (/EPERM/.test(e.message)) { 77 | console.error( 78 | chalk.red( 79 | 'Failed to write to ' + dstHook + 80 | '\nMake sure you have the necessary permissions.' 81 | ) 82 | ) 83 | } 84 | 85 | throw e 86 | } 87 | }) 88 | -------------------------------------------------------------------------------- /lib/defaults.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | var defaults = { 4 | jquery: { 5 | style: 'jquery', 6 | limits: { 7 | firstLine: 72, 8 | otherLine: 80 9 | }, 10 | markerPattern: '^(clos|fix|resolv)(e[sd]|ing)', 11 | actionPattern: '^([Cc]los|[Ff]ix|[Rr]esolv)(e[sd]|ing)' + 12 | '\\s+[^\\s\\d]+(\\s|$)', 13 | ticketPattern: '^(' + 14 | '(([Cc]los|[Ff]ix|[Rr]esolv)(e[sd]))' + 15 | '|' + 16 | '([Cc]onnects)' + 17 | '|' + 18 | '(([Cc]onnect|[Cc]onnects|[Cc]onnected) to)' + 19 | ')' + 20 | ' (.*#|gh-|[A-Z]{2,}-)[0-9]+', 21 | guidelinesUrl: 'https://bit.ly/jquery-guidelines', 22 | // jQuery specific settings follow: 23 | component: true, 24 | components: [] 25 | }, 26 | angular: { 27 | style: 'angular', 28 | limits: { 29 | firstLine: 100, 30 | otherLine: 100 31 | }, 32 | markerPattern: '^(clos|fix|resolv)(e[sd]|ing)', 33 | actionPattern: '^([Cc]los|[Ff]ix|[Rr]esolv)(e[sd]|ing)' + 34 | '\\s+[^\\s\\d]+(\\s|$)', 35 | ticketPattern: '^(' + 36 | '(([Cc]los|[Ff]ix|[Rr]esolv)(e[sd]))' + 37 | '|' + 38 | '([Cc]onnects)' + 39 | '|' + 40 | '(([Cc]onnect|[Cc]onnects|[Cc]onnected) to)' + 41 | ')' + 42 | ' (.*#|gh-|[A-Z]{2,}-)[0-9]+', 43 | guidelinesUrl: 'https://bit.ly/angular-guidelines', 44 | // Angular specific settings follow: 45 | types: [ 46 | 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore' 47 | ], 48 | scope: '\\S+.*' 49 | }, 50 | oldMessagePath: path.join('.git', 'COMMIT_EDITMSG_OLD'), 51 | oldMessageSeconds: 300 52 | } 53 | 54 | module.exports = defaults 55 | -------------------------------------------------------------------------------- /lib/sanitize.js: -------------------------------------------------------------------------------- 1 | var scissor = /# ------------------------ >8 ------------------------[\s\S]+/ 2 | 3 | module.exports = function (value) { 4 | var isComment = new RegExp('^#') 5 | return value.replace(scissor, '').split(/\n/).filter(function (line) { 6 | return !isComment.test(line) 7 | }).join('\n').trim() 8 | } 9 | -------------------------------------------------------------------------------- /lib/styles/angular.js: -------------------------------------------------------------------------------- 1 | module.exports = function (lines, options, errors) { 2 | var scheme = '(): ' 3 | var prefix = 'First line must be ' + scheme + '\n' 4 | 5 | var line = lines[0] 6 | 7 | if (line.startsWith('revert: ')) { 8 | line = line.replace(/^revert: /, '') 9 | prefix = 'First line must be revert: ' + scheme + '\n' 10 | } else if (line.startsWith('revert')) { 11 | errors.push( 12 | 'If this is a revert of a previous commit, please write:\n' + 13 | 'revert: (): ' 14 | ) 15 | 16 | return 17 | } 18 | 19 | if (line.indexOf('(') === -1) { 20 | errors.push(prefix + 'Need an opening parenthesis: (') 21 | 22 | return 23 | } 24 | 25 | var type = line.replace(/\(.*/, '') 26 | 27 | if (!type) { 28 | errors.push( 29 | prefix + ' was empty, must be one of these:\n' + 30 | options.types.join(', ') 31 | ) 32 | 33 | return 34 | } 35 | 36 | if (options.types.indexOf(type) === -1) { 37 | errors.push( 38 | prefix + ' invalid, was "' + type + 39 | '", must be one of these:\n' + options.types.join(', ') 40 | ) 41 | 42 | return 43 | } 44 | 45 | if (line.indexOf(')') === -1) { 46 | errors.push(prefix + 'Need a closing parenthesis after scope: )') 47 | 48 | return 49 | } 50 | 51 | var scope = line.slice(line.indexOf('(') + 1, line.indexOf(')')) 52 | 53 | if (!RegExp(options.scope).test(scope)) { 54 | errors.push( 55 | prefix + 'Scope ' + scope + ' does not match ' + options.scope 56 | ) 57 | 58 | return 59 | } 60 | 61 | if (line.indexOf(type + '(' + scope + '):') === -1) { 62 | errors.push(prefix + 'Need a colon after the closing parenthesis: ):') 63 | 64 | return 65 | } 66 | 67 | var subject = line.split(':')[1] 68 | 69 | if (!subject.startsWith(' ')) { 70 | errors.push(prefix + 'Need a space after colon: ": "') 71 | 72 | return 73 | } 74 | 75 | subject = subject.slice(1, subject.length) 76 | 77 | if (subject.length === 0) { 78 | errors.push(prefix + ' must not be empty') 79 | 80 | return 81 | } 82 | 83 | if (!/^[a-z]/.test(subject)) { 84 | errors.push(' must start with a lowercase letter') 85 | 86 | return 87 | } 88 | 89 | if (subject[subject.length - 1] === '.') { 90 | errors.push(' must not end with a dot') 91 | 92 | return 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/styles/jquery.js: -------------------------------------------------------------------------------- 1 | module.exports = function (lines, options, errors) { 2 | if (!options.component) { return } 3 | 4 | var prefix = 'First line must be : \n' 5 | 6 | var line = lines[0] 7 | 8 | if (line.indexOf(':') === -1) { 9 | errors.push(prefix + 'Missing colon :') 10 | 11 | return 12 | } 13 | 14 | var component = line.replace(/:.*/, '') 15 | 16 | if (!component) { 17 | errors.push( 18 | prefix + ' was empty, must be one of these:\n' + 19 | options.components.join(', ') 20 | ) 21 | 22 | return 23 | } 24 | 25 | var components = options.components 26 | if (components.length && !components.some( 27 | function (x) { return RegExp('^' + x + '$').test(component) } 28 | )) { 29 | errors.push( 30 | prefix + ' invalid, was "' + component + 31 | '", must be one of these:\n' + components.join(', ') 32 | ) 33 | 34 | return 35 | } 36 | 37 | if (line.substring(line.indexOf(':') + 1).length < 1) { 38 | errors.push(prefix + ' was empty') 39 | 40 | return 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/utils/limits.js: -------------------------------------------------------------------------------- 1 | module.exports = function (lines, options, errors) { 2 | var limits = options.limits 3 | lines.forEach(function (line, index) { 4 | var length = line.length 5 | if (index === 0) { 6 | if (length === 0) { 7 | errors.push('First line of commit message must not be empty') 8 | } else if (length > limits.firstLine) { 9 | errors.push( 10 | 'First line of commit message must be no longer than ' + 11 | limits.firstLine + ' characters' 12 | ) 13 | } 14 | } else if (index === 1 && length > 0) { 15 | errors.push('Second line must always be empty') 16 | } else if (length > limits.otherLine) { 17 | errors.push( 18 | 'Commit message line ' + (index + 1) + ' too long: ' + 19 | length + ' characters, only ' + limits.otherLine + ' allowed.\n' + 20 | 'Was: ' + line.substring(0, 20) + '[...]' 21 | ) 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /lib/utils/tickets.js: -------------------------------------------------------------------------------- 1 | module.exports = function (lines, options, errors) { 2 | var marker = new RegExp(options.markerPattern, 'i') 3 | var action = new RegExp(options.actionPattern) 4 | var ticket = new RegExp(options.ticketPattern) 5 | 6 | lines.forEach(function (line, index) { 7 | if (marker.test(line)) { 8 | if (!action.test(line) && !ticket.test(line)) { 9 | errors.push( 10 | 'Invalid ticket reference, must be ' + ticket + '\n' + 11 | 'Was: ' + line 12 | ) 13 | } 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /lib/validate.js: -------------------------------------------------------------------------------- 1 | var styles = { 2 | 'jquery': require('./styles/jquery.js'), 3 | 'angular': require('./styles/angular.js') 4 | } 5 | 6 | var limits = require('./utils/limits') 7 | var tickets = require('./utils/tickets') 8 | 9 | var semver = require('semver') 10 | 11 | module.exports = function (message, options) { 12 | if (message === undefined) { 13 | return ['Commit message is undefined, abort with error'] 14 | } else if ( 15 | !(typeof message === 'string' || message instanceof String) 16 | ) { 17 | return ['Commit message is not a string, abort with error'] 18 | } else if (message.length === 0) { 19 | return ['Commit message is empty, abort with error'] 20 | } 21 | 22 | var lines = message.split('\n') 23 | 24 | if (semver.valid(lines[0])) { 25 | return [] 26 | } 27 | 28 | if (/^WIP|^Wip|^wip/.test(lines[0])) { 29 | return [] 30 | } 31 | 32 | if (/^Merge branch|^Merge [0-9a-f]+ into [0-9a-f]+/.test(lines[0])) { 33 | return [] 34 | } 35 | 36 | if (/^fixup!|^squash!/.test(lines[0])) { 37 | return [] 38 | } 39 | 40 | var errors = [] 41 | 42 | limits(lines, options, errors) 43 | tickets(lines, options, errors) 44 | 45 | styles[options.style](lines, options, errors) 46 | 47 | return errors 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commitplease", 3 | "version": "3.2.0", 4 | "description": "Validates strings as commit messages", 5 | "main": "index.js", 6 | "bin": { 7 | "commitplease": "commitplease.js" 8 | }, 9 | "scripts": { 10 | "install": "node install", 11 | "uninstall": "node uninstall", 12 | "test": "standard && babel-node --presets=es2015 -- node_modules/.bin/nodeunit test.js", 13 | "test-watch": "nodemon --exec npm run --silent test" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/jzaefferer/commitplease.git" 18 | }, 19 | "keywords": [ 20 | "commit", 21 | "message", 22 | "validation" 23 | ], 24 | "author": { 25 | "name": "Jörn Zaefferer", 26 | "url": "http://bassistance.de" 27 | }, 28 | "license": "MIT", 29 | "devDependencies": { 30 | "babel-cli": "^6.10.1", 31 | "babel-preset-es2015": "^6.9.0", 32 | "babel-register": "^6.9.0", 33 | "nodemon": "^1.9.1", 34 | "nodeunit": "^0.9.1", 35 | "standard": "^3.0.0" 36 | }, 37 | "dependencies": { 38 | "chalk": "^1.1.1", 39 | "git-tools": "^0.2.1", 40 | "ini": "^1.3.4", 41 | "semver": "^5.1.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /prepare-commit-msg-hook.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs') 4 | 5 | var options = require('commitplease').getOptions() 6 | 7 | var oldMessagePath = options.oldMessagePath 8 | var oldMessageSeconds = options.oldMessageSeconds 9 | 10 | try { 11 | // There may be an old message that was previously rejected by us 12 | // Suggest it to the user so they do not have to start from scratch 13 | 14 | // Will throw ENOENT if no such file, ask forgiveness not permission 15 | var mtime = new Date(fs.statSync(oldMessagePath).mtime) 16 | 17 | // Date.now() - mtime.getTime() is milliseconds, convert to seconds 18 | var fresh = (Date.now() - mtime.getTime()) / 1000 < oldMessageSeconds 19 | 20 | // There are many scenarios that trigger the prepare-commit-msg hook 21 | // These scenarios pass different console parameters, see here: 22 | // https://www.kernel.org/pub/software/scm/git/docs/githooks.html 23 | // 24 | // A plain `git commit` is the only scenario that passes 3 entries 25 | // For all other scenarios (like `git commit -m`, squash or merge) 26 | // just delete oldMessagePath, do not actually suggest it to user 27 | var plain = process.argv === 3 28 | 29 | if (plain && fresh) { 30 | fs.writeFileSync(process.argv[2], fs.readFileSync(oldMessagePath)) 31 | } 32 | 33 | fs.unlinkSync(oldMessagePath) 34 | } catch (err) { 35 | if (!/ENOENT/.test(err.message)) { 36 | throw err 37 | } 38 | 39 | // there is no old message to reuse, swallow the exception 40 | } 41 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var validate = require('./lib/validate') 2 | var sanitize = require('./lib/sanitize') 3 | var defaults = require('./lib/defaults') 4 | 5 | var jqueryColon = 6 | 'First line must be : \n' + 7 | 'Missing colon :' 8 | var jqueryComponent = 9 | 'First line must be : \n' + 10 | ' invalid, was "Component", must be one of these:\n' 11 | var jqueryEmptyComponent = 12 | 'First line must be : \n' + 13 | ' was empty, must be one of these:\n' 14 | var jqueryTestComponent = 15 | 'First line must be : \n' + 16 | ' invalid, was "Test", must be one of these:\n' 17 | var jqueryFixComponent = 18 | 'First line must be : \n' + 19 | ' invalid, was "[fix]", must be one of these:\n' 20 | var jqueryTmpComponent = 21 | 'First line must be : \n' + 22 | ' invalid, was "[Tmp]", must be one of these:\n' 23 | var jqueryEmptySubject = 24 | 'First line must be : \n' + 25 | ' was empty' 26 | var jqueryCommitMessageEmpty = 27 | 'Commit message is empty, abort with error' 28 | var jqueryFirstLine72 = 29 | 'First line of commit message must be no longer than 72 characters' 30 | 31 | var jquery0 = defaults.jquery 32 | 33 | var jquery1 = Object.assign( 34 | {}, defaults.jquery, {component: false} 35 | ) 36 | 37 | var jquery2 = Object.assign( 38 | {}, defaults.jquery, {components: ['Build', 'Legacy']} 39 | ) 40 | 41 | var jquery3 = Object.assign( 42 | {}, defaults.jquery, { 43 | markerPattern: '^((clos|fix|resolv)(e[sd]|ing))|^(refs?)', 44 | ticketPattern: '^((Closes|Fixes) ([a-zA-Z]{2,}-)[0-9]+)|^(Refs? [^#])' 45 | } 46 | ) 47 | 48 | var jquery4 = Object.assign( 49 | {}, defaults.jquery, {components: ['^\\[\\w+-\\d+\\]']} 50 | ) 51 | 52 | var profiles0 = [jquery0, jquery1, jquery3] 53 | 54 | var messages0 = [ 55 | { 56 | msg: 'Component: short message' 57 | }, 58 | { 59 | msg: 'Component: short message\n\n' + 60 | 'Long description' 61 | }, 62 | { 63 | msg: 'Component: short message\n\n' + 64 | 'Long description\n\n' + 65 | 'That spans many paragraphs' 66 | }, 67 | { 68 | msg: 'Component: must not trigger false positives\n\n' + 69 | 'This fixes a reference to reformat a fix\n' + 70 | 'This closes a fix to reformat a reference' 71 | }, 72 | { 73 | msg: 'Component: short message\n' + 74 | '#comment' 75 | }, 76 | { 77 | msg: 'Component: short message\n' + 78 | '# comment' 79 | }, 80 | { 81 | msg: 'Component: short message\n' + 82 | '# comment' 83 | }, 84 | { 85 | msg: '# comment\n' + 86 | 'Component: short message' 87 | }, 88 | { 89 | msg: 'Component: short message\n' + 90 | 'text on next line', 91 | reasons: new Map([ 92 | [jquery0, [ 'Second line must always be empty' ]], 93 | [jquery1, [ 'Second line must always be empty' ]], 94 | [jquery3, [ 'Second line must always be empty' ]] 95 | ]) 96 | }, 97 | { 98 | msg: 'No component here, short message', 99 | accepts: [jquery1], 100 | reasons: new Map([ 101 | [jquery0, [jqueryColon]], 102 | [jquery2, [jqueryColon]], 103 | [jquery3, [jqueryColon]], 104 | [jquery4, [jqueryColon]] 105 | ]) 106 | }, 107 | { 108 | msg: ':No component here, short message', 109 | reasons: new Map([ 110 | [jquery0, [jqueryEmptyComponent]], 111 | [jquery3, [jqueryEmptyComponent]] 112 | ]) 113 | }, 114 | { 115 | msg: '# comment\n' + 116 | 'No component here, short message', 117 | accepts: [jquery1], 118 | reasons: new Map([ 119 | [jquery0, [jqueryColon]], 120 | [jquery3, [jqueryColon]] 121 | ]) 122 | }, 123 | { 124 | msg: 'Build: short message', 125 | accepts: [jquery2] 126 | }, 127 | { 128 | msg: '[AB-42]: short message', 129 | accepts: [jquery4] 130 | }, 131 | { 132 | msg: 'Test: short message', 133 | reasons: new Map([ 134 | [jquery2, [jqueryTestComponent + jquery2.components.join(', ')]], 135 | [jquery4, [jqueryTestComponent + jquery4.components.join(', ')]] 136 | ]) 137 | }, 138 | { 139 | msg: 'Component: short message\n' + 140 | '# Long comments still have to be ignored' + 141 | 'even though they are longer than 72 characters' + 142 | 'notice the absense of newline character in test' 143 | }, 144 | { 145 | msg: 'Component: short message' + 146 | ' but actually a little bit over default character limit', 147 | reasons: new Map([ 148 | [jquery0, [jqueryFirstLine72]], 149 | [jquery1, [jqueryFirstLine72]], 150 | [jquery2, [jqueryFirstLine72, jqueryComponent + jquery2.components.join(', ')]], 151 | [jquery3, [jqueryFirstLine72]], 152 | [jquery4, [jqueryFirstLine72, jqueryComponent + jquery4.components.join(', ')]] 153 | ]) 154 | }, 155 | { 156 | msg: 'Component: short message\n\n' + 157 | 'Long description is way past the 80 characters limit' + 158 | 'Long description is way past the 80 characters limit', 159 | reasons: new Map([ 160 | [jquery0, 161 | [ 'Commit message line 3 too long: 104 characters, only 80 allowed.\n' + 162 | 'Was: Long description is [...]' ] 163 | ], 164 | [jquery1, 165 | ['Commit message line 3 too long: 104 characters, only 80 allowed.\n' + 166 | 'Was: Long description is [...]'] 167 | ], 168 | [jquery3, 169 | ['Commit message line 3 too long: 104 characters, only 80 allowed.\n' + 170 | 'Was: Long description is [...]'] 171 | ] 172 | ]) 173 | }, 174 | { 175 | msg: 'Component:', 176 | accepts: [jquery1], 177 | reasons: new Map([ 178 | [jquery0, [jqueryEmptySubject]], 179 | [jquery3, [jqueryEmptySubject]] 180 | ]) 181 | }, 182 | { 183 | msg: 'Build:', 184 | accepts: [jquery1], 185 | reasons: new Map([ 186 | [jquery0, [jqueryEmptySubject]], 187 | [jquery2, [jqueryEmptySubject]], 188 | [jquery3, [jqueryEmptySubject]] 189 | ]) 190 | }, 191 | { 192 | msg: '[AB-42]:', 193 | accepts: [jquery1], 194 | reasons: new Map([ 195 | [jquery0, [jqueryEmptySubject]], 196 | [jquery3, [jqueryEmptySubject]], 197 | [jquery4, [jqueryEmptySubject]] 198 | ]) 199 | }, 200 | { 201 | msg: 'Component0:Component1: short message\n\n' 202 | }, 203 | { 204 | msg: 'Component: short message\n\n' + 205 | '1. List element\n' + 206 | '2. List element\n\n' + 207 | '* List element\n' + 208 | '* List element\n\n' + 209 | '- List element\n' + 210 | '- List element\n\n' + 211 | '+ List element\n' + 212 | '+ List element' 213 | }, 214 | { 215 | msg: 'Component: message over default of 50 but below limit of 70' 216 | }, 217 | { 218 | msg: 'Component: short message\n\n' + 219 | 'Fixes some bug.\n' + 220 | 'Fix some other bug.\n' + 221 | 'And fix these bugs too.\n' + 222 | 'And fixes other bugs too.' 223 | }, 224 | { 225 | msg: 'Component: short message\n\n' + 226 | 'Resolves some issue.\n' + 227 | 'Resolve some other issue.\n' + 228 | 'And resolve these issues too.\n' + 229 | 'And resolves some other issues too.' 230 | }, 231 | { 232 | msg: 'Component: short message\n\n' + 233 | 'Closes some issue.\n' + 234 | 'Close some other issue.\n' + 235 | 'And close these issues too.\n' + 236 | 'And closes some other issues too.' 237 | }, 238 | { 239 | msg: 'Component: short message\n\n' + 240 | 'Fixes #1\n' + 241 | 'Fixes #123', 242 | reasons: new Map([ 243 | [jquery3, [ 244 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes #1', 245 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes #123' 246 | ]] 247 | ]) 248 | }, 249 | { 250 | msg: 'Component: short message\n\n' + 251 | 'fixes #1\n' + 252 | 'fixes #123', 253 | reasons: new Map([ 254 | [jquery3, [ 255 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: fixes #1', 256 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: fixes #123' 257 | ]] 258 | ]) 259 | }, 260 | { 261 | msg: 'Component: short message\n\n' + 262 | 'Fixes #1 Fixes #123', 263 | reasons: new Map([ 264 | [jquery3, [ 265 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes #1 Fixes #123' 266 | ]] 267 | ]) 268 | }, 269 | { 270 | msg: 'Component: short message\n\n' + 271 | 'Fixes jquery/jquery#1\n' + 272 | 'Fixes jquery/jquery#123', 273 | reasons: new Map([ 274 | [jquery3, [ 275 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes jquery/jquery#1', 276 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes jquery/jquery#123' 277 | ]] 278 | ]) 279 | }, 280 | { 281 | msg: 'Component: short message\n\n' + 282 | 'Fixes jquery/jquery#1 Fixes jquery/jquery#123', 283 | reasons: new Map([ 284 | [jquery3, [ 285 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixes jquery/jquery#1 Fixes jquery/jquery#123' 286 | ]] 287 | ]) 288 | }, 289 | { 290 | msg: 'Component: short message\n\n' + 291 | 'Fixes gh-1\n' + 292 | 'Fixes gh-123' 293 | }, 294 | { 295 | msg: 'Component: short message\n\n' + 296 | 'Fixes gh-1 Fixes gh-123' 297 | }, 298 | { 299 | msg: 'Component: short message\n\n' + 300 | 'Fixes WEB-1\n' + 301 | 'Fixes WEB-123' 302 | }, 303 | { 304 | msg: 'Component: short message\n\n' + 305 | 'Fixes WEB-1 Fixes WEB-123' 306 | }, 307 | { 308 | msg: 'Component: short message\n\n' + 309 | 'Fixes CRM-1\n' + 310 | 'Fixes CRM-123' 311 | }, 312 | { 313 | msg: 'Component: short message\n\n' + 314 | 'Fixes CRM-1 Fixes CRM-123' 315 | }, 316 | { 317 | msg: 'Component: short message\n\n' + 318 | 'Closes #1\n' + 319 | 'Closes #123', 320 | reasons: new Map([ 321 | [jquery3, [ 322 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes #1', 323 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes #123' 324 | ]] 325 | ]) 326 | }, 327 | { 328 | msg: 'Component: short message\n\n' + 329 | 'closes #1\n' + 330 | 'closes #123', 331 | reasons: new Map([ 332 | [jquery3, [ 333 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: closes #1', 334 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: closes #123' 335 | ]] 336 | ]) 337 | }, 338 | { 339 | msg: 'Component: short message\n\n' + 340 | 'Closes #1 Closes #123', 341 | reasons: new Map([ 342 | [jquery3, [ 343 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes #1 Closes #123' 344 | ]] 345 | ]) 346 | }, 347 | { 348 | msg: 'Component: short message\n\n' + 349 | 'Closes jquery/jquery#1\n' + 350 | 'Closes jquery/jquery#123', 351 | reasons: new Map([ 352 | [jquery3, [ 353 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes jquery/jquery#1', 354 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes jquery/jquery#123' 355 | ]] 356 | ]) 357 | }, 358 | { 359 | msg: 'Component: short message\n\n' + 360 | 'Closes jquery/jquery#1 Closes jquery/jquery#123', 361 | reasons: new Map([ 362 | [jquery3, [ 363 | 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes jquery/jquery#1 Closes jquery/jquery#123' 364 | ]] 365 | ]) 366 | }, 367 | { 368 | msg: 'Component: short message\n\n' + 369 | 'Closes gh-1\n' + 370 | 'Closes gh-123' 371 | }, 372 | { 373 | msg: 'Component: short message\n\n' + 374 | 'Closes gh-1 Closes gh-123' 375 | }, 376 | { 377 | msg: 'Component: short message\n\n' + 378 | 'Closes WEB-1\n' + 379 | 'Closes WEB-123' 380 | }, 381 | { 382 | msg: 'Component: short message\n\n' + 383 | 'Closes WEB-1 Closes WEB-123' 384 | }, 385 | { 386 | msg: 'Component: short message\n\n' + 387 | 'Closes CRM-1\n' + 388 | 'Closes CRM-123' 389 | }, 390 | { 391 | msg: 'Component: short message\n\n' + 392 | 'Closes CRM-1 Closes CRM-123' 393 | }, 394 | { 395 | msg: 'Component: short message\n\n' + 396 | 'Refs #1', 397 | reasons: new Map([ 398 | [jquery3, ['Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Refs #1']] 399 | ]) 400 | }, 401 | { 402 | msg: 'Component: short message\n\n' + 403 | 'Refs gh-1\n' 404 | }, 405 | { 406 | msg: 'Component: short message\n\n' + 407 | 'Refs WEB-1' 408 | }, 409 | { 410 | msg: 'Component: short message\n\n' + 411 | 'Refs 90d828b' 412 | }, 413 | { 414 | msg: 'Component: short message\n\n' + 415 | 'Refs jquery/jquery#1' 416 | }, 417 | { 418 | msg: 'Component: short message\n\n' + 419 | 'Refs short text' 420 | }, 421 | { 422 | msg: 'Component: short message\n\n' + 423 | 'Refs github.com/wiki#link' 424 | }, 425 | { 426 | msg: 'Component: short message\n\n' + 427 | 'Ref #1', 428 | reasons: new Map([ 429 | [jquery3, ['Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Ref #1']] 430 | ]) 431 | }, 432 | { 433 | msg: 'Component: short message\n\n' + 434 | 'Ref gh-1\n' 435 | }, 436 | { 437 | msg: 'Component: short message\n\n' + 438 | 'Ref WEB-1' 439 | }, 440 | { 441 | msg: 'Component: short message\n\n' + 442 | 'Ref 90d828b' 443 | }, 444 | { 445 | msg: 'Component: short message\n\n' + 446 | 'Ref jquery/jquery#1' 447 | }, 448 | { 449 | msg: 'Component: short message\n\n' + 450 | 'Ref short text' 451 | }, 452 | { 453 | msg: 'Component: short message\n\n' + 454 | 'Ref github.com/wiki#link' 455 | }, 456 | { 457 | msg: 'Component: short message\n\n' + 458 | 'Connect #1\n' + 459 | 'Connect to #1\n' + 460 | 'Connects to #1\n' + 461 | 'Connected to #1' 462 | }, 463 | { 464 | msg: 'Component: short message\n\n' + 465 | 'connect #1\n' + 466 | 'connect to #1\n' + 467 | 'connects to #1\n' + 468 | 'connected to #1' 469 | }, 470 | { 471 | msg: 'Component: short message\n\n' + 472 | 'Fixes #123\n' + 473 | 'Fixes #1 Fixes #123\n' + 474 | 'Fixes gh-123\n' + 475 | 'Fixes gh-1 Fixes gh-123\n' + 476 | 'Fixes WEB-123\n' + 477 | 'Fixes WEB-1 Fixes WEB-123\n' + 478 | 'Fixes CRM-123\n' + 479 | 'Fixes CRM-1 Fixes CRM-123\n', 480 | rejects: [jquery3] 481 | }, 482 | { 483 | msg: 'Component: short message\n\n' + 484 | 'Closes #123\n' + 485 | 'Closes #1 Closes #123\n' + 486 | 'Closes gh-123\n' + 487 | 'Closes gh-1 Closes gh-123\n' + 488 | 'Closes WEB-123\n' + 489 | 'Closes WEB-1 Closes WEB-123\n' + 490 | 'Closes CRM-123\n' + 491 | 'Closes CRM-1 Closes CRM-123\n', 492 | rejects: [jquery3] 493 | }, 494 | { 495 | msg: 'Component: short message\n\n' + 496 | 'Closes #123\n' + 497 | 'Fixes #1 Closes #123\n' + 498 | 'Fixes gh-123\n' + 499 | 'Closes gh-1 Fixes gh-123\n' + 500 | 'Closes WEB-123\n' + 501 | 'Fixes WEB-1 Closes WEB-123\n' + 502 | 'Fixes CRM-123\n' + 503 | 'Closes CRM-1 Fixes CRM-123\n', 504 | rejects: [jquery3] 505 | }, 506 | { 507 | msg: 'Component: short message\n\n' + 508 | 'Long description\n\n' + 509 | 'Closes #42\n\n' + 510 | 'An afterthought is ok', 511 | reasons: new Map([ 512 | [jquery3, [ 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes #42' ]] 513 | ]) 514 | }, 515 | { 516 | msg: 'Component: short message\n\n' + 517 | 'Closes: gh-1', 518 | reasons: new Map([ 519 | [jquery0, [ 'Invalid ticket reference, must be /' + jquery0.ticketPattern + '/\nWas: Closes: gh-1' ]], 520 | [jquery1, [ 'Invalid ticket reference, must be /' + jquery1.ticketPattern + '/\nWas: Closes: gh-1' ]], 521 | [jquery3, [ 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closes: gh-1' ]] 522 | ]) 523 | }, 524 | { 525 | msg: 'Component: short message\n\n' + 526 | 'Closing #1', 527 | reasons: new Map([ 528 | [jquery0, [ 'Invalid ticket reference, must be /' + jquery0.ticketPattern + '/\nWas: Closing #1' ]], 529 | [jquery1, [ 'Invalid ticket reference, must be /' + jquery1.ticketPattern + '/\nWas: Closing #1' ]], 530 | [jquery3, [ 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Closing #1' ]] 531 | ]) 532 | }, 533 | { 534 | msg: 'Component: short message\n\n' + 535 | 'Fixing gh-1', 536 | reasons: new Map([ 537 | [jquery0, [ 'Invalid ticket reference, must be /' + jquery0.ticketPattern + '/\nWas: Fixing gh-1' ]], 538 | [jquery1, [ 'Invalid ticket reference, must be /' + jquery1.ticketPattern + '/\nWas: Fixing gh-1' ]], 539 | [jquery3, [ 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Fixing gh-1' ]] 540 | ]) 541 | }, 542 | { 543 | msg: 'Component: short message\n\n' + 544 | 'Resolving WEB-1', 545 | reasons: new Map([ 546 | [jquery0, [ 'Invalid ticket reference, must be /' + jquery0.ticketPattern + '/\nWas: Resolving WEB-1' ]], 547 | [jquery1, [ 'Invalid ticket reference, must be /' + jquery1.ticketPattern + '/\nWas: Resolving WEB-1' ]], 548 | [jquery3, [ 'Invalid ticket reference, must be /' + jquery3.ticketPattern + '/\nWas: Resolving WEB-1' ]] 549 | ]) 550 | }, 551 | { 552 | msg: 'Component: short message\n\n' + 553 | '# Please enter the commit message . Lines starting\n' + 554 | "# with '#' will be ignored, an empty message aborts the commit.\n" + 555 | '# On branch commitplease-rocks\n' + 556 | '# Changes to be committed:\n' + 557 | '# modified: test.js\n' + 558 | '# ------------------------ >8 ------------------------\n' + 559 | '# Do not touch the line above.\n' + 560 | '# Everything below will be removed.\n' + 561 | 'diff --git a/test.js b/test.js\n' + 562 | 'index c689515..706b86f 100644\n' + 563 | '--- a/test.js\n' + 564 | '+++ b/test.js\n' + 565 | '@@ -1,14 +1,10 @@\n' + 566 | "var validate = require('./lib/validate')\n" + 567 | "var sanitize = require('./lib/sanitize')\n" 568 | }, 569 | { 570 | msg: 'Component: short message\n\n' + 571 | 'This PR closes #123' 572 | } 573 | ] 574 | 575 | var angularOpening = 576 | 'First line must be (): \n' + 577 | 'Need an opening parenthesis: (' 578 | var angularOpeningRevert = 579 | 'First line must be revert: (): \n' + 580 | 'Need an opening parenthesis: (' 581 | var angularClosing = 582 | 'First line must be (): \n' + 583 | 'Need a closing parenthesis after scope: )' 584 | var angularSpace = 585 | 'First line must be (): \n' + 586 | 'Need a space after colon: ": "' 587 | var angularScope = 588 | 'First line must be (): \n' + 589 | 'Scope does not match \\S+.*' 590 | var angularColon = 591 | 'First line must be (): \n' + 592 | 'Need a colon after the closing parenthesis: ):' 593 | var angularLowercase = 594 | ' must start with a lowercase letter' 595 | var angularDot = 596 | ' must not end with a dot' 597 | var angularIfRevert = 598 | 'If this is a revert of a previous commit, please write:\n' + 599 | 'revert: (): ' 600 | var angularEmptyTypeRevert = 601 | 'First line must be revert: (): \n' + 602 | ' was empty, must be one of these:\n' + 603 | 'feat, fix, docs, style, refactor, perf, test, chore' 604 | 605 | var angular0 = defaults.angular 606 | 607 | var profiles1 = [angular0] 608 | var messages1 = [ 609 | { 610 | msg: 'feat(scope): subject' 611 | }, 612 | { 613 | msg: 'fix(scope): subject' 614 | }, 615 | { 616 | msg: 'docs(scope): subject' 617 | }, 618 | { 619 | msg: 'style(scope): subject' 620 | }, 621 | { 622 | msg: 'refactor(scope): subject' 623 | }, 624 | { 625 | msg: 'perf(scope): subject' 626 | }, 627 | { 628 | msg: 'test(scope): subject' 629 | }, 630 | { 631 | msg: 'chore(scope): subject' 632 | }, 633 | { 634 | msg: 'feat($scope): subject' 635 | }, 636 | { 637 | msg: 'feat(guide/location): subject' 638 | }, 639 | { 640 | msg: 'feat(scope-scope): subject' 641 | }, 642 | { 643 | msg: 'feat(ngCamelCase): subject' 644 | }, 645 | { 646 | msg: 'revert: feat(scope): subject' 647 | }, 648 | { 649 | msg: 'feat(*): subject' 650 | }, 651 | { 652 | msg: 'feat(scope1): docs(scope2):' 653 | }, 654 | { 655 | msg: 'feat($scope): subject\n\n' + 656 | 'Closes #1\n' + 657 | 'Closes #123' 658 | }, 659 | { 660 | msg: 'feat($scope): subject\n\n' + 661 | 'closes #1\n' + 662 | 'closes #123' 663 | }, 664 | { 665 | msg: 'feat($scope): subject\n\n' + 666 | 'Connect #1\n' + 667 | 'Connect to #1\n' + 668 | 'Connects to #1\n' + 669 | 'Connected to #1' 670 | }, 671 | { 672 | msg: 'feat($scope): subject\n\n' + 673 | 'connect #1\n' + 674 | 'connect to #1\n' + 675 | 'connects to #1\n' + 676 | 'connected to #1' 677 | }, 678 | { 679 | msg: 'feat', 680 | reasons: new Map([[angular0, [angularOpening]]]) 681 | }, 682 | { 683 | msg: 'feat subject', 684 | reasons: new Map([[angular0, [angularOpening]]]) 685 | }, 686 | { 687 | msg: 'feat: subject', 688 | reasons: new Map([[angular0, [angularOpening]]]) 689 | }, 690 | { 691 | msg: 'feat(', 692 | reasons: new Map([[angular0, [angularClosing]]]) 693 | }, 694 | { 695 | msg: 'feat()', 696 | reasons: new Map([[angular0, [angularScope]]]) 697 | }, 698 | { 699 | msg: 'feat(scope)', 700 | reasons: new Map([[angular0, [angularColon]]]) 701 | }, 702 | { 703 | msg: 'feat(scope):', 704 | reasons: new Map([[angular0, [angularSpace]]]) 705 | }, 706 | { 707 | msg: 'feat(scope):subject', 708 | reasons: new Map([[angular0, [angularSpace]]]) 709 | }, 710 | { 711 | msg: 'feat(scope): Subject', 712 | reasons: new Map([[angular0, [angularLowercase]]]) 713 | }, 714 | { 715 | msg: 'feat(scope): subject.', 716 | reasons: new Map([[angular0, [angularDot]]]) 717 | }, 718 | { 719 | msg: 'revert this commit', 720 | reasons: new Map([[angular0, [angularIfRevert]]]) 721 | }, 722 | { 723 | msg: 'revert(scope): subject', 724 | reasons: new Map([[angular0, [angularIfRevert]]]) 725 | }, 726 | { 727 | msg: 'revert: (scope): subject', 728 | reasons: new Map([[angular0, [angularEmptyTypeRevert]]]) 729 | }, 730 | { 731 | msg: 'revert: feat: subject', 732 | reasons: new Map([[angular0, [angularOpeningRevert]]]) 733 | }, 734 | { 735 | msg: 'revert: subject', 736 | reasons: new Map([[angular0, [angularOpeningRevert]]]) 737 | }, 738 | { 739 | msg: 'feat(scope1):docs(scope2): subject', 740 | reasons: new Map([[angular0, [angularSpace]]]) 741 | } 742 | ] 743 | 744 | var profiles9 = profiles0.concat(profiles1) 745 | var messages9 = [ 746 | { 747 | msg: '0.0.1' 748 | }, 749 | { 750 | msg: 'v0.0.1' 751 | }, 752 | { 753 | msg: '512.4096.65536' 754 | }, 755 | { 756 | msg: 'v512.4096.65536' 757 | }, 758 | { 759 | msg: "Merge branch 'one' into two" 760 | }, 761 | { 762 | msg: 'Merge e8d808b into 0f40453' 763 | }, 764 | { 765 | msg: 'fixup!' 766 | }, 767 | { 768 | msg: 'fixup! short message' 769 | }, 770 | { 771 | msg: 'squash!' 772 | }, 773 | { 774 | msg: 'squash! short message' 775 | }, 776 | { 777 | msg: '[fix]: short message', 778 | reasons: new Map([ 779 | [jquery2, [jqueryFixComponent + jquery2.components.join(', ')]], 780 | [jquery4, [jqueryFixComponent + jquery4.components.join(', ')]], 781 | [angular0, [angularOpening]] 782 | ]) 783 | }, 784 | { 785 | msg: '[Tmp]: short message', 786 | reasons: new Map([ 787 | [jquery2, [jqueryTmpComponent + jquery2.components.join(', ')]], 788 | [jquery4, [jqueryTmpComponent + jquery4.components.join(', ')]], 789 | [angular0, [angularOpening]] 790 | ]) 791 | }, 792 | { 793 | msg: '', 794 | reasons: new Map([ 795 | [jquery0, [jqueryCommitMessageEmpty]], 796 | [jquery1, [jqueryCommitMessageEmpty]], 797 | [jquery2, [jqueryCommitMessageEmpty]], 798 | [jquery3, [jqueryCommitMessageEmpty]], 799 | [jquery4, [jqueryCommitMessageEmpty]], 800 | [angular0, ['Commit message is empty, abort with error']] 801 | ]) 802 | } 803 | ] 804 | 805 | var groups = new Map([ 806 | [profiles0, messages0], 807 | [profiles1, messages1], 808 | [profiles9, messages9] 809 | ]) 810 | 811 | exports.tests = function (test) { 812 | for (var group of groups) { 813 | testGroup(test, group) 814 | } 815 | test.done() 816 | } 817 | 818 | function testGroup (test, group) { 819 | let [profiles, messages] = [group[0], group[1]] 820 | 821 | for (let profile of profiles) { 822 | for (let message of messages) { 823 | let excludes = message.excludes 824 | if (excludes && excludes.indexOf(profile) !== -1) { 825 | continue 826 | } 827 | 828 | let msg = message.msg 829 | 830 | let accepts = message.accepts 831 | if (accepts && accepts.indexOf(profile) !== -1) { 832 | let result = validate(sanitize(msg), profile) 833 | test.deepEqual(result, [], '\n' + msg) 834 | 835 | continue 836 | } 837 | 838 | let rejects = message.rejects 839 | if (rejects && rejects.indexOf(profile) !== -1) { 840 | let result = validate(sanitize(msg), profile) 841 | test.notDeepEqual(result, [], '\n' + msg) 842 | 843 | continue 844 | } 845 | 846 | let reasons = message.reasons 847 | if (reasons && reasons.get(profile)) { 848 | let reason = reasons.get(profile) 849 | 850 | let result = validate(sanitize(msg), profile) 851 | test.deepEqual(result, reason, '\n' + msg) 852 | 853 | continue 854 | } 855 | 856 | // assume default: profile must pass the test 857 | let result = validate(sanitize(msg), profile) 858 | test.deepEqual(result, [], '\n' + msg) 859 | } 860 | } 861 | 862 | for (let message of messages) { 863 | let msg = message.msg 864 | 865 | let accepts = message.accepts 866 | if (accepts) { 867 | for (let profile of accepts) { 868 | if (profiles.indexOf(profile) === -1) { 869 | let result = validate(sanitize(msg), profile) 870 | test.deepEqual(result, [], '\n' + msg) 871 | } 872 | } 873 | } 874 | 875 | let rejects = message.rejects 876 | if (rejects) { 877 | for (let profile of rejects) { 878 | if (profiles.indexOf(profile) === -1) { 879 | let result = validate(sanitize(msg), profile) 880 | test.notDeepEqual(result, [], '\n' + msg) 881 | } 882 | } 883 | } 884 | 885 | let reasons = message.reasons 886 | if (reasons) { 887 | for (let key2val of reasons) { 888 | let [profile, reason] = [key2val[0], key2val[1]] 889 | if (profiles.indexOf(profile) === -1) { 890 | let result = validate(sanitize(msg), profile) 891 | test.deepEqual(result, reason, '\n' + msg) 892 | } 893 | } 894 | } 895 | } 896 | } 897 | -------------------------------------------------------------------------------- /uninstall.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | 4 | var hooks = path.join(process.cwd(), '..', '..', '.git', 'hooks') 5 | 6 | var dstCommitHook = path.join(hooks, 'commit-msg') 7 | var srcCommitHook = path.relative(hooks, 'commit-msg-hook.js') 8 | 9 | var dstPrepareHook = path.join(hooks, 'prepare-commit-msg') 10 | var srcPrepareHook = path.relative(hooks, 'prepare-commit-msg-hook.js') 11 | 12 | var dstHooks = [dstCommitHook, dstPrepareHook] 13 | var srcHooks = [srcCommitHook, srcPrepareHook] 14 | 15 | for (var i = 0; i < dstHooks.length; ++i) { 16 | var dstHook = dstHooks[i] 17 | var srcHook = srcHooks[i] 18 | 19 | if (fs.existsSync(dstHook) && fs.existsSync(srcHook)) { 20 | var githook = fs.readFileSync(dstHook) 21 | var comhook = fs.readFileSync(srcHook) 22 | 23 | if (githook.toString() === comhook.toString()) { 24 | console.log('Removing the following hook:') 25 | console.log(dstHook) 26 | 27 | fs.unlinkSync(dstHook) 28 | } 29 | } 30 | } 31 | 32 | try { 33 | var options = require('commitplease').getOptions() 34 | 35 | var oldMessagePath = path.join( 36 | process.cwd(), '..', '..', options.oldMessagePath 37 | ) 38 | 39 | fs.unlinkSync(oldMessagePath) 40 | } catch (err) { 41 | if (!/ENOENT/.test(err.message)) { 42 | throw err 43 | } 44 | } 45 | --------------------------------------------------------------------------------