├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── pr_commits.yml │ └── pr_title.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .pubignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── bin └── commitlint_cli.dart ├── docs ├── .nojekyll ├── README.md ├── _sidebar.md ├── concepts-convensional-commits.md ├── concepts-shareable-config.md ├── guides-setup.md ├── index.html ├── references-cli.md └── references-rules.md ├── example └── README.md ├── lib ├── commitlint.yaml └── src │ ├── ensure.dart │ ├── format.dart │ ├── is_ignored.dart │ ├── lint.dart │ ├── load.dart │ ├── parse.dart │ ├── read.dart │ ├── rules.dart │ ├── runner.dart │ └── types │ ├── case.dart │ ├── commit.dart │ ├── commitlint.dart │ ├── format.dart │ ├── lint.dart │ ├── parser.dart │ └── rule.dart ├── pubspec.yaml └── test ├── __fixtures__ ├── empty.yaml ├── include-package.yaml ├── include-path.yaml ├── only-rules.yaml └── parser-options.yaml ├── format_test.dart ├── ignore_test.dart ├── lint_test.dart ├── load_test.dart ├── parse_test.dart ├── read_test.dart └── utils.dart /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pub" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: CI 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: dart-lang/setup-dart@v1.3 21 | 22 | - run: dart --version 23 | 24 | - name: Get Dependencies 25 | run: dart pub get 26 | 27 | - name: Analyze 28 | run: dart analyze . --fatal-infos 29 | 30 | - name: Check Format 31 | run: dart format . --output=none --set-exit-if-changed 32 | 33 | - name: Run tests 34 | run: dart test --reporter expanded 35 | -------------------------------------------------------------------------------- /.github/workflows/pr_commits.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: [ "main" ] 4 | types: [opened, synchronize] 5 | 6 | jobs: 7 | validate: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 13 | 14 | - uses: dart-lang/setup-dart@v1.3 15 | 16 | - name: Get Dependencies 17 | run: dart pub get 18 | 19 | - name: Validate PR Commits 20 | run: VERBOSE=true dart run commitlint_cli --from=${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to=${{ github.event.pull_request.head.sha }} --config lib/commitlint.yaml 21 | -------------------------------------------------------------------------------- /.github/workflows/pr_title.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: [ "main" ] 4 | types: [opened, synchronize] 5 | 6 | jobs: 7 | validate: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: dart-lang/setup-dart@v1.3 12 | 13 | - name: Get Dependencies 14 | run: dart pub get 15 | 16 | - name: Validate Title of PR 17 | run: echo '${{ github.event.pull_request.title }}' | dart bin/commitlint_cli.dart --config lib/commitlint.yaml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /**/pubspec.lock 2 | .idea/ 3 | *.iml 4 | .dart_tool/ 5 | .DS_Store -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | VERBOSE=true dart bin/commitlint_cli.dart --edit "$1" --config lib/commitlint.yaml 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | dart run lint_staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | dart test 5 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | test/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.8.1 2 | 3 | - Fix `parserOptions` not passed to `lint` 4 | 5 | ## 0.8.0 6 | 7 | - Support parser options in `commitlint.yaml` under section `parser` 8 | 9 | ## 0.7.2 10 | 11 | - ignoring mulitline merge commit message (fix #20) 12 | 13 | ## 0.7.1 14 | 15 | - rule should pass if commit component raw is null (fix #18) 16 | 17 | ## 0.7.0 18 | 19 | - Support `references-empty` rule. 20 | 21 | ## 0.6.3 22 | 23 | - Bump `ansi` version `0.4.0` 24 | 25 | ## 0.6.2 26 | 27 | - Bump `ansi` version to `0.3.0`, `change_case` to `1.1.0` 28 | 29 | ## 0.6.1 30 | 31 | - Fix bug in reading history commits when body contains multi lines 32 | 33 | ## 0.6.0 34 | 35 | > Note: This release has breaking changes. 36 | 37 | - **BREAKING** **FEAT**: Support ignores commit messages. Default ignores patterns are: 38 | - `r'((Merge pull request)|(Merge (.*?) into (.*?)|(Merge branch (.*?)))(?:\r?\n)*$)'` 39 | - `r'(Merge tag (.*?))(?:\r?\n)*$'` 40 | - `r'(R|r)evert (.*)'` 41 | - `r'(fixup|squash)!'` 42 | - `r'(Merged (.*?)(in|into) (.*)|Merged PR (.*): (.*))'` 43 | - `r'Merge remote-tracking branch(\s*)(.*)'` 44 | - `r'Automatic merge(.*)'` 45 | - `r'Auto-merged (.*?) into (.*)'` 46 | 47 | ## 0.5.0 48 | 49 | > Note: This release has breaking changes. 50 | 51 | - **BREAKING** **FEAT**: throw exception if read commit message failed 52 | - **FEAT**: support multi scopes 53 | - **FIX**: print empty when output is empty (fix #9) 54 | 55 | ## 0.4.2 56 | 57 | - Set dart sdk minVersion to 2.15.0 58 | ## 0.4.1 59 | 60 | - Add exmaple README.md 61 | 62 | ## 0.4.0 63 | 64 | > Note: This release has breaking changes. 65 | 66 | - **BREAKING** **FEAT**: remove `--version` 67 | - **BREAKING** **FEAT**: Replace support of `DEBUG=true` env to `VERBOSE=true` by using package [`verbose`](https://pub.dev/packages/verbose). 68 | - Fix parse `!` like `feat!:subject`. 69 | - Fix parse Merge commit. 70 | 71 | ## 0.3.0 72 | 73 | > Note: This release has breaking changes. 74 | 75 | - **BREAKING** **REFACTOR**: Make all `commitlint_*` packages into one `commitlint_cli` package. 76 | - Move `package:commitlint_config/commitlint.yaml` to `package:commitlint_cli/commitlint.yaml`. 77 | - Support `DEBUG=true` env to print verbose message. 78 | ## 0.2.1+1 79 | 80 | - Update a dependency to the latest release. 81 | 82 | ## 0.2.1 83 | 84 | - **FEAT**: add documentation link. ([305bb990](https://github.com/hyiso/commitlint/commit/305bb990f0e1f70e6f0ca7266231603a28c84820)) 85 | 86 | ## 0.2.0 87 | 88 | > Note: This release has breaking changes. 89 | 90 | - **FEAT**: change cli to CommandRunner. ([f8b640ab](https://github.com/hyiso/commitlint/commit/f8b640ab1b337ed27ae4b37808d4fea74869c709)) 91 | - **BREAKING** **FEAT**: change --edit preceded to --from and --to. ([fb9a6a8d](https://github.com/hyiso/commitlint/commit/fb9a6a8d33b87d8ee3784642e284a68b6cc90dea)) 92 | 93 | ## 0.1.0+1 94 | 95 | - **DOCS**: add usage documentation. ([23f70976](https://github.com/hyiso/commitlint/commit/23f70976f2bb87776a0951f6fb7ccb067f743c52)) 96 | 97 | ## 0.1.0 98 | 99 | - Initial version. 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Zhu Huiyuan Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # commitlint 2 | 3 | [![Pub Version](https://img.shields.io/pub/v/commitlint_cli?color=blue)](https://pub.dev/packages/commitlint_cli) 4 | [![popularity](https://img.shields.io/pub/popularity/commitlint_cli?logo=dart)](https://pub.dev/packages/commitlint_cli/score) 5 | [![likes](https://img.shields.io/pub/likes/commitlint_cli?logo=dart)](https://pub.dev/packages/commitlint_cli/score) 6 | [![CI](https://github.com/hyiso/commitlint/actions/workflows/ci.yml/badge.svg)](https://github.com/hyiso/commitlint/actions/workflows/ci.yml) 7 | 8 | > Dart version commitlint - A tool to lint commit messages. (Inspired by JavaScript [commitlint](https://github.com/conventional-changelog/commitlint)) 9 | 10 | commitlint lint commit messages to satisfy [conventional commit format](https://www.conventionalcommits.org/) 11 | 12 | commitlint helps your team adhere to a commit convention. By supporting pub-installed configurations it makes sharing of commit conventions easy. 13 | 14 | ## About Package Name 15 | 16 | Because a package `commit_lint` already exists (not in active development), the name `commitlint` can't be used according to [Pub's naming policy](https://pub.dev/policy#naming-policy). So `commitlint_cli` is used currently. 17 | 18 | 19 | ## Getting started 20 | 21 | ### Install 22 | 23 | Add `commitlint_cli` to your `dev_dependencies` in pubspec.yaml 24 | 25 | ```bash 26 | # Install commitlint_cli 27 | dart pub add --dev commitlint_cli 28 | ``` 29 | 30 | ### Configuration 31 | 32 | ```bash 33 | # Simply use configuration of a package 34 | echo "include: package:commitlint_cli/commitlint.yaml" > commitlint.yaml 35 | ``` 36 | You can also customize your configuration in `commitlint.yaml` 37 | ```yaml 38 | # Inherit configuration of a package 39 | include: package:commitlint_cli/commitlint.yaml 40 | 41 | # Custom rules 42 | rules: 43 | type-case: 44 | - 2 45 | - always 46 | - 'upper-case' 47 | 48 | # Whether commitlint uses the default ignore rules. 49 | defaultIgnores: true 50 | # Pattern that matches commit message if commitlint should ignore the given message. 51 | ignores: 52 | - r'^fixup' 53 | ``` 54 | 55 | 56 | ### Test 57 | 58 | ```bash 59 | # Lint from stdin 60 | echo 'foo: bar' | dart run commitlint_cli 61 | ⧗ input: type: add docs 62 | ✖ type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] type-enum 63 | 64 | ✖ found 1 errors, 0 warnings 65 | ``` 66 | 67 | **Note: output on successful commit will be omitted, you can use the `VERBOSE=true` env to get positive output.** (Functionality of [verbose](https://pub.dev/packages/verbose)) 68 | 69 | ```bash 70 | # Output on successful commit will be omitted 71 | echo 'feat: test message' | dart run commitlint_cli 72 | # Verbse Output on successful commit 73 | echo 'feat: test message' | VERBOSE=true dart run commitlint_cli 74 | ``` 75 | ## Setup git hook 76 | 77 | With [husky](https://pub.dev/packages/husky) (a tool for managing git hooks), commitlint cli can be used in `commmit-msg` git hook 78 | 79 | ### Set `commit-msg` hook: 80 | 81 | ```sh 82 | dart pub add --dev husky 83 | dart run husky install 84 | dart run husky set .husky/commit-msg 'dart run commitlint_cli --edit "$1"' 85 | ``` 86 | 87 | ### Make a commit: 88 | 89 | ```sh 90 | git add . 91 | git commit -m "Keep calm and commit" 92 | # `dart run commitlint_cli --edit "$1"` will run 93 | ``` 94 | 95 | > To get the most out of `commitlint` you'll want to automate it in your project lifecycle. See our [Setup guide](https://hyiso.github.io/commitlint/#/guides-setup) for next steps. 96 | 97 | ## Documentation 98 | 99 | See [documention](https://hyiso.github.io/commitlint) 100 | 101 | - **Guides** - Common use cases explained in a step-by-step pace 102 | - **Concepts** - Overarching topics important to understand the use of `commitlint` 103 | - **Reference** - Mostly technical documentation 104 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /bin/commitlint_cli.dart: -------------------------------------------------------------------------------- 1 | import 'package:commitlint_cli/src/runner.dart'; 2 | 3 | Future main(List args) async { 4 | await CommitLintRunner().run(args); 5 | } 6 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyiso/commitlint/eaad24cb94e227ccb6554c577af07673c453f72b/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - Guides 2 | - [Setup](guides-setup.md) 3 | - Concepts 4 | - [Convensional commits](concepts-convensional-commits.md) 5 | - [Shareable config](concepts-shareable-config.md) 6 | - References 7 | - [CLI](references-cli.md) 8 | - [Rules](references-rules.md) -------------------------------------------------------------------------------- /docs/concepts-convensional-commits.md: -------------------------------------------------------------------------------- 1 | # Concept: Convensional commits 2 | 3 | [Convensional commits](https://www.conventionalcommits.org/) allow your team to add more semantic meaning to your git history. This e.g. includes `type`, `scope` or `breaking changes`. 4 | 5 | With this additional information tools can derive useful human-readable information for releases of your project. Some examples are 6 | 7 | - Automated, rich changelogs 8 | - Automatic version bumps 9 | - Filter for test harnesses to run 10 | 11 | The most common convensional commits follow this pattern: 12 | 13 | ``` 14 | type(scope?): subject 15 | body? 16 | footer? 17 | ``` 18 | 19 | ## Multiple scopes 20 | 21 | Commitlint supports multiple scopes. 22 | Current delimiter options are: 23 | 24 | - "/" 25 | - "\\" 26 | - "," -------------------------------------------------------------------------------- /docs/concepts-shareable-config.md: -------------------------------------------------------------------------------- 1 | # Concept: Shareable configuration 2 | 3 | Most commonly shareable configuration is delivered as pub package exporting one or multi 4 | `.yaml` file(s) containing `rules` section. To use shared configuration you specify it as value for key `include` 5 | 6 | ```yaml 7 | # commitlint.yaml 8 | include: package:commitlint_cli/commitlint.yaml 9 | ``` 10 | 11 | This causes `commitlint` to pick up `commitlint_cli/commitlint.yaml`. 12 | 13 | The rules found in `commitlint_cli/commitlint.yaml` are merged with the rules in `commitlint.yaml`, if any. 14 | 15 | This works recursively, enabling shareable configuration to extend on an indefinite chain of other shareable configurations. 16 | 17 | ## Relative config 18 | 19 | You can also load local configuration by using a relative path to the file. 20 | 21 | > This must always start with a `.` (dot). 22 | 23 | ```yaml 24 | # commitlint.yaml 25 | include: ./lints/commitlint.yaml 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/guides-setup.md: -------------------------------------------------------------------------------- 1 | # Guide: Setup 2 | 3 | Get high commit message quality and short feedback cycles by linting commit messages right when they are authored. 4 | 5 | This guide demonstrates how to achieve this via git hooks. 6 | 7 | ## Install commitlint 8 | 9 | Install `commitlint_cli` as dev dependency and configure `commitlint` to use it. 10 | 11 | ```bash 12 | # Install and configure if needed 13 | dart pub add --dev commitlint_cli 14 | 15 | # Configure commitlint to use conventional cli config 16 | echo "include: package:commitlint_cli/commitlint.yaml" > commitlint.yaml 17 | ``` 18 | 19 | ## Install husky 20 | 21 | Install [husky](https://pub.dev/packages/husky) as dev dependency, a handy git hook helper available on pub. 22 | 23 | ```sh 24 | # Install Husky 25 | dart pub add --dev husky 26 | 27 | # Activate hooks 28 | dart run husky install 29 | ``` 30 | 31 | ### Add hook 32 | 33 | ``` 34 | dart run husky add .husky/commit-msg 'dart run commitlint_cli --edit ${1}' 35 | ``` 36 | 37 | ## Test 38 | 39 | ### Test simple usage 40 | 41 | For a first simple usage test of commitlint you can do the following: 42 | 43 | ```bash 44 | dart run commitlint_cli --from HEAD~1 --to HEAD 45 | ``` 46 | 47 | This will check your last commit and return an error if invalid or a positive output if valid. 48 | 49 | ### Test the hook 50 | 51 | You can test the hook by simply committing. You should see something like this if everything works. 52 | 53 | ```bash 54 | git commit -m "foo: this will fail" 55 | No staged files match any of provided globs. 56 | ⧗ input: foo: this will fail 57 | ✖ type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum] 58 | 59 | ✖ found 1 problems, 0 warnings 60 | ⓘ Get help: http://hyiso.github.io/commitlint/#/concepts-convensional-commits 61 | 62 | husky - commit-msg hook exited with code 1 (add --no-verify to bypass) 63 | ``` 64 | 65 | ## Setup Github Actions 66 | 67 | ### Validate PR Title 68 | Add `.github/workflows/pr_title.yml` 69 | ```yaml 70 | on: 71 | pull_request: 72 | branches: [ "main" ] 73 | types: [opened, synchronize] 74 | 75 | jobs: 76 | validate: 77 | runs-on: ubuntu-latest 78 | steps: 79 | - uses: actions/checkout@v3 80 | - uses: dart-lang/setup-dart@v1.3 81 | 82 | - name: Get Dependencies 83 | run: dart pub get 84 | 85 | - name: Validate Title of PR 86 | run: echo ${{ github.event.pull_request.title }} | dart run commitlint_cli 87 | ``` 88 | 89 | ### Validate PR Commits 90 | Add `.github/workflows/pr_commits.yml` 91 | ```yaml 92 | on: 93 | pull_request: 94 | branches: [ "main" ] 95 | types: [opened, synchronize] 96 | 97 | jobs: 98 | validate: 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v3 102 | with: 103 | fetch-depth: 0 104 | 105 | - uses: dart-lang/setup-dart@v1.3 106 | 107 | - name: Get Dependencies 108 | run: dart pub get 109 | 110 | - name: Validate PR Commits 111 | run: VERBOSE=true dart run commitlint_cli --from=${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to=${{ github.event.pull_request.head.sha }} --config lib/commitlint.yaml 112 | ``` 113 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | commitlint - Lint commit messages 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /docs/references-cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | ```bash 4 | $ dart run commitlint_cli --help 5 | 6 | commitlint - Lint commit messages 7 | 8 | Usage: commitlint [arguments] 9 | 10 | [input] reads from stdin if --edit, --from and --to are omitted 11 | 12 | Global options: 13 | -h, --help Print this usage information. 14 | --config path to the config file 15 | (defaults to "commitlint.yaml") 16 | --edit read last commit message from the specified file. 17 | --from lower end of the commit range to lint. This is succeeded to --edit 18 | --to upper end of the commit range to lint. This is succeeded to --edit 19 | ``` -------------------------------------------------------------------------------- /docs/references-rules.md: -------------------------------------------------------------------------------- 1 | # Rules 2 | 3 | Rules are made up by a name and a configuration list. The configuration list contains: 4 | 5 | - **Level** `[0..2]`: `0` disables the rule. For `1` it will be considered a warning for `2` an error. 6 | - **Applicable** `always|never`: `never` inverts the rule. 7 | - **Value**: value to use for this rule. 8 | 9 | ```yaml 10 | rules: 11 | type-case: 12 | - 2 13 | - always 14 | - lower-case 15 | ``` 16 | 17 | ### Available rules 18 | 19 | #### body-full-stop 20 | 21 | - **condition**: `body` ends with `value` 22 | - **rule**: `never` 23 | - **value** 24 | 25 | ``` 26 | '.' 27 | ``` 28 | 29 | #### body-leading-blank 30 | 31 | - **condition**: `body` begins with blank line 32 | - **rule**: `always` 33 | 34 | #### body-empty 35 | 36 | - **condition**: `body` is empty 37 | - **rule**: `never` 38 | 39 | #### body-max-length 40 | 41 | - **condition**: `body` has `value` or less characters 42 | - **rule**: `always` 43 | - **value** 44 | 45 | ``` 46 | Infinity 47 | ``` 48 | 49 | #### body-max-line-length 50 | 51 | - **condition**: `body` lines has `value` or less characters 52 | - **rule**: `always` 53 | - **value** 54 | 55 | ``` 56 | Infinity 57 | ``` 58 | 59 | #### body-min-length 60 | 61 | - **condition**: `body` has `value` or more characters 62 | - **rule**: `always` 63 | - **value** 64 | 65 | ``` 66 | 0 67 | ``` 68 | 69 | #### body-case 70 | 71 | - **condition**: `body` is in case `value` 72 | - **rule**: `always` 73 | - **value** 74 | 75 | ``` 76 | 'lower-case' 77 | ``` 78 | 79 | - **possible values** 80 | 81 | ```yaml 82 | - lower-case # default 83 | - upper-case # UPPERCASE 84 | - camel-case # camelCase 85 | - kebab-case # kebab-case 86 | - pascal-case # PascalCase 87 | - sentence-case # Sentence case 88 | - snake-case # snake_case 89 | - start-case # Start Case 90 | ``` 91 | 92 | #### footer-leading-blank 93 | 94 | - **condition**: `footer` begins with blank line 95 | - **rule**: `always` 96 | 97 | #### footer-empty 98 | 99 | - **condition**: `footer` is empty 100 | - **rule**: `never` 101 | 102 | #### footer-max-length 103 | 104 | - **condition**: `footer` has `value` or less characters 105 | - **rule**: `always` 106 | - **value** 107 | 108 | ``` 109 | Infinity 110 | ``` 111 | 112 | #### footer-max-line-length 113 | 114 | - **condition**: `footer` lines has `value` or less characters 115 | - **rule**: `always` 116 | - **value** 117 | 118 | ``` 119 | Infinity 120 | ``` 121 | 122 | #### footer-min-length 123 | 124 | - **condition**: `footer` has `value` or more characters 125 | - **rule**: `always` 126 | - **value** 127 | 128 | ``` 129 | 0 130 | ``` 131 | 132 | #### header-case 133 | 134 | - **condition**: `header` is in case `value` 135 | - **rule**: `always` 136 | - **value** 137 | 138 | ``` 139 | 'lower-case' 140 | ``` 141 | 142 | - **possible values** 143 | 144 | ```yaml 145 | - lower-case # default 146 | - upper-case # UPPERCASE 147 | - camel-case # camelCase 148 | - kebab-case # kebab-case 149 | - pascal-case # PascalCase 150 | - sentence-case # Sentence case 151 | - snake-case # snake_case 152 | - start-case # Start Case 153 | ``` 154 | 155 | #### header-full-stop 156 | 157 | - **condition**: `header` ends with `value` 158 | - **rule**: `never` 159 | - **value** 160 | 161 | ``` 162 | '.' 163 | ``` 164 | 165 | #### header-max-length 166 | 167 | - **condition**: `header` has `value` or less characters 168 | - **rule**: `always` 169 | - **value** 170 | 171 | ``` 172 | 72 173 | ``` 174 | 175 | #### header-min-length 176 | 177 | - **condition**: `header` has `value` or more characters 178 | - **rule**: `always` 179 | - **value** 180 | 181 | ``` 182 | 0 183 | ``` 184 | 185 | #### scope-enum 186 | 187 | - **condition**: `scope` is found in value 188 | - **rule**: `always` 189 | - **value** 190 | ``` 191 | [] 192 | ``` 193 | 194 | #### scope-case 195 | 196 | - **condition**: `scope` is in case `value` 197 | - **rule**: `always` 198 | - **value** 199 | 200 | ``` 201 | 'lower-case' 202 | ``` 203 | 204 | - **possible values** 205 | 206 | ```yaml 207 | - lower-case # default 208 | - upper-case # UPPERCASE 209 | - camel-case # camelCase 210 | - kebab-case # kebab-case 211 | - pascal-case # PascalCase 212 | - sentence-case # Sentence case 213 | - snake-case # snake_case 214 | - start-case # Start Case 215 | ``` 216 | 217 | #### scope-empty 218 | 219 | - **condition**: `scope` is empty 220 | - **rule**: `never` 221 | 222 | #### scope-max-length 223 | 224 | - **condition**: `scope` has `value` or less characters 225 | - **rule**: `always` 226 | - **value** 227 | 228 | ``` 229 | Infinity 230 | ``` 231 | 232 | #### scope-min-length 233 | 234 | - **condition**: `scope` has `value` or more characters 235 | - **rule**: `always` 236 | - **value** 237 | 238 | ``` 239 | 0 240 | ``` 241 | 242 | #### type-enum 243 | 244 | - **condition**: `type` is found in value 245 | - **rule**: `always` 246 | - **value** 247 | ```js 248 | ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'revert'] 249 | ``` 250 | 251 | #### type-case 252 | 253 | - **description**: `type` is in case `value` 254 | - **rule**: `always` 255 | - **value** 256 | ``` 257 | 'lower-case' 258 | ``` 259 | - **possible values** 260 | 261 | ```yaml 262 | - lower-case # default 263 | - upper-case # UPPERCASE 264 | - camel-case # camelCase 265 | - kebab-case # kebab-case 266 | - pascal-case # PascalCase 267 | - sentence-case # Sentence case 268 | - snake-case # snake_case 269 | - start-case # Start Case 270 | ``` 271 | 272 | #### type-empty 273 | 274 | - **condition**: `type` is empty 275 | - **rule**: `never` 276 | 277 | #### type-max-length 278 | 279 | - **condition**: `type` has `value` or less characters 280 | - **rule**: `always` 281 | - **value** 282 | 283 | ``` 284 | Infinity 285 | ``` 286 | 287 | #### type-min-length 288 | 289 | - **condition**: `type` has `value` or more characters 290 | - **rule**: `always` 291 | - **value** 292 | 293 | ``` 294 | 0 295 | ``` 296 | 297 | #### subject-full-stop 298 | 299 | - **condition**: `subject` ends with `value` 300 | - **rule**: `never` 301 | - **value** 302 | 303 | ``` 304 | '.' 305 | ``` 306 | 307 | #### subject-case 308 | 309 | - **condition**: `subject` is in case `value` 310 | - **rule**: `always` 311 | - **value** 312 | 313 | ``` 314 | 'lower-case' 315 | ``` 316 | 317 | - **possible values** 318 | 319 | ```yaml 320 | - lower-case # default 321 | - upper-case # UPPERCASE 322 | - camel-case # camelCase 323 | - kebab-case # kebab-case 324 | - pascal-case # PascalCase 325 | - sentence-case # Sentence case 326 | - snake-case # snake_case 327 | - start-case # Start Case 328 | ``` 329 | 330 | #### subject-empty 331 | 332 | - **condition**: `subject` is empty 333 | - **rule**: `never` 334 | 335 | #### subject-max-length 336 | 337 | - **condition**: `subject` has `value` or less characters 338 | - **rule**: `always` 339 | - **value** 340 | 341 | ``` 342 | Infinity 343 | ``` 344 | 345 | #### subject-min-length 346 | 347 | - **condition**: `subject` has `value` or more characters 348 | - **rule**: `always` 349 | - **value** 350 | 351 | ``` 352 | 0 353 | ``` 354 | 355 | #### references-empty 356 | 357 | - **condition**: `references` has at least one entry 358 | - **rule**: `never` -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /lib/commitlint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | type-case: 3 | - 2 4 | - always 5 | - lower-case 6 | type-empty: 7 | - 2 8 | - never 9 | type-enum: 10 | - 2 11 | - always 12 | - - build 13 | - chore 14 | - ci 15 | - docs 16 | - feat 17 | - fix 18 | - perf 19 | - refactor 20 | - revert 21 | - style 22 | - test 23 | scope-enum: 24 | - 2 25 | - always 26 | - - ci 27 | - deps 28 | - release -------------------------------------------------------------------------------- /lib/src/ensure.dart: -------------------------------------------------------------------------------- 1 | import 'package:change_case/change_case.dart'; 2 | 3 | import 'types/case.dart'; 4 | 5 | bool ensureCase(dynamic raw, Case target) { 6 | if (raw is Iterable) { 7 | return raw.isEmpty || raw.every((element) => ensureCase(element, target)); 8 | } 9 | if (raw is String) { 10 | switch (target) { 11 | case Case.lower: 12 | return raw.toLowerCase() == raw; 13 | case Case.upper: 14 | return raw.toUpperCase() == raw; 15 | case Case.camel: 16 | return raw.toCamelCase() == raw; 17 | case Case.kebab: 18 | return raw.toKebabCase() == raw; 19 | case Case.pascal: 20 | return raw.toPascalCase() == raw; 21 | case Case.sentence: 22 | return raw.toSentenceCase() == raw; 23 | case Case.snake: 24 | return raw.toSnakeCase() == raw; 25 | case Case.capital: 26 | return raw.toCapitalCase() == raw; 27 | } 28 | } 29 | return false; 30 | } 31 | 32 | bool ensureFullStop(String raw, String target) { 33 | return raw.endsWith(target); 34 | } 35 | 36 | bool ensureLeadingBlank(String raw) { 37 | return raw.startsWith('\n'); 38 | } 39 | 40 | bool ensureMaxLength(dynamic raw, num maxLength) { 41 | if (raw is String) { 42 | return raw.length <= maxLength; 43 | } 44 | if (raw is Iterable) { 45 | return raw.isEmpty || raw.every((element) => element.length <= maxLength); 46 | } 47 | return false; 48 | } 49 | 50 | bool ensureMaxLineLength(String raw, num maxLineLength) { 51 | return raw 52 | .split(RegExp(r'(?:\r?\n)')) 53 | .every((line) => ensureMaxLength(line, maxLineLength)); 54 | } 55 | 56 | bool ensureMinLength(dynamic raw, num minLength) { 57 | if (raw is String) { 58 | return raw.length >= minLength; 59 | } 60 | if (raw is Iterable) { 61 | return raw.isEmpty || raw.every((element) => element.length >= minLength); 62 | } 63 | return false; 64 | } 65 | 66 | bool ensureEmpty(dynamic raw) { 67 | if (raw is String) { 68 | return raw.isEmpty; 69 | } 70 | if (raw is Iterable) { 71 | return raw.isEmpty; 72 | } 73 | return false; 74 | } 75 | 76 | bool ensureEnum(dynamic raw, Iterable enums) { 77 | if (raw is String) { 78 | return raw.isEmpty || enums.contains(raw); 79 | } 80 | if (raw is Iterable) { 81 | return raw.isEmpty || raw.every((element) => enums.contains(element)); 82 | } 83 | return false; 84 | } 85 | -------------------------------------------------------------------------------- /lib/src/format.dart: -------------------------------------------------------------------------------- 1 | import 'package:ansi/ansi.dart'; 2 | import 'package:verbose/verbose.dart'; 3 | import 'types/format.dart'; 4 | import 'types/lint.dart'; 5 | 6 | const _kDefaultSigns = [' ', '⚠', '✖']; 7 | final _defaultColors = [white, yellow, red]; 8 | 9 | /// 10 | /// Format commitlint [report] to formatted output message. 11 | /// 12 | String format({ 13 | required FormattableReport report, 14 | }) { 15 | return report.results 16 | .map((result) => [..._formatInput(result), ..._formatResult(result)]) 17 | .fold>( 18 | [], 19 | (previousValue, element) => 20 | [...previousValue, ...element]).join('\n'); 21 | } 22 | 23 | List _formatInput(LintOutcome result) { 24 | final sign = '⧗'; 25 | final decoration = gray(sign); 26 | final commitText = 27 | result.errors.isNotEmpty ? result.input : result.input.split('\n').first; 28 | final decoratedInput = bold(commitText); 29 | final hasProblem = result.errors.isNotEmpty || result.warnings.isNotEmpty; 30 | return hasProblem || Verbose.enabled 31 | ? ['$decoration input: $decoratedInput'] 32 | : []; 33 | } 34 | 35 | List _formatResult(LintOutcome result) { 36 | final problems = [...result.errors, ...result.warnings].map((problem) { 37 | final sign = _kDefaultSigns[problem.level.index]; 38 | final color = _defaultColors[problem.level.index]; 39 | final decoration = color(sign); 40 | final name = gray(problem.name); 41 | return '$decoration ${problem.message} $name'; 42 | }); 43 | 44 | final sign = _selectSign(result); 45 | final color = _selectColor(result); 46 | final decoration = color(sign); 47 | final summary = problems.isNotEmpty || Verbose.enabled 48 | ? '$decoration found ${result.errors.length} error(s), ${result.warnings.length} warning(s)' 49 | : ''; 50 | final fmtSummary = summary.isNotEmpty ? bold(summary) : summary; 51 | return [ 52 | ...problems, 53 | if (problems.isNotEmpty) '', 54 | fmtSummary.toString(), 55 | if (problems.isNotEmpty) '', 56 | ]; 57 | } 58 | 59 | String _selectSign(LintOutcome result) { 60 | if (result.errors.isNotEmpty) { 61 | return '✖'; 62 | } 63 | return result.warnings.isNotEmpty ? '⚠' : '✔'; 64 | } 65 | 66 | String Function(String) _selectColor(LintOutcome result) { 67 | if (result.errors.isNotEmpty) { 68 | return red; 69 | } 70 | return result.warnings.isNotEmpty ? yellow : green; 71 | } 72 | -------------------------------------------------------------------------------- /lib/src/is_ignored.dart: -------------------------------------------------------------------------------- 1 | bool isIgnored(String message, 2 | {bool? defaultIgnores, Iterable? ignores}) { 3 | return [if (defaultIgnores != false) ..._wildcards, ...?ignores] 4 | .any((pattern) => RegExp(pattern).hasMatch(message)); 5 | } 6 | 7 | final _wildcards = [ 8 | r'((Merge pull request)|(Merge (.*?) into (.*?)|(Merge branch (.*?)))(?:\r?\n)*)', 9 | r'(Merge tag (.*?))(?:\r?\n)*$', 10 | r'(R|r)evert (.*)', 11 | r'(fixup|squash)!', 12 | r'(Merged (.*?)(in|into) (.*)|Merged PR (.*): (.*))', 13 | r'Merge remote-tracking branch(\s*)(.*)', 14 | r'Automatic merge(.*)', 15 | r'Auto-merged (.*?) into (.*)', 16 | ]; 17 | -------------------------------------------------------------------------------- /lib/src/lint.dart: -------------------------------------------------------------------------------- 1 | import 'is_ignored.dart'; 2 | import 'parse.dart'; 3 | import 'rules.dart'; 4 | import 'types/commit.dart'; 5 | import 'types/lint.dart'; 6 | import 'types/parser.dart'; 7 | import 'types/rule.dart'; 8 | 9 | /// 10 | /// Lint commit [message] with configured [rules] 11 | /// 12 | Future lint( 13 | String message, 14 | Map rules, { 15 | ParserOptions? parserOptions, 16 | bool? defaultIgnores, 17 | Iterable? ignores, 18 | }) async { 19 | /// Found a wildcard match, skip 20 | if (isIgnored(message, defaultIgnores: defaultIgnores, ignores: ignores)) { 21 | return LintOutcome(input: message, valid: true, errors: [], warnings: []); 22 | } 23 | 24 | /// Parse the commit message 25 | final commit = 26 | message.isEmpty ? Commit.empty() : parse(message, options: parserOptions); 27 | 28 | if (commit.header.isEmpty && commit.body == null && commit.footer == null) { 29 | /// Commit is empty, skip 30 | return LintOutcome(input: message, valid: true, errors: [], warnings: []); 31 | } 32 | final allRules = Map.of(supportedRules); 33 | 34 | /// Find invalid rules configs 35 | final missing = rules.keys.where((key) => !allRules.containsKey(key)); 36 | if (missing.isNotEmpty) { 37 | final names = [...allRules.keys]; 38 | throw RangeError( 39 | 'Found invalid rule names: ${missing.join(', ')}. \nSupported rule names are: ${names.join(', ')}'); 40 | } 41 | 42 | /// Validate against all rules 43 | final results = rules.entries 44 | // Level 0 rules are ignored 45 | .where((entry) => entry.value.severity != RuleSeverity.ignore) 46 | .map((entry) { 47 | final name = entry.key; 48 | final config = entry.value; 49 | final rule = allRules[name]; 50 | 51 | if (rule == null) { 52 | throw Exception('Could not find rule implementation for $name'); 53 | } 54 | final ruleOutcome = rule.call(commit, config); 55 | 56 | return LintRuleOutcome( 57 | valid: ruleOutcome.valid, 58 | level: config.severity, 59 | name: name, 60 | message: ruleOutcome.message, 61 | ); 62 | }) 63 | .where((outcome) => !outcome.valid) 64 | .toList(); 65 | final errors = 66 | results.where((element) => element.level == RuleSeverity.error); 67 | final warnings = 68 | results.where((element) => element.level == RuleSeverity.warning); 69 | return LintOutcome( 70 | input: message, 71 | valid: errors.isEmpty, 72 | errors: errors, 73 | warnings: warnings, 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/load.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:isolate'; 3 | 4 | import 'package:path/path.dart'; 5 | import 'package:yaml/yaml.dart'; 6 | 7 | import 'types/case.dart'; 8 | import 'types/commitlint.dart'; 9 | import 'types/parser.dart'; 10 | import 'types/rule.dart'; 11 | 12 | /// 13 | /// Load configured rules in given [path] from given [directory]. 14 | /// 15 | Future load( 16 | String path, { 17 | Directory? directory, 18 | }) async { 19 | File? file; 20 | if (!path.startsWith('package:')) { 21 | final uri = toUri(join(directory?.path ?? Directory.current.path, path)); 22 | file = File.fromUri(uri); 23 | } else { 24 | final uri = await Isolate.resolvePackageUri(Uri.parse(path)); 25 | if (uri != null) { 26 | file = File.fromUri(uri); 27 | } 28 | } 29 | if (file != null && file.existsSync()) { 30 | final yaml = loadYaml(await file.readAsString()); 31 | final include = yaml?['include'] as String?; 32 | final rules = yaml?['rules'] as YamlMap?; 33 | final ignores = yaml?['ignores'] as YamlList?; 34 | final defaultIgnores = yaml?['defaultIgnores'] as bool?; 35 | final parser = yaml?['parser'] as YamlMap?; 36 | final config = CommitLint( 37 | rules: 38 | rules?.map((key, value) => MapEntry(key, _extractRule(value))) ?? {}, 39 | ignores: ignores?.cast(), 40 | defaultIgnores: defaultIgnores, 41 | parser: parser != null ? ParserOptions.fromYaml(parser) : null, 42 | ); 43 | if (include != null) { 44 | final upstream = await load(include, directory: file.parent); 45 | return config.inherit(upstream); 46 | } 47 | return config; 48 | } 49 | return CommitLint(); 50 | } 51 | 52 | Rule _extractRule(dynamic config) { 53 | if (config is! List) { 54 | throw Exception('rule config must be list, but get $config'); 55 | } 56 | if (config.isEmpty || config.length < 2 || config.length > 3) { 57 | throw Exception( 58 | 'rule config must contain at least two, at most three items.'); 59 | } 60 | final severity = _extractRuleSeverity(config.first as int); 61 | final condition = _extractRuleCondition(config.elementAt(1) as String); 62 | dynamic value; 63 | if (config.length == 3) { 64 | value = config.last; 65 | } 66 | if (value == null) { 67 | return Rule(severity: severity, condition: condition); 68 | } 69 | if (value is num) { 70 | return LengthRule( 71 | severity: severity, 72 | condition: condition, 73 | length: value, 74 | ); 75 | } 76 | if (value is String) { 77 | if (value.endsWith('-case')) { 78 | return CaseRule( 79 | severity: severity, 80 | condition: condition, 81 | type: _extractCase(value), 82 | ); 83 | } else { 84 | return ValueRule( 85 | severity: severity, 86 | condition: condition, 87 | value: value, 88 | ); 89 | } 90 | } 91 | if (value is List) { 92 | return EnumRule( 93 | severity: severity, 94 | condition: condition, 95 | allowed: value.cast(), 96 | ); 97 | } 98 | return ValueRule( 99 | severity: severity, 100 | condition: condition, 101 | value: value, 102 | ); 103 | } 104 | 105 | RuleSeverity _extractRuleSeverity(int severity) { 106 | if (severity < 0 || severity > RuleSeverity.values.length - 1) { 107 | throw Exception( 108 | 'rule severity can only be 0..${RuleSeverity.values.length - 1}'); 109 | } 110 | return RuleSeverity.values[severity]; 111 | } 112 | 113 | RuleCondition _extractRuleCondition(String condition) { 114 | var allowed = RuleCondition.values.map((e) => e.name).toList(); 115 | final index = allowed.indexOf(condition); 116 | if (index == -1) { 117 | throw Exception('rule condition can only one of $allowed'); 118 | } 119 | return RuleCondition.values[index]; 120 | } 121 | 122 | Case _extractCase(String name) { 123 | var allowed = Case.values.map((e) => e.caseName).toList(); 124 | final index = allowed.indexOf(name); 125 | if (index == -1) { 126 | throw Exception('rule case can only one of $allowed'); 127 | } 128 | return Case.values[index]; 129 | } 130 | -------------------------------------------------------------------------------- /lib/src/parse.dart: -------------------------------------------------------------------------------- 1 | import 'types/commit.dart'; 2 | import 'types/parser.dart'; 3 | 4 | /// 5 | /// Parse Commit Message String to Convensional Commit 6 | /// 7 | Commit parse( 8 | String raw, { 9 | ParserOptions? options, 10 | }) { 11 | options ??= const ParserOptions(); 12 | if (raw.trim().isEmpty) { 13 | throw ArgumentError.value(raw, null, 'message raw must have content.'); 14 | } 15 | String? body; 16 | String? footer; 17 | List mentions = []; 18 | List notes = []; 19 | List references = []; 20 | Map? revert; 21 | String? merge; 22 | String? header; 23 | final rawLines = _trimOffNewlines(raw).split(RegExp(r'\r?\n')); 24 | final lines = _truncateToScissor(rawLines).where(_gpgFilter).toList(); 25 | merge = lines.removeAt(0); 26 | final mergeMatch = RegExp(options.mergePattern).firstMatch(merge); 27 | if (mergeMatch != null) { 28 | merge = mergeMatch.group(0); 29 | if (lines.isNotEmpty) { 30 | header = lines.removeAt(0); 31 | while (header!.trim().isEmpty && lines.isNotEmpty) { 32 | header = lines.removeAt(0); 33 | } 34 | } 35 | header ??= ''; 36 | } else { 37 | header = merge; 38 | merge = null; 39 | } 40 | final headerMatch = RegExp(options.headerPattern).firstMatch(header); 41 | final headerParts = {}; 42 | if (headerMatch != null) { 43 | for (int i = 0; i < options.headerCorrespondence.length; i++) { 44 | final String key = options.headerCorrespondence[i]; 45 | headerParts[key] = headerMatch.group(i + 1); 46 | } 47 | // for (var name in options.headerCorrespondence) { 48 | // headerParts[name] = headerMatch.namedGroup(name); 49 | // } 50 | } 51 | final referencesPattern = _getReferenceRegex(options.referenceActions); 52 | final referencePartsPattern = 53 | _getReferencePartsRegex(options.issuePrefixes, false); 54 | references.addAll(_getReferences(header, 55 | referencesPattern: referencesPattern, 56 | referencePartsPattern: referencePartsPattern)); 57 | 58 | bool continueNote = false; 59 | bool isBody = true; 60 | final notesPattern = _getNotesRegex(options.noteKeywords); 61 | 62 | /// body or footer 63 | for (var line in lines) { 64 | bool referenceMatched = false; 65 | final notesMatch = notesPattern.firstMatch(line); 66 | if (notesMatch != null) { 67 | continueNote = true; 68 | isBody = false; 69 | footer = _append(footer, line); 70 | notes.add( 71 | CommitNote(title: notesMatch.group(1)!, text: notesMatch.group(2)!)); 72 | continue; 73 | } 74 | 75 | final lineReferences = _getReferences( 76 | line, 77 | referencesPattern: referencesPattern, 78 | referencePartsPattern: referencePartsPattern, 79 | ); 80 | 81 | if (lineReferences.isNotEmpty) { 82 | isBody = false; 83 | referenceMatched = true; 84 | continueNote = false; 85 | references.addAll(lineReferences); 86 | } 87 | 88 | if (referenceMatched) { 89 | footer = _append(footer, line); 90 | continue; 91 | } 92 | 93 | if (continueNote) { 94 | notes.last.text = _append(notes.last.text, line); 95 | footer = _append(footer, line); 96 | continue; 97 | } 98 | if (isBody) { 99 | body = _append(body, line); 100 | } else { 101 | footer = _append(footer, line); 102 | } 103 | } 104 | 105 | final mentionsRegex = RegExp(options.mentionsPattern); 106 | Match? mentionsMatch = mentionsRegex.firstMatch(raw); 107 | while (mentionsMatch != null) { 108 | mentions.add(mentionsMatch.group(1)!); 109 | mentionsMatch = mentionsRegex.matchAsPrefix(raw, mentionsMatch.end); 110 | } 111 | 112 | // does this commit revert any other commit? 113 | final revertMatch = RegExp(options.revertPattern).firstMatch(raw); 114 | if (revertMatch != null) { 115 | revert = {}; 116 | for (var i = 0; i < options.revertCorrespondence.length; i++) { 117 | revert[options.revertCorrespondence[i]] = revertMatch.group(i + 1); 118 | } 119 | } 120 | 121 | for (var note in notes) { 122 | note.text = _trimOffNewlines(note.text); 123 | } 124 | return Commit( 125 | revert: revert, 126 | merge: merge, 127 | header: header, 128 | type: headerParts['type'], 129 | scopes: headerParts['scope']?.split(RegExp(r'\/|\\|, ?')), 130 | subject: headerParts['subject'], 131 | body: body != null ? _trimOffNewlines(body) : null, 132 | footer: footer != null ? _trimOffNewlines(footer) : null, 133 | notes: notes, 134 | references: references, 135 | mentions: mentions, 136 | ); 137 | } 138 | 139 | String _trimOffNewlines(String input) { 140 | final result = RegExp(r'[^\r\n]').firstMatch(input); 141 | if (result == null) { 142 | return ''; 143 | } 144 | final firstIndex = result.start; 145 | var lastIndex = input.length - 1; 146 | while (input[lastIndex] == '\r' || input[lastIndex] == '\n') { 147 | lastIndex--; 148 | } 149 | return input.substring(firstIndex, lastIndex + 1); 150 | } 151 | 152 | bool _gpgFilter(String line) { 153 | return !RegExp(r'^\s*gpg:').hasMatch(line); 154 | } 155 | 156 | final _kMatchAll = RegExp(r'()(.+)', caseSensitive: false); 157 | 158 | const _kScissor = '# ------------------------ >8 ------------------------'; 159 | 160 | List _truncateToScissor(List lines) { 161 | final scissorIndex = lines.indexOf(_kScissor); 162 | 163 | if (scissorIndex == -1) { 164 | return lines; 165 | } 166 | 167 | return lines.sublist(0, scissorIndex); 168 | } 169 | 170 | List _getReferences( 171 | String input, { 172 | required RegExp referencesPattern, 173 | required RegExp referencePartsPattern, 174 | }) { 175 | final references = []; 176 | final reApplicable = 177 | referencesPattern.hasMatch(input) ? referencesPattern : _kMatchAll; 178 | Match? referenceSentences = reApplicable.firstMatch(input); 179 | while (referenceSentences != null) { 180 | final action = referenceSentences.group(1); 181 | final sentence = referenceSentences.group(2); 182 | Match? referenceMatch = referencePartsPattern.firstMatch(sentence!); 183 | while (referenceMatch != null) { 184 | String? owner; 185 | String? repository = referenceMatch.group(1); 186 | final ownerRepo = repository?.split('/') ?? []; 187 | 188 | if (ownerRepo.length > 1) { 189 | owner = ownerRepo.removeAt(0); 190 | repository = ownerRepo.join('/'); 191 | } 192 | references.add(CommitReference( 193 | action: action, 194 | owner: owner, 195 | repository: repository, 196 | issue: referenceMatch.group(3), 197 | raw: referenceMatch.group(0)!, 198 | prefix: referenceMatch.group(2)!, 199 | )); 200 | referenceMatch = 201 | referencePartsPattern.matchAsPrefix(sentence, referenceMatch.end); 202 | } 203 | referenceSentences = 204 | reApplicable.matchAsPrefix(input, referenceSentences.end); 205 | } 206 | return references; 207 | } 208 | 209 | RegExp _getReferenceRegex(Iterable referenceActions) { 210 | if (referenceActions.isEmpty) { 211 | // matches everything 212 | return RegExp(r'()(.+)', caseSensitive: false); //gi 213 | } 214 | 215 | final joinedKeywords = referenceActions.join('|'); 216 | return RegExp('($joinedKeywords)(?:\\s+(.*?))(?=(?:$joinedKeywords)|\$)', 217 | caseSensitive: false); 218 | } 219 | 220 | RegExp _getReferencePartsRegex( 221 | List issuePrefixes, bool issuePrefixesCaseSensitive) { 222 | if (issuePrefixes.isEmpty) { 223 | return RegExp(r'(?!.*)'); 224 | } 225 | return RegExp( 226 | '(?:.*?)??\\s*([\\w-\\.\\/]*?)??(${issuePrefixes.join('|')})([\\w-]*\\d+)', 227 | caseSensitive: issuePrefixesCaseSensitive); 228 | } 229 | 230 | RegExp _getNotesRegex(List noteKeywords) { 231 | if (noteKeywords.isEmpty) { 232 | return RegExp(r'(?!.*)'); 233 | } 234 | final noteKeywordsSelection = noteKeywords.join('|'); 235 | return RegExp( 236 | '^[\\s|*]*($noteKeywordsSelection)[:\\s]+(.*)', 237 | caseSensitive: false, 238 | ); 239 | } 240 | 241 | String _append(String? src, String line) { 242 | if (src != null && src.isNotEmpty) { 243 | return '$src\n$line'; 244 | } else { 245 | return line; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /lib/src/read.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:path/path.dart'; 3 | 4 | const _kDelimiter = '------------------------ >8 ------------------------'; 5 | 6 | /// Read commit messages in given range([from], [to]), 7 | /// or in [edit] file. 8 | /// Return commit messages list. 9 | Future> read({ 10 | String? from, 11 | String? to, 12 | String? edit, 13 | String? workingDirectory, 14 | Iterable? gitLogArgs, 15 | }) async { 16 | if (edit != null) { 17 | return await _getEditingCommit( 18 | edit: edit, workingDirectory: workingDirectory); 19 | } 20 | final range = [if (from != null) from, to ?? 'HEAD'].join('..'); 21 | return _getRangeCommits( 22 | gitLogArgs: ['--format=%B%n$_kDelimiter', range, ...?gitLogArgs], 23 | workingDirectory: workingDirectory, 24 | ); 25 | } 26 | 27 | Future> _getRangeCommits({ 28 | required Iterable gitLogArgs, 29 | String? workingDirectory, 30 | }) async { 31 | final result = await Process.run( 32 | 'git', 33 | ['log', ...gitLogArgs], 34 | workingDirectory: workingDirectory, 35 | ); 36 | if (result.exitCode != 0) { 37 | throw ProcessException( 38 | 'git', ['log', ...gitLogArgs], result.stderr, result.exitCode); 39 | } 40 | return ((result.stdout as String).split('$_kDelimiter\n')) 41 | .where((message) => message.isNotEmpty) 42 | .toList(); 43 | } 44 | 45 | Future> _getEditingCommit({ 46 | required String edit, 47 | String? workingDirectory, 48 | }) async { 49 | final result = await Process.run( 50 | 'git', 51 | ['rev-parse', '--show-toplevel'], 52 | workingDirectory: workingDirectory, 53 | ); 54 | final root = result.stdout.toString().trim(); 55 | final file = File(join(root, edit)); 56 | if (await file.exists()) { 57 | final message = await file.readAsString(); 58 | return ['$message\n']; 59 | } 60 | return []; 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/rules.dart: -------------------------------------------------------------------------------- 1 | import 'ensure.dart'; 2 | import 'types/case.dart'; 3 | import 'types/commit.dart'; 4 | import 'types/rule.dart'; 5 | 6 | Map get supportedRules => { 7 | 'type-case': caseRule(CommitComponent.type), 8 | 'type-empty': emptyRule(CommitComponent.type), 9 | 'type-enum': enumRule(CommitComponent.type), 10 | 'type-max-length': maxLengthRule(CommitComponent.type), 11 | 'type-min-length': minLengthRule(CommitComponent.type), 12 | 'scope-case': caseRule(CommitComponent.scope), 13 | 'scope-empty': emptyRule(CommitComponent.scope), 14 | 'scope-enum': enumRule(CommitComponent.scope), 15 | 'scope-max-length': maxLengthRule(CommitComponent.scope), 16 | 'scope-min-length': minLengthRule(CommitComponent.scope), 17 | 'subject-case': caseRule(CommitComponent.subject), 18 | 'subject-empty': emptyRule(CommitComponent.subject), 19 | 'subject-full-stop': fullStopRule(CommitComponent.subject), 20 | 'subject-max-length': maxLengthRule(CommitComponent.subject), 21 | 'subject-min-length': minLengthRule(CommitComponent.subject), 22 | 'header-case': caseRule(CommitComponent.header), 23 | 'header-full-stop': fullStopRule(CommitComponent.header), 24 | 'header-max-length': maxLengthRule(CommitComponent.header), 25 | 'header-min-length': minLengthRule(CommitComponent.header), 26 | 'body-case': caseRule(CommitComponent.body), 27 | 'body-empty': emptyRule(CommitComponent.body), 28 | 'body-full-stop': fullStopRule(CommitComponent.body), 29 | 'body-leading-blank': leadingBlankRule(CommitComponent.body), 30 | 'body-max-length': maxLengthRule(CommitComponent.body), 31 | 'body-max-line-length': maxLineLengthRule(CommitComponent.body), 32 | 'body-min-length': minLengthRule(CommitComponent.body), 33 | 'footer-case': caseRule(CommitComponent.footer), 34 | 'footer-empty': emptyRule(CommitComponent.footer), 35 | 'footer-leading-blank': leadingBlankRule(CommitComponent.footer), 36 | 'footer-max-length': maxLengthRule(CommitComponent.footer), 37 | 'footer-max-line-length': maxLineLengthRule(CommitComponent.footer), 38 | 'footer-min-length': minLengthRule(CommitComponent.footer), 39 | 'references-empty': emptyRule(CommitComponent.references), 40 | }; 41 | 42 | /// Build full-stop rule for commit component. 43 | RuleFunction fullStopRule(CommitComponent component) { 44 | return (Commit commit, Rule config) { 45 | if (config is! ValueRule) { 46 | throw Exception('$config is not ValueRuleConfig'); 47 | } 48 | final raw = commit.componentRaw(component); 49 | final result = raw == null || ensureFullStop(raw, config.value); 50 | final negated = config.condition == RuleCondition.never; 51 | return RuleOutcome( 52 | valid: negated ? !result : result, 53 | message: [ 54 | '${component.name} must', 55 | if (negated) 'not', 56 | 'end with ${config.value}' 57 | ].join(' '), 58 | ); 59 | }; 60 | } 61 | 62 | /// Build leanding-blank rule for commit component. 63 | RuleFunction leadingBlankRule(CommitComponent component) { 64 | return (Commit commit, Rule config) { 65 | final raw = commit.componentRaw(component); 66 | final result = raw == null || ensureLeadingBlank(raw); 67 | final negated = config.condition == RuleCondition.never; 68 | return RuleOutcome( 69 | valid: negated ? !result : result, 70 | message: [ 71 | '${component.name} must', 72 | if (negated) 'not', 73 | 'begin with blank line' 74 | ].join(' '), 75 | ); 76 | }; 77 | } 78 | 79 | /// Build empty rule for commit component. 80 | RuleFunction emptyRule(CommitComponent component) { 81 | return (Commit commit, Rule config) { 82 | final raw = commit.componentRaw(component); 83 | final result = raw == null || ensureEmpty(raw); 84 | final negated = config.condition == RuleCondition.never; 85 | return RuleOutcome( 86 | valid: negated ? !result : result, 87 | message: 88 | ['${component.name} must', if (negated) 'not', 'be empty'].join(' '), 89 | ); 90 | }; 91 | } 92 | 93 | /// Build case rule for commit component. 94 | RuleFunction caseRule(CommitComponent component) { 95 | return (Commit commit, Rule config) { 96 | if (config is! CaseRule) { 97 | throw Exception('$config is not CaseRuleConfig'); 98 | } 99 | final raw = commit.componentRaw(component); 100 | final result = raw == null || ensureCase(raw, config.type); 101 | final negated = config.condition == RuleCondition.never; 102 | return RuleOutcome( 103 | valid: negated ? !result : result, 104 | message: [ 105 | '${component.name} case must', 106 | if (negated) 'not', 107 | 'be ${config.type.caseName}' 108 | ].join(' '), 109 | ); 110 | }; 111 | } 112 | 113 | /// Build max-length rule for commit component. 114 | RuleFunction maxLengthRule(CommitComponent component) { 115 | return (Commit commit, Rule config) { 116 | if (config is! LengthRule) { 117 | throw Exception('$config is not LengthRuleConfig'); 118 | } 119 | final raw = commit.componentRaw(component); 120 | final result = raw == null || ensureMaxLength(raw, config.length); 121 | final negated = config.condition == RuleCondition.never; 122 | return RuleOutcome( 123 | valid: negated ? !result : result, 124 | message: [ 125 | '${component.name} must', 126 | if (negated) 'not', 127 | 'have ${config.length} or less characters' 128 | ].join(' '), 129 | ); 130 | }; 131 | } 132 | 133 | /// Build max-line-length rule for commit component. 134 | RuleFunction maxLineLengthRule(CommitComponent component) { 135 | return (Commit commit, Rule config) { 136 | if (config is! LengthRule) { 137 | throw Exception('$config is not LengthRuleConfig'); 138 | } 139 | final raw = commit.componentRaw(component); 140 | final result = raw == null || ensureMaxLineLength(raw, config.length); 141 | final negated = config.condition == RuleCondition.never; 142 | return RuleOutcome( 143 | valid: negated ? !result : result, 144 | message: [ 145 | '${component.name} lines must', 146 | if (negated) 'not', 147 | 'have ${config.length} or less characters' 148 | ].join(' '), 149 | ); 150 | }; 151 | } 152 | 153 | /// Build min-length rule for commit component. 154 | RuleFunction minLengthRule(CommitComponent component) { 155 | return (Commit commit, Rule config) { 156 | if (config is! LengthRule) { 157 | throw Exception('$config is not LengthRuleConfig'); 158 | } 159 | final raw = commit.componentRaw(component); 160 | final result = raw == null || ensureMinLength(raw, config.length); 161 | final negated = config.condition == RuleCondition.never; 162 | return RuleOutcome( 163 | valid: negated ? !result : result, 164 | message: [ 165 | '${component.name} must', 166 | if (negated) 'not', 167 | 'have ${config.length} or more characters' 168 | ].join(' '), 169 | ); 170 | }; 171 | } 172 | 173 | /// Build enum rule for commit component. 174 | RuleFunction enumRule(CommitComponent component) { 175 | return (Commit commit, Rule config) { 176 | if (config is! EnumRule) { 177 | throw Exception('$config is not EnumRuleConfig'); 178 | } 179 | final raw = commit.componentRaw(component); 180 | final result = raw == null || ensureEnum(raw, config.allowed); 181 | final negated = config.condition == RuleCondition.never; 182 | return RuleOutcome( 183 | valid: negated ? !result : result, 184 | message: [ 185 | '${component.name} must', 186 | if (negated) 'not', 187 | 'be one of ${config.allowed}' 188 | ].join(' '), 189 | ); 190 | }; 191 | } 192 | -------------------------------------------------------------------------------- /lib/src/runner.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:args/args.dart'; 4 | import 'package:args/command_runner.dart'; 5 | 6 | import 'format.dart'; 7 | import 'lint.dart'; 8 | import 'load.dart'; 9 | import 'read.dart'; 10 | import 'types/format.dart'; 11 | import 'types/lint.dart'; 12 | import 'types/rule.dart'; 13 | 14 | class CommitLintRunner extends CommandRunner { 15 | CommitLintRunner() 16 | : super('commitlint', 'commitlint - Lint commit messages') { 17 | argParser 18 | ..addOption('config', 19 | defaultsTo: 'commitlint.yaml', help: 'path to the config file') 20 | ..addOption('edit', 21 | help: 'read last commit message from the specified file.') 22 | ..addOption('from', 23 | help: 24 | 'lower end of the commit range to lint. This is succeeded to --edit') 25 | ..addOption('to', 26 | help: 27 | 'upper end of the commit range to lint. This is succeeded to --edit'); 28 | } 29 | 30 | @override 31 | String get invocation => '$executableName [arguments]\n\n' 32 | '[input] reads from stdin if --edit, --from and --to are omitted'; 33 | 34 | @override 35 | Future runCommand(ArgResults topLevelResults) async { 36 | if (topLevelResults.arguments.contains('-h') || 37 | topLevelResults.arguments.contains('--help')) { 38 | printUsage(); 39 | return; 40 | } 41 | final from = topLevelResults['from'] as String?; 42 | final to = topLevelResults['to'] as String?; 43 | final edit = topLevelResults['edit'] as String?; 44 | bool fromStdin = from == null && to == null && edit == null; 45 | final messages = 46 | fromStdin ? await _stdin() : await read(from: from, to: to, edit: edit); 47 | final config = await load(topLevelResults['config']); 48 | final results = 49 | (await Future.wait(messages.map((message) async => await lint( 50 | message, 51 | config.rules, 52 | parserOptions: config.parser, 53 | defaultIgnores: config.defaultIgnores, 54 | ignores: config.ignores, 55 | )))); 56 | if (config.rules.isEmpty) { 57 | String input = ''; 58 | if (results.isNotEmpty) { 59 | input = results.first.input; 60 | } 61 | results.replaceRange(0, results.length, [ 62 | LintOutcome( 63 | input: input, 64 | valid: false, 65 | errors: [ 66 | LintRuleOutcome( 67 | level: RuleSeverity.error, 68 | valid: false, 69 | name: 'empty-rules', 70 | message: [ 71 | 'Please add rules to your \'commitlint.yaml\'', 72 | ' - Example config: https://github.com/hyiso/commitlint/blob/main/commitlint.yaml', 73 | ].join('\n'), 74 | ) 75 | ], 76 | warnings: [], 77 | ) 78 | ]); 79 | } 80 | final report = results.fold( 81 | FormattableReport.empty(), 82 | (info, result) => info + result, 83 | ); 84 | final output = format(report: report); 85 | if (output.isNotEmpty) { 86 | stderr.writeln(output); 87 | } 88 | if (!report.valid) { 89 | exit(1); 90 | } 91 | } 92 | 93 | Future> _stdin() async { 94 | final input = stdin.readLineSync(); 95 | if (input != null) { 96 | return [input]; 97 | } 98 | return []; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/src/types/case.dart: -------------------------------------------------------------------------------- 1 | /// 'lower-case' // default 2 | /// 'upper-case' // UPPERCASE 3 | /// 'camel-case' // camelCase 4 | /// 'kebab-case' // kebab-case 5 | /// 'pascal-case' // PascalCase 6 | /// 'sentence-case' // Sentence case 7 | /// 'snake-case' // snake_case 8 | /// 'capital-case' // Start Case 9 | enum Case { 10 | lower, // default 11 | upper, // UPPERCASE 12 | camel, // camelCase 13 | kebab, // kebab-case 14 | pascal, // PascalCase 15 | sentence, // Sentence case 16 | snake, // snake_case 17 | capital, // Capital Case 18 | } 19 | 20 | extension CaseName on Case { 21 | String get caseName => '$name-case'; 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/types/commit.dart: -------------------------------------------------------------------------------- 1 | /// The Commit Class 2 | class Commit { 3 | final String header; 4 | final String? type; 5 | final List? scopes; 6 | final String? subject; 7 | final String? body; 8 | final String? footer; 9 | final List mentions; 10 | final List notes; 11 | final List references; 12 | final dynamic revert; 13 | final dynamic merge; 14 | 15 | Commit({ 16 | required this.header, 17 | this.type, 18 | this.scopes, 19 | this.subject, 20 | this.body, 21 | this.footer, 22 | this.mentions = const [], 23 | this.notes = const [], 24 | this.references = const [], 25 | this.revert, 26 | this.merge, 27 | }); 28 | 29 | Commit.empty() 30 | : header = '', 31 | type = null, 32 | scopes = null, 33 | subject = null, 34 | body = null, 35 | footer = null, 36 | mentions = [], 37 | notes = [], 38 | references = [], 39 | revert = null, 40 | merge = null; 41 | 42 | T? componentRaw(CommitComponent component) { 43 | switch (component) { 44 | case CommitComponent.type: 45 | return type as T?; 46 | case CommitComponent.scope: 47 | return scopes as T?; 48 | case CommitComponent.subject: 49 | return subject as T?; 50 | case CommitComponent.header: 51 | return header as T?; 52 | case CommitComponent.body: 53 | return body as T?; 54 | case CommitComponent.footer: 55 | return footer as T?; 56 | case CommitComponent.references: 57 | return references as T?; 58 | } 59 | } 60 | } 61 | 62 | enum CommitComponent { 63 | type, 64 | scope, 65 | subject, 66 | header, 67 | body, 68 | footer, 69 | references, 70 | } 71 | 72 | /// Commit Note 73 | class CommitNote { 74 | final String title; 75 | String text; 76 | CommitNote({required this.title, required this.text}); 77 | } 78 | 79 | /// Commit Reference 80 | class CommitReference { 81 | final String raw; 82 | final String prefix; 83 | final String? action; 84 | final String? owner; 85 | final String? repository; 86 | final String? issue; 87 | CommitReference({ 88 | required this.raw, 89 | required this.prefix, 90 | this.action, 91 | this.owner, 92 | this.repository, 93 | this.issue, 94 | }); 95 | 96 | @override 97 | operator ==(other) { 98 | return other is CommitReference && 99 | raw == other.raw && 100 | prefix == other.prefix && 101 | action == other.action && 102 | owner == other.owner && 103 | repository == other.repository && 104 | issue == other.issue; 105 | } 106 | 107 | @override 108 | int get hashCode => raw.hashCode; 109 | } 110 | -------------------------------------------------------------------------------- /lib/src/types/commitlint.dart: -------------------------------------------------------------------------------- 1 | import 'parser.dart'; 2 | import 'rule.dart'; 3 | 4 | class CommitLint { 5 | CommitLint({ 6 | this.rules = const {}, 7 | this.defaultIgnores, 8 | this.ignores, 9 | this.parser, 10 | }); 11 | 12 | final Map rules; 13 | 14 | final bool? defaultIgnores; 15 | 16 | final Iterable? ignores; 17 | 18 | final ParserOptions? parser; 19 | 20 | CommitLint inherit(CommitLint other) { 21 | return CommitLint( 22 | rules: { 23 | ...other.rules, 24 | ...rules, 25 | }, 26 | defaultIgnores: defaultIgnores ?? other.defaultIgnores, 27 | ignores: [ 28 | ...?other.ignores, 29 | ...?ignores, 30 | ], 31 | parser: parser ?? other.parser, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/types/format.dart: -------------------------------------------------------------------------------- 1 | import 'lint.dart'; 2 | 3 | class FormattableReport { 4 | final bool valid; 5 | final int errorCount; 6 | final int warningCount; 7 | final List results; 8 | 9 | FormattableReport({ 10 | required this.valid, 11 | required this.errorCount, 12 | required this.warningCount, 13 | required this.results, 14 | }); 15 | 16 | factory FormattableReport.empty() => FormattableReport( 17 | valid: true, 18 | errorCount: 0, 19 | warningCount: 0, 20 | results: [], 21 | ); 22 | 23 | operator +(LintOutcome result) { 24 | return FormattableReport( 25 | valid: result.valid && valid, 26 | errorCount: errorCount + result.errors.length, 27 | warningCount: warningCount + result.warnings.length, 28 | results: results..add(result)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/types/lint.dart: -------------------------------------------------------------------------------- 1 | import 'rule.dart'; 2 | 3 | class LintOutcome { 4 | /// The linted commit, as string 5 | final String input; 6 | 7 | /// If the linted commit is considered valid 8 | final bool valid; 9 | 10 | /// All errors, per rule, for the commit 11 | final Iterable errors; 12 | 13 | /// All warnings, per rule, for the commit 14 | final Iterable warnings; 15 | 16 | LintOutcome({ 17 | required this.input, 18 | required this.valid, 19 | required this.errors, 20 | required this.warnings, 21 | }); 22 | } 23 | 24 | class LintRuleOutcome { 25 | /// If the commit is considered valid for the rule 26 | final bool valid; 27 | 28 | /// The "severity" of the rule (0 = ignore, 1 = warning, 2 = error) 29 | final RuleSeverity level; 30 | 31 | /// The name of the rule 32 | final String name; 33 | 34 | /// The message returned from the rule, if invalid 35 | final String message; 36 | 37 | LintRuleOutcome({ 38 | required this.valid, 39 | required this.level, 40 | required this.name, 41 | required this.message, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /lib/src/types/parser.dart: -------------------------------------------------------------------------------- 1 | import 'package:yaml/yaml.dart'; 2 | 3 | const _kHeaderPattern = 4 | r'^(?\w*)(?:\((?.*)\))?!?: (?.*)$'; 5 | const _kHeaderCorrespondence = ['type', 'scope', 'subject']; 6 | 7 | const _kReferenceActions = [ 8 | 'close', 9 | 'closes', 10 | 'closed', 11 | 'fix', 12 | 'fixes', 13 | 'fixed', 14 | 'resolve', 15 | 'resolves', 16 | 'resolved' 17 | ]; 18 | 19 | const _kIssuePrefixes = ['#']; 20 | const _kNoteKeywords = ['BREAKING CHANGE', 'BREAKING-CHANGE']; 21 | const _kMergePattern = r'^(Merge|merge)\s(.*)$'; 22 | const _kRevertPattern = 23 | r'^(?:Revert|revert:)\s"?(?
[\s\S]+?)"?\s*This reverts commit (?\w*)\.'; 24 | const _kRevertCorrespondence = ['header', 'hash']; 25 | 26 | const _kMentionsPattern = r'@([\w-]+)'; 27 | 28 | class ParserOptions { 29 | final List issuePrefixes; 30 | final List noteKeywords; 31 | final List referenceActions; 32 | final String headerPattern; 33 | final List headerCorrespondence; 34 | final String revertPattern; 35 | final List revertCorrespondence; 36 | final String mergePattern; 37 | final String mentionsPattern; 38 | 39 | const ParserOptions({ 40 | this.issuePrefixes = _kIssuePrefixes, 41 | this.noteKeywords = _kNoteKeywords, 42 | this.referenceActions = _kReferenceActions, 43 | this.headerPattern = _kHeaderPattern, 44 | this.headerCorrespondence = _kHeaderCorrespondence, 45 | this.revertPattern = _kRevertPattern, 46 | this.revertCorrespondence = _kRevertCorrespondence, 47 | this.mergePattern = _kMergePattern, 48 | this.mentionsPattern = _kMentionsPattern, 49 | }); 50 | 51 | ParserOptions copyWith(ParserOptions? other) { 52 | return ParserOptions( 53 | issuePrefixes: other?.issuePrefixes ?? issuePrefixes, 54 | noteKeywords: other?.noteKeywords ?? noteKeywords, 55 | referenceActions: other?.referenceActions ?? referenceActions, 56 | headerPattern: other?.headerPattern ?? headerPattern, 57 | headerCorrespondence: other?.headerCorrespondence ?? headerCorrespondence, 58 | revertPattern: other?.revertPattern ?? revertPattern, 59 | revertCorrespondence: other?.revertCorrespondence ?? revertCorrespondence, 60 | mergePattern: other?.mergePattern ?? mergePattern, 61 | mentionsPattern: other?.mentionsPattern ?? mentionsPattern, 62 | ); 63 | } 64 | 65 | static ParserOptions fromYaml(YamlMap yaml) { 66 | return ParserOptions( 67 | issuePrefixes: 68 | List.from(yaml['issuePrefixes'] ?? _kIssuePrefixes), 69 | noteKeywords: List.from(yaml['noteKeywords'] ?? _kNoteKeywords), 70 | referenceActions: 71 | List.from(yaml['referenceActions'] ?? _kReferenceActions), 72 | headerPattern: yaml['headerPattern'] ?? _kHeaderPattern, 73 | headerCorrespondence: List.from( 74 | yaml['headerCorrespondence'] ?? _kHeaderCorrespondence), 75 | revertPattern: yaml['revertPattern'] ?? _kRevertPattern, 76 | revertCorrespondence: List.from( 77 | yaml['revertCorrespondence'] ?? _kRevertCorrespondence), 78 | mergePattern: yaml['mergePattern'] ?? _kMergePattern, 79 | mentionsPattern: yaml['mentionsPattern'] ?? _kMentionsPattern, 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/types/rule.dart: -------------------------------------------------------------------------------- 1 | import 'case.dart'; 2 | import 'commit.dart'; 3 | 4 | /// 0 disables the rule. For 1 it will be considered a warning for 2 an error 5 | enum RuleSeverity { 6 | ignore, 7 | warning, 8 | error, 9 | } 10 | 11 | enum RuleCondition { 12 | always, 13 | never, 14 | } 15 | 16 | class Rule { 17 | final RuleSeverity severity; 18 | final RuleCondition condition; 19 | 20 | Rule({ 21 | required this.severity, 22 | required this.condition, 23 | }); 24 | } 25 | 26 | class RuleOutcome { 27 | final bool valid; 28 | final String message; 29 | 30 | RuleOutcome({required this.valid, required this.message}); 31 | } 32 | 33 | typedef RuleFunction = RuleOutcome Function(Commit, Rule config); 34 | 35 | class ValueRule extends Rule { 36 | final String value; 37 | 38 | ValueRule({ 39 | required RuleSeverity severity, 40 | required RuleCondition condition, 41 | required this.value, 42 | }) : super(severity: severity, condition: condition); 43 | } 44 | 45 | class LengthRule extends Rule { 46 | final num length; 47 | 48 | LengthRule({ 49 | required RuleSeverity severity, 50 | required RuleCondition condition, 51 | required this.length, 52 | }) : super( 53 | severity: severity, 54 | condition: condition, 55 | ); 56 | } 57 | 58 | class EnumRule extends Rule { 59 | final List allowed; 60 | 61 | EnumRule({ 62 | required RuleSeverity severity, 63 | required RuleCondition condition, 64 | required this.allowed, 65 | }) : super(severity: severity, condition: condition); 66 | } 67 | 68 | class CaseRule extends Rule { 69 | final Case type; 70 | 71 | CaseRule({ 72 | required RuleSeverity severity, 73 | required RuleCondition condition, 74 | required this.type, 75 | }) : super(severity: severity, condition: condition); 76 | } 77 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: commitlint_cli 2 | description: Commitlint lint commit messages to satisfy conventional commit format 3 | version: 0.8.1 4 | homepage: https://github.com/hyiso/commitlint 5 | documentation: https://hyiso.github.io/commitlint 6 | 7 | environment: 8 | sdk: '>=2.15.0 <3.0.0' 9 | 10 | dependencies: 11 | ansi: ^0.4.0 12 | args: ^2.3.1 13 | change_case: ^1.1.0 14 | path: ^1.8.0 15 | verbose: ^0.1.0 16 | yaml: ^3.1.1 17 | 18 | dev_dependencies: 19 | collection: ^1.17.1 20 | husky: ^0.1.6 21 | lint_staged: ^0.5.0 22 | lints: ^2.0.0 23 | test: ^1.21.0 24 | 25 | lint_staged: 26 | '**.dart': dart fix --apply && dart format --fix 27 | -------------------------------------------------------------------------------- /test/__fixtures__/empty.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyiso/commitlint/eaad24cb94e227ccb6554c577af07673c453f72b/test/__fixtures__/empty.yaml -------------------------------------------------------------------------------- /test/__fixtures__/include-package.yaml: -------------------------------------------------------------------------------- 1 | include: package:commitlint_cli/commitlint.yaml 2 | 3 | rules: 4 | type-case: 5 | - 2 6 | - always 7 | - 'upper-case' 8 | 9 | ignores: 10 | - r'^fixup' -------------------------------------------------------------------------------- /test/__fixtures__/include-path.yaml: -------------------------------------------------------------------------------- 1 | include: ../../lib/commitlint.yaml 2 | 3 | rules: 4 | type-case: 5 | - 2 6 | - always 7 | - 'upper-case' 8 | 9 | defaultIgnores: false 10 | ignores: 11 | - r'^fixup' -------------------------------------------------------------------------------- /test/__fixtures__/only-rules.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | type-case: 3 | - 2 4 | - always 5 | - lower-case 6 | type-enum: 7 | - 2 8 | - always 9 | - - feat 10 | - fix 11 | - docs 12 | - chore -------------------------------------------------------------------------------- /test/__fixtures__/parser-options.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/hyiso/commitlint/blob/main/lib/commitlint.yaml 2 | include: package:commitlint_cli/commitlint.yaml 3 | 4 | # https://github.com/hyiso/commitlint/pull/22 5 | parser: 6 | issuePrefixes: 7 | - "sv-" 8 | 9 | # https://hyiso.github.io/commitlint/#/references-rules 10 | rules: 11 | type-enum: 12 | - 2 13 | - always 14 | - - build 15 | - chore 16 | - docs 17 | - feat 18 | - fix 19 | - refactor 20 | - revert 21 | - style 22 | - test 23 | scope-enum: 24 | - 2 25 | - always 26 | - - domain 27 | - infrastructures 28 | - use_cases 29 | - interfaces 30 | - lib 31 | - root 32 | references-empty: 33 | - 2 34 | - never -------------------------------------------------------------------------------- /test/format_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:commitlint_cli/src/format.dart'; 2 | import 'package:commitlint_cli/src/types/format.dart'; 3 | import 'package:commitlint_cli/src/types/lint.dart'; 4 | import 'package:commitlint_cli/src/types/rule.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | test('does nothing without report results', () { 9 | final actual = format(report: FormattableReport.empty()); 10 | expect(actual.isEmpty, true); 11 | }); 12 | test('returns a correct summary of empty errors and warnings', () { 13 | final fakeError = LintOutcome( 14 | input: '', 15 | valid: false, 16 | errors: [ 17 | LintRuleOutcome( 18 | valid: false, 19 | level: RuleSeverity.error, 20 | name: 'error-name', 21 | message: 'There was an error', 22 | ), 23 | ], 24 | warnings: [], 25 | ); 26 | final actualError = format(report: FormattableReport.empty() + fakeError); 27 | final fakeWarning = LintOutcome( 28 | input: '', 29 | valid: false, 30 | errors: [], 31 | warnings: [ 32 | LintRuleOutcome( 33 | valid: false, 34 | level: RuleSeverity.warning, 35 | name: 'warning-name', 36 | message: 'There was a problem', 37 | ), 38 | ], 39 | ); 40 | final actualWarning = 41 | format(report: FormattableReport.empty() + fakeWarning); 42 | expect(actualError, contains('There was an error')); 43 | expect(actualError, contains('1 error(s), 0 warning(s)')); 44 | expect(actualWarning, contains('There was a problem')); 45 | expect(actualWarning, contains('0 error(s), 1 warning(s)')); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /test/ignore_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:commitlint_cli/src/is_ignored.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('Should ignore configurated multi-line merge message', () async { 6 | final message = '''Merge branch 'develop' into feature/xyz 7 | 8 | # Conflicts: 9 | # xyz.yaml 10 | '''; 11 | final result = isIgnored(message, defaultIgnores: true); 12 | expect(result, true); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /test/lint_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:commitlint_cli/src/lint.dart'; 2 | import 'package:commitlint_cli/src/load.dart'; 3 | import 'package:commitlint_cli/src/types/rule.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('positive on empty message', () async { 8 | final result = await lint('', {}); 9 | expect(result.valid, true); 10 | expect(result.input, equals('')); 11 | expect(result.errors.isEmpty, true); 12 | expect(result.warnings.isEmpty, true); 13 | }); 14 | 15 | test('positive on stub message and no rule', () async { 16 | final result = await lint('foo: bar', {}); 17 | expect(result.valid, true); 18 | }); 19 | 20 | test('positive on stub message and adhered rule', () async { 21 | final result = await lint('foo: bar', { 22 | 'type-enum': EnumRule( 23 | severity: RuleSeverity.error, 24 | condition: RuleCondition.always, 25 | allowed: ['foo'], 26 | ), 27 | }); 28 | expect(result.valid, true); 29 | }); 30 | 31 | test('negative on stub message and broken rule', () async { 32 | final result = await lint('foo: bar', { 33 | 'type-enum': EnumRule( 34 | severity: RuleSeverity.error, 35 | condition: RuleCondition.never, 36 | allowed: ['foo'], 37 | ), 38 | }); 39 | expect(result.valid, false); 40 | }); 41 | 42 | test('positive on ignored message and broken rule', () async { 43 | final result = await lint('Revert "some bogus commit"', { 44 | 'type-empty': Rule( 45 | severity: RuleSeverity.error, 46 | condition: RuleCondition.never, 47 | ), 48 | }); 49 | expect(result.valid, true); 50 | expect(result.input, equals('Revert "some bogus commit"')); 51 | }); 52 | 53 | test('negative on ignored message, disabled ignored messages and broken rule', 54 | () async { 55 | final result = await lint( 56 | 'Revert "some bogus commit"', 57 | { 58 | 'type-empty': Rule( 59 | severity: RuleSeverity.error, 60 | condition: RuleCondition.never, 61 | ), 62 | }, 63 | defaultIgnores: false); 64 | expect(result.valid, false); 65 | }); 66 | 67 | test('positive on custom ignored message and broken rule', () async { 68 | final ignoredMessage = 'some ignored custom message'; 69 | final result = await lint(ignoredMessage, { 70 | 'type-empty': Rule( 71 | severity: RuleSeverity.error, 72 | condition: RuleCondition.never, 73 | ), 74 | }, ignores: [ 75 | ignoredMessage 76 | ]); 77 | expect(result.valid, true); 78 | expect(result.input, equals(ignoredMessage)); 79 | }); 80 | 81 | test('throws for invalid rule names', () async { 82 | await expectLater( 83 | lint('foo', { 84 | 'foo': Rule( 85 | severity: RuleSeverity.error, 86 | condition: RuleCondition.always, 87 | ), 88 | 'bar': Rule( 89 | severity: RuleSeverity.warning, 90 | condition: RuleCondition.never, 91 | ), 92 | }), 93 | throwsRangeError); 94 | }); 95 | 96 | test('succeds for issue', () async { 97 | final result = await lint('somehting #1', { 98 | 'references-empty': Rule( 99 | severity: RuleSeverity.error, 100 | condition: RuleCondition.never, 101 | ), 102 | }); 103 | expect(result.valid, true); 104 | }); 105 | 106 | test('fails for issue', () async { 107 | final result = await lint('somehting #1', { 108 | 'references-empty': Rule( 109 | severity: RuleSeverity.error, 110 | condition: RuleCondition.always, 111 | ), 112 | }); 113 | expect(result.valid, false); 114 | }); 115 | 116 | test('positive on multi-line body message', () async { 117 | final message = '''chore(deps): bump commitlint_cli from 0.5.0 to 0.6.0 118 | Bumps [commitlint_cli](https://github.com/hyiso/commitlint) from 0.5.0 to 0.6.0. 119 | - [Release notes](https://github.com/hyiso/commitlint/releases) 120 | - [Changelog](https://github.com/hyiso/commitlint/blob/main/CHANGELOG.md) 121 | - [Commits](hyiso/commitlint@v0.5.0...v0.6.0) 122 | 123 | --- 124 | updated-dependencies: 125 | - dependency-name: commitlint_cli 126 | dependency-type: direct:production 127 | update-type: version-update:semver-minor 128 | ... 129 | 130 | Signed-off-by: dependabot[bot] 131 | 132 | '''; 133 | final result = await lint(message, { 134 | 'type-empty': Rule( 135 | severity: RuleSeverity.error, 136 | condition: RuleCondition.never, 137 | ), 138 | }); 139 | expect(result.valid, true); 140 | expect(result.input, equals(message)); 141 | }); 142 | 143 | test('should use custom parser options with custom issuePrefixes', () async { 144 | final config = await load('test/__fixtures__/parser-options.yaml'); 145 | final result = await lint( 146 | 'fix(root): fix commitlint config sv-1', 147 | config.rules, 148 | parserOptions: config.parser, 149 | ); 150 | expect(result.valid, true); 151 | }); 152 | } 153 | -------------------------------------------------------------------------------- /test/load_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:commitlint_cli/src/load.dart'; 2 | import 'package:commitlint_cli/src/types/case.dart'; 3 | import 'package:commitlint_cli/src/types/rule.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('empty should have no rules', () async { 8 | final config = await load('test/__fixtures__/empty.yaml'); 9 | expect(config.rules.isEmpty, true); 10 | }); 11 | test('only `rules` should work', () async { 12 | final config = await load('test/__fixtures__/only-rules.yaml'); 13 | expect(config.rules.isEmpty, false); 14 | expect(config.rules.keys.length, equals(2)); 15 | expect(config.rules['type-case'], isA()); 16 | expect(config.rules['type-enum'], isA()); 17 | expect((config.rules['type-case'] as CaseRule).type, Case.lower); 18 | expect((config.rules['type-enum'] as EnumRule).allowed, 19 | equals(['feat', 'fix', 'docs', 'chore'])); 20 | expect(config.defaultIgnores, equals(null)); 21 | expect(config.ignores, equals(null)); 22 | }); 23 | test('include relative path should work', () async { 24 | final config = await load('test/__fixtures__/include-path.yaml'); 25 | expect(config.rules.isEmpty, false); 26 | expect(config.rules.keys.length, greaterThan(1)); 27 | expect(config.rules['type-case'], isA()); 28 | expect(config.rules['type-enum'], isA()); 29 | expect((config.rules['type-case'] as CaseRule).type, Case.upper); 30 | expect(config.defaultIgnores, equals(false)); 31 | expect(config.ignores, equals(["r'^fixup'"])); 32 | }); 33 | test('include package path should work', () async { 34 | final config = await load('test/__fixtures__/include-package.yaml'); 35 | expect(config.rules.isEmpty, false); 36 | expect(config.rules.keys.length, greaterThan(1)); 37 | expect(config.rules['type-case'], isA()); 38 | expect(config.rules['type-enum'], isA()); 39 | expect((config.rules['type-case'] as CaseRule).type, Case.upper); 40 | expect(config.defaultIgnores, equals(null)); 41 | expect(config.ignores, equals(["r'^fixup'"])); 42 | }); 43 | test('custom parser options should work', () async { 44 | final config = await load('test/__fixtures__/parser-options.yaml'); 45 | expect(config.parser, isNotNull); 46 | expect(config.parser!.issuePrefixes, equals(['sv-'])); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/parse_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_interpolation_to_compose_strings, prefer_adjacent_string_concatenation 2 | 3 | import 'package:collection/collection.dart'; 4 | import 'package:commitlint_cli/src/parse.dart'; 5 | import 'package:commitlint_cli/src/types/commit.dart'; 6 | import 'package:commitlint_cli/src/types/parser.dart'; 7 | import 'package:test/test.dart'; 8 | 9 | void main() { 10 | group('parse', () { 11 | test('throws when called with empty message', () { 12 | expect(() => parse(''), throwsArgumentError); 13 | expect(() => parse('\n'), throwsArgumentError); 14 | expect(() => parse(' '), throwsArgumentError); 15 | }); 16 | test('supports plain message', () { 17 | final message = 'message'; 18 | final commit = parse(message); 19 | expect(commit.body, equals(null)); 20 | expect(commit.footer, equals(null)); 21 | expect(commit.header, equals('message')); 22 | expect(commit.mentions, equals([])); 23 | expect(commit.notes, equals([])); 24 | expect(commit.references, equals([])); 25 | expect(commit.type, equals(null)); 26 | expect(commit.scopes, equals(null)); 27 | expect(commit.subject, equals(null)); 28 | }); 29 | test('supports `type(scope): subject`', () { 30 | final message = 'type(scope): subject'; 31 | final commit = parse(message); 32 | expect(commit.body, equals(null)); 33 | expect(commit.footer, equals(null)); 34 | expect(commit.header, equals('type(scope): subject')); 35 | expect(commit.mentions, equals([])); 36 | expect(commit.notes, equals([])); 37 | expect(commit.references, equals([])); 38 | expect(commit.type, equals('type')); 39 | expect(commit.scopes, equals(['scope'])); 40 | expect(commit.subject, equals('subject')); 41 | }); 42 | test('supports `type!: subject`', () { 43 | final message = 'type!: subject'; 44 | final commit = parse(message); 45 | expect(commit.body, equals(null)); 46 | expect(commit.footer, equals(null)); 47 | expect(commit.header, equals('type!: subject')); 48 | expect(commit.mentions, equals([])); 49 | expect(commit.notes, equals([])); 50 | expect(commit.references, equals([])); 51 | expect(commit.type, equals('type')); 52 | expect(commit.scopes, equals(null)); 53 | expect(commit.subject, equals('subject')); 54 | }); 55 | test('supports `type(scope)!: subject`', () { 56 | final message = 'type(scope)!: subject'; 57 | final commit = parse(message); 58 | expect(commit.body, equals(null)); 59 | expect(commit.footer, equals(null)); 60 | expect(commit.header, equals('type(scope)!: subject')); 61 | expect(commit.mentions, equals([])); 62 | expect(commit.notes, equals([])); 63 | expect(commit.references, equals([])); 64 | expect(commit.type, equals('type')); 65 | expect(commit.scopes, equals(['scope'])); 66 | expect(commit.subject, equals('subject')); 67 | }); 68 | test('supports multi scopes separated by `/`', () { 69 | const message = 'type(multi/scope): subject'; 70 | final commit = parse(message); 71 | expect(commit.scopes, equals(['multi', 'scope'])); 72 | expect(commit.subject, equals('subject')); 73 | }); 74 | test('supports multi scopes separated by `,`)', () { 75 | const message = 'type(multi,scope): subject'; 76 | final commit = parse(message); 77 | expect(commit.scopes, equals(['multi', 'scope'])); 78 | expect(commit.subject, equals('subject')); 79 | }); 80 | 81 | test('keep -side notes- in the body section', () { 82 | final header = "type(scope): subject"; 83 | final body = 84 | "CI on master branch caught this:\n\n```\nUnhandled Exception:\nSystem.AggregateException: One or more errors occurred. (Some problem when connecting to 'api.mycryptoapi.com/eth')\n\n--- End of stack trace from previous location where exception was thrown ---\n\nat GWallet.Backend.FSharpUtil.ReRaise (System.Exception ex) [0x00000] in /Users/runner/work/geewallet/geewallet/src/GWallet.Backend/FSharpUtil.fs:206\n...\n```"; 85 | final message = "$header\n\n$body"; 86 | final commit = parse(message); 87 | expect(commit.body, equals(body)); 88 | }); 89 | test('parses references leading subject', () { 90 | const message = '#1 some subject'; 91 | final commit = parse(message); 92 | expect(commit.references.first.issue, equals('1')); 93 | }); 94 | test('works with chinese scope by default', () { 95 | const message = 'fix(面试评价): 测试'; 96 | final commit = parse(message); 97 | expect(commit.subject, isNotNull); 98 | expect(commit.scopes, isNotNull); 99 | }); 100 | test('should trim extra newlines', () { 101 | final commit = parse( 102 | '\n\n\n\n\n\n\nfeat(scope): broadcast destroy event on scope destruction\n\n\n' + 103 | '\n\n\nperf testing shows that in chrome this change adds 5-15% overhead\n' + 104 | '\n\n\nwhen destroying 10k nested scopes where each scope has a destroy listener\n\n' + 105 | '\n\n\n\nBREAKING CHANGE: some breaking change\n' + 106 | '\n\n\n\nBREAKING CHANGE: An awesome breaking change\n\n\n```\ncode here\n```' + 107 | '\n\nfixes #1\n' + 108 | '\n\n\nfixed #25\n\n\n\n\n'); 109 | expect(commit.merge, equals(null)); 110 | expect(commit.revert, equals(null)); 111 | expect(commit.header, 112 | equals('feat(scope): broadcast destroy event on scope destruction')); 113 | expect(commit.type, equals('feat')); 114 | expect(commit.scopes, equals(['scope'])); 115 | expect(commit.subject, 116 | equals('broadcast destroy event on scope destruction')); 117 | expect( 118 | commit.body, 119 | equals( 120 | 'perf testing shows that in chrome this change adds 5-15% overhead\n\n\n\nwhen destroying 10k nested scopes where each scope has a destroy listener')); 121 | expect( 122 | commit.footer, 123 | equals( 124 | 'BREAKING CHANGE: some breaking change\n\n\n\n\nBREAKING CHANGE: An awesome breaking change\n\n\n```\ncode here\n```\n\nfixes #1\n\n\n\nfixed #25')); 125 | expect(commit.mentions, equals([])); 126 | 127 | expect(commit.notes.first.title, equals('BREAKING CHANGE')); 128 | expect(commit.notes.first.text, equals('some breaking change')); 129 | expect(commit.notes.last.title, equals('BREAKING CHANGE')); 130 | expect(commit.notes.last.text, 131 | equals('An awesome breaking change\n\n\n```\ncode here\n```')); 132 | expect( 133 | ListEquality().equals(commit.references, [ 134 | CommitReference( 135 | raw: '#1', 136 | prefix: '#', 137 | action: 'fixes', 138 | owner: null, 139 | repository: null, 140 | issue: '1'), 141 | CommitReference( 142 | raw: '#25', 143 | prefix: '#', 144 | action: 'fixed', 145 | owner: null, 146 | repository: null, 147 | issue: '25'), 148 | ]), 149 | true); 150 | }); 151 | 152 | test('should keep spaces', () { 153 | final commit = parse(' feat(scope): broadcast destroy event on scope destruction \n' + 154 | ' perf testing shows that in chrome this change adds 5-15% overhead \n\n' + 155 | ' when destroying 10k nested scopes where each scope has a destroy listener \n' + 156 | ' BREAKING CHANGE: some breaking change \n\n' + 157 | ' BREAKING CHANGE: An awesome breaking change\n\n\n```\ncode here\n```' + 158 | '\n\n fixes #1\n'); 159 | expect(commit.merge, equals(null)); 160 | expect(commit.revert, equals(null)); 161 | expect( 162 | commit.header, 163 | equals( 164 | ' feat(scope): broadcast destroy event on scope destruction ')); 165 | expect(commit.type, equals(null)); 166 | expect(commit.scopes, equals(null)); 167 | expect(commit.subject, equals(null)); 168 | expect( 169 | commit.body, 170 | equals( 171 | ' perf testing shows that in chrome this change adds 5-15% overhead \n\n when destroying 10k nested scopes where each scope has a destroy listener ')); 172 | expect( 173 | commit.footer, 174 | equals( 175 | ' BREAKING CHANGE: some breaking change \n\n BREAKING CHANGE: An awesome breaking change\n\n\n```\ncode here\n```\n\n fixes #1')); 176 | expect(commit.mentions, equals([])); 177 | expect(commit.notes.first.title, equals('BREAKING CHANGE')); 178 | expect(commit.notes.first.text, equals('some breaking change ')); 179 | expect(commit.notes.last.title, equals('BREAKING CHANGE')); 180 | expect(commit.notes.last.text, 181 | equals('An awesome breaking change\n\n\n```\ncode here\n```')); 182 | expect( 183 | ListEquality().equals(commit.references, [ 184 | CommitReference( 185 | action: 'fixes', 186 | owner: null, 187 | repository: null, 188 | issue: '1', 189 | raw: '#1', 190 | prefix: '#', 191 | ), 192 | ]), 193 | true); 194 | }); 195 | 196 | test('should ignore gpg signature lines', () { 197 | final commit = parse('gpg: Signature made Thu Oct 22 12:19:30 2020 EDT\n' + 198 | 'gpg: using RSA key ABCDEF1234567890\n' + 199 | 'gpg: Good signature from "Author " [ultimate]\n' + 200 | 'feat(scope): broadcast destroy event on scope destruction\n' + 201 | 'perf testing shows that in chrome this change adds 5-15% overhead\n' + 202 | 'when destroying 10k nested scopes where each scope has a destroy listener\n' + 203 | 'BREAKING CHANGE: some breaking change\n' + 204 | 'fixes #1\n'); 205 | expect(commit.merge, equals(null)); 206 | expect(commit.revert, equals(null)); 207 | expect(commit.header, 208 | equals('feat(scope): broadcast destroy event on scope destruction')); 209 | expect(commit.type, equals('feat')); 210 | expect(commit.scopes, equals(['scope'])); 211 | expect(commit.subject, 212 | equals('broadcast destroy event on scope destruction')); 213 | expect( 214 | commit.body, 215 | equals( 216 | 'perf testing shows that in chrome this change adds 5-15% overhead\nwhen destroying 10k nested scopes where each scope has a destroy listener')); 217 | expect(commit.footer, 218 | equals('BREAKING CHANGE: some breaking change\nfixes #1')); 219 | expect(commit.mentions, equals(['example'])); 220 | expect(commit.notes.single.title, equals('BREAKING CHANGE')); 221 | expect(commit.notes.single.text, equals('some breaking change')); 222 | expect( 223 | ListEquality().equals(commit.references, [ 224 | CommitReference( 225 | action: 'fixes', 226 | owner: null, 227 | repository: null, 228 | issue: '1', 229 | raw: '#1', 230 | prefix: '#', 231 | ), 232 | ]), 233 | true); 234 | }); 235 | 236 | test('should truncate from scissors line', () { 237 | final commit = parse('this is some header before a scissors-line\n' + 238 | '# ------------------------ >8 ------------------------\n' + 239 | 'this is a line that should be truncated\n'); 240 | expect(commit.body, equals(null)); 241 | }); 242 | 243 | test('should keep header before scissor line', () { 244 | final commit = parse('this is some header before a scissors-line\n' + 245 | '# ------------------------ >8 ------------------------\n' + 246 | 'this is a line that should be truncated\n'); 247 | expect( 248 | commit.header, equals('this is some header before a scissors-line')); 249 | }); 250 | 251 | test('should keep body before scissor line', () { 252 | final commit = parse('this is some header before a scissors-line\n' + 253 | 'this is some body before a scissors-line\n' + 254 | '# ------------------------ >8 ------------------------\n' + 255 | 'this is a line that should be truncated\n'); 256 | expect(commit.body, equals('this is some body before a scissors-line')); 257 | }); 258 | 259 | test('should use custom parser options with headerPattern', () { 260 | final commit = parse('type(scope)-subject', 261 | options: ParserOptions(headerPattern: r'^(\w*)(?:\((.*)\))?-(.*)$')); 262 | expect(commit.header, equals('type(scope)-subject')); 263 | expect(commit.scopes, equals(['scope'])); 264 | expect(commit.subject, equals('subject')); 265 | expect(commit.type, equals('type')); 266 | }); 267 | 268 | test('should use custom parser options with custom issuePrefixes', () { 269 | final commit = parse('fix: change git convention to fix CD workflow sv-4', 270 | options: ParserOptions(issuePrefixes: ['sv-'])); 271 | expect(commit.type, equals('fix')); 272 | expect(commit.references.first.issue, equals('4')); 273 | }); 274 | 275 | group('merge commits', () { 276 | final githubCommit = parse( 277 | 'Merge pull request #1 from user/feature/feature-name\n' + 278 | '\n' + 279 | 'feat(scope): broadcast destroy event on scope destruction\n' + 280 | '\n' + 281 | 'perf testing shows that in chrome this change adds 5-15% overhead\n' + 282 | 'when destroying 10k nested scopes where each scope has a destroy listener'); 283 | 284 | test('should parse header in GitHub like pull request', () { 285 | expect( 286 | githubCommit.header, 287 | equals( 288 | 'feat(scope): broadcast destroy event on scope destruction')); 289 | }); 290 | 291 | test('should understand header parts in GitHub like pull request', () { 292 | expect(githubCommit.type, equals('feat')); 293 | expect(githubCommit.scopes, equals(['scope'])); 294 | expect(githubCommit.subject, 295 | equals('broadcast destroy event on scope destruction')); 296 | }); 297 | 298 | test('should understand merge parts in GitHub like pull request', () { 299 | expect(githubCommit.merge, 300 | equals('Merge pull request #1 from user/feature/feature-name')); 301 | }); 302 | 303 | final gitlabCommit = parse( 304 | 'Merge branch \'feature/feature-name\' into \'master\'\r\n' + 305 | '\r\n' + 306 | 'feat(scope): broadcast destroy event on scope destruction\r\n' + 307 | '\r\n' + 308 | 'perf testing shows that in chrome this change adds 5-15% overhead\r\n' + 309 | 'when destroying 10k nested scopes where each scope has a destroy listener\r\n' + 310 | '\r\n' + 311 | 'See merge request !1'); 312 | test('should parse header in GitLab like merge request', () { 313 | expect( 314 | gitlabCommit.header, 315 | equals( 316 | 'feat(scope): broadcast destroy event on scope destruction')); 317 | }); 318 | 319 | test('should understand header parts in GitLab like merge request', () { 320 | expect(gitlabCommit.type, equals('feat')); 321 | expect(gitlabCommit.scopes, equals(['scope'])); 322 | expect(gitlabCommit.subject, 323 | equals('broadcast destroy event on scope destruction')); 324 | }); 325 | 326 | test('should understand merge parts in GitLab like merge request', () { 327 | expect(gitlabCommit.merge, 328 | equals('Merge branch \'feature/feature-name\' into \'master\'')); 329 | }); 330 | 331 | test('does not throw if merge commit has no header', () { 332 | parse('Merge branch \'feature\''); 333 | }); 334 | }); 335 | }); 336 | } 337 | -------------------------------------------------------------------------------- /test/read_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:commitlint_cli/src/read.dart'; 4 | import 'package:path/path.dart' show join; 5 | import 'package:test/test.dart'; 6 | import 'utils.dart' as git; 7 | 8 | void main() { 9 | test('get edit commit message specified by the `edit` option', () async { 10 | final dir = await git.bootstrap(); 11 | await File(join(dir, 'commit-msg-file')).writeAsString('foo'); 12 | final commits = await read(edit: 'commit-msg-file', workingDirectory: dir); 13 | expect(commits, equals(['foo\n'])); 14 | }); 15 | 16 | test('get edit commit message from git root', () async { 17 | final dir = await git.bootstrap(); 18 | await File(join(dir, 'alpha.txt')).writeAsString('alpha'); 19 | await Process.run('git', ['add', '.'], workingDirectory: dir); 20 | await Process.run('git', ['commit', '-m', 'alpha'], workingDirectory: dir); 21 | final commits = await read(workingDirectory: dir); 22 | expect(commits, equals(['alpha\n\n'])); 23 | }); 24 | 25 | test('get history commit messages', () async { 26 | final dir = await git.bootstrap(); 27 | await File(join(dir, 'alpha.txt')).writeAsString('alpha'); 28 | await Process.run('git', ['add', 'alpha.txt'], workingDirectory: dir); 29 | await Process.run('git', ['commit', '-m', 'alpha'], workingDirectory: dir); 30 | await Process.run('git', ['rm', 'alpha.txt'], workingDirectory: dir); 31 | await Process.run('git', ['commit', '-m', 'remove alpha'], 32 | workingDirectory: dir); 33 | final commits = await read(workingDirectory: dir); 34 | expect(commits, equals(['remove alpha\n\n', 'alpha\n\n'])); 35 | }); 36 | 37 | test('get edit commit message from git subdirectory', () async { 38 | final dir = await git.bootstrap(); 39 | await Directory(join(dir, 'beta')).create(recursive: true); 40 | await File(join(dir, 'beta/beta.txt')).writeAsString('beta'); 41 | 42 | await Process.run('git', ['add', '.'], workingDirectory: dir); 43 | await Process.run('git', ['commit', '-m', 'beta'], workingDirectory: dir); 44 | 45 | final commits = await read(workingDirectory: dir); 46 | expect(commits, equals(['beta\n\n'])); 47 | }); 48 | 49 | test('get edit commit message while skipping first commit', () async { 50 | final dir = await git.bootstrap(); 51 | await Directory(join(dir, 'beta')).create(recursive: true); 52 | await File(join(dir, 'beta/beta.txt')).writeAsString('beta'); 53 | 54 | await File(join(dir, 'alpha.txt')).writeAsString('alpha'); 55 | await Process.run('git', ['add', 'alpha.txt'], workingDirectory: dir); 56 | await Process.run('git', ['commit', '-m', 'alpha'], workingDirectory: dir); 57 | await File(join(dir, 'beta.txt')).writeAsString('beta'); 58 | await Process.run('git', ['add', 'beta.txt'], workingDirectory: dir); 59 | await Process.run('git', ['commit', '-m', 'beta'], workingDirectory: dir); 60 | await File(join(dir, 'gamma.txt')).writeAsString('gamma'); 61 | await Process.run('git', ['add', 'gamma.txt'], workingDirectory: dir); 62 | await Process.run('git', ['commit', '-m', 'gamma'], workingDirectory: dir); 63 | 64 | final commits = await read( 65 | from: 'HEAD~2', 66 | workingDirectory: dir, 67 | gitLogArgs: '--skip 1'.split(' ')); 68 | expect(commits, equals(['beta\n\n'])); 69 | }); 70 | 71 | test('get history commit messages - body contains multi lines', () async { 72 | final bodyMultiLineMessage = 73 | '''chore(deps): bump commitlint_cli from 0.5.0 to 0.6.0 74 | Bumps [commitlint_cli](https://github.com/hyiso/commitlint) from 0.5.0 to 0.6.0. 75 | - [Release notes](https://github.com/hyiso/commitlint/releases) 76 | - [Changelog](https://github.com/hyiso/commitlint/blob/main/CHANGELOG.md) 77 | - [Commits](hyiso/commitlint@v0.5.0...v0.6.0) 78 | 79 | --- 80 | updated-dependencies: 81 | - dependency-name: commitlint_cli 82 | dependency-type: direct:production 83 | update-type: version-update:semver-minor 84 | ... 85 | 86 | Signed-off-by: dependabot[bot] '''; 87 | final dir = await git.bootstrap(); 88 | await File(join(dir, 'alpha.txt')).writeAsString('alpha'); 89 | await Process.run('git', ['add', 'alpha.txt'], workingDirectory: dir); 90 | await Process.run('git', ['commit', '-m', 'alpha'], workingDirectory: dir); 91 | await File(join(dir, 'beta.txt')).writeAsString('beta'); 92 | await Process.run('git', ['add', 'beta.txt'], workingDirectory: dir); 93 | await Process.run('git', ['commit', '-m', bodyMultiLineMessage], 94 | workingDirectory: dir); 95 | 96 | final commits = await read(from: 'HEAD~1', workingDirectory: dir); 97 | expect(commits, equals(['$bodyMultiLineMessage\n\n'])); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /test/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:path/path.dart'; 3 | 4 | String tmp() => join(Directory.systemTemp.path, 'tmp', 5 | 'commitlint_test_${DateTime.now().millisecondsSinceEpoch}'); 6 | 7 | Future bootstrap([String? fixture, String? directory]) async { 8 | final dir = tmp(); 9 | await init(dir); 10 | await config(dir); 11 | return dir; 12 | } 13 | 14 | Future init(String dir) async { 15 | await Process.run('git', ['init', dir]); 16 | } 17 | 18 | Future config(String dir) async { 19 | await Process.run('git', ['config', 'user.name', 'ava'], 20 | workingDirectory: dir); 21 | await Process.run('git', ['config', 'user.email', 'test@example.com'], 22 | workingDirectory: dir); 23 | await Process.run('git', ['config', 'commit.gpgsign', 'false'], 24 | workingDirectory: dir); 25 | } 26 | --------------------------------------------------------------------------------