├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .npmignore ├── .sgcrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── Config.ts ├── check.spec.ts ├── cli.js ├── fixtures │ ├── .sgcrc │ ├── .sgcrc.customType │ ├── questions.js │ └── very │ │ └── deep │ │ └── directory │ │ └── .gitikeep ├── helper │ ├── commitMeetsRules.spec.ts │ ├── formatters.js │ ├── getTypeFromName.js │ ├── gitCommitExeca.js │ ├── promptOrInitialCommit.js │ ├── retryCommit.js │ └── sgcPrompt.js ├── questions.js └── rules │ ├── availableRules.js │ └── ruleWarningMessages.js ├── appveyor.yml ├── global.d.ts ├── jest.config.js ├── lib ├── Config.ts ├── check.ts ├── cli.js ├── helpers │ ├── commitMeetsRules.ts │ ├── formatters.js │ ├── getTypeFromName.js │ ├── gitCommitExeca.js │ ├── promptOrInitialCommit.js │ ├── retryCommit.js │ └── sgcPrompt.js ├── index.js ├── questions.js └── rules │ ├── availableRules.js │ └── ruleWarningMessages.js ├── media └── screenshot.gif ├── package-lock.json ├── package.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | }, 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 2018, 17 | sourceType: 'module', 18 | }, 19 | settings: { 20 | 'import/resolver': { 21 | node: { 22 | extensions: ['.js', '.ts'], 23 | }, 24 | }, 25 | }, 26 | overrides: [ 27 | { 28 | files: ['*.ts', '*.tsx'], 29 | rules: { 30 | 'no-dupe-class-members': 'off', 31 | }, 32 | }, 33 | ], 34 | plugins: [ 35 | '@typescript-eslint', 36 | ], 37 | rules: { 38 | '@typescript-eslint/explicit-function-return-type': ['warn', { 39 | allowTypedFunctionExpressions: true, 40 | allowExpressions: true, 41 | }], 42 | 'import/extensions': ['error', 'ignorePackages', { 43 | js: 'never', 44 | ts: 'never', 45 | }], 46 | 'no-shadow-restricted-names': 'off', 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: jpeer 5 | open_collective: sgc 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # modules 2 | node_modules 3 | npm-debug.log 4 | 5 | # generated 6 | dest 7 | .nyc* 8 | coverage 9 | 10 | # macOS 11 | .DS_STORE 12 | 13 | # IDE / Editors 14 | .vscode 15 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib 2 | .nyc_output 3 | coverage 4 | test 5 | -------------------------------------------------------------------------------- /.sgcrc: -------------------------------------------------------------------------------- 1 | { 2 | "scope": false, 3 | "body": true, 4 | "emoji": false, 5 | "delimiter": ":", 6 | "lowercaseTypes": false, 7 | "addScopeSpace": true, 8 | "initialCommit": { 9 | "isEnabled": true, 10 | "emoji": ":tada:", 11 | "message": "Initial commit" 12 | }, 13 | "types": [ 14 | { 15 | "emoji": ":wrench:", 16 | "type": "Chore", 17 | "description": "Changes that affect the build system or external dependencies and moving files", 18 | "argKeys": ["c", "chore"] 19 | }, 20 | { 21 | "emoji": ":construction_worker:", 22 | "type": "CI", 23 | "description": "Changes to our CI configuration files and scripts", 24 | "argKeys": ["ci"] 25 | }, 26 | { 27 | "emoji": ":memo:", 28 | "type": "Docs", 29 | "description": "Documentation only changes", 30 | "argKeys": ["d", "docs"] 31 | }, 32 | { 33 | "emoji": ":sparkles:", 34 | "type": "Feat", 35 | "description": "New feature", 36 | "argKeys": ["f", "feat", "feature"] 37 | }, 38 | { 39 | "emoji": ":bug:", 40 | "type": "Fix", 41 | "description": "Bug fix", 42 | "argKeys": ["fi", "fix", "bug"] 43 | }, 44 | { 45 | "emoji": ":zapr:", 46 | "type": "Perf", 47 | "description": "Code change that improves performance", 48 | "argKeys": ["p", "perf", "performance"] 49 | }, 50 | { 51 | "emoji": ":hammer:", 52 | "type": "Refactor", 53 | "description": "Code change that neither fixes a bug nor adds a feature", 54 | "argKeys": ["r", "refactor"] 55 | }, 56 | { 57 | "emoji": ":art:", 58 | "type": "Style", 59 | "description": "Changes that do not affect the meaning of the code", 60 | "argKeys": ["s", "style"] 61 | }, 62 | { 63 | "emoji": ":white_check_mark:", 64 | "type": "Test", 65 | "description": "Adding missing tests or correcting existing tests", 66 | "argKeys": ["t", "test"] 67 | } 68 | ], 69 | "rules": { 70 | "maxChar": 72, 71 | "minChar": 10, 72 | "endWithDot": false 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: true 3 | dist: trusty 4 | node_js: 5 | - 10 6 | - 12 7 | - 14 8 | script: 9 | - npm run lint 10 | - npm test 11 | notifications: 12 | email: 13 | on_failure: change 14 | on_success: change 15 | after_success: 16 | - npm run coveralls 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3.7.0 - June, 12 2020 2 | 3 | * e81cc87 Feat: check commits by command (closes #88) (#90) (Jan Peer Stöcklmair) 4 | * 1ef051d Test: add test for availableRules .message (JPeer264) 5 | * c741bbc Chore: add funding.yml (Jan Peer Stöcklmair) 6 | * 3bea5b5 Chore: update appveyor node install strategy (JPeer264) 7 | * e175d03 Chore: add local development strategy (JPeer264) 8 | * c7e8cc3 Feat: test (JPeer264) 9 | * ba9b038 Chore: remove yarn from travis (JPeer264) 10 | 11 | 3.6.0 - March, 18 2020 12 | 13 | * 9c30f29 Feat: use autocomplete instead of list for types (closes #83) (JPeer264) 14 | * 1ff0261 Chore: upgrade eslint with ts support (JPeer264) 15 | * c7ee1fc Test: move tests from ava to jest (JPeer264) 16 | * adf6b46 Chore: move to typescript compiler (JPeer264) 17 | * a0473c5 Chore: remove yarn (JPeer264) 18 | 19 | 3.5.2 - March, 04 2020 20 | 21 | * b42d46a Docs: add open collective (JPeer264) 22 | 23 | 3.5.1 - March, 04 2020 24 | 25 | * 95da27b Docs: Fixed erroneous json comments (#81) (Seniru Pasan Indira) 26 | 27 | 3.5.0 - October, 13 2019 28 | 29 | * e453c89 Migrate to travis-ci.com (Jan Peer Stöcklmair) 30 | * 2936a06 Chore: remove installing npm version in appveyor config (JPeer264) 31 | * 13a0fd3 Feat: also bubble up with package.json (cc: #64) (JPeer264) 32 | * 8f85ab1 Chore: remove yarn.lock (JPeer264) 33 | * 0b8f9a5 Feat: bubble up to closest sgcrc (closes #64) (JPeer264) 34 | * 4794873 Chore: update husky | lint-staged strategy (JPeer264) 35 | * 1fc50c0 Chore: drop support of node v6 add support for v10 + v12 (JPeer264) 36 | * 7e27616 Chore: update eslint to latest (JPeer264) 37 | 38 | 3.4.0 - September, 27 2019 39 | 40 | * 09e5d8a Feat: optional space between type and scope (closes #79) (JPeer264) 41 | 42 | 3.3.0 - May, 06 2019 43 | 44 | * c35b175 Feat: add custom delimiter (closes #74) (#80) (Jan Peer Stöcklmair) 45 | * 0435a16 Feat: custom type (closes #31) (#76) (Jan Peer Stöcklmair) 46 | 47 | 3.2.2 - April, 04 2019 48 | 49 | * dc0e5d7 Fix: take formattedMessage if editor is undefined (closes #77) (#78) (Jan Peer Stöcklmair) 50 | 51 | 3.2.1 - March, 15 2019 52 | 53 | * bbc1a17 Fix: target to the correct entrypoint (JPeer264) 54 | 55 | 3.2.0 - March, 11 2019 56 | 57 | * 89de027 Feat: sgc with parameters (closes #70) (#73) (Jan Peer Stöcklmair) 58 | * 786c6d3 Feat: sgc --retry (closes #65) (#69) (Jan Peer Stöcklmair) 59 | 60 | 3.1.1 - December, 14 2018 61 | 62 | * 93ce722 Chore: update inquirer (#72) (Jan Peer Stöcklmair) 63 | * b30c854 Chore: move to babel7 (#68) (Jan Peer Stöcklmair) 64 | 65 | 3.1.0 - October, 15 2018 66 | 67 | * c3de4e5 Feature: Prefer dynamic `sgc.config.js` over static `.sgcrc` (#67) (Stephan Schubert) 68 | * 6631218 Feat: throw on not valid route (#63) (Jan Peer Stöcklmair) 69 | * ff40a4c docs: update rules docs to reflect change. Fixes #61 (#62) (Martin Muzatko) 70 | 71 | 3.0.2 - June, 19 2018 72 | 73 | * c5506fa Fix: call sgc after initCommit No (closes #54) (#59) (Jan Peer Stöcklmair) 74 | 75 | 3.0.1 - January, 24 2018 76 | 77 | * d325a89 Fix: wrong value for initialCommit (#58) (Jan Peer Stöcklmair) 78 | * edbb6d9 Docs: fix initial-commit change (#57) (Jan Peer Stöcklmair) 79 | * a878812 Update README.md (#56) (jy95) 80 | 81 | 3.0.0 - January, 05 2018 82 | 83 | * fb01392 Chore: drop Node v4 (#55) (Jan Peer Stöcklmair) 84 | * c5b5941 Docs: link semantic-release (#52) (Jan Peer Stöcklmair) 85 | * ba91f6a CI: test node version 8 (#50) (Lukas Aichbauer) 86 | * 866cffd Style: use consistent writing style (closes #43) (#48) (Lukas Aichbauer) 87 | * 540af59 Docs: recover changelog (#47) (Jan Peer Stöcklmair) 88 | 89 | 2.3.1 - October, 01 2017 90 | 91 | * f37923b Fix: apply length check on type + scope + message (closes #35) (#42) (Lukas Aichbauer) 92 | * 67892ae Fix: trim trailing spaces (closes #39) (#41) (Lukas Aichbauer) 93 | * 8b8fe39 Docs: Fix typo on installation with yarn (closes #45) (#46) (Guillermo Omar Lopez Lopez) 94 | 95 | 2.3.0 - September, 11 2017 96 | 97 | * 5befc1b Docs: for lowercaseTypes (closes #34) (#44) (Lukas Aichbauer) 98 | * b28add9 Feat: show commit msg length (closes #36) (#40) (Lukas Aichbauer) 99 | * 270d752 Docs: add usage with semantic-release (#38) (jy95) 100 | 101 | 2.2.0 - August, 10 2017 102 | 103 | * 216fd4d Feat: lowercase types (closes #32) (#33) (Lukas Aichbauer) 104 | 105 | 2.1.0 - May, 15 2017 106 | 107 | * 3ca438c Feat: init commit (#28) (Lukas Aichbauer) 108 | * 6cc8d7d Docs: update screenshot to 2.0.0 (JPeer264) 109 | 110 | 2.0.1 - May, 07 2017 111 | 112 | * fc739da Docs: improved why (JPeer264) 113 | 114 | 2.0.0 - May, 06 2017 115 | 116 | * c3baf46 CI: change travis email on_success change (JPeer264) 117 | * bf1bcf6 Test: change min-char to max-char (JPeer264) 118 | * 6c245c0 CI: change npm install to yarn (JPeer264) 119 | * 2643477 Refactor: remove one liner ifs (JPeer264) 120 | * 0db6844 Refactor: change function behavior (JPeer264) 121 | * e9805dc Feat: change min/maxChar disable function to -1 (JPeer264) 122 | * a02bd55 Refactor: change function behavior (JPeer264) 123 | * cd322a9 Feat: remove inherit, change readme defaults (JPeer264) 124 | * 31e6578 Refactor: get defaults from our config (JPeer264) 125 | * ed721af Refactor: remove if-condition (JPeer264) 126 | * a1dbbe4 Fix: improve body prompt (JPeer264) 127 | * 6ff0403 Refactor: rename moreInfo to body (JPeer264) 128 | * d2e8b57 Test: add confirm test (JPeer264) 129 | * 1800c8e Fix: remove whitespace in (): (JPeer264) 130 | * 9a374e3 Test: better test description (JPeer264) 131 | * 9cf843c Fix: check for whitespaces in scope (JPeer264) 132 | * b726219 Test: helpers -> helper due to ava does not trigger helper (JPeer264) 133 | * f4806a5 Test: add testcase (JPeer264) 134 | * cfafcc1 Docs: change the emoji default to false (JPeer264) 135 | * 602c769 Feat: add inherit mode (fixes #26) (JPeer264) 136 | * 944d286 Feat: added local config for future contributors (JPeer264) 137 | * 471632a Feat: remove Init type (fixes #25) (JPeer264) 138 | * 88bd97e Fix: change emojies plural to emoji (JPeer264) 139 | * 1ca3d30 Feat: change emoji default to false (JPeer264) 140 | * d59514a :sparkles: Feat: add scope and default values (fixes #18) (JPeer264) 141 | * d150c5e :hammer: Refactor: update param in questions (JPeer264) 142 | * 409e865 :hammer: Refactor: rename promptConfig to questions (JPeer264) 143 | 144 | 1.4.0 - April, 18 2017 145 | 146 | * 039b1d5 :white_check_mark: Test: change to serial tests (JPeer264) 147 | * db6978b :memo: Docs: set emojie default to true (JPeer264) 148 | * 5026299 :wrench: Chore: update is-git-added (JPeer264) 149 | * 9b615eb :white_check_mark: Test: update test to serial (JPeer264) 150 | * ecb1d74 :bug: Fix: change emojies default to true (JPeer264) 151 | * 5b8c15d :white_check_mark: Test: rename typo test (JPeer264) 152 | * 2014e8e :art: Style: change return into one liner (JPeer264) 153 | * fed34f0 :sparkles: Feat: just run sgc if files are really added (JPeer264) 154 | * ab20d9a :wrench: Chore: add is-git-added (JPeer264) 155 | * c005eb0 1.3.2 (JPeer264) 156 | * 0d319bb :bug: Fix: add emojies by default (JPeer264) 157 | * 923e147 Feat: add option to disable emojies (JPeer264) 158 | * 5e29cd4 :wrench: Chore: remove unnecessary async/await (JPeer264) 159 | 160 | 1.3.2 - October, 01 2017 161 | 162 | * e967966 :bug: Fix: change error message, and add stdio for a better output (fixes #21) (JPeer264) 163 | 164 | 1.3.1 - April, 07 2017 165 | 166 | * c03ee1d :sparkles: Feat: add sgc type init for initial commit (rudolfsonjunior) 167 | 168 | 1.3.0 - March, 23 2017 169 | 170 | * 1d57058 :memo: Docs: add appveyor badge (JPeer264) 171 | * dcf46aa :construction_worker: CI: add appveyor (JPeer264) 172 | * c5b5550 :hammer: Refactor: add datetime to testfiles (JPeer264) 173 | * 2fba853 :bug: Fix: works on windows (JPeer264) 174 | * deb1c1a :hammer: Refactor: reorder imports (JPeer264) 175 | * fa7fb37 :white_check_mark: Test: add before and after test (JPeer264) 176 | * c2de1b6 :bug: Fix: change git-utils to is-git-repository (JPeer264) 177 | * 6d70d5f :wrench: Chore: add is-git-repository (JPeer264) 178 | * e0e2258 :sparkles: Feat: fail on non git (JPeer264) 179 | * 316bcbe :wrench: Chore: add git-util, add run-babel in pretest (JPeer264) 180 | 181 | 1.2.1 - March, 23 2017 182 | 183 | * cec8984 :memo: Docs: fix gif path for npm website (rudolfsonjunior) 184 | * 7c90586 :memo: Docs: split usage to installation and usage (JPeer264) 185 | 186 | 1.2.0 - March, 19 2017 187 | 188 | * fbe667f :memo: Docs: add npm description (JPeer264) 189 | * 724f7ee :memo: Docs: add rules into docs (JPeer264) 190 | * 161a972 :art: Style: removed {} from one-line stmt (rudolfsonjunior) 191 | * 3b4c788 :white_check_mark: Test: add test for uncovered lines 44,45 lib/promptConfig (rudolfsonjunior) 192 | * 140bc96 :white_check_mark: Test: add tests for rules/availableRules.js and rules/ruleWarningMessages.js (rudolfsonjunior) 193 | * 3400b63 :bug: Fix: remove Ï character from test/fixtures/.sgrc (rudolfsonjunior) 194 | * 5fccb4e :white_check_mark: Test: change validate functions in questions (rudolfsonjunior) 195 | * 3166dbf :hammer: Refactor: add rule for .sgcrc fixtures (rudolfsonjunior) 196 | * 5fae00e :wrench: Chore: add package object.entries (rudolfsonjunior) 197 | * c44f0c3 :hammer: Refactor: add the ruleWarningMessages (rudolfsonjunior) 198 | * cda32d1 :hammer: Refactor: delete test and lib/rulesConfig.js (rudolfsonjunior) 199 | * 3032d98 :art: Style: rules in .sgrc_default (rudolfsonjunior) 200 | * 2775287 :art: Style: remove trailing comma (rudolfsonjunior) 201 | * 99527bf :white_check_mark: Test: add test for new rules (rudolfsonjunior) 202 | * 67525e2 :sparkles: Feat: add rules max-char, min-char, end-with-dot (rudolfsonjunior) 203 | * f978ec9 :wrench: Chore: add babel-polyfill for async await for tests (rudolfsonjunior) 204 | * 6bbb556 :bug: Fix: remove global sgcrc before test and revert it after (#12) (JPeer264) 205 | * e59ccaa :memo: Docs: add keywords (JPeer264) 206 | 207 | 1.1.0 - March, 18 2017 208 | 209 | * 2c5d4d5 :sparkles: Feat: add -v and -h flags (JPeer264) 210 | * a7ae4df :memo: Docs: add global config description (JPeer264) 211 | * 5aac51f :sparkles: Feat: load global config (JPeer264) 212 | * 8a5a54a :sparkles: Feat: add update-notifier to cli (JPeer264) 213 | * 78f14e5 :wrench: Chore: add update-notifier (JPeer264) 214 | * 610a30d :bug: Fix: add media in npm (JPeer264) 215 | 216 | 1.0.2 - March, 17 2017 217 | 218 | * 69a23f2 :bug: Fix: wrong git commit error message (JPeer264) 219 | * 23b6668 1.0.1 (JPeer264) 220 | * cf3ae13 :bug: Fix: run babel as prepublish (JPeer264) 221 | * 57a3dbc :bug: Fix: run babel as prepublish (JPeer264) 222 | * 3f5a2af 1.0.0 (JPeer264) 223 | * 67e5a6b :wrench: Chore: update npm scripts (JPeer264) 224 | * 7af4710 :bug: Fix: shorten commit messages (#7) (JPeer264) 225 | * 0da149a :bug: Fix: provided a choices param (rudolfsonjunior) 226 | * 965ec99 :white_check_mark: Test: changed config to getConfig direct (rudolfsonjunior) 227 | * d176c8f :hammer: Refactor: changed config to import getConfig directly (rudolfsonjunior) 228 | * e4284b3 :white_check_mark: Test: update test for promptConfig (rudolfsonjunior) 229 | * 1d5b0de :wrench: Chore: remove unused bdd-stdin dependency (rudolfsonjunior) 230 | * a572ee2 :hammer: Refactor: change promptConfig from export default to named exports (rudolfsonjunior) 231 | * d6e6076 :hammer: Refactor: move prompt from lib/promptConfig to lib/cli (rudolfsonjunior) 232 | * 3c17c1d :bug: Fix: change includes for node 4 support (rudolfsonjunior) 233 | * 4e5408d :white_check_mark: Test: correct test choices generated from .sgcrc_default (rudolfsonjunior) 234 | * 4352f3a :white_check_mark: Test: added all tests for prompt, delete test/sgcPromt and add test/promptConfig (rudolfsonjunior) 235 | * def9174 :wrench: Chore: add bdd-stdin for testing (rudolfsonjunior) 236 | * 0cb3060 :hammer: Refactor: delete sgcPromt.js and add promptConfig.js (rudolfsonjunior) 237 | * 9aa9c70 :hammer: Refactor: use new promtConfig file (rudolfsonjunior) 238 | * 551da9b :bug: Fix: remove strings in commit message (JPeer264) 239 | * 76917ab :memo: Docs: add screenshot gif (JPeer264) 240 | * c9a8cdf refactor: simplify validate statement (Daniel Scheffknecht) 241 | * 11a26e0 feat: make commit messages mandatory (Daniel Scheffknecht) 242 | * be60087 Update: prefix to type (closes #4) (JPeer264) 243 | * c56c0be :hammer: Refactor: changed path.join (rudolfsonjunior) 244 | * 564f4f4 :hammer: Refactor: skip test (rudolfsonjunior) 245 | * 67e778e :hammer: Refactor: changed test promise to sync (rudolfsonjunior) 246 | * d231010 :wrench: Chore: change lint - tests to test (rudolfsonjunior) 247 | * a7ed0ec :white_check_mark: Test: update test for package.json, fs-extra (rudolfsonjunior) 248 | * ca85dad :wrench: Chore: add fs-extra (rudolfsonjunior) 249 | * bf7665d :bug: Fix: updated stmt, pass with altPath, package.json and .sgcrc_default (rudolfsonjunior) 250 | * 9814eef :white_check_mark: Test: add test for .sgcrc_default, updated test for package.json .sgc (rudolfsonjunior) 251 | * 4de8063 :wrench: Refactor: remove package.json .sgc (rudolfsonjunior) 252 | * 34af0be :hammer: Refactor: change sgc.types.prefix to sgc.types.type (rudolfsonjunior) 253 | * b7f9c79 :hammer: Refactor: delete Add: from types (rudolfsonjunior) 254 | * cdfb5c3 :ok_hand: Fix: add ! to if stmt (rudolfsonjunior) 255 | * 8e18b70 :ok_hand: Code Review: fixed the test types (rudolfsonjunior) 256 | * 718353a :sparkles: Feat: added all types for .sgcrc_default (rudolfsonjunior) 257 | * bc42f43 :bug: Fix: remove ! from first if stmt (rudolfsonjunior) 258 | * d03dca4 Update: add types and why section (JPeer264) 259 | * 756d9bf Feat: spawn commit (JPeer264) 260 | * 273138f Feat: add spawn (JPeer264) 261 | * 57d1631 Chore: add execa (JPeer264) 262 | * 8a8617c :wrench: Chore: changed precommit hook to prepush hook (rudolfsonjunior) 263 | * d2e2779 Add: inquirer prompt (JPeer264) 264 | * 199f9c3 Chore: chalk (JPeer264) 265 | * 25f52ea Add more default values (JPeer264) 266 | * b560890 Update: change name from plural to singular (JPeer264) 267 | * 25a9552 Chore/Fix: precommit (rudolfsonjunior) 268 | * 567b20d Chore: add husky for precommit hook (rudolfsonjunior) 269 | * 5c63fce Add getConfig (JPeer264) 270 | * 0124b5a Add sgcrc_default (JPeer264) 271 | * 11126f1 Add getCofig test (JPeer264) 272 | * 8ae80f0 Add: fixtures files (JPeer264) 273 | * 73b633f Chore: inquirer, json-extra as deps; sgc config added (JPeer264) 274 | 275 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jan Peer Stöcklmair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # semantic-git-commit-cli 2 | 3 | [![Backers on Open Collective](https://opencollective.com/sgc/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/sgc/sponsors/badge.svg)](#sponsors) 4 | [![Build Status](https://travis-ci.com/JPeer264/node-semantic-git-commit-cli.svg?branch=master)](https://travis-ci.com/JPeer264/node-semantic-git-commit-cli) 5 | [![Build status](https://ci.appveyor.com/api/projects/status/t9xwo0r3n2oe5ywf/branch/master?svg=true)](https://ci.appveyor.com/project/JPeer264/node-semantic-git-commit-cli/branch/master) 6 | [![Coverage Status](https://coveralls.io/repos/github/JPeer264/node-semantic-git-commit-cli/badge.svg?branch=master)](https://coveralls.io/github/JPeer264/node-semantic-git-commit-cli?branch=master) 7 | 8 | 9 | > A CLI to keep semantic git commits. With emoji support 😄 👍 10 | 11 | 12 | 13 | ## Why? 14 | 15 | Many projects got different git commit rules. It is hard to remember them all. Usually you start with `git commit -m "`, and then? You have to think about the projects commit guidelines. 16 | 17 | `sgc` will take care of the commit guidelines, so you can focus on the more important stuff: **code** 18 | 19 | ## Installation 20 | 21 | ```sh 22 | $ npm i -g semantic-git-commit-cli 23 | ``` 24 | or 25 | ```sh 26 | $ yarn global add semantic-git-commit-cli 27 | ``` 28 | 29 | ## Usage 30 | 31 | Forget the times when you used `git commit -m "..."`, now just type: 32 | ```sh 33 | $ sgc 34 | ``` 35 | or if you already have an alias for sgc, use following instead: 36 | ```sh 37 | $ semantic-git-commit 38 | ``` 39 | 40 | ### Usage with parameters 41 | 42 | > Note: if any block is added it will get skipped in the questions. If there are still some questions open they will still be asked 43 | 44 | Available parameters: 45 | 46 | - `m` | `message`: Add and skip the message block 47 | - `t` | `type`: Add and skip the type block (this has to be defined in the [types](#types) as `argKey`) 48 | - `s` | `scope`: Add and skip the scope block 49 | 50 | To skip some questions you can add parameters: 51 | 52 | Following: 53 | ```sh 54 | $ sgc -t feat -m some new features 55 | ``` 56 | 57 | Will generate: `Feat: some new features` 58 | 59 | -- 60 | 61 | Following: 62 | ```sh 63 | $ sgc -t feat -s myScope -m some new features 64 | ``` 65 | 66 | Will generate: `Feat(myScope): some new features` 67 | 68 | ### Usage with [semantic-release](https://github.com/semantic-release/semantic-release) 69 | 70 | > Configure sgc for the following [semantic-release options](https://github.com/semantic-release/semantic-release#plugins): `analyzeCommits` and `generateNotes` 71 | 72 | First step, install the following plugins with 73 | ```sh 74 | $ npm install --save-dev sr-commit-analyzer sr-release-notes-generator conventional-changelog-eslint 75 | ``` 76 | 77 | or 78 | 79 | ```sh 80 | $ yarn add -D sr-commit-analyzer sr-release-notes-generator conventional-changelog-eslint 81 | ``` 82 | 83 | Then, create a `release.config.js` file in a `config` folder in the root folder of your project: 84 | ```js 85 | /* eslint-disable no-useless-escape */ 86 | module.exports = { 87 | analyzeCommits: { 88 | preset: 'eslint', 89 | releaseRules: './config/release-rules.js', // optional, only if you want to set up new/modified release rules inside another file 90 | parserOpts: { // optional, only you want to have emoji commit support 91 | headerPattern: /^(?::([\w-]*):)?\s*(\w*):\s*(.*)$/, 92 | headerCorrespondence: [ 93 | 'emoji', 94 | 'tag', 95 | 'message', 96 | ], 97 | }, 98 | }, 99 | generateNotes: { 100 | preset: 'eslint', 101 | parserOpts: { // optional, only you want to have emoji commit support 102 | headerPattern: /^(?::([\w-]*):)?\s*(\w*):\s*(.*)$/, 103 | headerCorrespondence: [ 104 | 'emoji', 105 | 'tag', 106 | 'message', 107 | ], 108 | }, 109 | }, 110 | }; 111 | ``` 112 | 113 | Then, update the `semantic-release ` script to your `package.json` to this : 114 | ```json 115 | "scripts": { 116 | "semantic-release": "semantic-release -e ./config/release.config.js", 117 | } 118 | ``` 119 | 120 | ## Commands 121 | 122 | ### check 123 | 124 | This will check all commits and will fail if your commits do not meet the defined config. 125 | 126 | **Flags** 127 | - `start`: A commit SHA to start, in case you started using `sgc` later of your development 128 | 129 | ```sh 130 | $ sgc check --start 84a1abd 131 | ``` 132 | 133 | ## Config 134 | 135 | > Just create a `.sgcrc` in your project root or you can add everything in your `package.json` with the value `sgc` 136 | 137 | You can even create a global config. Just go to your users home and create a `.sgcrc`. The global config will be triggered if no project configurations are present. 138 | 139 | The order and namings of the commit (this can vary with different settings): 140 | ``` 141 | () 142 | 143 | 144 | ``` 145 | 146 | **Options:** 147 | - [body](#body) 148 | - [scope](#scope) 149 | - [emoji](#emoji) 150 | - [delimiter](#delimiter) 151 | - [lowercaseTypes](#lowercaseTypes) 152 | - [initialCommit](#initialCommit) 153 | - [types](#types) 154 | - [addScopeSpace](#addScopeSpace) 155 | - [rules](#rules) 156 | 157 | ### body 158 | 159 | **Type:** `boolean` 160 | 161 | **Default**: `true` 162 | 163 | Asks if more info (body) should be added. This will open your default editor. 164 | 165 | Example: 166 | ```json 167 | { 168 | "body": false 169 | } 170 | ``` 171 | 172 | ### scope 173 | 174 | **Type:** `boolean` 175 | 176 | **Default:** `false` 177 | 178 | Asks for the scope in parentheses of the commit. 179 | 180 | Example: 181 | ```json 182 | { 183 | "scope": true 184 | } 185 | ``` 186 | 187 | ### emoji 188 | 189 | **Type:** `boolean` 190 | 191 | **Default:** `false` 192 | 193 | A boolean to enable emoji at the beginning of a commit message 194 | 195 | Example: 196 | ```json 197 | { 198 | "emoji": true 199 | } 200 | ``` 201 | 202 | ### delimiter 203 | 204 | **Type:** `string` 205 | 206 | **Default:** `:` 207 | 208 | A string which is the delimiter between the type and the message. 209 | 210 | Example: 211 | ```json 212 | { 213 | "delimiter": ":" 214 | } 215 | ``` 216 | or type specific delimiters, which will overwrite the global one: 217 | ```json5 218 | { 219 | "delimiter": ":", 220 | "types": [ 221 | { 222 | "type": "Feat", 223 | "delimiter": " -" 224 | }, // will generate "Feat - message" 225 | { 226 | "type": "Fix", 227 | } // will generate "Fix: message" 228 | ] 229 | } 230 | ``` 231 | 232 | ### lowercaseTypes 233 | 234 | **Type:** `boolean` 235 | 236 | **Default:** `false` 237 | 238 | A boolean to lowercase types. 239 | 240 | Example: 241 | ```json 242 | { 243 | "lowercaseTypes": true 244 | } 245 | ``` 246 | 247 | ### initialCommit 248 | 249 | **Type:** `object` 250 | 251 | **Default:** 252 | 253 | ```json 254 | { 255 | "initialCommit": { 256 | "isEnabled": true, 257 | "emoji": ":tada:", 258 | "message": "Initial commit" 259 | } 260 | } 261 | ``` 262 | 263 | **Keys:** 264 | 265 | - `isEnabled` - Whether an explicit initial commit should be used for the very first commit 266 | - `emoji` - An emoji which will be appended at the beginning of the commit ([Emoji Cheat Sheet](https://www.webpagefx.com/tools/emoji-cheat-sheet/)) 267 | - `message` - The commit message for the very first commit 268 | 269 | ### types 270 | 271 | > Types will define your git commits. If `types` is not set in your own `.sgcrc`, the `types` of the global [.sgcrc](.sgcrc) 272 | 273 | > Notice: If the `type` is `false` it will let you to manually add the type. This is usefull especially if you have a `prefix` named `SGC-` to reference these as a ticket number for your ticket tool 274 | 275 | **Keys** 276 | 277 | - `type` (`string` or `false`) - This will be your commit convention and will be your start of your commit - e.g.: `Feat:` 278 | - `prefix` (optional) - This option is just valid, if `type` is `false` 279 | - `description` (optional) - The description to explain what your type is about 280 | - `emoji` (optional) - An emoji which will be appended at the beginning of the commit ([Emoji Cheat Sheet](https://www.webpagefx.com/tools/emoji-cheat-sheet/)) 281 | - `argKeys` | Array (optional) - Keys which will be accessed through the `-t` [parameter](#usage-with-parameters) 282 | 283 | The `.sgcrc`: 284 | 285 | ```json 286 | { 287 | "types": [ 288 | { 289 | "emoji": ":sparkles:", 290 | "type": "Feat:", 291 | "description": "Any description to describe the type", 292 | "argKeys": ["f", "feat", "feature"] 293 | } 294 | ] 295 | } 296 | ``` 297 | 298 | or the `package.json`: 299 | 300 | ```json 301 | { 302 | "name": "Your application name", 303 | "version": "1.0.0", 304 | "sgc": { 305 | "types": [ 306 | { 307 | "emoji": ":sparkles:", 308 | "type": "Feat:", 309 | "description": "Any description to describe the type", 310 | "argKeys": ["f", "feat", "feature"] 311 | } 312 | ] 313 | } 314 | } 315 | ``` 316 | 317 | ### addScopeSpace 318 | 319 | **Type:** `boolean` 320 | 321 | **Default:** `true` 322 | 323 | > This rule just affects the commit message if `scope` is set to true 324 | 325 | If set to `false` there will be no space between `` and `()` 326 | 327 | Example: 328 | ```json 329 | { 330 | "addScopeSpace": false 331 | } 332 | ``` 333 | 334 | 335 | ### rules 336 | 337 | Available rules: 338 | 339 | - [maxChar](#maxChar) 340 | - [minChar](#minChar) 341 | - [endWithDot](#endWithDot) 342 | 343 | #### maxChar 344 | 345 | **Type:** `number` 346 | 347 | **Default:** `72` 348 | 349 | If a number is set, it will not allow to commit messages **more than** the given number. If it is set to `-1` the rule is deactivated 350 | 351 | Example: 352 | ```json 353 | { 354 | "rules": { 355 | "maxChar": -1 356 | } 357 | } 358 | ``` 359 | 360 | #### minChar 361 | 362 | **Type:** `number` 363 | 364 | **Default:** `10` 365 | 366 | If a number is set, it will not allow to commit messages **less than** the given number. If it is set to `-1` the rule is deactivated 367 | 368 | Example: 369 | ```json 370 | { 371 | "rules": { 372 | "minChar": -1 373 | } 374 | } 375 | ``` 376 | 377 | #### endWithDot 378 | 379 | **Type:** `boolean` 380 | 381 | **Default:** `true` 382 | 383 | If it is set to false, it will not allow to commit messages with a dot at the 384 | 385 | Example: 386 | ```json 387 | { 388 | "rules": { 389 | "endWithDot": false 390 | } 391 | } 392 | ``` 393 | -------------------------------------------------------------------------------- /__tests__/Config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import * as json from 'json-extra'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | 6 | import Config from '../lib/Config'; 7 | 8 | const cwd = process.cwd(); 9 | const homedir = os.homedir(); 10 | const fixtures = path.join(cwd, '__tests__', 'fixtures'); 11 | const date = new Date(); 12 | const datetime = date.toISOString().slice(0, 10); 13 | const randomString = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 4); 14 | 15 | let globalExist = false; 16 | 17 | // rename global .sgcrc 18 | beforeAll(() => { 19 | // rename global sgcrc 20 | if (fs.existsSync(path.join(homedir, '.sgcrc'))) { 21 | globalExist = true; 22 | fs.renameSync(path.join(homedir, '.sgcrc'), path.join(homedir, `.sgcrc.${randomString}-${datetime}.back`)); 23 | } 24 | 25 | // rename local sgcrc 26 | fs.renameSync(path.join(cwd, '.sgcrc'), path.join(cwd, '.sgcrc_default')); 27 | }); 28 | 29 | afterAll(() => { 30 | // rename global sgrc 31 | if (globalExist) { 32 | fs.renameSync(path.join(homedir, `.sgcrc.${randomString}-${datetime}.back`), path.join(homedir, '.sgcrc')); 33 | } 34 | 35 | // rename local sgcrc 36 | fs.renameSync(path.join(cwd, '.sgcrc_default'), path.join(cwd, '.sgcrc')); 37 | }); 38 | 39 | it('read config from a specific path', () => { 40 | expect(new Config(fixtures).config).toEqual(json.readToObjSync(path.join(fixtures, '.sgcrc'))); 41 | }); 42 | 43 | it('read config from a .sgcrc_default', () => { 44 | const globalConfig = json.readToObjSync(path.join(cwd, '.sgcrc_default')); 45 | 46 | expect(new Config().config).toEqual(globalConfig); 47 | }); 48 | 49 | it('read config from package.json', () => { 50 | const sgcrc = json.readToObjSync(path.join(fixtures, '.sgcrc')); 51 | const packageJson = json.readToObjSync(path.join(cwd, 'package.json')); 52 | 53 | packageJson.sgc = sgcrc; 54 | 55 | // manipulate local package 56 | fs.renameSync(path.join(cwd, 'package.json'), path.join(cwd, `package.json.${randomString}-${datetime}.back`)); 57 | fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify(packageJson)); 58 | 59 | const { config } = new Config(); 60 | 61 | // revert local package 62 | fs.removeSync(path.join(cwd, 'package.json')); 63 | fs.renameSync(path.join(cwd, `package.json.${randomString}-${datetime}.back`), path.join(cwd, 'package.json')); 64 | 65 | expect(config).toEqual(sgcrc); 66 | }); 67 | 68 | it('read global config', () => { 69 | const sgcrc = json.readToObjSync(path.join(fixtures, '.sgcrc')); 70 | 71 | fs.writeFileSync(path.join(homedir, '.sgcrc'), JSON.stringify(sgcrc)); 72 | expect(new Config().config).toEqual(sgcrc); 73 | fs.removeSync(path.join(homedir, '.sgcrc')); 74 | }); 75 | 76 | it('read local config from `sgc.config.js`', () => { 77 | const sgcrc = json.readToObjSync(path.join(fixtures, '.sgcrc')); 78 | 79 | fs.writeFileSync(path.join(cwd, 'sgc.config.js'), `module.exports = (${JSON.stringify(sgcrc)})`); 80 | expect(new Config().config).toEqual(sgcrc); 81 | fs.removeSync(path.join(cwd, 'sgc.config.js')); 82 | }); 83 | 84 | it('read global config from `sgc.config.js`', () => { 85 | const sgcrc = json.readToObjSync(path.join(fixtures, '.sgcrc')); 86 | 87 | fs.writeFileSync(path.join(homedir, 'sgc.config.js'), `module.exports = (${JSON.stringify(sgcrc)})`); 88 | expect(new Config().config).toEqual(sgcrc); 89 | fs.removeSync(path.join(homedir, 'sgc.config.js')); 90 | }); 91 | 92 | it('read a .sgcrc_default from a deep nested cwd', () => { 93 | const deepCwd = path.join(fixtures, 'very', 'deep', 'directory'); 94 | const fixturesConfig = json.readToObjSync(path.join(fixtures, '.sgcrc')); 95 | 96 | expect(new Config(deepCwd).config).toEqual(fixturesConfig); 97 | }); 98 | -------------------------------------------------------------------------------- /__tests__/check.spec.ts: -------------------------------------------------------------------------------- 1 | import gitCommitRange from 'git-commit-range'; 2 | import chalk from 'chalk'; 3 | 4 | import check from '../lib/check'; 5 | 6 | jest.mock('git-commit-range', jest.fn); 7 | 8 | const gitCommitRangeMock = gitCommitRange as jest.MockedFunction; 9 | 10 | describe('check', () => { 11 | beforeEach(() => { 12 | global.process.exit = jest.fn() as any; 13 | global.console.error = jest.fn() as any; 14 | 15 | chalk.level = 0; 16 | }); 17 | 18 | it('should have valid commits', () => { 19 | gitCommitRangeMock.mockReturnValue([ 20 | 'Feat: should be valid', 21 | ]); 22 | 23 | check(); 24 | 25 | expect(process.exit).toBeCalledTimes(1); 26 | expect(process.exit).toBeCalledWith(0); 27 | 28 | check(); 29 | 30 | expect(process.exit).toBeCalledTimes(2); 31 | expect(process.exit).toBeCalledWith(0); 32 | }); 33 | 34 | it('should have valid initial commits', () => { 35 | gitCommitRangeMock.mockReturnValue([ 36 | 'Feat: should be valid', 37 | 'Initial commit', 38 | ]); 39 | 40 | check(); 41 | 42 | expect(process.exit).toBeCalledTimes(1); 43 | expect(process.exit).toBeCalledWith(0); 44 | }); 45 | 46 | it('should have invalid commits', () => { 47 | gitCommitRangeMock.mockReturnValue([ 48 | 'NotValid : should be valid', 49 | ]); 50 | 51 | check(); 52 | 53 | expect(process.exit).toBeCalledTimes(1); 54 | expect(process.exit).toBeCalledWith(1); 55 | // eslint-disable-next-line no-console 56 | expect(console.error).toBeCalledWith('\nNotVali\nNotValid : should be valid'); 57 | 58 | check(); 59 | 60 | expect(process.exit).toBeCalledTimes(2); 61 | expect(process.exit).toBeCalledWith(1); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /__tests__/cli.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import isAdded from 'is-git-added'; 3 | import isGit from 'is-git-repository'; 4 | import commitCount from 'git-commit-count'; 5 | 6 | import cli from '../lib/cli'; 7 | import sgcPrompt from '../lib/helpers/sgcPrompt'; 8 | import retryCommit from '../lib/helpers/retryCommit'; 9 | import promptOrInitialCommit from '../lib/helpers/promptOrInitialCommit'; 10 | import Config from '../lib/Config'; 11 | 12 | import pkg from '../package.json'; 13 | 14 | jest.mock('is-git-added'); 15 | jest.mock('is-git-repository'); 16 | jest.mock('git-commit-count'); 17 | jest.mock('../lib/helpers/sgcPrompt'); 18 | jest.mock('../lib/helpers/retryCommit'); 19 | jest.mock('../lib/helpers/promptOrInitialCommit'); 20 | 21 | beforeEach(() => { 22 | sgcPrompt.mockReset(); 23 | retryCommit.mockReset(); 24 | promptOrInitialCommit.mockReset(); 25 | console.log = jest.fn(); 26 | console.error = jest.fn(); 27 | }); 28 | 29 | it('should print the right version', () => { 30 | cli({ v: true }); 31 | 32 | expect(console.log).toHaveBeenCalledWith(`v${pkg.version}`); 33 | }); 34 | 35 | it('should fail on non git repository', async () => { 36 | isGit.mockReturnValue(false); 37 | cli(); 38 | 39 | expect(console.error).toHaveBeenCalledWith('fatal: Not a git repository (or any of the parent directories): .git'); 40 | }); 41 | 42 | it('should fail on git repos where nothing is added', async () => { 43 | isGit.mockReturnValue(true); 44 | isAdded.mockReturnValue(false); 45 | cli(); 46 | 47 | expect(console.error).toHaveBeenCalledWith(chalk.red('Please', chalk.bold('git add'), 'some files first before you commit.')); 48 | }); 49 | 50 | it('should retry commit', async () => { 51 | isGit.mockReturnValue(true); 52 | isAdded.mockReturnValue(true); 53 | await cli({ r: true }); 54 | 55 | expect(retryCommit).toBeCalledTimes(1); 56 | }); 57 | 58 | it('should prompt init', async () => { 59 | isGit.mockReturnValue(true); 60 | isAdded.mockReturnValue(true); 61 | commitCount.mockReturnValue(0); 62 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ initialCommit: { isEnabled: true } }); 63 | await cli(); 64 | 65 | expect(promptOrInitialCommit).toBeCalledTimes(1); 66 | }); 67 | 68 | it('should not prompt init', async () => { 69 | isGit.mockReturnValue(true); 70 | isAdded.mockReturnValue(true); 71 | commitCount.mockReturnValue(0); 72 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ initialCommit: { isEnabled: false } }); 73 | await cli(); 74 | 75 | expect(promptOrInitialCommit).not.toBeCalled(); 76 | expect(sgcPrompt).toBeCalledTimes(1); 77 | }); 78 | -------------------------------------------------------------------------------- /__tests__/fixtures/.sgcrc: -------------------------------------------------------------------------------- 1 | { 2 | "scope": false, 3 | "body": true, 4 | "emoji": false, 5 | "delimiter": ":", 6 | "lowercaseTypes": false, 7 | "addScopeSpace": true, 8 | "initialCommit": { 9 | "isEnabled": true, 10 | "emoji": ":tada:", 11 | "message": "Initial commit" 12 | }, 13 | "types": [ 14 | { 15 | "emoji": ":emo:", 16 | "type": "Add:", 17 | "description": "Files added" 18 | } 19 | ], 20 | "rules": { 21 | "maxChar": 72, 22 | "minChar": 10, 23 | "endWithDot": false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /__tests__/fixtures/.sgcrc.customType: -------------------------------------------------------------------------------- 1 | { 2 | "scope": false, 3 | "body": true, 4 | "emoji": false, 5 | "lowercaseTypes": false, 6 | "initialCommit": { 7 | "isEnabled": true, 8 | "emoji": ":tada:", 9 | "message": "Initial commit" 10 | }, 11 | "types": [ 12 | { 13 | "emoji": ":wrench:", 14 | "type": false, 15 | "description": "A custom type with prefix", 16 | "prefix": "SGC-", 17 | "argKeys": ["cu", "custom"] 18 | }, 19 | { 20 | "emoji": ":wrench:", 21 | "type": false, 22 | "description": "Another custom type with prefix", 23 | "prefix": "SGGC-" 24 | } 25 | ], 26 | "rules": { 27 | "maxChar": 72, 28 | "minChar": 10, 29 | "endWithDot": false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /__tests__/fixtures/questions.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | const withEmoji = [ 4 | { 5 | name: `${chalk.bold('Add:')} Files added`, 6 | value: ':emo: Add:', 7 | key: [], 8 | }, 9 | ]; 10 | 11 | const withoutEmoji = [ 12 | { 13 | name: `${chalk.bold('Add:')} Files added`, 14 | value: 'Add:', 15 | key: [], 16 | }, 17 | ]; 18 | 19 | export { 20 | withEmoji, 21 | withoutEmoji, 22 | }; 23 | -------------------------------------------------------------------------------- /__tests__/fixtures/very/deep/directory/.gitikeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JPeer264/node-semantic-git-commit-cli/9e143576c0a07944a6fe1b56e84f2ab3d898c6fd/__tests__/fixtures/very/deep/directory/.gitikeep -------------------------------------------------------------------------------- /__tests__/helper/commitMeetsRules.spec.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../lib/Config'; 2 | import commitMeetsRules from '../../lib/helpers/commitMeetsRules'; 3 | 4 | describe('commitMeetsRules', () => { 5 | it('should have one of the types', () => { 6 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 7 | types: [ 8 | { type: 'Chore' }, 9 | ], 10 | }); 11 | 12 | expect(commitMeetsRules('Feat: false')).toBe(false); 13 | expect(commitMeetsRules('Chore: true')).toBe(true); 14 | expect(commitMeetsRules('Chore : true')).toBe(false); 15 | }); 16 | 17 | it('should have one of the types 2', () => { 18 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 19 | lowercaseTypes: true, 20 | types: [ 21 | { type: 'Chore' }, 22 | ], 23 | }); 24 | 25 | expect(commitMeetsRules('Feat: false')).toBe(false); 26 | expect(commitMeetsRules('feat: false')).toBe(false); 27 | expect(commitMeetsRules('chore: true')).toBe(true); 28 | expect(commitMeetsRules('Chore: true')).toBe(false); 29 | expect(commitMeetsRules('chore : true')).toBe(false); 30 | }); 31 | 32 | it('should have one of the types with different delimiter', () => { 33 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 34 | delimiter: ' -', 35 | types: [ 36 | { type: 'Chore' }, 37 | { type: 'Fix' }, 38 | ], 39 | }); 40 | 41 | expect(commitMeetsRules('Feat - false')).toBe(false); 42 | expect(commitMeetsRules('Fix - false')).toBe(true); 43 | expect(commitMeetsRules('Chore - true')).toBe(true); 44 | expect(commitMeetsRules('Chore : true')).toBe(false); 45 | }); 46 | 47 | it('should not have scope', () => { 48 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 49 | scope: false, 50 | types: [ 51 | { type: 'Feat' }, 52 | ], 53 | }); 54 | 55 | expect(commitMeetsRules('Feat(scope): Test')).toBe(false); 56 | expect(commitMeetsRules('Feat (scope): Test')).toBe(false); 57 | expect(commitMeetsRules('Feat (scope) : Test')).toBe(false); 58 | expect(commitMeetsRules('Feat: Test')).toBe(true); 59 | expect(commitMeetsRules('Feat: Test (scope at the end)')).toBe(true); 60 | expect(commitMeetsRules('Feat : Test')).toBe(false); 61 | expect(commitMeetsRules('Feat: Test ')).toBe(true); 62 | expect(commitMeetsRules('Feat : Test')).toBe(false); 63 | }); 64 | 65 | it('should have optional scope', () => { 66 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 67 | scope: true, 68 | types: [ 69 | { type: 'Feat' }, 70 | ], 71 | }); 72 | 73 | expect(commitMeetsRules('Feat(scope): Test')).toBe(true); 74 | expect(commitMeetsRules('Feat (scope): Test')).toBe(false); 75 | expect(commitMeetsRules('Feat (scope) : Test')).toBe(false); 76 | expect(commitMeetsRules('Feat: Test')).toBe(true); 77 | expect(commitMeetsRules('Feat : Test')).toBe(false); 78 | expect(commitMeetsRules('Feat: Test ')).toBe(true); 79 | expect(commitMeetsRules('Feat : Test')).toBe(false); 80 | }); 81 | 82 | it('should have optional scope with scopespace', () => { 83 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 84 | scope: true, 85 | addScopeSpace: true, 86 | types: [ 87 | { type: 'Feat' }, 88 | ], 89 | }); 90 | 91 | expect(commitMeetsRules('Feat(scope): Test')).toBe(false); 92 | expect(commitMeetsRules('Feat (scope): Test')).toBe(true); 93 | expect(commitMeetsRules('Feat (scope) : Test')).toBe(false); 94 | expect(commitMeetsRules('Feat: Test')).toBe(true); 95 | expect(commitMeetsRules('Feat : Test')).toBe(false); 96 | expect(commitMeetsRules('Feat: Test ')).toBe(true); 97 | expect(commitMeetsRules('Feat : Test')).toBe(false); 98 | }); 99 | 100 | it('should have dot ending', () => { 101 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 102 | rules: { 103 | endWithDot: true, 104 | }, 105 | types: [ 106 | { type: 'Feat' }, 107 | ], 108 | }); 109 | 110 | expect(commitMeetsRules('Feat: Test.')).toBe(true); 111 | expect(commitMeetsRules('Feat: Test')).toBe(false); 112 | expect(commitMeetsRules('Feat : Test.')).toBe(false); 113 | expect(commitMeetsRules('Feat : Test')).toBe(false); 114 | }); 115 | 116 | it('should have no dot ending', () => { 117 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 118 | rules: { 119 | endWithDot: false, 120 | }, 121 | types: [ 122 | { type: 'Feat' }, 123 | ], 124 | }); 125 | 126 | expect(commitMeetsRules('Feat: Test')).toBe(true); 127 | expect(commitMeetsRules('Feat: Test.')).toBe(false); 128 | expect(commitMeetsRules('Feat : Test.')).toBe(false); 129 | expect(commitMeetsRules('Feat : Test')).toBe(false); 130 | }); 131 | 132 | it('should have correct length', () => { 133 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 134 | rules: { 135 | maxChar: 10, 136 | minChar: 8, 137 | }, 138 | types: [ 139 | { type: 'Feat' }, 140 | ], 141 | }); 142 | 143 | expect(commitMeetsRules('Feat: T')).toBe(false); 144 | expect(commitMeetsRules('Feat: Te')).toBe(true); 145 | expect(commitMeetsRules('Feat: Tes')).toBe(true); 146 | expect(commitMeetsRules('Feat: Test')).toBe(true); 147 | expect(commitMeetsRules('Feat: Test1')).toBe(false); 148 | }); 149 | 150 | it('should have no length', () => { 151 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 152 | types: [ 153 | { type: 'Feat' }, 154 | ], 155 | }); 156 | 157 | expect(commitMeetsRules('Feat: T')).toBe(true); 158 | expect(commitMeetsRules('Feat: Te')).toBe(true); 159 | expect(commitMeetsRules('Feat: Tes')).toBe(true); 160 | expect(commitMeetsRules('Feat: Test')).toBe(true); 161 | expect(commitMeetsRules('Feat: Test1')).toBe(true); 162 | }); 163 | 164 | it('should have body', () => { 165 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 166 | body: true, 167 | types: [ 168 | { type: 'Feat' }, 169 | ], 170 | }); 171 | 172 | expect(commitMeetsRules('Chore: T\n\nmy body in here')).toBe(false); 173 | expect(commitMeetsRules('Feat: T\n\nmy body in here')).toBe(true); 174 | expect(commitMeetsRules('Feat: T\ninvalid body in here')).toBe(false); 175 | }); 176 | 177 | it('should have initial commit', () => { 178 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 179 | initialCommit: { 180 | isEnabled: true, 181 | message: 'initial commit', 182 | }, 183 | }); 184 | 185 | expect(commitMeetsRules('initial commit')).toBe(true); 186 | expect(commitMeetsRules('Initial commit')).toBe(false); 187 | 188 | jest.spyOn(Config.prototype, 'config', 'get').mockReturnValue({ 189 | initialCommit: { 190 | isEnabled: false, 191 | message: 'initial commit', 192 | }, 193 | }); 194 | 195 | expect(commitMeetsRules('initial commit')).toBe(false); 196 | expect(commitMeetsRules('Initial commit')).toBe(false); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /__tests__/helper/formatters.js: -------------------------------------------------------------------------------- 1 | import { formatScope, combineTypeScope, formatMessage } from '../../lib/helpers/formatters'; 2 | 3 | it('FORMATSCOPE | should format scope correctly', () => { 4 | const scope = formatScope(); 5 | 6 | expect(scope).toBe(''); 7 | }); 8 | 9 | it('FORMATSCOPE | should trim scope', () => { 10 | const scope = formatScope(' te st '); 11 | 12 | expect(scope).toBe('(te st)'); 13 | }); 14 | 15 | it('COMBINETYPESCOPE | should combine type and scope correctly', () => { 16 | const typeScope = combineTypeScope('myType', 'myScope'); 17 | 18 | expect(typeScope).toBe('myType (myScope):'); 19 | }); 20 | 21 | it('COMBINETYPESCOPE | should combine type and empty scope', () => { 22 | const typeScope = combineTypeScope('myType'); 23 | 24 | expect(typeScope).toBe('myType:'); 25 | }); 26 | 27 | it('COMBINETYPESCOPE | should combine type with : and scope', () => { 28 | const typeScope = combineTypeScope('myType:', 'myScope'); 29 | 30 | expect(typeScope).toBe('myType (myScope):'); 31 | }); 32 | 33 | it('COMBINETYPESCOPE | should combine type with another delimiter', () => { 34 | const typeScope = combineTypeScope('myType:', 'myScope', ' - '); 35 | const typeScope2 = combineTypeScope('myType:', 'myScope', ' ='); 36 | const typeScope3 = combineTypeScope('myType', 'myScope', ' -'); 37 | 38 | expect(typeScope).toBe('myType (myScope) -'); 39 | expect(typeScope2).toBe('myType (myScope) ='); 40 | expect(typeScope3).toBe('myType (myScope) -'); 41 | }); 42 | 43 | it('COMBINETYPESCOPE | should combine type with another delimiter but with no space between type | #79', () => { 44 | const typeScope = combineTypeScope('myType:', 'myScope', ' - '); 45 | const typeScope2 = combineTypeScope('myType:', 'myScope', ' =', false); 46 | const typeScope3 = combineTypeScope('myType', 'myScope', undefined, false); 47 | const typeNoScope = combineTypeScope('myType', undefined, undefined, false); 48 | const typeNoScope2 = combineTypeScope('myType', undefined, undefined, true); 49 | 50 | expect(typeScope).toBe('myType (myScope) -'); 51 | expect(typeScope2).toBe('myType(myScope) ='); 52 | expect(typeScope3).toBe('myType(myScope):'); 53 | expect(typeNoScope).toBe('myType:'); 54 | expect(typeNoScope2).toBe('myType:'); 55 | }); 56 | 57 | it('FORMATMESSAGE | should format message', () => { 58 | const message = formatMessage({ 59 | type: 'myType', 60 | message: ' something ', 61 | }); 62 | 63 | expect(message).toBe('myType: something'); 64 | }); 65 | 66 | it('FORMATMESSAGE | should format message with customType', () => { 67 | const message = formatMessage({ 68 | message: ' something ', 69 | customType: 'custom', 70 | }); 71 | 72 | expect(message).toBe('custom: something'); 73 | }); 74 | 75 | it('FORMATMESSAGE | should format with scope', () => { 76 | const message = formatMessage({ 77 | type: 'myType', 78 | scope: 'myScope', 79 | message: ' something ', 80 | }); 81 | 82 | expect(message).toBe('myType (myScope): something'); 83 | }); 84 | 85 | it('FORMATMESSAGE | should format message with body', () => { 86 | const message = formatMessage({ 87 | type: 'myType', 88 | scope: 'myScope', 89 | body: true, 90 | editor: 'take this', 91 | }); 92 | 93 | expect(message).toBe('take this'); 94 | }); 95 | 96 | it('FORMATMESSAGE | should format message with argv overrides', () => { 97 | const message = formatMessage( 98 | { 99 | type: 'myType', 100 | scope: 'myScope', 101 | message: 'message', 102 | }, 103 | { 104 | type: 'overwrite', 105 | }, 106 | ); 107 | 108 | expect(message).toBe('overwrite (myScope): message'); 109 | }); 110 | 111 | it('FORMATMESSAGE | should format message with more argv overrides', () => { 112 | const message = formatMessage( 113 | { 114 | type: 'myType', 115 | scope: 'myScope', 116 | message: 'message', 117 | }, 118 | { 119 | type: 'myTypeOverwrite', 120 | scope: 'myScopeOverwrite', 121 | message: 'messageOverwrite', 122 | }, 123 | ); 124 | 125 | expect(message).toBe('myTypeOverwrite (myScopeOverwrite): messageOverwrite'); 126 | }); 127 | 128 | it('FORMATMESSAGE | should format when editor is undefined but body is set to true', () => { 129 | const message = formatMessage( 130 | { 131 | type: 'myType', 132 | scope: 'myScope', 133 | message: 'message', 134 | body: true, 135 | }, 136 | ); 137 | 138 | expect(message).toBe('myType (myScope): message'); 139 | }); 140 | 141 | it('FORMATMESSAGE | should format when editor is undefined but body is set to true, and no scopespace', () => { 142 | const message = formatMessage( 143 | { 144 | type: 'myType', 145 | scope: 'myScope', 146 | message: 'message', 147 | body: true, 148 | }, 149 | undefined, 150 | { 151 | addScopeSpace: false, 152 | }, 153 | ); 154 | 155 | expect(message).toBe('myType(myScope): message'); 156 | }); 157 | 158 | it('FORMATMESSAGE | should take editor when editor is not undefined and body is set to true', () => { 159 | const message = formatMessage( 160 | { 161 | type: 'myType', 162 | scope: 'myScope', 163 | message: 'message', 164 | body: true, 165 | editor: 'take this', 166 | }, 167 | ); 168 | 169 | expect(message).toBe('take this'); 170 | }); 171 | 172 | it('FORMATMESSAGE | should format when editor is not undefined and body is set to false', () => { 173 | const message = formatMessage( 174 | { 175 | type: 'myType', 176 | scope: 'myScope', 177 | message: 'message', 178 | body: false, 179 | editor: 'take this', 180 | }, 181 | ); 182 | 183 | expect(message).toBe('myType (myScope): message'); 184 | }); 185 | 186 | it('FORMATMESSAGE | should format with a delimiter', () => { 187 | const message = formatMessage( 188 | { 189 | type: 'myType', 190 | scope: 'myScope', 191 | message: 'message', 192 | body: false, 193 | editor: 'take this', 194 | }, 195 | undefined, 196 | { 197 | delimiter: ' -', 198 | }, 199 | ); 200 | 201 | expect(message).toBe('myType (myScope) - message'); 202 | }); 203 | 204 | it('FORMATMESSAGE | should format with a type specific delimiter', () => { 205 | const message = formatMessage( 206 | { 207 | type: 'myType', 208 | scope: 'myScope', 209 | message: 'message', 210 | body: false, 211 | editor: 'take this', 212 | }, 213 | undefined, 214 | { 215 | delimiter: ' -', 216 | types: [ 217 | { type: 'myType', delimiter: '---' }, 218 | ], 219 | }, 220 | ); 221 | 222 | expect(message).toBe('myType (myScope)--- message'); 223 | }); 224 | -------------------------------------------------------------------------------- /__tests__/helper/getTypeFromName.js: -------------------------------------------------------------------------------- 1 | import getTypeFromName from '../../lib/helpers/getTypeFromName'; 2 | 3 | it('accept empty configs', () => { 4 | expect(getTypeFromName()).toBe(null); 5 | expect(getTypeFromName({})).toBe(null); 6 | expect(getTypeFromName({ types: [] })).toBe(null); 7 | }); 8 | 9 | it('cannot find name', () => { 10 | const config = { types: [{ type: 'find' }] }; 11 | 12 | expect(getTypeFromName(config, 'findme')).toBe(null); 13 | }); 14 | 15 | it('find name', () => { 16 | const config = { types: [{ type: 'findme' }] }; 17 | 18 | expect(getTypeFromName(config, 'findme')).toEqual({ type: 'findme' }); 19 | }); 20 | -------------------------------------------------------------------------------- /__tests__/helper/gitCommitExeca.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | import execa from 'execa'; 4 | import tempDir from 'temp-dir'; 5 | 6 | import gitCommitExeca from '../../lib/helpers/gitCommitExeca'; 7 | 8 | const filename = 'testretry'; 9 | const sgcTempDir = path.join(tempDir, 'sgc'); 10 | const pathToRetryFile = path.join(sgcTempDir, `${filename}.txt`); 11 | 12 | jest.mock('execa'); 13 | 14 | beforeEach(() => { 15 | fs.removeSync(pathToRetryFile); 16 | }); 17 | 18 | it('commit should fail', async () => { 19 | execa.mockReturnValue(Promise.reject()); 20 | 21 | const isOk = await gitCommitExeca('hello', filename); 22 | const file = await fs.readFile(pathToRetryFile, 'utf8'); 23 | 24 | expect(isOk).toBe(false); 25 | expect(file).toBe('hello'); 26 | }); 27 | 28 | it('commit should pass', async () => { 29 | execa.mockReturnValue(Promise.resolve()); 30 | 31 | const isOk = await gitCommitExeca('hello', filename); 32 | 33 | expect(isOk).toBe(true); 34 | expect(await fs.exists(pathToRetryFile)).toBe(false); 35 | }); 36 | -------------------------------------------------------------------------------- /__tests__/helper/promptOrInitialCommit.js: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | import inquirer from 'inquirer'; 3 | 4 | import gitCommitExeca from '../../lib/helpers/gitCommitExeca'; 5 | import sgcPrompt from '../../lib/helpers/sgcPrompt'; 6 | import promptOrInitialCommit from '../../lib/helpers/promptOrInitialCommit'; 7 | 8 | stub(inquirer, 'prompt'); 9 | jest.mock('../../lib/helpers/gitCommitExeca'); 10 | jest.mock('../../lib/helpers/sgcPrompt'); 11 | 12 | beforeEach(() => { 13 | gitCommitExeca.mockReturnValue(Promise.resolve()); 14 | sgcPrompt.mockReturnValue(Promise.resolve()); 15 | }); 16 | 17 | it('should run gitCommitExeca', async () => { 18 | inquirer.prompt.returns(Promise.resolve({ initCommit: true })); 19 | 20 | await promptOrInitialCommit(); 21 | 22 | expect(gitCommitExeca).toBeCalledTimes(1); 23 | }); 24 | 25 | it('should run sgcPrompt', async () => { 26 | inquirer.prompt.returns(Promise.resolve({ initCommit: false })); 27 | 28 | await promptOrInitialCommit(); 29 | 30 | expect(sgcPrompt).toBeCalledTimes(1); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/helper/retryCommit.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs-extra'; 4 | import tempDir from 'temp-dir'; 5 | 6 | import retryCommit from '../../lib/helpers/retryCommit'; 7 | import gitCommitExeca from '../../lib/helpers/gitCommitExeca'; 8 | 9 | jest.mock('../../lib/helpers/gitCommitExeca'); 10 | 11 | const filename = 'testretry'; 12 | const sgcTempDir = path.join(tempDir, 'sgc'); 13 | const pathToRetryFile = path.join(sgcTempDir, `${filename}.txt`); 14 | 15 | beforeEach(() => { 16 | gitCommitExeca.mockReset(); 17 | console.error = jest.fn(); 18 | }); 19 | 20 | it('commit should fail', async () => { 21 | await retryCommit(filename); 22 | 23 | expect(console.error).toBeCalledWith(chalk.red('No previous failed commit found.')); 24 | }); 25 | 26 | it('commit should pass', async () => { 27 | await fs.ensureDir(sgcTempDir); 28 | await fs.writeFile(pathToRetryFile, 'new commit', 'utf8'); 29 | await retryCommit(filename); 30 | 31 | expect(gitCommitExeca).toBeCalledWith('new commit'); 32 | }); 33 | -------------------------------------------------------------------------------- /__tests__/helper/sgcPrompt.js: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | import inquirer from 'inquirer'; 3 | 4 | import sgcPrompt from '../../lib/helpers/sgcPrompt'; 5 | 6 | stub(inquirer, 'prompt'); 7 | 8 | jest.mock('../../lib/helpers/gitCommitExeca', () => (input) => Promise.resolve(input)); 9 | jest.mock('../../lib/helpers/formatters', () => ({ 10 | formatMessage: () => 'message', 11 | })); 12 | 13 | it('should return editor output', async () => { 14 | inquirer.prompt.returns(Promise.resolve({ 15 | body: true, 16 | editor: 'editor output', 17 | })); 18 | 19 | const message = await sgcPrompt(); 20 | 21 | expect(message).toBe('message'); 22 | }); 23 | 24 | it('should return scope with message', async () => { 25 | inquirer.prompt.returns(Promise.resolve({ 26 | body: false, 27 | scope: 'scope', 28 | message: 'message', 29 | })); 30 | 31 | const message = await sgcPrompt(); 32 | 33 | expect(message).toBe('message'); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/questions.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import chalk from 'chalk'; 5 | import { stub } from 'sinon'; 6 | 7 | import { withEmoji, withoutEmoji } from './fixtures/questions'; 8 | import Config from '../lib/Config'; 9 | import questions, { 10 | choices, 11 | customName, 12 | initMessage, 13 | initQuestion, 14 | } from '../lib/questions'; 15 | 16 | stub(console, 'error'); 17 | 18 | const cwd = process.cwd(); 19 | const date = new Date(); 20 | const homedir = os.homedir(); 21 | const fixtures = path.join(cwd, '__tests__', 'fixtures'); 22 | const datetime = date.toISOString().slice(0, 10); 23 | const randomString = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 4); 24 | 25 | let globalExist = false; 26 | 27 | const questionsListOrder = { 28 | type: 0, 29 | customType: 1, 30 | scope: 2, 31 | message: 3, 32 | body: 4, 33 | editor: 5, 34 | }; 35 | 36 | // rename global .sgcrc 37 | beforeAll(() => { 38 | // rename global config 39 | if (fs.existsSync(path.join(homedir, '.sgcrc'))) { 40 | globalExist = true; 41 | fs.renameSync(path.join(homedir, '.sgcrc'), path.join(homedir, `.sgcrc.${randomString}-${datetime}.back`)); 42 | } 43 | 44 | // rename local sgcrc 45 | fs.renameSync(path.join(cwd, '.sgcrc'), path.join(cwd, '.sgcrc_default')); 46 | }); 47 | 48 | afterAll(() => { 49 | // rename global config 50 | if (globalExist) { 51 | fs.renameSync(path.join(homedir, `.sgcrc.${randomString}-${datetime}.back`), path.join(homedir, '.sgcrc')); 52 | } 53 | 54 | // rename local sgcrc 55 | fs.renameSync(path.join(cwd, '.sgcrc_default'), path.join(cwd, '.sgcrc')); 56 | }); 57 | 58 | it('choices are rendered without emoji', () => { 59 | const sgc = new Config(fixtures, '.sgcrc').config; 60 | const choicesList = choices(sgc); 61 | 62 | expect(choicesList).toEqual(withoutEmoji); 63 | }); 64 | 65 | it('choices are rendered with emoji (default)', () => { 66 | const sgc = new Config(fixtures, '.sgcrc').config; 67 | 68 | sgc.emoji = true; 69 | 70 | const choicesList = choices(sgc); 71 | 72 | expect(choicesList).toEqual(withEmoji); 73 | }); 74 | 75 | it('choices are rendered as custom type', () => { 76 | const sgc = new Config(fixtures, '.sgcrc.customType').config; 77 | 78 | sgc.emoji = false; 79 | sgc.types[0].type = false; 80 | sgc.types[1].type = false; 81 | 82 | const choicesList = choices(sgc); 83 | 84 | expect(choicesList[0].value).toEqual(`${customName} 1`); 85 | expect(choicesList[1].value).toEqual(`${customName} 2`); 86 | }); 87 | 88 | it('check the values of the question object', () => { 89 | const { config } = new Config(); 90 | const questionsList = questions(config); 91 | 92 | expect(typeof questionsList).toBe('object'); 93 | }); 94 | 95 | it('alternative description', () => { 96 | const sgc = new Config(fixtures, '.sgcrc').config; 97 | 98 | sgc.types[0].description = undefined; 99 | 100 | const choicesList = choices(sgc); 101 | 102 | expect(choicesList[0].name).toBe(`${chalk.bold('Add:')} `); 103 | }); 104 | 105 | it('correct description', () => { 106 | const sgc = new Config(fixtures, '.sgcrc').config; 107 | 108 | sgc.types[0].description = 'lala land'; 109 | 110 | const choicesList = choices(sgc); 111 | 112 | expect(choicesList[0].name).toBe(`${chalk.bold('Add:')} ${'lala land'}`); 113 | }); 114 | 115 | it('wrong argKeys', () => { 116 | const sgc = new Config(fixtures, '.sgcrc').config; 117 | 118 | sgc.types[0].argKeys = 'wrong'; 119 | 120 | const choicesList = choices(sgc); 121 | 122 | expect(choicesList[0].key).toEqual([]); 123 | }); 124 | 125 | it('correct argKeys', () => { 126 | const sgc = new Config(fixtures, '.sgcrc').config; 127 | 128 | sgc.types[0].argKeys = ['n', 'notwrong']; 129 | 130 | const choicesList = choices(sgc); 131 | 132 | expect(choicesList[0].key).toEqual(['n', 'notwrong']); 133 | }); 134 | 135 | it('TYPES | upperCase (default)', () => { 136 | const sgc = new Config(fixtures, '.sgcrc').config; 137 | 138 | const choicesList = choices(sgc); 139 | 140 | expect(choicesList[0].value).toBe('Add:'); 141 | }); 142 | 143 | it('TYPES | lowerCase', () => { 144 | const sgc = new Config(fixtures, '.sgcrc').config; 145 | 146 | sgc.lowercaseTypes = true; 147 | 148 | const choicesList = choices(sgc); 149 | 150 | expect(choicesList[0].value).toBe('add:'); 151 | }); 152 | 153 | it('TYPE | just show if type has not been added', () => { 154 | const { config } = new Config(); 155 | const questionsList = questions(config); 156 | 157 | expect(questionsList[questionsListOrder.type].when()).toBe(true); 158 | }); 159 | 160 | it('TYPE | not show if type has been added', () => { 161 | const { config } = new Config(); 162 | const questionsList = questions(config, { t: 'feat' }); 163 | 164 | expect(questionsList[questionsListOrder.type].when()).toBe(false); 165 | }); 166 | 167 | it('TYPE | filter correct types', async () => { 168 | const { config } = new Config(); 169 | const allChoices = choices(config); 170 | const [autocomplete] = questions(config); 171 | 172 | const answerOne = await autocomplete.source(undefined, 'feat'); 173 | const answerTwo = await autocomplete.source(); 174 | const answerKey = await autocomplete.source(undefined, 'performance'); 175 | 176 | expect(answerOne).toHaveLength(1); 177 | expect(answerOne[0].value).toBe('Feat'); 178 | expect(answerTwo).toHaveLength(allChoices.length); 179 | expect(answerKey).toHaveLength(1); 180 | expect(answerKey[0].value).toBe('Perf'); 181 | }); 182 | 183 | it('SCOPE | check if scope is off by default', () => { 184 | const { config } = new Config(); 185 | const questionsList = questions(config); 186 | 187 | expect(questionsList[questionsListOrder.scope].when()).toBe(false); 188 | }); 189 | 190 | it('CUSTOMTYPE | check if customType gets shown when type is defined', () => { 191 | const { config } = new Config(fixtures, '.sgcrc.customType'); 192 | const questionsList = questions(config); 193 | 194 | expect(questionsList[questionsListOrder.customType].when({ type: 'Feat:' })).toBe(false); 195 | expect(questionsList[questionsListOrder.customType].when({ type: 'anything' })).toBe(false); 196 | }); 197 | 198 | it('CUSTOMTYPE | check if customType gets shown when type is custom', () => { 199 | const { config } = new Config(fixtures, '.sgcrc.customType'); 200 | const questionsList = questions(config); 201 | 202 | expect(questionsList[questionsListOrder.customType].when({ type: customName })).toBe(true); 203 | expect(questionsList[questionsListOrder.customType].when({ type: `${customName}feat` })).toBe(true); 204 | }); 205 | 206 | it('CUSTOMTYPE | should not show when argv is specified', () => { 207 | const { config } = new Config(fixtures, '.sgcrc.customType'); 208 | const questionsList = questions(config, { c: 'Feat:' }); 209 | 210 | expect(questionsList[questionsListOrder.customType].when({ type: customName })).toBe(false); 211 | expect(questionsList[questionsListOrder.customType].when({ type: `${customName}feat` })).toBe(false); 212 | }); 213 | 214 | it('CUSTOMTYPE | return prefixed answer', () => { 215 | const { config } = new Config(fixtures, '.sgcrc.customType'); 216 | 217 | config.types[0].prefix = 'myprefix'; 218 | 219 | const questionsList = questions(config); 220 | 221 | expect(questionsList[questionsListOrder.customType].filter('answer', { type: `${customName} 1` })).toBe('myprefixanswer'); 222 | }); 223 | 224 | it('CUSTOMTYPE | return nonprefixed answer', () => { 225 | const { config } = new Config(fixtures, '.sgcrc.customType'); 226 | 227 | config.types[0].prefix = undefined; 228 | 229 | const questionsList = questions(config); 230 | 231 | expect(questionsList[questionsListOrder.customType].filter('answer', { type: `${customName} 1` })).toBe('answer'); 232 | }); 233 | 234 | it('CUSTOMTYPE | return any type', () => { 235 | const { config } = new Config(fixtures, '.sgcrc.customType'); 236 | const questionsList = questions(config); 237 | 238 | expect(questionsList[questionsListOrder.customType].filter('something', { type: 'none' })).toBe('something'); 239 | }); 240 | 241 | it('SCOPE | check if scope is off when it has been added by argv', () => { 242 | const { config } = new Config(); 243 | const questionsList = questions(config, { s: 'some scope' }); 244 | 245 | expect(questionsList[questionsListOrder.scope].when()).toBe(false); 246 | }); 247 | 248 | it('SCOPE | check if scope is off when it has been added in config and argv', () => { 249 | const { config } = new Config(); 250 | 251 | config.scope = true; 252 | 253 | const questionsList = questions(config, { s: 'some scope' }); 254 | 255 | expect(questionsList[questionsListOrder.scope].when()).toBe(false); 256 | }); 257 | 258 | it('SCOPE | check if scope is on when it has been added just in config', () => { 259 | const { config } = new Config(); 260 | 261 | config.scope = true; 262 | 263 | const questionsList = questions(config); 264 | 265 | expect(questionsList[questionsListOrder.scope].when()).toBe(true); 266 | }); 267 | 268 | it('SCOPE | check if scope validates correctly', () => { 269 | const { config } = new Config(); 270 | const questionsList = questions(config); 271 | 272 | expect(questionsList[questionsListOrder.scope].validate('not correct')).toBe('No whitespaces allowed'); 273 | expect(questionsList[questionsListOrder.scope].validate('correct')).toBe(true); 274 | }); 275 | 276 | it('MESSAGE | validate functions in questions', () => { 277 | const { config } = new Config(); 278 | const questionsList = questions(config); 279 | 280 | expect(questionsList[questionsListOrder.message].validate('', { type: 'Fix' })).toBe('The commit message is not allowed to be empty'); 281 | expect(questionsList[questionsListOrder.message].validate('input text', { type: 'Fix' })).toBe(true); 282 | expect(questionsList[questionsListOrder.message].validate('This message has over 72 characters. So this test will definitely fail. I can guarantee that I am telling the truth', { type: 'Fix' })).toBe('The commit message is not allowed to be longer as 72 character, but is 120 character long. Consider writing a body.\n'); 283 | }); 284 | 285 | it('MESSAGE | do not show if there is the message in argv', () => { 286 | const { config } = new Config(); 287 | const questionsList = questions(config, { m: 'something' }); 288 | 289 | expect(questionsList[questionsListOrder.message].when()).toBe(false); 290 | }); 291 | 292 | it('MESSAGE | show if no argv has been added', () => { 293 | const { config } = new Config(); 294 | const questionsList = questions(config); 295 | 296 | expect(questionsList[questionsListOrder.message].when()).toBe(true); 297 | }); 298 | 299 | it('EDITOR | when and default functions in questions', () => { 300 | const { config } = new Config(); 301 | const questionsList = questions(config); 302 | 303 | expect(questionsList[questionsListOrder.editor].when({ body: true })).toBe(true); 304 | expect(questionsList[questionsListOrder.editor].when({ body: false })).toBe(false); 305 | }); 306 | 307 | it('EDITOR | should return formatted message', () => { 308 | const { config } = new Config(); 309 | const questionsList = questions(config); 310 | 311 | expect(questionsList[questionsListOrder.editor].default({ message: 'message', type: 'type' })).toBe('type: message\n\n\n'); 312 | }); 313 | 314 | it('CONFIRM EDITOR | check if it shows if it has to', () => { 315 | const { config } = new Config(); 316 | const questionsList = questions(config); 317 | 318 | expect(questionsList[3].when()).toBe(config.body); 319 | }); 320 | 321 | it('CONFIRM EDITOR | check if it returns config.body', () => { 322 | const { config } = new Config(); 323 | const questionsList = questions(config); 324 | 325 | expect(questionsList[questionsListOrder.body].when()).toBe(config.body); 326 | }); 327 | 328 | it('INIT COMMIT | check message without emoji', () => { 329 | const { config } = new Config(); 330 | const message = initMessage(config); 331 | 332 | expect(message).toBe(config.initialCommit.message); 333 | }); 334 | 335 | it('INIT COMMIT | check message with emoji', () => { 336 | const { config } = new Config(); 337 | 338 | config.emoji = true; 339 | 340 | const message = initMessage(config); 341 | 342 | expect(message).toBe(`${config.initialCommit.emoji} ${config.initialCommit.message}`); 343 | }); 344 | 345 | it('INIT QUESTION | check message without emoji', () => { 346 | const { config } = new Config(); 347 | const question = initQuestion(config); 348 | 349 | expect(question.message).toBe(`Confirm as first commit message: "${config.initialCommit.message}"`); 350 | }); 351 | 352 | it('INIT QUESTION | check message with emoji', () => { 353 | const { config } = new Config(); 354 | 355 | config.emoji = true; 356 | 357 | const question = initQuestion(config); 358 | 359 | expect(question.message).toBe(`Confirm as first commit message: "${config.initialCommit.emoji} ${config.initialCommit.message}"`); 360 | }); 361 | -------------------------------------------------------------------------------- /__tests__/rules/availableRules.js: -------------------------------------------------------------------------------- 1 | import rules from '../../lib/rules/availableRules'; 2 | 3 | it('rules endWithDot', () => { 4 | const rulesObj = { 5 | endWithDot: false, 6 | }; 7 | const endWithDot = rules.endWithDot('input with dot.', { rules: rulesObj }).check(); 8 | const endWithoutDot = rules.endWithDot('input with dot', { rules: rulesObj }).check(); 9 | 10 | expect(endWithDot).toBe(false); 11 | expect(endWithoutDot).toBe(true); 12 | }); 13 | 14 | it('rules minChar', () => { 15 | const rulesObj = { 16 | minChar: 10, 17 | }; 18 | const notMinChar = rules.minChar('less', { rules: rulesObj }).check(); 19 | const minChar = rules.minChar('this are more than 10 characters', { rules: rulesObj }).check(); 20 | 21 | expect(notMinChar).toBe(false); 22 | expect(minChar).toBe(true); 23 | }); 24 | 25 | it('-1 in minChar', () => { 26 | const rulesObj = { 27 | minChar: -1, 28 | }; 29 | const shortText = rules.minChar('n', { rules: rulesObj }).check(); 30 | const longText = rules.minChar('this are more than 10 characters', { rules: rulesObj }).check(); 31 | 32 | rules.minChar('n', { rules: rulesObj }).message(); 33 | 34 | expect(shortText).toBe(true); 35 | expect(longText).toBe(true); 36 | }); 37 | 38 | it('rules mxChar', () => { 39 | const rulesObj = { 40 | maxChar: 72, 41 | }; 42 | const moreThanMaxChar = rules.maxChar('this are more than 72 characters, believe me or not but the value moreThanMaxChar will be false ;-P', { rules: rulesObj }).check(); 43 | const lessThanMaxChar = rules.maxChar('this are less than 72 characters', { rules: rulesObj }).check(); 44 | 45 | rules.maxChar('this are more than 72 characters, believe me or not but the value moreThanMaxChar will be false ;-P', { rules: rulesObj }).message(); 46 | 47 | expect(moreThanMaxChar).toBe(false); 48 | expect(lessThanMaxChar).toBe(true); 49 | }); 50 | 51 | it('-1 in maxChar', () => { 52 | const rulesObj = { 53 | maxChar: -1, 54 | }; 55 | const longText = rules.maxChar('this are more than 72 characters, believe me or not but the value moreThanMaxChar will be true ;-P', { rules: rulesObj }).check(); 56 | const shortText = rules.maxChar('this are less than 72 characters', { rules: rulesObj }).check(); 57 | 58 | expect(longText).toBe(true); 59 | expect(shortText).toBe(true); 60 | }); 61 | -------------------------------------------------------------------------------- /__tests__/rules/ruleWarningMessages.js: -------------------------------------------------------------------------------- 1 | import ruleWaringMessages from '../../lib/rules/ruleWarningMessages'; 2 | 3 | it('ruleWarningMessages', () => { 4 | const config = { 5 | rules: { 6 | maxChar: 72, 7 | minChar: 10, 8 | endWithDot: false, 9 | }, 10 | }; 11 | const messages = ruleWaringMessages('input.', config); 12 | expect(messages).toBe('The commit message has to be at least 10 character, but is only 6 character long.\nThe commit message can not end with a dot\n'); 13 | }); 14 | 15 | it('should throw an error', () => { 16 | const config = { 17 | rules: { 18 | notExisting: 10, 19 | }, 20 | }; 21 | 22 | expect(() => ruleWaringMessages('input.', config)).toThrow(); 23 | }); 24 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: "10" 4 | - nodejs_version: "12" 5 | - nodejs_version: "14" 6 | 7 | platform: 8 | - x64 9 | 10 | install: 11 | # Get the latest stable version of Node.js or io.js 12 | - ps: | 13 | try { 14 | Install-Product node $env:nodejs_version $env:platform 15 | } catch { 16 | echo "Unable to install node $env:nodejs_version, trying update..." 17 | Update-NodeJsInstallation (Get-NodeJsLatestBuild $env:nodejs_version) $env:platform 18 | } 19 | # install modules 20 | - npm config set loglevel warn 21 | - npm i -g npm 22 | - npm ci 23 | 24 | test_script: 25 | - npm run lint 26 | - npm test 27 | 28 | build: off 29 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'git-commit-range'; 2 | declare module 'chalk'; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testPathIgnorePatterns: ['/node_modules/', '/__tests__/fixtures/', '/test/'], 4 | transform: { 5 | '^.+\\.(t|j)sx?$': 'ts-jest', 6 | }, 7 | coveragePathIgnorePatterns: [ 8 | '/node_modules/', 9 | '/__tests__/', 10 | ], 11 | globals: { 12 | 'ts-jest': { 13 | isolatedModules: true, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lib/Config.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import findup from 'findup-sync'; 3 | import * as json from 'json-extra'; 4 | import path from 'path'; 5 | import os from 'os'; 6 | import fs from 'fs'; 7 | 8 | const cwd = process.cwd(); 9 | const homedir = os.homedir(); 10 | 11 | export interface SgcConfig { 12 | scope?: boolean; 13 | body?: boolean; 14 | emoji?: boolean; 15 | delimiter?: string; 16 | lowercaseTypes?: boolean; 17 | addScopeSpace?: boolean; 18 | initialCommit?: { 19 | isEnabled: boolean; 20 | emoji: string; 21 | message: string; 22 | }; 23 | types: { 24 | emoji?: string; 25 | type: string; 26 | description?: string; 27 | argKeys?: string[]; 28 | }[]; 29 | rules?: { 30 | maxChar?: number; 31 | minChar?: number; 32 | endWithDot?: boolean; 33 | }; 34 | } 35 | 36 | const safeRequire = (jsPath: string | null): SgcConfig | false => ( 37 | // eslint-disable-next-line global-require, import/no-dynamic-require 38 | jsPath && fs.existsSync(jsPath) && require(jsPath) 39 | ); 40 | 41 | class Config { 42 | altPath: string | null; 43 | 44 | fileName: string; 45 | 46 | constructor(altPath: string | null = null, fileName = '.sgcrc') { 47 | this.altPath = altPath; 48 | this.fileName = fileName; 49 | 50 | this.setConfig(); 51 | } 52 | 53 | private setConfig(): SgcConfig { 54 | const pathString = findup(this.fileName, { cwd: this.altPath || cwd }); 55 | const localeConfigJS = safeRequire(findup('sgc.config.js', { cwd })); 56 | const localeConfig = pathString ? json.readToObjSync(pathString) : false; 57 | const globalConfigJS = safeRequire(path.join(homedir, 'sgc.config.js')); 58 | const globalConfig = json.readToObjSync(path.join(homedir, '.sgcrc')); 59 | const packageJson = findup('package.json', { cwd }); 60 | const packageConfig = packageJson 61 | ? (json.readToObjSync<{ sgc?: SgcConfig }>(packageJson) || {}).sgc 62 | : false; 63 | const sgcrcDefaultConfig = json.readToObjSync(path.join(__dirname, '..', '.sgcrc')) as SgcConfig; 64 | const sgcrcTestDefaultConfig = json.readToObjSync(path.join(__dirname, '..', '.sgcrc_default')) as SgcConfig; 65 | const sgcrcDefault = sgcrcDefaultConfig || sgcrcTestDefaultConfig; 66 | 67 | // priority order (1. highest priority): 68 | // 1. local config 69 | // - 1. sgc.config.js 70 | // - 2. .sgcrc 71 | // - 3. (package.json).sgc 72 | // 2. global config 73 | // 3. default config 74 | // - 1. from ../.sgcrc 75 | // - 2. test case ../.sgcrc is renamed to ../.sgcrc_default 76 | const config = localeConfigJS 77 | || localeConfig 78 | || packageConfig 79 | || globalConfigJS 80 | || globalConfig 81 | || sgcrcDefault; 82 | 83 | // set defaults which are necessary 84 | const modifiedConfig = merge({}, sgcrcDefault, config); 85 | 86 | // do not merge types 87 | // so return them to their set default 88 | if (config.types) { 89 | modifiedConfig.types = config.types; 90 | } 91 | 92 | if (config.initialCommit) { 93 | modifiedConfig.initialCommit = config.initialCommit; 94 | } 95 | 96 | return modifiedConfig; 97 | } 98 | 99 | public get config(): SgcConfig { 100 | return this.setConfig(); 101 | } 102 | } 103 | 104 | export default Config; 105 | -------------------------------------------------------------------------------- /lib/check.ts: -------------------------------------------------------------------------------- 1 | import gitCommitRange from 'git-commit-range'; 2 | import chalk from 'chalk'; 3 | 4 | import commitMeetsRules from './helpers/commitMeetsRules'; 5 | 6 | const check = ({ start }: { start?: string } = {}): void => { 7 | const commitRangeText: string[] = gitCommitRange({ 8 | from: start, 9 | type: 'text', 10 | includeMerges: false, 11 | }); 12 | const commitRangeSha: string[] = gitCommitRange({ 13 | from: start, 14 | includeMerges: false, 15 | }); 16 | 17 | let hasErrors = false; 18 | 19 | commitRangeText.forEach((commit, i) => { 20 | const isCommitValid = commitMeetsRules(commit); 21 | 22 | if (!isCommitValid) { 23 | const commitSha = commitRangeSha[i].slice(0, 7); 24 | 25 | hasErrors = true; 26 | 27 | // eslint-disable-next-line no-console 28 | console.error(chalk.red(`\n${chalk.bold(commitSha)}\n${commit}`)); 29 | } 30 | 31 | return isCommitValid; 32 | }); 33 | 34 | process.exit(+hasErrors); 35 | }; 36 | 37 | export default check; 38 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import inquirer from 'inquirer'; 3 | import commitCount from 'git-commit-count'; 4 | import isAdded from 'is-git-added'; 5 | import isGit from 'is-git-repository'; 6 | 7 | import Config from './Config'; 8 | import pkg from '../package.json'; 9 | import retryCommit from './helpers/retryCommit'; 10 | import sgcPrompt from './helpers/sgcPrompt'; 11 | import promptOrInitialCommit from './helpers/promptOrInitialCommit'; 12 | 13 | inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); 14 | 15 | const cli = async (argv = {}) => { 16 | const { config } = new Config(); 17 | 18 | if (argv.v) { 19 | console.log(`v${pkg.version}`); 20 | } else if (!isGit()) { 21 | console.error('fatal: Not a git repository (or any of the parent directories): .git'); 22 | } else if (!isAdded()) { 23 | console.error(chalk.red('Please', chalk.bold('git add'), 'some files first before you commit.')); 24 | } else if (argv.r) { 25 | await retryCommit(); 26 | } else if ( 27 | commitCount() === 0 28 | && typeof config.initialCommit === 'object' 29 | && config.initialCommit.isEnabled 30 | ) { 31 | await promptOrInitialCommit(); 32 | } else { 33 | await sgcPrompt(argv); 34 | } 35 | }; 36 | 37 | export default cli; 38 | -------------------------------------------------------------------------------- /lib/helpers/commitMeetsRules.ts: -------------------------------------------------------------------------------- 1 | import Config from '../Config'; 2 | import rules from '../rules/availableRules'; 3 | 4 | const commitMeetsRules = (commit: string): boolean => { 5 | const { config } = new Config(); 6 | 7 | // commit exceptions 8 | if (config.initialCommit?.isEnabled && commit === config.initialCommit?.message) { 9 | return true; 10 | } 11 | 12 | // check if type is correct 13 | let scopeRegex = '\\([\\s\\S]*\\)'; 14 | 15 | if (!config.scope) { 16 | scopeRegex = ''; 17 | } 18 | 19 | if (config.addScopeSpace) { 20 | scopeRegex = ` ${scopeRegex}`; 21 | } 22 | 23 | const hasType = config.types?.some(({ type }: any) => { 24 | const newType = config.lowercaseTypes ? type.toLowerCase() : type; 25 | const delimiter = config.delimiter || ':'; 26 | 27 | return commit.match(new RegExp(`^${newType}(${scopeRegex})?${delimiter}`)); 28 | }); 29 | 30 | if (!hasType) { 31 | return false; 32 | } 33 | 34 | // if length === 5 there is a footer 35 | // footer is not yet implemented 36 | const splittedCommit = commit.split('\n'); 37 | const hasBody = splittedCommit.length === 3 && splittedCommit[1] === '' && splittedCommit[2] !== ''; 38 | const invalidBody = splittedCommit.length > 1 && splittedCommit[1] !== ''; 39 | 40 | if ( 41 | (!config.body && hasBody) 42 | || invalidBody 43 | ) { 44 | return false; 45 | } 46 | 47 | // rules 48 | const endWithDot = !rules.endWithDot(commit).check(); 49 | const maxChar = !rules.maxChar(commit, config).check(); 50 | const minChar = !rules.minChar(commit, config).check(); 51 | 52 | if ( 53 | // end with dot correctly 54 | (config.rules?.endWithDot && !endWithDot) 55 | || (!config.rules?.endWithDot && endWithDot) 56 | // has correct length 57 | || maxChar 58 | || minChar 59 | ) { 60 | return false; 61 | } 62 | 63 | return true; 64 | }; 65 | 66 | export default commitMeetsRules; 67 | -------------------------------------------------------------------------------- /lib/helpers/formatters.js: -------------------------------------------------------------------------------- 1 | import getTypeFromName from './getTypeFromName'; 2 | 3 | export const formatScope = (scope = '') => (scope ? `(${scope.trim()})` : scope.trim()); 4 | 5 | export const combineTypeScope = (type, scope, delimiter = ':', addScopeSpace = true) => { 6 | let thisType = type; 7 | 8 | const thisScope = formatScope(scope); 9 | // originalDelimiter to still keep the previous behavior 10 | const originalDelimiter = ':'; 11 | const trimmedDelimiter = delimiter.replace(/ *$/, ''); 12 | 13 | // add scope correctly if delimiter is at the end 14 | if (thisScope.length > 0) { 15 | if ( 16 | thisType.charAt(thisType.length - 1) === trimmedDelimiter 17 | || thisType.charAt(thisType.length - 1) === originalDelimiter 18 | ) { 19 | thisType = thisType.slice(0, thisType.length - 1); 20 | } 21 | 22 | thisType = `${thisType}${addScopeSpace ? ' ' : ''}${thisScope}${trimmedDelimiter}`; 23 | } else if ( 24 | thisType.charAt(thisType.length - 1) !== trimmedDelimiter 25 | || thisType.charAt(thisType.length - 1) !== originalDelimiter 26 | ) { 27 | thisType = `${thisType}${trimmedDelimiter}`; 28 | } 29 | 30 | return thisType; 31 | }; 32 | 33 | export const formatMessage = (answers, argv, config) => { 34 | const combinedAnswers = { 35 | ...answers, 36 | ...argv, 37 | }; 38 | 39 | const correctType = combinedAnswers.customType || combinedAnswers.type; 40 | const typeInfo = getTypeFromName(config, combinedAnswers.type); 41 | const delimiter = (typeInfo && typeInfo.delimiter) || (config && config.delimiter); 42 | const type = combineTypeScope( 43 | correctType, 44 | combinedAnswers.scope, 45 | delimiter, 46 | (config && config.addScopeSpace), 47 | ); 48 | const formattedMessage = `${type} ${(combinedAnswers.message || '').trim()}`; 49 | let result = formattedMessage; 50 | 51 | if (combinedAnswers.editor) { 52 | result = answers.body ? combinedAnswers.editor : formattedMessage; 53 | } 54 | 55 | return result; 56 | }; 57 | -------------------------------------------------------------------------------- /lib/helpers/getTypeFromName.js: -------------------------------------------------------------------------------- 1 | const getTypeFromName = (config, typeName) => { 2 | const types = config ? config.types || [] : []; 3 | 4 | return types.find((type) => type.type === typeName) || null; 5 | }; 6 | 7 | export default getTypeFromName; 8 | -------------------------------------------------------------------------------- /lib/helpers/gitCommitExeca.js: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | import tempDir from 'temp-dir'; 6 | 7 | const gitCommitExeca = async (message, filename = 'retry') => { 8 | const sgcTempDir = path.join(tempDir, 'sgc'); 9 | const pathToRetryFile = path.join(sgcTempDir, `${filename}.txt`); 10 | 11 | try { 12 | await execa('git', ['commit', '-m', message], { stdio: 'inherit' }); 13 | 14 | return true; 15 | } catch (_) { 16 | console.error(chalk.red('\nAn error occured. Try to resolve the previous errors and run following command:')); 17 | console.error(chalk.green('sgc --retry')); 18 | 19 | await fs.ensureDir(sgcTempDir); 20 | await fs.writeFile(pathToRetryFile, message, 'utf8'); 21 | 22 | return false; 23 | } 24 | }; 25 | 26 | export default gitCommitExeca; 27 | -------------------------------------------------------------------------------- /lib/helpers/promptOrInitialCommit.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | import Config from '../Config'; 4 | import { 5 | initMessage, 6 | initQuestion, 7 | } from '../questions'; 8 | import gitCommitExeca from './gitCommitExeca'; 9 | import sgcPrompt from './sgcPrompt'; 10 | 11 | const promptOrInitialCommit = async (argv) => { 12 | const { config } = new Config(); 13 | const question = initQuestion(config); 14 | const message = initMessage(config); 15 | const answers = await inquirer.prompt(question); 16 | 17 | if (answers.initCommit) { 18 | await gitCommitExeca(message); 19 | } else { 20 | await sgcPrompt(argv); 21 | } 22 | }; 23 | 24 | export default promptOrInitialCommit; 25 | -------------------------------------------------------------------------------- /lib/helpers/retryCommit.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import tempDir from 'temp-dir'; 4 | import chalk from 'chalk'; 5 | 6 | import gitCommitExeca from './gitCommitExeca'; 7 | 8 | const retryCommit = async (filename = 'retry') => { 9 | const sgcTempDir = path.join(tempDir, 'sgc'); 10 | const pathToRetryFile = path.join(sgcTempDir, `${filename}.txt`); 11 | 12 | try { 13 | const message = await fs.readFile(pathToRetryFile, 'utf8'); 14 | 15 | await fs.unlink(pathToRetryFile); 16 | await gitCommitExeca(message); 17 | } catch (_) { 18 | console.error(chalk.red('No previous failed commit found.')); 19 | } 20 | }; 21 | 22 | export default retryCommit; 23 | -------------------------------------------------------------------------------- /lib/helpers/sgcPrompt.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | import Config from '../Config'; 4 | import questions from '../questions'; 5 | import gitCommitExeca from './gitCommitExeca'; 6 | import { formatMessage } from './formatters'; 7 | 8 | const sgcPrompt = async (argv) => { 9 | const { config } = new Config(); 10 | const questionsList = questions(config, argv); 11 | const answers = await inquirer.prompt(questionsList); 12 | const message = formatMessage(answers, argv, config); 13 | 14 | return gitCommitExeca(message); 15 | }; 16 | 17 | export default sgcPrompt; 18 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import updateNotifier from 'update-notifier'; 4 | import yargs from 'yargs'; 5 | 6 | import pkg from '../package.json'; 7 | import cli from './cli'; 8 | import check from './check'; 9 | 10 | const { argv } = yargs 11 | .usage('Usage: $0') 12 | .alias('v', 'version') 13 | .describe('v', 'Version number') 14 | .alias('r', 'retry') 15 | .describe('r', 'Retry your previous failed commit') 16 | .alias('m', 'message') 17 | .describe('m', 'Use this to add it as commit message. If this option is given, it will skip the original message prompt') 18 | .alias('s', 'scope') 19 | .describe('s', 'Use this to add it as commit scope. If this option is given, it will skip the original message prompt. If scope is deactivated by the sgcrc this argument will get ignored') 20 | .alias('t', 'type') 21 | .describe('t', 'Use this to choose the type. Remember: the given key must be specified in your config file (types). If this option is given, it will skip the original message prompt') 22 | .help('h') 23 | .alias('h', 'help') 24 | .command( 25 | 'check', 26 | 'Check if commits follow the specs', 27 | { 28 | start: { 29 | alias: 'st', 30 | description: 'A git SHA where to start for checking. This could be helpful if sgc is implemented after a specific commit', 31 | }, 32 | }, 33 | check, 34 | ); 35 | 36 | updateNotifier({ pkg }).notify(); 37 | 38 | if (argv._.length <= 0) { 39 | cli(argv); 40 | } 41 | -------------------------------------------------------------------------------- /lib/questions.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import ruleWarningMessages from './rules/ruleWarningMessages'; 3 | import { formatMessage } from './helpers/formatters'; 4 | 5 | const customName = 'Custom'; 6 | 7 | const choices = (config) => { 8 | const choicesList = []; 9 | 10 | let customCount = 1; 11 | 12 | config.types.forEach((type) => { 13 | const emoji = config.emoji && type.emoji ? `${type.emoji} ` : ''; 14 | let changedType = type.type; 15 | 16 | // type = false === it is a custom type 17 | if (!changedType) { 18 | changedType = `${customName} ${customCount}`; 19 | customCount += 1; 20 | } 21 | 22 | const configType = config.lowercaseTypes ? changedType.toLowerCase() : changedType; 23 | const description = type.description || ''; 24 | const argKeys = type.argKeys || []; 25 | const isArray = Array.isArray(argKeys); 26 | 27 | if (!isArray) { 28 | console.error( 29 | chalk.red( 30 | '\nAn error occured. The value', 31 | chalk.bold('argKeys'), 32 | 'of', 33 | chalk.bold(type.type), 34 | 'must be an array', 35 | ), 36 | ); 37 | } 38 | 39 | choicesList.push({ 40 | value: emoji + configType, 41 | name: `${chalk.bold(configType)} ${description}`, 42 | key: isArray ? argKeys : [], 43 | }); 44 | }); 45 | 46 | return choicesList; 47 | }; 48 | 49 | const initMessage = (config) => { 50 | let message = ''; 51 | 52 | if (config.emoji 53 | && typeof config.initialCommit === 'object' 54 | && config.initialCommit.isEnabled) { 55 | message = `${config.initialCommit.emoji} ${config.initialCommit.message}`; 56 | } else { 57 | message = config.initialCommit.message; 58 | } 59 | 60 | return message; 61 | }; 62 | 63 | const initQuestion = (config) => { 64 | const message = initMessage(config); 65 | 66 | return { 67 | type: 'confirm', 68 | name: 'initCommit', 69 | message: `Confirm as first commit message: "${message}"`, 70 | default: true, 71 | }; 72 | }; 73 | 74 | const questions = (config, argv = {}) => { 75 | const modifiedArgv = argv; 76 | const choicesList = choices(config); 77 | const argChoice = choicesList.find((choice) => choice.key.includes(modifiedArgv.t)); 78 | 79 | if (argChoice) { 80 | modifiedArgv.type = argChoice.value; 81 | } 82 | 83 | const questionsList = [ 84 | { 85 | type: 'autocomplete', 86 | name: 'type', 87 | when: () => !argChoice, 88 | message: 'Select the type of your commit:', 89 | source: (answer, input) => { 90 | const filteredChoices = choicesList.filter((choice) => ( 91 | // check if input matches value 92 | choice.value.toLowerCase().includes(input) 93 | // check if input matches any key 94 | || (choice.key || []).some((key) => key.toLowerCase().includes(input)) 95 | )); 96 | 97 | return Promise.resolve(input ? filteredChoices : choicesList); 98 | }, 99 | }, 100 | { 101 | type: 'input', 102 | name: 'customType', 103 | when: (answers) => (answers.type.includes(customName) && !modifiedArgv.c), 104 | filter: (answer, answers) => { 105 | let customCount = 1; 106 | 107 | const typeChoice = config.types.find((type) => { 108 | // if there is no type it is a custom type 109 | if (!type.type) { 110 | if (answers.type === `${customName} ${customCount}`) { 111 | return true; 112 | } 113 | 114 | customCount += 1; 115 | } 116 | 117 | return false; 118 | }); 119 | 120 | if (!typeChoice) { 121 | return answer; 122 | } 123 | 124 | return `${typeChoice.prefix || ''}${answer}`; 125 | }, 126 | message: 'Choose your custom commit:', 127 | choices: choicesList, 128 | }, 129 | { 130 | type: 'input', 131 | name: 'scope', 132 | message: 'Enter your scope (no whitespaces allowed):', 133 | when: () => (config.scope && !modifiedArgv.s), 134 | validate: (input) => (input.match(/\s/) !== null ? 'No whitespaces allowed' : true), 135 | }, 136 | { 137 | type: 'input', 138 | name: 'message', 139 | message: 'Enter your commit message:', 140 | when: () => !modifiedArgv.m, 141 | validate: (message, answers) => { 142 | if (message.length === 0) { 143 | return 'The commit message is not allowed to be empty'; 144 | } 145 | 146 | const formattedMessage = formatMessage({ ...answers, message }, modifiedArgv, config); 147 | const warnings = ruleWarningMessages(formattedMessage, config); 148 | 149 | return warnings || true; 150 | }, 151 | }, 152 | { 153 | type: 'confirm', 154 | name: 'body', 155 | message: 'Do you want to add a body?', 156 | when: () => config.body, 157 | default: false, 158 | }, 159 | { 160 | type: 'editor', 161 | name: 'editor', 162 | message: 'This will let you add more information', 163 | when: (answers) => answers.body, 164 | default: (answers) => { 165 | const formattedMessage = formatMessage(answers, modifiedArgv, config); 166 | 167 | return `${formattedMessage}\n\n\n`; 168 | }, 169 | }, 170 | ]; 171 | 172 | return questionsList; 173 | }; 174 | 175 | export default questions; 176 | export { 177 | choices, 178 | customName, 179 | initMessage, 180 | initQuestion, 181 | }; 182 | -------------------------------------------------------------------------------- /lib/rules/availableRules.js: -------------------------------------------------------------------------------- 1 | const rules = { 2 | endWithDot: (input) => ({ 3 | message: () => 'The commit message can not end with a dot', 4 | check: () => { 5 | if (input[input.length - 1] === '.') { 6 | return false; 7 | } 8 | 9 | return true; 10 | }, 11 | }), 12 | maxChar: (input, config) => ({ 13 | message: () => `The commit message is not allowed to be longer as ${config.rules?.maxChar} character, but is ${input.length} character long. Consider writing a body.`, 14 | check: () => { 15 | let number = config.rules?.maxChar; 16 | 17 | if (number === -1) { 18 | number = Number.POSITIVE_INFINITY; 19 | } 20 | 21 | if (input.length > number) { 22 | return false; 23 | } 24 | 25 | return true; 26 | }, 27 | }), 28 | minChar: (input, config) => ({ 29 | message: () => `The commit message has to be at least ${config.rules?.minChar} character, but is only ${input.length} character long.`, 30 | check: () => { 31 | if (input.length < config.rules?.minChar) { 32 | return false; 33 | } 34 | 35 | return true; 36 | }, 37 | }), 38 | }; 39 | 40 | export default rules; 41 | -------------------------------------------------------------------------------- /lib/rules/ruleWarningMessages.js: -------------------------------------------------------------------------------- 1 | import entries from 'object.entries'; 2 | import rules from './availableRules'; 3 | 4 | const ruleWarningMessages = (input, config) => { 5 | let warningMessage = ''; 6 | 7 | const configRuleEntries = entries(config.rules); 8 | 9 | configRuleEntries.forEach((rule) => { 10 | const ruleName = rule[0]; 11 | 12 | if (!(rules[ruleName] instanceof Function)) { 13 | throw new Error(`The rule '${ruleName}' does not exist, look at the documentation to check valid rules.`); 14 | } 15 | 16 | const ruleIs = rules[ruleName](input, config).check(); 17 | 18 | if (!ruleIs) { 19 | warningMessage += `${rules[ruleName](input, config).message()}\n`; 20 | } 21 | }); 22 | 23 | return warningMessage; 24 | }; 25 | 26 | export default ruleWarningMessages; 27 | -------------------------------------------------------------------------------- /media/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JPeer264/node-semantic-git-commit-cli/9e143576c0a07944a6fe1b56e84f2ab3d898c6fd/media/screenshot.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "semantic-git-commit-cli", 3 | "version": "3.7.0", 4 | "description": "A CLI for semantic git commits", 5 | "main": "dest", 6 | "bin": { 7 | "semantic-git-commit": "./dest/index.js", 8 | "sgc": "./dest/index.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "test": "jest --coverage --runInBand", 13 | "lint": "eslint lib __tests__", 14 | "prepublish": "npm run build", 15 | "coveralls": "cat ./coverage/lcov.info | coveralls", 16 | "sgc": "ts-node lib/index.js", 17 | "prepare": "husky install" 18 | }, 19 | "lint-staged": { 20 | "*.{js,ts}": [ 21 | "eslint --fix" 22 | ] 23 | }, 24 | "engines": { 25 | "node": ">=8.0.0" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/JPeer264/node-semantic-git-commit-cli.git" 30 | }, 31 | "author": "Jan Peer Stöcklmair ", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/JPeer264/node-semantic-git-commit-cli/issues" 35 | }, 36 | "keywords": [ 37 | "semantic", 38 | "git", 39 | "commits", 40 | "cli", 41 | "fast", 42 | "enhance", 43 | "workflow" 44 | ], 45 | "homepage": "https://github.com/JPeer264/node-semantic-git-commit-cli#readme", 46 | "devDependencies": { 47 | "@types/findup-sync": "^4.0.2", 48 | "@types/jest": "^28.1.8", 49 | "@types/lodash.merge": "^4.6.7", 50 | "@typescript-eslint/eslint-plugin": "^5.35.1", 51 | "@typescript-eslint/parser": "^5.35.1", 52 | "coveralls": "^3.1.1", 53 | "eslint": "^8.22.0", 54 | "eslint-config-airbnb-base": "^15.0.0", 55 | "eslint-plugin-import": "^2.26.0", 56 | "husky": "^8.0.0", 57 | "jest": "^28.1.3", 58 | "lint-staged": "^13.0.3", 59 | "randomstring": "^1.2.2", 60 | "sinon": "^14.0.0", 61 | "ts-jest": "^28.0.8", 62 | "ts-node": "^10.9.1", 63 | "typescript": "^4.7.4" 64 | }, 65 | "dependencies": { 66 | "chalk": "^4.1.2", 67 | "execa": "^5.1.1", 68 | "findup-sync": "^5.0.0", 69 | "fs-extra": "^10.1.0", 70 | "git-commit-count": "^1.1.3", 71 | "git-commit-range": "^1.2.0", 72 | "inquirer": "^8.2.4", 73 | "inquirer-autocomplete-prompt": "^2.0.0", 74 | "is-git-added": "^1.0.2", 75 | "is-git-repository": "^2.0.0", 76 | "json-extra": "^2.0.1", 77 | "lodash.merge": "^4.6.2", 78 | "object.entries": "^1.1.5", 79 | "path-is-absolute": "^2.0.0", 80 | "temp-dir": "^2.0.0", 81 | "update-notifier": "^6.0.2", 82 | "yargs": "^17.5.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "declaration": false, 6 | "lib": [ 7 | "es2018" 8 | ], 9 | "allowJs": true, 10 | "checkJs": false, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "outDir": "dest", 15 | "rootDir": "lib" 16 | }, 17 | "include": [ 18 | "lib" 19 | ], 20 | "files": [ 21 | "./global.d.ts" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------