summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.coveragerc2
-rw-r--r--.flake811
-rw-r--r--.github/ISSUE_TEMPLATE/issue-template.md22
-rw-r--r--.github/workflows/checks.yml113
-rw-r--r--.gitignore68
-rw-r--r--.pre-commit-hooks.yaml5
-rw-r--r--.pylintrc48
-rw-r--r--CHANGELOG.md271
-rw-r--r--CONTRIBUTING.md6
-rw-r--r--Dockerfile15
-rw-r--r--Dockerfile.dev17
-rw-r--r--LICENSE22
-rw-r--r--MANIFEST.in7
-rw-r--r--README.md21
-rw-r--r--Vagrantfile47
-rw-r--r--doc-requirements.txt1
-rw-r--r--docs/configuration.md432
-rw-r--r--docs/contrib_rules.md67
-rw-r--r--docs/contributing.md132
-rw-r--r--docs/demos/asciicinema.json3798
-rw-r--r--docs/demos/scenario.txt75
-rw-r--r--docs/extra.css4
-rw-r--r--docs/images/RuleViolation.pngbin0 -> 27806 bytes
-rw-r--r--docs/images/RuleViolations.grafflebin0 -> 3291 bytes
-rw-r--r--docs/index.md351
-rw-r--r--docs/rules.md243
-rw-r--r--docs/user_defined_rules.md312
-rw-r--r--examples/commit-message-15
-rw-r--r--examples/commit-message-106
-rw-r--r--examples/commit-message-25
-rw-r--r--examples/commit-message-33
-rw-r--r--examples/commit-message-43
-rw-r--r--examples/commit-message-51
-rw-r--r--examples/commit-message-61
-rw-r--r--examples/commit-message-74
-rw-r--r--examples/commit-message-86
-rw-r--r--examples/commit-message-97
-rw-r--r--examples/gitlint58
-rw-r--r--examples/my_commit_rules.py87
-rw-r--r--examples/my_line_rules.py45
-rw-r--r--gitlint/__init__.py1
-rw-r--r--gitlint/cache.py57
-rw-r--r--gitlint/cli.py338
-rw-r--r--gitlint/config.py482
-rw-r--r--gitlint/contrib/__init__.py0
-rw-r--r--gitlint/contrib/rules/__init__.py0
-rw-r--r--gitlint/contrib/rules/conventional_commit.py39
-rw-r--r--gitlint/contrib/rules/signedoff_by.py18
-rw-r--r--gitlint/display.py46
-rw-r--r--gitlint/files/commit-msg81
-rw-r--r--gitlint/files/gitlint106
-rw-r--r--gitlint/git.py395
-rw-r--r--gitlint/hooks.py62
-rw-r--r--gitlint/lint.py108
-rw-r--r--gitlint/options.py122
-rw-r--r--gitlint/rule_finder.py137
-rw-r--r--gitlint/rules.py363
-rw-r--r--gitlint/shell.py76
-rw-r--r--gitlint/tests/__init__.py0
-rw-r--r--gitlint/tests/base.py169
-rw-r--r--gitlint/tests/cli/test_cli.py541
-rw-r--r--gitlint/tests/cli/test_cli_hooks.py96
-rw-r--r--gitlint/tests/config/test_config.py263
-rw-r--r--gitlint/tests/config/test_config_builder.py203
-rw-r--r--gitlint/tests/config/test_config_precedence.py100
-rw-r--r--gitlint/tests/config/test_rule_collection.py64
-rw-r--r--gitlint/tests/contrib/__init__.py0
-rw-r--r--gitlint/tests/contrib/test_contrib_rules.py72
-rw-r--r--gitlint/tests/contrib/test_conventional_commit.py47
-rw-r--r--gitlint/tests/contrib/test_signedoff_by.py32
-rw-r--r--gitlint/tests/expected/test_cli/test_contrib_13
-rw-r--r--gitlint/tests/expected/test_cli/test_debug_1102
-rw-r--r--gitlint/tests/expected/test_cli/test_input_stream_13
-rw-r--r--gitlint/tests/expected/test_cli/test_input_stream_debug_13
-rw-r--r--gitlint/tests/expected/test_cli/test_input_stream_debug_271
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_multiple_commits_18
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_16
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_12
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_270
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_staged_stdin_13
-rw-r--r--gitlint/tests/expected/test_cli/test_lint_staged_stdin_272
-rw-r--r--gitlint/tests/git/test_git.py115
-rw-r--r--gitlint/tests/git/test_git_commit.py535
-rw-r--r--gitlint/tests/git/test_git_context.py89
-rw-r--r--gitlint/tests/rules/__init__.py0
-rw-r--r--gitlint/tests/rules/test_body_rules.py180
-rw-r--r--gitlint/tests/rules/test_configuration_rules.py71
-rw-r--r--gitlint/tests/rules/test_meta_rules.py50
-rw-r--r--gitlint/tests/rules/test_rules.py18
-rw-r--r--gitlint/tests/rules/test_title_rules.py154
-rw-r--r--gitlint/tests/rules/test_user_rules.py223
-rw-r--r--gitlint/tests/samples/commit_message/fixup1
-rw-r--r--gitlint/tests/samples/commit_message/merge3
-rw-r--r--gitlint/tests/samples/commit_message/revert3
-rw-r--r--gitlint/tests/samples/commit_message/sample114
-rw-r--r--gitlint/tests/samples/commit_message/sample21
-rw-r--r--gitlint/tests/samples/commit_message/sample36
-rw-r--r--gitlint/tests/samples/commit_message/sample47
-rw-r--r--gitlint/tests/samples/commit_message/sample57
-rw-r--r--gitlint/tests/samples/commit_message/squash3
-rw-r--r--gitlint/tests/samples/config/gitlintconfig15
-rw-r--r--gitlint/tests/samples/config/invalid-option-value11
-rw-r--r--gitlint/tests/samples/config/no-sections1
-rw-r--r--gitlint/tests/samples/config/nonexisting-general-option13
-rw-r--r--gitlint/tests/samples/config/nonexisting-option11
-rw-r--r--gitlint/tests/samples/config/nonexisting-rule11
-rw-r--r--gitlint/tests/samples/user_rules/bogus-file.txt2
-rw-r--r--gitlint/tests/samples/user_rules/import_exception/invalid_python.py3
-rw-r--r--gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py10
-rw-r--r--gitlint/tests/samples/user_rules/my_commit_rules.foo16
-rw-r--r--gitlint/tests/samples/user_rules/my_commit_rules.py26
-rw-r--r--gitlint/tests/samples/user_rules/parent_package/__init__.py13
-rw-r--r--gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py12
-rw-r--r--gitlint/tests/test_cache.py57
-rw-r--r--gitlint/tests/test_display.py74
-rw-r--r--gitlint/tests/test_hooks.py136
-rw-r--r--gitlint/tests/test_lint.py197
-rw-r--r--gitlint/tests/test_options.py179
-rw-r--r--gitlint/tests/test_utils.py78
-rw-r--r--gitlint/utils.py105
-rw-r--r--mkdocs.yml17
-rw-r--r--qa/__init__.py0
-rw-r--r--qa/base.py178
-rw-r--r--qa/expected/test_commits/test_ignore_commits_111
-rw-r--r--qa/expected/test_commits/test_lint_head_18
-rw-r--r--qa/expected/test_commits/test_lint_staged_msg_filename_173
-rw-r--r--qa/expected/test_commits/test_lint_staged_stdin_175
-rw-r--r--qa/expected/test_commits/test_violations_17
-rw-r--r--qa/expected/test_config/test_config_from_file_15
-rw-r--r--qa/expected/test_config/test_config_from_file_debug_177
-rw-r--r--qa/expected/test_config/test_set_rule_option_13
-rw-r--r--qa/expected/test_config/test_verbosity_13
-rw-r--r--qa/expected/test_config/test_verbosity_23
-rw-r--r--qa/expected/test_contrib/test_contrib_rules_14
-rw-r--r--qa/expected/test_contrib/test_contrib_rules_with_config_14
-rw-r--r--qa/expected/test_gitlint/test_msg_filename_13
-rw-r--r--qa/expected/test_gitlint/test_msg_filename_no_tty_13
-rw-r--r--qa/expected/test_gitlint/test_violations_13
-rw-r--r--qa/expected/test_stdin/test_stdin_file_13
-rw-r--r--qa/expected/test_stdin/test_stdin_pipe_13
-rw-r--r--qa/expected/test_stdin/test_stdin_pipe_empty_13
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_examples_15
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_16
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_extra_15
-rw-r--r--qa/requirements.txt4
-rw-r--r--qa/samples/config/contrib-enabled0
-rw-r--r--qa/samples/config/gitlintconfig13
-rw-r--r--qa/samples/config/ignore-release-commits7
-rw-r--r--qa/samples/user_rules/extra/extra_rules.py29
-rw-r--r--qa/samples/user_rules/incorrect_linerule/my_line_rule.py8
-rw-r--r--qa/shell.py90
-rw-r--r--qa/test_commits.py161
-rw-r--r--qa/test_config.py67
-rw-r--r--qa/test_contrib.py26
-rw-r--r--qa/test_gitlint.py171
-rw-r--r--qa/test_hooks.py153
-rw-r--r--qa/test_stdin.py56
-rw-r--r--qa/test_user_defined.py38
-rw-r--r--qa/utils.py99
-rw-r--r--requirements.txt5
-rwxr-xr-xrun_tests.sh539
-rw-r--r--setup.cfg2
-rw-r--r--setup.py105
-rw-r--r--test-requirements.txt10
-rwxr-xr-xtools/create-test-repo.sh35
-rw-r--r--tools/windows/create-test-repo.bat35
-rw-r--r--tools/windows/run_tests.bat15
167 files changed, 15302 insertions, 0 deletions
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..a2e4c8f
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,2 @@
+[run]
+omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv* \ No newline at end of file
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..df7800e
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,11 @@
+[flake8]
+# H307: like imports should be grouped together
+# H405: multi line docstring summary not separated with an empty line
+# H803: git title must end with a period
+# H904: Wrap long lines in parentheses instead of a backslash
+# H802: git commit title should be under 50 chars
+# H701: empty localization string
+extend-ignore = H307,H405,H803,H904,H802,H701
+# exclude settings files and virtualenvs
+exclude = *settings.py,*.venv/*.py
+max-line-length = 120 \ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md
new file mode 100644
index 0000000..c178614
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue-template.md
@@ -0,0 +1,22 @@
+---
+name: Issue template
+about: Bug reports, feature requests
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+<!--- THIS IS A COMMENT BLOCK, REMOVE IT BEFORE SUBMITTING YOUR ISSUE
+
+Thank you for your interest in gitlint and taking the time to open a bug report!
+
+A few quick notes:
+
+- If you can, please include the output of `gitlint --debug` as this includes useful debugging info.
+- It's really just me (https://github.com/jorisroovers) maintaining gitlint, and I do so in a hobby capacity. More recently it has become harder for me to find time to maintain gitlint on a regular basis, which in practice means that it might take me a while (sometimes months) to get back to you. Rest assured though, I absolutely read all bug reports as soon as they come in - I just tend to only "work" on gitlint a few times a year.
+- If you're looking to contribute code to gitlint, please start here: http://jorisroovers.github.io/gitlint/contributing/
+
+-->
+
+Enter your issue details here
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..348fb47
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,113 @@
+name: Tests and Checks
+
+on: [push]
+
+jobs:
+ checks:
+ runs-on: "ubuntu-latest"
+ strategy:
+ matrix:
+ python-version: [2.7, 3.5, 3.6, 3.7, 3.8, pypy2, pypy3]
+ os: ["macos-latest", "ubuntu-latest"]
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Setup python
+ uses: actions/setup-python@v1
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install requirements
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install -r test-requirements.txt
+
+ - name: Unit Tests
+ run: ./run_tests.sh
+
+ # Coveralls integration doesn't properly work at this point, also see below
+ # - name: Coveralls
+ # env:
+ # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
+ # run: coveralls
+
+ - name: Integration Tests
+ run: ./run_tests.sh -i
+
+ - name: Integration Tests (GITLINT_USE_SH_LIB=0)
+ env:
+ GITLINT_USE_SH_LIB: 0
+ run: ./run_tests.sh -i
+
+ - name: PEP8
+ run: ./run_tests.sh -p
+
+ - name: PyLint
+ run: ./run_tests.sh -l
+
+ - name: Build tests
+ run: ./run_tests.sh --build
+
+ # Coveralls GH Action currently doesn't support current non-LCOV reporting format
+ # For now, still using Travis for unit test coverage reporting
+ # https://github.com/coverallsapp/github-action/issues/30
+ # - name: Coveralls
+ # uses: coverallsapp/github-action@master
+ # with:
+ # github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Gitlint check
+ run: ./run_tests.sh -g
+
+ windows-checks:
+ runs-on: windows-latest
+ strategy:
+ matrix:
+ python-version: [2.7, 3.5]
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Setup python
+ uses: actions/setup-python@v1
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: "Upgrade pip on Python 3"
+ if: matrix.python-version == '3.5'
+ run: python -m pip install --upgrade pip
+
+ - name: Install requirements
+ run: |
+ pip install -r requirements.txt
+ pip install -r test-requirements.txt
+
+ - name: gitlint --version
+ run: gitlint --version
+
+ - name: Tests (sanity)
+ run: tools\windows\run_tests.bat "gitlint\tests\cli\test_cli.py::CLITests::test_lint"
+
+ - name: Tests (ignore test_cli.py)
+ run: pytest --ignore gitlint\tests\cli\test_cli.py -rw -s gitlint
+
+ - name: Tests (test_cli.py only - continue-on-error:true)
+ run: tools\windows\run_tests.bat "gitlint\tests\cli\test_cli.py"
+ continue-on-error: true # Known to fail at this point
+
+ - name: Tests (all - continue-on-error:true)
+ run: tools\windows\run_tests.bat
+ continue-on-error: true # Known to fail at this point
+
+ - name: Integration tests (continue-on-error:true)
+ run: pytest -rw -s qa
+ continue-on-error: true # Known to fail at this point
+
+ - name: PEP8
+ run: flake8 gitlint qa examples
+
+ - name: PyLint
+ run: pylint gitlint qa --rcfile=".pylintrc" -r n
+
+ - name: Gitlint check
+ run: gitlint --debug
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c350158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,68 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+.pytest_cache
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+.venv*
+virtualenv
+
+# Vagrant
+.vagrant
+
+
+# mkdocs
+site/
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
new file mode 100644
index 0000000..5b3d51a
--- /dev/null
+++ b/.pre-commit-hooks.yaml
@@ -0,0 +1,5 @@
+- id: gitlint
+ name: gitlint
+ language: python
+ entry: gitlint --staged --msg-filename
+ stages: [commit-msg]
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..dc54455
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,48 @@
+# The format of this file isn't really documented; just use --generate-rcfile
+[MASTER]
+
+[Messages Control]
+# C0111: Don't require docstrings on every method
+# W0511: TODOs in code comments are fine.
+# W0142: *args and **kwargs are fine.
+# W0223: abstract methods don't need to be overwritten (i.e. when overwriting a Django REST serializer)
+# W0622: Redefining id is fine.
+# R0901: Too many ancestors (i.e. when subclassing test classes)
+# R0801: Similar lines in files
+# I0011: Informational: locally disabled pylint
+# I0013: Informational: Ignoring entire file
+disable=bad-option-value,C0111,W0511,W0142,W0622,W0223,W0212,R0901,R0801,I0011,I0013,anomalous-backslash-in-string,useless-object-inheritance,unnecessary-pass
+
+[Format]
+max-line-length=120
+
+[Basic]
+# Variable names can be 1 to 31 characters long, with lowercase and underscores
+variable-rgx=[a-z_][a-z0-9_]{0,30}$
+
+# Argument names can be 2 to 31 characters long, with lowercase and underscores
+argument-rgx=[a-z_][a-z0-9_]{1,30}$
+
+# Method names should be at least 3 characters long
+# and be lower-cased with underscores
+method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|tearDown)$
+
+# Allow 'id' as variable name everywhere
+good-names=id,c,_
+
+bad-names=__author__
+
+# Ignore all variables that start with an underscore (e.g. unused _request variable in a view)
+dummy-variables-rgx=_
+
+[Design]
+max-public-methods=100
+min-public-methods=0
+# Maximum number of attributes of a class
+max-attributes=15
+max-args=10
+max-locals=20
+
+[Typecheck]
+# Allow the use of the Django 'objects' members
+generated-members=sh.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0a3991d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,271 @@
+# Changelog #
+
+## v0.13.1 (2020-02-26)
+
+- Patch to enable `--staged` flag for pre-commit.
+- Minor doc updates ([#109](https://github.com/jorisroovers/gitlint/issues/109))
+
+## v0.13.0 (2020-02-25)
+
+- **Behavior Change**: Revert Commits are now recognized and ignored by default ([#99](https://github.com/jorisroovers/gitlint/issues/99))
+- ```--staged``` flag: gitlint can now detect meta-data (such as author details, changed files, etc) of staged/pre-commits. Useful when you use [gitlint's commit-msg hook](https://jorisroovers.github.io/gitlint/#using-gitlint-as-a-commit-msg-hook) or [precommit](https://jorisroovers.github.io/gitlint/#using-gitlint-through-pre-commit) ([#105](https://github.com/jorisroovers/gitlint/issues/105))
+- New branch properties on ```GitCommit``` and ```GitContext```, useful when writing your own user-defined rules: ```commit.branches``` and ```commit.context.current_branch``` ([#108](https://github.com/jorisroovers/gitlint/issues/108))
+- Python 3.8 support
+- Python 3.4 no longer supported. Python 3.4 has [reached EOL](https://www.python.org/dev/peps/pep-0429/#id4) and an increasing
+ of gitlint's dependencies have dropped support which makes it hard to maintain.
+- Improved Windows support: better unicode handling. [Issues remain](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows) but the basic functionality works.
+- Bugfixes:
+ - Gitlint no longer crashes when acting on empty repositories (this only occurred in specific circumstances).
+ - Changed files are now better detected in repos that only have a root commit
+- Improved performance and memory (gitlint now caches git properties)
+- Improved `--debug` output
+- Improved documentation
+- Under-the-hood: dependencies updated, unit and integration test improvements, migrated from TravisCI to Github Actions.
+
+## v0.12.0 (2019-07-15) ##
+
+Contributors:
+Special thanks to all contributors for this release, in particular [@rogalksi](https://github.com/rogalski) and [@byrney](https://github.com/byrney).
+
+- [Contrib Rules](http://jorisroovers.github.io/gitlint/contrib_rules): community-contributed rules that are disabled
+ by default, but can be enabled through configuration. Contrib rules are meant to augment default gitlint behavior by
+ providing users with rules for common use-cases without forcing these rules on all gitlint users.
+ - **New Contrib Rule**: ```contrib-title-conventional-commits``` enforces the [Conventional Commits](https://www.conventionalcommits.org) spec. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits).
+ - **New Contrib Rule**: ```cc1-contrib-requires-signed-off-by``` ensures that all commit messages contain a ```Sign-Off-By``` line. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#cc1-contrib-requires-signed-off-by).
+ - If you're interested in adding new Contrib rules to gitlint, please start by reading the
+ [Contributing](http://jorisroovers.github.io/gitlint/contributing/) page. Thanks for considering!
+- *Experimental (!)* Windows support: Basic functionality is working, but there are still caveats. For more details, please refer to [#20](https://github.com/jorisroovers/gitlint/issues/20) and the [open issues related to Windows](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows).
+- Python 3.3 no longer supported. Python 3.4 is likely to follow in a future release as it has [reached EOL](https://www.python.org/dev/peps/pep-0429/#id4) as well.
+- PyPy 3.5 support
+- Support for ```--ignore-stdin``` command-line flag to ignore any text send via stdin. ([#56](https://github.com/jorisroovers/gitlint/issues/56), [#89](https://github.com/jorisroovers/gitlint/issues/89))
+- Bugfixes:
+ - [#68: Can't use install-hooks in with git worktree](https://github.com/jorisroovers/gitlint/issues/68)
+ - [#59: gitlint failed with configured commentchar](https://github.com/jorisroovers/gitlint/issues/59)
+- Under-the-hood: dependencies updated, experimental Dockerfile, github issue template.
+
+## v0.11.0 (2019-03-13) ##
+
+- Python 3.7 support
+- Python 2.6 no longer supported
+- Various dependency updates and under the hood fixes (see [#76](https://github.com/jorisroovers/gitlint/pull/76) for details).
+
+Special thanks to @pbregener for his contributions related to python 3.7 support and test fixes.
+
+## v0.10.0 (2018-04-15) ##
+The 0.10.0 release adds the ability to ignore commits based on their contents,
+support for [pre-commit](https://pre-commit.com/), and important fix for running gitlint in CI environments
+(such as Jenkins, Gitlab, etc).
+
+Special thanks to [asottile](https://github.com/asottile), [bdrung](https://github.com/bdrung), [pbregener](https://github.com/pbregener), [torwald-sergesson](https://github.com/torwald-sergesson), [RykHawthorn](https://github.com/RykHawthorn), [SteffenKockel](https://github.com/SteffenKockel) and [tommyip](https://github.com/tommyip) for their contributions.
+
+**Since it's becoming increasingly hard to support Python 2.6 and 3.3, we'd like to encourage our users to upgrade their
+python version to 2.7 or 3.3+. Future versions of gitlint are likely to drop support for Python 2.6 and 3.3.**
+
+Full Changelog:
+
+- **New Rule**: ```ignore-by-title``` allows users to
+[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
+a commit message title. ([#54](https://github.com/jorisroovers/gitlint/issues/54), [#57](https://github.com/jorisroovers/gitlint/issues/57)).
+- **New Rule**: ```ignore-by-body``` allows users to
+[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
+a line in a commit message body.
+- Gitlint now supports [pre-commit.com](https://pre-commit.com).
+[Details in our documentation](http://jorisroovers.github.io/gitlint/#using-gitlint-through-pre-commit)
+([#62](https://github.com/jorisroovers/gitlint/issues/62)).
+- Gitlint now has a ```--msg-filename``` commandline flag that allows you to specify the commit message to lint via
+ a file ([#39](https://github.com/jorisroovers/gitlint/issues/39)).
+- Gitlint will now be silent by default when a specified commit range is empty ([#46](https://github.com/jorisroovers/gitlint/issues/46)).
+- Gitlint can now be installed on MacOS by brew via the [homebrew-devops](https://github.com/rockyluke/homebrew-devops) tap. To get the latest version of gitlint, always use pip for installation.
+- If all goes well,
+[gitlint will also be available as a package in the Ubuntu 18.04 repositories](https://launchpad.net/ubuntu/+source/gitlint).
+- Bugfixes:
+ - We fixed a nasty and recurring issue with running gitlint in CI. Hopefully that's the end of it :-) ([#40](https://github.com/jorisroovers/gitlint/issues/40)).
+ - Fix for custom git comment characters ([#48](https://github.com/jorisroovers/gitlint/issues/48)).
+
+## v0.9.0 (2017-12-03) ##
+The 0.9.0 release adds a new default ```author-valid-email``` rule, important bugfixes and special case handling.
+Special thanks to [joshholl](https://github.com/joshholl), [ron8mcr](https://github.com/ron8mcr),
+[omarkohl](https://github.com/omarkohl), [domo141](https://github.com/domo141), [nud](https://github.com/nud)
+and [AlexMooney](https://github.com/AlexMooney) for their contributions.
+
+- New Rule: ```author-valid-email``` enforces a valid author email address. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/#m1-author-valid-email).
+- **Breaking change**: The ```--commits``` commandline flag now strictly follows the refspec format as interpreted
+ by the [```git rev-list <refspec>```](https://git-scm.com/docs/git-rev-list) command. This means
+ that linting a single commit using ```gitlint --commits <SHA>``` won't work anymore. Instead, for single commits,
+ users now need to specificy ```gitlint --commits <SHA>^...<SHA>```. On the upside, this change also means
+ that gitlint will now understand all refspec formatters, including ```gitlint --commits HEAD``` to lint all commits
+ in the repository. This fixes [#23](https://github.com/jorisroovers/gitlint/issues/23).
+- **Breaking change**: Gitlint now always falls back on trying to read a git message from a local git repository, only
+ reading a commit message from STDIN if one is passed. Before, gitlint only read from the local git repository when
+ a TTY was present. This is likely the expected and desired behavior for anyone running gitlint in a CI environment.
+ This fixes [#40](https://github.com/jorisroovers/gitlint/issues/40) and
+ [#42](https://github.com/jorisroovers/gitlint/issues/42).
+- **Behavior Change**: Gitlint will now by default
+ [ignore squash and fixup commits](http://jorisroovers.github.io/gitlint/#merge-fixup-and-squash-commits)
+ (fix for [#33: fixup messages should not trigger a gitlint violation](https://github.com/jorisroovers/gitlint/issues/33))
+- Support for custom comment characters ([#34](https://github.com/jorisroovers/gitlint/issues/34))
+- Support for [```git commit --cleanup=scissors```](https://git-scm.com/docs/git-commit#git-commit---cleanupltmodegt)
+ ([#34](https://github.com/jorisroovers/gitlint/issues/34))
+- Bugfix: [#37: Prevent Commas in text fields from breaking git log printing](https://github.com/jorisroovers/gitlint/issues/37)
+- Debug output improvements
+
+## v0.8.2 (2017-04-25) ##
+
+The 0.8.2 release brings minor improvements, bugfixes and some under-the-hood changes. Special thanks to
+[tommyip](https://github.com/tommyip) for his contributions.
+
+- ```--extra-path``` now also accepts a file path (in the past only directory paths where accepted).
+Thanks to [tommyip](https://github.com/tommyip) for implementing this!
+- gitlint will now show more information when using the ```--debug``` flag. This is initial work and will continue to
+be improved upon in later releases.
+- Bugfixes:
+ - [#24: --commits doesn't take commit specific config into account](https://github.com/jorisroovers/gitlint/issues/24)
+ - [#27: --commits returns the wrong exit code](https://github.com/jorisroovers/gitlint/issues/27)
+- Development: better unit and integration test coverage for ```--commits```
+
+## v0.8.1 (2017-03-16) ##
+
+The 0.8.1 release brings minor tweaks and some experimental features. Special thanks to
+[tommyip](https://github.com/tommyip) for his contributions.
+
+- Experimental: Linting a range of commits.
+ [Documentation](http://jorisroovers.github.io/gitlint/#linting-a-range-of-commits).
+ Known Caveats: [#23](https://github.com/jorisroovers/gitlint/issues/23),
+ [#24](https://github.com/jorisroovers/gitlint/issues/24).
+ Closes [#14](https://github.com/jorisroovers/gitlint/issues/14). Thanks to [tommyip](https://github.com/tommyip)
+ for implementing this!
+- Experimental: Python 3.6 support
+- Improved Windows error messaging: gitlint will now show a more descriptive error message when ran on windows.
+ See [#20](https://github.com/jorisroovers/gitlint/issues/20) for details on the lack of Windows support.
+
+## v0.8.0 (2016-12-30) ##
+
+The 0.8.0 release is a significant release that has been in the works for a long time. Special thanks to
+[Claymore](https://github.com/Claymore), [gernd](https://github.com/gernd) and
+[ZhangYaxu](https://github.com/ZhangYaxu) for submitting bug reports and pull requests.
+
+- Full unicode support: you can now lint messages in any language! This fixes
+ [#16](https://github.com/jorisroovers/gitlint/issues/16) and [#18](https://github.com/jorisroovers/gitlint/pull/18).
+- User-defined rules: you can now
+ [define your own custom rules](http://jorisroovers.github.io/gitlint/user_defined_rules/)
+ if you want to extend gitlint's functionality.
+- Pypy2 support!
+- Debug output improvements: Gitlint will now print your active configuration when using ```--debug```
+- The ```general.target``` option can now also be set via ```-c``` flags or a ```.gitlint``` file
+- Bugfixes:
+ - Various important fixes related to configuration precedence
+ - [#17: Body MinLength is not working properly](https://github.com/jorisroovers/gitlint/issues/17).
+ **Behavior Change**: Gitlint now always applies this rule, even if the body has just a single line of content.
+ Also, gitlint now counts the body-length for the entire body, not just the length of the first line.
+- Various documentation improvements
+- Development:
+ - Pylint compliance for all supported python versions
+ - Updated dependencies to latest versions
+ - Various ```run_tests.sh``` improvements for developer convenience
+
+## v0.7.1 (2016-06-18) ##
+Bugfixes:
+
+- **Behavior Change**: gitlint no longer prints the file path by default when using a ```.gitlint``` file. The path
+will still be printed when using the new ```--debug``` flag. Special thanks to [Slipcon](https://github.com/slipcon)
+for submitting this.
+- Gitlint now prints a correct violation message for the ```title-match-regex``` rule. Special thanks to
+[Slipcon](https://github.com/slipcon) for submitting this.
+- Gitlint is now better at parsing commit messages cross-platform by taking platform specific line endings into account
+- Minor documentation improvements
+
+## v0.7.0 (2016-04-20) ##
+This release contains mostly bugfix and internal code improvements. Special thanks to
+[William Turell](https://github.com/wturrell) and [Joe Grund](https://github.com/jgrund) for bug reports and pull
+requests.
+
+- commit-msg hooks improvements: The new commit-msg hook now allows you to edit your message if it contains violations,
+ prints the commit message on aborting and is more compatible with GUI-based git clients such as SourceTree.
+ *You will need to uninstall and reinstall the commit-msg hook for these latest features*.
+- Python 2.6 support
+- **Behavior change**: merge commits are now ignored by default. The rationale is that the original commits
+ should already be linted and that many merge commits don't pass gitlint checks by default
+ (e.g. exceeding title length or empty body is very common). This behavior can be overwritten by setting the
+ general option ```ignore-merge-commit=false```.
+- Bugfixes and enhancements:
+ - [#7: Hook compatibility with SourceTree](https://github.com/jorisroovers/gitlint/issues/7)
+ - [#8: Illegal option -e](https://github.com/jorisroovers/gitlint/issues/8)
+ - [#9: print full commit msg to stdout if aborted](https://github.com/jorisroovers/gitlint/issues/9)
+ - [#11 merge commit titles exceeding the max title length by default](https://github.com/jorisroovers/gitlint/issues/11)
+ - Better error handling of invalid general options
+- Development: internal refactoring to extract more info from git. This will allow for more complex rules in the future.
+- Development: initial set of integration tests. Test gitlint end-to-end after it is installed.
+- Development: pylint compliance for python 2.7
+
+## v0.6.1 (2015-11-22) ##
+
+- Fix: ```install-hook``` and ```generate-config``` commands not working when gitlint is installed from pypi.
+
+## v0.6.0 (2015-11-22) ##
+
+- Python 3 (3.3+) support!
+- All documentation is now hosted on [http://jorisroovers.github.io/gitlint/]()
+- New ```generate-config``` command generates a sample gitlint config file
+- New ```--target``` flag allows users to lint different directories than the current working directory
+- **Breaking change**: exit code behavior has changed. More details in the
+ [Exit codes section of the documentation](http://jorisroovers.github.io/gitlint/#exit-codes).
+- **Breaking change**: ```--install-hook``` and ```--uninstall-hook``` have been renamed to ```install-hook``` and
+ ```uninstall-hook``` respectively to better express that they are commands instead of options.
+- Better error handling when gitlint is executed in a directory that is not a git repository or
+ when git is not installed.
+- The git commit message hook now uses pretty colored output
+- Fix: ```--config``` option no longer accepts directories as value
+- Development: unit tests are now ran using py.test
+
+## v0.5.0 (2015-10-04) ##
+
+- New Rule: ```title-match-regex```. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
+- Uninstall previously installed gitlint git commit hooks using: ```gitlint --uninstall-hook```
+- Ignore rules on a per commit basis by adding e.g.: ```gitlint-ignore: T1, body-hard-tab``` to your git commit message.
+ Use ```gitlint-ignore: all``` to disable gitlint all together for a specific commit.
+- ```body-is-missing``` will now automatically be disabled for merge commits (use the ```ignore-merge-commit: false```
+ option to disable this behavior)
+- Violations are now sorted by line number first and then by rule id (previously the order of violations on the
+ same line was arbitrary).
+
+## v0.4.1 (2015-09-19) ##
+
+- Internal fix: added missing comma to setup.py which prevented pypi upload
+
+## v0.4.0 (2015-09-19) ##
+
+- New rules: ```body-is-missing```, ```body-min-length```, ```title-leading-whitespace```,
+ ```body-changed-file-mention```. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
+- The git ```commit-msg``` hook now allows you to keep or discard the commit when it fails gitlint validation
+- gitlint is now also released as a [python wheel](http://pythonwheels.com/) on pypi.
+- Internal: rule classes now have access to a gitcontext containing body the commit message and the files changed in the
+ last commit.
+
+## v0.3.0 (2015-09-11) ##
+- ```title-must-not-contain-word``` now has a ```words``` option that can be used to specify which words should not
+ occur in the title
+- gitlint violations are now printed to the stderr instead of stdout
+- Various minor bugfixes
+- gitlint now ignores commented out lines (i.e. starting with #) in your commit messages
+- Experimental: git commit-msg hook support
+- Under-the-hood: better test coverage :-)
+
+## v0.2.0 (2015-09-10) ##
+ - Rules can now have their behavior configured through options.
+ For example, the ```title-max-length``` rule now has a ```line-length``` option.
+ - Under-the-hood: The codebase now has a basic level of unit test coverage, increasing overall quality assurance
+
+## v0.1.1 (2015-09-08) ##
+- Bugfix: added missing ```sh``` dependency
+
+## v0.1.0 (2015-09-08) ##
+- Initial gitlint release
+- Initial set of rules: title-max-length, title-trailing-whitespace, title-trailing-punctuation , title-hard-tab,
+ title-must-not-contain-word, body-max-line-length, body-trailing-whitespace, body-hard-tab
+- General gitlint configuration through a ```gitlint``` file
+- Silent and verbose mode
+- Vagrantfile for easy development
+- gitlint is available on [pypi](https://pypi.python.org/pypi/gitlint)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..892ff53
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,6 @@
+# Contributing
+
+Thanks for your interest in contributing to gitlint!
+
+Instructions on how to get started can be found on [http://jorisroovers.github.io/gitlint/contributing](http://jorisroovers.github.io/gitlint/contributing/).
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b66bb71
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+# User-facing Dockerfile. For development, see Dockerfile.dev and ./run_tests.sh -h
+
+# To lint your current working directory:
+# docker run -v $(pwd):/repo jorisroovers/gitlint
+
+# With arguments:
+# docker run -v $(pwd):/repo jorisroovers/gitlint --debug --ignore T1
+
+FROM python:3.8-alpine
+ARG GITLINT_VERSION
+
+RUN apk add git
+RUN pip install gitlint==$GITLINT_VERSION
+
+ENTRYPOINT ["gitlint", "--target", "/repo"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 0000000..5cd1739
--- /dev/null
+++ b/Dockerfile.dev
@@ -0,0 +1,17 @@
+# Note: development using the local Dockerfile is still work-in-progress
+# Getting started: http://jorisroovers.github.io/gitlint/contributing/
+ARG python_version_dotted
+
+FROM python:${python_version_dotted}-stretch
+
+RUN apt-get update
+# software-properties-common contains 'add-apt-repository'
+RUN apt-get install -y git silversearcher-ag jq curl
+
+ADD . /gitlint
+WORKDIR /gitlint
+
+RUN pip install --ignore-requires-python -r requirements.txt
+RUN pip install --ignore-requires-python -r test-requirements.txt
+
+CMD ["/bin/bash"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..122bd28
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Joris Roovers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..51a5598
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,7 @@
+include README.md
+include LICENSE
+exclude Vagrantfile
+exclude *.yml *.sh *.txt
+recursive-exclude examples *
+recursive-exclude gitlint/tests *
+recursive-exclude qa * \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..81f2ac9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+# gitlint: [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) #
+
+[![Tests](https://github.com/jorisroovers/gitlint/workflows/Tests%20and%20Checks/badge.svg)](https://github.com/jorisroovers/gitlint/actions?query=workflow%3A%22Tests+and+Checks%22)
+[![PyPi Package](https://img.shields.io/pypi/v/gitlint.png)](https://pypi.python.org/pypi/gitlint)
+![Supported Python Versions](https://img.shields.io/pypi/pyversions/gitlint.svg)
+
+Git commit message linter written in python (for Linux and Mac, experimental on Windows), checks your commit messages for style.
+
+**See [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) for full documentation.**
+
+<a href="http://jorisroovers.github.io/gitlint/" target="_blank"><img src="https://asciinema.org/a/30477.png" width="640"/></a>
+
+## Contributing ##
+All contributions are welcome and very much appreciated!
+
+**I'm looking for contributors that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please open a PR if you're interested - Thanks!**
+
+See [jorisroovers.github.io/gitlint/contributing](http://jorisroovers.github.io/gitlint/contributing) for details on
+how to get started - it's easy!
+
+We maintain a [loose roadmap on our wiki](https://github.com/jorisroovers/gitlint/wiki/Roadmap).
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 0000000..2a26aab
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,47 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+VAGRANTFILE_API_VERSION = "2"
+
+INSTALL_DEPS=<<EOF
+cd /vagrant
+sudo add-apt-repository -y ppa:deadsnakes/ppa
+sudo apt-get update
+sudo apt-get install -y --allow-unauthenticated python2.7-dev python3.5-dev python3.6-dev python3.7-dev python3.8-dev
+sudo apt-get install -y --allow-unauthenticated python3.8-distutils # Needed to work around python3.8+virtualenv issue
+sudo apt-get install -y python-virtualenv git ipython python-pip python3-pip silversearcher-ag jq
+sudo apt-get purge -y python3-virtualenv
+sudo pip3 install virtualenv
+
+./run_tests.sh --uninstall --envs all
+./run_tests.sh --install --envs all
+
+grep 'cd /vagrant' /home/vagrant/.bashrc || echo 'cd /vagrant' >> /home/vagrant/.bashrc
+grep 'source .venv27/bin/activate' /home/vagrant/.bashrc || echo 'source .venv27/bin/activate' >> /home/vagrant/.bashrc
+EOF
+
+INSTALL_JENKINS=<<EOF
+wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -
+sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
+sudo apt-get update
+sudo apt-get install -y openjdk-8-jre
+sudo apt-get install -y jenkins
+EOF
+
+Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
+
+ config.vm.box = "ubuntu/xenial64"
+
+ config.vm.define "dev" do |dev|
+ dev.vm.provision "gitlint", type: "shell", inline: "#{INSTALL_DEPS}"
+ # Use 'vagrant provision --provision-with jenkins' to only run jenkins install
+ dev.vm.provision "jenkins", type: "shell", inline: "#{INSTALL_JENKINS}"
+ end
+
+ config.vm.network "forwarded_port", guest: 8080, host: 9080
+
+ if Vagrant.has_plugin?("vagrant-cachier")
+ config.cache.scope = :box
+ end
+
+end
diff --git a/doc-requirements.txt b/doc-requirements.txt
new file mode 100644
index 0000000..baf208d
--- /dev/null
+++ b/doc-requirements.txt
@@ -0,0 +1 @@
+mkdocs==1.0.4 \ No newline at end of file
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..641b361
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,432 @@
+# Configuration
+Gitlint can be configured through different means.
+
+# Config files #
+You can modify gitlint's behavior by adding a ```.gitlint``` file to your git repository.
+
+Generate a default ```.gitlint``` config file by running:
+```bash
+gitlint generate-config
+```
+You can also use a different config file like so:
+
+```bash
+gitlint --config myconfigfile.ini
+```
+
+The block below shows a sample ```.gitlint``` file. Details about rule config options can be found on the
+[Rules](rules.md) page, details about the ```[general]``` section can be found in the
+[General Configuration](configuration.md#general-configuration) section of this page.
+
+```ini
+# Edit this file as you like.
+#
+# All these sections are optional. Each section with the exception of [general] represents
+# one rule and each key in it is an option for that specific rule.
+#
+# Rules and sections can be referenced by their full name or by id. For example
+# section "[body-max-line-length]" could be written as "[B1]". Full section names are
+# used in here for clarity.
+# Rule reference documentation: http://jorisroovers.github.io/gitlint/rules/
+#
+# Use 'gitlint generate-config' to generate a config file with all possible options
+[general]
+# Ignore certain rules (comma-separated list), you can reference them by their
+# id or by their full name
+ignore=title-trailing-punctuation, T3
+
+# verbosity should be a value between 1 and 3, the commandline -v flags take
+# precedence over this
+verbosity = 2
+
+# By default gitlint will ignore merge, revert, fixup and squash commits.
+ignore-merge-commits=true
+ignore-revert-commits=true
+ignore-fixup-commits=true
+ignore-squash-commits=true
+
+# Ignore any data send to gitlint via stdin
+ignore-stdin=true
+
+# Fetch additional meta-data from the local repository when manually passing a
+# commit message to gitlint via stdin or --commit-msg. Disabled by default.
+staged=true
+
+# Enable debug mode (prints more output). Disabled by default.
+debug=true
+
+# Enable community contributed rules
+# See http://jorisroovers.github.io/gitlint/contrib_rules for details
+contrib=contrib-title-conventional-commits,CC1
+
+# Set the extra-path where gitlint will search for user defined rules
+# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
+extra-path=examples/
+
+# This is an example of how to configure the "title-max-length" rule and
+# set the line-length it enforces to 80
+[title-max-length]
+line-length=80
+
+[title-must-not-contain-word]
+# Comma-separated list of words that should not occur in the title. Matching is case
+# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
+# will not cause a violation, but "WIP: my title" will.
+words=wip
+
+[title-match-regex]
+# python like regex (https://docs.python.org/2/library/re.html) that the
+# commit-msg title must be matched to.
+# Note that the regex can contradict with other rules if not used correctly
+# (e.g. title-must-not-contain-word).
+regex=^US[0-9]*
+
+[body-max-line-length]
+line-length=120
+
+[body-min-length]
+min-length=5
+
+[body-is-missing]
+# Whether to ignore this rule on merge commits (which typically only have a title)
+# default = True
+ignore-merge-commits=false
+
+[body-changed-file-mention]
+# List of files that need to be explicitly mentioned in the body when they are changed
+# This is useful for when developers often erroneously edit certain files or git submodules.
+# By specifying this rule, developers can only change the file when they explicitly reference
+# it in the commit message.
+files=gitlint/rules.py,README.md
+
+[author-valid-email]
+# python like regex (https://docs.python.org/2/library/re.html) that the
+# commit author email address should be matched to
+# For example, use the following regex if you only want to allow email addresses from foo.com
+regex=[^@]+@foo.com
+
+[ignore-by-title]
+# Ignore certain rules for commits of which the title matches a regex
+# E.g. Match commit titles that start with "Release"
+regex=^Release(.*)
+
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+ignore=T1,body-min-length
+
+[ignore-by-body]
+# Ignore certain rules for commits of which the body has a line that matches a regex
+# E.g. Match bodies that have a line that that contain "release"
+# regex=(.*)release(.*)
+#
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+ignore=T1,body-min-length
+
+# This is a contrib rule - a community contributed rule. These are disabled by default.
+# You need to explicitly enable them one-by-one by adding them to the "contrib" option
+# under [general] section above.
+[contrib-title-conventional-commits]
+# Specify allowed commit types. For details see: https://www.conventionalcommits.org/
+types = bugfix,user-story,epic
+```
+
+# Commandline config #
+
+You can also use one or more ```-c``` flags like so:
+
+```
+$ gitlint -c general.verbosity=2 -c title-max-length.line-length=80 -c B1.line-length=100
+```
+The generic config flag format is ```-c <rule>.<option>=<value>``` and supports all the same rules and options which
+you can also use in a ```.gitlint``` config file.
+
+# Commit specific config #
+
+You can also configure gitlint by adding specific lines to your commit message.
+For now, we only support ignoring commits by adding ```gitlint-ignore: all``` to the commit
+message like so:
+
+```
+WIP: This is my commit message
+
+I want gitlint to ignore this entire commit message.
+gitlint-ignore: all
+```
+
+```gitlint-ignore: all``` can occur on any line, as long as it is at the start of the line.
+
+You can also specify specific rules to be ignored as follows:
+```
+WIP: This is my commit message
+
+I want gitlint to ignore this entire commit message.
+gitlint-ignore: T1, body-hard-tab
+```
+
+
+
+# Configuration precedence #
+gitlint configuration is applied in the following order of precedence:
+
+1. Commit specific config (e.g.: ```gitlint-ignore: all``` in the commit message)
+2. Configuration Rules (e.g.: [ignore-by-title](/rules/#i1-ignore-by-title))
+3. Commandline convenience flags (e.g.: ```-vv```, ```--silent```, ```--ignore```)
+4. Commandline configuration flags (e.g.: ```-c title-max-length=123```)
+5. Configuration file (local ```.gitlint``` file, or file specified using ```-C```/```--config```)
+6. Default gitlint config
+
+# General Options
+Below we outline all configuration options that modify gitlint's overall behavior. These options can be specified
+using commandline flags or in ```[general]``` section in a ```.gitlint``` configuration file.
+
+## silent
+
+Enable silent mode (no output). Use [exit](index.md#exit-codes) code to determine result.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ false | >= 0.1.0 | ```--silent```
+
+### Examples
+```sh
+# CLI
+gitlint --silent
+```
+
+## verbosity
+
+Amount of output gitlint will show when printing errors.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ 3 | >= 0.1.0 | `-v`
+
+
+### Examples
+```sh
+# CLI
+gitlint -vvv # default (level 3)
+gitlint -vv # less output (level 2)
+gitlint -v # even less (level 1)
+gitlint --silent # no output (level 0)
+gitlint -c general.verbosity=1 # Set specific level
+gitlint -c general.verbosity=0 # Same as --silent
+```
+```ini
+.gitlint
+[general]
+verbosity=2
+```
+
+## ignore-merge-commits
+
+Whether or not to ignore merge commits.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ true | >= 0.7.0 | Not Available
+
+### Examples
+```sh
+# CLI
+gitlint -c general.ignore-merge-commits=false
+```
+```ini
+#.gitlint
+[general]
+ignore-merge-commits=false
+```
+
+## ignore-revert-commits
+
+Whether or not to ignore revert commits.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ true | >= 0.13.0 | Not Available
+
+### Examples
+```sh
+# CLI
+gitlint -c general.ignore-revert-commits=false
+```
+```ini
+#.gitlint
+[general]
+ignore-revert-commits=false
+```
+
+## ignore-fixup-commits
+
+Whether or not to ignore [fixup](https://git-scm.com/docs/git-commit#git-commit---fixupltcommitgt) commits.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ true | >= 0.9.0 | Not Available
+
+### Examples
+```sh
+# CLI
+gitlint -c general.ignore-fixup-commits=false
+```
+```ini
+#.gitlint
+[general]
+ignore-fixup-commits=false
+```
+
+## ignore-squash-commits
+
+Whether or not to ignore [squash](https://git-scm.com/docs/git-commit#git-commit---squashltcommitgt) commits.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ true | >= 0.9.0 | Not Available
+
+### Examples
+```sh
+# CLI
+gitlint -c general.ignore-squash-commits=false
+```
+```ini
+#.gitlint
+[general]
+ignore-squash-commits=false
+```
+
+## ignore
+
+Comma separated list of rules to ignore (by name or id).
+
+Default value | gitlint version | commandline flag
+---------------------------|------------------|-------------------
+ [] (=empty list) | >= 0.1.0 | `--ignore`
+
+### Examples
+```sh
+# CLI
+gitlint --ignore=body-min-length # ignore single rule
+gitlint --ignore=T1,body-min-length # ignore multiple rule
+gitlint -c general.ignore=T1,body-min-length # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+ignore=T1,body-min-length
+```
+
+## debug
+
+Enable debugging output.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ false | >= 0.7.1 | `--debug`
+
+### Examples
+```sh
+# CLI
+gitlint --debug
+# --debug is special, the following does NOT work
+# gitlint -c general.debug=true
+```
+
+## target
+
+Target git repository gitlint should be linting against.
+
+Default value | gitlint version | commandline flag
+---------------------------|------------------|-------------------
+ (empty) | >= 0.8.0 | `--target`
+
+### Examples
+```sh
+# CLI
+gitlint --target=/home/joe/myrepo/
+gitlint -c general.target=/home/joe/myrepo/ # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+target=/home/joe/myrepo/
+```
+
+## extra-path
+
+Path where gitlint looks for [user-defined rules](user_defined_rules.md).
+
+Default value | gitlint version | commandline flag
+---------------------------|------------------|-------------------
+ (empty) | >= 0.8.0 | `--extra-path`
+
+### Examples
+```sh
+# CLI
+gitlint --extra-path=/home/joe/rules/
+gitlint -c general.extra-path=/home/joe/rules/ # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+extra-path=/home/joe/rules/
+```
+
+## contrib
+
+[Contrib rules](contrib_rules) to enable.
+
+Default value | gitlint version | commandline flag
+---------------------------|------------------|-------------------
+ (empty) | >= 0.12.0 | `--contrib`
+
+### Examples
+```sh
+# CLI
+gitlint --contrib=contrib-title-conventional-commits,CC1
+gitlint -c general.contrib=contrib-title-conventional-commits,CC1 # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+contrib=contrib-title-conventional-commits,CC1
+```
+## ignore-stdin
+
+Ignore any stdin data. Sometimes useful when running gitlint in a CI server.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ false | >= 0.12.0 | `--ignore-stdin`
+
+### Examples
+```sh
+# CLI
+gitlint --ignore-stdin
+gitlint -c general.ignore-stdin=true # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+ignore-stdin=true
+```
+
+## staged
+
+Fetch additional meta-data from the local `repository when manually passing a commit message to gitlint via stdin or ```--commit-msg```.
+
+Default value | gitlint version | commandline flag
+---------------|------------------|-------------------
+ false | >= 0.13.0 | `--staged`
+
+### Examples
+```sh
+# CLI
+gitlint --staged
+gitlint -c general.staged=true # different way of doing the same
+```
+```ini
+#.gitlint
+[general]
+staged=true
+``` \ No newline at end of file
diff --git a/docs/contrib_rules.md b/docs/contrib_rules.md
new file mode 100644
index 0000000..a4f4f0d
--- /dev/null
+++ b/docs/contrib_rules.md
@@ -0,0 +1,67 @@
+# Using Contrib Rules
+_Introduced in gitlint v0.12.0_
+
+Contrib rules are community-**contrib**uted rules that are disabled by default, but can be enabled through configuration.
+
+Contrib rules are meant to augment default gitlint behavior by providing users with rules for common use-cases without
+forcing these rules on all gitlint users. This also means that users don't have to
+re-implement these commonly used rules themselves as [user-defined](user_defined_rules) rules.
+
+To enable certain contrib rules, you can use the ```--contrib``` flag.
+```sh
+$ cat examples/commit-message-1 | gitlint --contrib contrib-title-conventional-commits,CC1
+1: CC1 Body does not contain a 'Signed-Off-By' line
+1: CL1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test: "WIP: This is the title of a commit message."
+
+# These are the default violations
+1: T3 Title has trailing punctuation (.): "WIP: This is the title of a commit message."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: This is the title of a commit message."
+2: B4 Second line is not empty: "The second line should typically be empty"
+3: B1 Line exceeds max length (123>80): "Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120."
+```
+
+Same thing using a ```.gitlint``` file:
+
+```ini
+[general]
+# You HAVE to add the rule here to enable it, only configuring (such as below)
+# does NOT enable it.
+contrib=contrib-title-conventional-commits,CC1
+
+
+[contrib-title-conventional-commits]
+# Specify allowed commit types. For details see: https://www.conventionalcommits.org/
+types = bugfix,user-story,epic
+```
+
+You can also configure contrib rules using [any of the other ways to configure gitlint](configuration.md).
+
+# Available Contrib Rules
+
+ID | Name | gitlint version | Description
+------|-------------------------------------|------------------ |-------------------------------------------
+CT1 | contrib-title-conventional-commits | >= 0.12.0 | Enforces [Conventional Commits](https://www.conventionalcommits.org/) commit message style on the title.
+CC1 | contrib-requires-signed-off-by | >= 0.12.0 | Commit body must contain a `Signed-Off-By` line.
+
+## CT1: contrib-title-conventional-commits ##
+
+ID | Name | gitlint version | Description
+------|---------------------------------------|--------------------|-------------------------------------------
+CT1 | contrib-title-conventional-commits | >= 0.12.0 | Enforces [Conventional Commits](https://www.conventionalcommits.org/) commit message style on the title.
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|--------------------|--------------|----------------------------------
+types | >= 0.12.0 | `fix,feat,chore,docs,style,refactor,perf,test,revert` | Comma separated list of allowed commit types.
+
+
+## CC1: contrib-requires-signed-off-by ##
+
+ID | Name | gitlint version | Description
+------|---------------------------------------|--------------------|-------------------------------------------
+CC1 | contrib-requires-signed-off-by | >= 0.12.0 | Commit body must contain a `Signed-Off-By` line. This means, a line that starts with the `Signed-Off-By` keyword.
+
+
+# Contributing Contrib rules
+We'd love for you to contribute new Contrib rules to gitlint or improve existing ones! Please visit the [Contributing](contributing) page on how to get started. \ No newline at end of file
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..0cd6eaf
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,132 @@
+# Contributing
+
+We'd love for you to contribute to gitlint. Thanks for your interest!
+The [source-code and issue tracker](https://github.com/jorisroovers/gitlint) are hosted on Github.
+
+Often it takes a while for us (well, actually just [me](https://github.com/jorisroovers)) to get back to you
+(sometimes up to a few months, this is a hobby project), but rest assured that we read your message and appreciate
+your interest!
+We maintain a [loose roadmap on our wiki](https://github.com/jorisroovers/gitlint/wiki/Roadmap), but
+that's open to a lot of change and input.
+
+# Guidelines
+
+When contributing code, please consider all the parts that are typically required:
+
+- [Unit tests](https://github.com/jorisroovers/gitlint/tree/master/gitlint/tests) (automatically
+ [enforced by CI](https://github.com/jorisroovers/gitlint/actions)). Please consider writing
+ new ones for your functionality, not only updating existing ones to make the build pass.
+- [Integration tests](https://github.com/jorisroovers/gitlint/tree/master/qa) (also automatically
+ [enforced by CI](https://github.com/jorisroovers/gitlint/actions)). Again, please consider writing new ones
+ for your functionality, not only updating existing ones to make the build pass.
+- [Documentation](https://github.com/jorisroovers/gitlint/tree/master/docs)
+
+Since we want to maintain a high standard of quality, all of these things will have to be done regardless before code
+can make it as part of a release. If you can already include them as part of your PR, it's a huge timesaver for us
+and it's likely that your PR will be merged and released a lot sooner. Thanks!
+
+# Development #
+
+There is a Vagrantfile in this repository that can be used for development.
+```bash
+vagrant up
+vagrant ssh
+```
+
+Or you can choose to use your local environment:
+
+```bash
+virtualenv .venv
+pip install -r requirements.txt -r test-requirements.txt -r doc-requirements.txt
+python setup.py develop
+```
+
+To run tests:
+```bash
+./run_tests.sh # run unit tests and print test coverage
+./run_test.sh gitlint/tests/test_body_rules.py::BodyRuleTests::test_body_missing # run a single test
+./run_tests.sh --no-coverage # run unit tests without test coverage
+./run_tests.sh --collect-only --no-coverage # Only collect, don't run unit tests
+./run_tests.sh --integration # Run integration tests (requires that you have gitlint installed)
+./run_tests.sh --build # Run build tests (=build python package)
+./run_tests.sh --pep8 # pep8 checks
+./run_tests.sh --stats # print some code stats
+./run_tests.sh --git # inception: run gitlint against itself
+./run_tests.sh --lint # run pylint checks
+./run_tests.sh --all # Run unit, integration, pep8 and gitlint checks
+
+
+```
+
+The ```Vagrantfile``` comes with ```virtualenv```s for python 2.7, 3.5, 3.6, 3.7 and pypy2.
+You can easily run tests against specific python environments by using the following commands *inside* of the Vagrant VM:
+```
+./run_tests.sh --envs 27 # Run the unit tests against Python 2.7
+./run_tests.sh --envs 27,35,pypy2 # Run the unit tests against Python 2.7, Python 3.5 and Pypy2
+./run_tests.sh --envs 27,35 --pep8 # Run pep8 checks against Python 2.7 and Python 3.5 (also works for ```--git```, ```--integration```, ```--pep8```, ```--stats``` and ```--lint```).
+./run_tests.sh --envs all --all # Run all tests against all environments
+./run_tests.sh --all-env --all # Idem: Run all tests against all environments
+```
+
+!!! important
+ Gitlint commits and pull requests are gated on all of our tests and checks.
+
+# Packaging #
+
+To see the package description in HTML format
+```
+pip install docutils
+export LC_ALL=en_US.UTF-8
+export LANG=en_US.UTF-8
+python setup.py --long-description | rst2html.py > output.html
+```
+
+# Documentation #
+We use [mkdocs](https://www.mkdocs.org/) for generating our documentation from markdown.
+
+To use it, do the following outside of the vagrant box (on your host machine):
+```bash
+pip install -r doc-requirements.txt # install doc requirements
+mkdocs serve
+```
+
+Then access the documentation website on your host machine on [http://localhost:8000]().
+
+# Tools #
+We keep a small set of scripts in the ```tools/``` directory:
+
+```sh
+tools/create-test-repo.sh # Create a test git repo in your /tmp directory
+tools/windows/create-test-repo.bat # Windows: create git test repo
+tools/windows/run_tests.bat # Windows run unit tests
+```
+
+# Contrib rules
+Since gitlint 0.12.0, we support [Contrib rules](../contrib_rules): community contributed rules that are part of gitlint
+itself. Thanks for considering to add a new one to gitlint!
+
+Before starting, please read all the other documentation on this page about contributing first.
+Then, we suggest taking the following approach to add a Contrib rule:
+
+1. **Write your rule as a [user-defined rule](../user_defined_rules)**. In terms of code, Contrib rules are identical to
+ user-defined rules, they just happen to have their code sit within the gitlint codebase itself.
+2. **Add your user-defined rule to gitlint**. You should put your file(s) in the [gitlint/contrib/rules](https://github.com/jorisroovers/gitlint/tree/master/gitlint/contrib/rules) directory.
+3. **Write unit tests**. The gitlint codebase contains [Contrib rule test files you can copy and modify](https://github.com/jorisroovers/gitlint/tree/master/gitlint/tests/contrib).
+4. **Write documentation**. In particular, you should update the [gitlint/docs/contrib_rules.md](https://github.com/jorisroovers/gitlint/blob/master/docs/contrib_rules.md) file with details on your Contrib rule.
+5. **Create a Pull Request**: code review typically requires a bit of back and forth. Thanks for your contribution!
+
+
+## Contrib rule requirements
+If you follow the steps above and follow the existing gitlint conventions wrt naming things, you should already be fairly close to done.
+
+In case you're looking for a slightly more formal spec, here's what gitlint requires of Contrib rules.
+
+- Since Contrib rules are really just user-defined rules that live within the gitlint code-base, all the [user-rule requirements](../user_defined_rules/#rule-requirements) also apply to Contrib rules.
+- All contrib rules **must** have associated unit tests. We *sort of* enforce this by a unit test that verifies that there's a
+ test file for each contrib file.
+- All contrib rules **must** have names that start with `contrib-`. This is to easily distinguish them from default gitlint rules.
+- All contrib rule ids **must** start with `CT` (for LineRules targeting the title), `CB` (for LineRules targeting the body) or `CC` (for CommitRules). Again, this is to easily distinguish them from default gitlint rules.
+- All contrib rules **must** have unique names and ids.
+- You **can** add multiple rule classes to the same file, but classes **should** be logically grouped together in a single file that implements related rules.
+- Contrib rules **should** be meaningfully different from one another. If a behavior change or tweak can be added to an existing rule by adding options, that should be considered first. However, large [god classes](https://en.wikipedia.org/wiki/God_object) that implement multiple rules in a single class should obviously also be avoided.
+- Contrib rules **should** use [options](../user_defined_rules/#options) to make rules configurable.
diff --git a/docs/demos/asciicinema.json b/docs/demos/asciicinema.json
new file mode 100644
index 0000000..b499765
--- /dev/null
+++ b/docs/demos/asciicinema.json
@@ -0,0 +1,3798 @@
+{
+ "version": 1,
+ "width": 102,
+ "height": 28,
+ "duration": 161.307896,
+ "command": "/bin/bash",
+ "title": "",
+ "env": {
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/bash"
+ },
+ "stdout": [
+ [
+ 0.007348,
+ "\u001b[?1034h"
+ ],
+ [
+ 0.000015,
+ "bash-3.2$ "
+ ],
+ [
+ 0.504301,
+ "#"
+ ],
+ [
+ 0.139436,
+ " "
+ ],
+ [
+ 0.324556,
+ "I"
+ ],
+ [
+ 0.088019,
+ "n"
+ ],
+ [
+ 0.104007,
+ "s"
+ ],
+ [
+ 0.079986,
+ "t"
+ ],
+ [
+ 0.056291,
+ "a"
+ ],
+ [
+ 0.063684,
+ "l"
+ ],
+ [
+ 0.136015,
+ "l"
+ ],
+ [
+ 0.047705,
+ " "
+ ],
+ [
+ 0.144308,
+ "g"
+ ],
+ [
+ 0.087760,
+ "i"
+ ],
+ [
+ 0.088234,
+ "t"
+ ],
+ [
+ 0.119918,
+ "l"
+ ],
+ [
+ 0.031966,
+ "i"
+ ],
+ [
+ 0.056016,
+ "n"
+ ],
+ [
+ 0.104074,
+ "t"
+ ],
+ [
+ 0.151839,
+ "\r\n"
+ ],
+ [
+ 0.000117,
+ "bash-3.2$ "
+ ],
+ [
+ 0.247690,
+ "p"
+ ],
+ [
+ 0.064297,
+ "i"
+ ],
+ [
+ 0.119980,
+ "p"
+ ],
+ [
+ 0.112350,
+ " "
+ ],
+ [
+ 0.119395,
+ "i"
+ ],
+ [
+ 0.055802,
+ "n"
+ ],
+ [
+ 0.064480,
+ "s"
+ ],
+ [
+ 0.048012,
+ "t"
+ ],
+ [
+ 0.039930,
+ "a"
+ ],
+ [
+ 0.071932,
+ "l"
+ ],
+ [
+ 0.152065,
+ "l"
+ ],
+ [
+ 0.432253,
+ " "
+ ],
+ [
+ 0.143697,
+ "g"
+ ],
+ [
+ 0.056276,
+ "i"
+ ],
+ [
+ 0.127369,
+ "t"
+ ],
+ [
+ 0.104317,
+ "l"
+ ],
+ [
+ 0.039881,
+ "i"
+ ],
+ [
+ 0.072170,
+ "n"
+ ],
+ [
+ 0.119946,
+ "t"
+ ],
+ [
+ 0.168100,
+ "\r\n"
+ ],
+ [
+ 0.179873,
+ "Collecting gitlint\r\n"
+ ],
+ [
+ 0.031411,
+ " Using cached gitlint-0.6.1-py2.py3-none-any.whl\r\n"
+ ],
+ [
+ 0.011427,
+ "Requirement already satisfied (use --upgrade to upgrade): sh==1.11 in ./repos/demo-env/lib/python2.7/site-packages (from gitlint)\r\n"
+ ],
+ [
+ 0.000262,
+ "Requirement already satisfied (use --upgrade to upgrade): Click==5.1 in ./repos/demo-env/lib/python2.7/site-packages (from gitlint)\r\n"
+ ],
+ [
+ 0.000334,
+ "Installing collected packages: gitlint\r\n"
+ ],
+ [
+ 0.047796,
+ "Successfully installed gitlint-0.6.1\r\n"
+ ],
+ [
+ 0.022382,
+ "bash-3.2$ "
+ ],
+ [
+ 0.762766,
+ "#"
+ ],
+ [
+ 0.151744,
+ " "
+ ],
+ [
+ 0.431785,
+ "G"
+ ],
+ [
+ 0.095891,
+ "o"
+ ],
+ [
+ 0.192284,
+ " "
+ ],
+ [
+ 0.184164,
+ "t"
+ ],
+ [
+ 0.039770,
+ "o"
+ ],
+ [
+ 0.127949,
+ " "
+ ],
+ [
+ 0.232071,
+ "y"
+ ],
+ [
+ 0.071710,
+ "o"
+ ],
+ [
+ 0.023881,
+ "u"
+ ],
+ [
+ 0.184228,
+ "r"
+ ],
+ [
+ 0.144517,
+ " "
+ ],
+ [
+ 0.159631,
+ "g"
+ ],
+ [
+ 0.087950,
+ "i"
+ ],
+ [
+ 0.087976,
+ "t"
+ ],
+ [
+ 0.136095,
+ " "
+ ],
+ [
+ 0.183896,
+ "r"
+ ],
+ [
+ 0.047895,
+ "e"
+ ],
+ [
+ 0.072082,
+ "p"
+ ],
+ [
+ 0.072384,
+ "o"
+ ],
+ [
+ 0.359651,
+ "\r\n"
+ ],
+ [
+ 0.000096,
+ "bash-3.2$ "
+ ],
+ [
+ 0.463951,
+ "c"
+ ],
+ [
+ 0.463994,
+ "d"
+ ],
+ [
+ 0.079579,
+ " "
+ ],
+ [
+ 0.192355,
+ "m"
+ ],
+ [
+ 0.183732,
+ "\u0007"
+ ],
+ [
+ 0.496586,
+ "y"
+ ],
+ [
+ 0.175813,
+ "-git-repo/"
+ ],
+ [
+ 0.455841,
+ "\r\n"
+ ],
+ [
+ 0.000186,
+ "bash-3.2$ "
+ ],
+ [
+ 1.791755,
+ "#"
+ ],
+ [
+ 0.255933,
+ " "
+ ],
+ [
+ 0.296001,
+ "R"
+ ],
+ [
+ 0.159913,
+ "u"
+ ],
+ [
+ 0.064074,
+ "n"
+ ],
+ [
+ 0.175969,
+ " "
+ ],
+ [
+ 0.352173,
+ "g"
+ ],
+ [
+ 0.079863,
+ "i"
+ ],
+ [
+ 0.095948,
+ "t"
+ ],
+ [
+ 0.111985,
+ "l"
+ ],
+ [
+ 0.040922,
+ "i"
+ ],
+ [
+ 0.055153,
+ "n"
+ ],
+ [
+ 0.095944,
+ "t"
+ ],
+ [
+ 0.096118,
+ " "
+ ],
+ [
+ 0.095832,
+ "t"
+ ],
+ [
+ 0.024113,
+ "o"
+ ],
+ [
+ 0.144015,
+ " "
+ ],
+ [
+ 0.455972,
+ "c"
+ ],
+ [
+ 0.120097,
+ "h"
+ ],
+ [
+ 0.000354,
+ "e"
+ ],
+ [
+ 0.103450,
+ "c"
+ ],
+ [
+ 0.104258,
+ "k"
+ ],
+ [
+ 0.127354,
+ " "
+ ],
+ [
+ 0.536192,
+ "y"
+ ],
+ [
+ 0.088300,
+ "o"
+ ],
+ [
+ 0.055953,
+ "u"
+ ],
+ [
+ 0.111366,
+ "r"
+ ],
+ [
+ 0.136559,
+ " "
+ ],
+ [
+ 0.207924,
+ "l"
+ ],
+ [
+ 0.056410,
+ "a"
+ ],
+ [
+ 0.095988,
+ "s"
+ ],
+ [
+ 0.087665,
+ "t"
+ ],
+ [
+ 0.112209,
+ " "
+ ],
+ [
+ 0.215799,
+ "c"
+ ],
+ [
+ 0.015909,
+ "o"
+ ],
+ [
+ 0.159743,
+ "m"
+ ],
+ [
+ 0.200550,
+ "m"
+ ],
+ [
+ 0.135722,
+ "i"
+ ],
+ [
+ 0.120069,
+ "t"
+ ],
+ [
+ 0.087796,
+ " "
+ ],
+ [
+ 0.152194,
+ "m"
+ ],
+ [
+ 0.096258,
+ "e"
+ ],
+ [
+ 0.200052,
+ "s"
+ ],
+ [
+ 0.167944,
+ "s"
+ ],
+ [
+ 0.079747,
+ "a"
+ ],
+ [
+ 0.079582,
+ "g"
+ ],
+ [
+ 0.120304,
+ "e"
+ ],
+ [
+ 0.039891,
+ " "
+ ],
+ [
+ 0.208356,
+ "f"
+ ],
+ [
+ 0.071741,
+ "o"
+ ],
+ [
+ 0.080006,
+ "r"
+ ],
+ [
+ 0.119789,
+ " "
+ ],
+ [
+ 0.144509,
+ "s"
+ ],
+ [
+ 0.128456,
+ "t"
+ ],
+ [
+ 0.103452,
+ "y"
+ ],
+ [
+ 0.104515,
+ "l"
+ ],
+ [
+ 0.143681,
+ "e"
+ ],
+ [
+ 0.368030,
+ "\r\n"
+ ],
+ [
+ 0.000109,
+ "bash-3.2$ "
+ ],
+ [
+ 0.463969,
+ "g"
+ ],
+ [
+ 0.080036,
+ "i"
+ ],
+ [
+ 0.143920,
+ "t"
+ ],
+ [
+ 0.120008,
+ "l"
+ ],
+ [
+ 0.040025,
+ "i"
+ ],
+ [
+ 0.072262,
+ "n"
+ ],
+ [
+ 0.087179,
+ "t"
+ ],
+ [
+ 0.560443,
+ "\r\n"
+ ],
+ [
+ 0.123301,
+ "1: T3 Title has trailing punctuation (.): \"WIP: This is a commit message title.\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is a commit message title.\"\r\n"
+ ],
+ [
+ 0.000027,
+ "2: B4 Second line is not empty: \"Second line not empty\"\r\n3: B1 Line exceeds max length (97\u003e80): \"This body line exceeds the defacto standard length of 80 characters per line in a commit message.\"\r\n"
+ ],
+ [
+ 0.005792,
+ "bash-3.2$ "
+ ],
+ [
+ 2.814656,
+ "#"
+ ],
+ [
+ 0.376209,
+ " "
+ ],
+ [
+ 0.167631,
+ "F"
+ ],
+ [
+ 0.104242,
+ "o"
+ ],
+ [
+ 0.112316,
+ "r"
+ ],
+ [
+ 0.103655,
+ " "
+ ],
+ [
+ 0.112300,
+ "r"
+ ],
+ [
+ 0.079789,
+ "e"
+ ],
+ [
+ 0.111886,
+ "f"
+ ],
+ [
+ 0.096013,
+ "e"
+ ],
+ [
+ 0.080098,
+ "r"
+ ],
+ [
+ 0.087892,
+ "e"
+ ],
+ [
+ 0.064119,
+ "n"
+ ],
+ [
+ 0.192204,
+ "c"
+ ],
+ [
+ 0.112131,
+ "e"
+ ],
+ [
+ 0.375630,
+ ","
+ ],
+ [
+ 0.056176,
+ " "
+ ],
+ [
+ 0.239821,
+ "h"
+ ],
+ [
+ 0.047836,
+ "e"
+ ],
+ [
+ 0.080354,
+ "r"
+ ],
+ [
+ 0.080001,
+ "e"
+ ],
+ [
+ 0.095738,
+ "'"
+ ],
+ [
+ 0.216132,
+ "s"
+ ],
+ [
+ 0.071779,
+ " "
+ ],
+ [
+ 0.128086,
+ "t"
+ ],
+ [
+ 0.104034,
+ "h"
+ ],
+ [
+ 0.055946,
+ "a"
+ ],
+ [
+ 0.072018,
+ "t"
+ ],
+ [
+ 0.128240,
+ " "
+ ],
+ [
+ 0.239827,
+ "l"
+ ],
+ [
+ 0.080168,
+ "a"
+ ],
+ [
+ 0.055749,
+ "s"
+ ],
+ [
+ 0.103959,
+ "t"
+ ],
+ [
+ 0.080368,
+ " "
+ ],
+ [
+ 0.168008,
+ "c"
+ ],
+ [
+ 0.047675,
+ "o"
+ ],
+ [
+ 0.199913,
+ "m"
+ ],
+ [
+ 0.168041,
+ "m"
+ ],
+ [
+ 0.184377,
+ "i"
+ ],
+ [
+ 0.111843,
+ "t"
+ ],
+ [
+ 0.104075,
+ " "
+ ],
+ [
+ 0.119731,
+ "m"
+ ],
+ [
+ 0.079482,
+ "e"
+ ],
+ [
+ 0.216511,
+ "s"
+ ],
+ [
+ 0.167541,
+ "s"
+ ],
+ [
+ 0.176420,
+ "a"
+ ],
+ [
+ 0.487983,
+ "g"
+ ],
+ [
+ 0.063926,
+ "e"
+ ],
+ [
+ 0.240043,
+ "\r\n"
+ ],
+ [
+ 0.000103,
+ "bash-3.2$ "
+ ],
+ [
+ 0.303813,
+ "g"
+ ],
+ [
+ 0.088101,
+ "i"
+ ],
+ [
+ 0.095792,
+ "t"
+ ],
+ [
+ 0.535908,
+ " "
+ ],
+ [
+ 0.080185,
+ "l"
+ ],
+ [
+ 0.112012,
+ "o"
+ ],
+ [
+ 0.056328,
+ "g"
+ ],
+ [
+ 0.127542,
+ " "
+ ],
+ [
+ 0.136200,
+ "-"
+ ],
+ [
+ 0.143844,
+ "1"
+ ],
+ [
+ 0.416074,
+ "\r\n"
+ ],
+ [
+ 0.012270,
+ "\u001b[?1h\u001b=\r"
+ ],
+ [
+ 0.000209,
+ "\u001b[33mcommit c8ad52bbf7386d2e6ca39e479456a8bfae086629\u001b[m\u001b[m\r\nAuthor: Joris Roovers \u003cjroovers@cisco.com\u003e\u001b[m\r\nDate: Sun Nov 22 17:31:49 2015 +0100\u001b[m\r\n\u001b[m\r\n WIP: This is a commit message title.\u001b[m\r\n Second line not empty\u001b[m\r\n This body line exceeds the defacto standard length of 80 characters per line in a commit message.\u001b[m\r\n\r\u001b[K\u001b[?1l\u001b\u003e"
+ ],
+ [
+ 0.000643,
+ "bash-3.2$ "
+ ],
+ [
+ 1.618373,
+ "#"
+ ],
+ [
+ 0.152173,
+ " "
+ ],
+ [
+ 0.296271,
+ "Y"
+ ],
+ [
+ 0.143886,
+ "o"
+ ],
+ [
+ 0.032116,
+ "u"
+ ],
+ [
+ 0.255972,
+ " "
+ ],
+ [
+ 0.056004,
+ "c"
+ ],
+ [
+ 0.080252,
+ "a"
+ ],
+ [
+ 0.095730,
+ "n"
+ ],
+ [
+ 0.136000,
+ " "
+ ],
+ [
+ 0.112007,
+ "a"
+ ],
+ [
+ 0.119993,
+ "l"
+ ],
+ [
+ 0.104070,
+ "s"
+ ],
+ [
+ 0.095935,
+ "o"
+ ],
+ [
+ 0.191605,
+ " "
+ ],
+ [
+ 0.456320,
+ "i"
+ ],
+ [
+ 0.032035,
+ "n"
+ ],
+ [
+ 0.071923,
+ "s"
+ ],
+ [
+ 0.080070,
+ "t"
+ ],
+ [
+ 0.079964,
+ "a"
+ ],
+ [
+ 0.088007,
+ "l"
+ ],
+ [
+ 0.144266,
+ "l"
+ ],
+ [
+ 0.071532,
+ " "
+ ],
+ [
+ 0.424083,
+ "g"
+ ],
+ [
+ 0.064402,
+ "i"
+ ],
+ [
+ 0.119971,
+ "t"
+ ],
+ [
+ 0.087788,
+ "l"
+ ],
+ [
+ 0.047978,
+ "i"
+ ],
+ [
+ 0.055909,
+ "n"
+ ],
+ [
+ 0.104026,
+ "t"
+ ],
+ [
+ 0.079939,
+ " "
+ ],
+ [
+ 0.152052,
+ "a"
+ ],
+ [
+ 0.079983,
+ "s"
+ ],
+ [
+ 0.127987,
+ " "
+ ],
+ [
+ 0.776097,
+ "a"
+ ],
+ [
+ 0.416226,
+ " "
+ ],
+ [
+ 0.191962,
+ "c"
+ ],
+ [
+ 0.031735,
+ "o"
+ ],
+ [
+ 0.200042,
+ "m"
+ ],
+ [
+ 0.159913,
+ "m"
+ ],
+ [
+ 0.127947,
+ "i"
+ ],
+ [
+ 0.456063,
+ "t"
+ ],
+ [
+ 0.136031,
+ "-"
+ ],
+ [
+ 0.215964,
+ "m"
+ ],
+ [
+ 0.175984,
+ "s"
+ ],
+ [
+ 0.272013,
+ "g"
+ ],
+ [
+ 0.119964,
+ " "
+ ],
+ [
+ 0.184214,
+ "h"
+ ],
+ [
+ 0.192024,
+ "o"
+ ],
+ [
+ 0.119950,
+ "o"
+ ],
+ [
+ 0.047751,
+ "k"
+ ],
+ [
+ 0.431743,
+ "\r\n"
+ ],
+ [
+ 0.000085,
+ "bash-3.2$ "
+ ],
+ [
+ 0.760296,
+ "g"
+ ],
+ [
+ 0.079911,
+ "i"
+ ],
+ [
+ 0.191987,
+ "t"
+ ],
+ [
+ 0.304096,
+ "l"
+ ],
+ [
+ 0.287428,
+ "i"
+ ],
+ [
+ 0.064628,
+ "n"
+ ],
+ [
+ 0.127768,
+ "t"
+ ],
+ [
+ 0.072263,
+ " "
+ ],
+ [
+ 0.351385,
+ "i"
+ ],
+ [
+ 0.031803,
+ "n"
+ ],
+ [
+ 0.088639,
+ "s"
+ ],
+ [
+ 0.080034,
+ "t"
+ ],
+ [
+ 0.064156,
+ "a"
+ ],
+ [
+ 0.071949,
+ "l"
+ ],
+ [
+ 0.135713,
+ "l"
+ ],
+ [
+ 0.192018,
+ "-"
+ ],
+ [
+ 0.191953,
+ "h"
+ ],
+ [
+ 0.207996,
+ "o"
+ ],
+ [
+ 0.127991,
+ "o"
+ ],
+ [
+ 0.088152,
+ "k"
+ ],
+ [
+ 0.431858,
+ "\r\n"
+ ],
+ [
+ 0.072226,
+ "Successfully installed gitlint commit-msg hook in /Users/jroovers/my-git-repo/.git/hooks/commit-msg\r\n"
+ ],
+ [
+ 0.003614,
+ "bash-3.2$ "
+ ],
+ [
+ 1.036119,
+ "#"
+ ],
+ [
+ 0.160217,
+ " "
+ ],
+ [
+ 0.295771,
+ "L"
+ ],
+ [
+ 0.151619,
+ "e"
+ ],
+ [
+ 0.096263,
+ "t"
+ ],
+ [
+ 0.895797,
+ "'"
+ ],
+ [
+ 0.184596,
+ "s"
+ ],
+ [
+ 0.143677,
+ " "
+ ],
+ [
+ 0.103909,
+ "t"
+ ],
+ [
+ 0.175892,
+ "r"
+ ],
+ [
+ 0.072131,
+ "y"
+ ],
+ [
+ 0.144032,
+ " "
+ ],
+ [
+ 0.160272,
+ "i"
+ ],
+ [
+ 0.119444,
+ "t"
+ ],
+ [
+ 0.088258,
+ " "
+ ],
+ [
+ 0.207962,
+ "o"
+ ],
+ [
+ 0.056392,
+ "u"
+ ],
+ [
+ 0.103632,
+ "t"
+ ],
+ [
+ 0.552056,
+ "\r\n"
+ ],
+ [
+ 0.000096,
+ "bash-3.2$ "
+ ],
+ [
+ 0.0591595,
+ "e"
+ ],
+ [
+ 0.104138,
+ "c"
+ ],
+ [
+ 0.104065,
+ "h"
+ ],
+ [
+ 0.064048,
+ "o"
+ ],
+ [
+ 0.135782,
+ " "
+ ],
+ [
+ 0.192483,
+ "\""
+ ],
+ [
+ 0.175634,
+ "t"
+ ],
+ [
+ 0.072179,
+ "e"
+ ],
+ [
+ 0.151799,
+ "s"
+ ],
+ [
+ 0.080120,
+ "t"
+ ],
+ [
+ 0.175911,
+ "\""
+ ],
+ [
+ 0.135948,
+ " "
+ ],
+ [
+ 0.208327,
+ "\u003e"
+ ],
+ [
+ 0.079867,
+ " "
+ ],
+ [
+ 0.240416,
+ "f"
+ ],
+ [
+ 0.096300,
+ "o"
+ ],
+ [
+ 0.119709,
+ "o"
+ ],
+ [
+ 0.184111,
+ "."
+ ],
+ [
+ 0.199981,
+ "t"
+ ],
+ [
+ 0.223634,
+ "x"
+ ],
+ [
+ 0.232100,
+ "t"
+ ],
+ [
+ 0.839909,
+ "\r\n"
+ ],
+ [
+ 0.000434,
+ "bash-3.2$ "
+ ],
+ [
+ 0.743621,
+ "g"
+ ],
+ [
+ 0.047948,
+ "i"
+ ],
+ [
+ 0.103991,
+ "t"
+ ],
+ [
+ 0.088317,
+ " "
+ ],
+ [
+ 0.159935,
+ "a"
+ ],
+ [
+ 0.200067,
+ "d"
+ ],
+ [
+ 0.159339,
+ "d"
+ ],
+ [
+ 0.144280,
+ " "
+ ],
+ [
+ 0.136254,
+ "."
+ ],
+ [
+ 0.399760,
+ "\r\n"
+ ],
+ [
+ 0.010930,
+ "bash-3.2$ "
+ ],
+ [
+ 0.213093,
+ "g"
+ ],
+ [
+ 0.095974,
+ "i"
+ ],
+ [
+ 0.103967,
+ "t"
+ ],
+ [
+ 0.120050,
+ " "
+ ],
+ [
+ 0.176294,
+ "c"
+ ],
+ [
+ 0.127966,
+ "o"
+ ],
+ [
+ 0.183809,
+ "m"
+ ],
+ [
+ 0.160022,
+ "m"
+ ],
+ [
+ 0.120056,
+ "i"
+ ],
+ [
+ 0.143736,
+ "t"
+ ],
+ [
+ 1.104266,
+ "\r\n"
+ ],
+ [
+ 0.090605,
+ "\u001b[?1049h\u001b[?1h\u001b="
+ ],
+ [
+ 0.003626,
+ "\u001b[1;28r\u001b[?12;25h\u001b[?12l\u001b[?25h\u001b[27m\u001b[m\u001b[H\u001b[2J\u001b[?25l\u001b[28;1H\"~/my-git-repo/.git/COMMIT_EDITMSG\""
+ ],
+ [
+ 0.000779,
+ " 7L, 206C"
+ ],
+ [
+ 0.004938,
+ "\u001b[\u003ec"
+ ],
+ [
+ 0.002767,
+ "\u001b[1;1H\u001b[93m 1 \r\n 2 \u001b[m\u001b[96m# Please enter the commit message for your changes. Lines starting\u001b[m\r\n\u001b[93m 3 \u001b[m\u001b[96m# with '#' will be ignored, and an empty message aborts the commit.\u001b[m\r\n\u001b[93m 4 \u001b[m\u001b[96m# On branch \u001b[m\u001b[38;5;224mmaster\u001b[m\r\n\u001b[93m 5 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;81mChanges to be committed:\u001b[m\r\n\u001b[93m 6 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;121mnew file\u001b[m\u001b[96m: \u001b[m\u001b[95m foo.txt\u001b[m\r\n\u001b[93m 7 \u001b[m\u001b[96m#\u001b[m\r\n\u001b[94m~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "
+ ],
+ [
+ 0.000062,
+ " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[17;1H~ \u001b[18;1H~ \u001b[19;1H~ \u001b[20;1H~ \u001b[21;1H~ \u001b[22;1H~ "
+ ],
+ [
+ 0.000865,
+ "\u001b[23;1H~ \u001b[24;1H~ \u001b[25;1H~ \u001b[26;1H~ \u001b[27;1H~ \u001b[1;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.468652,
+ "\u001b[?25l\u001b[m\u001b[28;1H\u001b[1m-- INSERT --\u001b[m\u001b[28;13H\u001b[K\u001b[1;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.292362,
+ "\u001b[?25l\u0008\u001b[93m W\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.112916,
+ "\u001b[?25l\u0008WI"
+ ],
+ [
+ 0.000464,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000027,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000025,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000084,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000029,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000004,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000273,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000004,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000920,
+ "\u001b[1;7H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.076999,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mIP\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.463368,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.352661,
+ "\u001b[?25l\u001b[m\u001b[1;8H\u001b[K"
+ ],
+ [
+ 0.000019,
+ "\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000039,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000039,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000081,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000018,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000022,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000227,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000004,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000839,
+ "\u001b[1;8H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.214580,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mP:\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.208063,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.167897,
+ "\u001b[?25l\u0008 T\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.119757,
+ "\u001b[?25l\u0008Th"
+ ],
+ [
+ 0.000048,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000014,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000022,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000070,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000016,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000022,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000225,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000031,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000752,
+ "\u001b[1;12H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.055116,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mhi\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.128002,
+ "\u001b[?25l\u0008is\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.064056,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.143867,
+ "\u001b[?25l\u0008 i\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.072155,
+ "\u001b[?25l\u0008is"
+ ],
+ [
+ 0.000057,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000040,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000019,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000100,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000040,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000005,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000279,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000004,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000905,
+ "\u001b[1;17H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.134356,
+ "\u001b[?25l\u001b[m\u001b[93m \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.072054,
+ "\u001b[?25l\u0008 a\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.520162,
+ "\u001b[?25l\u0008an"
+ ],
+ [
+ 0.000090,
+ "\u001b[m\u001b[2;17H\u001b[48;5;242m and \u001b[m\u001b[3;17H\u001b[105m an \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000021,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000023,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000074,
+ "\u001b[2;17H\u001b[105m and "
+ ],
+ [
+ 0.000043,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000355,
+ "\u001b[m\u001b[2;16H\u001b[96mter the commit me\u001b[3;16Hwill be ignored, "
+ ],
+ [
+ 0.000123,
+ "\u001b[m\u001b[28;29H\u001b[1m \u001b[m\u001b[38;5;121mmatch 1 of 2"
+ ],
+ [
+ 0.000067,
+ "\u001b[1;20H"
+ ],
+ [
+ 0.000004,
+ "\u001b[m\u001b[2;17H\u001b[48;5;242m and \u001b[m\u001b[3;17H\u001b[105m an "
+ ],
+ [
+ 0.000021,
+ "\u001b[1;20H"
+ ],
+ [
+ 0.000078,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.157947,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000033,
+ "\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000037,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000308,
+ "\u001b[m\u001b[1;20H\u001b[93m \u001b[m\u001b[2;16H\u001b[96mter the commit me\u001b[3;16Hwill be ignored, "
+ ],
+ [
+ 0.000548,
+ "\u001b[1;21H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.735999,
+ "\u001b[?25l\u001b[m\u0008\u001b[93m p\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.104200,
+ "\u001b[?25l\u0008pa"
+ ],
+ [
+ 0.000005,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000041,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000033,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000071,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000038,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000020,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000245,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000033,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000896,
+ "\u001b[1;23H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.086431,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mat\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.176505,
+ "\u001b[?25l\u0008tc\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.103498,
+ "\u001b[?25l\u0008ch\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.080001,
+ "\u001b[?25l\u0008hs\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.176470,
+ "\u001b[?25l\u0008se\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.063481,
+ "\u001b[?25l\u0008et\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.079520,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.128786,
+ "\u001b[?25l\u0008 t\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.199925,
+ "\u001b[?25l\u0008th"
+ ],
+ [
+ 0.000115,
+ "\u001b[m\u001b[2;29H\u001b[48;5;242m the \u001b[m\u001b[3;29H\u001b[105m This \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000013,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000024,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000064,
+ "\u001b[2;29H\u001b[105m the "
+ ],
+ [
+ 0.000048,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000335,
+ "\u001b[m\u001b[2;28H\u001b[96mit message for yo\u001b[3;28Hred, and an empty"
+ ],
+ [
+ 0.000100,
+ "\u001b[m\u001b[28;29H\u001b[1m \u001b[m\u001b[38;5;121mmatch 1 of 2"
+ ],
+ [
+ 0.000048,
+ "\u001b[1;32H"
+ ],
+ [
+ 0.000004,
+ "\u001b[m\u001b[2;29H\u001b[48;5;242m the \u001b[m\u001b[3;29H\u001b[105m This "
+ ],
+ [
+ 0.000026,
+ "\u001b[1;32H"
+ ],
+ [
+ 0.000068,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.014726,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mha\u001b[m\u001b[2;28H\u001b[96mit message for yo\u001b[3;28Hred, and an empty"
+ ],
+ [
+ 0.000780,
+ "\u001b[1;33H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.679312,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mat"
+ ],
+ [
+ 0.000870,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000049,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000015,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000129,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000035,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000020,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000373,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000006,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.001035,
+ "\u001b[1;34H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.085617,
+ "\u001b[?25l\u001b[m\u001b[93m \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.327942,
+ "\u001b[?25l\u0008 I\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.167989,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.136505,
+ "\u001b[?25l\u0008 n\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.111668,
+ "\u001b[?25l\u0008ne"
+ ],
+ [
+ 0.000119,
+ "\u001b[m\u001b[2;36H\u001b[48;5;242m new \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000024,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000051,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000054,
+ "\u001b[2;36H\u001b[105m new "
+ ],
+ [
+ 0.000066,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000361,
+ "\u001b[m\u001b[2;35H\u001b[96mage for your chan"
+ ],
+ [
+ 0.000117,
+ "\u001b[m\u001b[28;29H\u001b[1m The only match"
+ ],
+ [
+ 0.000049,
+ "\u001b[1;39H"
+ ],
+ [
+ 0.000030,
+ "\u001b[m\u001b[2;36H\u001b[48;5;242m new "
+ ],
+ [
+ 0.000008,
+ "\u001b[1;39H"
+ ],
+ [
+ 0.000084,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.142635,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mee\u001b[m\u001b[2;35H\u001b[96mage for your chan"
+ ],
+ [
+ 0.000743,
+ "\u001b[1;40H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.079680,
+ "\u001b[?25l\u001b[m\u0008\u001b[93med\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000006,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000047,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000099,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000010,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000027,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000251,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000026,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000876,
+ "\u001b[1;41H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.086590,
+ "\u001b[?25l\u001b[m\u001b[93m \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.159864,
+ "\u001b[?25l\u0008 t\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.079981,
+ "\u001b[?25l\u0008to"
+ ],
+ [
+ 0.000080,
+ "\u001b[m\u001b[2;41H\u001b[48;5;242m to \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000021,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000020,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000076,
+ "\u001b[2;41H\u001b[105m to "
+ ],
+ [
+ 0.000039,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000345,
+ "\u001b[m\u001b[2;40H\u001b[96mor your changes. "
+ ],
+ [
+ 0.000085,
+ "\u001b[m\u001b[28;29H\u001b[1m The only match"
+ ],
+ [
+ 0.000041,
+ "\u001b[1;44H"
+ ],
+ [
+ 0.000029,
+ "\u001b[m\u001b[2;41H\u001b[48;5;242m to "
+ ],
+ [
+ 0.000009,
+ "\u001b[1;44H"
+ ],
+ [
+ 0.000071,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.190677,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000033,
+ "\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000040,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000500,
+ "\u001b[m\u001b[1;44H\u001b[93m \u001b[m\u001b[2;40H\u001b[96mor your changes. "
+ ],
+ [
+ 0.000827,
+ "\u001b[1;45H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.095121,
+ "\u001b[?25l\u001b[m\u0008\u001b[93m c\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.096188,
+ "\u001b[?25l\u0008co"
+ ],
+ [
+ 0.000114,
+ "\u001b[m\u001b[2;44H\u001b[48;5;242m commit \u001b[m\u001b[3;44H\u001b[105m committed \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000027,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000025,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000098,
+ "\u001b[2;44H\u001b[105m commit "
+ ],
+ [
+ 0.000033,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000401,
+ "\u001b[m\u001b[2;43H\u001b[96myour changes. Lin\u001b[3;43Hty message aborts"
+ ],
+ [
+ 0.000108,
+ "\u001b[m\u001b[28;29H\u001b[1m \u001b[m\u001b[38;5;121mmatch 1 of 2"
+ ],
+ [
+ 0.000043,
+ "\u001b[1;47H"
+ ],
+ [
+ 0.000031,
+ "\u001b[m\u001b[2;44H\u001b[48;5;242m commit \u001b[m\u001b[3;44H\u001b[105m committed "
+ ],
+ [
+ 0.000011,
+ "\u001b[1;47H"
+ ],
+ [
+ 0.000074,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.070604,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mon\u001b[m\u001b[2;43H\u001b[96myour changes. Lin\u001b[3;43Hty message aborts"
+ ],
+ [
+ 0.000833,
+ "\u001b[1;48H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.110931,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mnt"
+ ],
+ [
+ 0.000665,
+ "\u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000021,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000027,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000096,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000013,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000026,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000273,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000034,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000994,
+ "\u001b[1;49H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.158110,
+ "\u001b[?25l\u001b[m\u0008\u001b[93mti\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.120153,
+ "\u001b[?25l\u0008in\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.456043,
+ "\u001b[?25l\u0008nu\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.136040,
+ "\u001b[?25l\u0008ue\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.087847,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.200047,
+ "\u001b[?25l\u0008 w\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.072235,
+ "\u001b[?25l\u0008w\u001b[mo"
+ ],
+ [
+ 0.000052,
+ "\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000004,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000027,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000071,
+ "\u001b[28;1H="
+ ],
+ [
+ 0.000014,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000021,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000258,
+ "\u001b[28;1H\u001b[1m-- Keyword completion (^N^P) \u001b[m\u001b[97m\u001b[41mPattern not found\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000004,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000928,
+ "\u001b[1;56H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.142550,
+ "\u001b[?25l\u001b[m\u0008or\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.072562,
+ "\u001b[?25l\u0008rk\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.143446,
+ "\u001b[?25l\u0008ki\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.064195,
+ "\u001b[?25l\u0008in\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.064352,
+ "\u001b[?25l\u0008ng\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.135448,
+ "\u001b[?25l \u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.431640,
+ "\u001b[?25l\u0008 o\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.080069,
+ "\u001b[?25l\u0008on"
+ ],
+ [
+ 0.000051,
+ "\u001b[2;61H\u001b[48;5;242m On \u001b[m\u001b[28;1H\u001b[K\u001b[28;1H="
+ ],
+ [
+ 0.000018,
+ "acp#onPopupPost()\r"
+ ],
+ [
+ 0.000040,
+ "\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000054,
+ "\u001b[2;61H\u001b[105m On "
+ ],
+ [
+ 0.000010,
+ "\u001b[m\u001b[28;1H\u001b[1m-- Keyword completion (^N^P)"
+ ],
+ [
+ 0.000288,
+ "\u001b[m\u001b[2;60H\u001b[96mes starting\u001b[m\u001b[2;71H\u001b[K"
+ ],
+ [
+ 0.000076,
+ "\u001b[28;29H\u001b[1m The only match"
+ ],
+ [
+ 0.000039,
+ "\u001b[1;64H"
+ ],
+ [
+ 0.000016,
+ "\u001b[m\u001b[2;61H\u001b[48;5;242m On "
+ ],
+ [
+ 0.000028,
+ "\u001b[1;64H"
+ ],
+ [
+ 0.000055,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 1.438871,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000006,
+ "\u001b[m\u001b[28;1H\u001b[K"
+ ],
+ [
+ 0.000050,
+ "\u001b[28;1H\u001b[1m-- INSERT --"
+ ],
+ [
+ 0.000498,
+ "\u001b[m\u001b[1;63Hn!\u001b[2;60H\u001b[96mes starting\u001b[m\u001b[2;71H\u001b[K"
+ ],
+ [
+ 0.000856,
+ "\u001b[1;65H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.435983,
+ "\u001b[28;1H\u001b[K\u001b[1;64H"
+ ],
+ [
+ 0.314354,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000414,
+ "\u001b[?12l\u001b[?25h\u001b[?25l\u001b[28;1H:"
+ ],
+ [
+ 0.000020,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.191831,
+ "w"
+ ],
+ [
+ 0.039852,
+ "q"
+ ],
+ [
+ 0.560137,
+ "\r"
+ ],
+ [
+ 0.000384,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000061,
+ "\".git/COMMIT_EDITMSG\""
+ ],
+ [
+ 0.001403,
+ " 7L, 266C written"
+ ],
+ [
+ 0.001698,
+ "\r\r\r\n\u001b[?1l\u001b\u003e\u001b[?12l\u001b[?25h\u001b[?1049l"
+ ],
+ [
+ 0.003162,
+ "gitlint: checking commit message...\r\n"
+ ],
+ [
+ 0.052844,
+ "1: T3 Title has trailing punctuation (!): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n"
+ ],
+ [
+ 0.006075,
+ "-----------------------------------------------\r\n"
+ ],
+ [
+ 0.000020,
+ "gitlint: \u001b[31mYour commit message contains the above violations.\u001b[0m\r\n"
+ ],
+ [
+ 0.002541,
+ "\u001b[?1034h"
+ ],
+ [
+ 0.000014,
+ "Continue with commit anyways (this keeps the current commit message)? [y/n] "
+ ],
+ [
+ 6.778903,
+ "y"
+ ],
+ [
+ 0.376370,
+ "\r\n"
+ ],
+ [
+ 0.004763,
+ "[master 4b1f92d] WIP: This is an patchset that I need to continue working on!\r\n"
+ ],
+ [
+ 0.001504,
+ " 1 file changed, 1 insertion(+)\r\n create mode 100644 foo.txt\r\n"
+ ],
+ [
+ 0.000420,
+ "bash-3.2$ "
+ ],
+ [
+ 0.913122,
+ "#"
+ ],
+ [
+ 0.143873,
+ " "
+ ],
+ [
+ 1.040537,
+ "Y"
+ ],
+ [
+ 0.159468,
+ "o"
+ ],
+ [
+ 0.032403,
+ "u"
+ ],
+ [
+ 0.255831,
+ " "
+ ],
+ [
+ 0.128028,
+ "c"
+ ],
+ [
+ 0.056381,
+ "a"
+ ],
+ [
+ 0.047650,
+ "n"
+ ],
+ [
+ 0.144010,
+ " "
+ ],
+ [
+ 0.143892,
+ "m"
+ ],
+ [
+ 0.096047,
+ "o"
+ ],
+ [
+ 0.127988,
+ "d"
+ ],
+ [
+ 0.144268,
+ "i"
+ ],
+ [
+ 0.183322,
+ "f"
+ ],
+ [
+ 0.136376,
+ "y"
+ ],
+ [
+ 0.192288,
+ " "
+ ],
+ [
+ 0.127701,
+ "g"
+ ],
+ [
+ 0.088308,
+ "i"
+ ],
+ [
+ 0.495279,
+ "t"
+ ],
+ [
+ 0.192340,
+ "l"
+ ],
+ [
+ 0.055845,
+ "i"
+ ],
+ [
+ 0.072236,
+ "n"
+ ],
+ [
+ 0.111890,
+ "t"
+ ],
+ [
+ 0.320013,
+ "'"
+ ],
+ [
+ 0.167698,
+ "s"
+ ],
+ [
+ 0.088331,
+ " "
+ ],
+ [
+ 0.208264,
+ "b"
+ ],
+ [
+ 0.087663,
+ "e"
+ ],
+ [
+ 0.352021,
+ "h"
+ ],
+ [
+ 0.191971,
+ "a"
+ ],
+ [
+ 0.176006,
+ "v"
+ ],
+ [
+ 0.104268,
+ "i"
+ ],
+ [
+ 0.023762,
+ "o"
+ ],
+ [
+ 0.128201,
+ "r"
+ ],
+ [
+ 0.119900,
+ " "
+ ],
+ [
+ 0.183877,
+ "b"
+ ],
+ [
+ 0.143917,
+ "y"
+ ],
+ [
+ 0.240199,
+ " "
+ ],
+ [
+ 0.647870,
+ "c"
+ ],
+ [
+ 0.041003,
+ "o"
+ ],
+ [
+ 0.063052,
+ "n"
+ ],
+ [
+ 0.144261,
+ "f"
+ ],
+ [
+ 0.103317,
+ "i"
+ ],
+ [
+ 0.128402,
+ "g"
+ ],
+ [
+ 0.080038,
+ "u"
+ ],
+ [
+ 0.128003,
+ "r"
+ ],
+ [
+ 0.480050,
+ "i"
+ ],
+ [
+ 0.047741,
+ "n"
+ ],
+ [
+ 0.103828,
+ "g"
+ ],
+ [
+ 0.126593,
+ " "
+ ],
+ [
+ 0.113591,
+ "a"
+ ],
+ [
+ 0.104071,
+ " "
+ ],
+ [
+ 0.343976,
+ "."
+ ],
+ [
+ 0.215812,
+ "g"
+ ],
+ [
+ 0.088229,
+ "i"
+ ],
+ [
+ 0.167944,
+ "t"
+ ],
+ [
+ 0.104389,
+ "l"
+ ],
+ [
+ 0.055649,
+ "i"
+ ],
+ [
+ 0.064009,
+ "n"
+ ],
+ [
+ 0.128039,
+ "t"
+ ],
+ [
+ 0.111929,
+ " "
+ ],
+ [
+ 0.151932,
+ "f"
+ ],
+ [
+ 0.072042,
+ "i"
+ ],
+ [
+ 0.072020,
+ "l"
+ ],
+ [
+ 0.079850,
+ "e"
+ ],
+ [
+ 0.656150,
+ "\r\n"
+ ],
+ [
+ 0.000100,
+ "bash-3.2$ "
+ ],
+ [
+ 0.735877,
+ "g"
+ ],
+ [
+ 0.103942,
+ "i"
+ ],
+ [
+ 0.184038,
+ "t"
+ ],
+ [
+ 0.111946,
+ "l"
+ ],
+ [
+ 0.064269,
+ "i"
+ ],
+ [
+ 0.063764,
+ "n"
+ ],
+ [
+ 0.472229,
+ "t"
+ ],
+ [
+ 0.183704,
+ " "
+ ],
+ [
+ 0.416073,
+ "g"
+ ],
+ [
+ 0.096000,
+ "e"
+ ],
+ [
+ 0.143925,
+ "n"
+ ],
+ [
+ 0.064290,
+ "e"
+ ],
+ [
+ 0.079792,
+ "r"
+ ],
+ [
+ 0.095868,
+ "a"
+ ],
+ [
+ 0.104267,
+ "t"
+ ],
+ [
+ 0.207732,
+ "e"
+ ],
+ [
+ 0.184086,
+ "-"
+ ],
+ [
+ 0.171619,
+ "c"
+ ],
+ [
+ 0.084287,
+ "o"
+ ],
+ [
+ 0.064003,
+ "n"
+ ],
+ [
+ 0.111626,
+ "f"
+ ],
+ [
+ 0.168397,
+ "i"
+ ],
+ [
+ 0.135945,
+ "g"
+ ],
+ [
+ 0.344287,
+ "\r\n"
+ ],
+ [
+ 0.054614,
+ "Please specify a location for the sample gitlint config file [.gitlint]: "
+ ],
+ [
+ 1.281099,
+ "\r\n"
+ ],
+ [
+ 0.001231,
+ "Successfully generated /Users/jroovers/my-git-repo/.gitlint\r\n"
+ ],
+ [
+ 0.005057,
+ "bash-3.2$ "
+ ],
+ [
+ 1.481485,
+ "v"
+ ],
+ [
+ 0.056099,
+ "i"
+ ],
+ [
+ 0.063695,
+ "m"
+ ],
+ [
+ 0.159794,
+ " "
+ ],
+ [
+ 0.138400,
+ "."
+ ],
+ [
+ 0.198256,
+ "g"
+ ],
+ [
+ 0.119954,
+ "i"
+ ],
+ [
+ 0.119891,
+ "t"
+ ],
+ [
+ 0.120085,
+ "l"
+ ],
+ [
+ 0.055836,
+ "i"
+ ],
+ [
+ 0.080111,
+ "n"
+ ],
+ [
+ 0.135971,
+ "t"
+ ],
+ [
+ 0.928127,
+ "\r\n"
+ ],
+ [
+ 0.039380,
+ "\u001b[?1049h\u001b[?1h\u001b="
+ ],
+ [
+ 0.001629,
+ "\u001b[1;28r\u001b[?12;25h\u001b[?12l\u001b[?25h\u001b[27m\u001b[m\u001b[H\u001b[2J\u001b[?25l\u001b[28;1H\".gitlint\""
+ ],
+ [
+ 0.000064,
+ " 41L, 1416C"
+ ],
+ [
+ 0.003242,
+ "\u001b[\u003ec"
+ ],
+ [
+ 0.007250,
+ "\u001b[1;1H\u001b[93m 1 \u001b[m\u001b[96m# All these sections are optional, edit this file as you like.\u001b[m\r\n\u001b[93m 2 \u001b[m\u001b[96m# [general]\u001b[m\r\n\u001b[93m 3 \u001b[m\u001b[96m# ignore=title-trailing-punctuation, T3\u001b[m\r\n\u001b[93m 4 \u001b[m\u001b[96m# verbosity should be a value between 1 and 3, the commandline -v flags take pre\u001b[m\u001b[97m\u001b[101mcedence over\u001b[m\r\n\u001b[93m 5 \u001b[m\u001b[96m# this\u001b[m\r\n\u001b[93m 6 \u001b[m\u001b[96m# verbosity = 2\u001b[m\r\n\u001b[93m 7 \r\n 8 \u001b[m\u001b[96m# [title-max-length]\u001b[m\r\n\u001b[93m 9 \u001b[m\u001b[96m# line-length=80\u001b[m\r\n\u001b[93m 10 \r\n 11 \u001b[m\u001b[96m# [title-must-not-contain-word]\u001b[m\r\n\u001b[93m 12 \u001b[m\u001b[96m# Comma-separated list of words that should not occur in the title. Matching is \u001b[m\u001b[97m\u001b[101mcase\u001b[m\r\n\u001b[93m 13 \u001b[m\u001b[96m# insensitive. It's fine if the keyword occurs as part of a larger word (so \"WIP\u001b[m\u001b[97m\u001b[101mING\"\u001b[m\r\n\u001b[93m 14 \u001b[m\u001b[96m# will not cause a violation, but \"WIP: my title\" will.\u001b[m\r\n\u001b[93m 15 \u001b[m\u001b[96m# words=wip\u001b[m\r\n\u001b[93m 16 \r\n 17 \u001b[m\u001b[96m# [title-match-regex]\u001b[m\r\n\u001b[93m 18 \u001b[m\u001b[96m# python like regex (https://docs.python.org/2/library/re.html) that the\u001b[m\r\n\u001b[93m 19 "
+ ],
+ [
+ 0.000011,
+ "\u001b[m\u001b[96m# commit-msg title must be matched to.\u001b[m\r\n\u001b[93m 20 \u001b[m\u001b[96m# Note that the regex can contradict with other rules if not used correctly\u001b[m\r\n\u001b[93m 21 \u001b[m\u001b[96m# (e.g. title-must-not-contain-word).\u001b[m\r\n\u001b[93m 22 \u001b[m\u001b[96m# regex=^US[0-9]*\u001b[m\r\n\u001b[93m 23 \r\n 24 \u001b[m\u001b[96m# [B1]\u001b[m\r\n\u001b[93m 25 \u001b[m\u001b[96m# B1 = body-max-line-length\u001b[m\r\n\u001b[93m 26 \u001b[m\u001b[96m# line-length=120\u001b[m\r\n\u001b[93m 27 \u001b[1;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 1.532701,
+ "\r\n 2 "
+ ],
+ [
+ 0.104014,
+ "\r\n 3 "
+ ],
+ [
+ 0.191728,
+ "\r\n 4 "
+ ],
+ [
+ 0.135939,
+ "\r\n 5 "
+ ],
+ [
+ 0.144151,
+ "\r\n 6 "
+ ],
+ [
+ 0.151956,
+ "\r\n 7 "
+ ],
+ [
+ 0.143897,
+ "\r\n 8 "
+ ],
+ [
+ 0.424058,
+ "\u001b[?25l\u0008 \u001b[m [title-max-length]\u001b[8;24H\u001b[K\u001b[8;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.160610,
+ "\u001b[?25l\u0008\u001b[93m \u001b[m\u001b[46m[\u001b[mtitle-max-length\u001b[46m]\u001b[m\u001b[8;23H\u001b[K\u001b[8;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.192102,
+ "\u001b[?25l[\u001b[16C]\u001b[9;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.231348,
+ "\u001b[?25l\u0008\u001b[93m \u001b[m line-length=80\u001b[9;20H\u001b[K\u001b[9;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.144198,
+ "\u001b[?25l\u0008\u001b[93m \u001b[mline-length=80\u001b[9;19H\u001b[K\u001b[9;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.552150,
+ "\u001b[?25l\u001b[28;1H\u001b[1m-- INSERT --\u001b[m\u001b[28;13H\u001b[K\u001b[9;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.303680,
+ "l"
+ ],
+ [
+ 0.201138,
+ "i"
+ ],
+ [
+ 0.017440,
+ "n"
+ ],
+ [
+ 0.017353,
+ "e"
+ ],
+ [
+ 0.016784,
+ "-"
+ ],
+ [
+ 0.017813,
+ "l"
+ ],
+ [
+ 0.017864,
+ "e"
+ ],
+ [
+ 0.017036,
+ "n"
+ ],
+ [
+ 0.017743,
+ "g"
+ ],
+ [
+ 0.017214,
+ "t"
+ ],
+ [
+ 0.017098,
+ "h"
+ ],
+ [
+ 0.018114,
+ "="
+ ],
+ [
+ 0.017698,
+ "8"
+ ],
+ [
+ 0.015624,
+ "0"
+ ],
+ [
+ 0.391058,
+ "\u0008"
+ ],
+ [
+ 0.320130,
+ "\u001b[?25l\u0008\u0008=0\u001b[9;18H\u001b[K\u001b[9;17H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.271868,
+ "\u001b[?25l\u0008=50\u0008\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.581296,
+ "\u001b[28;1H\u001b[K\u001b[9;17H"
+ ],
+ [
+ 0.242328,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000209,
+ "\u001b[?12l\u001b[?25h\u001b[?25l\u001b[28;1H:"
+ ],
+ [
+ 0.000006,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.199592,
+ "w"
+ ],
+ [
+ 0.048010,
+ "q"
+ ],
+ [
+ 1.080364,
+ "\r"
+ ],
+ [
+ 0.000041,
+ "\u001b[?25l"
+ ],
+ [
+ 0.000082,
+ "\".gitlint\""
+ ],
+ [
+ 0.001253,
+ " 41L, 1412C written"
+ ],
+ [
+ 0.001495,
+ "\r\r\r\n\u001b[?1l\u001b\u003e\u001b[?12l\u001b[?25h\u001b[?1049l"
+ ],
+ [
+ 0.000815,
+ "bash-3.2$ "
+ ],
+ [
+ 1.491944,
+ "g"
+ ],
+ [
+ 0.071961,
+ "i"
+ ],
+ [
+ 0.160254,
+ "t"
+ ],
+ [
+ 0.128030,
+ "l"
+ ],
+ [
+ 0.071789,
+ "i"
+ ],
+ [
+ 0.055927,
+ "n"
+ ],
+ [
+ 0.127907,
+ "t"
+ ],
+ [
+ 0.728389,
+ "\r\n"
+ ],
+ [
+ 0.053628,
+ "Using config from /Users/jroovers/my-git-repo/.gitlint\r\n"
+ ],
+ [
+ 0.050694,
+ "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is an patchset that I need to continue working on!\"\r\n"
+ ],
+ [
+ 0.000006,
+ "1: T3 Title has trailing punctuation (!): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n"
+ ],
+ [
+ 0.005418,
+ "bash-3.2$ "
+ ],
+ [
+ 2.577825,
+ "#"
+ ],
+ [
+ 0.264000,
+ " "
+ ],
+ [
+ 1.095576,
+ "O"
+ ],
+ [
+ 0.205521,
+ "r"
+ ],
+ [
+ 0.083054,
+ " "
+ ],
+ [
+ 0.127713,
+ "s"
+ ],
+ [
+ 0.096301,
+ "p"
+ ],
+ [
+ 0.079687,
+ "e"
+ ],
+ [
+ 0.088075,
+ "c"
+ ],
+ [
+ 0.087991,
+ "i"
+ ],
+ [
+ 0.144246,
+ "f"
+ ],
+ [
+ 0.839973,
+ "y"
+ ],
+ [
+ 0.392068,
+ " "
+ ],
+ [
+ 0.487898,
+ "a"
+ ],
+ [
+ 0.208003,
+ "d"
+ ],
+ [
+ 0.135717,
+ "d"
+ ],
+ [
+ 0.079782,
+ "i"
+ ],
+ [
+ 0.151837,
+ "t"
+ ],
+ [
+ 0.096448,
+ "i"
+ ],
+ [
+ 0.079993,
+ "o"
+ ],
+ [
+ 0.000349,
+ "n"
+ ],
+ [
+ 0.135585,
+ "a"
+ ],
+ [
+ 0.095980,
+ "l"
+ ],
+ [
+ 0.151930,
+ " "
+ ],
+ [
+ 0.151950,
+ "c"
+ ],
+ [
+ 0.079947,
+ "o"
+ ],
+ [
+ 0.032040,
+ "n"
+ ],
+ [
+ 0.103990,
+ "f"
+ ],
+ [
+ 0.112311,
+ "i"
+ ],
+ [
+ 0.127670,
+ "g"
+ ],
+ [
+ 0.191958,
+ " "
+ ],
+ [
+ 0.335974,
+ "v"
+ ],
+ [
+ 0.072137,
+ "i"
+ ],
+ [
+ 0.127902,
+ "a"
+ ],
+ [
+ 0.103834,
+ " "
+ ],
+ [
+ 0.168232,
+ "t"
+ ],
+ [
+ 0.112211,
+ "h"
+ ],
+ [
+ 0.095730,
+ "e"
+ ],
+ [
+ 0.112047,
+ " "
+ ],
+ [
+ 0.087745,
+ "c"
+ ],
+ [
+ 0.063982,
+ "o"
+ ],
+ [
+ 0.368225,
+ "m"
+ ],
+ [
+ 0.168146,
+ "m"
+ ],
+ [
+ 0.063823,
+ "a"
+ ],
+ [
+ 0.112252,
+ "n"
+ ],
+ [
+ 0.087711,
+ "d"
+ ],
+ [
+ 0.112231,
+ "l"
+ ],
+ [
+ 0.079753,
+ "i"
+ ],
+ [
+ 0.056014,
+ "n"
+ ],
+ [
+ 0.111900,
+ "e"
+ ],
+ [
+ 1.128086,
+ "\r\n"
+ ],
+ [
+ 0.000116,
+ "bash-3.2$ "
+ ],
+ [
+ 1.096057,
+ "g"
+ ],
+ [
+ 0.063721,
+ "i"
+ ],
+ [
+ 0.151557,
+ "t"
+ ],
+ [
+ 0.288291,
+ "l"
+ ],
+ [
+ 0.040064,
+ "i"
+ ],
+ [
+ 0.063972,
+ "n"
+ ],
+ [
+ 0.119883,
+ "t"
+ ],
+ [
+ 0.192140,
+ " "
+ ],
+ [
+ 0.383892,
+ "-"
+ ],
+ [
+ 0.143814,
+ "-"
+ ],
+ [
+ 0.200589,
+ "i"
+ ],
+ [
+ 0.063787,
+ "g"
+ ],
+ [
+ 0.151539,
+ "n"
+ ],
+ [
+ 0.112696,
+ "o"
+ ],
+ [
+ 0.095761,
+ "r"
+ ],
+ [
+ 0.056248,
+ "e"
+ ],
+ [
+ 0.471314,
+ " "
+ ],
+ [
+ 0.496411,
+ "t"
+ ],
+ [
+ 0.032231,
+ "i"
+ ],
+ [
+ 0.775702,
+ "t"
+ ],
+ [
+ 0.071997,
+ "l"
+ ],
+ [
+ 0.119928,
+ "e"
+ ],
+ [
+ 0.152044,
+ "-"
+ ],
+ [
+ 0.192289,
+ "t"
+ ],
+ [
+ 0.168098,
+ "r"
+ ],
+ [
+ 0.095641,
+ "a"
+ ],
+ [
+ 0.079916,
+ "i"
+ ],
+ [
+ 0.088017,
+ "l"
+ ],
+ [
+ 0.208343,
+ "i"
+ ],
+ [
+ 0.087674,
+ "n"
+ ],
+ [
+ 0.192216,
+ "g"
+ ],
+ [
+ 0.463349,
+ "-"
+ ],
+ [
+ 0.224422,
+ "p"
+ ],
+ [
+ 0.303974,
+ "u"
+ ],
+ [
+ 0.071948,
+ "n"
+ ],
+ [
+ 0.472005,
+ "c"
+ ],
+ [
+ 0.368016,
+ "t"
+ ],
+ [
+ 0.303934,
+ "u"
+ ],
+ [
+ 0.112267,
+ "a"
+ ],
+ [
+ 0.087621,
+ "t"
+ ],
+ [
+ 0.080151,
+ "i"
+ ],
+ [
+ 0.048003,
+ "o"
+ ],
+ [
+ 0.031962,
+ "n"
+ ],
+ [
+ 1.887520,
+ "\r\n"
+ ],
+ [
+ 0.052100,
+ "Using config from /Users/jroovers/my-git-repo/.gitlint\r\n"
+ ],
+ [
+ 0.050989,
+ "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n"
+ ],
+ [
+ 0.000025,
+ "3: B6 Body message is missing\r\n"
+ ],
+ [
+ 0.006495,
+ "bash-3.2$ "
+ ],
+ [
+ 1.578501,
+ "#"
+ ],
+ [
+ 0.177781,
+ " "
+ ],
+ [
+ 0.222470,
+ "F"
+ ],
+ [
+ 0.088284,
+ "o"
+ ],
+ [
+ 0.127955,
+ "r"
+ ],
+ [
+ 0.056062,
+ " "
+ ],
+ [
+ 0.144004,
+ "m"
+ ],
+ [
+ 0.095681,
+ "o"
+ ],
+ [
+ 0.032018,
+ "r"
+ ],
+ [
+ 0.047994,
+ "e"
+ ],
+ [
+ 0.096045,
+ " "
+ ],
+ [
+ 0.111871,
+ "i"
+ ],
+ [
+ 0.071986,
+ "n"
+ ],
+ [
+ 0.056142,
+ "f"
+ ],
+ [
+ 0.095939,
+ "o"
+ ],
+ [
+ 0.279967,
+ ","
+ ],
+ [
+ 0.087962,
+ " "
+ ],
+ [
+ 0.175948,
+ "v"
+ ],
+ [
+ 0.072089,
+ "i"
+ ],
+ [
+ 0.144243,
+ "s"
+ ],
+ [
+ 0.031668,
+ "i"
+ ],
+ [
+ 0.232173,
+ "t"
+ ],
+ [
+ 0.143995,
+ ":"
+ ],
+ [
+ 0.200215,
+ " "
+ ],
+ [
+ 0.359698,
+ "h"
+ ],
+ [
+ 0.127942,
+ "t"
+ ],
+ [
+ 0.151997,
+ "t"
+ ],
+ [
+ 0.048065,
+ "p"
+ ],
+ [
+ 0.319959,
+ ":"
+ ],
+ [
+ 0.256283,
+ "/"
+ ],
+ [
+ 0.143558,
+ "/"
+ ],
+ [
+ 0.487848,
+ "j"
+ ],
+ [
+ 0.048256,
+ "o"
+ ],
+ [
+ 0.079996,
+ "r"
+ ],
+ [
+ 0.104020,
+ "i"
+ ],
+ [
+ 0.095905,
+ "s"
+ ],
+ [
+ 0.240093,
+ "r"
+ ],
+ [
+ 0.136044,
+ "o"
+ ],
+ [
+ 0.127483,
+ "o"
+ ],
+ [
+ 0.072697,
+ "v"
+ ],
+ [
+ 0.103625,
+ "e"
+ ],
+ [
+ 0.088072,
+ "r"
+ ],
+ [
+ 0.112033,
+ "s"
+ ],
+ [
+ 0.143951,
+ "."
+ ],
+ [
+ 0.648188,
+ "g"
+ ],
+ [
+ 0.279829,
+ "i"
+ ],
+ [
+ 0.463949,
+ "t"
+ ],
+ [
+ 0.079922,
+ "h"
+ ],
+ [
+ 0.120064,
+ "u"
+ ],
+ [
+ 0.080043,
+ "b"
+ ],
+ [
+ 0.231966,
+ "."
+ ],
+ [
+ 0.239964,
+ "i"
+ ],
+ [
+ 0.056111,
+ "o"
+ ],
+ [
+ 0.303921,
+ "/"
+ ],
+ [
+ 0.367976,
+ "g"
+ ],
+ [
+ 0.055984,
+ "i"
+ ],
+ [
+ 0.135983,
+ "t"
+ ],
+ [
+ 0.104035,
+ "l"
+ ],
+ [
+ 0.056048,
+ "i"
+ ],
+ [
+ 0.072242,
+ "n"
+ ],
+ [
+ 0.111889,
+ "t"
+ ],
+ [
+ 0.439701,
+ "\r\n"
+ ],
+ [
+ 0.000100,
+ "bash-3.2$ "
+ ],
+ [
+ 0.919921,
+ "e"
+ ],
+ [
+ 0.176231,
+ "x"
+ ],
+ [
+ 0.119224,
+ "i"
+ ],
+ [
+ 0.104616,
+ "t"
+ ],
+ [
+ 1.008087,
+ "\r\n"
+ ],
+ [
+ 0.000129,
+ "exit\r\n"
+ ]
+ ]
+}
diff --git a/docs/demos/scenario.txt b/docs/demos/scenario.txt
new file mode 100644
index 0000000..7a4b692
--- /dev/null
+++ b/docs/demos/scenario.txt
@@ -0,0 +1,75 @@
+sudo pip uninstall gitlint
+
+virtualenv ~/gitlint-demo
+
+source ~/gitlint-demo
+
+mkdir ~/my-git-repo
+
+git init
+
+echo "test" > myfile.txt
+
+git add .
+
+git commit
+
+WIP: This is a commit message title.
+Second line not empty
+This body line exceeds the defacto standard length of 80 characters per line in a commit m
+essage.
+
+cd ..
+
+
+asciicinema rec demo.json
+
+------------------------------------
+
+pip install gitlint
+
+# Go to your git repo
+
+cd my-git-repo
+
+# Run gitlint to check for violations in the last commit message
+
+gitlint
+
+# For reference, here you can see that last commit message
+
+git log -1
+
+# You can also install gitlint as a git commit-msg hook
+
+gitlint install-hook
+
+# Let's try it out
+
+echo "This is a test" > foo.txt
+
+git add .
+
+git commit
+
+WIP: Still working on this awesome patchset that will change the world forever!
+
+[Keep commit -> yes]
+
+# You can modify gitlint's behavior by adding a .gitlint file
+
+gitlint generate-config
+
+vim .gitlint
+
+gitlint
+
+# Or specify additional config via the commandline
+
+gitlint --ignore title-trailing-punctuation
+
+# For more info, visit: http://jorisroovers.github.io/gitlint
+
+exit
+
+------------------------------ \ No newline at end of file
diff --git a/docs/extra.css b/docs/extra.css
new file mode 100644
index 0000000..5643925
--- /dev/null
+++ b/docs/extra.css
@@ -0,0 +1,4 @@
+a.toctree-l3 {
+ margin-left: 10px;
+ /* display: none; */
+}
diff --git a/docs/images/RuleViolation.png b/docs/images/RuleViolation.png
new file mode 100644
index 0000000..410dca9
--- /dev/null
+++ b/docs/images/RuleViolation.png
Binary files differ
diff --git a/docs/images/RuleViolations.graffle b/docs/images/RuleViolations.graffle
new file mode 100644
index 0000000..1fea2dd
--- /dev/null
+++ b/docs/images/RuleViolations.graffle
Binary files differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..3155b19
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,351 @@
+# Intro
+Gitlint is a git commit message linter written in python: it checks your commit messages for style.
+
+Great for use as a [commit-msg git hook](#using-gitlint-as-a-commit-msg-hook) or as part of your gating script in a
+[CI pipeline (e.g. Jenkins)](index.md#using-gitlint-in-a-ci-environment).
+
+<script type="text/javascript" src="https://asciinema.org/a/30477.js" id="asciicast-30477" async></script>
+
+!!! note
+ **Gitlint support for Windows is experimental**, and [there are some known issues](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows).
+
+ Also, gitlint is not the only git commit message linter out there, if you are looking for an alternative written in a different language,
+ have a look at [fit-commit](https://github.com/m1foley/fit-commit) (Ruby),
+ [node-commit-msg](https://github.com/clns/node-commit-msg) (Node.js) or [commitlint](http://marionebl.github.io/commitlint) (Node.js).
+
+## Features ##
+ - **Commit message hook**: [Auto-trigger validations against new commit message right when you're committing](#using-gitlint-as-a-commit-msg-hook). Also [works with pre-commit](#using-gitlint-through-pre-commit).
+ - **Easily integrated**: Gitlint is designed to work [with your own scripts or CI system](#using-gitlint-in-a-ci-environment).
+ - **Sane defaults:** Many of gitlint's validations are based on
+[well-known](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html),
+[community](http://addamhardy.com/2013/06/05/good-commit-messages-and-enforcing-them-with-git-hooks.html),
+[standards](http://chris.beams.io/posts/git-commit/), others are based on checks that we've found
+useful throughout the years.
+ - **Easily configurable:** Gitlint has sane defaults, but [you can also easily customize it to your own liking](configuration.md).
+ - **Community contributed rules**: Conventions that are common but not universal [can be selectively enabled](contrib_rules).
+ - **User-defined rules:** Want to do more then what gitlint offers out of the box? Write your own [user defined rules](user_defined_rules.md).
+ - **Broad python version support:** Gitlint supports python versions 2.7, 3.5+, PyPy2 and PyPy3.5.
+ - **Full unicode support:** Lint your Russian, Chinese or Emoji commit messages with ease!
+ - **Production-ready:** Gitlint checks a lot of the boxes you're looking for: actively maintained, high unit test coverage, integration tests,
+ python code standards (pep8, pylint), good documentation, widely used, proven track record.
+
+# Getting Started
+## Installation
+```bash
+# Pip is recommended to install the latest version
+pip install gitlint
+
+# macOS
+brew tap rockyluke/devops
+brew install gitlint
+
+# Ubuntu
+apt-get install gitlint
+
+# Docker: https://hub.docker.com/r/jorisroovers/gitlint
+docker run -v $(pwd):/repo jorisroovers/gitlint
+```
+
+## Usage
+```sh
+# Check the last commit message
+gitlint
+# Alternatively, pipe a commit message to gitlint:
+cat examples/commit-message-1 | gitlint
+# or
+git log -1 --pretty=%B | gitlint
+# Or read the commit-msg from a file, like so:
+gitlint --msg-filename examples/commit-message-2
+# Lint all commits in your repo
+gitlint --commits HEAD
+
+# To install a gitlint as a commit-msg git hook:
+gitlint install-hook
+```
+
+Output example:
+```bash
+$ cat examples/commit-message-2 | gitlint
+1: T1 Title exceeds max length (134>80): "This is the title of a commit message that is over 80 characters and contains hard tabs and trailing whitespace and the word wiping "
+1: T2 Title has trailing whitespace: "This is the title of a commit message that is over 80 characters and contains hard tabs and trailing whitespace and the word wiping "
+1: T4 Title contains hard tab characters (\t): "This is the title of a commit message that is over 80 characters and contains hard tabs and trailing whitespace and the word wiping "
+2: B4 Second line is not empty: "This line should not contain text"
+3: B1 Line exceeds max length (125>80): "Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120. "
+3: B2 Line has trailing whitespace: "Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120. "
+3: B3 Line contains hard tab characters (\t): "Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120. "
+```
+!!! note
+ The returned exit code equals the number of errors found. [Some exit codes are special](index.md#exit-codes).
+
+# Configuration
+
+For in-depth documentation of general and rule-specific configuration options, have a look at the [Configuration](configuration.md) and [Rules](rules.md) pages.
+
+Short example ```.gitlint``` file ([full reference](configuration.md)):
+
+```ini
+[general]
+# Ignore certain rules (comma-separated list), you can reference them by
+# their id or by their full name
+ignore=body-is-missing,T3
+
+# Ignore any data send to gitlint via stdin
+ignore-stdin=true
+
+# Configure title-max-length rule, set title length to 80 (72 = default)
+[title-max-length]
+line-length=80
+
+# You can also reference rules by their id (B1 = body-max-line-length)
+[B1]
+line-length=123
+```
+
+Example use of flags:
+
+```bash
+# Change gitlint's verbosity.
+$ gitlint -v
+# Ignore certain rules
+$ gitlint --ignore body-is-missing,T3
+# Enable debug mode
+$ gitlint --debug
+# Load user-defined rules (see http://jorisroovers.github.io/gitlint/user_defined_rules)
+$ gitlint --extra-path /home/joe/mygitlint_rules
+```
+
+Other commands and variations:
+
+```no-highlight
+$ gitlint --help
+Usage: gitlint [OPTIONS] COMMAND [ARGS]...
+
+ Git lint tool, checks your git commit messages for styling issues
+
+ Documentation: http://jorisroovers.github.io/gitlint
+
+Options:
+ --target DIRECTORY Path of the target git repository. [default:
+ current working directory]
+ -C, --config FILE Config file location [default: .gitlint]
+ -c TEXT Config flags in format <rule>.<option>=<value>
+ (e.g.: -c T1.line-length=80). Flag can be used
+ multiple times to set multiple config values.
+ --commits TEXT The range of commits to lint. [default: HEAD]
+ -e, --extra-path PATH Path to a directory or python module with extra
+ user-defined rules
+ --ignore TEXT Ignore rules (comma-separated by id or name).
+ --contrib TEXT Contrib rules to enable (comma-separated by id or
+ name).
+ --msg-filename FILENAME Path to a file containing a commit-msg.
+ --ignore-stdin Ignore any stdin data. Useful for running in CI
+ server.
+ --staged Read staged commit meta-info from the local
+ repository.
+ -v, --verbose Verbosity, more v's for more verbose output (e.g.:
+ -v, -vv, -vvv). [default: -vvv]
+ -s, --silent Silent mode (no output). Takes precedence over -v,
+ -vv, -vvv.
+ -d, --debug Enable debugging output.
+ --version Show the version and exit.
+ --help Show this message and exit.
+
+Commands:
+ generate-config Generates a sample gitlint config file.
+ install-hook Install gitlint as a git commit-msg hook.
+ lint Lints a git repository [default command]
+ uninstall-hook Uninstall gitlint commit-msg hook.
+
+ When no COMMAND is specified, gitlint defaults to 'gitlint lint'.
+```
+
+
+# Using gitlint as a commit-msg hook ##
+_Introduced in gitlint v0.4.0_
+
+You can also install gitlint as a git ```commit-msg``` hook so that gitlint checks your commit messages automatically
+after each commit.
+
+```bash
+gitlint install-hook
+# To remove the hook
+gitlint uninstall-hook
+```
+
+!!! important
+
+ Gitlint cannot work together with an existing hook. If you already have a ```.git/hooks/commit-msg```
+ file in your local repository, gitlint will refuse to install the ```commit-msg``` hook. Gitlint will also only
+ uninstall unmodified commit-msg hooks that were installed by gitlint.
+ If you're looking to use gitlint in conjunction with other hooks, you should consider
+ [using gitlint with pre-commit](#using-gitlint-through-pre-commit).
+
+# Using gitlint through [pre-commit](https://pre-commit.com)
+
+`gitlint` can be configured as a plugin for the `pre-commit` git hooks
+framework. Simply add the configuration to your `.pre-commit-config.yaml`:
+
+```yaml
+- repo: https://github.com/jorisroovers/gitlint
+ rev: # Fill in a tag / sha here
+ hooks:
+ - id: gitlint
+```
+
+You then need to install the pre-commit hook like so:
+```sh
+pre-commit install --hook-type commit-msg
+```
+!!! important
+
+ It's important that you run ```pre-commit install --hook-type commit-msg```, even if you've already used
+ ```pre-commit install``` before. ```pre-commit install``` does **not** install commit-msg hooks by default!
+
+To manually trigger gitlint using ```pre-commit``` for your last commit message, use the following command:
+```sh
+pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
+```
+
+In case you want to change gitlint's behavior, you should either use a `.gitlint` file
+(see [Configuration](configuration.md)) or modify the gitlint invocation in
+your `.pre-commit-config.yaml` file like so:
+```yaml
+- repo: https://github.com/jorisroovers/gitlint
+ rev: # Fill in a tag / sha here
+ hooks:
+ - id: gitlint
+ stages: [commit-msg]
+ entry: gitlint
+ args: [--contrib=CT1, --msg-filename]
+```
+
+# Using gitlint in a CI environment ##
+By default, when just running ```gitlint``` without additional parameters, gitlint lints the last commit in the current
+working directory.
+
+This makes it easy to use gitlint in a CI environment (Jenkins, TravisCI, Github Actions, pre-commit, CircleCI, Gitlab, etc).
+In fact, this is exactly what we do ourselves: on every commit,
+[we run gitlint as part of our CI checks](https://github.com/jorisroovers/gitlint/blob/v0.12.0/run_tests.sh#L133-L134).
+This will cause the build to fail when we submit a bad commit message.
+
+Alternatively, gitlint will also lint any commit message that you feed it via stdin like so:
+```bash
+# lint the last commit message
+git log -1 --pretty=%B | gitlint
+# lint a specific commit: 62c0519
+git log -1 --pretty=%B 62c0519 | gitlint
+```
+Note that gitlint requires that you specify ```--pretty=%B``` (=only print the log message, not the metadata),
+future versions of gitlint might fix this and not require the ```--pretty``` argument.
+
+## Linting a range of commits ##
+
+_Introduced in gitlint v0.9.0 (experimental in v0.8.0)_
+
+Gitlint allows users to commit a number of commits at once like so:
+
+```bash
+# Lint a specific commit range:
+gitlint --commits "019cf40...d6bc75a"
+# You can also use git's special references:
+gitlint --commits "origin..HEAD"
+# Or specify a single specific commit in refspec format, like so:
+gitlint --commits "019cf40^...019cf40"
+```
+
+The ```--commits``` flag takes a **single** refspec argument or commit range. Basically, any range that is understood
+by [git rev-list](https://git-scm.com/docs/git-rev-list) as a single argument will work.
+
+Prior to v0.8.1 gitlint didn't support this feature. However, older versions of gitlint can still lint a range or set
+of commits at once by creating a simple bash script that pipes the commit messages one by one into gitlint. This
+approach can still be used with newer versions of gitlint in case ```--commits``` doesn't provide the flexibility you
+are looking for.
+
+```bash
+#!/bin/bash
+
+for commit in $(git rev-list master); do
+ commit_msg=$(git log -1 --pretty=%B $commit)
+ echo "$commit"
+ echo "$commit_msg" | gitlint
+ echo "--------"
+done
+```
+
+!!! note
+ One downside to this approach is that you invoke gitlint once per commit vs. once per set of commits.
+ This means you'll incur the gitlint startup time once per commit, making this approach rather slow if you want to
+ lint a large set of commits. Always use ```--commits``` if you can to avoid this performance penalty.
+
+
+# Merge, fixup and squash commits ##
+_Introduced in gitlint v0.7.0 (merge), v0.9.0 (fixup, squash) and v0.13.0 (revert)_
+
+**Gitlint ignores merge, revert, fixup and squash commits by default.**
+
+For merge and revert commits, the rationale for ignoring them is
+that most users keep git's default messages for these commits (i.e *Merge/Revert "[original commit message]"*).
+Often times these commit messages are also auto-generated through tools like github.
+These default/auto-generated commit messages tend to cause gitlint violations.
+For example, a common case is that *"Merge:"* being auto-prepended triggers a
+[title-max-length](rules.md#t1-title-max-length) violation. Most users don't want this, so we disable linting
+on Merge and Revert commits by default.
+
+For [squash](https://git-scm.com/docs/git-commit#git-commit---squashltcommitgt) and [fixup](https://git-scm.com/docs/git-commit#git-commit---fixupltcommitgt) commits, the rationale is that these are temporary
+commits that will be squashed into a different commit, and hence the commit messages for these commits are very
+short-lived and not intended to make it into the final commit history. In addition, by prepending *"fixup!"* or
+*"squash!"* to your commit message, certain gitlint rules might be violated
+(e.g. [title-max-length](rules.md#t1-title-max-length)) which is often undesirable.
+
+In case you *do* want to lint these commit messages, you can disable this behavior by setting the
+general ```ignore-merge-commits```, ```ignore-revert-commits```, ```ignore-fixup-commits``` or
+```ignore-squash-commits``` option to ```false```
+[using one of the various ways to configure gitlint](configuration.md).
+
+# Ignoring commits ##
+_Introduced in gitlint v0.10.0_
+
+You can configure gitlint to ignore specific commits.
+
+One way to do this, is to by [adding a gitline-ignore line to your commit message](configuration.md#commit-specific-config).
+
+If you have a case where you want to ignore a certain type of commits all-together, you can
+use gitlint's *ignore* rules.
+Here's an example gitlint file that configures gitlint to ignore rules ```title-max-length``` and ```body-min-length```
+for all commits with a title starting with *"Release"*.
+
+```ini
+[ignore-by-title]
+# Match commit titles starting with Release
+regex=^Release(.*)
+ignore=title-max-length,body-min-length
+# ignore all rules by setting ignore to 'all'
+# ignore=all
+
+[ignore-by-body]
+# Match commits message bodies that have a line that contains 'release'
+regex=(.*)release(.*)
+ignore=all
+```
+
+!!! note
+
+ Right now it's not possible to write user-defined ignore rules to handle more complex use-cases.
+ This is however something that we'd like to implement in a future version. If this is something you're interested in
+ please let us know by [opening an issue](https://github.com/jorisroovers/gitlint/issues).
+
+# Exit codes ##
+Gitlint uses the exit code as a simple way to indicate the number of violations found.
+Some exit codes are used to indicate special errors as indicated in the table below.
+
+Because of these special error codes and the fact that
+[bash only supports exit codes between 0 and 255](http://tldp.org/LDP/abs/html/exitcodes.html), the maximum number
+of violations counted by the exit code is 252. Note that gitlint does not have a limit on the number of violations
+it can detect, it will just always return with exit code 252 when the number of violations is greater than or equal
+to 252.
+
+Exit Code | Description
+-----------|------------------------------------------------------------
+253 | Wrong invocation of the ```gitlint``` command.
+254 | Something went wrong when invoking git.
+255 | Invalid gitlint configuration
diff --git a/docs/rules.md b/docs/rules.md
new file mode 100644
index 0000000..173c5b1
--- /dev/null
+++ b/docs/rules.md
@@ -0,0 +1,243 @@
+# Overview #
+
+The table below shows an overview of all gitlint's built-in rules.
+Note that you can also [write your own user-defined rule](user_defined_rules.md) in case you don't find
+what you're looking for.
+The rest of this page contains details on the available configuration options for each built-in rule.
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-------------------|-------------------------------------------
+T1 | title-max-length | >= 0.1.0 | Title length must be &lt; 72 chars.
+T2 | title-trailing-whitespace | >= 0.1.0 | Title cannot have trailing whitespace (space or tab)
+T3 | title-trailing-punctuation | >= 0.1.0 | Title cannot have trailing punctuation (?:!.,;)
+T4 | title-hard-tab | >= 0.1.0 | Title cannot contain hard tab characters (\t)
+T5 | title-must-not-contain-word | >= 0.1.0 | Title cannot contain certain words (default: "WIP")
+T6 | title-leading-whitespace | >= 0.4.0 | Title cannot have leading whitespace (space or tab)
+T7 | title-match-regex | >= 0.5.0 | Title must match a given regex (default: .*)
+B1 | body-max-line-length | >= 0.1.0 | Lines in the body must be &lt; 80 chars
+B2 | body-trailing-whitespace | >= 0.1.0 | Body cannot have trailing whitespace (space or tab)
+B3 | body-hard-tab | >= 0.1.0 | Body cannot contain hard tab characters (\t)
+B4 | body-first-line-empty | >= 0.1.0 | First line of the body (second line of commit message) must be empty
+B5 | body-min-length | >= 0.4.0 | Body length must be at least 20 characters
+B6 | body-is-missing | >= 0.4.0 | Body message must be specified
+B7 | body-changed-file-mention | >= 0.4.0 | Body must contain references to certain files if those files are changed in the last commit
+M1 | author-valid-email | >= 0.9.0 | Author email address must be a valid email address
+I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title
+I2 | ignore-by-body | >= 0.10.0 | Ignore a commit based on matching its body
+
+## T1: title-max-length ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T1 | title-max-length | >= 0.1 | Title length must be &lt; 72 chars.
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|-----------------|---------|----------------------------------
+line-length | >= 0.2 | 72 | Maximum allowed title length
+
+## T2: title-trailing-whitespace ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T2 | title-trailing-whitespace | >= 0.1 | Title cannot have trailing whitespace (space or tab)
+
+
+## T3: title-trailing-punctuation ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T3 | title-trailing-punctuation | >= 0.1 | Title cannot have trailing punctuation (?:!.,;)
+
+
+## T4: title-hard-tab ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T4 | title-hard-tab | >= 0.1 | Title cannot contain hard tab characters (\t)
+
+
+## T5: title-must-not-contain-word ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T5 | title-must-not-contain-word | >= 0.1 | Title cannot contain certain words (default: "WIP")
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|-----------------|---------|----------------------------------
+words | >= 0.3 | WIP | Comma-separated list of words that should not be used in the title. Matching is case insensitive
+
+## T6: title-leading-whitespace ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T6 | title-leading-whitespace | >= 0.4 | Title cannot have leading whitespace (space or tab)
+
+## T7: title-match-regex ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+T7 | title-match-regex | >= 0.5 | Title must match a given regex (default: .*)
+
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|-----------------|---------|----------------------------------
+regex | >= 0.5 | .* | [Python-style regular expression](https://docs.python.org/3.5/library/re.html) that the title should match.
+
+## B1: body-max-line-length ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B1 | body-max-line-length | >= 0.1 | Lines in the body must be &lt; 80 chars
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|-----------------|---------|----------------------------------
+line-length | >= 0.2 | 80 | Maximum allowed line length in the commit message body
+
+## B2: body-trailing-whitespace ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B2 | body-trailing-whitespace | >= 0.1 | Body cannot have trailing whitespace (space or tab)
+
+
+## B3: body-hard-tab ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B3 | body-hard-tab | >= 0.1 | Body cannot contain hard tab characters (\t)
+
+
+## B4: body-first-line-empty ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B4 | body-first-line-empty | >= 0.1 | First line of the body (second line of commit message) must be empty
+
+## B5: body-min-length ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B5 | body-min-length | >= 0.4 | Body length must be at least 20 characters. In versions >= 0.8.0, gitlint will not count newline characters.
+
+### Options ###
+
+Name | gitlint version | Default | Description
+---------------|-----------------|---------|----------------------------------
+min-length | >= 0.4 | 20 | Minimum number of required characters in body
+
+## B6: body-is-missing ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B6 | body-is-missing | >= 0.4 | Body message must be specified
+
+
+### Options ###
+
+Name | gitlint version | Default | Description
+----------------------|-----------------|-----------|----------------------------------
+ignore-merge-commits | >= 0.4 | true | Whether this rule should be ignored during merge commits. Allowed values: true,false.
+
+## B7: body-changed-file-mention ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+B7 | body-changed-file-mention | >= 0.4 | Body must contain references to certain files if those files are changed in the last commit
+
+### Options ###
+
+Name | gitlint version | Default | Description
+----------------------|-----------------|--------------|----------------------------------
+files | >= 0.4 | (empty) | Comma-separated list of files that need to an explicit mention in the commit message in case they are changed.
+
+
+
+## M1: author-valid-email ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+M1 | author-valid-email | >= 0.8.3 | Author email address must be a valid email address
+
+!!! note
+ Email addresses are [notoriously hard to validate and the official email valid spec is often too loose for any real world application](http://stackoverflow.com/a/201378/381010).
+ Gitlint by default takes a pragmatic approach and requires users to enter email addresses that contain a name, domain and tld and has no spaces.
+
+
+
+### Options ###
+
+Name | gitlint version | Default | Description
+----------------------|-------------------|------------------------------|----------------------------------
+regex | >= 0.9.0 | ```[^@ ]+@[^@ ]+\.[^@ ]+``` | Regex the commit author email address is matched against
+
+
+!!! note
+ An often recurring use-case is to only allow email addresses from a certain domain. The following regular expression achieves this: ```[^@]+@foo.com```
+
+
+## I1: ignore-by-title ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title.
+
+
+### Options ###
+
+Name | gitlint version | Default | Description
+----------------------|-------------------|------------------------------|----------------------------------
+regex | >= 0.10.0 | None | Regex to match against commit title. On match, the commit will be ignored.
+ignore | >= 0.10.0 | all | Comma-seperated list of rule names or ids to ignore when this rule is matched.
+
+### Examples
+
+#### .gitlint
+
+```ini
+# Match commit titles starting with Release
+# For those commits, ignore title-max-length and body-min-length rules
+[ignore-by-title]
+regex=^Release(.*)
+ignore=title-max-length,body-min-length
+# ignore all rules by setting ignore to 'all'
+# ignore=all
+```
+
+## I2: ignore-by-body ##
+
+ID | Name | gitlint version | Description
+------|-----------------------------|-----------------|-------------------------------------------
+I2 | ignore-by-body | >= 0.10.0 | Ignore a commit based on matching its body.
+
+
+### Options ###
+
+Name | gitlint version | Default | Description
+----------------------|-------------------|------------------------------|----------------------------------
+regex | >= 0.10.0 | None | Regex to match against each line of the body. On match, the commit will be ignored.
+ignore | >= 0.10.0 | all | Comma-seperated list of rule names or ids to ignore when this rule is matched.
+
+### Examples
+
+#### .gitlint
+
+```ini
+# Ignore all commits with a commit message body with a line that contains 'release'
+[ignore-by-body]
+regex=(.*)release(.*)
+ignore=all
+
+# For matching commits, only ignore rules T1, body-min-length, B6.
+# You can use both names as well as ids to refer to other rules.
+[ignore-by-body]
+regex=(.*)release(.*)
+ignore=T1,body-min-length,B6
+``` \ No newline at end of file
diff --git a/docs/user_defined_rules.md b/docs/user_defined_rules.md
new file mode 100644
index 0000000..a8a51d5
--- /dev/null
+++ b/docs/user_defined_rules.md
@@ -0,0 +1,312 @@
+# User Defined Rules
+_Introduced in gitlint v0.8.0_
+
+Gitlint supports the concept of **user-defined** rules: the ability for users to write their own custom rules in python.
+
+In a nutshell, use ```--extra-path /home/joe/myextensions``` to point gitlint to a ```myextensions``` directory where it will search
+for python files containing gitlint rule classes. You can also specify a single python module, ie
+```--extra-path /home/joe/my_rules.py```.
+
+```bash
+cat examples/commit-message-1 | gitlint --extra-path examples/
+# Example output of a user-defined Signed-Off-By rule
+1: UC2 Body does not contain a 'Signed-Off-By Line'
+# other violations were removed for brevity
+```
+
+The `SignedOffBy` user-defined ```CommitRule``` was discovered by gitlint when it scanned
+[examples/gitlint/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py),
+which is part of the examples directory that was passed via ```--extra-path```:
+
+```python
+from gitlint.rules import CommitRule, RuleViolation
+
+class SignedOffBy(CommitRule):
+ """ This rule will enforce that each commit contains a "Signed-Off-By" line.
+ We keep things simple here and just check whether the commit body contains a
+ line that starts with "Signed-Off-By".
+ """
+
+ # A rule MUST have a human friendly name
+ name = "body-requires-signed-off-by"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC
+ # (for User-defined Commit-rule).
+ id = "UC2"
+
+ def validate(self, commit):
+ for line in commit.message.body:
+ if line.startswith("Signed-Off-By"):
+ return
+
+ msg = "Body does not contain a 'Signed-Off-By' line"
+ return [RuleViolation(self.id, msg, line_nr=1)]
+```
+
+As always, ```--extra-path``` can also be set by adding it under the ```[general]``` section in your ```.gitlint``` file or using
+[one of the other ways to configure gitlint](configuration.md).
+
+If you want to check whether your rules are properly discovered by gitlint, you can use the ```--debug``` flag:
+
+```bash
+$ gitlint --debug --extra-path examples/
+# [output cut for brevity]
+ UC1: body-max-line-count
+ body-max-line-count=3
+ UC2: body-requires-signed-off-by
+ UL1: title-no-special-chars
+ special-chars=['$', '^', '%', '@', '!', '*', '(', ')']
+```
+
+!!! Note
+ In most cases it's really the easiest to just copy an example from the
+ [examples](https://github.com/jorisroovers/gitlint/tree/master/examples) directory and modify it to your needs.
+ The remainder of this page contains the technical details, mostly for reference.
+
+# Line and Commit Rules ##
+The ```SignedOffBy``` class above was an example of a user-defined ```CommitRule```. Commit rules are gitlint rules that
+act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
+to the entire commit. This happens exactly once per commit.
+
+A ```CommitRule``` contrasts with a ```LineRule```
+(see e.g.: [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_line_rules.py))
+in that a ```CommitRule``` is only applied once on an entire commit while a ```LineRule``` is applied for every line in the commit
+(you can also apply it once to the title using a ```target``` - see the examples section below).
+
+The benefit of a commit rule is that it allows commit rules to implement more complex checks that span multiple lines and/or checks
+that should only be done once per commit.
+
+While every ```LineRule``` can be implemented as a ```CommitRule```, it's usually easier and more concise to go with a ```LineRule``` if
+that fits your needs.
+
+## Examples ##
+
+In terms of code, writing your own ```CommitRule``` or ```LineRule``` is very similar.
+The only 2 differences between a ```CommitRule``` and a ```LineRule``` are the parameters of the ```validate(...)``` method and the extra
+```target``` attribute that ```LineRule``` requires.
+
+Consider the following ```CommitRule``` that can be found in [examples/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py):
+
+```python
+from gitlint.rules import CommitRule, RuleViolation
+
+class SignedOffBy(CommitRule):
+ """ This rule will enforce that each commit contains a "Signed-Off-By" line.
+ We keep things simple here and just check whether the commit body contains a
+ line that starts with "Signed-Off-By".
+ """
+
+ # A rule MUST have a human friendly name
+ name = "body-requires-signed-off-by"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC
+ # (for User-defined Commit-rule).
+ id = "UC2"
+
+ def validate(self, commit):
+ for line in commit.message.body:
+ if line.startswith("Signed-Off-By"):
+ return []
+
+ msg = "Body does not contain a 'Signed-Off-By Line'"
+ return [RuleViolation(self.id, msg, line_nr=1)]
+```
+Note the use of the ```name``` and ```id``` class attributes and the ```validate(...)``` method taking a single ```commit``` parameter.
+
+Contrast this with the following ```LineRule``` that can be found in [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_line_rules.py):
+
+```python
+from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle
+from gitlint.options import ListOption
+
+class SpecialChars(LineRule):
+ """ This rule will enforce that the commit message title does not contai
+ any of the following characters:
+ $^%@!*() """
+
+ # A rule MUST have a human friendly name
+ name = "title-no-special-chars"
+
+ # A rule MUST have a *unique* id, we recommend starting with UL
+ # for User-defined Line-rule), but this can really be anything.
+ id = "UL1"
+
+ # A line-rule MUST have a target (not required for CommitRules).
+ target = CommitMessageTitle
+
+ # A rule MAY have an option_spec if its behavior should be configurable.
+ options_spec = [ListOption('special-chars', ['$', '^', '%', '@', '!', '*', '(', ')'],
+ "Comma separated list of characters that should not occur in the title")]
+
+ def validate(self, line, commit):
+ violations = []
+ # option values can be accessed via self.options
+ for char in self.options['special-chars'].value:
+ if char in line:
+ violation = RuleViolation(self.id, "Title contains the special character '{}'".format(char), line)
+ violations.append(violation)
+
+ return violations
+```
+
+Note the following 2 differences:
+
+- **extra ```target``` class attribute**: in this example set to ```CommitMessageTitle``` indicating that this ```LineRule```
+should only be applied once to the commit message title. The alternative value for ```target``` is ```CommitMessageBody```,
+ in which case gitlint will apply
+your rule to **every** line in the commit message body.
+- **```validate(...)``` takes 2 parameters**: Line rules get the ```line``` against which they are applied as the first parameter and
+the ```commit``` object of which the line is part of as second.
+
+In addition, you probably also noticed the extra ```options_spec``` class attribute which allows you to make your rules configurable.
+Options are not unique to ```LineRule```s, they can also be used by ```CommitRule```s and are further explained in the
+[Options](user_defined_rules.md#options) section below.
+
+
+# The commit object ##
+Both ```CommitRule```s and ```LineRule```s take a ```commit``` object in their ```validate(...)``` methods.
+The table below outlines the various attributes of that commit object that can be used during validation.
+
+
+Property | Type | Description
+-------------------------------| ---------------|-------------------
+commit.message | object | Python object representing the commit message
+commit.message.original | string | Original commit message as returned by git
+commit.message.full | string | Full commit message, with comments (lines starting with #) removed.
+commit.message.title | string | Title/subject of the commit message: the first line
+commit.message.body | string[] | List of lines in the body of the commit message (i.e. starting from the second line)
+commit.author_name | string | Name of the author, result of ```git log --pretty=%aN```
+commit.author_email | string | Email of the author, result of ```git log --pretty=%aE```
+commit.date | datetime | Python ```datetime``` object representing the time of commit
+commit.is_merge_commit | boolean | Boolean indicating whether the commit is a merge commit or not.
+commit.is_revert_commit | boolean | Boolean indicating whether the commit is a revert commit or not.
+commit.is_fixup_commit | boolean | Boolean indicating whether the commit is a fixup commit or not.
+commit.is_squash_commit | boolean | Boolean indicating whether the commit is a squash commit or not.
+commit.parents | string[] | List of parent commit ```sha```s (only for merge commits).
+commit.changed_files | string[] | List of files changed in the commit (relative paths).
+commit.branches | string[] | List of branch names the commit is part of
+commit.context | object | Object pointing to the bigger git context that the commit is part of
+commit.context.current_branch | string | Name of the currently active branch (of local repo)
+commit.context.repository_path | string | Absolute path pointing to the git repository being linted
+commit.context.commits | object[] | List of commits gitlint is acting on, NOT all commits in the repo.
+
+# Violations ##
+In order to let gitlint know that there is a violation in the commit being linted, users should have the ```validate(...)```
+method in their rules return a list of ```RuleViolation```s.
+
+!!! important
+ The ```validate(...)``` method doesn't always need to return a list, you can just skip the return statement in case there are no violations.
+ However, in case of a single violation, validate should return a **list** with a single item.
+
+The ```RuleViolation``` class has the following generic signature:
+
+```
+RuleViolation(rule_id, message, content=None, line_nr=None):
+```
+With the parameters meaning the following:
+
+Parameter | Type | Description
+--------------|---------|--------------------------------
+rule_id | string | Rule's unique string id
+message | string | Short description of the violation
+content | string | (optional) the violating part of commit or line
+line_nr | int | (optional) line number in the commit message where the violation occurs. **Automatically set to the correct line number for ```LineRule```s if not set explicitly.**
+
+A typical ```validate(...)``` implementation for a ```CommitRule``` would then be as follows:
+```python
+def validate(self, commit)
+ for line_nr, line in commit.message.body:
+ if "Jon Snow" in line:
+ # we add 1 to the line_nr because we offset the title which is on the first line
+ return [RuleViolation(self.id, "Commit message has the words 'Jon Snow' in it", line, line_nr + 1)]
+ return []
+```
+
+The parameters of this ```RuleViolation``` can be directly mapped onto gitlint's output as follows:
+
+![How Rule violations map to gitlint output](images/RuleViolation.png)
+
+# Options ##
+
+In order to make your own rules configurable, you can add an optional ```options_spec``` attribute to your rule class
+(supported for both ```LineRule``` and ```CommitRule```).
+
+```python
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.options import IntOption
+
+class BodyMaxLineCount(CommitRule):
+ # A rule MUST have a human friendly name
+ name = "body-max-line-count"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC (for
+ # User-defined Commit-rule).
+ id = "UC1"
+
+ # A rule MAY have an option_spec if its behavior should be configurable.
+ options_spec = [IntOption('max-line-count', 3, "Maximum body line count")]
+
+ def validate(self, commit):
+ line_count = len(commit.message.body)
+ max_line_count = self.options['max-line-count'].value
+ if line_count > max_line_count:
+ message = "Body contains too many lines ({0} > {1})".format(line_count,
+ max_line_count)
+ return [RuleViolation(self.id, message, line_nr=1)]
+```
+
+
+By using ```options_spec```, you make your option available to be configured through a ```.gitlint``` file
+or one of the [other ways to configure gitlint](configuration.md). Gitlint automatically takes care of the parsing and input validation.
+
+For example, to change the value of the ```max-line-count``` option, add the following to your ```.gitlint``` file:
+```ini
+[body-max-line-count]
+body-max-line-count=1
+```
+
+As ```options_spec``` is a list, you can obviously have multiple options per rule. The general signature of an option is:
+```Option(name, default_value, description)```.
+
+Gitlint supports a variety of different option types, all can be imported from ```gitlint.options```:
+
+Option Class | Use for
+----------------|--------------
+StrOption | Strings
+IntOption | Integers. ```IntOption``` takes an optional ```allow_negative``` parameter if you want to allow negative integers.
+BoolOption | Booleans. Valid values: `true`, `false`. Case-insensitive.
+ListOption | List of strings. Comma separated.
+PathOption | Directory or file path. Takes an optional ```type``` parameter for specifying path type (```file```, ```dir``` (=default) or ```both```).
+
+!!! note
+ Gitlint currently does not support options for all possible types (e.g. float, list of int, etc).
+ [We could use a hand getting those implemented](contributing.md)!
+
+
+# Rule requirements ##
+
+As long as you stick with simple rules that are similar to the sample user-defined rules (see the
+[examples](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py) directory), gitlint
+should be able to discover and execute them. While clearly you can run any python code you want in your rules,
+you might run into some issues if you don't follow the conventions that gitlint requires.
+
+While the [rule finding source-code](https://github.com/jorisroovers/gitlint/blob/master/gitlint/rule_finder.py) is the
+ultimate source of truth, here are some of the requirements that gitlint enforces.
+
+## Rule class requirements ###
+
+- Rules **must** extend from ```LineRule``` or ```CommitRule```
+- Rule classes **must** have ```id``` and ```name``` string attributes. The ```options_spec``` is optional,
+ but if set, it **must** be a list of gitlint Options.
+- Rule classes **must** have a ```validate``` method. In case of a ```CommitRule```, ```validate``` **must** take a single ```commit``` parameter.
+ In case of ```LineRule```, ```validate``` **must** take ```line``` and ```commit``` as first and second parameters.
+- LineRule classes **must** have a ```target``` class attributes that is set to either ```CommitMessageTitle``` or ```CommitMessageBody```.
+- User Rule id's **cannot** start with ```R```, ```T```, ```B``` or ```M``` as these rule ids are reserved for gitlint itself.
+- Rules **should** have a case-insensitive unique id as only one rule can exist with a given id. While gitlint does not enforce this, having multiple rules with
+ the same id might lead to unexpected or undeterministic behavior.
+
+## extra-path requirements ###
+- If ```extra-path``` is a directory, it does **not** need to be a proper python package, i.e. it doesn't require an ```__init__.py``` file.
+- Python files containing user-defined rules must have a ```.py``` extension. Files with a different extension will be ignored.
+- The ```extra-path``` will be searched non-recursively, i.e. all rule classes must be present at the top level ```extra-path``` directory.
+- User rule classes must be defined in the modules that are part of ```extra-path```, rules that are imported from outside the ```extra-path``` will be ignored.
diff --git a/examples/commit-message-1 b/examples/commit-message-1
new file mode 100644
index 0000000..7be3ddd
--- /dev/null
+++ b/examples/commit-message-1
@@ -0,0 +1,5 @@
+WIP: This is the title of a commit message.
+The second line should typically be empty
+Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120.
+# All of the following is ignored
+# This line starts with a hard tab
diff --git a/examples/commit-message-10 b/examples/commit-message-10
new file mode 100644
index 0000000..f5bff2a
--- /dev/null
+++ b/examples/commit-message-10
@@ -0,0 +1,6 @@
+This h@s $pecialCh@rs!
+
+Commit body
+with more
+than 3 lines
+and no signed off by line \ No newline at end of file
diff --git a/examples/commit-message-2 b/examples/commit-message-2
new file mode 100644
index 0000000..8ca3b4a
--- /dev/null
+++ b/examples/commit-message-2
@@ -0,0 +1,5 @@
+This is the title of a commit message that is over 72 characters and contains hard tabs and trailing whitespace and the word wiping
+This line should not contain text
+Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120.
+
+# This line will be ignored by gitlint because it starts with a #.
diff --git a/examples/commit-message-3 b/examples/commit-message-3
new file mode 100644
index 0000000..9a3eb59
--- /dev/null
+++ b/examples/commit-message-3
@@ -0,0 +1,3 @@
+ This is the wip title of a commit message!
+
+Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120.
diff --git a/examples/commit-message-4 b/examples/commit-message-4
new file mode 100644
index 0000000..3a15cb4
--- /dev/null
+++ b/examples/commit-message-4
@@ -0,0 +1,3 @@
+ This title has a leading tab whitespace
+
+tooshort
diff --git a/examples/commit-message-5 b/examples/commit-message-5
new file mode 100644
index 0000000..0088dae
--- /dev/null
+++ b/examples/commit-message-5
@@ -0,0 +1 @@
+US1234: This commit message has no body
diff --git a/examples/commit-message-6 b/examples/commit-message-6
new file mode 100644
index 0000000..631cf62
--- /dev/null
+++ b/examples/commit-message-6
@@ -0,0 +1 @@
+Merge "US1234: This merge has no body and that's OK"
diff --git a/examples/commit-message-7 b/examples/commit-message-7
new file mode 100644
index 0000000..6f7c192
--- /dev/null
+++ b/examples/commit-message-7
@@ -0,0 +1,4 @@
+This is the title of a commit message that is over 72 characters and contains hard tabs and trailing whitespace and the word wiping
+This line should not contain text
+Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120.
+gitlint-ignore: all
diff --git a/examples/commit-message-8 b/examples/commit-message-8
new file mode 100644
index 0000000..4ba6e86
--- /dev/null
+++ b/examples/commit-message-8
@@ -0,0 +1,6 @@
+This is the title of a commit message that is over 72 characters and contains hard tabs and trailing whitespace and the word wiping
+This line should not contain text
+Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120.
+
+# This line will be ignored by gitlint because it starts with a #.
+gitlint-ignore: B4, title-hard-tab \ No newline at end of file
diff --git a/examples/commit-message-9 b/examples/commit-message-9
new file mode 100644
index 0000000..018ac46
--- /dev/null
+++ b/examples/commit-message-9
@@ -0,0 +1,7 @@
+Merge: "This is a merge commit with a long title that most definitely exceeds the normal limit of 72 chars"
+This line should be empty
+This is the first line is meant to test a line that exceeds the maximum line length of 80 characters.
+
+You will notice that gitlint ignores all of these errors by default because this is a merge commit.
+
+If you want to change this behavior, set the following option: 'general.ignore-merge-commits=false'
diff --git a/examples/gitlint b/examples/gitlint
new file mode 100644
index 0000000..b722023
--- /dev/null
+++ b/examples/gitlint
@@ -0,0 +1,58 @@
+# Edit this file as you like.
+#
+# All these sections are optional. Each section with the exception of general represents
+# one rule and each key in it is an option for that specific rule.
+#
+# Rules and sections can be referenced by their full name or by id. For example
+# section "[body-max-line-length]" could be written as "[B1]". Full section names are
+# used in here for clarity.
+# Rule reference documentation: http://jorisroovers.github.io/gitlint/rules/
+#
+# Note that this file is not exhaustive, it's just an example
+# Use 'gitlint generate-config' to generate a config file with all possible options
+[general]
+ignore=title-trailing-punctuation, T3
+# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
+verbosity = 2
+# By default gitlint will ignore merge commits. Set to 'false' to disable.
+ignore-merge-commits=true
+# Enable debug mode (prints more output). Disabled by default
+debug = true
+
+# Set the extra-path where gitlint will search for user defined rules
+# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
+# extra-path=examples/
+
+[title-max-length]
+line-length=50
+
+[title-must-not-contain-word]
+# Comma-separated list of words that should not occur in the title. Matching is case
+# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
+# will not cause a violation, but "WIP: my title" will.
+words=wip,title
+
+[title-match-regex]
+# python like regex (https://docs.python.org/2/library/re.html) that the
+# commit-msg title must be matched to.
+# Note that the regex can contradict with other rules if not used correctly
+# (e.g. title-must-not-contain-word).
+regex=^US[0-9]*
+
+[body-max-line-length]
+line-length=72
+
+[body-min-length]
+min-length=5
+
+[body-is-missing]
+# Whether to ignore this rule on merge commits (which typically only have a title)
+# default = True
+ignore-merge-commits=false
+
+[body-changed-file-mention]
+# List of files that need to be explicitly mentioned in the body when they are changed
+# This is useful for when developers often erroneously edit certain files or git submodules.
+# By specifying this rule, developers can only change the file when they explicitly reference
+# it in the commit message.
+files=gitlint/rules.py,README.md
diff --git a/examples/my_commit_rules.py b/examples/my_commit_rules.py
new file mode 100644
index 0000000..e12e02d
--- /dev/null
+++ b/examples/my_commit_rules.py
@@ -0,0 +1,87 @@
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.options import IntOption, ListOption
+from gitlint import utils
+
+
+"""
+The classes below are examples of user-defined CommitRules. Commit rules are gitlint rules that
+act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
+to the entire commit. This happens exactly once per commit.
+
+A CommitRule contrasts with a LineRule (see examples/my_line_rules.py) in that a commit rule is only applied once on
+an entire commit. This allows commit rules to implement more complex checks that span multiple lines and/or checks
+that should only be done once per gitlint run.
+
+While every LineRule can be implemented as a CommitRule, it's usually easier and more concise to go with a LineRule if
+that fits your needs.
+"""
+
+
+class BodyMaxLineCount(CommitRule):
+ # A rule MUST have a human friendly name
+ name = "body-max-line-count"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC (for User-defined Commit-rule).
+ id = "UC1"
+
+ # A rule MAY have an option_spec if its behavior should be configurable.
+ options_spec = [IntOption('max-line-count', 3, "Maximum body line count")]
+
+ def validate(self, commit):
+ line_count = len(commit.message.body)
+ max_line_count = self.options['max-line-count'].value
+ if line_count > max_line_count:
+ message = "Body contains too many lines ({0} > {1})".format(line_count, max_line_count)
+ return [RuleViolation(self.id, message, line_nr=1)]
+
+
+class SignedOffBy(CommitRule):
+ """ This rule will enforce that each commit contains a "Signed-Off-By" line.
+ We keep things simple here and just check whether the commit body contains a line that starts with "Signed-Off-By".
+ """
+
+ # A rule MUST have a human friendly name
+ name = "body-requires-signed-off-by"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC (for User-defined Commit-rule).
+ id = "UC2"
+
+ def validate(self, commit):
+ for line in commit.message.body:
+ if line.startswith("Signed-Off-By"):
+ return
+
+ return [RuleViolation(self.id, "Body does not contain a 'Signed-Off-By' line", line_nr=1)]
+
+
+class BranchNamingConventions(CommitRule):
+ """ This rule will enforce that a commit is part of a branch that meets certain naming conventions.
+ See GitFlow for real-world example of this: https://nvie.com/posts/a-successful-git-branching-model/
+ """
+
+ # A rule MUST have a human friendly name
+ name = "branch-naming-conventions"
+
+ # A rule MUST have a *unique* id, we recommend starting with UC (for User-defined Commit-rule).
+ id = "UC3"
+
+ # A rule MAY have an option_spec if its behavior should be configurable.
+ options_spec = [ListOption('branch-prefixes', ["feature/", "hotfix/", "release/"], "Allowed branch prefixes")]
+
+ def validate(self, commit):
+ violations = []
+ allowed_branch_prefixes = self.options['branch-prefixes'].value
+ for branch in commit.branches:
+ valid_branch_name = False
+
+ for allowed_prefix in allowed_branch_prefixes:
+ if branch.startswith(allowed_prefix):
+ valid_branch_name = True
+ break
+
+ if not valid_branch_name:
+ msg = "Branch name '{0}' does not start with one of {1}".format(branch,
+ utils.sstr(allowed_branch_prefixes))
+ violations.append(RuleViolation(self.id, msg, line_nr=1))
+
+ return violations
diff --git a/examples/my_line_rules.py b/examples/my_line_rules.py
new file mode 100644
index 0000000..cc69fb9
--- /dev/null
+++ b/examples/my_line_rules.py
@@ -0,0 +1,45 @@
+from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle
+from gitlint.options import ListOption
+
+"""
+The SpecialChars class below is an example of a user-defined LineRule. Line rules are gitlint rules that only act on a
+single line at once. Once the rule is discovered, gitlint will automatically take care of applying this rule
+against each line of the commit message title or body (whether it is applied to the title or body is determined by the
+`target` attribute of the class).
+
+A LineRule contrasts with a CommitRule (see examples/my_commit_rules.py) in that a commit rule is only applied once on
+an entire commit. This allows commit rules to implement more complex checks that span multiple lines and/or checks
+that should only be done once per gitlint run.
+
+While every LineRule can be implemented as a CommitRule, it's usually easier and more concise to go with a LineRule if
+that fits your needs.
+"""
+
+
+class SpecialChars(LineRule):
+ """ This rule will enforce that the commit message title does not contain any of the following characters:
+ $^%@!*() """
+
+ # A rule MUST have a human friendly name
+ name = "title-no-special-chars"
+
+ # A rule MUST have a *unique* id, we recommend starting with UL (for User-defined Line-rule), but this can
+ # really be anything.
+ id = "UL1"
+
+ # A line-rule MUST have a target (not required for CommitRules).
+ target = CommitMessageTitle
+
+ # A rule MAY have an option_spec if its behavior should be configurable.
+ options_spec = [ListOption('special-chars', ['$', '^', '%', '@', '!', '*', '(', ')'],
+ "Comma separated list of characters that should not occur in the title")]
+
+ def validate(self, line, _commit):
+ violations = []
+ # options can be accessed by looking them up by their name in self.options
+ for char in self.options['special-chars'].value:
+ if char in line:
+ violation = RuleViolation(self.id, "Title contains the special character '{0}'".format(char), line)
+ violations.append(violation)
+
+ return violations
diff --git a/gitlint/__init__.py b/gitlint/__init__.py
new file mode 100644
index 0000000..7e0dc0e
--- /dev/null
+++ b/gitlint/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.13.1"
diff --git a/gitlint/cache.py b/gitlint/cache.py
new file mode 100644
index 0000000..b7f9e6c
--- /dev/null
+++ b/gitlint/cache.py
@@ -0,0 +1,57 @@
+class PropertyCache(object):
+ """ Mixin class providing a simple cache. """
+
+ def __init__(self):
+ self._cache = {}
+
+ def _try_cache(self, cache_key, cache_populate_func):
+ """ Tries to get a value from the cache identified by `cache_key`.
+ If no value is found in the cache, do a function call to `cache_populate_func` to populate the cache
+ and then return the value from the cache. """
+ if cache_key not in self._cache:
+ cache_populate_func()
+ return self._cache[cache_key]
+
+
+def cache(original_func=None, cachekey=None):
+ """ Cache decorator. Caches function return values.
+ Requires the parent class to extend and initialize PropertyCache.
+ Usage:
+ # Use function name as cache key
+ @cache
+ def myfunc(args):
+ ...
+
+ # Specify cache key
+ @cache(cachekey="foobar")
+ def myfunc(args):
+ ...
+ """
+
+ # Decorators with optional arguments are a bit convoluted in python, especially if you want to support both
+ # Python 2 and 3. See some of the links below for details.
+
+ def cache_decorator(func):
+
+ # If no specific cache key is given, use the function name as cache key
+ if not cache_decorator.cachekey:
+ cache_decorator.cachekey = func.__name__
+
+ def wrapped(*args):
+ def cache_func_result():
+ # Call decorated function and store its result in the cache
+ args[0]._cache[cache_decorator.cachekey] = func(*args)
+ return args[0]._try_cache(cache_decorator.cachekey, cache_func_result)
+
+ return wrapped
+
+ # Passing parent function variables to child functions requires special voodoo in python2:
+ # https://stackoverflow.com/a/14678445/381010
+ cache_decorator.cachekey = cachekey # attribute on the function
+
+ # To support optional kwargs for decorators, we need to check if a function is passed as first argument or not.
+ # https://stackoverflow.com/a/24617244/381010
+ if original_func:
+ return cache_decorator(original_func)
+
+ return cache_decorator
diff --git a/gitlint/cli.py b/gitlint/cli.py
new file mode 100644
index 0000000..4553fda
--- /dev/null
+++ b/gitlint/cli.py
@@ -0,0 +1,338 @@
+# pylint: disable=bad-option-value,wrong-import-position
+# We need to disable the import position checks because of the windows check that we need to do below
+import copy
+import logging
+import os
+import platform
+import stat
+import sys
+import click
+
+# Error codes
+MAX_VIOLATION_ERROR_CODE = 252 # noqa
+USAGE_ERROR_CODE = 253 # noqa
+GIT_CONTEXT_ERROR_CODE = 254 # noqa
+CONFIG_ERROR_CODE = 255 # noqa
+
+import gitlint
+from gitlint.lint import GitLinter
+from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator
+from gitlint.git import GitContext, GitContextError, git_version
+from gitlint import hooks
+from gitlint.utils import ustr, LOG_FORMAT
+
+DEFAULT_CONFIG_FILE = ".gitlint"
+
+# Since we use the return code to denote the amount of errors, we need to change the default click usage error code
+click.UsageError.exit_code = USAGE_ERROR_CODE
+
+LOG = logging.getLogger(__name__)
+
+
+class GitLintUsageError(Exception):
+ """ Exception indicating there is an issue with how gitlint is used. """
+ pass
+
+
+def setup_logging():
+ """ Setup gitlint logging """
+ root_log = logging.getLogger("gitlint")
+ root_log.propagate = False # Don't propagate to child loggers, the gitlint root logger handles everything
+ handler = logging.StreamHandler()
+ formatter = logging.Formatter(LOG_FORMAT)
+ handler.setFormatter(formatter)
+ root_log.addHandler(handler)
+ root_log.setLevel(logging.ERROR)
+
+
+def log_system_info():
+ LOG.debug("Platform: %s", platform.platform())
+ LOG.debug("Python version: %s", sys.version)
+ LOG.debug("Git version: %s", git_version())
+ LOG.debug("Gitlint version: %s", gitlint.__version__)
+ LOG.debug("GITLINT_USE_SH_LIB: %s", os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]"))
+
+
+def build_config( # pylint: disable=too-many-arguments
+ target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, verbose, silent, debug
+):
+ """ Creates a LintConfig object based on a set of commandline parameters. """
+ config_builder = LintConfigBuilder()
+ # Config precedence:
+ # First, load default config or config from configfile
+ if config_path:
+ config_builder.set_from_config_file(config_path)
+ elif os.path.exists(DEFAULT_CONFIG_FILE):
+ config_builder.set_from_config_file(DEFAULT_CONFIG_FILE)
+
+ # Then process any commandline configuration flags
+ config_builder.set_config_from_string_list(c)
+
+ # Finally, overwrite with any convenience commandline flags
+ if ignore:
+ config_builder.set_option('general', 'ignore', ignore)
+
+ if contrib:
+ config_builder.set_option('general', 'contrib', contrib)
+
+ if ignore_stdin:
+ config_builder.set_option('general', 'ignore-stdin', ignore_stdin)
+
+ if silent:
+ config_builder.set_option('general', 'verbosity', 0)
+ elif verbose > 0:
+ config_builder.set_option('general', 'verbosity', verbose)
+
+ if extra_path:
+ config_builder.set_option('general', 'extra-path', extra_path)
+
+ if target:
+ config_builder.set_option('general', 'target', target)
+
+ if debug:
+ config_builder.set_option('general', 'debug', debug)
+
+ if staged:
+ config_builder.set_option('general', 'staged', staged)
+
+ config = config_builder.build()
+
+ return config, config_builder
+
+
+def get_stdin_data():
+ """ Helper function that returns data send to stdin or False if nothing is send """
+ # STDIN can only be 3 different types of things ("modes")
+ # 1. An interactive terminal device (i.e. a TTY -> sys.stdin.isatty() or stat.S_ISCHR)
+ # 2. A (named) pipe (stat.S_ISFIFO)
+ # 3. A regular file (stat.S_ISREG)
+ # Technically, STDIN can also be other device type like a named unix socket (stat.S_ISSOCK), but we don't
+ # support that in gitlint (at least not today).
+ #
+ # Now, the behavior that we want is the following:
+ # If someone sends something directly to gitlint via a pipe or a regular file, read it. If not, read from the
+ # local repository.
+ # Note that we don't care about whether STDIN is a TTY or not, we only care whether data is via a pipe or regular
+ # file.
+ # However, in case STDIN is not a TTY, it HAS to be one of the 2 other things (pipe or regular file), even if
+ # no-one is actually sending anything to gitlint over them. In this case, we still want to read from the local
+ # repository.
+ # To support this use-case (which is common in CI runners such as Jenkins and Gitlab), we need to actually attempt
+ # to read from STDIN in case it's a pipe or regular file. In case that fails, then we'll fall back to reading
+ # from the local repo.
+
+ mode = os.fstat(sys.stdin.fileno()).st_mode
+ stdin_is_pipe_or_file = stat.S_ISFIFO(mode) or stat.S_ISREG(mode)
+ if stdin_is_pipe_or_file:
+ input_data = sys.stdin.read()
+ # Only return the input data if there's actually something passed
+ # i.e. don't consider empty piped data
+ if input_data:
+ return ustr(input_data)
+ return False
+
+
+def build_git_context(lint_config, msg_filename, refspec):
+ """ Builds a git context based on passed parameters and order of precedence """
+
+ # Determine which GitContext method to use if a custom message is passed
+ from_commit_msg = GitContext.from_commit_msg
+ if lint_config.staged:
+ LOG.debug("Fetching additional meta-data from staged commit")
+ from_commit_msg = lambda message: GitContext.from_staged_commit(message, lint_config.target) # noqa
+
+ # Order of precedence:
+ # 1. Any data specified via --msg-filename
+ if msg_filename:
+ LOG.debug("Using --msg-filename.")
+ return from_commit_msg(ustr(msg_filename.read()))
+
+ # 2. Any data sent to stdin (unless stdin is being ignored)
+ if not lint_config.ignore_stdin:
+ stdin_input = get_stdin_data()
+ if stdin_input:
+ LOG.debug("Stdin data: '%s'", stdin_input)
+ LOG.debug("Stdin detected and not ignored. Using as input.")
+ return from_commit_msg(stdin_input)
+
+ if lint_config.staged:
+ raise GitLintUsageError(u"The 'staged' option (--staged) can only be used when using '--msg-filename' or "
+ u"when piping data to gitlint via stdin.")
+
+ # 3. Fallback to reading from local repository
+ LOG.debug("No --msg-filename flag, no or empty data passed to stdin. Using the local repo.")
+ return GitContext.from_local_repository(lint_config.target, refspec)
+
+
+@click.group(invoke_without_command=True, context_settings={'max_content_width': 120},
+ epilog="When no COMMAND is specified, gitlint defaults to 'gitlint lint'.")
+@click.option('--target', type=click.Path(exists=True, resolve_path=True, file_okay=False, readable=True),
+ help="Path of the target git repository. [default: current working directory]")
+@click.option('-C', '--config', type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
+ help="Config file location [default: {0}]".format(DEFAULT_CONFIG_FILE))
+@click.option('-c', multiple=True,
+ help="Config flags in format <rule>.<option>=<value> (e.g.: -c T1.line-length=80). " +
+ "Flag can be used multiple times to set multiple config values.") # pylint: disable=bad-continuation
+@click.option('--commits', default=None, help="The range of commits to lint. [default: HEAD]")
+@click.option('-e', '--extra-path', help="Path to a directory or python module with extra user-defined rules",
+ type=click.Path(exists=True, resolve_path=True, readable=True))
+@click.option('--ignore', default="", help="Ignore rules (comma-separated by id or name).")
+@click.option('--contrib', default="", help="Contrib rules to enable (comma-separated by id or name).")
+@click.option('--msg-filename', type=click.File(), help="Path to a file containing a commit-msg.")
+@click.option('--ignore-stdin', is_flag=True, help="Ignore any stdin data. Useful for running in CI server.")
+@click.option('--staged', is_flag=True, help="Read staged commit meta-info from the local repository.")
+@click.option('-v', '--verbose', count=True, default=0,
+ help="Verbosity, more v's for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv]", )
+@click.option('-s', '--silent', help="Silent mode (no output). Takes precedence over -v, -vv, -vvv.", is_flag=True)
+@click.option('-d', '--debug', help="Enable debugging output.", is_flag=True)
+@click.version_option(version=gitlint.__version__)
+@click.pass_context
+def cli( # pylint: disable=too-many-arguments
+ ctx, target, config, c, commits, extra_path, ignore, contrib,
+ msg_filename, ignore_stdin, staged, verbose, silent, debug,
+):
+ """ Git lint tool, checks your git commit messages for styling issues
+
+ Documentation: http://jorisroovers.github.io/gitlint
+ """
+
+ try:
+ if debug:
+ logging.getLogger("gitlint").setLevel(logging.DEBUG)
+ LOG.debug("To report issues, please visit https://github.com/jorisroovers/gitlint/issues")
+
+ log_system_info()
+
+ # Get the lint config from the commandline parameters and
+ # store it in the context (click allows storing an arbitrary object in ctx.obj).
+ config, config_builder = build_config(target, config, c, extra_path, ignore, contrib,
+ ignore_stdin, staged, verbose, silent, debug)
+ LOG.debug(u"Configuration\n%s", ustr(config))
+
+ ctx.obj = (config, config_builder, commits, msg_filename)
+
+ # If no subcommand is specified, then just lint
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(lint)
+
+ except GitContextError as e:
+ click.echo(ustr(e))
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+ except GitLintUsageError as e:
+ click.echo(u"Error: {0}".format(ustr(e)))
+ ctx.exit(USAGE_ERROR_CODE)
+ except LintConfigError as e:
+ click.echo(u"Config Error: {0}".format(ustr(e)))
+ ctx.exit(CONFIG_ERROR_CODE)
+
+
+@cli.command("lint")
+@click.pass_context
+def lint(ctx):
+ """ Lints a git repository [default command] """
+ lint_config = ctx.obj[0]
+ refspec = ctx.obj[2]
+ msg_filename = ctx.obj[3]
+
+ gitcontext = build_git_context(lint_config, msg_filename, refspec)
+
+ number_of_commits = len(gitcontext.commits)
+ # Exit if we don't have commits in the specified range. Use a 0 exit code, since a popular use-case is one
+ # where users are using --commits in a check job to check the commit messages inside a CI job. By returning 0, we
+ # ensure that these jobs don't fail if for whatever reason the specified commit range is empty.
+ if number_of_commits == 0:
+ LOG.debug(u'No commits in range "%s"', refspec)
+ ctx.exit(0)
+
+ LOG.debug(u'Linting %d commit(s)', number_of_commits)
+ general_config_builder = ctx.obj[1]
+ last_commit = gitcontext.commits[-1]
+
+ # Let's get linting!
+ first_violation = True
+ exit_code = 0
+ for commit in gitcontext.commits:
+ # Build a config_builder taking into account the commit specific config (if any)
+ config_builder = general_config_builder.clone()
+ config_builder.set_config_from_commit(commit)
+
+ # Create a deepcopy from the original config, so we have a unique config object per commit
+ # This is important for configuration rules to be able to modifying the config on a per commit basis
+ commit_config = config_builder.build(copy.deepcopy(lint_config))
+
+ # Actually do the linting
+ linter = GitLinter(commit_config)
+ violations = linter.lint(commit)
+ # exit code equals the total number of violations in all commits
+ exit_code += len(violations)
+ if violations:
+ # Display the commit hash & new lines intelligently
+ if number_of_commits > 1 and commit.sha:
+ linter.display.e(u"{0}Commit {1}:".format(
+ "\n" if not first_violation or commit is last_commit else "",
+ commit.sha[:10]
+ ))
+ linter.print_violations(violations)
+ first_violation = False
+
+ # cap actual max exit code because bash doesn't like exit codes larger than 255:
+ # http://tldp.org/LDP/abs/html/exitcodes.html
+ exit_code = min(MAX_VIOLATION_ERROR_CODE, exit_code)
+ LOG.debug("Exit Code = %s", exit_code)
+ ctx.exit(exit_code)
+
+
+@cli.command("install-hook")
+@click.pass_context
+def install_hook(ctx):
+ """ Install gitlint as a git commit-msg hook. """
+ try:
+ lint_config = ctx.obj[0]
+ hooks.GitHookInstaller.install_commit_msg_hook(lint_config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(lint_config)
+ click.echo(u"Successfully installed gitlint commit-msg hook in {0}".format(hook_path))
+ ctx.exit(0)
+ except hooks.GitHookInstallerError as e:
+ click.echo(ustr(e), err=True)
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+
+
+@cli.command("uninstall-hook")
+@click.pass_context
+def uninstall_hook(ctx):
+ """ Uninstall gitlint commit-msg hook. """
+ try:
+ lint_config = ctx.obj[0]
+ hooks.GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(lint_config)
+ click.echo(u"Successfully uninstalled gitlint commit-msg hook from {0}".format(hook_path))
+ ctx.exit(0)
+ except hooks.GitHookInstallerError as e:
+ click.echo(ustr(e), err=True)
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+
+
+@cli.command("generate-config")
+@click.pass_context
+def generate_config(ctx):
+ """ Generates a sample gitlint config file. """
+ path = click.prompt('Please specify a location for the sample gitlint config file', default=DEFAULT_CONFIG_FILE)
+ path = os.path.realpath(path)
+ dir_name = os.path.dirname(path)
+ if not os.path.exists(dir_name):
+ click.echo(u"Error: Directory '{0}' does not exist.".format(dir_name), err=True)
+ ctx.exit(USAGE_ERROR_CODE)
+ elif os.path.exists(path):
+ click.echo(u"Error: File \"{0}\" already exists.".format(path), err=True)
+ ctx.exit(USAGE_ERROR_CODE)
+
+ LintConfigGenerator.generate_config(path)
+ click.echo(u"Successfully generated {0}".format(path))
+ ctx.exit(0)
+
+
+# Let's Party!
+setup_logging()
+if __name__ == "__main__":
+ # pylint: disable=no-value-for-parameter
+ cli() # pragma: no cover
diff --git a/gitlint/config.py b/gitlint/config.py
new file mode 100644
index 0000000..914357e
--- /dev/null
+++ b/gitlint/config.py
@@ -0,0 +1,482 @@
+try:
+ # python 2.x
+ from ConfigParser import ConfigParser, Error as ConfigParserError
+except ImportError: # pragma: no cover
+ # python 3.x
+ from configparser import ConfigParser, Error as ConfigParserError # pragma: no cover, pylint: disable=import-error
+
+import copy
+import io
+import re
+import os
+import shutil
+
+from collections import OrderedDict
+from gitlint.utils import ustr, DEFAULT_ENCODING
+from gitlint import rules # For some weird reason pylint complains about this, pylint: disable=unused-import
+from gitlint import options
+from gitlint import rule_finder
+from gitlint.contrib import rules as contrib_rules
+
+
+def handle_option_error(func):
+ """ Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a
+ LintConfigError. """
+
+ def wrapped(*args):
+ try:
+ return func(*args)
+ except options.RuleOptionError as e:
+ raise LintConfigError(ustr(e))
+
+ return wrapped
+
+
+class LintConfigError(Exception):
+ pass
+
+
+class LintConfig(object):
+ """ Class representing gitlint configuration.
+ Contains active config as well as number of methods to easily get/set the config.
+ """
+
+ # Default tuple of rule classes (tuple because immutable).
+ default_rule_classes = (rules.IgnoreByTitle,
+ rules.IgnoreByBody,
+ rules.TitleMaxLength,
+ rules.TitleTrailingWhitespace,
+ rules.TitleLeadingWhitespace,
+ rules.TitleTrailingPunctuation,
+ rules.TitleHardTab,
+ rules.TitleMustNotContainWord,
+ rules.TitleRegexMatches,
+ rules.BodyMaxLineLength,
+ rules.BodyMinLength,
+ rules.BodyMissing,
+ rules.BodyTrailingWhitespace,
+ rules.BodyHardTab,
+ rules.BodyFirstLineEmpty,
+ rules.BodyChangedFileMention,
+ rules.AuthorValidEmail)
+
+ def __init__(self):
+ self.rules = RuleCollection(self.default_rule_classes)
+ self._verbosity = options.IntOption('verbosity', 3, "Verbosity")
+ self._ignore_merge_commits = options.BoolOption('ignore-merge-commits', True, "Ignore merge commits")
+ self._ignore_fixup_commits = options.BoolOption('ignore-fixup-commits', True, "Ignore fixup commits")
+ self._ignore_squash_commits = options.BoolOption('ignore-squash-commits', True, "Ignore squash commits")
+ self._ignore_revert_commits = options.BoolOption('ignore-revert-commits', True, "Ignore revert commits")
+ self._debug = options.BoolOption('debug', False, "Enable debug mode")
+ self._extra_path = None
+ target_description = "Path of the target git repository (default=current working directory)"
+ self._target = options.PathOption('target', os.path.realpath(os.getcwd()), target_description)
+ self._ignore = options.ListOption('ignore', [], 'List of rule-ids to ignore')
+ self._contrib = options.ListOption('contrib', [], 'List of contrib-rules to enable')
+ self._config_path = None
+ ignore_stdin_description = "Ignore any stdin data. Useful for running in CI server."
+ self._ignore_stdin = options.BoolOption('ignore-stdin', False, ignore_stdin_description)
+ self._staged = options.BoolOption('staged', False, "Read staged commit meta-info from the local repository.")
+
+ @property
+ def target(self):
+ return self._target.value if self._target else None
+
+ @target.setter
+ @handle_option_error
+ def target(self, value):
+ return self._target.set(value)
+
+ @property
+ def verbosity(self):
+ return self._verbosity.value
+
+ @verbosity.setter
+ @handle_option_error
+ def verbosity(self, value):
+ self._verbosity.set(value)
+ if self.verbosity < 0 or self.verbosity > 3:
+ raise LintConfigError("Option 'verbosity' must be set between 0 and 3")
+
+ @property
+ def ignore_merge_commits(self):
+ return self._ignore_merge_commits.value
+
+ @ignore_merge_commits.setter
+ @handle_option_error
+ def ignore_merge_commits(self, value):
+ return self._ignore_merge_commits.set(value)
+
+ @property
+ def ignore_fixup_commits(self):
+ return self._ignore_fixup_commits.value
+
+ @ignore_fixup_commits.setter
+ @handle_option_error
+ def ignore_fixup_commits(self, value):
+ return self._ignore_fixup_commits.set(value)
+
+ @property
+ def ignore_squash_commits(self):
+ return self._ignore_squash_commits.value
+
+ @ignore_squash_commits.setter
+ @handle_option_error
+ def ignore_squash_commits(self, value):
+ return self._ignore_squash_commits.set(value)
+
+ @property
+ def ignore_revert_commits(self):
+ return self._ignore_revert_commits.value
+
+ @ignore_revert_commits.setter
+ @handle_option_error
+ def ignore_revert_commits(self, value):
+ return self._ignore_revert_commits.set(value)
+
+ @property
+ def debug(self):
+ return self._debug.value
+
+ @debug.setter
+ @handle_option_error
+ def debug(self, value):
+ return self._debug.set(value)
+
+ @property
+ def ignore(self):
+ return self._ignore.value
+
+ @ignore.setter
+ def ignore(self, value):
+ if value == "all":
+ value = [rule.id for rule in self.rules]
+ return self._ignore.set(value)
+
+ @property
+ def ignore_stdin(self):
+ return self._ignore_stdin.value
+
+ @ignore_stdin.setter
+ @handle_option_error
+ def ignore_stdin(self, value):
+ return self._ignore_stdin.set(value)
+
+ @property
+ def staged(self):
+ return self._staged.value
+
+ @staged.setter
+ @handle_option_error
+ def staged(self, value):
+ return self._staged.set(value)
+
+ @property
+ def extra_path(self):
+ return self._extra_path.value if self._extra_path else None
+
+ @extra_path.setter
+ def extra_path(self, value):
+ try:
+ if self.extra_path:
+ self._extra_path.set(value)
+ else:
+ self._extra_path = options.PathOption(
+ 'extra-path', value,
+ "Path to a directory or module with extra user-defined rules",
+ type='both'
+ )
+
+ # Make sure we unload any previously loaded extra-path rules
+ self.rules.delete_rules_by_attr("is_user_defined", True)
+
+ # Find rules in the new extra-path and add them to the existing rules
+ rule_classes = rule_finder.find_rule_classes(self.extra_path)
+ self.rules.add_rules(rule_classes, {'is_user_defined': True})
+
+ except (options.RuleOptionError, rules.UserRuleError) as e:
+ raise LintConfigError(ustr(e))
+
+ @property
+ def contrib(self):
+ return self._contrib.value
+
+ @contrib.setter
+ def contrib(self, value):
+ try:
+ self._contrib.set(value)
+
+ # Make sure we unload any previously loaded contrib rules when re-setting the value
+ self.rules.delete_rules_by_attr("is_contrib", True)
+
+ # Load all classes from the contrib directory
+ contrib_dir_path = os.path.dirname(os.path.realpath(contrib_rules.__file__))
+ rule_classes = rule_finder.find_rule_classes(contrib_dir_path)
+
+ # For each specified contrib rule, check whether it exists among the contrib classes
+ for rule_id_or_name in self.contrib:
+ rule_class = next((rc for rc in rule_classes if
+ rc.id == ustr(rule_id_or_name) or rc.name == ustr(rule_id_or_name)), False)
+
+ # If contrib rule exists, instantiate it and add it to the rules list
+ if rule_class:
+ self.rules.add_rule(rule_class, rule_class.id, {'is_contrib': True})
+ else:
+ raise LintConfigError(u"No contrib rule with id or name '{0}' found.".format(ustr(rule_id_or_name)))
+
+ except (options.RuleOptionError, rules.UserRuleError) as e:
+ raise LintConfigError(ustr(e))
+
+ def _get_option(self, rule_name_or_id, option_name):
+ rule_name_or_id = ustr(rule_name_or_id) # convert to unicode first
+ option_name = ustr(option_name)
+ rule = self.rules.find_rule(rule_name_or_id)
+ if not rule:
+ raise LintConfigError(u"No such rule '{0}'".format(rule_name_or_id))
+
+ option = rule.options.get(option_name)
+ if not option:
+ raise LintConfigError(u"Rule '{0}' has no option '{1}'".format(rule_name_or_id, option_name))
+
+ return option
+
+ def get_rule_option(self, rule_name_or_id, option_name):
+ """ Returns the value of a given option for a given rule. LintConfigErrors will be raised if the
+ rule or option don't exist. """
+ option = self._get_option(rule_name_or_id, option_name)
+ return option.value
+
+ def set_rule_option(self, rule_name_or_id, option_name, option_value):
+ """ Attempts to set a given value for a given option for a given rule.
+ LintConfigErrors will be raised if the rule or option don't exist or if the value is invalid. """
+ option = self._get_option(rule_name_or_id, option_name)
+ try:
+ option.set(option_value)
+ except options.RuleOptionError as e:
+ msg = u"'{0}' is not a valid value for option '{1}.{2}'. {3}."
+ raise LintConfigError(msg.format(option_value, rule_name_or_id, option_name, ustr(e)))
+
+ def set_general_option(self, option_name, option_value):
+ attr_name = option_name.replace("-", "_")
+ # only allow setting general options that exist and don't start with an underscore
+ if not hasattr(self, attr_name) or attr_name[0] == "_":
+ raise LintConfigError(u"'{0}' is not a valid gitlint option".format(option_name))
+
+ # else:
+ setattr(self, attr_name, option_value)
+
+ def __eq__(self, other):
+ return isinstance(other, LintConfig) and \
+ self.rules == other.rules and \
+ self.verbosity == other.verbosity and \
+ self.target == other.target and \
+ self.extra_path == other.extra_path and \
+ self.contrib == other.contrib and \
+ self.ignore_merge_commits == other.ignore_merge_commits and \
+ self.ignore_fixup_commits == other.ignore_fixup_commits and \
+ self.ignore_squash_commits == other.ignore_squash_commits and \
+ self.ignore_revert_commits == other.ignore_revert_commits and \
+ self.ignore_stdin == other.ignore_stdin and \
+ self.staged == other.staged and \
+ self.debug == other.debug and \
+ self.ignore == other.ignore and \
+ self._config_path == other._config_path # noqa
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+ def __str__(self):
+ # config-path is not a user exposed variable, so don't print it under the general section
+ return_str = u"config-path: {0}\n".format(self._config_path)
+ return_str += u"[GENERAL]\n"
+ return_str += u"extra-path: {0}\n".format(self.extra_path)
+ return_str += u"contrib: {0}\n".format(self.contrib)
+ return_str += u"ignore: {0}\n".format(",".join(self.ignore))
+ return_str += u"ignore-merge-commits: {0}\n".format(self.ignore_merge_commits)
+ return_str += u"ignore-fixup-commits: {0}\n".format(self.ignore_fixup_commits)
+ return_str += u"ignore-squash-commits: {0}\n".format(self.ignore_squash_commits)
+ return_str += u"ignore-revert-commits: {0}\n".format(self.ignore_revert_commits)
+ return_str += u"ignore-stdin: {0}\n".format(self.ignore_stdin)
+ return_str += u"staged: {0}\n".format(self.staged)
+ return_str += u"verbosity: {0}\n".format(self.verbosity)
+ return_str += u"debug: {0}\n".format(self.debug)
+ return_str += u"target: {0}\n".format(self.target)
+ return_str += u"[RULES]\n{0}".format(self.rules)
+ return return_str
+
+
+class RuleCollection(object):
+ """ Class representing an ordered list of rules. Methods are provided to easily retrieve, add or delete rules. """
+
+ def __init__(self, rule_classes=None, rule_attrs=None):
+ # Use an ordered dict so that the order in which rules are applied is always the same
+ self._rules = OrderedDict()
+ if rule_classes:
+ self.add_rules(rule_classes, rule_attrs)
+
+ def find_rule(self, rule_id_or_name):
+ # try finding rule by id
+ rule_id_or_name = ustr(rule_id_or_name) # convert to unicode first
+ rule = self._rules.get(rule_id_or_name)
+ # if not found, try finding rule by name
+ if not rule:
+ rule = next((rule for rule in self._rules.values() if rule.name == rule_id_or_name), None)
+ return rule
+
+ def add_rule(self, rule_class, rule_id, rule_attrs=None):
+ """ Instantiates and adds a rule to RuleCollection.
+ Note: There can be multiple instantiations of the same rule_class in the RuleCollection, as long as the
+ rule_id is unique.
+ :param rule_class python class representing the rule
+ :param rule_id unique identifier for the rule. If not unique, it will
+ overwrite the existing rule with that id
+ :param rule_attrs dictionary of attributes to set on the instantiated rule obj
+ """
+ rule_obj = rule_class()
+ rule_obj.id = rule_id
+ if rule_attrs:
+ for key, val in rule_attrs.items():
+ setattr(rule_obj, key, val)
+ self._rules[rule_obj.id] = rule_obj
+
+ def add_rules(self, rule_classes, rule_attrs=None):
+ """ Convenience method to add multiple rules at once based on a list of rule classes. """
+ for rule_class in rule_classes:
+ self.add_rule(rule_class, rule_class.id, rule_attrs)
+
+ def delete_rules_by_attr(self, attr_name, attr_val):
+ """ Deletes all rules from the collection that match a given attribute name and value """
+ # Create a new list based on _rules.values() because in python 3, values() is a ValuesView as opposed to a list
+ # This means you can't modify the ValueView while iterating over it.
+ for rule in [r for r in self._rules.values()]:
+ if hasattr(rule, attr_name) and (getattr(rule, attr_name) == attr_val):
+ del self._rules[rule.id]
+
+ def __iter__(self):
+ for rule in self._rules.values():
+ yield rule
+
+ def __eq__(self, other):
+ return isinstance(other, RuleCollection) and self._rules == other._rules
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+ def __len__(self):
+ return len(self._rules)
+
+ def __str__(self):
+ return_str = ""
+ for rule in self._rules.values():
+ return_str += u" {0}: {1}\n".format(rule.id, rule.name)
+ for option_name, option_value in sorted(rule.options.items()):
+ if isinstance(option_value.value, list):
+ option_val_repr = ",".join(option_value.value)
+ else:
+ option_val_repr = option_value.value
+ return_str += u" {0}={1}\n".format(option_name, option_val_repr)
+ return return_str
+
+
+class LintConfigBuilder(object):
+ """ Factory class that can build gitlint config.
+ This is primarily useful to deal with complex configuration scenarios where configuration can be set and overridden
+ from various sources (typically according to certain precedence rules) before the actual config should be
+ normalized, validated and build. Example usage can be found in gitlint.cli.
+ """
+
+ def __init__(self):
+ self._config_blueprint = {}
+ self._config_path = None
+
+ def set_option(self, section, option_name, option_value):
+ if section not in self._config_blueprint:
+ self._config_blueprint[section] = {}
+ self._config_blueprint[section][option_name] = option_value
+
+ def set_config_from_commit(self, commit):
+ """ Given a git commit, applies config specified in the commit message.
+ Supported:
+ - gitlint-ignore: all
+ """
+ for line in commit.message.body:
+ pattern = re.compile(r"^gitlint-ignore:\s*(.*)")
+ matches = pattern.match(line)
+ if matches and len(matches.groups()) == 1:
+ self.set_option('general', 'ignore', matches.group(1))
+
+ def set_config_from_string_list(self, config_options):
+ """ Given a list of config options of the form "<rule>.<option>=<value>", parses out the correct rule and option
+ and sets the value accordingly in this factory object. """
+ for config_option in config_options:
+ try:
+ config_name, option_value = config_option.split("=", 1)
+ if not option_value:
+ raise ValueError()
+ rule_name, option_name = config_name.split(".", 1)
+ self.set_option(rule_name, option_name, option_value)
+ except ValueError: # raised if the config string is invalid
+ raise LintConfigError(
+ u"'{0}' is an invalid configuration option. Use '<rule>.<option>=<value>'".format(config_option))
+
+ def set_from_config_file(self, filename):
+ """ Loads lint config from a ini-style config file """
+ if not os.path.exists(filename):
+ raise LintConfigError(u"Invalid file path: {0}".format(filename))
+ self._config_path = os.path.realpath(filename)
+ try:
+ parser = ConfigParser()
+
+ with io.open(filename, encoding=DEFAULT_ENCODING) as config_file:
+ # readfp() is deprecated in python 3.2+, but compatible with 2.7
+ parser.readfp(config_file, filename) # pylint: disable=deprecated-method
+
+ for section_name in parser.sections():
+ for option_name, option_value in parser.items(section_name):
+ self.set_option(section_name, option_name, ustr(option_value))
+
+ except ConfigParserError as e:
+ raise LintConfigError(ustr(e))
+
+ def build(self, config=None):
+ """ Build a real LintConfig object by normalizing and validating the options that were previously set on this
+ factory. """
+
+ # If we are passed a config object, then rebuild that object instead of building a new lintconfig object from
+ # scratch
+ if not config:
+ config = LintConfig()
+
+ config._config_path = self._config_path
+
+ # Set general options first as this might change the behavior or validity of the other options
+ general_section = self._config_blueprint.get('general')
+ if general_section:
+ for option_name, option_value in general_section.items():
+ config.set_general_option(option_name, option_value)
+
+ for section_name, section_dict in self._config_blueprint.items():
+ for option_name, option_value in section_dict.items():
+ # Skip over the general section, as we've already done that above
+ if section_name != "general":
+ config.set_rule_option(section_name, option_name, option_value)
+
+ return config
+
+ def clone(self):
+ """ Creates an exact copy of a LintConfigBuilder. """
+ builder = LintConfigBuilder()
+ builder._config_blueprint = copy.deepcopy(self._config_blueprint)
+ builder._config_path = self._config_path
+ return builder
+
+
+GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/gitlint")
+
+
+class LintConfigGenerator(object):
+ @staticmethod
+ def generate_config(dest):
+ """ Generates a gitlint config file at the given destination location.
+ Expects that the given ```dest``` points to a valid destination. """
+ shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest)
diff --git a/gitlint/contrib/__init__.py b/gitlint/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/contrib/__init__.py
diff --git a/gitlint/contrib/rules/__init__.py b/gitlint/contrib/rules/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/contrib/rules/__init__.py
diff --git a/gitlint/contrib/rules/conventional_commit.py b/gitlint/contrib/rules/conventional_commit.py
new file mode 100644
index 0000000..3bbbd0f
--- /dev/null
+++ b/gitlint/contrib/rules/conventional_commit.py
@@ -0,0 +1,39 @@
+import re
+
+from gitlint.options import ListOption
+from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
+from gitlint.utils import ustr
+
+RULE_REGEX = re.compile(r"[^(]+?(\([^)]+?\))?: .+")
+
+
+class ConventionalCommit(LineRule):
+ """ This rule enforces the spec at https://www.conventionalcommits.org/. """
+
+ name = "contrib-title-conventional-commits"
+ id = "CT1"
+ target = CommitMessageTitle
+
+ options_spec = [
+ ListOption(
+ "types",
+ ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"],
+ "Comma separated list of allowed commit types.",
+ )
+ ]
+
+ def validate(self, line, _commit):
+ violations = []
+
+ for commit_type in self.options["types"].value:
+ if line.startswith(ustr(commit_type)):
+ break
+ else:
+ msg = u"Title does not start with one of {0}".format(', '.join(self.options['types'].value))
+ violations.append(RuleViolation(self.id, msg, line))
+
+ if not RULE_REGEX.match(line):
+ msg = u"Title does not follow ConventionalCommits.org format 'type(optional-scope): description'"
+ violations.append(RuleViolation(self.id, msg, line))
+
+ return violations
diff --git a/gitlint/contrib/rules/signedoff_by.py b/gitlint/contrib/rules/signedoff_by.py
new file mode 100644
index 0000000..c2034e7
--- /dev/null
+++ b/gitlint/contrib/rules/signedoff_by.py
@@ -0,0 +1,18 @@
+
+from gitlint.rules import CommitRule, RuleViolation
+
+
+class SignedOffBy(CommitRule):
+ """ This rule will enforce that each commit body contains a "Signed-Off-By" line.
+ We keep things simple here and just check whether the commit body contains a line that starts with "Signed-Off-By".
+ """
+
+ name = "contrib-body-requires-signed-off-by"
+ id = "CC1"
+
+ def validate(self, commit):
+ for line in commit.message.body:
+ if line.startswith("Signed-Off-By"):
+ return []
+
+ return [RuleViolation(self.id, "Body does not contain a 'Signed-Off-By' line", line_nr=1)]
diff --git a/gitlint/display.py b/gitlint/display.py
new file mode 100644
index 0000000..dd17ac0
--- /dev/null
+++ b/gitlint/display.py
@@ -0,0 +1,46 @@
+import codecs
+import locale
+from sys import stdout, stderr, version_info
+
+# For some reason, python 2.x sometimes messes up with printing unicode chars to stdout/stderr
+# This is mostly when there is a mismatch between the terminal encoding and the python encoding.
+# This use-case is primarily triggered when piping input between commands, in particular our integration tests
+# tend to trip over this.
+if version_info[0] == 2:
+ stdout = codecs.getwriter(locale.getpreferredencoding())(stdout) # pylint: disable=invalid-name
+ stderr = codecs.getwriter(locale.getpreferredencoding())(stderr) # pylint: disable=invalid-name
+
+
+class Display(object):
+ """ Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity """
+
+ def __init__(self, lint_config):
+ self.config = lint_config
+
+ def _output(self, message, verbosity, exact, stream):
+ """ Output a message if the config's verbosity is >= to the given verbosity. If exact == True, the message
+ will only be outputted if the given verbosity exactly matches the config's verbosity. """
+ if exact:
+ if self.config.verbosity == verbosity:
+ stream.write(message + "\n")
+ else:
+ if self.config.verbosity >= verbosity:
+ stream.write(message + "\n")
+
+ def v(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 1, exact, stdout)
+
+ def vv(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 2, exact, stdout)
+
+ def vvv(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 3, exact, stdout)
+
+ def e(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 1, exact, stderr)
+
+ def ee(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 2, exact, stderr)
+
+ def eee(self, message, exact=False): # pylint: disable=invalid-name
+ self._output(message, 3, exact, stderr)
diff --git a/gitlint/files/commit-msg b/gitlint/files/commit-msg
new file mode 100644
index 0000000..e468290
--- /dev/null
+++ b/gitlint/files/commit-msg
@@ -0,0 +1,81 @@
+#!/bin/sh
+### gitlint commit-msg hook start ###
+
+# Determine whether we have a tty available by trying to access it.
+# This allows us to deal with UI based gitclient's like Atlassian SourceTree.
+# NOTE: "exec < /dev/tty" sets stdin to the keyboard
+stdin_available=1
+(exec < /dev/tty) 2> /dev/null || stdin_available=0
+
+if [ $stdin_available -eq 1 ]; then
+ # Set bash color codes in case we have a tty
+ RED="\033[31m"
+ YELLOW="\033[33m"
+ GREEN="\033[32m"
+ END_COLOR="\033[0m"
+
+ # Now that we know we have a functional tty, set stdin to it so we can ask the user questions :-)
+ exec < /dev/tty
+else
+ # Unset bash colors if we don't have a tty
+ RED=""
+ YELLOW=""
+ GREEN=""
+ END_COLOR=""
+fi
+
+run_gitlint(){
+ echo "gitlint: checking commit message..."
+ python -m gitlint.cli --staged --msg-filename "$1"
+ gitlint_exit_code=$?
+}
+
+# Prompts a given yes/no question.
+# Returns 0 if user answers yes, 1 if no
+# Reprompts if different answer
+ask_yes_no_edit(){
+ ask_yes_no_edit_result="no"
+ # If we don't have a stdin available, then just return "No".
+ if [ $stdin_available -eq 0 ]; then
+ ask_yes_no_edit_result="no"
+ return;
+ fi
+ # Otherwise, ask the question until the user answers yes or no
+ question="$1"
+ while true; do
+ read -p "$question" yn
+ case $yn in
+ [Yy]* ) ask_yes_no_edit_result="yes"; return;;
+ [Nn]* ) ask_yes_no_edit_result="no"; return;;
+ [Ee]* ) ask_yes_no_edit_result="edit"; return;;
+ esac
+ done
+}
+
+run_gitlint "$1"
+
+while [ $gitlint_exit_code -gt 0 ]; do
+ echo "-----------------------------------------------"
+ echo "gitlint: ${RED}Your commit message contains the above violations.${END_COLOR}"
+ ask_yes_no_edit "Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] "
+ if [ $ask_yes_no_edit_result = "yes" ]; then
+ exit 0
+ elif [ $ask_yes_no_edit_result = "edit" ]; then
+ EDITOR=${EDITOR:-vim}
+ $EDITOR "$1"
+ run_gitlint "$1"
+ else
+ echo "Commit aborted."
+ echo "Your commit message: "
+ echo "-----------------------------------------------"
+ cat "$1"
+ echo "-----------------------------------------------"
+
+ exit $gitlint_exit_code
+ fi
+done
+
+echo "gitlint: ${GREEN}OK${END_COLOR} (no violations in commit message)"
+exit 0
+
+### gitlint commit-msg hook end ###
diff --git a/gitlint/files/gitlint b/gitlint/files/gitlint
new file mode 100644
index 0000000..15a6626
--- /dev/null
+++ b/gitlint/files/gitlint
@@ -0,0 +1,106 @@
+# Edit this file as you like.
+#
+# All these sections are optional. Each section with the exception of [general] represents
+# one rule and each key in it is an option for that specific rule.
+#
+# Rules and sections can be referenced by their full name or by id. For example
+# section "[body-max-line-length]" could be written as "[B1]". Full section names are
+# used in here for clarity.
+#
+# [general]
+# Ignore certain rules, this example uses both full name and id
+# ignore=title-trailing-punctuation, T3
+
+# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
+# verbosity = 2
+
+# By default gitlint will ignore merge, revert, fixup and squash commits.
+# ignore-merge-commits=true
+# ignore-revert-commits=true
+# ignore-fixup-commits=true
+# ignore-squash-commits=true
+
+# Ignore any data send to gitlint via stdin
+# ignore-stdin=true
+
+# Fetch additional meta-data from the local repository when manually passing a
+# commit message to gitlint via stdin or --commit-msg. Disabled by default.
+# staged=true
+
+# Enable debug mode (prints more output). Disabled by default.
+# debug=true
+
+# Enable community contributed rules
+# See http://jorisroovers.github.io/gitlint/contrib_rules for details
+# contrib=contrib-title-conventional-commits,CC1
+
+# Set the extra-path where gitlint will search for user defined rules
+# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
+# extra-path=examples/
+
+# This is an example of how to configure the "title-max-length" rule and
+# set the line-length it enforces to 80
+# [title-max-length]
+# line-length=50
+
+# [title-must-not-contain-word]
+# Comma-separated list of words that should not occur in the title. Matching is case
+# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
+# will not cause a violation, but "WIP: my title" will.
+# words=wip
+
+# [title-match-regex]
+# python like regex (https://docs.python.org/2/library/re.html) that the
+# commit-msg title must be matched to.
+# Note that the regex can contradict with other rules if not used correctly
+# (e.g. title-must-not-contain-word).
+# regex=^US[0-9]*
+
+# [body-max-line-length]
+# line-length=72
+
+# [body-min-length]
+# min-length=5
+
+# [body-is-missing]
+# Whether to ignore this rule on merge commits (which typically only have a title)
+# default = True
+# ignore-merge-commits=false
+
+# [body-changed-file-mention]
+# List of files that need to be explicitly mentioned in the body when they are changed
+# This is useful for when developers often erroneously edit certain files or git submodules.
+# By specifying this rule, developers can only change the file when they explicitly reference
+# it in the commit message.
+# files=gitlint/rules.py,README.md
+
+# [author-valid-email]
+# python like regex (https://docs.python.org/2/library/re.html) that the
+# commit author email address should be matched to
+# For example, use the following regex if you only want to allow email addresses from foo.com
+# regex=[^@]+@foo.com
+
+# [ignore-by-title]
+# Ignore certain rules for commits of which the title matches a regex
+# E.g. Match commit titles that start with "Release"
+# regex=^Release(.*)
+
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+# ignore=T1,body-min-length
+
+# [ignore-by-body]
+# Ignore certain rules for commits of which the body has a line that matches a regex
+# E.g. Match bodies that have a line that that contain "release"
+# regex=(.*)release(.*)
+#
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+# ignore=T1,body-min-length
+
+# This is a contrib rule - a community contributed rule. These are disabled by default.
+# You need to explicitly enable them one-by-one by adding them to the "contrib" option
+# under [general] section above.
+# [contrib-title-conventional-commits]
+# Specify allowed commit types. For details see: https://www.conventionalcommits.org/
+# types = bugfix,user-story,epic \ No newline at end of file
diff --git a/gitlint/git.py b/gitlint/git.py
new file mode 100644
index 0000000..ca7ad92
--- /dev/null
+++ b/gitlint/git.py
@@ -0,0 +1,395 @@
+import os
+import arrow
+
+from gitlint import shell as sh
+# import exceptions separately, this makes it a little easier to mock them out in the unit tests
+from gitlint.shell import CommandNotFound, ErrorReturnCode
+
+from gitlint.cache import PropertyCache, cache
+from gitlint.utils import ustr, sstr
+
+# For now, the git date format we use is fixed, but technically this format is determined by `git config log.date`
+# We should fix this at some point :-)
+GIT_TIMEFORMAT = "YYYY-MM-DD HH:mm:ss Z"
+
+
+class GitContextError(Exception):
+ """ Exception indicating there is an issue with the git context """
+ pass
+
+
+class GitNotInstalledError(GitContextError):
+ def __init__(self):
+ super(GitNotInstalledError, self).__init__(
+ u"'git' command not found. You need to install git to use gitlint on a local repository. " +
+ u"See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git.")
+
+
+def _git(*command_parts, **kwargs):
+ """ Convenience function for running git commands. Automatically deals with exceptions and unicode. """
+ git_kwargs = {'_tty_out': False}
+ git_kwargs.update(kwargs)
+ try:
+ result = sh.git(*command_parts, **git_kwargs) # pylint: disable=unexpected-keyword-arg
+ # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
+ # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
+ # a non-zero exit code -> just return the entire result
+ if hasattr(result, 'exit_code') and result.exit_code > 0:
+ return result
+ return ustr(result)
+ except CommandNotFound:
+ raise GitNotInstalledError()
+ except ErrorReturnCode as e: # Something went wrong while executing the git command
+ error_msg = e.stderr.strip()
+ error_msg_lower = error_msg.lower()
+ if '_cwd' in git_kwargs and b"not a git repository" in error_msg_lower:
+ error_msg = u"{0} is not a git repository.".format(git_kwargs['_cwd'])
+ elif (b"does not have any commits yet" in error_msg_lower or
+ b"ambiguous argument 'head': unknown revision" in error_msg_lower):
+ raise GitContextError(u"Current branch has no commits. Gitlint requires at least one commit to function.")
+ else:
+ error_msg = u"An error occurred while executing '{0}': {1}".format(e.full_cmd, error_msg)
+ raise GitContextError(error_msg)
+
+
+def git_version():
+ """ Determine the git version installed on this host by calling git --version"""
+ return _git("--version").replace(u"\n", u"")
+
+
+def git_commentchar(repository_path=None):
+ """ Shortcut for retrieving comment char from git config """
+ commentchar = _git("config", "--get", "core.commentchar", _cwd=repository_path, _ok_code=[0, 1])
+ # git will return an exit code of 1 if it can't find a config value, in this case we fall-back to # as commentchar
+ if hasattr(commentchar, 'exit_code') and commentchar.exit_code == 1: # pylint: disable=no-member
+ commentchar = "#"
+ return ustr(commentchar).replace(u"\n", u"")
+
+
+def git_hooks_dir(repository_path):
+ """ Determine hooks directory for a given target dir """
+ hooks_dir = _git("rev-parse", "--git-path", "hooks", _cwd=repository_path)
+ hooks_dir = ustr(hooks_dir).replace(u"\n", u"")
+ return os.path.realpath(os.path.join(repository_path, hooks_dir))
+
+
+class GitCommitMessage(object):
+ """ Class representing a git commit message. A commit message consists of the following:
+ - context: The `GitContext` this commit message is part of
+ - original: The actual commit message as returned by `git log`
+ - full: original, but stripped of any comments
+ - title: the first line of full
+ - body: all lines following the title
+ """
+ def __init__(self, context, original=None, full=None, title=None, body=None):
+ self.context = context
+ self.original = original
+ self.full = full
+ self.title = title
+ self.body = body
+
+ @staticmethod
+ def from_full_message(context, commit_msg_str):
+ """ Parses a full git commit message by parsing a given string into the different parts of a commit message """
+ all_lines = commit_msg_str.splitlines()
+ cutline = u"{0} ------------------------ >8 ------------------------".format(context.commentchar)
+ try:
+ cutline_index = all_lines.index(cutline)
+ except ValueError:
+ cutline_index = None
+ lines = [ustr(line) for line in all_lines[:cutline_index] if not line.startswith(context.commentchar)]
+ full = "\n".join(lines)
+ title = lines[0] if lines else ""
+ body = lines[1:] if len(lines) > 1 else []
+ return GitCommitMessage(context=context, original=commit_msg_str, full=full, title=title, body=body)
+
+ def __unicode__(self):
+ return self.full # pragma: no cover
+
+ def __str__(self):
+ return sstr(self.__unicode__()) # pragma: no cover
+
+ def __repr__(self):
+ return self.__str__() # pragma: no cover
+
+ def __eq__(self, other):
+ return (isinstance(other, GitCommitMessage) and self.original == other.original
+ and self.full == other.full and self.title == other.title and self.body == other.body) # noqa
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+
+class GitCommit(object):
+ """ Class representing a git commit.
+ A commit consists of: context, message, author name, author email, date, list of parent commit shas,
+ list of changed files, list of branch names.
+ In the context of gitlint, only the git context and commit message are required.
+ """
+
+ def __init__(self, context, message, sha=None, date=None, author_name=None, # pylint: disable=too-many-arguments
+ author_email=None, parents=None, changed_files=None, branches=None):
+ self.context = context
+ self.message = message
+ self.sha = sha
+ self.date = date
+ self.author_name = author_name
+ self.author_email = author_email
+ self.parents = parents or [] # parent commit hashes
+ self.changed_files = changed_files or []
+ self.branches = branches or []
+
+ @property
+ def is_merge_commit(self):
+ return self.message.title.startswith(u"Merge")
+
+ @property
+ def is_fixup_commit(self):
+ return self.message.title.startswith(u"fixup!")
+
+ @property
+ def is_squash_commit(self):
+ return self.message.title.startswith(u"squash!")
+
+ @property
+ def is_revert_commit(self):
+ return self.message.title.startswith(u"Revert")
+
+ def __unicode__(self):
+ format_str = (u"--- Commit Message ----\n%s\n"
+ u"--- Meta info ---------\n"
+ u"Author: %s <%s>\nDate: %s\n"
+ u"is-merge-commit: %s\nis-fixup-commit: %s\n"
+ u"is-squash-commit: %s\nis-revert-commit: %s\n"
+ u"Branches: %s\n"
+ u"Changed Files: %s\n"
+ u"-----------------------") # pragma: no cover
+ date_str = arrow.get(self.date).format(GIT_TIMEFORMAT) if self.date else None
+ return format_str % (ustr(self.message), self.author_name, self.author_email, date_str,
+ self.is_merge_commit, self.is_fixup_commit, self.is_squash_commit,
+ self.is_revert_commit, sstr(self.branches), sstr(self.changed_files)) # pragma: no cover
+
+ def __str__(self):
+ return sstr(self.__unicode__()) # pragma: no cover
+
+ def __repr__(self):
+ return self.__str__() # pragma: no cover
+
+ def __eq__(self, other):
+ # skip checking the context as context refers back to this obj, this will trigger a cyclic dependency
+ return (isinstance(other, GitCommit) and self.message == other.message
+ and self.sha == other.sha and self.author_name == other.author_name
+ and self.author_email == other.author_email
+ and self.date == other.date and self.parents == other.parents
+ and self.is_merge_commit == other.is_merge_commit and self.is_fixup_commit == other.is_fixup_commit
+ and self.is_squash_commit == other.is_squash_commit and self.is_revert_commit == other.is_revert_commit
+ and self.changed_files == other.changed_files and self.branches == other.branches) # noqa
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+
+class LocalGitCommit(GitCommit, PropertyCache):
+ """ Class representing a git commit that exists in the local git repository.
+ This class uses lazy loading: it defers reading information from the local git repository until the associated
+ property is accessed for the first time. Properties are then cached for subsequent access.
+
+ This approach ensures that we don't do 'expensive' git calls when certain properties are not actually used.
+ In addition, reading the required info when it's needed rather than up front avoids adding delay during gitlint
+ startup time and reduces gitlint's memory footprint.
+ """
+ def __init__(self, context, sha): # pylint: disable=super-init-not-called
+ PropertyCache.__init__(self)
+ self.context = context
+ self.sha = sha
+
+ def _log(self):
+ """ Does a call to `git log` to determine a bunch of information about the commit. """
+ long_format = "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B"
+ raw_commit = _git("log", self.sha, "-1", long_format, _cwd=self.context.repository_path).split("\n")
+
+ (name, email, date, parents), commit_msg = raw_commit[0].split('\x00'), "\n".join(raw_commit[1:])
+
+ commit_parents = parents.split(" ")
+ commit_is_merge_commit = len(commit_parents) > 1
+
+ # "YYYY-MM-DD HH:mm:ss Z" -> ISO 8601-like format
+ # Use arrow for datetime parsing, because apparently python is quirky around ISO-8601 dates:
+ # http://stackoverflow.com/a/30696682/381010
+ commit_date = arrow.get(ustr(date), GIT_TIMEFORMAT).datetime
+
+ # Create Git commit object with the retrieved info
+ commit_msg_obj = GitCommitMessage.from_full_message(self.context, commit_msg)
+
+ self._cache.update({'message': commit_msg_obj, 'author_name': name, 'author_email': email, 'date': commit_date,
+ 'parents': commit_parents, 'is_merge_commit': commit_is_merge_commit})
+
+ @property
+ def message(self):
+ return self._try_cache("message", self._log)
+
+ @property
+ def author_name(self):
+ return self._try_cache("author_name", self._log)
+
+ @property
+ def author_email(self):
+ return self._try_cache("author_email", self._log)
+
+ @property
+ def date(self):
+ return self._try_cache("date", self._log)
+
+ @property
+ def parents(self):
+ return self._try_cache("parents", self._log)
+
+ @property
+ def branches(self):
+ def cache_branches():
+ # We have to parse 'git branch --contains <sha>' instead of 'git for-each-ref' to be compatible with
+ # git versions < 2.7.0
+ # https://stackoverflow.com/questions/45173979/can-i-force-git-branch-contains-tag-to-not-print-the-asterisk
+ branches = _git("branch", "--contains", self.sha, _cwd=self.context.repository_path).split("\n")
+
+ # This means that we need to remove any leading * that indicates the current branch. Note that we can
+ # safely do this since git branches cannot contain '*' anywhere, so if we find an '*' we know it's output
+ # from the git CLI and not part of the branch name. See https://git-scm.com/docs/git-check-ref-format
+ # We also drop the last empty line from the output.
+ self._cache['branches'] = [ustr(branch.replace("*", "").strip()) for branch in branches[:-1]]
+
+ return self._try_cache("branches", cache_branches)
+
+ @property
+ def is_merge_commit(self):
+ return self._try_cache("is_merge_commit", self._log)
+
+ @property
+ def changed_files(self):
+ def cache_changed_files():
+ self._cache['changed_files'] = _git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root",
+ self.sha, _cwd=self.context.repository_path).split()
+
+ return self._try_cache("changed_files", cache_changed_files)
+
+
+class StagedLocalGitCommit(GitCommit, PropertyCache):
+ """ Class representing a git commit that has been staged, but not committed.
+
+ Other than the commit message itself (and changed files), a lot of information is actually not known at staging
+ time, since the commit hasn't happened yet. However, we can make educated guesses based on existing repository
+ information.
+ """
+
+ def __init__(self, context, commit_message): # pylint: disable=super-init-not-called
+ PropertyCache.__init__(self)
+ self.context = context
+ self.message = commit_message
+ self.sha = None
+ self.parents = [] # Not really possible to determine before a commit
+
+ @property
+ @cache
+ def author_name(self):
+ return ustr(_git("config", "--get", "user.name", _cwd=self.context.repository_path)).strip()
+
+ @property
+ @cache
+ def author_email(self):
+ return ustr(_git("config", "--get", "user.email", _cwd=self.context.repository_path)).strip()
+
+ @property
+ @cache
+ def date(self):
+ # We don't know the actual commit date yet, but we make a pragmatic trade-off here by providing the current date
+ # We get current date from arrow, reformat in git date format, then re-interpret it as a date.
+ # This ensure we capture the same precision and timezone information that git does.
+ return arrow.get(arrow.now().format(GIT_TIMEFORMAT), GIT_TIMEFORMAT).datetime
+
+ @property
+ @cache
+ def branches(self):
+ # We don't know the branch this commit will be part of yet, but we're pragmatic here and just return the
+ # current branch, as for all intents and purposes, this will be what the user is looking for.
+ return [self.context.current_branch]
+
+ @property
+ def changed_files(self):
+ return _git("diff", "--staged", "--name-only", "-r", _cwd=self.context.repository_path).split()
+
+
+class GitContext(PropertyCache):
+ """ Class representing the git context in which gitlint is operating: a data object storing information about
+ the git repository that gitlint is linting.
+ """
+
+ def __init__(self, repository_path=None):
+ PropertyCache.__init__(self)
+ self.commits = []
+ self.repository_path = repository_path
+
+ @property
+ @cache
+ def commentchar(self):
+ return git_commentchar(self.repository_path)
+
+ @property
+ @cache
+ def current_branch(self):
+ current_branch = ustr(_git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path)).strip()
+ return current_branch
+
+ @staticmethod
+ def from_commit_msg(commit_msg_str):
+ """ Determines git context based on a commit message.
+ :param commit_msg_str: Full git commit message.
+ """
+ context = GitContext()
+ commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
+ commit = GitCommit(context, commit_msg_obj)
+ context.commits.append(commit)
+ return context
+
+ @staticmethod
+ def from_staged_commit(commit_msg_str, repository_path):
+ """ Determines git context based on a commit message that is a staged commit for a local git repository.
+ :param commit_msg_str: Full git commit message.
+ :param repository_path: Path to the git repository to retrieve the context from
+ """
+ context = GitContext(repository_path=repository_path)
+ commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
+ commit = StagedLocalGitCommit(context, commit_msg_obj)
+ context.commits.append(commit)
+ return context
+
+ @staticmethod
+ def from_local_repository(repository_path, refspec=None):
+ """ Retrieves the git context from a local git repository.
+ :param repository_path: Path to the git repository to retrieve the context from
+ :param refspec: The commit(s) to retrieve
+ """
+
+ context = GitContext(repository_path=repository_path)
+
+ # If no refspec is defined, fallback to the last commit on the current branch
+ if refspec is None:
+ # We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with
+ # repos that only have a single commit - HEAD^... doesn't work there), but then we still get into
+ # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`.
+ sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace(u"\n", u"")]
+ else:
+ sha_list = _git("rev-list", refspec, _cwd=repository_path).split()
+
+ for sha in sha_list:
+ commit = LocalGitCommit(context, sha)
+ context.commits.append(commit)
+
+ return context
+
+ def __eq__(self, other):
+ return (isinstance(other, GitContext) and self.commits == other.commits
+ and self.repository_path == other.repository_path
+ and self.commentchar == other.commentchar and self.current_branch == other.current_branch) # noqa
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
diff --git a/gitlint/hooks.py b/gitlint/hooks.py
new file mode 100644
index 0000000..fc4dc4e
--- /dev/null
+++ b/gitlint/hooks.py
@@ -0,0 +1,62 @@
+import io
+import shutil
+import os
+import stat
+
+from gitlint.utils import DEFAULT_ENCODING
+from gitlint.git import git_hooks_dir
+
+COMMIT_MSG_HOOK_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files", "commit-msg")
+COMMIT_MSG_HOOK_DST_PATH = "commit-msg"
+GITLINT_HOOK_IDENTIFIER = "### gitlint commit-msg hook start ###\n"
+
+
+class GitHookInstallerError(Exception):
+ pass
+
+
+class GitHookInstaller(object):
+ """ Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook. """
+
+ @staticmethod
+ def commit_msg_hook_path(lint_config):
+ return os.path.join(git_hooks_dir(lint_config.target), COMMIT_MSG_HOOK_DST_PATH)
+
+ @staticmethod
+ def _assert_git_repo(target):
+ """ Asserts that a given target directory is a git repository """
+ hooks_dir = git_hooks_dir(target)
+ if not os.path.isdir(hooks_dir):
+ raise GitHookInstallerError(u"{0} is not a git repository.".format(target))
+
+ @staticmethod
+ def install_commit_msg_hook(lint_config):
+ GitHookInstaller._assert_git_repo(lint_config.target)
+ dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
+ if os.path.exists(dest_path):
+ raise GitHookInstallerError(
+ u"There is already a commit-msg hook file present in {0}.\n".format(dest_path) +
+ u"gitlint currently does not support appending to an existing commit-msg file.")
+
+ # copy hook file
+ shutil.copy(COMMIT_MSG_HOOK_SRC_PATH, dest_path)
+ # make hook executable
+ st = os.stat(dest_path)
+ os.chmod(dest_path, st.st_mode | stat.S_IEXEC)
+
+ @staticmethod
+ def uninstall_commit_msg_hook(lint_config):
+ GitHookInstaller._assert_git_repo(lint_config.target)
+ dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
+ if not os.path.exists(dest_path):
+ raise GitHookInstallerError(u"There is no commit-msg hook present in {0}.".format(dest_path))
+
+ with io.open(dest_path, encoding=DEFAULT_ENCODING) as fp:
+ lines = fp.readlines()
+ if len(lines) < 2 or lines[1] != GITLINT_HOOK_IDENTIFIER:
+ msg = u"The commit-msg hook in {0} was not installed by gitlint (or it was modified).\n" + \
+ u"Uninstallation of 3th party or modified gitlint hooks is not supported."
+ raise GitHookInstallerError(msg.format(dest_path))
+
+ # If we are sure it's a gitlint hook, go ahead and remove it
+ os.remove(dest_path)
diff --git a/gitlint/lint.py b/gitlint/lint.py
new file mode 100644
index 0000000..6ef7174
--- /dev/null
+++ b/gitlint/lint.py
@@ -0,0 +1,108 @@
+# pylint: disable=logging-not-lazy
+import logging
+from gitlint import rules as gitlint_rules
+from gitlint import display
+from gitlint.utils import ustr
+
+LOG = logging.getLogger(__name__)
+logging.basicConfig()
+
+
+class GitLinter(object):
+ """ Main linter class. This is where rules actually get applied. See the lint() method. """
+
+ def __init__(self, config):
+ self.config = config
+
+ self.display = display.Display(config)
+
+ def should_ignore_rule(self, rule):
+ """ Determines whether a rule should be ignored based on the general list of commits to ignore """
+ return rule.id in self.config.ignore or rule.name in self.config.ignore
+
+ @property
+ def configuration_rules(self):
+ return [rule for rule in self.config.rules if
+ isinstance(rule, gitlint_rules.ConfigurationRule) and not self.should_ignore_rule(rule)]
+
+ @property
+ def title_line_rules(self):
+ return [rule for rule in self.config.rules if
+ isinstance(rule, gitlint_rules.LineRule) and
+ rule.target == gitlint_rules.CommitMessageTitle and not self.should_ignore_rule(rule)]
+
+ @property
+ def body_line_rules(self):
+ return [rule for rule in self.config.rules if
+ isinstance(rule, gitlint_rules.LineRule) and
+ rule.target == gitlint_rules.CommitMessageBody and not self.should_ignore_rule(rule)]
+
+ @property
+ def commit_rules(self):
+ return [rule for rule in self.config.rules if isinstance(rule, gitlint_rules.CommitRule) and
+ not self.should_ignore_rule(rule)]
+
+ @staticmethod
+ def _apply_line_rules(lines, commit, rules, line_nr_start):
+ """ Iterates over the lines in a given list of lines and validates a given list of rules against each line """
+ all_violations = []
+ line_nr = line_nr_start
+ for line in lines:
+ for rule in rules:
+ violations = rule.validate(line, commit)
+ if violations:
+ for violation in violations:
+ violation.line_nr = line_nr
+ all_violations.append(violation)
+ line_nr += 1
+ return all_violations
+
+ @staticmethod
+ def _apply_commit_rules(rules, commit):
+ """ Applies a set of rules against a given commit and gitcontext """
+ all_violations = []
+ for rule in rules:
+ violations = rule.validate(commit)
+ if violations:
+ all_violations.extend(violations)
+ return all_violations
+
+ def lint(self, commit):
+ """ Lint the last commit in a given git context by applying all ignore, title, body and commit rules. """
+ LOG.debug("Linting commit %s", commit.sha or "[SHA UNKNOWN]")
+ LOG.debug("Commit Object\n" + ustr(commit))
+
+ # Apply config rules
+ for rule in self.configuration_rules:
+ rule.apply(self.config, commit)
+
+ # Skip linting if this is a special commit type that is configured to be ignored
+ ignore_commit_types = ["merge", "squash", "fixup", "revert"]
+ for commit_type in ignore_commit_types:
+ if getattr(commit, "is_{0}_commit".format(commit_type)) and \
+ getattr(self.config, "ignore_{0}_commits".format(commit_type)):
+ return []
+
+ violations = []
+ # determine violations by applying all rules
+ violations.extend(self._apply_line_rules([commit.message.title], commit, self.title_line_rules, 1))
+ violations.extend(self._apply_line_rules(commit.message.body, commit, self.body_line_rules, 2))
+ violations.extend(self._apply_commit_rules(self.commit_rules, commit))
+
+ # Sort violations by line number and rule_id. If there's no line nr specified (=common certain commit rules),
+ # we replace None with -1 so that it always get's placed first. Note that we need this to do this to support
+ # python 3, as None is not allowed in a list that is being sorted.
+ violations.sort(key=lambda v: (-1 if v.line_nr is None else v.line_nr, v.rule_id))
+ return violations
+
+ def print_violations(self, violations):
+ """ Print a given set of violations to the standard error output """
+ for v in violations:
+ line_nr = v.line_nr if v.line_nr else "-"
+ self.display.e(u"{0}: {1}".format(line_nr, v.rule_id), exact=True)
+ self.display.ee(u"{0}: {1} {2}".format(line_nr, v.rule_id, v.message), exact=True)
+ if v.content:
+ self.display.eee(u"{0}: {1} {2}: \"{3}\"".format(line_nr, v.rule_id, v.message, v.content),
+ exact=True)
+ else:
+ self.display.eee(u"{0}: {1} {2}".format(line_nr, v.rule_id, v.message), exact=True)
diff --git a/gitlint/options.py b/gitlint/options.py
new file mode 100644
index 0000000..a1ae59c
--- /dev/null
+++ b/gitlint/options.py
@@ -0,0 +1,122 @@
+from abc import abstractmethod
+import os
+
+from gitlint.utils import ustr, sstr
+
+
+class RuleOptionError(Exception):
+ pass
+
+
+class RuleOption(object):
+ """ Base class representing a configurable part (i.e. option) of a rule (e.g. the max-length of the title-max-line
+ rule).
+ This class should not be used directly. Instead, use on the derived classes like StrOption, IntOption to set
+ options of a particular type like int, str, etc.
+ """
+
+ def __init__(self, name, value, description):
+ self.name = ustr(name)
+ self.description = ustr(description)
+ self.value = None
+ self.set(value)
+
+ @abstractmethod
+ def set(self, value):
+ """ Validates and sets the option's value """
+ pass # pragma: no cover
+
+ def __str__(self):
+ return sstr(self) # pragma: no cover
+
+ def __unicode__(self):
+ return u"({0}: {1} ({2}))".format(self.name, self.value, self.description) # pragma: no cover
+
+ def __repr__(self):
+ return self.__str__() # pragma: no cover
+
+ def __eq__(self, other):
+ return self.name == other.name and self.description == other.description and self.value == other.value
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+
+class StrOption(RuleOption):
+ def set(self, value):
+ self.value = ustr(value)
+
+
+class IntOption(RuleOption):
+ def __init__(self, name, value, description, allow_negative=False):
+ self.allow_negative = allow_negative
+ super(IntOption, self).__init__(name, value, description)
+
+ def _raise_exception(self, value):
+ if self.allow_negative:
+ error_msg = u"Option '{0}' must be an integer (current value: '{1}')".format(self.name, value)
+ else:
+ error_msg = u"Option '{0}' must be a positive integer (current value: '{1}')".format(self.name, value)
+ raise RuleOptionError(error_msg)
+
+ def set(self, value):
+ try:
+ self.value = int(value)
+ except ValueError:
+ self._raise_exception(value)
+
+ if not self.allow_negative and self.value < 0:
+ self._raise_exception(value)
+
+
+class BoolOption(RuleOption):
+ def set(self, value):
+ value = ustr(value).strip().lower()
+ if value not in ['true', 'false']:
+ raise RuleOptionError(u"Option '{0}' must be either 'true' or 'false'".format(self.name))
+ self.value = value == 'true'
+
+
+class ListOption(RuleOption):
+ """ Option that is either a given list or a comma-separated string that can be splitted into a list when being set.
+ """
+
+ def set(self, value):
+ if isinstance(value, list):
+ the_list = value
+ else:
+ the_list = ustr(value).split(",")
+
+ self.value = [ustr(item.strip()) for item in the_list if item.strip() != ""]
+
+
+class PathOption(RuleOption):
+ """ Option that accepts either a directory or both a directory and a file. """
+
+ def __init__(self, name, value, description, type=u"dir"):
+ self.type = type
+ super(PathOption, self).__init__(name, value, description)
+
+ def set(self, value):
+ value = ustr(value)
+
+ error_msg = u""
+
+ if self.type == 'dir':
+ if not os.path.isdir(value):
+ error_msg = u"Option {0} must be an existing directory (current value: '{1}')".format(self.name, value)
+ elif self.type == 'file':
+ if not os.path.isfile(value):
+ error_msg = u"Option {0} must be an existing file (current value: '{1}')".format(self.name, value)
+ elif self.type == 'both':
+ if not os.path.isdir(value) and not os.path.isfile(value):
+ error_msg = (u"Option {0} must be either an existing directory or file "
+ u"(current value: '{1}')").format(self.name, value)
+ else:
+ error_msg = u"Option {0} type must be one of: 'file', 'dir', 'both' (current: '{1}')".format(self.name,
+ self.type)
+
+ if error_msg:
+ raise RuleOptionError(error_msg)
+
+ self.value = os.path.realpath(value)
diff --git a/gitlint/rule_finder.py b/gitlint/rule_finder.py
new file mode 100644
index 0000000..2b8b293
--- /dev/null
+++ b/gitlint/rule_finder.py
@@ -0,0 +1,137 @@
+import fnmatch
+import inspect
+import os
+import sys
+import importlib
+
+from gitlint import rules, options
+from gitlint.utils import ustr
+
+
+def find_rule_classes(extra_path):
+ """
+ Searches a given directory or python module for rule classes. This is done by
+ adding the directory path to the python path, importing the modules and then finding
+ any Rule class in those modules.
+
+ :param extra_path: absolute directory or file path to search for rule classes
+ :return: The list of rule classes that are found in the given directory or module
+ """
+
+ files = []
+ modules = []
+
+ if os.path.isfile(extra_path):
+ files = [os.path.basename(extra_path)]
+ directory = os.path.dirname(extra_path)
+ elif os.path.isdir(extra_path):
+ files = os.listdir(extra_path)
+ directory = extra_path
+ else:
+ raise rules.UserRuleError(u"Invalid extra-path: {0}".format(extra_path))
+
+ # Filter out files that are not python modules
+ for filename in files:
+ if fnmatch.fnmatch(filename, '*.py'):
+ # We have to treat __init__ files a bit special: add the parent dir instead of the filename, and also
+ # add their parent dir to the sys.path (this fixes import issues with pypy2).
+ if filename == "__init__.py":
+ modules.append(os.path.basename(directory))
+ sys.path.append(os.path.dirname(directory))
+ else:
+ modules.append(os.path.splitext(filename)[0])
+
+ # No need to continue if there are no modules specified
+ if not modules:
+ return []
+
+ # Append the extra rules path to python path so that we can import them
+ sys.path.append(directory)
+
+ # Find all the rule classes in the found python files
+ rule_classes = []
+ for module in modules:
+ # Import the module
+ try:
+ importlib.import_module(module)
+
+ except Exception as e:
+ raise rules.UserRuleError(u"Error while importing extra-path module '{0}': {1}".format(module, ustr(e)))
+
+ # Find all rule classes in the module. We do this my inspecting all members of the module and checking
+ # 1) is it a class, if not, skip
+ # 2) is the parent path the current module. If not, we are dealing with an imported class, skip
+ # 3) is it a subclass of rule
+ rule_classes.extend([clazz for _, clazz in inspect.getmembers(sys.modules[module])
+ if
+ inspect.isclass(clazz) and # check isclass to ensure clazz.__module__ exists
+ clazz.__module__ == module and # ignore imported classes
+ (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule))])
+
+ # validate that the rule classes are valid user-defined rules
+ for rule_class in rule_classes:
+ assert_valid_rule_class(rule_class)
+
+ return rule_classes
+
+
+def assert_valid_rule_class(clazz, rule_type="User-defined"):
+ """
+ Asserts that a given rule clazz is valid by checking a number of its properties:
+ - Rules must extend from LineRule or CommitRule
+ - Rule classes must have id and name string attributes.
+ The options_spec is optional, but if set, it must be a list of gitlint Options.
+ - Rule classes must have a validate method. In case of a CommitRule, validate must take a single commit parameter.
+ In case of LineRule, validate must take line and commit as first and second parameters.
+ - LineRule classes must have a target class attributes that is set to either
+ CommitMessageTitle or CommitMessageBody.
+ - Rule id's cannot start with R, T, B or M as these rule ids are reserved for gitlint itself.
+ """
+
+ # Rules must extend from LineRule or CommitRule
+ if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)):
+ msg = u"{0} rule class '{1}' must extend from {2}.{3} or {2}.{4}"
+ raise rules.UserRuleError(msg.format(rule_type, clazz.__name__, rules.CommitRule.__module__,
+ rules.LineRule.__name__, rules.CommitRule.__name__))
+
+ # Rules must have an id attribute
+ if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id:
+ msg = u"{0} rule class '{1}' must have an 'id' attribute"
+ raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
+
+ # Rule id's cannot start with gitlint reserved letters
+ if clazz.id[0].upper() in ['R', 'T', 'B', 'M']:
+ msg = u"The id '{1}' of '{0}' is invalid. Gitlint reserves ids starting with R,T,B,M"
+ raise rules.UserRuleError(msg.format(clazz.__name__, clazz.id[0]))
+
+ # Rules must have a name attribute
+ if not hasattr(clazz, 'name') or clazz.name is None or not clazz.name:
+ msg = u"{0} rule class '{1}' must have a 'name' attribute"
+ raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
+
+ # if set, options_spec must be a list of RuleOption
+ if not isinstance(clazz.options_spec, list):
+ msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
+ raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
+ options.RuleOption.__module__, options.RuleOption.__name__))
+
+ # check that all items in options_spec are actual gitlint options
+ for option in clazz.options_spec:
+ if not isinstance(option, options.RuleOption):
+ msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
+ raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
+ options.RuleOption.__module__, options.RuleOption.__name__))
+
+ # Rules must have a validate method. We use isroutine() as it's both python 2 and 3 compatible.
+ # For more info see http://stackoverflow.com/a/17019998/381010
+ if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate):
+ msg = u"{0} rule class '{1}' must have a 'validate' method"
+ raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
+
+ # LineRules must have a valid target: rules.CommitMessageTitle or rules.CommitMessageBody
+ if issubclass(clazz, rules.LineRule):
+ if clazz.target not in [rules.CommitMessageTitle, rules.CommitMessageBody]:
+ msg = u"The target attribute of the {0} LineRule class '{1}' must be either {2}.{3} or {2}.{4}"
+ msg = msg.format(rule_type.lower(), clazz.__name__, rules.CommitMessageTitle.__module__,
+ rules.CommitMessageTitle.__name__, rules.CommitMessageBody.__name__)
+ raise rules.UserRuleError(msg)
diff --git a/gitlint/rules.py b/gitlint/rules.py
new file mode 100644
index 0000000..ad83204
--- /dev/null
+++ b/gitlint/rules.py
@@ -0,0 +1,363 @@
+# pylint: disable=inconsistent-return-statements
+import copy
+import logging
+import re
+
+from gitlint.options import IntOption, BoolOption, StrOption, ListOption
+from gitlint.utils import sstr
+
+LOG = logging.getLogger(__name__)
+logging.basicConfig()
+
+
+class Rule(object):
+ """ Class representing gitlint rules. """
+ options_spec = []
+ id = None
+ name = None
+ target = None
+
+ def __init__(self, opts=None):
+ if not opts:
+ opts = {}
+ self.options = {}
+ for op_spec in self.options_spec:
+ self.options[op_spec.name] = copy.deepcopy(op_spec)
+ actual_option = opts.get(op_spec.name)
+ if actual_option is not None:
+ self.options[op_spec.name].set(actual_option)
+
+ def __eq__(self, other):
+ return self.id == other.id and self.name == other.name and \
+ self.options == other.options and self.target == other.target # noqa
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+ def __str__(self):
+ return sstr(self) # pragma: no cover
+
+ def __unicode__(self):
+ return u"{0} {1}".format(self.id, self.name) # pragma: no cover
+
+ def __repr__(self):
+ return self.__str__() # pragma: no cover
+
+
+class ConfigurationRule(Rule):
+ """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """
+ pass
+
+
+class CommitRule(Rule):
+ """ Class representing rules that act on an entire commit at once """
+ pass
+
+
+class LineRule(Rule):
+ """ Class representing rules that act on a line by line basis """
+ pass
+
+
+class LineRuleTarget(object):
+ """ Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied
+ (e.g. commit message title, commit message body).
+ Each LineRule MUST have a target specified. """
+ pass
+
+
+class CommitMessageTitle(LineRuleTarget):
+ """ Target class used for rules that apply to a commit message title """
+ pass
+
+
+class CommitMessageBody(LineRuleTarget):
+ """ Target class used for rules that apply to a commit message body """
+ pass
+
+
+class RuleViolation(object):
+ """ Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class
+ to indicate how and where the rule was broken. """
+
+ def __init__(self, rule_id, message, content=None, line_nr=None):
+ self.rule_id = rule_id
+ self.line_nr = line_nr
+ self.message = message
+ self.content = content
+
+ def __eq__(self, other):
+ equal = self.rule_id == other.rule_id and self.message == other.message
+ equal = equal and self.content == other.content and self.line_nr == other.line_nr
+ return equal
+
+ def __ne__(self, other):
+ return not self.__eq__(other) # required for py2
+
+ def __str__(self):
+ return sstr(self) # pragma: no cover
+
+ def __unicode__(self):
+ return u"{0}: {1} {2}: \"{3}\"".format(self.line_nr, self.rule_id, self.message,
+ self.content) # pragma: no cover
+
+ def __repr__(self):
+ return self.__str__() # pragma: no cover
+
+
+class UserRuleError(Exception):
+ """ Error used to indicate that an error occurred while trying to load a user rule """
+ pass
+
+
+class MaxLineLength(LineRule):
+ name = "max-line-length"
+ id = "R1"
+ options_spec = [IntOption('line-length', 80, "Max line length")]
+ violation_message = "Line exceeds max length ({0}>{1})"
+
+ def validate(self, line, _commit):
+ max_length = self.options['line-length'].value
+ if len(line) > max_length:
+ return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
+
+
+class TrailingWhiteSpace(LineRule):
+ name = "trailing-whitespace"
+ id = "R2"
+ violation_message = "Line has trailing whitespace"
+
+ def validate(self, line, _commit):
+ pattern = re.compile(r"\s$", re.UNICODE)
+ if pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class HardTab(LineRule):
+ name = "hard-tab"
+ id = "R3"
+ violation_message = "Line contains hard tab characters (\\t)"
+
+ def validate(self, line, _commit):
+ if "\t" in line:
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class LineMustNotContainWord(LineRule):
+ """ Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
+ a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.) """
+ name = "line-must-not-contain"
+ id = "R5"
+ options_spec = [ListOption('words', [], "Comma separated list of words that should not be found")]
+ violation_message = u"Line contains {0}"
+
+ def validate(self, line, _commit):
+ strings = self.options['words'].value
+ violations = []
+ for string in strings:
+ regex = re.compile(r"\b%s\b" % string.lower(), re.IGNORECASE | re.UNICODE)
+ match = regex.search(line.lower())
+ if match:
+ violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
+ return violations if violations else None
+
+
+class LeadingWhiteSpace(LineRule):
+ name = "leading-whitespace"
+ id = "R6"
+ violation_message = "Line has leading whitespace"
+
+ def validate(self, line, _commit):
+ pattern = re.compile(r"^\s", re.UNICODE)
+ if pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class TitleMaxLength(MaxLineLength):
+ name = "title-max-length"
+ id = "T1"
+ target = CommitMessageTitle
+ options_spec = [IntOption('line-length', 72, "Max line length")]
+ violation_message = "Title exceeds max length ({0}>{1})"
+
+
+class TitleTrailingWhitespace(TrailingWhiteSpace):
+ name = "title-trailing-whitespace"
+ id = "T2"
+ target = CommitMessageTitle
+ violation_message = "Title has trailing whitespace"
+
+
+class TitleTrailingPunctuation(LineRule):
+ name = "title-trailing-punctuation"
+ id = "T3"
+ target = CommitMessageTitle
+
+ def validate(self, title, _commit):
+ punctuation_marks = '?:!.,;'
+ for punctuation_mark in punctuation_marks:
+ if title.endswith(punctuation_mark):
+ return [RuleViolation(self.id, u"Title has trailing punctuation ({0})".format(punctuation_mark), title)]
+
+
+class TitleHardTab(HardTab):
+ name = "title-hard-tab"
+ id = "T4"
+ target = CommitMessageTitle
+ violation_message = "Title contains hard tab characters (\\t)"
+
+
+class TitleMustNotContainWord(LineMustNotContainWord):
+ name = "title-must-not-contain-word"
+ id = "T5"
+ target = CommitMessageTitle
+ options_spec = [ListOption('words', ["WIP"], "Must not contain word")]
+ violation_message = u"Title contains the word '{0}' (case-insensitive)"
+
+
+class TitleLeadingWhitespace(LeadingWhiteSpace):
+ name = "title-leading-whitespace"
+ id = "T6"
+ target = CommitMessageTitle
+ violation_message = "Title has leading whitespace"
+
+
+class TitleRegexMatches(LineRule):
+ name = "title-match-regex"
+ id = "T7"
+ target = CommitMessageTitle
+ options_spec = [StrOption('regex', ".*", "Regex the title should match")]
+
+ def validate(self, title, _commit):
+ regex = self.options['regex'].value
+ pattern = re.compile(regex, re.UNICODE)
+ if not pattern.search(title):
+ violation_msg = u"Title does not match regex ({0})".format(regex)
+ return [RuleViolation(self.id, violation_msg, title)]
+
+
+class BodyMaxLineLength(MaxLineLength):
+ name = "body-max-line-length"
+ id = "B1"
+ target = CommitMessageBody
+
+
+class BodyTrailingWhitespace(TrailingWhiteSpace):
+ name = "body-trailing-whitespace"
+ id = "B2"
+ target = CommitMessageBody
+
+
+class BodyHardTab(HardTab):
+ name = "body-hard-tab"
+ id = "B3"
+ target = CommitMessageBody
+
+
+class BodyFirstLineEmpty(CommitRule):
+ name = "body-first-line-empty"
+ id = "B4"
+
+ def validate(self, commit):
+ if len(commit.message.body) >= 1:
+ first_line = commit.message.body[0]
+ if first_line != "":
+ return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]
+
+
+class BodyMinLength(CommitRule):
+ name = "body-min-length"
+ id = "B5"
+ options_spec = [IntOption('min-length', 20, "Minimum body length")]
+
+ def validate(self, commit):
+ min_length = self.options['min-length'].value
+ body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
+ actual_length = len(body_message_no_newline)
+ if 0 < actual_length < min_length:
+ violation_message = "Body message is too short ({0}<{1})".format(actual_length, min_length)
+ return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]
+
+
+class BodyMissing(CommitRule):
+ name = "body-is-missing"
+ id = "B6"
+ options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")]
+
+ def validate(self, commit):
+ # ignore merges when option tells us to, which may have no body
+ if self.options['ignore-merge-commits'].value and commit.is_merge_commit:
+ return
+ if len(commit.message.body) < 2:
+ return [RuleViolation(self.id, "Body message is missing", None, 3)]
+
+
+class BodyChangedFileMention(CommitRule):
+ name = "body-changed-file-mention"
+ id = "B7"
+ options_spec = [ListOption('files', [], "Files that need to be mentioned")]
+
+ def validate(self, commit):
+ violations = []
+ for needs_mentioned_file in self.options['files'].value:
+ # if a file that we need to look out for is actually changed, then check whether it occurs
+ # in the commit msg body
+ if needs_mentioned_file in commit.changed_files:
+ if needs_mentioned_file not in " ".join(commit.message.body):
+ violation_message = u"Body does not mention changed file '{0}'".format(needs_mentioned_file)
+ violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
+ return violations if violations else None
+
+
+class AuthorValidEmail(CommitRule):
+ name = "author-valid-email"
+ id = "M1"
+ options_spec = [StrOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]
+
+ def validate(self, commit):
+ # Note that unicode is allowed in email addresses
+ # See http://stackoverflow.com/questions/3844431
+ # /are-email-addresses-allowed-to-contain-non-alphanumeric-characters
+ email_regex = re.compile(self.options['regex'].value, re.UNICODE)
+
+ if commit.author_email and not email_regex.match(commit.author_email):
+ return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)]
+
+
+class IgnoreByTitle(ConfigurationRule):
+ name = "ignore-by-title"
+ id = "I1"
+ options_spec = [StrOption('regex', None, "Regex matching the titles of commits this rule should apply to"),
+ StrOption('ignore', "all", "Comma-separated list of rules to ignore")]
+
+ def apply(self, config, commit):
+ title_regex = re.compile(self.options['regex'].value, re.UNICODE)
+
+ if title_regex.match(commit.message.title):
+ config.ignore = self.options['ignore'].value
+
+ message = u"Commit title '{0}' matches the regex '{1}', ignoring rules: {2}"
+ message = message.format(commit.message.title, self.options['regex'].value, self.options['ignore'].value)
+
+ LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+
+
+class IgnoreByBody(ConfigurationRule):
+ name = "ignore-by-body"
+ id = "I2"
+ options_spec = [StrOption('regex', None, "Regex matching lines of the body of commits this rule should apply to"),
+ StrOption('ignore', "all", "Comma-separated list of rules to ignore")]
+
+ def apply(self, config, commit):
+ body_line_regex = re.compile(self.options['regex'].value, re.UNICODE)
+
+ for line in commit.message.body:
+ if body_line_regex.match(line):
+ config.ignore = self.options['ignore'].value
+
+ message = u"Commit message line '{0}' matches the regex '{1}', ignoring rules: {2}"
+ message = message.format(line, self.options['regex'].value, self.options['ignore'].value)
+
+ LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ # No need to check other lines if we found a match
+ return
diff --git a/gitlint/shell.py b/gitlint/shell.py
new file mode 100644
index 0000000..965f492
--- /dev/null
+++ b/gitlint/shell.py
@@ -0,0 +1,76 @@
+
+"""
+This module implements a shim for the 'sh' library, mainly for use on Windows (sh is not supported on Windows).
+We might consider removing the 'sh' dependency alltogether in the future, but 'sh' does provide a few
+capabilities wrt dealing with more edge-case environments on *nix systems that might be useful.
+"""
+
+import subprocess
+import sys
+from gitlint.utils import ustr, USE_SH_LIB
+
+if USE_SH_LIB:
+ from sh import git # pylint: disable=unused-import,import-error
+ # import exceptions separately, this makes it a little easier to mock them out in the unit tests
+ from sh import CommandNotFound, ErrorReturnCode # pylint: disable=import-error
+else:
+
+ class CommandNotFound(Exception):
+ """ Exception indicating a command was not found during execution """
+ pass
+
+ class ShResult(object):
+ """ Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using
+ the builtin subprocess. module """
+
+ def __init__(self, full_cmd, stdout, stderr='', exitcode=0):
+ self.full_cmd = full_cmd
+ self.stdout = stdout
+ self.stderr = stderr
+ self.exit_code = exitcode
+
+ def __str__(self):
+ return self.stdout
+
+ class ErrorReturnCode(ShResult, Exception):
+ """ ShResult subclass for unexpected results (acts as an exception). """
+ pass
+
+ def git(*command_parts, **kwargs):
+ """ Git shell wrapper.
+ Implemented as separate function here, so we can do a 'sh' style imports:
+ `from shell import git`
+ """
+ args = ['git'] + list(command_parts)
+ return _exec(*args, **kwargs)
+
+ def _exec(*args, **kwargs):
+ if sys.version_info[0] == 2:
+ no_command_error = OSError # noqa pylint: disable=undefined-variable,invalid-name
+ else:
+ no_command_error = FileNotFoundError # noqa pylint: disable=undefined-variable
+
+ pipe = subprocess.PIPE
+ popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs['_tty_out']}
+ if '_cwd' in kwargs:
+ popen_kwargs['cwd'] = kwargs['_cwd']
+
+ try:
+ p = subprocess.Popen(args, **popen_kwargs)
+ result = p.communicate()
+ except no_command_error:
+ raise CommandNotFound
+
+ exit_code = p.returncode
+ stdout = ustr(result[0])
+ stderr = result[1] # 'sh' does not decode the stderr bytes to unicode
+ full_cmd = '' if args is None else ' '.join(args)
+
+ # If not _ok_code is specified, then only a 0 exit code is allowed
+ ok_exit_codes = kwargs.get('_ok_code', [0])
+
+ if exit_code in ok_exit_codes:
+ return ShResult(full_cmd, stdout, stderr, exit_code)
+
+ # Unexpected error code => raise ErrorReturnCode
+ raise ErrorReturnCode(full_cmd, stdout, stderr, p.returncode)
diff --git a/gitlint/tests/__init__.py b/gitlint/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/tests/__init__.py
diff --git a/gitlint/tests/base.py b/gitlint/tests/base.py
new file mode 100644
index 0000000..add4d71
--- /dev/null
+++ b/gitlint/tests/base.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+
+import copy
+import io
+import logging
+import os
+import re
+
+try:
+ # python 2.x
+ import unittest2 as unittest
+except ImportError:
+ # python 3.x
+ import unittest
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.git import GitContext
+from gitlint.utils import ustr, LOG_FORMAT, DEFAULT_ENCODING
+
+
+# unittest2's assertRaisesRegex doesn't do unicode comparison.
+# Let's monkeypatch the str() function to point to unicode() so that it does :)
+# For reference, this is where this patch is required:
+# https://hg.python.org/unittest2/file/tip/unittest2/case.py#l227
+try:
+ # python 2.x
+ unittest.case.str = unicode
+except (AttributeError, NameError):
+ pass # python 3.x
+
+
+class BaseTestCase(unittest.TestCase):
+ """ Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods. """
+
+ # In case of assert failures, print the full error message
+ maxDiff = None
+
+ SAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
+ EXPECTED_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
+ GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
+
+ def setUp(self):
+ self.logcapture = LogCapture()
+ self.logcapture.setFormatter(logging.Formatter(LOG_FORMAT))
+ logging.getLogger('gitlint').setLevel(logging.DEBUG)
+ logging.getLogger('gitlint').handlers = [self.logcapture]
+
+ # Make sure we don't propagate anything to child loggers, we need to do this explicitely here
+ # because if you run a specific test file like test_lint.py, we won't be calling the setupLogging() method
+ # in gitlint.cli that normally takes care of this
+ logging.getLogger('gitlint').propagate = False
+
+ @staticmethod
+ def get_sample_path(filename=""):
+ # Don't join up empty files names because this will add a trailing slash
+ if filename == "":
+ return ustr(BaseTestCase.SAMPLES_DIR)
+
+ return ustr(os.path.join(BaseTestCase.SAMPLES_DIR, filename))
+
+ @staticmethod
+ def get_sample(filename=""):
+ """ Read and return the contents of a file in gitlint/tests/samples """
+ sample_path = BaseTestCase.get_sample_path(filename)
+ with io.open(sample_path, encoding=DEFAULT_ENCODING) as content:
+ sample = ustr(content.read())
+ return sample
+
+ @staticmethod
+ def get_expected(filename="", variable_dict=None):
+ """ Utility method to read an expected file from gitlint/tests/expected and return it as a string.
+ Optionally replace template variables specified by variable_dict. """
+ expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename)
+ with io.open(expected_path, encoding=DEFAULT_ENCODING) as content:
+ expected = ustr(content.read())
+
+ if variable_dict:
+ expected = expected.format(**variable_dict)
+ return expected
+
+ @staticmethod
+ def get_user_rules_path():
+ return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules")
+
+ @staticmethod
+ def gitcontext(commit_msg_str, changed_files=None, ):
+ """ Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of
+ changed files"""
+ with patch("gitlint.git.git_commentchar") as comment_char:
+ comment_char.return_value = u"#"
+ gitcontext = GitContext.from_commit_msg(commit_msg_str)
+ commit = gitcontext.commits[-1]
+ if changed_files:
+ commit.changed_files = changed_files
+ return gitcontext
+
+ @staticmethod
+ def gitcommit(commit_msg_str, changed_files=None, **kwargs):
+ """ Utility method to easily create git commit given a commit msg string and an optional set of changed files"""
+ gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files)
+ commit = gitcontext.commits[-1]
+ for attr, value in kwargs.items():
+ setattr(commit, attr, value)
+ return commit
+
+ def assert_logged(self, expected):
+ """ Asserts that the logs match an expected string or list.
+ This method knows how to compare a passed list of log lines as well as a newline concatenated string
+ of all loglines. """
+ if isinstance(expected, list):
+ self.assertListEqual(self.logcapture.messages, expected)
+ else:
+ self.assertEqual("\n".join(self.logcapture.messages), expected)
+
+ def assert_log_contains(self, line):
+ """ Asserts that a certain line is in the logs """
+ self.assertIn(line, self.logcapture.messages)
+
+ def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs):
+ """ Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed
+ `expected_regex`. This is useful to automatically escape all file paths that might be present in the regex.
+ """
+ return super(BaseTestCase, self).assertRaisesRegex(expected_exception, re.escape(expected_regex),
+ *args, **kwargs)
+
+ def object_equality_test(self, obj, attr_list, ctor_kwargs=None):
+ """ Helper function to easily implement object equality tests.
+ Creates an object clone for every passed attribute and checks for (in)equality
+ of the original object with the clone based on those attributes' values.
+ This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`.
+ """
+ if not ctor_kwargs:
+ ctor_kwargs = {}
+
+ attr_kwargs = {}
+ for attr in attr_list:
+ attr_kwargs[attr] = getattr(obj, attr)
+
+ # For every attr, clone the object and assert the clone and the original object are equal
+ # Then, change the current attr and assert objects are unequal
+ for attr in attr_list:
+ attr_kwargs_copy = copy.deepcopy(attr_kwargs)
+ attr_kwargs_copy.update(ctor_kwargs)
+ clone = obj.__class__(**attr_kwargs_copy)
+ self.assertEqual(obj, clone)
+
+ # Change attribute and assert objects are different (via both attribute set and ctor)
+ setattr(clone, attr, u"föo")
+ self.assertNotEqual(obj, clone)
+ attr_kwargs_copy[attr] = u"föo"
+
+ self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy))
+
+
+class LogCapture(logging.Handler):
+ """ Mock logging handler used to capture any log messages during tests."""
+
+ def __init__(self, *args, **kwargs):
+ logging.Handler.__init__(self, *args, **kwargs)
+ self.messages = []
+
+ def emit(self, record):
+ self.messages.append(ustr(self.format(record)))
diff --git a/gitlint/tests/cli/test_cli.py b/gitlint/tests/cli/test_cli.py
new file mode 100644
index 0000000..4d47f35
--- /dev/null
+++ b/gitlint/tests/cli/test_cli.py
@@ -0,0 +1,541 @@
+# -*- coding: utf-8 -*-
+
+import contextlib
+import io
+import os
+import sys
+import platform
+import shutil
+import tempfile
+
+import arrow
+
+try:
+ # python 2.x
+ from StringIO import StringIO
+except ImportError:
+ # python 3.x
+ from io import StringIO # pylint: disable=ungrouped-imports
+
+from click.testing import CliRunner
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.shell import CommandNotFound
+
+from gitlint.tests.base import BaseTestCase
+from gitlint import cli
+from gitlint import __version__
+from gitlint.utils import DEFAULT_ENCODING
+
+
+@contextlib.contextmanager
+def tempdir():
+ tmpdir = tempfile.mkdtemp()
+ try:
+ yield tmpdir
+ finally:
+ shutil.rmtree(tmpdir)
+
+
+class CLITests(BaseTestCase):
+ USAGE_ERROR_CODE = 253
+ GIT_CONTEXT_ERROR_CODE = 254
+ CONFIG_ERROR_CODE = 255
+
+ def setUp(self):
+ super(CLITests, self).setUp()
+ self.cli = CliRunner()
+
+ # Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
+ self.git_version_path = patch('gitlint.cli.git_version')
+ cli.git_version = self.git_version_path.start()
+ cli.git_version.return_value = "git version 1.2.3"
+
+ def tearDown(self):
+ self.git_version_path.stop()
+
+ @staticmethod
+ def get_system_info_dict():
+ """ Returns a dict with items related to system values logged by `gitlint --debug` """
+ return {'platform': platform.platform(), "python_version": sys.version, 'gitlint_version': __version__,
+ 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB, 'target': os.path.realpath(os.getcwd())}
+
+ def test_version(self):
+ """ Test for --version option """
+ result = self.cli.invoke(cli.cli, ["--version"])
+ self.assertEqual(result.output.split("\n")[0], "cli, version {0}".format(__version__))
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_lint(self, sh, _):
+ """ Test for basic simple linting functionality """
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360",
+ u"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"commït-title\n\ncommït-body",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n",
+ u"file1.txt\npåth/to/file2.txt\n"
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli)
+ self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
+ self.assertEqual(result.exit_code, 1)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_lint_multiple_commits(self, sh, _):
+ """ Test for --commits option """
+
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
+ "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
+ "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"commït-title1\n\ncommït-body1",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
+ u"commït-title2\n\ncommït-body2",
+ u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
+ u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
+ u"commït-title3\n\ncommït-body3",
+ u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
+ u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_multiple_commits_1"))
+ self.assertEqual(result.exit_code, 3)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_lint_multiple_commits_config(self, sh, _):
+ """ Test for --commits option where some of the commits have gitlint config in the commit message """
+
+ # Note that the second commit title has a trailing period that is being ignored by gitlint-ignore: T3
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
+ "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
+ "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"commït-title1\n\ncommït-body1",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
+ u"commït-title2.\n\ncommït-body2\ngitlint-ignore: T3\n",
+ u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
+ u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
+ u"commït-title3.\n\ncommït-body3",
+ u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
+ u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
+ # We expect that the second commit has no failures because of 'gitlint-ignore: T3' in its commit msg body
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_multiple_commits_config_1"))
+ self.assertEqual(result.exit_code, 3)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_lint_multiple_commits_configuration_rules(self, sh, _):
+ """ Test for --commits option where where we have configured gitlint to ignore certain rules for certain commits
+ """
+
+ # Note that the second commit
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
+ "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
+ "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"commït-title1\n\ncommït-body1",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
+ # Normally T3 violation (trailing punctuation), but this commit is ignored because of
+ # config below
+ u"commït-title2.\n\ncommït-body2\n",
+ u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
+ u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
+ # Normally T1 and B5 violations, now only T1 because we're ignoring B5 in config below
+ u"commït-title3.\n\ncommït-body3 foo",
+ u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
+ u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--commits", "foo...bar", "-c", "I1.regex=^commït-title2(.*)",
+ "-c", "I2.regex=^commït-body3(.*)", "-c", "I2.ignore=B5"])
+ # We expect that the second commit has no failures because of it matching against I1.regex
+ # Because we do test for the 3th commit to return violations, this test also ensures that a unique
+ # config object is passed to each commit lint call
+ expected = (u"Commit 6f29bf81a8:\n"
+ u'3: B5 Body message is too short (12<20): "commït-body1"\n\n'
+ u"Commit 4da2656b0d:\n"
+ u'1: T3 Title has trailing punctuation (.): "commït-title3."\n')
+ self.assertEqual(stderr.getvalue(), expected)
+ self.assertEqual(result.exit_code, 2)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
+ def test_input_stream(self, _):
+ """ Test for linting when a message is passed via stdin """
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli)
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_input_stream_1"))
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
+ def test_input_stream_debug(self, _):
+ """ Test for linting when a message is passed via stdin, and debug is enabled.
+ This tests specifically that git commit meta is not fetched when not passing --staged """
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--debug"])
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_input_stream_debug_1"))
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+ expected_kwargs = self.get_system_info_dict()
+ expected_logs = self.get_expected('test_cli/test_input_stream_debug_2', expected_kwargs)
+ self.assert_logged(expected_logs)
+
+ @patch('gitlint.cli.get_stdin_data', return_value="Should be ignored\n")
+ @patch('gitlint.git.sh')
+ def test_lint_ignore_stdin(self, sh, stdin_data):
+ """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360",
+ u"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"commït-title\n\ncommït-body",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
+ u"file1.txt\npåth/to/file2.txt\n" # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--ignore-stdin"])
+ self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
+ self.assertEqual(result.exit_code, 1)
+
+ # Assert that we didn't even try to get the stdin data
+ self.assertEqual(stdin_data.call_count, 0)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
+ @patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
+ @patch('gitlint.git.sh')
+ def test_lint_staged_stdin(self, sh, _, __):
+ """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
+
+ sh.git.side_effect = [
+ u"#", # git config --get core.commentchar
+ u"föo user\n", # git config --get user.name
+ u"föo@bar.com\n", # git config --get user.email
+ u"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--debug", "--staged"])
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_staged_stdin_1"))
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ expected_kwargs = self.get_system_info_dict()
+ expected_logs = self.get_expected('test_cli/test_lint_staged_stdin_2', expected_kwargs)
+ self.assert_logged(expected_logs)
+
+ @patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
+ @patch('gitlint.git.sh')
+ def test_lint_staged_msg_filename(self, sh, _):
+ """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
+
+ sh.git.side_effect = [
+ u"#", # git config --get core.commentchar
+ u"föo user\n", # git config --get user.name
+ u"föo@bar.com\n", # git config --get user.email
+ u"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ ]
+
+ with tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "msg")
+ with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
+ f.write(u"WIP: msg-filename tïtle\n")
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--debug", "--staged", "--msg-filename", msg_filename])
+ self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_staged_msg_filename_1"))
+ self.assertEqual(result.exit_code, 2)
+ self.assertEqual(result.output, "")
+
+ expected_kwargs = self.get_system_info_dict()
+ expected_logs = self.get_expected('test_cli/test_lint_staged_msg_filename_2', expected_kwargs)
+ self.assert_logged(expected_logs)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ def test_lint_staged_negative(self, _):
+ result = self.cli.invoke(cli.cli, ["--staged"])
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ self.assertEqual(result.output, (u"Error: The 'staged' option (--staged) can only be used when using "
+ u"'--msg-filename' or when piping data to gitlint via stdin.\n"))
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ def test_msg_filename(self, _):
+ expected_output = u"3: B6 Body message is missing\n"
+
+ with tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "msg")
+ with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
+ f.write(u"Commït title\n")
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename])
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 1)
+ self.assertEqual(result.output, "")
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tïtle \n")
+ def test_silent_mode(self, _):
+ """ Test for --silent option """
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--silent"])
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tïtle \n")
+ def test_verbosity(self, _):
+ """ Test for --verbosity option """
+ # We only test -v and -vv, more testing is really not required here
+ # -v
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-v"])
+ self.assertEqual(stderr.getvalue(), "1: T2\n1: T5\n3: B6\n")
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ # -vv
+ expected_output = "1: T2 Title has trailing whitespace\n" + \
+ "1: T5 Title contains the word 'WIP' (case-insensitive)\n" + \
+ "3: B6 Body message is missing\n"
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-vv"], input=u"WIP: tïtle \n")
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ # -vvvv: not supported -> should print a config error
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-vvvv"], input=u'WIP: tïtle \n')
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertEqual(result.exit_code, CLITests.CONFIG_ERROR_CODE)
+ self.assertEqual(result.output, "Config Error: Option 'verbosity' must be set between 0 and 3\n")
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_debug(self, sh, _):
+ """ Test for --debug option """
+
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360\n" # git rev-list <SHA>
+ "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n"
+ "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
+ # git log --pretty <FORMAT> <SHA>
+ u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00abc\n"
+ u"commït-title1\n\ncommït-body1",
+ u"#", # git config --get core.commentchar
+ u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
+ u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
+ u"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00abc\n"
+ u"commït-title2.\n\ncommït-body2",
+ u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
+ u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
+ u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00abc\n"
+ u"föo\nbar",
+ u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
+ u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
+ ]
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug", "--commits",
+ "foo...bar"])
+
+ expected = "Commit 6f29bf81a8:\n3: B5\n\n" + \
+ "Commit 25053ccec5:\n1: T3\n3: B5\n\n" + \
+ "Commit 4da2656b0d:\n2: B4\n3: B5\n3: B6\n"
+
+ self.assertEqual(stderr.getvalue(), expected)
+ self.assertEqual(result.exit_code, 6)
+
+ expected_kwargs = self.get_system_info_dict()
+ expected_kwargs.update({'config_path': config_path})
+ expected_logs = self.get_expected('test_cli/test_debug_1', expected_kwargs)
+ self.assert_logged(expected_logs)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n")
+ def test_extra_path(self, _):
+ """ Test for --extra-path flag """
+ # Test extra-path pointing to a directory
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ extra_path = self.get_sample_path("user_rules")
+ result = self.cli.invoke(cli.cli, ["--extra-path", extra_path, "--debug"])
+ expected_output = u"1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
+ "3: B6 Body message is missing\n"
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 2)
+
+ # Test extra-path pointing to a file
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ extra_path = self.get_sample_path(os.path.join("user_rules", "my_commit_rules.py"))
+ result = self.cli.invoke(cli.cli, ["--extra-path", extra_path, "--debug"])
+ expected_output = u"1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
+ "3: B6 Body message is missing\n"
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 2)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n\nMy body that is long enough")
+ def test_contrib(self, _):
+ # Test enabled contrib rules
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--contrib", "contrib-title-conventional-commits,CC1"])
+ expected_output = self.get_expected('test_cli/test_contrib_1')
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 3)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n")
+ def test_contrib_negative(self, _):
+ result = self.cli.invoke(cli.cli, ["--contrib", u"föobar,CC1"])
+ self.assertEqual(result.output, u"Config Error: No contrib rule with id or name 'föobar' found.\n")
+ self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tëst")
+ def test_config_file(self, _):
+ """ Test for --config option """
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5\n3: B6\n")
+ self.assertEqual(result.exit_code, 2)
+
+ def test_config_file_negative(self):
+ """ Negative test for --config option """
+ # Directory as config file
+ config_path = self.get_sample_path("config")
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ expected_string = u"Error: Invalid value for \"-C\" / \"--config\": File \"{0}\" is a directory.".format(
+ config_path)
+ self.assertEqual(result.output.split("\n")[3], expected_string)
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+
+ # Non existing file
+ config_path = self.get_sample_path(u"föo")
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ expected_string = u"Error: Invalid value for \"-C\" / \"--config\": File \"{0}\" does not exist.".format(
+ config_path)
+ self.assertEqual(result.output.split("\n")[3], expected_string)
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+
+ # Invalid config file
+ config_path = self.get_sample_path(os.path.join("config", "invalid-option-value"))
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ def test_target(self, _):
+ """ Test for the --target option """
+ os.environ["LANGUAGE"] = "C" # Force language to english so we can check for error message
+ result = self.cli.invoke(cli.cli, ["--target", "/tmp"])
+ # We expect gitlint to tell us that /tmp is not a git repo (this proves that it takes the target parameter
+ # into account).
+ expected_path = os.path.realpath("/tmp")
+ self.assertEqual(result.output, "%s is not a git repository.\n" % expected_path)
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+
+ def test_target_negative(self):
+ """ Negative test for the --target option """
+ # try setting a non-existing target
+ result = self.cli.invoke(cli.cli, ["--target", u"/föo/bar"])
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = u"Error: Invalid value for \"--target\": Directory \"/föo/bar\" does not exist."
+ self.assertEqual(result.output.split("\n")[3], expected_msg)
+
+ # try setting a file as target
+ target_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--target", target_path])
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = u"Error: Invalid value for \"--target\": Directory \"{0}\" is a file.".format(target_path)
+ self.assertEqual(result.output.split("\n")[3], expected_msg)
+
+ @patch('gitlint.config.LintConfigGenerator.generate_config')
+ def test_generate_config(self, generate_config):
+ """ Test for the generate-config subcommand """
+ result = self.cli.invoke(cli.cli, ["generate-config"], input=u"tëstfile\n")
+ self.assertEqual(result.exit_code, 0)
+ expected_msg = u"Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + \
+ u"Successfully generated {0}\n".format(os.path.realpath(u"tëstfile"))
+ self.assertEqual(result.output, expected_msg)
+ generate_config.assert_called_once_with(os.path.realpath(u"tëstfile"))
+
+ def test_generate_config_negative(self):
+ """ Negative test for the generate-config subcommand """
+ # Non-existing directory
+ fake_dir = os.path.abspath(u"/föo")
+ fake_path = os.path.join(fake_dir, u"bar")
+ result = self.cli.invoke(cli.cli, ["generate-config"], input=fake_path)
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = (u"Please specify a location for the sample gitlint config file [.gitlint]: {0}\n"
+ + u"Error: Directory '{1}' does not exist.\n").format(fake_path, fake_dir)
+ self.assertEqual(result.output, expected_msg)
+
+ # Existing file
+ sample_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["generate-config"], input=sample_path)
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = "Please specify a location for the sample gitlint " + \
+ "config file [.gitlint]: {0}\n".format(sample_path) + \
+ "Error: File \"{0}\" already exists.\n".format(sample_path)
+ self.assertEqual(result.output, expected_msg)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_git_error(self, sh, _):
+ """ Tests that the cli handles git errors properly """
+ sh.git.side_effect = CommandNotFound("git")
+ result = self.cli.invoke(cli.cli)
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=False)
+ @patch('gitlint.git.sh')
+ def test_no_commits_in_range(self, sh, _):
+ """ Test for --commits with the specified range being empty. """
+ sh.git.side_effect = lambda *_args, **_kwargs: ""
+ result = self.cli.invoke(cli.cli, ["--commits", "master...HEAD"])
+
+ self.assert_log_contains(u"DEBUG: gitlint.cli No commits in range \"master...HEAD\"")
+ self.assertEqual(result.exit_code, 0)
diff --git a/gitlint/tests/cli/test_cli_hooks.py b/gitlint/tests/cli/test_cli_hooks.py
new file mode 100644
index 0000000..0564808
--- /dev/null
+++ b/gitlint/tests/cli/test_cli_hooks.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+
+import os
+
+from click.testing import CliRunner
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint import cli
+from gitlint import hooks
+from gitlint import config
+
+
+class CLIHookTests(BaseTestCase):
+ USAGE_ERROR_CODE = 253
+ GIT_CONTEXT_ERROR_CODE = 254
+ CONFIG_ERROR_CODE = 255
+
+ def setUp(self):
+ super(CLIHookTests, self).setUp()
+ self.cli = CliRunner()
+
+ # Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
+ self.git_version_path = patch('gitlint.cli.git_version')
+ cli.git_version = self.git_version_path.start()
+ cli.git_version.return_value = "git version 1.2.3"
+
+ def tearDown(self):
+ self.git_version_path.stop()
+
+ @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
+ @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
+ def test_install_hook(self, _, install_hook):
+ """ Test for install-hook subcommand """
+ result = self.cli.invoke(cli.cli, ["install-hook"])
+ expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = u"Successfully installed gitlint commit-msg hook in {0}\n".format(expected_path)
+ self.assertEqual(result.output, expected)
+ self.assertEqual(result.exit_code, 0)
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
+ @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
+ def test_install_hook_target(self, _, install_hook):
+ """ Test for install-hook subcommand with a specific --target option specified """
+ # Specified target
+ result = self.cli.invoke(cli.cli, ["--target", self.SAMPLES_DIR, "install-hook"])
+ expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = "Successfully installed gitlint commit-msg hook in %s\n" % expected_path
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.output, expected)
+
+ expected_config = config.LintConfig()
+ expected_config.target = self.SAMPLES_DIR
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook', side_effect=hooks.GitHookInstallerError(u"tëst"))
+ def test_install_hook_negative(self, install_hook):
+ """ Negative test for install-hook subcommand """
+ result = self.cli.invoke(cli.cli, ["install-hook"])
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+ self.assertEqual(result.output, u"tëst\n")
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook')
+ @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
+ def test_uninstall_hook(self, _, uninstall_hook):
+ """ Test for uninstall-hook subcommand """
+ result = self.cli.invoke(cli.cli, ["uninstall-hook"])
+ expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = u"Successfully uninstalled gitlint commit-msg hook from {0}\n".format(expected_path)
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.output, expected)
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ uninstall_hook.assert_called_once_with(expected_config)
+
+ @patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook', side_effect=hooks.GitHookInstallerError(u"tëst"))
+ def test_uninstall_hook_negative(self, uninstall_hook):
+ """ Negative test for uninstall-hook subcommand """
+ result = self.cli.invoke(cli.cli, ["uninstall-hook"])
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+ self.assertEqual(result.output, u"tëst\n")
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ uninstall_hook.assert_called_once_with(expected_config)
diff --git a/gitlint/tests/config/test_config.py b/gitlint/tests/config/test_config.py
new file mode 100644
index 0000000..d3fdc2c
--- /dev/null
+++ b/gitlint/tests/config/test_config.py
@@ -0,0 +1,263 @@
+# -*- coding: utf-8 -*-
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint import rules
+from gitlint.config import LintConfig, LintConfigError, LintConfigGenerator, GITLINT_CONFIG_TEMPLATE_SRC_PATH
+from gitlint import options
+from gitlint.tests.base import BaseTestCase, ustr
+
+
+class LintConfigTests(BaseTestCase):
+
+ def test_set_rule_option(self):
+ config = LintConfig()
+
+ # assert default title line-length
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 72)
+
+ # change line length and assert it is set
+ config.set_rule_option('title-max-length', 'line-length', 60)
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 60)
+
+ def test_set_rule_option_negative(self):
+ config = LintConfig()
+
+ # non-existing rule
+ expected_error_msg = u"No such rule 'föobar'"
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config.set_rule_option(u'föobar', u'lïne-length', 60)
+
+ # non-existing option
+ expected_error_msg = u"Rule 'title-max-length' has no option 'föobar'"
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config.set_rule_option('title-max-length', u'föobar', 60)
+
+ # invalid option value
+ expected_error_msg = u"'föo' is not a valid value for option 'title-max-length.line-length'. " + \
+ u"Option 'line-length' must be a positive integer (current value: 'föo')."
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config.set_rule_option('title-max-length', 'line-length', u"föo")
+
+ def test_set_general_option(self):
+ config = LintConfig()
+
+ # Check that default general options are correct
+ self.assertTrue(config.ignore_merge_commits)
+ self.assertTrue(config.ignore_fixup_commits)
+ self.assertTrue(config.ignore_squash_commits)
+ self.assertTrue(config.ignore_revert_commits)
+
+ self.assertFalse(config.ignore_stdin)
+ self.assertFalse(config.staged)
+ self.assertFalse(config.debug)
+ self.assertEqual(config.verbosity, 3)
+ active_rule_classes = tuple(type(rule) for rule in config.rules)
+ self.assertTupleEqual(active_rule_classes, config.default_rule_classes)
+
+ # ignore - set by string
+ config.set_general_option("ignore", "title-trailing-whitespace, B2")
+ self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
+
+ # ignore - set by list
+ config.set_general_option("ignore", ["T1", "B3"])
+ self.assertEqual(config.ignore, ["T1", "B3"])
+
+ # verbosity
+ config.set_general_option("verbosity", 1)
+ self.assertEqual(config.verbosity, 1)
+
+ # ignore_merge_commit
+ config.set_general_option("ignore-merge-commits", "false")
+ self.assertFalse(config.ignore_merge_commits)
+
+ # ignore_fixup_commit
+ config.set_general_option("ignore-fixup-commits", "false")
+ self.assertFalse(config.ignore_fixup_commits)
+
+ # ignore_squash_commit
+ config.set_general_option("ignore-squash-commits", "false")
+ self.assertFalse(config.ignore_squash_commits)
+
+ # ignore_revert_commit
+ config.set_general_option("ignore-revert-commits", "false")
+ self.assertFalse(config.ignore_revert_commits)
+
+ # debug
+ config.set_general_option("debug", "true")
+ self.assertTrue(config.debug)
+
+ # ignore-stdin
+ config.set_general_option("ignore-stdin", "true")
+ self.assertTrue(config.debug)
+
+ # staged
+ config.set_general_option("staged", "true")
+ self.assertTrue(config.staged)
+
+ # target
+ config.set_general_option("target", self.SAMPLES_DIR)
+ self.assertEqual(config.target, self.SAMPLES_DIR)
+
+ # extra_path has its own test: test_extra_path and test_extra_path_negative
+ # contrib has its own tests: test_contrib and test_contrib_negative
+
+ def test_contrib(self):
+ config = LintConfig()
+ contrib_rules = ["contrib-title-conventional-commits", "CC1"]
+ config.set_general_option("contrib", ",".join(contrib_rules))
+ self.assertEqual(config.contrib, contrib_rules)
+
+ # Check contrib-title-conventional-commits contrib rule
+ actual_rule = config.rules.find_rule("contrib-title-conventional-commits")
+ self.assertTrue(actual_rule.is_contrib)
+
+ self.assertEqual(ustr(type(actual_rule)), "<class 'conventional_commit.ConventionalCommit'>")
+ self.assertEqual(actual_rule.id, 'CT1')
+ self.assertEqual(actual_rule.name, u'contrib-title-conventional-commits')
+ self.assertEqual(actual_rule.target, rules.CommitMessageTitle)
+
+ expected_rule_option = options.ListOption(
+ "types",
+ ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"],
+ "Comma separated list of allowed commit types.",
+ )
+
+ self.assertListEqual(actual_rule.options_spec, [expected_rule_option])
+ self.assertDictEqual(actual_rule.options, {'types': expected_rule_option})
+
+ # Check contrib-body-requires-signed-off-by contrib rule
+ actual_rule = config.rules.find_rule("contrib-body-requires-signed-off-by")
+ self.assertTrue(actual_rule.is_contrib)
+
+ self.assertEqual(ustr(type(actual_rule)), "<class 'signedoff_by.SignedOffBy'>")
+ self.assertEqual(actual_rule.id, 'CC1')
+ self.assertEqual(actual_rule.name, u'contrib-body-requires-signed-off-by')
+
+ # reset value (this is a different code path)
+ config.set_general_option("contrib", "contrib-body-requires-signed-off-by")
+ self.assertEqual(actual_rule, config.rules.find_rule("contrib-body-requires-signed-off-by"))
+ self.assertIsNone(config.rules.find_rule("contrib-title-conventional-commits"))
+
+ # empty value
+ config.set_general_option("contrib", "")
+ self.assertListEqual(config.contrib, [])
+
+ def test_contrib_negative(self):
+ config = LintConfig()
+ # non-existent contrib rule
+ with self.assertRaisesRegex(LintConfigError, u"No contrib rule with id or name 'föo' found."):
+ config.contrib = u"contrib-title-conventional-commits,föo"
+
+ # UserRuleError, RuleOptionError should be re-raised as LintConfigErrors
+ side_effects = [rules.UserRuleError(u"üser-rule"), options.RuleOptionError(u"rüle-option")]
+ for side_effect in side_effects:
+ with patch('gitlint.config.rule_finder.find_rule_classes', side_effect=side_effect):
+ with self.assertRaisesRegex(LintConfigError, ustr(side_effect)):
+ config.contrib = u"contrib-title-conventional-commits"
+
+ def test_extra_path(self):
+ config = LintConfig()
+
+ config.set_general_option("extra-path", self.get_user_rules_path())
+ self.assertEqual(config.extra_path, self.get_user_rules_path())
+ actual_rule = config.rules.find_rule('UC1')
+ self.assertTrue(actual_rule.is_user_defined)
+ self.assertEqual(ustr(type(actual_rule)), "<class 'my_commit_rules.MyUserCommitRule'>")
+ self.assertEqual(actual_rule.id, 'UC1')
+ self.assertEqual(actual_rule.name, u'my-üser-commit-rule')
+ self.assertEqual(actual_rule.target, None)
+ expected_rule_option = options.IntOption('violation-count', 1, u"Number of violåtions to return")
+ self.assertListEqual(actual_rule.options_spec, [expected_rule_option])
+ self.assertDictEqual(actual_rule.options, {'violation-count': expected_rule_option})
+
+ # reset value (this is a different code path)
+ config.set_general_option("extra-path", self.SAMPLES_DIR)
+ self.assertEqual(config.extra_path, self.SAMPLES_DIR)
+ self.assertIsNone(config.rules.find_rule("UC1"))
+
+ def test_extra_path_negative(self):
+ config = LintConfig()
+ regex = u"Option extra-path must be either an existing directory or file (current value: 'föo/bar')"
+ # incorrect extra_path
+ with self.assertRaisesRegex(LintConfigError, regex):
+ config.extra_path = u"föo/bar"
+
+ # extra path contains classes with errors
+ with self.assertRaisesRegex(LintConfigError,
+ "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
+ config.extra_path = self.get_sample_path("user_rules/incorrect_linerule")
+
+ def test_set_general_option_negative(self):
+ config = LintConfig()
+
+ # Note that we shouldn't test whether we can set unicode because python just doesn't allow unicode attributes
+ with self.assertRaisesRegex(LintConfigError, "'foo' is not a valid gitlint option"):
+ config.set_general_option("foo", u"bår")
+
+ # try setting _config_path, this is a real attribute of LintConfig, but the code should prevent it from
+ # being set
+ with self.assertRaisesRegex(LintConfigError, "'_config_path' is not a valid gitlint option"):
+ config.set_general_option("_config_path", u"bår")
+
+ # invalid verbosity
+ incorrect_values = [-1, u"föo"]
+ for value in incorrect_values:
+ expected_msg = u"Option 'verbosity' must be a positive integer (current value: '{0}')".format(value)
+ with self.assertRaisesRegex(LintConfigError, expected_msg):
+ config.verbosity = value
+
+ incorrect_values = [4]
+ for value in incorrect_values:
+ with self.assertRaisesRegex(LintConfigError, "Option 'verbosity' must be set between 0 and 3"):
+ config.verbosity = value
+
+ # invalid ignore_xxx_commits
+ ignore_attributes = ["ignore_merge_commits", "ignore_fixup_commits", "ignore_squash_commits",
+ "ignore_revert_commits"]
+ incorrect_values = [-1, 4, u"föo"]
+ for attribute in ignore_attributes:
+ for value in incorrect_values:
+ option_name = attribute.replace("_", "-")
+ with self.assertRaisesRegex(LintConfigError,
+ "Option '{0}' must be either 'true' or 'false'".format(option_name)):
+ setattr(config, attribute, value)
+
+ # invalid ignore -> not here because ignore is a ListOption which converts everything to a string before
+ # splitting which means it it will accept just about everything
+
+ # invalid boolean options
+ for attribute in ['debug', 'staged', 'ignore_stdin']:
+ option_name = attribute.replace("_", "-")
+ with self.assertRaisesRegex(LintConfigError,
+ "Option '{0}' must be either 'true' or 'false'".format(option_name)):
+ setattr(config, attribute, u"föobar")
+
+ # extra-path has its own negative test
+
+ # invalid target
+ with self.assertRaisesRegex(LintConfigError,
+ u"Option target must be an existing directory (current value: 'föo/bar')"):
+ config.target = u"föo/bar"
+
+ def test_ignore_independent_from_rules(self):
+ # Test that the lintconfig rules are not modified when setting config.ignore
+ # This was different in the past, this test is mostly here to catch regressions
+ config = LintConfig()
+ original_rules = config.rules
+ config.ignore = ["T1", "T2"]
+ self.assertEqual(config.ignore, ["T1", "T2"])
+ self.assertSequenceEqual(config.rules, original_rules)
+
+
+class LintConfigGeneratorTests(BaseTestCase):
+ @staticmethod
+ @patch('gitlint.config.shutil.copyfile')
+ def test_install_commit_msg_hook_negative(copy):
+ LintConfigGenerator.generate_config(u"föo/bar/test")
+ copy.assert_called_with(GITLINT_CONFIG_TEMPLATE_SRC_PATH, u"föo/bar/test")
diff --git a/gitlint/tests/config/test_config_builder.py b/gitlint/tests/config/test_config_builder.py
new file mode 100644
index 0000000..051a52f
--- /dev/null
+++ b/gitlint/tests/config/test_config_builder.py
@@ -0,0 +1,203 @@
+# -*- coding: utf-8 -*-
+
+from gitlint.tests.base import BaseTestCase
+
+from gitlint.config import LintConfig, LintConfigBuilder, LintConfigError
+
+
+class LintConfigBuilderTests(BaseTestCase):
+ def test_set_option(self):
+ config_builder = LintConfigBuilder()
+ config = config_builder.build()
+
+ # assert some defaults
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 72)
+ self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80)
+ self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["WIP"])
+ self.assertEqual(config.verbosity, 3)
+
+ # Make some changes and check blueprint
+ config_builder.set_option('title-max-length', 'line-length', 100)
+ config_builder.set_option('general', 'verbosity', 2)
+ config_builder.set_option('title-must-not-contain-word', 'words', ["foo", "bar"])
+ expected_blueprint = {'title-must-not-contain-word': {'words': ['foo', 'bar']},
+ 'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
+ self.assertDictEqual(config_builder._config_blueprint, expected_blueprint)
+
+ # Build config and verify that the changes have occurred and no other changes
+ config = config_builder.build()
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 100)
+ self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80) # should be unchanged
+ self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["foo", "bar"])
+ self.assertEqual(config.verbosity, 2)
+
+ def test_set_from_commit_ignore_all(self):
+ config = LintConfig()
+ original_rules = config.rules
+ original_rule_ids = [rule.id for rule in original_rules]
+
+ config_builder = LintConfigBuilder()
+
+ # nothing gitlint
+ config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint\nfoo"))
+ config = config_builder.build()
+ self.assertSequenceEqual(config.rules, original_rules)
+ self.assertListEqual(config.ignore, [])
+
+ # ignore all rules
+ config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ # ignore all rules, no space
+ config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore:all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ # ignore all rules, more spacing
+ config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: \t all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ def test_set_from_commit_ignore_specific(self):
+ # ignore specific rules
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: T1, body-hard-tab"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, ["T1", "body-hard-tab"])
+
+ def test_set_from_config_file(self):
+ # regular config file load, no problems
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(self.get_sample_path("config/gitlintconfig"))
+ config = config_builder.build()
+
+ # Do some assertions on the config
+ self.assertEqual(config.verbosity, 1)
+ self.assertFalse(config.debug)
+ self.assertFalse(config.ignore_merge_commits)
+ self.assertIsNone(config.extra_path)
+ self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
+
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 20)
+ self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 30)
+
+ def test_set_from_config_file_negative(self):
+ config_builder = LintConfigBuilder()
+
+ # bad config file load
+ foo_path = self.get_sample_path(u"föo")
+ expected_error_msg = u"Invalid file path: {0}".format(foo_path)
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.set_from_config_file(foo_path)
+
+ # error during file parsing
+ path = self.get_sample_path("config/no-sections")
+ expected_error_msg = u"File contains no section headers."
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.set_from_config_file(path)
+
+ # non-existing rule
+ path = self.get_sample_path("config/nonexisting-rule")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = u"No such rule 'föobar'"
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # non-existing general option
+ path = self.get_sample_path("config/nonexisting-general-option")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = u"'foo' is not a valid gitlint option"
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # non-existing option
+ path = self.get_sample_path("config/nonexisting-option")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = u"Rule 'title-max-length' has no option 'föobar'"
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # invalid option value
+ path = self.get_sample_path("config/invalid-option-value")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = u"'föo' is not a valid value for option 'title-max-length.line-length'. " + \
+ u"Option 'line-length' must be a positive integer (current value: 'föo')."
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ def test_set_config_from_string_list(self):
+ config = LintConfig()
+
+ # change and assert changes
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_string_list(['general.verbosity=1', 'title-max-length.line-length=60',
+ 'body-max-line-length.line-length=120',
+ u"title-must-not-contain-word.words=håha"])
+
+ config = config_builder.build()
+ self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 60)
+ self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 120)
+ self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), [u"håha"])
+ self.assertEqual(config.verbosity, 1)
+
+ def test_set_config_from_string_list_negative(self):
+ config_builder = LintConfigBuilder()
+
+ # assert error on incorrect rule - this happens at build time
+ config_builder.set_config_from_string_list([u"föo.bar=1"])
+ with self.assertRaisesRegex(LintConfigError, u"No such rule 'föo'"):
+ config_builder.build()
+
+ # no equal sign
+ expected_msg = u"'föo.bar' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesRegex(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list([u"föo.bar"])
+
+ # missing value
+ expected_msg = u"'föo.bar=' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesRegex(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list([u"föo.bar="])
+
+ # space instead of equal sign
+ expected_msg = u"'föo.bar 1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesRegex(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list([u"föo.bar 1"])
+
+ # no period between rule and option names
+ expected_msg = u"'föobar=1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesRegex(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list([u'föobar=1'])
+
+ def test_rebuild_config(self):
+ # normal config build
+ config_builder = LintConfigBuilder()
+ config_builder.set_option('general', 'verbosity', 3)
+ lint_config = config_builder.build()
+ self.assertEqual(lint_config.verbosity, 3)
+
+ # check that existing config gets overwritten when we pass it to a configbuilder with different options
+ existing_lintconfig = LintConfig()
+ existing_lintconfig.verbosity = 2
+ lint_config = config_builder.build(existing_lintconfig)
+ self.assertEqual(lint_config.verbosity, 3)
+ self.assertEqual(existing_lintconfig.verbosity, 3)
+
+ def test_clone(self):
+ config_builder = LintConfigBuilder()
+ config_builder.set_option('general', 'verbosity', 2)
+ config_builder.set_option('title-max-length', 'line-length', 100)
+ expected = {'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
+ self.assertDictEqual(config_builder._config_blueprint, expected)
+
+ # Clone and verify that the blueprint is the same as the original
+ cloned_builder = config_builder.clone()
+ self.assertDictEqual(cloned_builder._config_blueprint, expected)
+
+ # Modify the original and make sure we're not modifying the clone (i.e. check that the copy is a deep copy)
+ config_builder.set_option('title-max-length', 'line-length', 120)
+ self.assertDictEqual(cloned_builder._config_blueprint, expected)
diff --git a/gitlint/tests/config/test_config_precedence.py b/gitlint/tests/config/test_config_precedence.py
new file mode 100644
index 0000000..9689e55
--- /dev/null
+++ b/gitlint/tests/config/test_config_precedence.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+
+try:
+ # python 2.x
+ from StringIO import StringIO
+except ImportError:
+ # python 3.x
+ from io import StringIO
+
+from click.testing import CliRunner
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint import cli
+from gitlint.config import LintConfigBuilder
+
+
+class LintConfigPrecedenceTests(BaseTestCase):
+ def setUp(self):
+ self.cli = CliRunner()
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"WIP\n\nThis is å test message\n")
+ def test_config_precedence(self, _):
+ # TODO(jroovers): this test really only test verbosity, we need to do some refactoring to gitlint.cli
+ # to more easily test everything
+ # Test that the config precedence is followed:
+ # 1. commandline convenience flags
+ # 2. commandline -c flags
+ # 3. config file
+ # 4. default config
+ config_path = self.get_sample_path("config/gitlintconfig")
+
+ # 1. commandline convenience flags
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-vvv", "-c", "general.verbosity=2", "--config", config_path])
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP\"\n")
+
+ # 2. commandline -c flags
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-c", "general.verbosity=2", "--config", config_path])
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive)\n")
+
+ # 3. config file
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5\n")
+
+ # 4. default config
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli)
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP\"\n")
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: This is å test")
+ def test_ignore_precedence(self, get_stdin_data):
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ # --ignore takes precedence over -c general.ignore
+ result = self.cli.invoke(cli.cli, ["-c", "general.ignore=T5", "--ignore", "B6"])
+ self.assertEqual(result.output, "")
+ self.assertEqual(result.exit_code, 1)
+ # We still expect the T5 violation, but no B6 violation as --ignore overwrites -c general.ignore
+ self.assertEqual(stderr.getvalue(),
+ u"1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is å test\"\n")
+
+ # test that we can also still configure a rule that is first ignored but then not
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ get_stdin_data.return_value = u"This is å test"
+ # --ignore takes precedence over -c general.ignore
+ result = self.cli.invoke(cli.cli, ["-c", "general.ignore=title-max-length",
+ "-c", "title-max-length.line-length=5",
+ "--ignore", "B6"])
+ self.assertEqual(result.output, "")
+ self.assertEqual(result.exit_code, 1)
+
+ # We still expect the T1 violation with custom config,
+ # but no B6 violation as --ignore overwrites -c general.ignore
+ self.assertEqual(stderr.getvalue(), u"1: T1 Title exceeds max length (14>5): \"This is å test\"\n")
+
+ def test_general_option_after_rule_option(self):
+ # We used to have a bug where we didn't process general options before setting specific options, this would
+ # lead to errors when e.g.: trying to configure a user rule before the rule class was loaded by extra-path
+ # This test is here to test for regressions against this.
+
+ config_builder = LintConfigBuilder()
+ config_builder.set_option(u'my-üser-commit-rule', 'violation-count', 3)
+ user_rules_path = self.get_sample_path("user_rules")
+ config_builder.set_option('general', 'extra-path', user_rules_path)
+ config = config_builder.build()
+
+ self.assertEqual(config.extra_path, user_rules_path)
+ self.assertEqual(config.get_rule_option(u'my-üser-commit-rule', 'violation-count'), 3)
diff --git a/gitlint/tests/config/test_rule_collection.py b/gitlint/tests/config/test_rule_collection.py
new file mode 100644
index 0000000..089992c
--- /dev/null
+++ b/gitlint/tests/config/test_rule_collection.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+
+from collections import OrderedDict
+from gitlint import rules
+from gitlint.config import RuleCollection
+from gitlint.tests.base import BaseTestCase
+
+
+class RuleCollectionTests(BaseTestCase):
+
+ def test_add_rule(self):
+ collection = RuleCollection()
+ collection.add_rule(rules.TitleMaxLength, u"my-rüle", {"my_attr": u"föo", "my_attr2": 123})
+
+ expected = rules.TitleMaxLength()
+ expected.id = u"my-rüle"
+ expected.my_attr = u"föo"
+ expected.my_attr2 = 123
+
+ self.assertEqual(len(collection), 1)
+ self.assertDictEqual(collection._rules, OrderedDict({u"my-rüle": expected}))
+ # Need to explicitely compare expected attributes as the rule.__eq__ method does not compare these attributes
+ self.assertEqual(collection._rules[expected.id].my_attr, expected.my_attr)
+ self.assertEqual(collection._rules[expected.id].my_attr2, expected.my_attr2)
+
+ def test_add_find_rule(self):
+ collection = RuleCollection()
+ collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"my_attr": u"föo"})
+
+ # find by id
+ expected = rules.TitleMaxLength()
+ rule = collection.find_rule('T1')
+ self.assertEqual(rule, expected)
+ self.assertEqual(rule.my_attr, u"föo")
+
+ # find by name
+ expected2 = rules.TitleTrailingWhitespace()
+ rule = collection.find_rule('title-trailing-whitespace')
+ self.assertEqual(rule, expected2)
+ self.assertEqual(rule.my_attr, u"föo")
+
+ # find non-existing
+ rule = collection.find_rule(u'föo')
+ self.assertIsNone(rule)
+
+ def test_delete_rules_by_attr(self):
+ collection = RuleCollection()
+ collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"foo": u"bår"})
+ collection.add_rules([rules.BodyHardTab], {"hur": u"dûr"})
+
+ # Assert all rules are there as expected
+ self.assertEqual(len(collection), 3)
+ for expected_rule in [rules.TitleMaxLength(), rules.TitleTrailingWhitespace(), rules.BodyHardTab()]:
+ self.assertEqual(collection.find_rule(expected_rule.id), expected_rule)
+
+ # Delete rules by attr, assert that we still have the right rules in the collection
+ collection.delete_rules_by_attr("foo", u"bår")
+ self.assertEqual(len(collection), 1)
+ self.assertIsNone(collection.find_rule(rules.TitleMaxLength.id), None)
+ self.assertIsNone(collection.find_rule(rules.TitleTrailingWhitespace.id), None)
+
+ found = collection.find_rule(rules.BodyHardTab.id)
+ self.assertEqual(found, rules.BodyHardTab())
+ self.assertEqual(found.hur, u"dûr")
diff --git a/gitlint/tests/contrib/__init__.py b/gitlint/tests/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/tests/contrib/__init__.py
diff --git a/gitlint/tests/contrib/test_contrib_rules.py b/gitlint/tests/contrib/test_contrib_rules.py
new file mode 100644
index 0000000..3fa4048
--- /dev/null
+++ b/gitlint/tests/contrib/test_contrib_rules.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+import os
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.contrib import rules as contrib_rules
+from gitlint.tests import contrib as contrib_tests
+from gitlint import rule_finder, rules
+
+from gitlint.utils import ustr
+
+
+class ContribRuleTests(BaseTestCase):
+
+ CONTRIB_DIR = os.path.dirname(os.path.realpath(contrib_rules.__file__))
+
+ def test_contrib_tests_exist(self):
+ """ Tests that every contrib rule file has an associated test file.
+ While this doesn't guarantee that every contrib rule has associated tests (as we don't check the content
+ of the tests file), it's a good leading indicator. """
+
+ contrib_tests_dir = os.path.dirname(os.path.realpath(contrib_tests.__file__))
+ contrib_test_files = os.listdir(contrib_tests_dir)
+
+ # Find all python files in the contrib dir and assert there's a corresponding test file
+ for filename in os.listdir(self.CONTRIB_DIR):
+ if filename.endswith(".py") and filename not in ["__init__.py"]:
+ expected_test_file = ustr(u"test_" + filename)
+ error_msg = u"Every Contrib Rule must have associated tests. " + \
+ "Expected test file {0} not found.".format(os.path.join(contrib_tests_dir,
+ expected_test_file))
+ self.assertIn(expected_test_file, contrib_test_files, error_msg)
+
+ def test_contrib_rule_naming_conventions(self):
+ """ Tests that contrib rules follow certain naming conventions.
+ We can test for this at test time (and not during runtime like rule_finder.assert_valid_rule_class does)
+ because these are contrib rules: once they're part of gitlint they can't change unless they pass this test
+ again.
+ """
+ rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
+
+ for clazz in rule_classes:
+ # Contrib rule names start with "contrib-"
+ self.assertTrue(clazz.name.startswith("contrib-"))
+
+ # Contrib line rules id's start with "CL"
+ if issubclass(clazz, rules.LineRule):
+ if clazz.target == rules.CommitMessageTitle:
+ self.assertTrue(clazz.id.startswith("CT"))
+ elif clazz.target == rules.CommitMessageBody:
+ self.assertTrue(clazz.id.startswith("CB"))
+
+ def test_contrib_rule_uniqueness(self):
+ """ Tests that all contrib rules have unique identifiers.
+ We can test for this at test time (and not during runtime like rule_finder.assert_valid_rule_class does)
+ because these are contrib rules: once they're part of gitlint they can't change unless they pass this test
+ again.
+ """
+ rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
+
+ # Not very efficient way of checking uniqueness, but it works :-)
+ class_names = [rule_class.name for rule_class in rule_classes]
+ class_ids = [rule_class.id for rule_class in rule_classes]
+ self.assertEqual(len(set(class_names)), len(class_names))
+ self.assertEqual(len(set(class_ids)), len(class_ids))
+
+ def test_contrib_rule_instantiated(self):
+ """ Tests that all contrib rules can be instantiated without errors. """
+ rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
+
+ # No exceptions = what we want :-)
+ for rule_class in rule_classes:
+ rule_class()
diff --git a/gitlint/tests/contrib/test_conventional_commit.py b/gitlint/tests/contrib/test_conventional_commit.py
new file mode 100644
index 0000000..ea808fd
--- /dev/null
+++ b/gitlint/tests/contrib/test_conventional_commit.py
@@ -0,0 +1,47 @@
+
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import RuleViolation
+from gitlint.contrib.rules.conventional_commit import ConventionalCommit
+from gitlint.config import LintConfig
+
+
+class ContribConventionalCommitTests(BaseTestCase):
+
+ def test_enable(self):
+ # Test that rule can be enabled in config
+ for rule_ref in ['CT1', 'contrib-title-conventional-commits']:
+ config = LintConfig()
+ config.contrib = [rule_ref]
+ self.assertIn(ConventionalCommit(), config.rules)
+
+ def test_conventional_commits(self):
+ rule = ConventionalCommit()
+
+ # No violations when using a correct type and format
+ for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"]:
+ violations = rule.validate(type + u": föo", None)
+ self.assertListEqual([], violations)
+
+ # assert violation on wrong type
+ expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs,"
+ " style, refactor, perf, test, revert", u"bår: foo")
+ violations = rule.validate(u"bår: foo", None)
+ self.assertListEqual([expected_violation], violations)
+
+ # assert violation on wrong format
+ expected_violation = RuleViolation("CT1", "Title does not follow ConventionalCommits.org format "
+ "'type(optional-scope): description'", u"fix föo")
+ violations = rule.validate(u"fix föo", None)
+ self.assertListEqual([expected_violation], violations)
+
+ # assert no violation when adding new type
+ rule = ConventionalCommit({'types': [u"föo", u"bär"]})
+ for typ in [u"föo", u"bär"]:
+ violations = rule.validate(typ + u": hür dur", None)
+ self.assertListEqual([], violations)
+
+ # assert violation when using incorrect type when types have been reconfigured
+ violations = rule.validate(u"fix: hür dur", None)
+ expected_violation = RuleViolation("CT1", u"Title does not start with one of föo, bär", u"fix: hür dur")
+ self.assertListEqual([expected_violation], violations)
diff --git a/gitlint/tests/contrib/test_signedoff_by.py b/gitlint/tests/contrib/test_signedoff_by.py
new file mode 100644
index 0000000..934aec5
--- /dev/null
+++ b/gitlint/tests/contrib/test_signedoff_by.py
@@ -0,0 +1,32 @@
+
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import RuleViolation
+from gitlint.contrib.rules.signedoff_by import SignedOffBy
+
+from gitlint.config import LintConfig
+
+
+class ContribSignedOffByTests(BaseTestCase):
+
+ def test_enable(self):
+ # Test that rule can be enabled in config
+ for rule_ref in ['CC1', 'contrib-body-requires-signed-off-by']:
+ config = LintConfig()
+ config.contrib = [rule_ref]
+ self.assertIn(SignedOffBy(), config.rules)
+
+ def test_signedoff_by(self):
+ # No violations when 'Signed-Off-By' line is present
+ rule = SignedOffBy()
+ violations = rule.validate(self.gitcommit(u"Föobar\n\nMy Body\nSigned-Off-By: John Smith"))
+ self.assertListEqual([], violations)
+
+ # Assert violation when no 'Signed-Off-By' line is present
+ violations = rule.validate(self.gitcommit(u"Föobar\n\nMy Body"))
+ expected_violation = RuleViolation("CC1", "Body does not contain a 'Signed-Off-By' line", line_nr=1)
+ self.assertListEqual(violations, [expected_violation])
+
+ # Assert violation when no 'Signed-Off-By' in title but not in body
+ violations = rule.validate(self.gitcommit(u"Signed-Off-By\n\nFöobar"))
+ self.assertListEqual(violations, [expected_violation])
diff --git a/gitlint/tests/expected/test_cli/test_contrib_1 b/gitlint/tests/expected/test_cli/test_contrib_1
new file mode 100644
index 0000000..ea5d353
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_contrib_1
@@ -0,0 +1,3 @@
+1: CC1 Body does not contain a 'Signed-Off-By' line
+1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert: "Test tïtle"
+1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"
diff --git a/gitlint/tests/expected/test_cli/test_debug_1 b/gitlint/tests/expected/test_cli/test_debug_1
new file mode 100644
index 0000000..612f78e
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_debug_1
@@ -0,0 +1,102 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: git version 1.2.3
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: {config_path}
+[GENERAL]
+extra-path: None
+contrib: []
+ignore: title-trailing-whitespace,B2
+ignore-merge-commits: False
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: False
+verbosity: 1
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=20
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP,bögus
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=30
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo.
+DEBUG: gitlint.cli Linting 3 commit(s)
+DEBUG: gitlint.lint Linting commit 6f29bf81a8322a04071bb794666e48c443a90360
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+commït-title1
+
+commït-body1
+--- Meta info ---------
+Author: test åuthor1 <test-email1@föo.com>
+Date: 2016-12-03 15:28:15 +0100
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['commit-1-branch-1', 'commit-1-branch-2']
+Changed Files: ['commit-1/file-1', 'commit-1/file-2']
+-----------------------
+DEBUG: gitlint.lint Linting commit 25053ccec5e28e1bb8f7551fdbb5ab213ada2401
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+commït-title2.
+
+commït-body2
+--- Meta info ---------
+Author: test åuthor2 <test-email2@föo.com>
+Date: 2016-12-04 15:28:15 +0100
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['commit-2-branch-1', 'commit-2-branch-2']
+Changed Files: ['commit-2/file-1', 'commit-2/file-2']
+-----------------------
+DEBUG: gitlint.lint Linting commit 4da2656b0dadc76c7ee3fd0243a96cb64007f125
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+föo
+bar
+--- Meta info ---------
+Author: test åuthor3 <test-email3@föo.com>
+Date: 2016-12-05 15:28:15 +0100
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['commit-3-branch-1', 'commit-3-branch-2']
+Changed Files: ['commit-3/file-1', 'commit-3/file-2']
+-----------------------
+DEBUG: gitlint.cli Exit Code = 6 \ No newline at end of file
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_1 b/gitlint/tests/expected/test_cli/test_input_stream_1
new file mode 100644
index 0000000..4326729
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_input_stream_1
@@ -0,0 +1,3 @@
+1: T2 Title has trailing whitespace: "WIP: tïtle "
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
+3: B6 Body message is missing
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_debug_1 b/gitlint/tests/expected/test_cli/test_input_stream_debug_1
new file mode 100644
index 0000000..4326729
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_input_stream_debug_1
@@ -0,0 +1,3 @@
+1: T2 Title has trailing whitespace: "WIP: tïtle "
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
+3: B6 Body message is missing
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_debug_2 b/gitlint/tests/expected/test_cli/test_input_stream_debug_2
new file mode 100644
index 0000000..a9028e1
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_input_stream_debug_2
@@ -0,0 +1,71 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: git version 1.2.3
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: False
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=72
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=80
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli Stdin data: 'WIP: tïtle
+'
+DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: tïtle
+--- Meta info ---------
+Author: None <None>
+Date: None
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: []
+Changed Files: []
+-----------------------
+DEBUG: gitlint.cli Exit Code = 3 \ No newline at end of file
diff --git a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1 b/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1
new file mode 100644
index 0000000..be3288b
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1
@@ -0,0 +1,8 @@
+Commit 6f29bf81a8:
+3: B5 Body message is too short (12<20): "commït-body1"
+
+Commit 25053ccec5:
+3: B5 Body message is too short (12<20): "commït-body2"
+
+Commit 4da2656b0d:
+3: B5 Body message is too short (12<20): "commït-body3"
diff --git a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1 b/gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1
new file mode 100644
index 0000000..1bf0503
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1
@@ -0,0 +1,6 @@
+Commit 6f29bf81a8:
+3: B5 Body message is too short (12<20): "commït-body1"
+
+Commit 4da2656b0d:
+1: T3 Title has trailing punctuation (.): "commït-title3."
+3: B5 Body message is too short (12<20): "commït-body3"
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1 b/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1
new file mode 100644
index 0000000..9a9091b
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-filename tïtle"
+3: B6 Body message is missing
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2 b/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2
new file mode 100644
index 0000000..3e5dcb6
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2
@@ -0,0 +1,70 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: git version 1.2.3
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: True
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=72
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=80
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli Fetching additional meta-data from staged commit
+DEBUG: gitlint.cli Using --msg-filename.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: msg-filename tïtle
+--- Meta info ---------
+Author: föo user <föo@bar.com>
+Date: 2020-02-19 12:18:46 +0100
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['my-branch']
+Changed Files: ['commit-1/file-1', 'commit-1/file-2']
+-----------------------
+DEBUG: gitlint.cli Exit Code = 2 \ No newline at end of file
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1 b/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1
new file mode 100644
index 0000000..4326729
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1
@@ -0,0 +1,3 @@
+1: T2 Title has trailing whitespace: "WIP: tïtle "
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
+3: B6 Body message is missing
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2 b/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
new file mode 100644
index 0000000..03fd8c3
--- /dev/null
+++ b/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
@@ -0,0 +1,72 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: git version 1.2.3
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: True
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=72
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=80
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli Fetching additional meta-data from staged commit
+DEBUG: gitlint.cli Stdin data: 'WIP: tïtle
+'
+DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: tïtle
+--- Meta info ---------
+Author: föo user <föo@bar.com>
+Date: 2020-02-19 12:18:46 +0100
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['my-branch']
+Changed Files: ['commit-1/file-1', 'commit-1/file-2']
+-----------------------
+DEBUG: gitlint.cli Exit Code = 3 \ No newline at end of file
diff --git a/gitlint/tests/git/test_git.py b/gitlint/tests/git/test_git.py
new file mode 100644
index 0000000..297b10c
--- /dev/null
+++ b/gitlint/tests/git/test_git.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+import os
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.shell import ErrorReturnCode, CommandNotFound
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.git import GitContext, GitContextError, GitNotInstalledError, git_commentchar, git_hooks_dir
+
+
+class GitTests(BaseTestCase):
+
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {
+ '_tty_out': False,
+ '_cwd': u"fåke/path"
+ }
+
+ @patch('gitlint.git.sh')
+ def test_get_latest_commit_command_not_found(self, sh):
+ sh.git.side_effect = CommandNotFound("git")
+ expected_msg = "'git' command not found. You need to install git to use gitlint on a local repository. " + \
+ "See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git."
+ with self.assertRaisesRegex(GitNotInstalledError, expected_msg):
+ GitContext.from_local_repository(u"fåke/path")
+
+ # assert that commit message was read using git command
+ sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
+
+ @patch('gitlint.git.sh')
+ def test_get_latest_commit_git_error(self, sh):
+ # Current directory not a git repo
+ err = b"fatal: Not a git repository (or any of the parent directories): .git"
+ sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
+
+ with self.assertRaisesRegex(GitContextError, u"fåke/path is not a git repository."):
+ GitContext.from_local_repository(u"fåke/path")
+
+ # assert that commit message was read using git command
+ sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
+ sh.git.reset_mock()
+
+ err = b"fatal: Random git error"
+ sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
+
+ expected_msg = u"An error occurred while executing 'git log -1 --pretty=%H': {0}".format(err)
+ with self.assertRaisesRegex(GitContextError, expected_msg):
+ GitContext.from_local_repository(u"fåke/path")
+
+ # assert that commit message was read using git command
+ sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
+
+ @patch('gitlint.git.sh')
+ def test_git_no_commits_error(self, sh):
+ # No commits: returned by 'git log'
+ err = b"fatal: your current branch 'master' does not have any commits yet"
+
+ sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
+
+ expected_msg = u"Current branch has no commits. Gitlint requires at least one commit to function."
+ with self.assertRaisesRegex(GitContextError, expected_msg):
+ GitContext.from_local_repository(u"fåke/path")
+
+ # assert that commit message was read using git command
+ sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
+ sh.git.reset_mock()
+
+ # Unknown reference 'HEAD' commits: returned by 'git rev-parse'
+ err = (b"HEAD"
+ b"fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree."
+ b"Use '--' to separate paths from revisions, like this:"
+ b"'git <command> [<revision>...] -- [<file>...]'")
+
+ sh.git.side_effect = [
+ u"#\n", # git config --get core.commentchar
+ ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err)
+ ]
+
+ with self.assertRaisesRegex(GitContextError, expected_msg):
+ context = GitContext.from_commit_msg(u"test")
+ context.current_branch
+
+ # assert that commit message was read using git command
+ sh.git.assert_called_with("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None)
+
+ @patch("gitlint.git._git")
+ def test_git_commentchar(self, git):
+ git.return_value.exit_code = 1
+ self.assertEqual(git_commentchar(), "#")
+
+ git.return_value.exit_code = 0
+ git.return_value.__str__ = lambda _: u"ä"
+ git.return_value.__unicode__ = lambda _: u"ä"
+ self.assertEqual(git_commentchar(), u"ä")
+
+ git.return_value = ';\n'
+ self.assertEqual(git_commentchar(os.path.join(u"/föo", u"bar")), ';')
+
+ git.assert_called_with("config", "--get", "core.commentchar", _ok_code=[0, 1],
+ _cwd=os.path.join(u"/föo", u"bar"))
+
+ @patch("gitlint.git._git")
+ def test_git_hooks_dir(self, git):
+ hooks_dir = os.path.join(u"föo", ".git", "hooks")
+ git.return_value.__str__ = lambda _: hooks_dir + "\n"
+ git.return_value.__unicode__ = lambda _: hooks_dir + "\n"
+ self.assertEqual(git_hooks_dir(u"/blä"), os.path.abspath(os.path.join(u"/blä", hooks_dir)))
+
+ git.assert_called_once_with("rev-parse", "--git-path", "hooks", _cwd=u"/blä")
diff --git a/gitlint/tests/git/test_git_commit.py b/gitlint/tests/git/test_git_commit.py
new file mode 100644
index 0000000..dc83ccb
--- /dev/null
+++ b/gitlint/tests/git/test_git_commit.py
@@ -0,0 +1,535 @@
+# -*- coding: utf-8 -*-
+import copy
+import datetime
+
+import dateutil
+
+import arrow
+
+try:
+ # python 2.x
+ from mock import patch, call
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch, call # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.git import GitContext, GitCommit, LocalGitCommit, StagedLocalGitCommit, GitCommitMessage
+
+
+class GitCommitTests(BaseTestCase):
+
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {
+ '_tty_out': False,
+ '_cwd': u"fåke/path"
+ }
+
+ @patch('gitlint.git.sh')
+ def test_get_latest_commit(self, sh):
+ sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
+
+ sh.git.side_effect = [
+ sample_sha,
+ u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"cömmit-title\n\ncömmit-body",
+ u"#", # git config --get core.commentchar
+ u"file1.txt\npåth/to/file2.txt\n",
+ u"foöbar\n* hürdur\n"
+ ]
+
+ context = GitContext.from_local_repository(u"fåke/path")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
+ call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
+ **self.expected_sh_special_args),
+ call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
+ ]
+
+ # Only first 'git log' call should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, u"cömmit-title")
+ self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
+ self.assertEqual(last_commit.author_name, u"test åuthor")
+ self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
+ self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
+ tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
+ self.assertListEqual(last_commit.parents, [u"åbc"])
+ self.assertFalse(last_commit.is_merge_commit)
+ self.assertFalse(last_commit.is_fixup_commit)
+ self.assertFalse(last_commit.is_squash_commit)
+ self.assertFalse(last_commit.is_revert_commit)
+
+ # First 2 'git log' calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
+
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+ # 'git diff-tree' should have happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
+
+ self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch('gitlint.git.sh')
+ def test_from_local_repository_specific_ref(self, sh):
+ sample_sha = "myspecialref"
+
+ sh.git.side_effect = [
+ sample_sha,
+ u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"cömmit-title\n\ncömmit-body",
+ u"#", # git config --get core.commentchar
+ u"file1.txt\npåth/to/file2.txt\n",
+ u"foöbar\n* hürdur\n"
+ ]
+
+ context = GitContext.from_local_repository(u"fåke/path", sample_sha)
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("rev-list", sample_sha, **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
+ call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
+ **self.expected_sh_special_args),
+ call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
+ ]
+
+ # Only first 'git log' call should've happened at this point
+ self.assertEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, u"cömmit-title")
+ self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
+ self.assertEqual(last_commit.author_name, u"test åuthor")
+ self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
+ self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
+ tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
+ self.assertListEqual(last_commit.parents, [u"åbc"])
+ self.assertFalse(last_commit.is_merge_commit)
+ self.assertFalse(last_commit.is_fixup_commit)
+ self.assertFalse(last_commit.is_squash_commit)
+ self.assertFalse(last_commit.is_revert_commit)
+
+ # First 2 'git log' calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
+
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+ # 'git diff-tree' should have happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
+
+ self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch('gitlint.git.sh')
+ def test_get_latest_commit_merge_commit(self, sh):
+ sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
+
+ sh.git.side_effect = [
+ sample_sha,
+ u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\n"
+ u"Merge \"foo bår commit\"",
+ u"#", # git config --get core.commentchar
+ u"file1.txt\npåth/to/file2.txt\n",
+ u"foöbar\n* hürdur\n"
+ ]
+
+ context = GitContext.from_local_repository(u"fåke/path")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
+ call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
+ **self.expected_sh_special_args),
+ call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
+ ]
+
+ # Only first 'git log' call should've happened at this point
+ self.assertEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, u"Merge \"foo bår commit\"")
+ self.assertEqual(last_commit.message.body, [])
+ self.assertEqual(last_commit.author_name, u"test åuthor")
+ self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
+ self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
+ tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
+ self.assertListEqual(last_commit.parents, [u"åbc", "def"])
+ self.assertTrue(last_commit.is_merge_commit)
+ self.assertFalse(last_commit.is_fixup_commit)
+ self.assertFalse(last_commit.is_squash_commit)
+ self.assertFalse(last_commit.is_revert_commit)
+
+ # First 2 'git log' calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
+
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+ # 'git diff-tree' should have happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
+
+ self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch('gitlint.git.sh')
+ def test_get_latest_commit_fixup_squash_commit(self, sh):
+ commit_types = ["fixup", "squash"]
+ for commit_type in commit_types:
+ sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
+
+ sh.git.side_effect = [
+ sample_sha,
+ u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"{0}! \"foo bår commit\"".format(commit_type),
+ u"#", # git config --get core.commentchar
+ u"file1.txt\npåth/to/file2.txt\n",
+ u"foöbar\n* hürdur\n"
+ ]
+
+ context = GitContext.from_local_repository(u"fåke/path")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
+ call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
+ **self.expected_sh_special_args),
+ call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
+ ]
+
+ # Only first 'git log' call should've happened at this point
+ self.assertEqual(sh.git.mock_calls, expected_calls[:-4])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, u"{0}! \"foo bår commit\"".format(commit_type))
+ self.assertEqual(last_commit.message.body, [])
+ self.assertEqual(last_commit.author_name, u"test åuthor")
+ self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
+ self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
+ tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
+ self.assertListEqual(last_commit.parents, [u"åbc"])
+
+ # First 2 'git log' calls should've happened at this point
+ self.assertEqual(sh.git.mock_calls, expected_calls[:3])
+
+ # Asserting that squash and fixup are correct
+ for type in commit_types:
+ attr = "is_" + type + "_commit"
+ self.assertEqual(getattr(last_commit, attr), commit_type == type)
+
+ self.assertFalse(last_commit.is_merge_commit)
+ self.assertFalse(last_commit.is_revert_commit)
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+ # 'git diff-tree' should have happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
+
+ self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ sh.git.reset_mock()
+
+ @patch("gitlint.git.git_commentchar")
+ def test_from_commit_msg_full(self, commentchar):
+ commentchar.return_value = u"#"
+ gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample1"))
+
+ expected_title = u"Commit title contåining 'WIP', as well as trailing punctuation."
+ expected_body = ["This line should be empty",
+ "This is the first line of the commit message body and it is meant to test a " +
+ "line that exceeds the maximum line length of 80 characters.",
+ u"This line has a tråiling space. ",
+ "This line has a trailing tab.\t"]
+ expected_full = expected_title + "\n" + "\n".join(expected_body)
+ expected_original = expected_full + (
+ u"\n# This is a cömmented line\n"
+ u"# ------------------------ >8 ------------------------\n"
+ u"# Anything after this line should be cleaned up\n"
+ u"# this line appears on `git commit -v` command\n"
+ u"diff --git a/gitlint/tests/samples/commit_message/sample1 "
+ u"b/gitlint/tests/samples/commit_message/sample1\n"
+ u"index 82dbe7f..ae71a14 100644\n"
+ u"--- a/gitlint/tests/samples/commit_message/sample1\n"
+ u"+++ b/gitlint/tests/samples/commit_message/sample1\n"
+ u"@@ -1 +1 @@\n"
+ )
+
+ commit = gitcontext.commits[-1]
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, expected_title)
+ self.assertEqual(commit.message.body, expected_body)
+ self.assertEqual(commit.message.full, expected_full)
+ self.assertEqual(commit.message.original, expected_original)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_just_title(self):
+ gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample2"))
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, u"Just a title contåining WIP")
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, u"Just a title contåining WIP")
+ self.assertEqual(commit.message.original, u"Just a title contåining WIP")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_empty(self):
+ gitcontext = GitContext.from_commit_msg("")
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, "")
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, "")
+ self.assertEqual(commit.message.original, "")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ @patch("gitlint.git.git_commentchar")
+ def test_from_commit_msg_comment(self, commentchar):
+ commentchar.return_value = u"#"
+ gitcontext = GitContext.from_commit_msg(u"Tïtle\n\nBödy 1\n#Cömment\nBody 2")
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, u"Tïtle")
+ self.assertEqual(commit.message.body, ["", u"Bödy 1", "Body 2"])
+ self.assertEqual(commit.message.full, u"Tïtle\n\nBödy 1\nBody 2")
+ self.assertEqual(commit.message.original, u"Tïtle\n\nBödy 1\n#Cömment\nBody 2")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_merge_commit(self):
+ commit_msg = "Merge f919b8f34898d9b48048bcd703bc47139f4ff621 into 8b0409a26da6ba8a47c1fd2e746872a8dab15401"
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, commit_msg)
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertTrue(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_revert_commit(self):
+ commit_msg = "Revert \"Prev commit message\"\n\nThis reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, "Revert \"Prev commit message\"")
+ self.assertEqual(commit.message.body, ["", "This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertTrue(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_fixup_squash_commit(self):
+ commit_types = ["fixup", "squash"]
+ for commit_type in commit_types:
+ commit_msg = "{0}! Test message".format(commit_type)
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, commit_msg)
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertEqual(len(gitcontext.commits), 1)
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_revert_commit)
+ # Asserting that squash and fixup are correct
+ for type in commit_types:
+ attr = "is_" + type + "_commit"
+ self.assertEqual(getattr(commit, attr), commit_type == type)
+
+ @patch('gitlint.git.sh')
+ @patch('arrow.now')
+ def test_staged_commit(self, now, sh):
+ # StagedLocalGitCommit()
+
+ sh.git.side_effect = [
+ u"#", # git config --get core.commentchar
+ u"test åuthor\n", # git config --get user.name
+ u"test-emåil@foo.com\n", # git config --get user.email
+ u"my-brånch\n", # git rev-parse --abbrev-ref HEAD
+ u"file1.txt\npåth/to/file2.txt\n",
+ ]
+ now.side_effect = [arrow.get("2020-02-19T12:18:46.675182+01:00")]
+
+ # We use a fixup commit, just to test a non-default path
+ context = GitContext.from_staged_commit(u"fixup! Foōbar 123\n\ncömmit-body\n", u"fåke/path")
+
+ # git calls we're expexting
+ expected_calls = [
+ call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
+ call('config', '--get', 'user.name', **self.expected_sh_special_args),
+ call('config', '--get', 'user.email', **self.expected_sh_special_args),
+ call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args),
+ call("diff", "--staged", "--name-only", "-r", **self.expected_sh_special_args)
+ ]
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, StagedLocalGitCommit)
+ self.assertIsNone(last_commit.sha, None)
+ self.assertEqual(last_commit.message.title, u"fixup! Foōbar 123")
+ self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
+ # Only `git config --get core.commentchar` should've happened up until this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:1])
+
+ self.assertEqual(last_commit.author_name, u"test åuthor")
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:2])
+
+ self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:3])
+
+ self.assertEqual(last_commit.date, datetime.datetime(2020, 2, 19, 12, 18, 46,
+ tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
+ now.assert_called_once()
+
+ self.assertListEqual(last_commit.parents, [])
+ self.assertFalse(last_commit.is_merge_commit)
+ self.assertTrue(last_commit.is_fixup_commit)
+ self.assertFalse(last_commit.is_squash_commit)
+ self.assertFalse(last_commit.is_revert_commit)
+
+ self.assertListEqual(last_commit.branches, [u"my-brånch"])
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:4])
+
+ self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:5])
+
+ def test_gitcommitmessage_equality(self):
+ commit_message1 = GitCommitMessage(GitContext(), u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
+ attrs = ['original', 'full', 'title', 'body']
+ self.object_equality_test(commit_message1, attrs, {"context": commit_message1.context})
+
+ def test_gitcommit_equality(self):
+ # Test simple equality case
+ now = datetime.datetime.utcnow()
+ context1 = GitContext()
+ commit_message1 = GitCommitMessage(context1, u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
+ commit1 = GitCommit(context1, commit_message1, u"shä", now, u"Jöhn Smith", u"jöhn.smith@test.com", None,
+ [u"föo/bar"], [u"brånch1", u"brånch2"])
+ context1.commits = [commit1]
+
+ context2 = GitContext()
+ commit_message2 = GitCommitMessage(context2, u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
+ commit2 = GitCommit(context2, commit_message1, u"shä", now, u"Jöhn Smith", u"jöhn.smith@test.com", None,
+ [u"föo/bar"], [u"brånch1", u"brånch2"])
+ context2.commits = [commit2]
+
+ self.assertEqual(context1, context2)
+ self.assertEqual(commit_message1, commit_message2)
+ self.assertEqual(commit1, commit2)
+
+ # Check that objects are unequal when changing a single attribute
+ kwargs = {'message': commit1.message, 'sha': commit1.sha, 'date': commit1.date,
+ 'author_name': commit1.author_name, 'author_email': commit1.author_email, 'parents': commit1.parents,
+ 'changed_files': commit1.changed_files, 'branches': commit1.branches}
+
+ self.object_equality_test(commit1, kwargs.keys(), {"context": commit1.context})
+
+ # Check that the is_* attributes that are affected by the commit message affect equality
+ special_messages = {'is_merge_commit': u"Merge: foöbar", 'is_fixup_commit': u"fixup! foöbar",
+ 'is_squash_commit': u"squash! foöbar", 'is_revert_commit': u"Revert: foöbar"}
+ for key in special_messages:
+ kwargs_copy = copy.deepcopy(kwargs)
+ clone1 = GitCommit(context=commit1.context, **kwargs_copy)
+ clone1.message = GitCommitMessage.from_full_message(context1, special_messages[key])
+ self.assertTrue(getattr(clone1, key))
+
+ clone2 = GitCommit(context=commit1.context, **kwargs_copy)
+ clone2.message = GitCommitMessage.from_full_message(context1, u"foöbar")
+ self.assertNotEqual(clone1, clone2)
+
+ @patch("gitlint.git.git_commentchar")
+ def test_commit_msg_custom_commentchar(self, patched):
+ patched.return_value = u"ä"
+ context = GitContext()
+ message = GitCommitMessage.from_full_message(context, u"Tïtle\n\nBödy 1\näCömment\nBody 2")
+
+ self.assertEqual(message.title, u"Tïtle")
+ self.assertEqual(message.body, ["", u"Bödy 1", "Body 2"])
+ self.assertEqual(message.full, u"Tïtle\n\nBödy 1\nBody 2")
+ self.assertEqual(message.original, u"Tïtle\n\nBödy 1\näCömment\nBody 2")
diff --git a/gitlint/tests/git/test_git_context.py b/gitlint/tests/git/test_git_context.py
new file mode 100644
index 0000000..b243d5e
--- /dev/null
+++ b/gitlint/tests/git/test_git_context.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+
+try:
+ # python 2.x
+ from mock import patch, call
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch, call # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.git import GitContext
+
+
+class GitContextTests(BaseTestCase):
+
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {
+ '_tty_out': False,
+ '_cwd': u"fåke/path"
+ }
+
+ @patch('gitlint.git.sh')
+ def test_gitcontext(self, sh):
+
+ sh.git.side_effect = [
+ u"#", # git config --get core.commentchar
+ u"\nfoöbar\n"
+ ]
+
+ expected_calls = [
+ call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
+ call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args)
+ ]
+
+ context = GitContext(u"fåke/path")
+ self.assertEqual(sh.git.mock_calls, [])
+
+ # gitcontext.comment_branch
+ self.assertEqual(context.commentchar, u"#")
+ self.assertEqual(sh.git.mock_calls, expected_calls[0:1])
+
+ # gitcontext.current_branch
+ self.assertEqual(context.current_branch, u"foöbar")
+ self.assertEqual(sh.git.mock_calls, expected_calls)
+
+ @patch('gitlint.git.sh')
+ def test_gitcontext_equality(self, sh):
+
+ sh.git.side_effect = [
+ u"û\n", # context1: git config --get core.commentchar
+ u"û\n", # context2: git config --get core.commentchar
+ u"my-brånch\n", # context1: git rev-parse --abbrev-ref HEAD
+ u"my-brånch\n", # context2: git rev-parse --abbrev-ref HEAD
+ ]
+
+ context1 = GitContext(u"fåke/path")
+ context1.commits = [u"fōo", u"bår"] # we don't need real commits to check for equality
+
+ context2 = GitContext(u"fåke/path")
+ context2.commits = [u"fōo", u"bår"]
+ self.assertEqual(context1, context2)
+
+ # INEQUALITY
+ # Different commits
+ context2.commits = [u"hür", u"dür"]
+ self.assertNotEqual(context1, context2)
+
+ # Different repository_path
+ context2.commits = context1.commits
+ context2.repository_path = u"ōther/path"
+ self.assertNotEqual(context1, context2)
+
+ # Different comment_char
+ context3 = GitContext(u"fåke/path")
+ context3.commits = [u"fōo", u"bår"]
+ sh.git.side_effect = ([
+ u"ç\n", # context3: git config --get core.commentchar
+ u"my-brånch\n" # context3: git rev-parse --abbrev-ref HEAD
+ ])
+ self.assertNotEqual(context1, context3)
+
+ # Different current_branch
+ context4 = GitContext(u"fåke/path")
+ context4.commits = [u"fōo", u"bår"]
+ sh.git.side_effect = ([
+ u"û\n", # context4: git config --get core.commentchar
+ u"different-brånch\n" # context4: git rev-parse --abbrev-ref HEAD
+ ])
+ self.assertNotEqual(context1, context4)
diff --git a/gitlint/tests/rules/__init__.py b/gitlint/tests/rules/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/tests/rules/__init__.py
diff --git a/gitlint/tests/rules/test_body_rules.py b/gitlint/tests/rules/test_body_rules.py
new file mode 100644
index 0000000..fcb1b30
--- /dev/null
+++ b/gitlint/tests/rules/test_body_rules.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint import rules
+
+
+class BodyRuleTests(BaseTestCase):
+ def test_max_line_length(self):
+ rule = rules.BodyMaxLineLength()
+
+ # assert no error
+ violation = rule.validate(u"å" * 80, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length > 80
+ expected_violation = rules.RuleViolation("B1", "Line exceeds max length (81>80)", u"å" * 81)
+ violations = rule.validate(u"å" * 81, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check no violation on length 73
+ rule = rules.BodyMaxLineLength({'line-length': 120})
+ violations = rule.validate(u"å" * 73, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 121
+ expected_violation = rules.RuleViolation("B1", "Line exceeds max length (121>120)", u"å" * 121)
+ violations = rule.validate(u"å" * 121, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_whitespace(self):
+ rule = rules.BodyTrailingWhitespace()
+
+ # assert no error
+ violations = rule.validate(u"å", None)
+ self.assertIsNone(violations)
+
+ # trailing space
+ expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", u"å ")
+ violations = rule.validate(u"å ", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # trailing tab
+ expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", u"å\t")
+ violations = rule.validate(u"å\t", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_hard_tabs(self):
+ rule = rules.BodyHardTab()
+
+ # assert no error
+ violations = rule.validate(u"This is ã test", None)
+ self.assertIsNone(violations)
+
+ # contains hard tab
+ expected_violation = rules.RuleViolation("B3", "Line contains hard tab characters (\\t)", u"This is å\ttest")
+ violations = rule.validate(u"This is å\ttest", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_first_line_empty(self):
+ rule = rules.BodyFirstLineEmpty()
+
+ # assert no error
+ commit = self.gitcommit(u"Tïtle\n\nThis is the secōnd body line")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # second line not empty
+ expected_violation = rules.RuleViolation("B4", "Second line is not empty", u"nöt empty", 2)
+
+ commit = self.gitcommit(u"Tïtle\nnöt empty\nThis is the secönd body line")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_min_length(self):
+ rule = rules.BodyMinLength()
+
+ # assert no error - body is long enough
+ commit = self.gitcommit("Title\n\nThis is the second body line\n")
+
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error - no body
+ commit = self.gitcommit(u"Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # body is too short
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (8<20)", u"töoshort", 3)
+
+ commit = self.gitcommit(u"Tïtle\n\ntöoshort\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # assert error - short across multiple lines
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (11<20)", u"secöndthïrd", 3)
+ commit = self.gitcommit(u"Tïtle\n\nsecönd\nthïrd\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check violation on length 21
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", u"å" * 21, 3)
+
+ rule = rules.BodyMinLength({'min-length': 120})
+ commit = self.gitcommit(u"Title\n\n%s\n" % (u"å" * 21))
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # Make sure we don't get the error if the body-length is exactly the min-length
+ rule = rules.BodyMinLength({'min-length': 8})
+ commit = self.gitcommit(u"Tïtle\n\n%s\n" % (u"å" * 8))
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ def test_body_missing(self):
+ rule = rules.BodyMissing()
+
+ # assert no error - body is present
+ commit = self.gitcommit(u"Tïtle\n\nThis ïs the first body line\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # body is too short
+ expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
+
+ commit = self.gitcommit(u"Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_missing_merge_commit(self):
+ rule = rules.BodyMissing()
+
+ # assert no error - merge commit
+ commit = self.gitcommit(u"Merge: Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert error for merge commits if ignore-merge-commits is disabled
+ rule = rules.BodyMissing({'ignore-merge-commits': False})
+ violations = rule.validate(commit)
+ expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_changed_file_mention(self):
+ rule = rules.BodyChangedFileMention()
+
+ # assert no error when no files have changed and no files need to be mentioned
+ commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error when no files have changed but certain files need to be mentioned on change
+ rule = rules.BodyChangedFileMention({'files': u"bar.txt,föo/test.py"})
+ commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error if a file has changed and is mentioned
+ commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py", [u"föo/test.py"])
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error if multiple files have changed and are mentioned
+ commit_msg = u"This is a test\n\nHere is a mention of föo/test.py\nAnd here is a mention of bar.txt"
+ commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert error if file has changed and is not mentioned
+ commit_msg = u"This is a test\n\nHere is å mention of\nAnd here is a mention of bar.txt"
+ commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ expected_violation = rules.RuleViolation("B7", u"Body does not mention changed file 'föo/test.py'", None, 4)
+ self.assertEqual([expected_violation], violations)
+
+ # assert multiple errors if multiple files habe changed and are not mentioned
+ commit_msg = u"This is å test\n\nHere is a mention of\nAnd here is a mention of"
+ commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ expected_violation_2 = rules.RuleViolation("B7", "Body does not mention changed file 'bar.txt'", None, 4)
+ self.assertEqual([expected_violation_2, expected_violation], violations)
diff --git a/gitlint/tests/rules/test_configuration_rules.py b/gitlint/tests/rules/test_configuration_rules.py
new file mode 100644
index 0000000..73d42f3
--- /dev/null
+++ b/gitlint/tests/rules/test_configuration_rules.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint import rules
+from gitlint.config import LintConfig
+
+
+class ConfigurationRuleTests(BaseTestCase):
+ def test_ignore_by_title(self):
+ commit = self.gitcommit(u"Releäse\n\nThis is the secōnd body line")
+
+ # No regex specified -> Config shouldn't be changed
+ rule = rules.IgnoreByTitle()
+ config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([]) # nothing logged -> nothing ignored
+
+ # Matching regex -> expect config to ignore all rules
+ rule = rules.IgnoreByTitle({"regex": u"^Releäse(.*)"})
+ expected_config = LintConfig()
+ expected_config.ignore = "all"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
+ u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: all"
+ self.assert_log_contains(expected_log_message)
+
+ # Matching regex with specific ignore
+ rule = rules.IgnoreByTitle({"regex": u"^Releäse(.*)",
+ "ignore": "T1,B2"})
+ expected_config = LintConfig()
+ expected_config.ignore = "T1,B2"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
+ u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: T1,B2"
+
+ def test_ignore_by_body(self):
+ commit = self.gitcommit(u"Tïtle\n\nThis is\n a relëase body\n line")
+
+ # No regex specified -> Config shouldn't be changed
+ rule = rules.IgnoreByBody()
+ config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([]) # nothing logged -> nothing ignored
+
+ # Matching regex -> expect config to ignore all rules
+ rule = rules.IgnoreByBody({"regex": u"(.*)relëase(.*)"})
+ expected_config = LintConfig()
+ expected_config.ignore = "all"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " + \
+ u"Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)'," + \
+ u" ignoring rules: all"
+ self.assert_log_contains(expected_log_message)
+
+ # Matching regex with specific ignore
+ rule = rules.IgnoreByBody({"regex": u"(.*)relëase(.*)",
+ "ignore": "T1,B2"})
+ expected_config = LintConfig()
+ expected_config.ignore = "T1,B2"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
+ u"Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)', ignoring rules: T1,B2"
diff --git a/gitlint/tests/rules/test_meta_rules.py b/gitlint/tests/rules/test_meta_rules.py
new file mode 100644
index 0000000..c94b8b3
--- /dev/null
+++ b/gitlint/tests/rules/test_meta_rules.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import AuthorValidEmail, RuleViolation
+
+
+class MetaRuleTests(BaseTestCase):
+ def test_author_valid_email_rule(self):
+ rule = AuthorValidEmail()
+
+ # valid email addresses
+ valid_email_addresses = [u"föo@bar.com", u"Jöhn.Doe@bar.com", u"jöhn+doe@bar.com", u"jöhn/doe@bar.com",
+ u"jöhn.doe@subdomain.bar.com"]
+ for email in valid_email_addresses:
+ commit = self.gitcommit(u"", author_email=email)
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # No email address (=allowed for now, as gitlint also lints messages passed via stdin that don't have an
+ # email address)
+ commit = self.gitcommit(u"")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # Invalid email addresses: no TLD, no domain, no @, space anywhere (=valid but not allowed by gitlint)
+ invalid_email_addresses = [u"föo@bar", u"JöhnDoe", u"Jöhn Doe", u"Jöhn Doe@foo.com", u" JöhnDoe@foo.com",
+ u"JöhnDoe@ foo.com", u"JöhnDoe@foo. com", u"JöhnDoe@foo. com", u"@bår.com",
+ u"föo@.com"]
+ for email in invalid_email_addresses:
+ commit = self.gitcommit(u"", author_email=email)
+ violations = rule.validate(commit)
+ self.assertListEqual(violations,
+ [RuleViolation("M1", "Author email for commit is invalid", email)])
+
+ def test_author_valid_email_rule_custom_regex(self):
+ # Custom domain
+ rule = AuthorValidEmail({'regex': u"[^@]+@bår.com"})
+ valid_email_addresses = [
+ u"föo@bår.com", u"Jöhn.Doe@bår.com", u"jöhn+doe@bår.com", u"jöhn/doe@bår.com"]
+ for email in valid_email_addresses:
+ commit = self.gitcommit(u"", author_email=email)
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # Invalid email addresses
+ invalid_email_addresses = [u"föo@hur.com"]
+ for email in invalid_email_addresses:
+ commit = self.gitcommit(u"", author_email=email)
+ violations = rule.validate(commit)
+ self.assertListEqual(violations,
+ [RuleViolation("M1", "Author email for commit is invalid", email)])
diff --git a/gitlint/tests/rules/test_rules.py b/gitlint/tests/rules/test_rules.py
new file mode 100644
index 0000000..89caa27
--- /dev/null
+++ b/gitlint/tests/rules/test_rules.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import Rule, RuleViolation
+
+
+class RuleTests(BaseTestCase):
+
+ def test_rule_equality(self):
+ self.assertEqual(Rule(), Rule())
+ # Ensure rules are not equal if they differ on their attributes
+ for attr in ["id", "name", "target", "options"]:
+ rule = Rule()
+ setattr(rule, attr, u"åbc")
+ self.assertNotEqual(Rule(), rule)
+
+ def test_rule_violation_equality(self):
+ violation1 = RuleViolation(u"ïd1", u"My messåge", u"My cöntent", 1)
+ self.object_equality_test(violation1, ["rule_id", "message", "content", "line_nr"])
diff --git a/gitlint/tests/rules/test_title_rules.py b/gitlint/tests/rules/test_title_rules.py
new file mode 100644
index 0000000..07d2323
--- /dev/null
+++ b/gitlint/tests/rules/test_title_rules.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import TitleMaxLength, TitleTrailingWhitespace, TitleHardTab, TitleMustNotContainWord, \
+ TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation
+
+
+class TitleRuleTests(BaseTestCase):
+ def test_max_line_length(self):
+ rule = TitleMaxLength()
+
+ # assert no error
+ violation = rule.validate(u"å" * 72, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length > 72
+ expected_violation = RuleViolation("T1", "Title exceeds max length (73>72)", u"å" * 73)
+ violations = rule.validate(u"å" * 73, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check no violation on length 73
+ rule = TitleMaxLength({'line-length': 120})
+ violations = rule.validate(u"å" * 73, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 121
+ expected_violation = RuleViolation("T1", "Title exceeds max length (121>120)", u"å" * 121)
+ violations = rule.validate(u"å" * 121, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_whitespace(self):
+ rule = TitleTrailingWhitespace()
+
+ # assert no error
+ violations = rule.validate(u"å", None)
+ self.assertIsNone(violations)
+
+ # trailing space
+ expected_violation = RuleViolation("T2", "Title has trailing whitespace", u"å ")
+ violations = rule.validate(u"å ", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # trailing tab
+ expected_violation = RuleViolation("T2", "Title has trailing whitespace", u"å\t")
+ violations = rule.validate(u"å\t", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_hard_tabs(self):
+ rule = TitleHardTab()
+
+ # assert no error
+ violations = rule.validate(u"This is å test", None)
+ self.assertIsNone(violations)
+
+ # contains hard tab
+ expected_violation = RuleViolation("T4", "Title contains hard tab characters (\\t)", u"This is å\ttest")
+ violations = rule.validate(u"This is å\ttest", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_punctuation(self):
+ rule = TitleTrailingPunctuation()
+
+ # assert no error
+ violations = rule.validate(u"This is å test", None)
+ self.assertIsNone(violations)
+
+ # assert errors for different punctuations
+ punctuation = u"?:!.,;"
+ for char in punctuation:
+ line = u"This is å test" + char # note that make sure to include some unicode!
+ gitcontext = self.gitcontext(line)
+ expected_violation = RuleViolation("T3", u"Title has trailing punctuation ({0})".format(char), line)
+ violations = rule.validate(line, gitcontext)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_title_must_not_contain_word(self):
+ rule = TitleMustNotContainWord()
+
+ # no violations
+ violations = rule.validate(u"This is å test", None)
+ self.assertIsNone(violations)
+
+ # no violation if WIP occurs inside a wor
+ violations = rule.validate(u"This is å wiping test", None)
+ self.assertIsNone(violations)
+
+ # match literally
+ violations = rule.validate(u"WIP This is å test", None)
+ expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"WIP This is å test")
+ self.assertListEqual(violations, [expected_violation])
+
+ # match case insensitive
+ violations = rule.validate(u"wip This is å test", None)
+ expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"wip This is å test")
+ self.assertListEqual(violations, [expected_violation])
+
+ # match if there is a colon after the word
+ violations = rule.validate(u"WIP:This is å test", None)
+ expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"WIP:This is å test")
+ self.assertListEqual(violations, [expected_violation])
+
+ # match multiple words
+ rule = TitleMustNotContainWord({'words': u"wip,test,å"})
+ violations = rule.validate(u"WIP:This is å test", None)
+ expected_violation = RuleViolation("T5", "Title contains the word 'wip' (case-insensitive)",
+ u"WIP:This is å test")
+ expected_violation2 = RuleViolation("T5", "Title contains the word 'test' (case-insensitive)",
+ u"WIP:This is å test")
+ expected_violation3 = RuleViolation("T5", u"Title contains the word 'å' (case-insensitive)",
+ u"WIP:This is å test")
+ self.assertListEqual(violations, [expected_violation, expected_violation2, expected_violation3])
+
+ def test_leading_whitespace(self):
+ rule = TitleLeadingWhitespace()
+
+ # assert no error
+ violations = rule.validate("a", None)
+ self.assertIsNone(violations)
+
+ # leading space
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", " a")
+ violations = rule.validate(" a", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # leading tab
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", "\ta")
+ violations = rule.validate("\ta", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # unicode test
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", u" ☺")
+ violations = rule.validate(u" ☺", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_regex_matches(self):
+ commit = self.gitcommit(u"US1234: åbc\n")
+
+ # assert no violation on default regex (=everything allowed)
+ rule = TitleRegexMatches()
+ violations = rule.validate(commit.message.title, commit)
+ self.assertIsNone(violations)
+
+ # assert no violation on matching regex
+ rule = TitleRegexMatches({'regex': u"^US[0-9]*: å"})
+ violations = rule.validate(commit.message.title, commit)
+ self.assertIsNone(violations)
+
+ # assert violation when no matching regex
+ rule = TitleRegexMatches({'regex': u"^UÅ[0-9]*"})
+ violations = rule.validate(commit.message.title, commit)
+ expected_violation = RuleViolation("T7", u"Title does not match regex (^UÅ[0-9]*)", u"US1234: åbc")
+ self.assertListEqual(violations, [expected_violation])
diff --git a/gitlint/tests/rules/test_user_rules.py b/gitlint/tests/rules/test_user_rules.py
new file mode 100644
index 0000000..57c03a0
--- /dev/null
+++ b/gitlint/tests/rules/test_user_rules.py
@@ -0,0 +1,223 @@
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.rule_finder import find_rule_classes, assert_valid_rule_class
+from gitlint.rules import UserRuleError
+from gitlint.utils import ustr
+
+from gitlint import options, rules
+
+
+class UserRuleTests(BaseTestCase):
+ def test_find_rule_classes(self):
+ # Let's find some user classes!
+ user_rule_path = self.get_sample_path("user_rules")
+ classes = find_rule_classes(user_rule_path)
+
+ # Compare string representations because we can't import MyUserCommitRule here since samples/user_rules is not
+ # a proper python package
+ # Note that the following check effectively asserts that:
+ # - There is only 1 rule recognized and it is MyUserCommitRule
+ # - Other non-python files in the directory are ignored
+ # - Other members of the my_commit_rules module are ignored
+ # (such as func_should_be_ignored, global_variable_should_be_ignored)
+ # - Rules are loaded non-recursively (user_rules/import_exception directory is ignored)
+ self.assertEqual("[<class 'my_commit_rules.MyUserCommitRule'>]", ustr(classes))
+
+ # Assert that we added the new user_rules directory to the system path and modules
+ self.assertIn(user_rule_path, sys.path)
+ self.assertIn("my_commit_rules", sys.modules)
+
+ # Do some basic asserts on our user rule
+ self.assertEqual(classes[0].id, "UC1")
+ self.assertEqual(classes[0].name, u"my-üser-commit-rule")
+ expected_option = options.IntOption('violation-count', 1, u"Number of violåtions to return")
+ self.assertListEqual(classes[0].options_spec, [expected_option])
+ self.assertTrue(hasattr(classes[0], "validate"))
+
+ # Test that we can instantiate the class and can execute run the validate method and that it returns the
+ # expected result
+ rule_class = classes[0]()
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1)])
+
+ # Have it return more violations
+ rule_class.options['violation-count'].value = 2
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1),
+ rules.RuleViolation("UC1", u"Commit violåtion 2", u"Contënt 2", 2)])
+
+ def test_extra_path_specified_by_file(self):
+ # Test that find_rule_classes can handle an extra path given as a file name instead of a directory
+ user_rule_path = self.get_sample_path("user_rules")
+ user_rule_module = os.path.join(user_rule_path, "my_commit_rules.py")
+ classes = find_rule_classes(user_rule_module)
+
+ rule_class = classes[0]()
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1)])
+
+ def test_rules_from_init_file(self):
+ # Test that we can import rules that are defined in __init__.py files
+ # This also tests that we can import rules from python packages. This use to cause issues with pypy
+ # So this is also a regression test for that.
+ user_rule_path = self.get_sample_path(os.path.join("user_rules", "parent_package"))
+ classes = find_rule_classes(user_rule_path)
+
+ # convert classes to strings and sort them so we can compare them
+ class_strings = sorted([ustr(clazz) for clazz in classes])
+ expected = [u"<class 'my_commit_rules.MyUserCommitRule'>", u"<class 'parent_package.InitFileRule'>"]
+ self.assertListEqual(class_strings, expected)
+
+ def test_empty_user_classes(self):
+ # Test that we don't find rules if we scan a different directory
+ user_rule_path = self.get_sample_path("config")
+ classes = find_rule_classes(user_rule_path)
+ self.assertListEqual(classes, [])
+
+ # Importantly, ensure that the directory is not added to the syspath as this happens only when we actually
+ # find modules
+ self.assertNotIn(user_rule_path, sys.path)
+
+ def test_failed_module_import(self):
+ # test importing a bogus module
+ user_rule_path = self.get_sample_path("user_rules/import_exception")
+ # We don't check the entire error message because that is different based on the python version and underlying
+ # operating system
+ expected_msg = "Error while importing extra-path module 'invalid_python'"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ find_rule_classes(user_rule_path)
+
+ def test_find_rule_classes_nonexisting_path(self):
+ with self.assertRaisesRegex(UserRuleError, u"Invalid extra-path: föo/bar"):
+ find_rule_classes(u"föo/bar")
+
+ def test_assert_valid_rule_class(self):
+ class MyLineRuleClass(rules.LineRule):
+ id = 'UC1'
+ name = u'my-lïne-rule'
+ target = rules.CommitMessageTitle
+
+ def validate(self):
+ pass
+
+ class MyCommitRuleClass(rules.CommitRule):
+ id = 'UC2'
+ name = u'my-cömmit-rule'
+
+ def validate(self):
+ pass
+
+ # Just assert that no error is raised
+ self.assertIsNone(assert_valid_rule_class(MyLineRuleClass))
+ self.assertIsNone(assert_valid_rule_class(MyCommitRuleClass))
+
+ def test_assert_valid_rule_class_negative(self):
+ # general test to make sure that incorrect rules will raise an exception
+ user_rule_path = self.get_sample_path("user_rules/incorrect_linerule")
+ with self.assertRaisesRegex(UserRuleError,
+ "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
+ find_rule_classes(user_rule_path)
+
+ def test_assert_valid_rule_class_negative_parent(self):
+ # rule class must extend from LineRule or CommitRule
+ class MyRuleClass(object):
+ pass
+
+ expected_msg = "User-defined rule class 'MyRuleClass' must extend from gitlint.rules.LineRule " + \
+ "or gitlint.rules.CommitRule"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_id(self):
+ class MyRuleClass(rules.LineRule):
+ pass
+
+ # Rule class must have an id
+ expected_msg = "User-defined rule class 'MyRuleClass' must have an 'id' attribute"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule ids must be non-empty
+ MyRuleClass.id = ""
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule ids must not start with one of the reserved id letters
+ for letter in ["T", "R", "B", "M"]:
+ MyRuleClass.id = letter + "1"
+ expected_msg = "The id '{0}' of 'MyRuleClass' is invalid. Gitlint reserves ids starting with R,T,B,M"
+ with self.assertRaisesRegex(UserRuleError, expected_msg.format(letter)):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_name(self):
+ class MyRuleClass(rules.LineRule):
+ id = "UC1"
+
+ # Rule class must have an name
+ expected_msg = "User-defined rule class 'MyRuleClass' must have a 'name' attribute"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule names must be non-empty
+ MyRuleClass.name = ""
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_option_spec(self):
+ class MyRuleClass(rules.LineRule):
+ id = "UC1"
+ name = u"my-rüle-class"
+
+ # if set, option_spec must be a list of gitlint options
+ MyRuleClass.options_spec = u"föo"
+ expected_msg = "The options_spec attribute of user-defined rule class 'MyRuleClass' must be a list " + \
+ "of gitlint.options.RuleOption"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # option_spec is a list, but not of gitlint options
+ MyRuleClass.options_spec = [u"föo", 123] # pylint: disable=bad-option-value,redefined-variable-type
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_validate(self):
+ class MyRuleClass(rules.LineRule):
+ id = "UC1"
+ name = u"my-rüle-class"
+
+ with self.assertRaisesRegex(UserRuleError,
+ "User-defined rule class 'MyRuleClass' must have a 'validate' method"):
+ assert_valid_rule_class(MyRuleClass)
+
+ # validate attribute - not a method
+ MyRuleClass.validate = u"föo"
+ with self.assertRaisesRegex(UserRuleError,
+ "User-defined rule class 'MyRuleClass' must have a 'validate' method"):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_target(self):
+ class MyRuleClass(rules.LineRule):
+ id = "UC1"
+ name = u"my-rüle-class"
+
+ def validate(self):
+ pass
+
+ # no target
+ expected_msg = "The target attribute of the user-defined LineRule class 'MyRuleClass' must be either " + \
+ "gitlint.rules.CommitMessageTitle or gitlint.rules.CommitMessageBody"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # invalid target
+ MyRuleClass.target = u"föo"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # valid target, no exception should be raised
+ MyRuleClass.target = rules.CommitMessageTitle # pylint: disable=bad-option-value,redefined-variable-type
+ self.assertIsNone(assert_valid_rule_class(MyRuleClass))
diff --git a/gitlint/tests/samples/commit_message/fixup b/gitlint/tests/samples/commit_message/fixup
new file mode 100644
index 0000000..2539dd1
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/fixup
@@ -0,0 +1 @@
+fixup! WIP: This is a fixup cömmit with violations.
diff --git a/gitlint/tests/samples/commit_message/merge b/gitlint/tests/samples/commit_message/merge
new file mode 100644
index 0000000..764e131
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/merge
@@ -0,0 +1,3 @@
+Merge: "This is a merge commit with a long title that most definitely exceeds the normål limit of 72 chars"
+This line should be ëmpty
+This is the first line is meant to test å line that exceeds the maximum line length of 80 characters.
diff --git a/gitlint/tests/samples/commit_message/revert b/gitlint/tests/samples/commit_message/revert
new file mode 100644
index 0000000..6dc8368
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/revert
@@ -0,0 +1,3 @@
+Revert "WIP: this is a tïtle"
+
+This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c. \ No newline at end of file
diff --git a/gitlint/tests/samples/commit_message/sample1 b/gitlint/tests/samples/commit_message/sample1
new file mode 100644
index 0000000..646c0cb
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/sample1
@@ -0,0 +1,14 @@
+Commit title contåining 'WIP', as well as trailing punctuation.
+This line should be empty
+This is the first line of the commit message body and it is meant to test a line that exceeds the maximum line length of 80 characters.
+This line has a tråiling space.
+This line has a trailing tab.
+# This is a cömmented line
+# ------------------------ >8 ------------------------
+# Anything after this line should be cleaned up
+# this line appears on `git commit -v` command
+diff --git a/gitlint/tests/samples/commit_message/sample1 b/gitlint/tests/samples/commit_message/sample1
+index 82dbe7f..ae71a14 100644
+--- a/gitlint/tests/samples/commit_message/sample1
++++ b/gitlint/tests/samples/commit_message/sample1
+@@ -1 +1 @@
diff --git a/gitlint/tests/samples/commit_message/sample2 b/gitlint/tests/samples/commit_message/sample2
new file mode 100644
index 0000000..356540c
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/sample2
@@ -0,0 +1 @@
+Just a title contåining WIP \ No newline at end of file
diff --git a/gitlint/tests/samples/commit_message/sample3 b/gitlint/tests/samples/commit_message/sample3
new file mode 100644
index 0000000..d67d70b
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/sample3
@@ -0,0 +1,6 @@
+ Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
+This line should be empty
+This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
+This line has a trailing space.
+This line has a tråiling tab.
+# This is a commented line
diff --git a/gitlint/tests/samples/commit_message/sample4 b/gitlint/tests/samples/commit_message/sample4
new file mode 100644
index 0000000..c858d89
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/sample4
@@ -0,0 +1,7 @@
+ Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
+This line should be empty
+This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
+This line has a tråiling space.
+This line has a trailing tab.
+# This is a commented line
+gitlint-ignore: all
diff --git a/gitlint/tests/samples/commit_message/sample5 b/gitlint/tests/samples/commit_message/sample5
new file mode 100644
index 0000000..77ccbe8
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/sample5
@@ -0,0 +1,7 @@
+ Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
+This line should be ëmpty
+This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
+This line has a tråiling space.
+This line has a trailing tab.
+# This is a commented line
+gitlint-ignore: T3, T6, body-max-line-length
diff --git a/gitlint/tests/samples/commit_message/squash b/gitlint/tests/samples/commit_message/squash
new file mode 100644
index 0000000..538a93a
--- /dev/null
+++ b/gitlint/tests/samples/commit_message/squash
@@ -0,0 +1,3 @@
+squash! WIP: This is a squash cömmit with violations.
+
+Body töo short
diff --git a/gitlint/tests/samples/config/gitlintconfig b/gitlint/tests/samples/config/gitlintconfig
new file mode 100644
index 0000000..8c93f71
--- /dev/null
+++ b/gitlint/tests/samples/config/gitlintconfig
@@ -0,0 +1,15 @@
+[general]
+ignore=title-trailing-whitespace,B2
+verbosity = 1
+ignore-merge-commits = false
+debug = false
+
+[title-max-length]
+line-length=20
+
+[B1]
+# B1 = body-max-line-length
+line-length=30
+
+[title-must-not-contain-word]
+words=WIP,bögus \ No newline at end of file
diff --git a/gitlint/tests/samples/config/invalid-option-value b/gitlint/tests/samples/config/invalid-option-value
new file mode 100644
index 0000000..92015aa
--- /dev/null
+++ b/gitlint/tests/samples/config/invalid-option-value
@@ -0,0 +1,11 @@
+[general]
+ignore=title-trailing-whitespace,B2
+verbosity = 1
+
+[title-max-length]
+line-length=föo
+
+
+[B1]
+# B1 = body-max-line-length
+line-length=30 \ No newline at end of file
diff --git a/gitlint/tests/samples/config/no-sections b/gitlint/tests/samples/config/no-sections
new file mode 100644
index 0000000..ec82b25
--- /dev/null
+++ b/gitlint/tests/samples/config/no-sections
@@ -0,0 +1 @@
+ignore=title-max-length, T3
diff --git a/gitlint/tests/samples/config/nonexisting-general-option b/gitlint/tests/samples/config/nonexisting-general-option
new file mode 100644
index 0000000..d5cfef2
--- /dev/null
+++ b/gitlint/tests/samples/config/nonexisting-general-option
@@ -0,0 +1,13 @@
+[general]
+ignore=title-trailing-whitespace,B2
+verbosity = 1
+ignore-merge-commits = false
+foo = bar
+
+[title-max-length]
+line-length=20
+
+
+[B1]
+# B1 = body-max-line-length
+line-length=30 \ No newline at end of file
diff --git a/gitlint/tests/samples/config/nonexisting-option b/gitlint/tests/samples/config/nonexisting-option
new file mode 100644
index 0000000..6964c77
--- /dev/null
+++ b/gitlint/tests/samples/config/nonexisting-option
@@ -0,0 +1,11 @@
+[general]
+ignore=title-trailing-whitespace,B2
+verbosity = 1
+
+[title-max-length]
+föobar=foo
+
+
+[B1]
+# B1 = body-max-line-length
+line-length=30 \ No newline at end of file
diff --git a/gitlint/tests/samples/config/nonexisting-rule b/gitlint/tests/samples/config/nonexisting-rule
new file mode 100644
index 0000000..c0f0d2b
--- /dev/null
+++ b/gitlint/tests/samples/config/nonexisting-rule
@@ -0,0 +1,11 @@
+[general]
+ignore=title-trailing-whitespace,B2
+verbosity = 1
+
+[föobar]
+line-length=20
+
+
+[B1]
+# B1 = body-max-line-length
+line-length=30 \ No newline at end of file
diff --git a/gitlint/tests/samples/user_rules/bogus-file.txt b/gitlint/tests/samples/user_rules/bogus-file.txt
new file mode 100644
index 0000000..2a56650
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/bogus-file.txt
@@ -0,0 +1,2 @@
+This is just a bogus file.
+This file being here is part of the test: gitlint should ignore it. \ No newline at end of file
diff --git a/gitlint/tests/samples/user_rules/import_exception/invalid_python.py b/gitlint/tests/samples/user_rules/import_exception/invalid_python.py
new file mode 100644
index 0000000..e75fed3
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/import_exception/invalid_python.py
@@ -0,0 +1,3 @@
+# flake8: noqa
+# This is invalid python code which will cause an import exception
+class MyObject:
diff --git a/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py b/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py
new file mode 100644
index 0000000..004ef9d
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from gitlint.rules import LineRule
+
+
+class MyUserLineRule(LineRule):
+ id = "UC2"
+ name = "my-lïne-rule"
+
+ # missing validate method, missing target attribute
diff --git a/gitlint/tests/samples/user_rules/my_commit_rules.foo b/gitlint/tests/samples/user_rules/my_commit_rules.foo
new file mode 100644
index 0000000..605d704
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/my_commit_rules.foo
@@ -0,0 +1,16 @@
+# This rule is ignored because it doesn't have a .py extension
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.options import IntOption
+
+
+class MyUserCommitRule2(CommitRule):
+ name = "my-user-commit-rule2"
+ id = "TUC2"
+ options_spec = [IntOption('violation-count', 0, "Number of violations to return")]
+
+ def validate(self, _commit):
+ violations = []
+ for i in range(1, self.options['violation-count'].value + 1):
+ violations.append(RuleViolation(self.id, "Commit violation %d" % i, "Content %d" % i, i))
+
+ return violations
diff --git a/gitlint/tests/samples/user_rules/my_commit_rules.py b/gitlint/tests/samples/user_rules/my_commit_rules.py
new file mode 100644
index 0000000..5456487
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/my_commit_rules.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.options import IntOption
+
+
+class MyUserCommitRule(CommitRule):
+ name = u"my-üser-commit-rule"
+ id = "UC1"
+ options_spec = [IntOption('violation-count', 1, u"Number of violåtions to return")]
+
+ def validate(self, _commit):
+ violations = []
+ for i in range(1, self.options['violation-count'].value + 1):
+ violations.append(RuleViolation(self.id, u"Commit violåtion %d" % i, u"Contënt %d" % i, i))
+
+ return violations
+
+
+# The below code is present so that we can test that we actually ignore it
+
+def func_should_be_ignored():
+ pass
+
+
+global_variable_should_be_ignored = True
diff --git a/gitlint/tests/samples/user_rules/parent_package/__init__.py b/gitlint/tests/samples/user_rules/parent_package/__init__.py
new file mode 100644
index 0000000..32c05fc
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/parent_package/__init__.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# This file is meant to test that we can also load rules from __init__.py files, this was an issue with pypy before.
+
+from gitlint.rules import CommitRule
+
+
+class InitFileRule(CommitRule):
+ name = u"my-init-cömmit-rule"
+ id = "UC1"
+ options_spec = []
+
+ def validate(self, _commit):
+ return []
diff --git a/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py b/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py
new file mode 100644
index 0000000..b73a305
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+from gitlint.rules import CommitRule
+
+
+class MyUserCommitRule(CommitRule):
+ name = u"my-user-cömmit-rule"
+ id = "UC2"
+ options_spec = []
+
+ def validate(self, _commit):
+ return []
diff --git a/gitlint/tests/test_cache.py b/gitlint/tests/test_cache.py
new file mode 100644
index 0000000..5d78953
--- /dev/null
+++ b/gitlint/tests/test_cache.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.cache import PropertyCache, cache
+
+
+class CacheTests(BaseTestCase):
+
+ class MyClass(PropertyCache):
+ """ Simple class that has cached properties, used for testing. """
+
+ def __init__(self):
+ PropertyCache.__init__(self)
+ self.counter = 0
+
+ @property
+ @cache
+ def foo(self):
+ self.counter += 1
+ return u"bår"
+
+ @property
+ @cache(cachekey=u"hür")
+ def bar(self):
+ self.counter += 1
+ return u"fōo"
+
+ def test_cache(self):
+ # Init new class with cached properties
+ myclass = self.MyClass()
+ self.assertEqual(myclass.counter, 0)
+ self.assertDictEqual(myclass._cache, {})
+
+ # Assert that function is called on first access, cache is set
+ self.assertEqual(myclass.foo, u"bår")
+ self.assertEqual(myclass.counter, 1)
+ self.assertDictEqual(myclass._cache, {"foo": u"bår"})
+
+ # After function is not called on subsequent access, cache is still set
+ self.assertEqual(myclass.foo, u"bår")
+ self.assertEqual(myclass.counter, 1)
+ self.assertDictEqual(myclass._cache, {"foo": u"bår"})
+
+ def test_cache_custom_key(self):
+ # Init new class with cached properties
+ myclass = self.MyClass()
+ self.assertEqual(myclass.counter, 0)
+ self.assertDictEqual(myclass._cache, {})
+
+ # Assert that function is called on first access, cache is set with custom key
+ self.assertEqual(myclass.bar, u"fōo")
+ self.assertEqual(myclass.counter, 1)
+ self.assertDictEqual(myclass._cache, {u"hür": u"fōo"})
+
+ # After function is not called on subsequent access, cache is still set
+ self.assertEqual(myclass.bar, u"fōo")
+ self.assertEqual(myclass.counter, 1)
+ self.assertDictEqual(myclass._cache, {u"hür": u"fōo"})
diff --git a/gitlint/tests/test_display.py b/gitlint/tests/test_display.py
new file mode 100644
index 0000000..1c64b34
--- /dev/null
+++ b/gitlint/tests/test_display.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+
+try:
+ # python 2.x
+ from StringIO import StringIO
+except ImportError:
+ # python 3.x
+ from io import StringIO
+
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.display import Display
+from gitlint.config import LintConfig
+from gitlint.tests.base import BaseTestCase
+
+
+class DisplayTests(BaseTestCase):
+ def test_v(self):
+ display = Display(LintConfig())
+ display.config.verbosity = 2
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ # Non exact outputting, should output both v and vv output
+ with patch('gitlint.display.stdout', new=StringIO()) as stdout:
+ display.v(u"tëst")
+ display.vv(u"tëst2")
+ # vvvv should be ignored regardless
+ display.vvv(u"tëst3.1")
+ display.vvv(u"tëst3.2", exact=True)
+ self.assertEqual(u"tëst\ntëst2\n", stdout.getvalue())
+
+ # exact outputting, should only output v
+ with patch('gitlint.display.stdout', new=StringIO()) as stdout:
+ display.v(u"tëst", exact=True)
+ display.vv(u"tëst2", exact=True)
+ # vvvv should be ignored regardless
+ display.vvv(u"tëst3.1")
+ display.vvv(u"tëst3.2", exact=True)
+ self.assertEqual(u"tëst2\n", stdout.getvalue())
+
+ # standard error should be empty throughtout all of this
+ self.assertEqual('', stderr.getvalue())
+
+ def test_e(self):
+ display = Display(LintConfig())
+ display.config.verbosity = 2
+
+ with patch('gitlint.display.stdout', new=StringIO()) as stdout:
+ # Non exact outputting, should output both v and vv output
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ display.e(u"tëst")
+ display.ee(u"tëst2")
+ # vvvv should be ignored regardless
+ display.eee(u"tëst3.1")
+ display.eee(u"tëst3.2", exact=True)
+ self.assertEqual(u"tëst\ntëst2\n", stderr.getvalue())
+
+ # exact outputting, should only output v
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ display.e(u"tëst", exact=True)
+ display.ee(u"tëst2", exact=True)
+ # vvvv should be ignored regardless
+ display.eee(u"tëst3.1")
+ display.eee(u"tëst3.2", exact=True)
+ self.assertEqual(u"tëst2\n", stderr.getvalue())
+
+ # standard output should be empty throughtout all of this
+ self.assertEqual('', stdout.getvalue())
diff --git a/gitlint/tests/test_hooks.py b/gitlint/tests/test_hooks.py
new file mode 100644
index 0000000..08bd730
--- /dev/null
+++ b/gitlint/tests/test_hooks.py
@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+
+import os
+
+try:
+ # python 2.x
+ from mock import patch, ANY, mock_open
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch, ANY, mock_open # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.config import LintConfig
+from gitlint.hooks import GitHookInstaller, GitHookInstallerError, COMMIT_MSG_HOOK_SRC_PATH, COMMIT_MSG_HOOK_DST_PATH, \
+ GITLINT_HOOK_IDENTIFIER
+
+
+class HookTests(BaseTestCase):
+
+ @patch('gitlint.hooks.git_hooks_dir')
+ def test_commit_msg_hook_path(self, git_hooks_dir):
+ git_hooks_dir.return_value = os.path.join(u"/föo", u"bar")
+ lint_config = LintConfig()
+ lint_config.target = self.SAMPLES_DIR
+ expected_path = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ path = GitHookInstaller.commit_msg_hook_path(lint_config)
+
+ git_hooks_dir.assert_called_once_with(self.SAMPLES_DIR)
+ self.assertEqual(path, expected_path)
+
+ @staticmethod
+ @patch('os.chmod')
+ @patch('os.stat')
+ @patch('gitlint.hooks.shutil.copy')
+ @patch('os.path.exists', return_value=False)
+ @patch('os.path.isdir', return_value=True)
+ @patch('gitlint.hooks.git_hooks_dir')
+ def test_install_commit_msg_hook(git_hooks_dir, isdir, path_exists, copy, stat, chmod):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join(u"/hür", u"dur")
+ git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ GitHookInstaller.install_commit_msg_hook(lint_config)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ copy.assert_called_once_with(COMMIT_MSG_HOOK_SRC_PATH, expected_dst)
+ stat.assert_called_once_with(expected_dst)
+ chmod.assert_called_once_with(expected_dst, ANY)
+ git_hooks_dir.assert_called_with(lint_config.target)
+
+ @patch('gitlint.hooks.shutil.copy')
+ @patch('os.path.exists', return_value=False)
+ @patch('os.path.isdir', return_value=True)
+ @patch('gitlint.hooks.git_hooks_dir')
+ def test_install_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, copy):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join(u"/hür", u"dur")
+ git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
+ # mock that current dir is not a git repo
+ isdir.return_value = False
+ expected_msg = u"{0} is not a git repository".format(lint_config.target)
+ with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
+ GitHookInstaller.install_commit_msg_hook(lint_config)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_not_called()
+ copy.assert_not_called()
+
+ # mock that there is already a commit hook present
+ isdir.return_value = True
+ path_exists.return_value = True
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = u"There is already a commit-msg hook file present in {0}.\n".format(expected_dst) + \
+ "gitlint currently does not support appending to an existing commit-msg file."
+ with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
+ GitHookInstaller.install_commit_msg_hook(lint_config)
+
+ @staticmethod
+ @patch('os.remove')
+ @patch('os.path.exists', return_value=True)
+ @patch('os.path.isdir', return_value=True)
+ @patch('gitlint.hooks.git_hooks_dir')
+ def test_uninstall_commit_msg_hook(git_hooks_dir, isdir, path_exists, remove):
+ lint_config = LintConfig()
+ git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
+ lint_config.target = os.path.join(u"/hür", u"dur")
+ read_data = "#!/bin/sh\n" + GITLINT_HOOK_IDENTIFIER
+ with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ remove.assert_called_with(expected_dst)
+ git_hooks_dir.assert_called_with(lint_config.target)
+
+ @patch('os.remove')
+ @patch('os.path.exists', return_value=True)
+ @patch('os.path.isdir', return_value=True)
+ @patch('gitlint.hooks.git_hooks_dir')
+ def test_uninstall_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, remove):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join(u"/hür", u"dur")
+ git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
+
+ # mock that the current directory is not a git repo
+ isdir.return_value = False
+ expected_msg = u"{0} is not a git repository".format(lint_config.target)
+ with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_not_called()
+ remove.assert_not_called()
+
+ # mock that there is no commit hook present
+ isdir.return_value = True
+ path_exists.return_value = False
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = u"There is no commit-msg hook present in {0}.".format(expected_dst)
+ with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ remove.assert_not_called()
+
+ # mock that there is a different (=not gitlint) commit hook
+ isdir.return_value = True
+ path_exists.return_value = True
+ read_data = "#!/bin/sh\nfoo"
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = u"The commit-msg hook in {0} was not installed by gitlint ".format(expected_dst) + \
+ "(or it was modified).\nUninstallation of 3th party or modified gitlint hooks " + \
+ "is not supported."
+ with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True):
+ with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+ remove.assert_not_called()
diff --git a/gitlint/tests/test_lint.py b/gitlint/tests/test_lint.py
new file mode 100644
index 0000000..bcdd984
--- /dev/null
+++ b/gitlint/tests/test_lint.py
@@ -0,0 +1,197 @@
+# -*- coding: utf-8 -*-
+
+try:
+ # python 2.x
+ from StringIO import StringIO
+except ImportError:
+ # python 3.x
+ from io import StringIO
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.lint import GitLinter
+from gitlint.rules import RuleViolation
+from gitlint.config import LintConfig, LintConfigBuilder
+
+
+class LintTests(BaseTestCase):
+
+ def test_lint_sample1(self):
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample1"))
+ violations = linter.lint(gitcontext.commits[-1])
+ expected_errors = [RuleViolation("T3", "Title has trailing punctuation (.)",
+ u"Commit title contåining 'WIP', as well as trailing punctuation.", 1),
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"Commit title contåining 'WIP', as well as trailing punctuation.", 1),
+ RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
+ RuleViolation("B1", "Line exceeds max length (135>80)",
+ "This is the first line of the commit message body and it is meant to test " +
+ "a line that exceeds the maximum line length of 80 characters.", 3),
+ RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling space. ", 4),
+ RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)",
+ "This line has a trailing tab.\t", 5)]
+
+ self.assertListEqual(violations, expected_errors)
+
+ def test_lint_sample2(self):
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample2"))
+ violations = linter.lint(gitcontext.commits[-1])
+ expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"Just a title contåining WIP", 1),
+ RuleViolation("B6", "Body message is missing", None, 3)]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample3(self):
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample3"))
+ violations = linter.lint(gitcontext.commits[-1])
+
+ title = u" Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters."
+ expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1),
+ RuleViolation("T3", "Title has trailing punctuation (.)", title, 1),
+ RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1),
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1),
+ RuleViolation("T6", "Title has leading whitespace", title, 1),
+ RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
+ RuleViolation("B1", "Line exceeds max length (101>80)",
+ u"This is the first line is meånt to test a line that exceeds the maximum line " +
+ "length of 80 characters.", 3),
+ RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing space. ", 4),
+ RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling tab.\t", 5),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)",
+ u"This line has a tråiling tab.\t", 5)]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample4(self):
+ commit = self.gitcommit(self.get_sample("commit_message/sample4"))
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(commit)
+ linter = GitLinter(config_builder.build())
+ violations = linter.lint(commit)
+ # expect no violations because sample4 has a 'gitlint: disable line'
+ expected = []
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample5(self):
+ commit = self.gitcommit(self.get_sample("commit_message/sample5"))
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(commit)
+ linter = GitLinter(config_builder.build())
+ violations = linter.lint(commit)
+
+ title = u" Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters."
+ # expect only certain violations because sample5 has a 'gitlint-ignore: T3, T6, body-max-line-length'
+ expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1),
+ RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1),
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1),
+ RuleViolation("B4", "Second line is not empty", u"This line should be ëmpty", 2),
+ RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling space. ", 4),
+ RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)",
+ "This line has a trailing tab.\t", 5)]
+ self.assertListEqual(violations, expected)
+
+ def test_lint_meta(self):
+ """ Lint sample2 but also add some metadata to the commit so we that get's linted as well """
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample2"))
+ gitcontext.commits[0].author_email = u"foo bår"
+ violations = linter.lint(gitcontext.commits[-1])
+ expected = [RuleViolation("M1", "Author email for commit is invalid", u"foo bår", None),
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"Just a title contåining WIP", 1),
+ RuleViolation("B6", "Body message is missing", None, 3)]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_ignore(self):
+ lint_config = LintConfig()
+ lint_config.ignore = ["T1", "T3", "T4", "T5", "T6", "B1", "B2"]
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample3")))
+
+ expected = [RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)",
+ u"This line has a tråiling tab.\t", 5)]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_configuration_rule(self):
+ # Test that all rules are ignored because of matching regex
+ lint_config = LintConfig()
+ lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
+
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
+ self.assertListEqual(violations, [])
+
+ # Test ignoring only certain rules
+ lint_config = LintConfig()
+ lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
+ lint_config.set_rule_option("I1", "ignore", "B6")
+
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
+
+ # Normally we'd expect a B6 violation, but that one is skipped because of the specific ignore set above
+ expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
+ u"Just a title contåining WIP", 1)]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_special_commit(self):
+ for commit_type in ["merge", "revert", "squash", "fixup"]:
+ commit = self.gitcommit(self.get_sample("commit_message/{0}".format(commit_type)))
+ lintconfig = LintConfig()
+ linter = GitLinter(lintconfig)
+ violations = linter.lint(commit)
+ # Even though there are a number of violations in the commit message, they are ignored because
+ # we are dealing with a merge commit
+ self.assertListEqual(violations, [])
+
+ # Check that we do see violations if we disable 'ignore-merge-commits'
+ setattr(lintconfig, "ignore_{0}_commits".format(commit_type), False)
+ linter = GitLinter(lintconfig)
+ violations = linter.lint(commit)
+ self.assertTrue(len(violations) > 0)
+
+ def test_print_violations(self):
+ violations = [RuleViolation("RULE_ID_1", u"Error Messåge 1", "Violating Content 1", None),
+ RuleViolation("RULE_ID_2", "Error Message 2", u"Violåting Content 2", 2)]
+ linter = GitLinter(LintConfig())
+
+ # test output with increasing verbosity
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ linter.config.verbosity = 0
+ linter.print_violations(violations)
+ self.assertEqual("", stderr.getvalue())
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ linter.config.verbosity = 1
+ linter.print_violations(violations)
+ expected = u"-: RULE_ID_1\n2: RULE_ID_2\n"
+ self.assertEqual(expected, stderr.getvalue())
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ linter.config.verbosity = 2
+ linter.print_violations(violations)
+ expected = u"-: RULE_ID_1 Error Messåge 1\n2: RULE_ID_2 Error Message 2\n"
+ self.assertEqual(expected, stderr.getvalue())
+
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ linter.config.verbosity = 3
+ linter.print_violations(violations)
+ expected = u"-: RULE_ID_1 Error Messåge 1: \"Violating Content 1\"\n" + \
+ u"2: RULE_ID_2 Error Message 2: \"Violåting Content 2\"\n"
+ self.assertEqual(expected, stderr.getvalue())
diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py
new file mode 100644
index 0000000..2c17226
--- /dev/null
+++ b/gitlint/tests/test_options.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+import os
+
+from gitlint.tests.base import BaseTestCase
+
+from gitlint.options import IntOption, BoolOption, StrOption, ListOption, PathOption, RuleOptionError
+
+
+class RuleOptionTests(BaseTestCase):
+ def test_option_equality(self):
+ # 2 options are equal if their name, value and description match
+ option1 = IntOption("test-option", 123, u"Test Dëscription")
+ option2 = IntOption("test-option", 123, u"Test Dëscription")
+ self.assertEqual(option1, option2)
+
+ # Not equal: name, description, value are different
+ self.assertNotEqual(option1, IntOption("test-option1", 123, u"Test Dëscription"))
+ self.assertNotEqual(option1, IntOption("test-option", 1234, u"Test Dëscription"))
+ self.assertNotEqual(option1, IntOption("test-option", 123, u"Test Dëscription2"))
+
+ def test_int_option(self):
+ # normal behavior
+ option = IntOption("test-name", 123, "Test Description")
+ self.assertEqual(option.value, 123)
+ self.assertEqual(option.name, "test-name")
+ self.assertEqual(option.description, "Test Description")
+
+ # re-set value
+ option.set(456)
+ self.assertEqual(option.value, 456)
+
+ # error on negative int when not allowed
+ expected_error = u"Option 'test-name' must be a positive integer (current value: '-123')"
+ with self.assertRaisesRegex(RuleOptionError, expected_error):
+ option.set(-123)
+
+ # error on non-int value
+ expected_error = u"Option 'test-name' must be a positive integer (current value: 'foo')"
+ with self.assertRaisesRegex(RuleOptionError, expected_error):
+ option.set("foo")
+
+ # no error on negative value when allowed and negative int is passed
+ option = IntOption("test-name", 123, "Test Description", allow_negative=True)
+ option.set(-456)
+ self.assertEqual(option.value, -456)
+
+ # error on non-int value when negative int is allowed
+ expected_error = u"Option 'test-name' must be an integer (current value: 'foo')"
+ with self.assertRaisesRegex(RuleOptionError, expected_error):
+ option.set("foo")
+
+ def test_str_option(self):
+ # normal behavior
+ option = StrOption("test-name", u"föo", "Test Description")
+ self.assertEqual(option.value, u"föo")
+ self.assertEqual(option.name, "test-name")
+ self.assertEqual(option.description, "Test Description")
+
+ # re-set value
+ option.set(u"bår")
+ self.assertEqual(option.value, u"bår")
+
+ # conversion to str
+ option.set(123)
+ self.assertEqual(option.value, "123")
+
+ # conversion to str
+ option.set(-123)
+ self.assertEqual(option.value, "-123")
+
+ def test_boolean_option(self):
+ # normal behavior
+ option = BoolOption("test-name", "true", "Test Description")
+ self.assertEqual(option.value, True)
+
+ # re-set value
+ option.set("False")
+ self.assertEqual(option.value, False)
+
+ # Re-set using actual boolean
+ option.set(True)
+ self.assertEqual(option.value, True)
+
+ # error on incorrect value
+ incorrect_values = [1, -1, "foo", u"bår", ["foo"], {'foo': "bar"}]
+ for value in incorrect_values:
+ with self.assertRaisesRegex(RuleOptionError, "Option 'test-name' must be either 'true' or 'false'"):
+ option.set(value)
+
+ def test_list_option(self):
+ # normal behavior
+ option = ListOption("test-name", u"å,b,c,d", "Test Description")
+ self.assertListEqual(option.value, [u"å", u"b", u"c", u"d"])
+
+ # re-set value
+ option.set(u"1,2,3,4")
+ self.assertListEqual(option.value, [u"1", u"2", u"3", u"4"])
+
+ # set list
+ option.set([u"foo", u"bår", u"test"])
+ self.assertListEqual(option.value, [u"foo", u"bår", u"test"])
+
+ # empty string
+ option.set("")
+ self.assertListEqual(option.value, [])
+
+ # whitespace string
+ option.set(" \t ")
+ self.assertListEqual(option.value, [])
+
+ # empty list
+ option.set([])
+ self.assertListEqual(option.value, [])
+
+ # trailing comma
+ option.set(u"ë,f,g,")
+ self.assertListEqual(option.value, [u"ë", u"f", u"g"])
+
+ # leading and trailing whitespace should be trimmed, but only deduped within text
+ option.set(" abc , def , ghi \t , jkl mno ")
+ self.assertListEqual(option.value, ["abc", "def", "ghi", "jkl mno"])
+
+ # Also strip whitespace within a list
+ option.set(["\t foo", "bar \t ", " test 123 "])
+ self.assertListEqual(option.value, ["foo", "bar", "test 123"])
+
+ # conversion to string before split
+ option.set(123)
+ self.assertListEqual(option.value, ["123"])
+
+ def test_path_option(self):
+ option = PathOption("test-directory", ".", u"Test Description", type=u"dir")
+ self.assertEqual(option.value, os.getcwd())
+ self.assertEqual(option.name, "test-directory")
+ self.assertEqual(option.description, u"Test Description")
+ self.assertEqual(option.type, u"dir")
+
+ # re-set value
+ option.set(self.SAMPLES_DIR)
+ self.assertEqual(option.value, self.SAMPLES_DIR)
+
+ # set to int
+ expected = u"Option test-directory must be an existing directory (current value: '1234')"
+ with self.assertRaisesRegex(RuleOptionError, expected):
+ option.set(1234)
+
+ # set to non-existing directory
+ non_existing_path = os.path.join(u"/föo", u"bar")
+ expected = u"Option test-directory must be an existing directory (current value: '{0}')"
+ with self.assertRaisesRegex(RuleOptionError, expected.format(non_existing_path)):
+ option.set(non_existing_path)
+
+ # set to a file, should raise exception since option.type = dir
+ sample_path = self.get_sample_path(os.path.join("commit_message", "sample1"))
+ expected = u"Option test-directory must be an existing directory (current value: '{0}')".format(sample_path)
+ with self.assertRaisesRegex(RuleOptionError, expected):
+ option.set(sample_path)
+
+ # set option.type = file, file should now be accepted, directories not
+ option.type = u"file"
+ option.set(sample_path)
+ self.assertEqual(option.value, sample_path)
+ expected = u"Option test-directory must be an existing file (current value: '{0}')".format(
+ self.get_sample_path())
+ with self.assertRaisesRegex(RuleOptionError, expected):
+ option.set(self.get_sample_path())
+
+ # set option.type = both, files and directories should now be accepted
+ option.type = u"both"
+ option.set(sample_path)
+ self.assertEqual(option.value, sample_path)
+ option.set(self.get_sample_path())
+ self.assertEqual(option.value, self.get_sample_path())
+
+ # Expect exception if path type is invalid
+ option.type = u'föo'
+ expected = u"Option test-directory type must be one of: 'file', 'dir', 'both' (current: 'föo')"
+ with self.assertRaisesRegex(RuleOptionError, expected):
+ option.set("haha")
diff --git a/gitlint/tests/test_utils.py b/gitlint/tests/test_utils.py
new file mode 100644
index 0000000..6f667c2
--- /dev/null
+++ b/gitlint/tests/test_utils.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+
+from gitlint import utils
+from gitlint.tests.base import BaseTestCase
+
+try:
+ # python 2.x
+ from mock import patch
+except ImportError:
+ # python 3.x
+ from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
+
+
+class UtilsTests(BaseTestCase):
+
+ def tearDown(self):
+ # Since we're messing around with `utils.PLATFORM_IS_WINDOWS` during these tests, we need to reset
+ # its value after we're done this doesn't influence other tests
+ utils.PLATFORM_IS_WINDOWS = utils.platform_is_windows()
+
+ @patch('os.environ')
+ def test_use_sh_library(self, patched_env):
+ patched_env.get.return_value = "1"
+ self.assertEqual(utils.use_sh_library(), True)
+ patched_env.get.assert_called_once_with("GITLINT_USE_SH_LIB", None)
+
+ for invalid_val in ["0", u"foöbar"]:
+ patched_env.get.reset_mock() # reset mock call count
+ patched_env.get.return_value = invalid_val
+ self.assertEqual(utils.use_sh_library(), False, invalid_val)
+ patched_env.get.assert_called_once_with("GITLINT_USE_SH_LIB", None)
+
+ # Assert that when GITLINT_USE_SH_LIB is not set, we fallback to checking whether we're on Windows
+ utils.PLATFORM_IS_WINDOWS = True
+ patched_env.get.return_value = None
+ self.assertEqual(utils.use_sh_library(), False)
+
+ utils.PLATFORM_IS_WINDOWS = False
+ self.assertEqual(utils.use_sh_library(), True)
+
+ @patch('gitlint.utils.locale')
+ def test_default_encoding_non_windows(self, mocked_locale):
+ utils.PLATFORM_IS_WINDOWS = False
+ mocked_locale.getpreferredencoding.return_value = u"foöbar"
+ self.assertEqual(utils.getpreferredencoding(), u"foöbar")
+ mocked_locale.getpreferredencoding.assert_called_once()
+
+ mocked_locale.getpreferredencoding.return_value = False
+ self.assertEqual(utils.getpreferredencoding(), u"UTF-8")
+
+ @patch('os.environ')
+ def test_default_encoding_windows(self, patched_env):
+ utils.PLATFORM_IS_WINDOWS = True
+ # Mock out os.environ
+ mock_env = {}
+
+ def mocked_get(key, default):
+ return mock_env.get(key, default)
+
+ patched_env.get.side_effect = mocked_get
+
+ # Assert getpreferredencoding reads env vars in order: LC_ALL, LC_CTYPE, LANG
+ mock_env = {"LC_ALL": u"lc_all_välue", "LC_CTYPE": u"foo", "LANG": u"bar"}
+ self.assertEqual(utils.getpreferredencoding(), u"lc_all_välue")
+ mock_env = {"LC_CTYPE": u"lc_ctype_välue", "LANG": u"hur"}
+ self.assertEqual(utils.getpreferredencoding(), u"lc_ctype_välue")
+ mock_env = {"LANG": u"lang_välue"}
+ self.assertEqual(utils.getpreferredencoding(), u"lang_välue")
+
+ # Assert split on dot
+ mock_env = {"LANG": u"foo.bär"}
+ self.assertEqual(utils.getpreferredencoding(), u"bär")
+
+ # assert default encoding is UTF-8
+ mock_env = {}
+ self.assertEqual(utils.getpreferredencoding(), "UTF-8")
+ mock_env = {"FOO": u"föo"}
+ self.assertEqual(utils.getpreferredencoding(), "UTF-8")
diff --git a/gitlint/utils.py b/gitlint/utils.py
new file mode 100644
index 0000000..c418347
--- /dev/null
+++ b/gitlint/utils.py
@@ -0,0 +1,105 @@
+# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return
+import platform
+import sys
+import os
+
+import locale
+
+# Note: While we can easily inline the logic related to the constants set in this module, we deliberately create
+# small functions that encapsulate that logic as this enables easy unit testing. In particular, by creating functions
+# we can easily mock the dependencies during testing, which is not possible if the code is not enclosed in a function
+# and just executed at import-time.
+
+########################################################################################################################
+LOG_FORMAT = '%(levelname)s: %(name)s %(message)s'
+
+########################################################################################################################
+# PLATFORM_IS_WINDOWS
+
+
+def platform_is_windows():
+ return "windows" in platform.system().lower()
+
+
+PLATFORM_IS_WINDOWS = platform_is_windows()
+
+########################################################################################################################
+# USE_SH_LIB
+# Determine whether to use the `sh` library
+# On windows we won't want to use the sh library since it's not supported - instead we'll use our own shell module.
+# However, we want to be able to overwrite this behavior for testing using the GITLINT_USE_SH_LIB env var.
+
+
+def use_sh_library():
+ gitlint_use_sh_lib_env = os.environ.get('GITLINT_USE_SH_LIB', None)
+ if gitlint_use_sh_lib_env:
+ return gitlint_use_sh_lib_env == "1"
+ return not PLATFORM_IS_WINDOWS
+
+
+USE_SH_LIB = use_sh_library()
+
+########################################################################################################################
+# DEFAULT_ENCODING
+
+
+def getpreferredencoding():
+ """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars
+ on windows and falls back to UTF-8. """
+ default_encoding = locale.getpreferredencoding() or "UTF-8"
+
+ # On Windows, we mimic git/linux by trying to read the LC_ALL, LC_CTYPE, LANG env vars manually
+ # (on Linux/MacOS the `getpreferredencoding()` call will take care of this).
+ # We fallback to UTF-8
+ if PLATFORM_IS_WINDOWS:
+ default_encoding = "UTF-8"
+ for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]:
+ encoding = os.environ.get(env_var, False)
+ if encoding:
+ # Support dotted (C.UTF-8) and non-dotted (C or UTF-8) charsets:
+ # If encoding contains a dot: split and use second part, otherwise use everything
+ dot_index = encoding.find(".")
+ if dot_index != -1:
+ default_encoding = encoding[dot_index + 1:]
+ else:
+ default_encoding = encoding
+ break
+
+ return default_encoding
+
+
+DEFAULT_ENCODING = getpreferredencoding()
+
+########################################################################################################################
+# Unicode utility functions
+
+
+def ustr(obj):
+ """ Python 2 and 3 utility method that converts an obj to unicode in python 2 and to a str object in python 3"""
+ if sys.version_info[0] == 2:
+ # If we are getting a string, then do an explicit decode
+ # else, just call the unicode method of the object
+ if type(obj) in [str, basestring]: # pragma: no cover # noqa
+ return unicode(obj, DEFAULT_ENCODING) # pragma: no cover # noqa
+ else:
+ return unicode(obj) # pragma: no cover # noqa
+ else:
+ if type(obj) in [bytes]:
+ return obj.decode(DEFAULT_ENCODING)
+ else:
+ return str(obj)
+
+
+def sstr(obj):
+ """ Python 2 and 3 utility method that converts an obj to a DEFAULT_ENCODING encoded string in python 2
+ and to unicode in python 3.
+ Especially useful for implementing __str__ methods in python 2: http://stackoverflow.com/a/1307210/381010"""
+ if sys.version_info[0] == 2:
+ # For lists in python2, remove unicode string representation characters.
+ # i.e. ensure lists are printed as ['a', 'b'] and not [u'a', u'b']
+ if type(obj) in [list]:
+ return [sstr(item) for item in obj] # pragma: no cover # noqa
+
+ return unicode(obj).encode(DEFAULT_ENCODING) # pragma: no cover # noqa
+ else:
+ return obj # pragma: no cover
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..e373b71
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,17 @@
+site_name: Gitlint
+site_description: Linting for your git commit messages
+site_url: http://jorisroovers.github.io/gitlint/
+repo_url: https://github.com/jorisroovers/gitlint
+nav:
+ - Home: index.md
+ - Configuration: configuration.md
+ - Rules: rules.md
+ - Contrib Rules: contrib_rules.md
+ - User Defined Rules: user_defined_rules.md
+ - Contributing: contributing.md
+ - Changelog: https://github.com/jorisroovers/gitlint/blob/master/CHANGELOG.md
+
+markdown_extensions: [admonition]
+theme: readthedocs
+strict: true
+extra_css: [extra.css] \ No newline at end of file
diff --git a/qa/__init__.py b/qa/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/qa/__init__.py
diff --git a/qa/base.py b/qa/base.py
new file mode 100644
index 0000000..05d85e5
--- /dev/null
+++ b/qa/base.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return,
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+
+import io
+import os
+import platform
+import shutil
+import sys
+import tempfile
+from datetime import datetime
+from uuid import uuid4
+
+import arrow
+
+try:
+ # python 2.x
+ from unittest2 import TestCase
+except ImportError:
+ # python 3.x
+ from unittest import TestCase
+
+from qa.shell import git, gitlint, RunningCommand
+from qa.utils import DEFAULT_ENCODING, ustr
+
+
+class BaseTestCase(TestCase):
+ """ Base class of which all gitlint integration test classes are derived.
+ Provides a number of convenience methods. """
+
+ # In case of assert failures, print the full error message
+ maxDiff = None
+ tmp_git_repo = None
+
+ GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
+ GIT_CONTEXT_ERROR_CODE = 254
+
+ @classmethod
+ def setUpClass(cls):
+ """ Sets up the integration tests by creating a new temporary git repository """
+ cls.tmp_git_repos = []
+ cls.tmp_git_repo = cls.create_tmp_git_repo()
+
+ @classmethod
+ def tearDownClass(cls):
+ """ Cleans up the temporary git repositories """
+ for repo in cls.tmp_git_repos:
+ shutil.rmtree(repo)
+
+ def setUp(self):
+ self.tmpfiles = []
+
+ def tearDown(self):
+ for tmpfile in self.tmpfiles:
+ os.remove(tmpfile)
+
+ def assertEqualStdout(self, output, expected): # pylint: disable=invalid-name
+ self.assertIsInstance(output, RunningCommand)
+ output = ustr(output.stdout)
+ output = output.replace('\r', '')
+ self.assertMultiLineEqual(output, expected)
+
+ @classmethod
+ def generate_temp_path(cls):
+ return os.path.realpath("/tmp/gitlint-test-{0}".format(datetime.now().strftime("%Y%m%d-%H%M%S-%f")))
+
+ @classmethod
+ def create_tmp_git_repo(cls):
+ """ Creates a temporary git repository and returns its directory path """
+ tmp_git_repo = cls.generate_temp_path()
+ cls.tmp_git_repos.append(tmp_git_repo)
+
+ git("init", tmp_git_repo)
+ # configuring name and email is required in every git repot
+ git("config", "user.name", "gitlint-test-user", _cwd=tmp_git_repo)
+ git("config", "user.email", "gitlint@test.com", _cwd=tmp_git_repo)
+
+ # Git does not by default print unicode paths, fix that by setting core.quotePath to false
+ # http://stackoverflow.com/questions/34549040/git-not-displaying-unicode-file-names
+ # ftp://www.kernel.org/pub/software/scm/git/docs/git-config.html
+ git("config", "core.quotePath", "false", _cwd=tmp_git_repo)
+
+ # Git on mac doesn't like unicode characters by default, so we need to set this option
+ # http://stackoverflow.com/questions/5581857/git-and-the-umlaut-problem-on-mac-os-x
+ git("config", "core.precomposeunicode", "true", _cwd=tmp_git_repo)
+
+ return tmp_git_repo
+
+ @staticmethod
+ def create_file(parent_dir):
+ """ Creates a file inside a passed directory. Returns filename."""
+ test_filename = u"test-fïle-" + str(uuid4())
+ io.open(os.path.join(parent_dir, test_filename), 'a', encoding=DEFAULT_ENCODING).close()
+ return test_filename
+
+ def create_simple_commit(self, message, out=None, ok_code=None, env=None, git_repo=None, tty_in=False):
+ """ Creates a simple commit with an empty test file.
+ :param message: Commit message for the commit. """
+
+ git_repo = self.tmp_git_repo if git_repo is None else git_repo
+
+ # Let's make sure that we copy the environment in which this python code was executed as environment
+ # variables can influence how git runs.
+ # This was needed to fix https://github.com/jorisroovers/gitlint/issues/15 as we need to make sure to use
+ # the PATH variable that contains the virtualenv's python binary.
+ environment = os.environ
+ if env:
+ environment.update(env)
+
+ # Create file and add to git
+ test_filename = self.create_file(git_repo)
+ git("add", test_filename, _cwd=git_repo)
+ # https://amoffat.github.io/sh/#interactive-callbacks
+ if not ok_code:
+ ok_code = [0]
+
+ git("commit", "-m", message, _cwd=git_repo, _err_to_out=True, _out=out, _tty_in=tty_in,
+ _ok_code=ok_code, _env=environment)
+ return test_filename
+
+ def create_tmpfile(self, content):
+ """ Utility method to create temp files. These are cleaned at the end of the test """
+ # Not using a context manager to avoid unneccessary identation in test code
+ tmpfile, tmpfilepath = tempfile.mkstemp()
+ self.tmpfiles.append(tmpfilepath)
+ with io.open(tmpfile, "w", encoding=DEFAULT_ENCODING) as f:
+ f.write(content)
+ return tmpfilepath
+
+ @staticmethod
+ def get_example_path(filename=""):
+ examples_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../examples")
+ return os.path.join(examples_dir, filename)
+
+ @staticmethod
+ def get_sample_path(filename=""):
+ samples_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
+ return os.path.join(samples_dir, filename)
+
+ def get_last_commit_short_hash(self, git_repo=None):
+ git_repo = self.tmp_git_repo if git_repo is None else git_repo
+ return git("rev-parse", "--short", "HEAD", _cwd=git_repo, _err_to_out=True).replace("\n", "")
+
+ def get_last_commit_hash(self, git_repo=None):
+ git_repo = self.tmp_git_repo if git_repo is None else git_repo
+ return git("rev-parse", "HEAD", _cwd=git_repo, _err_to_out=True).replace("\n", "")
+
+ @staticmethod
+ def get_expected(filename="", variable_dict=None):
+ """ Utility method to read an 'expected' file and return it as a string. Optionally replace template variables
+ specified by variable_dict. """
+ expected_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
+ expected_path = os.path.join(expected_dir, filename)
+ expected = io.open(expected_path, encoding=DEFAULT_ENCODING).read()
+
+ if variable_dict:
+ expected = expected.format(**variable_dict)
+ return expected
+
+ @staticmethod
+ def get_system_info_dict():
+ """ Returns a dict with items related to system values logged by `gitlint --debug` """
+ expected_gitlint_version = gitlint("--version").replace("gitlint, version ", "").replace("\n", "")
+ expected_git_version = git("--version").replace("\n", "")
+ return {'platform': platform.platform(), 'python_version': sys.version,
+ 'git_version': expected_git_version, 'gitlint_version': expected_gitlint_version,
+ 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB}
+
+ def get_debug_vars_last_commit(self, git_repo=None):
+ """ Returns a dict with items related to `gitlint --debug` output for the last commit. """
+ target_repo = git_repo if git_repo else self.tmp_git_repo
+ commit_sha = self.get_last_commit_hash(git_repo=target_repo)
+ expected_date = git("log", "-1", "--pretty=%ai", _tty_out=False, _cwd=target_repo)
+ expected_date = arrow.get(str(expected_date), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z")
+
+ expected_kwargs = self.get_system_info_dict()
+ expected_kwargs.update({'target': target_repo, 'commit_sha': commit_sha, 'commit_date': expected_date})
+ return expected_kwargs
diff --git a/qa/expected/test_commits/test_ignore_commits_1 b/qa/expected/test_commits/test_ignore_commits_1
new file mode 100644
index 0000000..f9062c1
--- /dev/null
+++ b/qa/expected/test_commits/test_ignore_commits_1
@@ -0,0 +1,11 @@
+Commit {commit_sha0}:
+1: T3 Title has trailing punctuation (.): "Sïmple title4."
+
+Commit {commit_sha1}:
+1: T5 Title contains the word 'WIP' (case-insensitive): "Sïmple WIP title3."
+
+Commit {commit_sha2}:
+3: B5 Body message is too short (5<20): "Short"
+
+Commit {commit_sha3}:
+1: T3 Title has trailing punctuation (.): "Sïmple title."
diff --git a/qa/expected/test_commits/test_lint_head_1 b/qa/expected/test_commits/test_lint_head_1
new file mode 100644
index 0000000..d7ca594
--- /dev/null
+++ b/qa/expected/test_commits/test_lint_head_1
@@ -0,0 +1,8 @@
+Commit {commit_sha0}:
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Sïmple title"
+
+Commit {commit_sha1}:
+3: B6 Body message is missing
+
+Commit {commit_sha2}:
+1: T3 Title has trailing punctuation (.): "Sïmple title."
diff --git a/qa/expected/test_commits/test_lint_staged_msg_filename_1 b/qa/expected/test_commits/test_lint_staged_msg_filename_1
new file mode 100644
index 0000000..878bc4c
--- /dev/null
+++ b/qa/expected/test_commits/test_lint_staged_msg_filename_1
@@ -0,0 +1,73 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: {git_version}
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: True
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=72
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=80
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli Fetching additional meta-data from staged commit
+DEBUG: gitlint.cli Using --msg-filename.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: from fïle test.
+--- Meta info ---------
+Author: gitlint-test-user <gitlint@test.com>
+Date: {staged_date}
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['master']
+Changed Files: {changed_files}
+-----------------------
+1: T3 Title has trailing punctuation (.): "WIP: from fïle test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: from fïle test."
+3: B6 Body message is missing
+DEBUG: gitlint.cli Exit Code = 3
diff --git a/qa/expected/test_commits/test_lint_staged_stdin_1 b/qa/expected/test_commits/test_lint_staged_stdin_1
new file mode 100644
index 0000000..3f178f8
--- /dev/null
+++ b/qa/expected/test_commits/test_lint_staged_stdin_1
@@ -0,0 +1,75 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: {git_version}
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: True
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=72
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=80
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli Fetching additional meta-data from staged commit
+DEBUG: gitlint.cli Stdin data: 'WIP: Pïpe test.
+'
+DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: Pïpe test.
+--- Meta info ---------
+Author: gitlint-test-user <gitlint@test.com>
+Date: {staged_date}
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['master']
+Changed Files: {changed_files}
+-----------------------
+1: T3 Title has trailing punctuation (.): "WIP: Pïpe test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Pïpe test."
+3: B6 Body message is missing
+DEBUG: gitlint.cli Exit Code = 3
diff --git a/qa/expected/test_commits/test_violations_1 b/qa/expected/test_commits/test_violations_1
new file mode 100644
index 0000000..6f3f9e2
--- /dev/null
+++ b/qa/expected/test_commits/test_violations_1
@@ -0,0 +1,7 @@
+Commit {commit_sha2}:
+1: T3 Title has trailing punctuation (.): "Sïmple title3."
+3: B6 Body message is missing
+
+Commit {commit_sha1}:
+1: T3 Title has trailing punctuation (.): "Sïmple title2."
+3: B6 Body message is missing
diff --git a/qa/expected/test_config/test_config_from_file_1 b/qa/expected/test_config/test_config_from_file_1
new file mode 100644
index 0000000..6fe434a
--- /dev/null
+++ b/qa/expected/test_config/test_config_from_file_1
@@ -0,0 +1,5 @@
+1: T1 Title exceeds max length (42>20)
+1: T5 Title contains the word 'WIP' (case-insensitive)
+1: T5 Title contains the word 'thåt' (case-insensitive)
+2: B4 Second line is not empty
+3: B1 Line exceeds max length (48>30)
diff --git a/qa/expected/test_config/test_config_from_file_debug_1 b/qa/expected/test_config/test_config_from_file_debug_1
new file mode 100644
index 0000000..443ee26
--- /dev/null
+++ b/qa/expected/test_config/test_config_from_file_debug_1
@@ -0,0 +1,77 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: {git_version}
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli Configuration
+config-path: {config_path}
+[GENERAL]
+extra-path: None
+contrib: []
+ignore: title-trailing-punctuation,B2
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: False
+verbosity: 2
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ T1: title-max-length
+ line-length=20
+ T2: title-trailing-whitespace
+ T6: title-leading-whitespace
+ T3: title-trailing-punctuation
+ T4: title-hard-tab
+ T5: title-must-not-contain-word
+ words=WIP,thåt
+ T7: title-match-regex
+ regex=.*
+ B1: body-max-line-length
+ line-length=30
+ B5: body-min-length
+ min-length=20
+ B6: body-is-missing
+ ignore-merge-commits=True
+ B2: body-trailing-whitespace
+ B3: body-hard-tab
+ B4: body-first-line-empty
+ B7: body-changed-file-mention
+ files=
+ M1: author-valid-email
+ regex=[^@ ]+@[^@ ]+\.[^@ ]+
+
+DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo.
+DEBUG: gitlint.cli Linting 1 commit(s)
+DEBUG: gitlint.lint Linting commit {commit_sha}
+DEBUG: gitlint.lint Commit Object
+--- Commit Message ----
+WIP: Thïs is a title thåt is a bit longer.
+Content on the second line
+This line of the body is here because we need it
+
+--- Meta info ---------
+Author: gitlint-test-user <gitlint@test.com>
+Date: {commit_date}
+is-merge-commit: False
+is-fixup-commit: False
+is-squash-commit: False
+is-revert-commit: False
+Branches: ['master']
+Changed Files: {changed_files}
+-----------------------
+1: T1 Title exceeds max length (42>20)
+1: T5 Title contains the word 'WIP' (case-insensitive)
+1: T5 Title contains the word 'thåt' (case-insensitive)
+2: B4 Second line is not empty
+3: B1 Line exceeds max length (48>30)
+DEBUG: gitlint.cli Exit Code = 5
diff --git a/qa/expected/test_config/test_set_rule_option_1 b/qa/expected/test_config/test_set_rule_option_1
new file mode 100644
index 0000000..10b5e50
--- /dev/null
+++ b/qa/expected/test_config/test_set_rule_option_1
@@ -0,0 +1,3 @@
+1: T1 Title exceeds max length (16>5): "This ïs a title."
+1: T3 Title has trailing punctuation (.): "This ïs a title."
+3: B6 Body message is missing
diff --git a/qa/expected/test_config/test_verbosity_1 b/qa/expected/test_config/test_verbosity_1
new file mode 100644
index 0000000..0202072
--- /dev/null
+++ b/qa/expected/test_config/test_verbosity_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.)
+1: T5 Title contains the word 'WIP' (case-insensitive)
+2: B4 Second line is not empty
diff --git a/qa/expected/test_config/test_verbosity_2 b/qa/expected/test_config/test_verbosity_2
new file mode 100644
index 0000000..5a54082
--- /dev/null
+++ b/qa/expected/test_config/test_verbosity_2
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: Thïs is a title."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thïs is a title."
+2: B4 Second line is not empty: "Contënt on the second line"
diff --git a/qa/expected/test_contrib/test_contrib_rules_1 b/qa/expected/test_contrib/test_contrib_rules_1
new file mode 100644
index 0000000..99b33b7
--- /dev/null
+++ b/qa/expected/test_contrib/test_contrib_rules_1
@@ -0,0 +1,4 @@
+1: CC1 Body does not contain a 'Signed-Off-By' line
+1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert: "WIP Thi$ is å title"
+1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"
diff --git a/qa/expected/test_contrib/test_contrib_rules_with_config_1 b/qa/expected/test_contrib/test_contrib_rules_with_config_1
new file mode 100644
index 0000000..21d467a
--- /dev/null
+++ b/qa/expected/test_contrib/test_contrib_rules_with_config_1
@@ -0,0 +1,4 @@
+1: CC1 Body does not contain a 'Signed-Off-By' line
+1: CT1 Title does not start with one of föo, bår: "WIP Thi$ is å title"
+1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"
diff --git a/qa/expected/test_gitlint/test_msg_filename_1 b/qa/expected/test_gitlint/test_msg_filename_1
new file mode 100644
index 0000000..d01b23b
--- /dev/null
+++ b/qa/expected/test_gitlint/test_msg_filename_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: msg-fïlename test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-fïlename test."
+3: B6 Body message is missing
diff --git a/qa/expected/test_gitlint/test_msg_filename_no_tty_1 b/qa/expected/test_gitlint/test_msg_filename_no_tty_1
new file mode 100644
index 0000000..4785e28
--- /dev/null
+++ b/qa/expected/test_gitlint/test_msg_filename_no_tty_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: msg-fïlename NO TTY test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-fïlename NO TTY test."
+3: B6 Body message is missing
diff --git a/qa/expected/test_gitlint/test_violations_1 b/qa/expected/test_gitlint/test_violations_1
new file mode 100644
index 0000000..7e55eda
--- /dev/null
+++ b/qa/expected/test_gitlint/test_violations_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: This ïs a title."
+2: B4 Second line is not empty: "Content on the sëcond line"
diff --git a/qa/expected/test_stdin/test_stdin_file_1 b/qa/expected/test_stdin/test_stdin_file_1
new file mode 100644
index 0000000..ea7fad2
--- /dev/null
+++ b/qa/expected/test_stdin/test_stdin_file_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: STDIN ïs a file test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: STDIN ïs a file test."
+3: B6 Body message is missing
diff --git a/qa/expected/test_stdin/test_stdin_pipe_1 b/qa/expected/test_stdin/test_stdin_pipe_1
new file mode 100644
index 0000000..8714533
--- /dev/null
+++ b/qa/expected/test_stdin/test_stdin_pipe_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: Pïpe test."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Pïpe test."
+3: B6 Body message is missing
diff --git a/qa/expected/test_stdin/test_stdin_pipe_empty_1 b/qa/expected/test_stdin/test_stdin_pipe_empty_1
new file mode 100644
index 0000000..7e55eda
--- /dev/null
+++ b/qa/expected/test_stdin/test_stdin_pipe_empty_1
@@ -0,0 +1,3 @@
+1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: This ïs a title."
+2: B4 Second line is not empty: "Content on the sëcond line"
diff --git a/qa/expected/test_user_defined/test_user_defined_rules_examples_1 b/qa/expected/test_user_defined/test_user_defined_rules_examples_1
new file mode 100644
index 0000000..9d00445
--- /dev/null
+++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_1
@@ -0,0 +1,5 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title"
+1: UC2 Body does not contain a 'Signed-Off-By' line
+1: UC3 Branch name 'master' does not start with one of ['feature/', 'hotfix/', 'release/']
+1: UL1 Title contains the special character '$': "WIP: Thi$ is å title"
+2: B4 Second line is not empty: "Content on the second line"
diff --git a/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1 b/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1
new file mode 100644
index 0000000..a143715
--- /dev/null
+++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1
@@ -0,0 +1,6 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title"
+1: UC1 Body contains too many lines (2 > 1)
+1: UC2 Body does not contain a 'Signed-Off-By' line
+1: UC3 Branch name 'master' does not start with one of ['feature/', 'hotfix/', 'release/']
+1: UL1 Title contains the special character '$': "WIP: Thi$ is å title"
+2: B4 Second line is not empty: "Content on the second line"
diff --git a/qa/expected/test_user_defined/test_user_defined_rules_extra_1 b/qa/expected/test_user_defined/test_user_defined_rules_extra_1
new file mode 100644
index 0000000..65f3507
--- /dev/null
+++ b/qa/expected/test_user_defined/test_user_defined_rules_extra_1
@@ -0,0 +1,5 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title"
+1: UC1 GitContext.current_branch: master
+1: UC1 GitContext.commentchar: #
+1: UC2 GitCommit.branches: ['master']
+2: B4 Second line is not empty: "Content on the second line"
diff --git a/qa/requirements.txt b/qa/requirements.txt
new file mode 100644
index 0000000..f042dad
--- /dev/null
+++ b/qa/requirements.txt
@@ -0,0 +1,4 @@
+sh==1.12.14
+pytest==4.6.3;
+arrow==0.15.5;
+gitlint # no version as you want to test the currently installed version
diff --git a/qa/samples/config/contrib-enabled b/qa/samples/config/contrib-enabled
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/qa/samples/config/contrib-enabled
diff --git a/qa/samples/config/gitlintconfig b/qa/samples/config/gitlintconfig
new file mode 100644
index 0000000..a5ecb84
--- /dev/null
+++ b/qa/samples/config/gitlintconfig
@@ -0,0 +1,13 @@
+[general]
+ignore=title-trailing-punctuation,B2
+verbosity = 2
+
+[title-max-length]
+line-length=20
+
+[B1]
+# B1 = body-max-line-length
+line-length=30
+
+[title-must-not-contain-word]
+words=WIP,thåt \ No newline at end of file
diff --git a/qa/samples/config/ignore-release-commits b/qa/samples/config/ignore-release-commits
new file mode 100644
index 0000000..5807c96
--- /dev/null
+++ b/qa/samples/config/ignore-release-commits
@@ -0,0 +1,7 @@
+[ignore-by-title]
+regex=^Release(.*)
+ignore=T5,T3
+
+[ignore-by-body]
+regex=(.*)relëase(.*)
+ignore=T3,B3 \ No newline at end of file
diff --git a/qa/samples/user_rules/extra/extra_rules.py b/qa/samples/user_rules/extra/extra_rules.py
new file mode 100644
index 0000000..8109299
--- /dev/null
+++ b/qa/samples/user_rules/extra/extra_rules.py
@@ -0,0 +1,29 @@
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.utils import sstr
+
+
+class GitContextRule(CommitRule):
+ """ Rule that tests whether we can correctly access certain gitcontext properties """
+ name = "gitcontext"
+ id = "UC1"
+
+ def validate(self, commit):
+ violations = [
+ RuleViolation(self.id, "GitContext.current_branch: {0}".format(commit.context.current_branch), line_nr=1),
+ RuleViolation(self.id, "GitContext.commentchar: {0}".format(commit.context.commentchar), line_nr=1)
+ ]
+
+ return violations
+
+
+class GitCommitRule(CommitRule):
+ """ Rule that tests whether we can correctly access certain commit properties """
+ name = "gitcommit"
+ id = "UC2"
+
+ def validate(self, commit):
+ violations = [
+ RuleViolation(self.id, "GitCommit.branches: {0}".format(sstr(commit.branches)), line_nr=1),
+ ]
+
+ return violations
diff --git a/qa/samples/user_rules/incorrect_linerule/my_line_rule.py b/qa/samples/user_rules/incorrect_linerule/my_line_rule.py
new file mode 100644
index 0000000..33e511f
--- /dev/null
+++ b/qa/samples/user_rules/incorrect_linerule/my_line_rule.py
@@ -0,0 +1,8 @@
+from gitlint.rules import LineRule
+
+
+class MyUserLineRule(LineRule):
+ id = "UC2"
+ name = "my-line-rule"
+
+ # missing validate method, missing target attribute
diff --git a/qa/shell.py b/qa/shell.py
new file mode 100644
index 0000000..8ba6dc1
--- /dev/null
+++ b/qa/shell.py
@@ -0,0 +1,90 @@
+
+# This code is mostly duplicated from the `gitlint.shell` module. We conciously duplicate this code as to not depend
+# on gitlint internals for our integration testing framework.
+
+import subprocess
+import sys
+from qa.utils import ustr, USE_SH_LIB
+
+if USE_SH_LIB:
+ from sh import git, echo, gitlint # pylint: disable=unused-import,no-name-in-module,import-error
+
+ # import exceptions separately, this makes it a little easier to mock them out in the unit tests
+ from sh import CommandNotFound, ErrorReturnCode, RunningCommand # pylint: disable=import-error
+else:
+
+ class CommandNotFound(Exception):
+ """ Exception indicating a command was not found during execution """
+ pass
+
+ class RunningCommand(object):
+ pass
+
+ class ShResult(RunningCommand):
+ """ Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using
+ the builtin subprocess module. """
+
+ def __init__(self, full_cmd, stdout, stderr='', exitcode=0):
+ self.full_cmd = full_cmd
+ # TODO(jorisroovers): The 'sh' library by default will merge stdout and stderr. We mimic this behavior
+ # for now until we fully remove the 'sh' library.
+ self.stdout = stdout + ustr(stderr)
+ self.stderr = stderr
+ self.exit_code = exitcode
+
+ def __str__(self):
+ return self.stdout
+
+ class ErrorReturnCode(ShResult, Exception):
+ """ ShResult subclass for unexpected results (acts as an exception). """
+ pass
+
+ def git(*command_parts, **kwargs):
+ return run_command("git", *command_parts, **kwargs)
+
+ def echo(*command_parts, **kwargs):
+ return run_command("echo", *command_parts, **kwargs)
+
+ def gitlint(*command_parts, **kwargs):
+ return run_command("gitlint", *command_parts, **kwargs)
+
+ def run_command(command, *args, **kwargs):
+ args = [command] + list(args)
+ result = _exec(*args, **kwargs)
+ # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
+ # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
+ # a non-zero exit code -> just return the entire result
+ if hasattr(result, 'exit_code') and result.exit_code > 0:
+ return result
+ return ustr(result)
+
+ def _exec(*args, **kwargs):
+ if sys.version_info[0] == 2:
+ no_command_error = OSError # noqa pylint: disable=undefined-variable,invalid-name
+ else:
+ no_command_error = FileNotFoundError # noqa pylint: disable=undefined-variable
+
+ pipe = subprocess.PIPE
+ popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)}
+ if '_cwd' in kwargs:
+ popen_kwargs['cwd'] = kwargs['_cwd']
+
+ try:
+ p = subprocess.Popen(args, **popen_kwargs)
+ result = p.communicate()
+ except no_command_error:
+ raise CommandNotFound
+
+ exit_code = p.returncode
+ stdout = ustr(result[0])
+ stderr = result[1] # 'sh' does not decode the stderr bytes to unicode
+ full_cmd = '' if args is None else ' '.join(args)
+
+ # If not _ok_code is specified, then only a 0 exit code is allowed
+ ok_exit_codes = kwargs.get('_ok_code', [0])
+
+ if exit_code in ok_exit_codes:
+ return ShResult(full_cmd, stdout, stderr, exit_code)
+
+ # Unexpected error code => raise ErrorReturnCode
+ raise ErrorReturnCode(full_cmd, stdout, stderr, p.returncode)
diff --git a/qa/test_commits.py b/qa/test_commits.py
new file mode 100644
index 0000000..f485856
--- /dev/null
+++ b/qa/test_commits.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+import re
+
+import arrow
+
+from qa.shell import echo, git, gitlint
+from qa.base import BaseTestCase
+from qa.utils import sstr
+
+
+class CommitsTests(BaseTestCase):
+ """ Integration tests for the --commits argument, i.e. linting multiple commits at once or linting specific commits
+ """
+
+ def test_successful(self):
+ """ Test linting multiple commits without violations """
+ git("checkout", "-b", "test-branch-commits-base", _cwd=self.tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title\n\nSimple bödy describing the commit")
+ git("checkout", "-b", "test-branch-commits", _cwd=self.tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title2\n\nSimple bödy describing the commit2")
+ self.create_simple_commit(u"Sïmple title3\n\nSimple bödy describing the commit3")
+ output = gitlint("--commits", "test-branch-commits-base...test-branch-commits",
+ _cwd=self.tmp_git_repo, _tty_in=True)
+ self.assertEqualStdout(output, "")
+
+ def test_violations(self):
+ """ Test linting multiple commits with violations """
+ git("checkout", "-b", "test-branch-commits-violations-base", _cwd=self.tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title.\n")
+ git("checkout", "-b", "test-branch-commits-violations", _cwd=self.tmp_git_repo)
+
+ self.create_simple_commit(u"Sïmple title2.\n")
+ commit_sha1 = self.get_last_commit_hash()[:10]
+ self.create_simple_commit(u"Sïmple title3.\n")
+ commit_sha2 = self.get_last_commit_hash()[:10]
+ output = gitlint("--commits", "test-branch-commits-violations-base...test-branch-commits-violations",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
+
+ self.assertEqual(output.exit_code, 4)
+ expected_kwargs = {'commit_sha1': commit_sha1, 'commit_sha2': commit_sha2}
+ self.assertEqualStdout(output, self.get_expected("test_commits/test_violations_1", expected_kwargs))
+
+ def test_lint_single_commit(self):
+ """ Tests `gitlint --commits <sha>` """
+ self.create_simple_commit(u"Sïmple title.\n")
+ self.create_simple_commit(u"Sïmple title2.\n")
+ commit_sha = self.get_last_commit_hash()
+ refspec = "{0}^...{0}".format(commit_sha)
+ self.create_simple_commit(u"Sïmple title3.\n")
+ output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ expected = (u"1: T3 Title has trailing punctuation (.): \"Sïmple title2.\"\n" +
+ u"3: B6 Body message is missing\n")
+ self.assertEqual(output.exit_code, 2)
+ self.assertEqualStdout(output, expected)
+
+ def test_lint_staged_stdin(self):
+ """ Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data
+ from the underlying repository. The easiest way to test this is by inspecting `--debug` output.
+ This is the equivalent of doing:
+ echo "WIP: Pïpe test." | gitlint --staged --debug
+ """
+ # Create a commit first, before we stage changes. This ensures the repo is properly initialized.
+ self.create_simple_commit(u"Sïmple title.\n")
+
+ # Add some files, stage them: they should show up in the debug output as changed file
+ filename1 = self.create_file(self.tmp_git_repo)
+ git("add", filename1, _cwd=self.tmp_git_repo)
+ filename2 = self.create_file(self.tmp_git_repo)
+ git("add", filename2, _cwd=self.tmp_git_repo)
+
+ output = gitlint(echo(u"WIP: Pïpe test."), "--staged", "--debug",
+ _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
+
+ # Determine variable parts of expected output
+ expected_kwargs = self.get_debug_vars_last_commit()
+ expected_kwargs.update({'changed_files': sstr(sorted([filename1, filename2]))})
+
+ # It's not really possible to determine the "Date: ..." line that is part of the debug output as this date
+ # is not taken from git but instead generated by gitlint itself. As a workaround, we extract the date from the
+ # gitlint output using a regex, parse the date to ensure the format is correct, and then pass that as an
+ # expected variable.
+ matches = re.search(r'^Date:\s+(.*)', str(output), re.MULTILINE)
+ if matches:
+ expected_date = arrow.get(str(matches.group(1)), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z")
+ expected_kwargs['staged_date'] = expected_date
+
+ self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_staged_stdin_1", expected_kwargs))
+ self.assertEqual(output.exit_code, 3)
+
+ def test_lint_staged_msg_filename(self):
+ """ Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data
+ from the underlying repository. The easiest way to test this is by inspecting `--debug` output.
+ This is the equivalent of doing:
+ gitlint --msg-filename /tmp/my-commit-msg --staged --debug
+ """
+ # Create a commit first, before we stage changes. This ensures the repo is properly initialized.
+ self.create_simple_commit(u"Sïmple title.\n")
+
+ # Add some files, stage them: they should show up in the debug output as changed file
+ filename1 = self.create_file(self.tmp_git_repo)
+ git("add", filename1, _cwd=self.tmp_git_repo)
+ filename2 = self.create_file(self.tmp_git_repo)
+ git("add", filename2, _cwd=self.tmp_git_repo)
+
+ tmp_commit_msg_file = self.create_tmpfile(u"WIP: from fïle test.")
+
+ output = gitlint("--msg-filename", tmp_commit_msg_file, "--staged", "--debug",
+ _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
+
+ # Determine variable parts of expected output
+ expected_kwargs = self.get_debug_vars_last_commit()
+ expected_kwargs.update({'changed_files': sstr(sorted([filename1, filename2]))})
+
+ # It's not really possible to determine the "Date: ..." line that is part of the debug output as this date
+ # is not taken from git but instead generated by gitlint itself. As a workaround, we extract the date from the
+ # gitlint output using a regex, parse the date to ensure the format is correct, and then pass that as an
+ # expected variable.
+ matches = re.search(r'^Date:\s+(.*)', str(output), re.MULTILINE)
+ if matches:
+ expected_date = arrow.get(str(matches.group(1)), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z")
+ expected_kwargs['staged_date'] = expected_date
+
+ expected = self.get_expected("test_commits/test_lint_staged_msg_filename_1", expected_kwargs)
+ self.assertEqualStdout(output, expected)
+ self.assertEqual(output.exit_code, 3)
+
+ def test_lint_head(self):
+ """ Testing whether we can also recognize special refs like 'HEAD' """
+ tmp_git_repo = self.create_tmp_git_repo()
+ self.create_simple_commit(u"Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title", git_repo=tmp_git_repo)
+ self.create_simple_commit(u"WIP: Sïmple title\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
+ output = gitlint("--commits", "HEAD", _cwd=tmp_git_repo, _tty_in=True, _ok_code=[3])
+ revlist = git("rev-list", "HEAD", _tty_in=True, _cwd=tmp_git_repo).split()
+
+ expected_kwargs = {"commit_sha0": revlist[0][:10], "commit_sha1": revlist[1][:10],
+ "commit_sha2": revlist[2][:10]}
+
+ self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_head_1", expected_kwargs))
+
+ def test_ignore_commits(self):
+ """ Tests multiple commits of which some rules get igonored because of ignore-* rules """
+ # Create repo and some commits
+ tmp_git_repo = self.create_tmp_git_repo()
+ self.create_simple_commit(u"Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
+ # Normally, this commit will give T3 (trailing-punctuation), T5 (WIP) and B5 (bod-too-short) violations
+ # But in this case only B5 because T3 and T5 are being ignored because of config
+ self.create_simple_commit(u"Release: WIP tïtle.\n\nShort", git_repo=tmp_git_repo)
+ # In the following 2 commits, the T3 violations are as normal
+ self.create_simple_commit(
+ u"Sïmple WIP title3.\n\nThis is \ta relëase commit\nMore info", git_repo=tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title4.\n\nSimple bödy describing the commit4", git_repo=tmp_git_repo)
+ revlist = git("rev-list", "HEAD", _tty_in=True, _cwd=tmp_git_repo).split()
+
+ config_path = self.get_sample_path("config/ignore-release-commits")
+ output = gitlint("--commits", "HEAD", "--config", config_path, _cwd=tmp_git_repo, _tty_in=True, _ok_code=[4])
+
+ expected_kwargs = {"commit_sha0": revlist[0][:10], "commit_sha1": revlist[1][:10],
+ "commit_sha2": revlist[2][:10], "commit_sha3": revlist[3][:10]}
+ self.assertEqualStdout(output, self.get_expected("test_commits/test_ignore_commits_1", expected_kwargs))
diff --git a/qa/test_config.py b/qa/test_config.py
new file mode 100644
index 0000000..b893b1d
--- /dev/null
+++ b/qa/test_config.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+from qa.shell import gitlint
+from qa.base import BaseTestCase
+from qa.utils import sstr
+
+
+class ConfigTests(BaseTestCase):
+ """ Integration tests for gitlint configuration and configuration precedence. """
+
+ def test_ignore_by_id(self):
+ self.create_simple_commit(u"WIP: Thïs is a title.\nContënt on the second line")
+ output = gitlint("--ignore", "T5,B4", _tty_in=True, _cwd=self.tmp_git_repo, _ok_code=[1])
+ expected = u"1: T3 Title has trailing punctuation (.): \"WIP: Thïs is a title.\"\n"
+ self.assertEqualStdout(output, expected)
+
+ def test_ignore_by_name(self):
+ self.create_simple_commit(u"WIP: Thïs is a title.\nContënt on the second line")
+ output = gitlint("--ignore", "title-must-not-contain-word,body-first-line-empty",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
+ expected = u"1: T3 Title has trailing punctuation (.): \"WIP: Thïs is a title.\"\n"
+ self.assertEqualStdout(output, expected)
+
+ def test_verbosity(self):
+ self.create_simple_commit(u"WIP: Thïs is a title.\nContënt on the second line")
+ output = gitlint("-v", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+
+ expected = u"1: T3\n1: T5\n2: B4\n"
+ self.assertEqualStdout(output, expected)
+
+ output = gitlint("-vv", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_config/test_verbosity_1"))
+
+ output = gitlint("-vvv", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_config/test_verbosity_2"))
+
+ # test silent mode
+ output = gitlint("--silent", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, "")
+
+ def test_set_rule_option(self):
+ self.create_simple_commit(u"This ïs a title.")
+ output = gitlint("-c", "title-max-length.line-length=5", _tty_in=True, _cwd=self.tmp_git_repo, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_config/test_set_rule_option_1"))
+
+ def test_config_from_file(self):
+ commit_msg = u"WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \
+ "This line of the body is here because we need it"
+ self.create_simple_commit(commit_msg)
+ config_path = self.get_sample_path("config/gitlintconfig")
+ output = gitlint("--config", config_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
+ self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_file_1"))
+
+ def test_config_from_file_debug(self):
+ # Test bot on existing and new repo (we've had a bug in the past that was unique to empty repos)
+ repos = [self.tmp_git_repo, self.create_tmp_git_repo()]
+ for target_repo in repos:
+ commit_msg = u"WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \
+ "This line of the body is here because we need it"
+ filename = self.create_simple_commit(commit_msg, git_repo=target_repo)
+ config_path = self.get_sample_path("config/gitlintconfig")
+ output = gitlint("--config", config_path, "--debug", _cwd=target_repo, _tty_in=True, _ok_code=[5])
+
+ expected_kwargs = self.get_debug_vars_last_commit(git_repo=target_repo)
+ expected_kwargs.update({'config_path': config_path, 'changed_files': sstr([filename])})
+ self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_file_debug_1",
+ expected_kwargs))
diff --git a/qa/test_contrib.py b/qa/test_contrib.py
new file mode 100644
index 0000000..e2b4bc5
--- /dev/null
+++ b/qa/test_contrib.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=
+from qa.shell import gitlint
+from qa.base import BaseTestCase
+
+
+class ContribRuleTests(BaseTestCase):
+ """ Integration tests for contrib rules."""
+
+ def test_contrib_rules(self):
+ self.create_simple_commit(u"WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
+ output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
+ self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_1"))
+
+ def test_contrib_rules_with_config(self):
+ self.create_simple_commit(u"WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
+ output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
+ "-c", u"contrib-title-conventional-commits.types=föo,bår",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
+ self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_with_config_1"))
+
+ def test_invalid_contrib_rules(self):
+ self.create_simple_commit("WIP: test")
+ output = gitlint("--contrib", u"föobar,CC1", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[255])
+ self.assertEqualStdout(output, u"Config Error: No contrib rule with id or name 'föobar' found.\n")
diff --git a/qa/test_gitlint.py b/qa/test_gitlint.py
new file mode 100644
index 0000000..4762721
--- /dev/null
+++ b/qa/test_gitlint.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+import io
+import os
+from qa.shell import echo, git, gitlint
+from qa.base import BaseTestCase
+from qa.utils import DEFAULT_ENCODING
+
+
+class IntegrationTests(BaseTestCase):
+ """ Simple set of integration tests for gitlint """
+
+ def test_successful(self):
+ # Test for STDIN with and without a TTY attached
+ self.create_simple_commit(u"Sïmple title\n\nSimple bödy describing the commit")
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _err_to_out=True)
+ self.assertEqualStdout(output, "")
+
+ def test_successful_gitconfig(self):
+ """ Test gitlint when the underlying repo has specific git config set.
+ In the past, we've had issues with gitlint failing on some of these, so this acts as a regression test. """
+
+ # Different commentchar (Note: tried setting this to a special unicode char, but git doesn't like that)
+ git("config", "--add", "core.commentchar", "$", _cwd=self.tmp_git_repo)
+ self.create_simple_commit(u"Sïmple title\n\nSimple bödy describing the commit\n$after commentchar\t ignored")
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _err_to_out=True)
+ self.assertEqualStdout(output, "")
+
+ def test_successful_merge_commit(self):
+ # Create branch on master
+ self.create_simple_commit(u"Cömmit on master\n\nSimple bödy")
+
+ # Create test branch, add a commit and determine the commit hash
+ git("checkout", "-b", "test-branch", _cwd=self.tmp_git_repo)
+ git("checkout", "test-branch", _cwd=self.tmp_git_repo)
+ commit_title = u"Commit on test-brånch with a pretty long title that will cause issues when merging"
+ self.create_simple_commit(u"{0}\n\nSïmple body".format(commit_title))
+ hash = self.get_last_commit_hash()
+
+ # Checkout master and merge the commit
+ # We explicitly set the title of the merge commit to the title of the previous commit as this or similar
+ # behavior is what many tools do that handle merges (like github, gerrit, etc).
+ git("checkout", "master", _cwd=self.tmp_git_repo)
+ git("merge", "--no-ff", "-m", u"Merge '{0}'".format(commit_title), hash, _cwd=self.tmp_git_repo)
+
+ # Run gitlint and assert output is empty
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
+ self.assertEqualStdout(output, "")
+
+ # Assert that we do see the error if we disable the ignore-merge-commits option
+ output = gitlint("-c", "general.ignore-merge-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
+ self.assertEqual(output.exit_code, 1)
+ self.assertEqualStdout(output,
+ u"1: T1 Title exceeds max length (90>72): \"Merge '{0}'\"\n".format(commit_title))
+
+ def test_fixup_commit(self):
+ # Create a normal commit and assert that it has a violation
+ test_filename = self.create_simple_commit(u"Cömmit on WIP master\n\nSimple bödy that is long enough")
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
+ expected = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP master\"\n"
+ self.assertEqualStdout(output, expected)
+
+ # Make a small modification to the commit and commit it using fixup commit
+ with io.open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh:
+ # Wanted to write a unicode string, but that's obnoxious if you want to do it across Python 2 and 3.
+ # https://stackoverflow.com/questions/22392377/
+ # error-writing-a-file-with-file-write-in-python-unicodeencodeerror
+ # So just keeping it simple - ASCII will here
+ fh.write(u"Appending some stuff\n")
+
+ git("add", test_filename, _cwd=self.tmp_git_repo)
+
+ git("commit", "--fixup", self.get_last_commit_hash(), _cwd=self.tmp_git_repo)
+
+ # Assert that gitlint does not show an error for the fixup commit
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
+ # No need to check exit code, the command above throws an exception on > 0 exit codes
+ self.assertEqualStdout(output, "")
+
+ # Make sure that if we set the ignore-fixup-commits option to false that we do still see the violations
+ output = gitlint("-c", "general.ignore-fixup-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ expected = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"fixup! Cömmit on WIP master\"\n" + \
+ u"3: B6 Body message is missing\n"
+
+ self.assertEqualStdout(output, expected)
+
+ def test_revert_commit(self):
+ self.create_simple_commit(u"WIP: Cömmit on master.\n\nSimple bödy")
+ hash = self.get_last_commit_hash()
+ git("revert", hash, _cwd=self.tmp_git_repo)
+
+ # Run gitlint and assert output is empty
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
+ self.assertEqualStdout(output, "")
+
+ # Assert that we do see the error if we disable the ignore-revert-commits option
+ output = gitlint("-c", "general.ignore-revert-commits=false",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
+ self.assertEqual(output.exit_code, 1)
+ expected = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"Revert \"WIP: Cömmit on master.\"\"\n"
+ self.assertEqualStdout(output, expected)
+
+ def test_squash_commit(self):
+ # Create a normal commit and assert that it has a violation
+ test_filename = self.create_simple_commit(u"Cömmit on WIP master\n\nSimple bödy that is long enough")
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
+ expected = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP master\"\n"
+ self.assertEqualStdout(output, expected)
+
+ # Make a small modification to the commit and commit it using squash commit
+ with io.open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh:
+ # Wanted to write a unicode string, but that's obnoxious if you want to do it across Python 2 and 3.
+ # https://stackoverflow.com/questions/22392377/
+ # error-writing-a-file-with-file-write-in-python-unicodeencodeerror
+ # So just keeping it simple - ASCII will here
+ fh.write(u"Appending some stuff\n")
+
+ git("add", test_filename, _cwd=self.tmp_git_repo)
+
+ git("commit", "--squash", self.get_last_commit_hash(), "-m", u"Töo short body", _cwd=self.tmp_git_repo)
+
+ # Assert that gitlint does not show an error for the fixup commit
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
+ # No need to check exit code, the command above throws an exception on > 0 exit codes
+ self.assertEqualStdout(output, "")
+
+ # Make sure that if we set the ignore-squash-commits option to false that we do still see the violations
+ output = gitlint("-c", "general.ignore-squash-commits=false",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ expected = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"squash! Cömmit on WIP master\"\n" + \
+ u"3: B5 Body message is too short (14<20): \"Töo short body\"\n"
+
+ self.assertEqualStdout(output, expected)
+
+ def test_violations(self):
+ commit_msg = u"WIP: This ïs a title.\nContent on the sëcond line"
+ self.create_simple_commit(commit_msg)
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_gitlint/test_violations_1"))
+
+ def test_msg_filename(self):
+ tmp_commit_msg_file = self.create_tmpfile(u"WIP: msg-fïlename test.")
+ output = gitlint("--msg-filename", tmp_commit_msg_file, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_gitlint/test_msg_filename_1"))
+
+ def test_msg_filename_no_tty(self):
+ """ Make sure --msg-filename option also works with no TTY attached """
+ tmp_commit_msg_file = self.create_tmpfile(u"WIP: msg-fïlename NO TTY test.")
+
+ # We need to set _err_to_out explicitly for sh to merge stdout and stderr output in case there's
+ # no TTY attached to STDIN
+ # http://amoffat.github.io/sh/sections/special_arguments.html?highlight=_tty_in#err-to-out
+ # We need to pass some whitespace to _in as sh will otherwise hang, see
+ # https://github.com/amoffat/sh/issues/427
+ output = gitlint("--msg-filename", tmp_commit_msg_file, _in=" ",
+ _tty_in=False, _err_to_out=True, _ok_code=[3])
+
+ self.assertEqualStdout(output, self.get_expected("test_gitlint/test_msg_filename_no_tty_1"))
+
+ def test_git_errors(self):
+ # Repo has no commits: caused by `git log`
+ empty_git_repo = self.create_tmp_git_repo()
+ output = gitlint(_cwd=empty_git_repo, _tty_in=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE])
+
+ expected = u"Current branch has no commits. Gitlint requires at least one commit to function.\n"
+ self.assertEqualStdout(output, expected)
+
+ # Repo has no commits: caused by `git rev-parse`
+ output = gitlint(echo(u"WIP: Pïpe test."), "--staged", _cwd=empty_git_repo, _tty_in=False,
+ _err_to_out=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE])
+ self.assertEqualStdout(output, expected)
diff --git a/qa/test_hooks.py b/qa/test_hooks.py
new file mode 100644
index 0000000..a41580b
--- /dev/null
+++ b/qa/test_hooks.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+import os
+from qa.shell import git, gitlint
+from qa.base import BaseTestCase
+
+
+class HookTests(BaseTestCase):
+ """ Integration tests for gitlint commitmsg hooks"""
+
+ VIOLATIONS = ['gitlint: checking commit message...\n',
+ u'1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n',
+ u'1: T5 Title contains the word \'WIP\' (case-insensitive): "WIP: This ïs a title."\n',
+ u'2: B4 Second line is not empty: "Contënt on the second line"\n',
+ '3: B6 Body message is missing\n',
+ '-----------------------------------------------\n',
+ 'gitlint: \x1b[31mYour commit message contains the above violations.\x1b[0m\n']
+
+ def setUp(self):
+ self.responses = []
+ self.response_index = 0
+ self.githook_output = []
+
+ # The '--staged' flag used in the commit-msg hook fetches additional information from the underlying
+ # git repo which means there already needs to be a commit in the repo
+ # (as gitlint --staged doesn't work against empty repos)
+ self.create_simple_commit(u"Commït Title\n\nCommit Body explaining commit.")
+
+ # install git commit-msg hook and assert output
+ output_installed = gitlint("install-hook", _cwd=self.tmp_git_repo)
+ expected_installed = u"Successfully installed gitlint commit-msg hook in %s/.git/hooks/commit-msg\n" % \
+ self.tmp_git_repo
+ self.assertEqualStdout(output_installed, expected_installed)
+
+ def tearDown(self):
+ # uninstall git commit-msg hook and assert output
+ output_uninstalled = gitlint("uninstall-hook", _cwd=self.tmp_git_repo)
+ expected_uninstalled = u"Successfully uninstalled gitlint commit-msg hook from %s/.git/hooks/commit-msg\n" % \
+ self.tmp_git_repo
+ self.assertEqualStdout(output_uninstalled, expected_uninstalled)
+
+ def _violations(self):
+ # Make a copy of the violations array so that we don't inadvertently edit it in the test (like I did :D)
+ return list(self.VIOLATIONS)
+
+ # callback function that captures git commit-msg hook output
+
+ def _interact(self, line, stdin):
+ self.githook_output.append(line)
+ # Answer 'yes' to question to keep violating commit-msg
+ if "Your commit message contains the above violations" in line:
+ response = self.responses[self.response_index]
+ stdin.put("{0}\n".format(response))
+ self.response_index = (self.response_index + 1) % len(self.responses)
+
+ def test_commit_hook_continue(self):
+ self.responses = ["y"]
+ test_filename = self.create_simple_commit(u"WIP: This ïs a title.\nContënt on the second line",
+ out=self._interact, tty_in=True)
+
+ # Determine short commit-msg hash, needed to determine expected output
+ short_hash = self.get_last_commit_short_hash()
+
+ expected_output = self._violations()
+ expected_output += ["Continue with commit anyways (this keeps the current commit message)? " +
+ "[y(es)/n(no)/e(dit)] " +
+ u"[master %s] WIP: This ïs a title. Contënt on the second line\n"
+ % short_hash,
+ " 1 file changed, 0 insertions(+), 0 deletions(-)\n",
+ u" create mode 100644 %s\n" % test_filename]
+
+ assert len(self.githook_output) == len(expected_output)
+ for output, expected in zip(self.githook_output, expected_output):
+ self.assertMultiLineEqual(
+ output.replace('\r', ''),
+ expected.replace('\r', ''))
+
+ def test_commit_hook_abort(self):
+ self.responses = ["n"]
+ test_filename = self.create_simple_commit(u"WIP: This ïs a title.\nContënt on the second line",
+ out=self._interact, ok_code=1, tty_in=True)
+ git("rm", "-f", test_filename, _cwd=self.tmp_git_repo)
+
+ # Determine short commit-msg hash, needed to determine expected output
+
+ expected_output = self._violations()
+ expected_output += ["Continue with commit anyways (this keeps the current commit message)? " +
+ "[y(es)/n(no)/e(dit)] " +
+ "Commit aborted.\n",
+ "Your commit message: \n",
+ "-----------------------------------------------\n",
+ u"WIP: This ïs a title.\n",
+ u"Contënt on the second line\n",
+ "-----------------------------------------------\n"]
+
+ self.assertListEqual(expected_output, self.githook_output)
+
+ def test_commit_hook_edit(self):
+ self.responses = ["e", "y"]
+ env = {"EDITOR": ":"}
+ test_filename = self.create_simple_commit(u"WIP: This ïs a title.\nContënt on the second line",
+ out=self._interact, env=env, tty_in=True)
+ git("rm", "-f", test_filename, _cwd=self.tmp_git_repo)
+
+ short_hash = git("rev-parse", "--short", "HEAD", _cwd=self.tmp_git_repo, _tty_in=True).replace("\n", "")
+
+ # Determine short commit-msg hash, needed to determine expected output
+
+ expected_output = self._violations()
+ expected_output += ['Continue with commit anyways (this keeps the current commit message)? ' +
+ '[y(es)/n(no)/e(dit)] ' + self._violations()[0]]
+ expected_output += self._violations()[1:]
+ expected_output += ['Continue with commit anyways (this keeps the current commit message)? ' +
+ "[y(es)/n(no)/e(dit)] " +
+ u"[master %s] WIP: This ïs a title. Contënt on the second line\n" % short_hash,
+ " 1 file changed, 0 insertions(+), 0 deletions(-)\n",
+ u" create mode 100644 %s\n" % test_filename]
+
+ assert len(self.githook_output) == len(expected_output)
+ for output, expected in zip(self.githook_output, expected_output):
+ self.assertMultiLineEqual(
+ output.replace('\r', ''),
+ expected.replace('\r', ''))
+
+ def test_commit_hook_worktree(self):
+ """ Tests that hook installation and un-installation also work in git worktrees.
+ Test steps:
+ ```sh
+ git init <tmpdir>
+ cd <tmpdir>
+ git worktree add <worktree-tempdir>
+ cd <worktree-tempdir>
+ gitlint install-hook
+ gitlint uninstall-hook
+ ```
+ """
+ tmp_git_repo = self.create_tmp_git_repo()
+ self.create_simple_commit(u"Simple title\n\nContënt in the body", git_repo=tmp_git_repo)
+
+ worktree_dir = self.generate_temp_path()
+ self.tmp_git_repos.append(worktree_dir) # make sure we clean up the worktree afterwards
+
+ git("worktree", "add", worktree_dir, _cwd=tmp_git_repo, _tty_in=True)
+
+ output_installed = gitlint("install-hook", _cwd=worktree_dir)
+ expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg")
+ expected_msg = "Successfully installed gitlint commit-msg hook in {0}\n".format(expected_hook_path)
+ self.assertEqual(output_installed, expected_msg)
+
+ output_uninstalled = gitlint("uninstall-hook", _cwd=worktree_dir)
+ expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg")
+ expected_msg = "Successfully uninstalled gitlint commit-msg hook from {0}\n".format(expected_hook_path)
+ self.assertEqual(output_uninstalled, expected_msg)
diff --git a/qa/test_stdin.py b/qa/test_stdin.py
new file mode 100644
index 0000000..fff636f
--- /dev/null
+++ b/qa/test_stdin.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+import io
+import subprocess
+from qa.shell import echo, gitlint
+from qa.base import BaseTestCase
+from qa.utils import ustr, DEFAULT_ENCODING
+
+
+class StdInTests(BaseTestCase):
+ """ Integration tests for various STDIN scenarios for gitlint """
+
+ def test_stdin_pipe(self):
+ """ Test piping input into gitlint.
+ This is the equivalent of doing:
+ $ echo "foo" | gitlint
+ """
+ # NOTE: There is no use in testing this with _tty_in=True, because if you pipe something into a command
+ # there never is a TTY connected to stdin (per definition). We're setting _tty_in=False here to be explicit
+ # but note that this is always true when piping something into a command.
+ output = gitlint(echo(u"WIP: Pïpe test."),
+ _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_stdin/test_stdin_pipe_1"))
+
+ def test_stdin_pipe_empty(self):
+ """ Test the scenario where no TTY is attached an nothing is piped into gitlint. This occurs in
+ CI runners like Jenkins and Gitlab, see https://github.com/jorisroovers/gitlint/issues/42 for details.
+ This is the equivalent of doing:
+ $ echo -n "" | gitlint
+ """
+ commit_msg = u"WIP: This ïs a title.\nContent on the sëcond line"
+ self.create_simple_commit(commit_msg)
+
+ # We need to set _err_to_out explicitly for sh to merge stdout and stderr output in case there's
+ # no TTY attached to STDIN
+ # http://amoffat.github.io/sh/sections/special_arguments.html?highlight=_tty_in#err-to-out
+ output = gitlint(echo("-n", ""), _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
+
+ self.assertEqual(ustr(output), self.get_expected("test_stdin/test_stdin_pipe_empty_1"))
+
+ def test_stdin_file(self):
+ """ Test the scenario where STDIN is a regular file (stat.S_ISREG = True)
+ This is the equivalent of doing:
+ $ gitlint < myfile
+ """
+ tmp_commit_msg_file = self.create_tmpfile(u"WIP: STDIN ïs a file test.")
+
+ with io.open(tmp_commit_msg_file, encoding=DEFAULT_ENCODING) as file_handle:
+
+ # We need to use subprocess.Popen() here instead of sh because when passing a file_handle to sh, it will
+ # deal with reading the file itself instead of passing it on to gitlint as a STDIN. Since we're trying to
+ # test for the condition where stat.S_ISREG == True that won't work for us here.
+ p = subprocess.Popen(u"gitlint", stdin=file_handle, cwd=self.tmp_git_repo,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ output, _ = p.communicate()
+ self.assertEqual(ustr(output), self.get_expected("test_stdin/test_stdin_file_1"))
diff --git a/qa/test_user_defined.py b/qa/test_user_defined.py
new file mode 100644
index 0000000..cf7effd
--- /dev/null
+++ b/qa/test_user_defined.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-function-args,unexpected-keyword-arg
+from qa.shell import gitlint
+from qa.base import BaseTestCase
+
+
+class UserDefinedRuleTests(BaseTestCase):
+ """ Integration tests for user-defined rules."""
+
+ def test_user_defined_rules_examples(self):
+ extra_path = self.get_example_path()
+ commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ self.create_simple_commit(commit_msg)
+ output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
+ self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_examples_1"))
+
+ def test_user_defined_rules_examples_with_config(self):
+ extra_path = self.get_example_path()
+ commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ self.create_simple_commit(commit_msg)
+ output = gitlint("--extra-path", extra_path, "-c", "body-max-line-count.max-line-count=1",
+ _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[6])
+ expected_path = "test_user_defined/test_user_defined_rules_examples_with_config_1"
+ self.assertEqualStdout(output, self.get_expected(expected_path))
+
+ def test_user_defined_rules_extra(self):
+ extra_path = self.get_sample_path("user_rules/extra")
+ commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ self.create_simple_commit(commit_msg)
+ output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
+ self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_extra_1"))
+
+ def test_invalid_user_defined_rules(self):
+ extra_path = self.get_sample_path("user_rules/incorrect_linerule")
+ self.create_simple_commit("WIP: test")
+ output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[255])
+ self.assertEqualStdout(output,
+ "Config Error: User-defined rule class 'MyUserLineRule' must have a 'validate' method\n")
diff --git a/qa/utils.py b/qa/utils.py
new file mode 100644
index 0000000..eb9869a
--- /dev/null
+++ b/qa/utils.py
@@ -0,0 +1,99 @@
+# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return
+import platform
+import sys
+import os
+
+import locale
+
+########################################################################################################################
+# PLATFORM_IS_WINDOWS
+
+
+def platform_is_windows():
+ return "windows" in platform.system().lower()
+
+
+PLATFORM_IS_WINDOWS = platform_is_windows()
+
+########################################################################################################################
+# USE_SH_LIB
+# Determine whether to use the `sh` library
+# On windows we won't want to use the sh library since it's not supported - instead we'll use our own shell module.
+# However, we want to be able to overwrite this behavior for testing using the GITLINT_QA_USE_SH_LIB env var.
+
+
+def use_sh_library():
+ gitlint_use_sh_lib_env = os.environ.get('GITLINT_QA_USE_SH_LIB', None)
+ if gitlint_use_sh_lib_env:
+ return gitlint_use_sh_lib_env == "1"
+ return not PLATFORM_IS_WINDOWS
+
+
+USE_SH_LIB = use_sh_library()
+
+########################################################################################################################
+# DEFAULT_ENCODING
+
+
+def getpreferredencoding():
+ """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars
+ on windows and falls back to UTF-8. """
+ default_encoding = locale.getpreferredencoding() or "UTF-8"
+
+ # On Windows, we mimic git/linux by trying to read the LC_ALL, LC_CTYPE, LANG env vars manually
+ # (on Linux/MacOS the `getpreferredencoding()` call will take care of this).
+ # We fallback to UTF-8
+ if PLATFORM_IS_WINDOWS:
+ default_encoding = "UTF-8"
+ for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]:
+ encoding = os.environ.get(env_var, False)
+ if encoding:
+ # Support dotted (C.UTF-8) and non-dotted (C or UTF-8) charsets:
+ # If encoding contains a dot: split and use second part, otherwise use everything
+ dot_index = encoding.find(".")
+ if dot_index != -1:
+ default_encoding = encoding[dot_index + 1:]
+ else:
+ default_encoding = encoding
+ break
+
+ return default_encoding
+
+
+DEFAULT_ENCODING = getpreferredencoding()
+
+########################################################################################################################
+# Unicode utility functions
+
+
+def ustr(obj):
+ """ Python 2 and 3 utility method that converts an obj to unicode in python 2 and to a str object in python 3"""
+ if sys.version_info[0] == 2:
+ # If we are getting a string, then do an explicit decode
+ # else, just call the unicode method of the object
+ if type(obj) in [str, basestring]: # pragma: no cover # noqa
+ return unicode(obj, DEFAULT_ENCODING) # pragma: no cover # noqa
+ else:
+ return unicode(obj) # pragma: no cover # noqa
+ else:
+ if type(obj) in [bytes]:
+ return obj.decode(DEFAULT_ENCODING)
+ else:
+ return str(obj)
+
+
+def sstr(obj):
+ """ Python 2 and 3 utility method that converts an obj to a DEFAULT_ENCODING encoded string in python 2
+ and to unicode in python 3.
+ Especially useful for implementing __str__ methods in python 2: http://stackoverflow.com/a/1307210/381010"""
+ if sys.version_info[0] == 2:
+ # For lists in python2, remove unicode string representation characters.
+ # i.e. ensure lists are printed as ['a', 'b'] and not [u'a', u'b']
+ if type(obj) in [list]:
+ return [sstr(item) for item in obj] # pragma: no cover # noqa
+
+ return unicode(obj).encode(DEFAULT_ENCODING) # pragma: no cover # noqa
+ else:
+ return obj # pragma: no cover
+
+########################################################################################################################
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e8d531b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+setuptools
+wheel==0.33.4
+Click==7.0
+sh==1.12.14; sys_platform != 'win32' # sh is not supported on windows
+arrow==0.15.5;
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..23ccb37
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,539 @@
+#!/bin/bash
+
+
+help(){
+ echo "Usage: $0 [OPTION]..."
+ echo "Run gitlint's test suite(s) or some convience commands"
+ echo " -h, --help Show this help output"
+ echo " -c, --clean Clean the project of temporary files"
+ echo " -p, --pep8 Run pep8 checks"
+ echo " -l, --lint Run pylint checks"
+ echo " -g, --git Run gitlint checks"
+ echo " -i, --integration Run integration tests"
+ echo " -b, --build Run build tests"
+ echo " -a, --all Run all tests and checks (unit, integration, pep8, git)"
+ echo " -e, --envs [ENV1],[ENV2] Run tests against specified python environments"
+ echo " (envs: 27,35,36,37,pypy2,pypy35)."
+ echo " Also works for integration, pep8 and lint tests."
+ echo " -C, --container Run the specified command in the container for the --envs specified"
+ echo " --all-env Run all tests against all python environments"
+ echo " --install Install virtualenvs for the --envs specified"
+ echo " --uninstall Remove virtualenvs for the --envs specified"
+ echo " --install-container Build and run Docker container for the --envs specified"
+ echo " --uninstall-container Kill Docker container for the --envs specified"
+ echo " --exec [CMD] Execute [CMD] in the --envs specified"
+ echo " -s, --stats Show some project stats"
+ echo " --no-coverage Don't make a unit test coverage report"
+ echo ""
+ exit 0
+}
+
+RED="\033[31m"
+YELLOW="\033[33m"
+BLUE="\033[94m"
+GREEN="\033[32m"
+NO_COLOR="\033[0m"
+
+title(){
+ MSG="$BLUE$1$NO_COLOR"
+ echo -e $MSG
+}
+
+subtitle(){
+ MSG="$YELLOW$1$NO_COLOR"
+ echo -e $MSG
+}
+
+fatal(){
+ MSG="$RED$1$NO_COLOR"
+ echo -e $MSG
+ exit 1
+}
+
+assert_root(){
+ if [ "$(id -u)" != "0" ]; then
+ fatal "$1"
+ fi
+}
+
+# Utility method that prints SUCCESS if a test was succesful, or FAIL together with the test output
+handle_test_result(){
+ EXIT_CODE=$1
+ RESULT="$2"
+ # Change color to red or green depending on SUCCESS
+ if [ $EXIT_CODE -eq 0 ]; then
+ echo -e "${GREEN}SUCCESS"
+ else
+ echo -e "${RED}FAIL"
+ fi
+ # Print RESULT if not empty
+ if [ -n "$RESULT" ] ; then
+ echo -e "\n$RESULT"
+ fi
+ # Reset color
+ echo -e "${NO_COLOR}"
+}
+
+run_pep8_check(){
+ # FLAKE 8
+ target=${testargs:-"gitlint qa examples"}
+ echo -ne "Running flake8..."
+ RESULT=$(flake8 $target)
+ local exit_code=$?
+ handle_test_result $exit_code "$RESULT"
+ return $exit_code
+}
+
+run_unit_tests(){
+ clean
+ # py.test -s => print standard output (i.e. show print statement output)
+ # -rw => print warnings
+ OMIT="*pypy*,*venv*,*virtualenv*,*gitlint/tests/*"
+ target=${testargs:-"gitlint"}
+ coverage run --omit=$OMIT -m pytest -rw -s $target
+ TEST_RESULT=$?
+ if [ $include_coverage -eq 1 ]; then
+ COVERAGE_REPORT=$(coverage report -m)
+ echo "$COVERAGE_REPORT"
+ fi
+
+ return $TEST_RESULT;
+}
+
+run_integration_tests(){
+ clean
+ # Make sure the version of python used by the git hooks in our integration tests
+ # is the same one as the one that is currently active. In order to achieve this, we need to set
+ # GIT_EXEC_PATH (https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables) to the current PATH, otherwise
+ # the git hooks will use the default PATH variable as defined by .bashrc which doesn't contain the current
+ # virtualenv's python binary path.
+ export GIT_EXEC_PATH="$PATH"
+
+ echo ""
+ gitlint --version
+ echo -e "Using $(which gitlint)\n"
+
+ # py.test -s => print standard output (i.e. show print statement output)
+ # -rw => print warnings
+ target=${testargs:-"qa/"}
+ py.test -s $target
+}
+
+run_git_check(){
+ echo -ne "Running gitlint...${RED}"
+ RESULT=$(gitlint 2>&1)
+ local exit_code=$?
+ handle_test_result $exit_code "$RESULT"
+ # FUTURE: check if we use str() function: egrep -nriI "( |\(|\[)+str\(" gitlint | egrep -v "\w*#(.*)"
+ return $exit_code
+}
+
+run_lint_check(){
+ echo -ne "Running pylint...${RED}"
+ target=${testargs:-"gitlint qa"}
+ RESULT=$(pylint $target --rcfile=".pylintrc" -r n)
+ local exit_code=$?
+ handle_test_result $exit_code "$RESULT"
+ return $exit_code
+}
+
+run_build_test(){
+ clean
+ datestr=$(date +"%Y-%m-%d-%H-%M-%S")
+ temp_dir="/tmp/gitlint-build-test-$datestr"
+
+ # Copy gitlint to a new temp dir
+ echo -n "Copying gitlint to $temp_dir..."
+ mkdir "$temp_dir"
+ rsync -az --exclude ".vagrant" --exclude ".git" --exclude ".venv*" . "$temp_dir"
+ echo -e "${GREEN}DONE${NO_COLOR}"
+
+ # Update the version to include a timestamp
+ echo -n "Writing new version to file..."
+ version_file="$temp_dir/gitlint/__init__.py"
+ version_str="$(cat $version_file)"
+ version_str="${version_str:0:${#version_str}-1}-$datestr\""
+ echo "$version_str" > $version_file
+ echo -e "${GREEN}DONE${NO_COLOR}"
+ # Attempt to build the package
+ echo "Building package ..."
+ pushd "$temp_dir"
+ # Copy stdout file descriptor so we can both print output to stdout as well as capture it in a variable
+ # https://stackoverflow.com/questions/12451278/bash-capture-stdout-to-a-variable-but-still-display-it-in-the-console
+ exec 5>&1
+ output=$(python setup.py sdist bdist_wheel | tee /dev/fd/5)
+ local exit_code=$?
+ popd
+ # Cleanup :-)
+ rm -rf "$temp_dir"
+
+ # Print success/no success
+ if [ $exit_code -gt 0 ]; then
+ echo -e "Building package...${RED}FAIL${NO_COLOR}"
+ else
+ echo -e "Building package...${GREEN}SUCCESS${NO_COLOR}"
+ fi
+
+ return $exit_code
+}
+
+run_stats(){
+ clean # required for py.test to count properly
+ echo "*** Code ***"
+ radon raw -s gitlint | tail -n 11
+ echo "*** Docs ***"
+ echo " Markdown: $(cat docs/*.md | wc -l | tr -d " ") lines"
+ echo "*** Tests ***"
+ nr_unit_tests=$(py.test gitlint/ --collect-only | grep TestCaseFunction | wc -l)
+ nr_integration_tests=$(py.test qa/ --collect-only | grep TestCaseFunction | wc -l)
+ echo " Unit Tests: ${nr_unit_tests//[[:space:]]/}"
+ echo " Integration Tests: ${nr_integration_tests//[[:space:]]/}"
+ echo "*** Git ***"
+ echo " Commits: $(git rev-list --all --count)"
+ echo " Commits (master): $(git rev-list master --count)"
+ echo " First commit: $(git log --pretty="%aD" $(git rev-list --max-parents=0 HEAD))"
+ echo " Contributors: $(git log --format='%aN' | sort -u | wc -l | tr -d ' ')"
+ echo " Releases (tags): $(git tag --list | wc -l | tr -d ' ')"
+ latest_tag=$(git tag --sort=creatordate | tail -n 1)
+ echo " Latest Release (tag): $latest_tag"
+ echo " Commits since $latest_tag: $(git log --format=oneline HEAD...$latest_tag | wc -l | tr -d ' ')"
+ echo " Line changes since $latest_tag: $(git diff --shortstat $latest_tag)"
+ # PyPi API: https://pypistats.org/api/
+ echo "*** PyPi ***"
+ info=$(curl -Ls https://pypi.python.org/pypi/gitlint/json)
+ echo " Current version: $(echo $info | jq -r .info.version)"
+ echo "*** PyPi (Downloads) ***"
+ overall_stats=$(curl -s https://pypistats.org/api/packages/gitlint/overall)
+ recent_stats=$(curl -s https://pypistats.org/api/packages/gitlint/recent)
+ echo " Last 6 Months: $(echo $overall_stats | jq -r '.data[].downloads' | awk '{sum+=$1} END {print sum}')"
+ echo " Last Month: $(echo $recent_stats | jq .data.last_month)"
+ echo " Last Week: $(echo $recent_stats | jq .data.last_week)"
+ echo " Last Day: $(echo $recent_stats | jq .data.last_day)"
+}
+
+clean(){
+ echo -n "Cleaning the *.pyc, site/, build/, dist/ and all __pycache__ directories..."
+ find gitlint -type d -name "__pycache__" -exec rm -rf {} \; 2> /dev/null
+ find qa -type d -name "__pycache__" -exec rm -rf {} \; 2> /dev/null
+ find gitlint -iname *.pyc -exec rm -rf {} \; 2> /dev/null
+ find qa -iname *.pyc -exec rm -rf {} \; 2> /dev/null
+ rm -rf "site" "dist" "build"
+ echo -e "${GREEN}DONE${NO_COLOR}"
+}
+
+run_all(){
+ local exit_code=0
+ subtitle "# UNIT TESTS ($(python --version 2>&1), $(which python)) #"
+ run_unit_tests
+ exit_code=$((exit_code + $?))
+ subtitle "# INTEGRATION TESTS ($(python --version 2>&1), $(which python)) #"
+ run_integration_tests
+ exit_code=$((exit_code + $?))
+ subtitle "# BUILD TEST ($(python --version 2>&1), $(which python)) #"
+ run_build_test
+ exit_code=$((exit_code + $?))
+ subtitle "# STYLE CHECKS ($(python --version 2>&1), $(which python)) #"
+ run_pep8_check
+ exit_code=$((exit_code + $?))
+ run_lint_check
+ exit_code=$((exit_code + $?))
+ run_git_check
+ exit_code=$((exit_code + $?))
+ return $exit_code
+}
+
+uninstall_virtualenv(){
+ version="$1"
+ venv_name=".venv$version"
+ echo -n "Uninstalling $venv_name..."
+ deactivate 2> /dev/null # deactivate any active environment
+ rm -rf "$venv_name"
+ echo -e "${GREEN}DONE${NO_COLOR}"
+}
+
+install_virtualenv(){
+ version="$1"
+ venv_name=".venv$version"
+
+ # For regular python: the binary has a dot between the first and second char of the version string
+ python_binary="/usr/bin/python${version:0:1}.${version:1:1}"
+
+ # For pypy: custom path + fetch from the web if not installed (=distro agnostic)
+ if [[ $version == *"pypy2"* ]]; then
+ python_binary="/opt/pypy2.7-v7.3.0-linux64/bin/pypy"
+ if [ ! -f $python_binary ]; then
+ assert_root "Must be root to install pypy2.7, use sudo"
+ title "### DOWNLOADING PYPY2 ($pypy_archive) ###"
+ pushd "/opt"
+ pypy_archive="pypy2.7-v7.3.0-linux64.tar.bz2"
+ wget "https://bitbucket.org/pypy/pypy/downloads/$pypy_archive"
+ title "### EXTRACTING PYPY TARBALL ($pypy_archive) ###"
+ tar xvf $pypy_archive
+ popd
+ fi
+ fi
+
+ if [[ $version == *"pypy35"* ]]; then
+ python_binary="/opt/pypy3.5-v7.0.0-linux64/bin/pypy3"
+ if [ ! -f $python_binary ]; then
+ assert_root "Must be root to install pypy3.5, use sudo"
+ title "### DOWNLOADING PYPY3 ($pypy_archive) ###"
+ pushd "/opt"
+ pypy_archive="pypy3.5-v7.0.0-linux64.tar.bz2"
+ wget "https://bitbucket.org/pypy/pypy/downloads/$pypy_archive"
+ title "### EXTRACTING PYPY TARBALL ($pypy_archive) ###"
+ tar xvf $pypy_archive
+ popd
+ fi
+ fi
+
+ title "### INSTALLING $venv_name ($python_binary) ###"
+ deactivate 2> /dev/null # deactivate any active environment
+ virtualenv -p "$python_binary" "$venv_name"
+ source "${venv_name}/bin/activate"
+ pip install --ignore-requires-python -r requirements.txt
+ pip install --ignore-requires-python -r test-requirements.txt
+ deactivate 2> /dev/null
+}
+
+container_name(){
+ echo "jorisroovers/gitlint:dev-python-$1"
+}
+
+start_container(){
+ container_name="$1"
+ echo -n "Starting container $1..."
+ container_details=$(docker container inspect $container_name 2>&1 > /dev/null)
+ local exit_code=$?
+ if [ $exit_code -gt 0 ]; then
+ docker run -t -d -v $(pwd):/gitlint --name $container_name $container_name
+ exit_code=$?
+ echo -e "${GREEN}DONE${NO_COLOR}"
+ else
+ echo -e "${YELLOW}SKIP (ALREADY RUNNING)${NO_COLOR}"
+ exit_code=0
+ fi
+ return $exit_code
+}
+
+stop_container(){
+ container_name="$1"
+ echo -n "Stopping container $container_name..."
+ result=$(docker kill $container_name 2> /dev/null)
+ local exit_code=$?
+ if [ $exit_code -gt 0 ]; then
+ echo -e "${YELLOW}SKIP (DOES NOT EXIST)${NO_COLOR}"
+ exit_code=0
+ else
+ echo -e "${GREEN}DONE${NO_COLOR}"
+ fi
+ return $exit_code
+}
+
+install_container(){
+ local exit_code=0
+ python_version="$1"
+ python_version_dotted="${python_version:0:1}.${python_version:1:1}"
+ container_name="$(container_name $python_version)"
+
+ title "Installing container $container_name"
+ image_details=$(docker image inspect $container_name 2> /dev/null)
+ tmp_exit_code=$?
+ if [ $tmp_exit_code -gt 0 ]; then
+ subtitle "Building container image from python:${python_version_dotted}-stretch..."
+ docker build -f Dockerfile.dev --build-arg python_version_dotted="$python_version_dotted" -t $container_name .
+ exit_code=$?
+ else
+ subtitle "Building container image from python:${python_version_dotted}-stretch...SKIP (ALREADY-EXISTS)"
+ echo " Use '$0 --uninstall-container; $0 --install-container' to rebuild"
+ exit_code=0
+ fi
+ return $exit_code
+}
+
+uninstall_container(){
+ python_version="$1"
+ container_name="$(container_name $python_version)"
+
+ echo -n "Removing container image $container_name..."
+ image_details=$(docker image inspect $container_name 2> /dev/null)
+ tmp_exit_code=$?
+ if [ $tmp_exit_code -gt 0 ]; then
+ echo -e "${YELLOW}SKIP (DOES NOT EXIST)${NO_COLOR}"
+ exit_code=0
+ else
+ result=$(docker image rm -f $container_name 2> /dev/null)
+ exit_code=$?
+ fi
+ return $exit_code
+}
+
+assert_specific_env(){
+ if [ -z "$1" ] || [ "$1" == "default" ]; then
+ fatal "ERROR: Please specify one or more valid python environments using --envs: 27,35,36,37,pypy2,pypy35"
+ exit 1
+ fi
+}
+
+switch_env(){
+ if [ "$1" != "default" ]; then
+ # If we activated a virtualenv within this script, deactivate it
+ deactivate 2> /dev/null # deactivate any active environment
+
+ # If this script was run from within an existing virtualenv, manually remove the current VIRTUAL_ENV from the
+ # current path. This ensures that our PATH is clean of that virtualenv.
+ # Note that the 'deactivate' function from the virtualenv is not available here unless the script was invoked
+ # as 'source ./run_tests.sh').
+ # Thanks internet stranger! https://unix.stackexchange.com/a/496050/38465
+ if [ ! -z "$VIRTUAL_ENV" ]; then
+ export PATH=$(echo $PATH | tr ":" "\n" | grep -v "$VIRTUAL_ENV" | tr "\n" ":");
+ fi
+ set -e # Let's error out if you try executing against a non-existing env
+ source "/vagrant/.venv${1}/bin/activate"
+ set +e
+ fi
+ title "### PYTHON ($(python --version 2>&1), $(which python)) ###"
+}
+
+run_in_container(){
+ python_version="$1"
+ envs="$2"
+ args="$3"
+ container_name="$(container_name $python_version)"
+ container_command=$(echo "$0 $args" | sed -E "s/( -e | --envs )$envs//" | sed -E "s/( --container| -C)//")
+
+ title "### CONTAINER $container_name"
+ start_container "$container_name"
+ docker exec "$container_name" $container_command
+}
+##############################################################################
+# The magic starts here: argument parsing and determining what to do
+
+
+# default behavior
+just_pep8=0
+just_lint=0
+just_git=0
+just_integration_tests=0
+just_build_tests=0
+just_stats=0
+just_all=0
+just_clean=0
+just_install=0
+just_uninstall=0
+just_install_container=0
+just_uninstall_container=0
+just_exec=0
+container_enabled=0
+include_coverage=1
+envs="default"
+cmd=""
+testargs=""
+original_args="$@"
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -h|--help) shift; help;;
+ -c|--clean) shift; just_clean=1;;
+ -p|--pep8) shift; just_pep8=1;;
+ -l|--lint) shift; just_lint=1;;
+ -g|--git) shift; just_git=1;;
+ -b|--build) shift; just_build_tests=1;;
+ -s|--stats) shift; just_stats=1;;
+ -i|--integration) shift; just_integration_tests=1;;
+ -a|--all) shift; just_all=1;;
+ -e|--envs) shift; envs="$1"; shift;;
+ --exec) shift; just_exec=1; cmd="$1"; shift;;
+ --install) shift; just_install=1;;
+ --uninstall) shift; just_uninstall=1;;
+ --install-container) shift; just_install_container=1;;
+ --uninstall-container) shift; just_uninstall_container=1;;
+ --all-env) shift; envs="all";;
+ -C|--container) shift; container_enabled=1;;
+ --no-coverage)shift; include_coverage=0;;
+ *) testargs="$1"; shift;
+ esac
+done
+
+old_virtualenv="$VIRTUAL_ENV" # Store the current virtualenv so we can restore it at the end
+
+trap exit_script INT # Exit on interrupt (i.e. ^C)
+exit_script(){
+ echo -e -n $NO_COLOR # make sure we don't have color left on the terminal
+ exit
+}
+
+exit_code=0
+
+# If the users specified 'all', then just replace $envs with the list of all envs
+if [ "$envs" == "all" ]; then
+ envs="27,35,36,37,38,pypy2,pypy35"
+fi
+original_envs="$envs"
+envs=$(echo "$envs" | tr ',' '\n') # Split the env list on comma so we can loop through it
+
+for environment in $envs; do
+
+ if [ $container_enabled -eq 1 ]; then
+ run_in_container "$environment" "$original_envs" "$original_args"
+ elif [ $just_pep8 -eq 1 ]; then
+ switch_env "$environment"
+ run_pep8_check
+ elif [ $just_stats -eq 1 ]; then
+ switch_env "$environment"
+ run_stats
+ elif [ $just_integration_tests -eq 1 ]; then
+ switch_env "$environment"
+ run_integration_tests
+ elif [ $just_build_tests -eq 1 ]; then
+ switch_env "$environment"
+ run_build_test
+ elif [ $just_git -eq 1 ]; then
+ switch_env "$environment"
+ run_git_check
+ elif [ $just_lint -eq 1 ]; then
+ switch_env "$environment"
+ run_lint_check
+ elif [ $just_all -eq 1 ]; then
+ switch_env "$environment"
+ run_all
+ elif [ $just_clean -eq 1 ]; then
+ switch_env "$environment"
+ clean
+ elif [ $just_exec -eq 1 ]; then
+ switch_env "$environment"
+ eval "$cmd"
+ elif [ $just_uninstall -eq 1 ]; then
+ assert_specific_env "$environment"
+ uninstall_virtualenv "$environment"
+ elif [ $just_install -eq 1 ]; then
+ assert_specific_env "$environment"
+ install_virtualenv "$environment"
+ elif [ $just_install_container -eq 1 ]; then
+ assert_specific_env "$environment"
+ install_container "$environment"
+ elif [ $just_uninstall_container -eq 1 ]; then
+ assert_specific_env "$environment"
+ uninstall_container "$environment"
+ else
+ switch_env "$environment"
+ run_unit_tests
+ fi
+ # We add up all the exit codes and use that as our final exit code
+ # While we lose the meaning of the exit code per individual environment by doing this, we do ensure that the end
+ # exit code reflects success (=0) or failure (>0).
+ exit_code=$((exit_code + $?))
+done
+
+# reactivate the virtualenv if we had one before
+if [ ! -z "$old_virtualenv" ]; then
+ source "$old_virtualenv/bin/activate"
+fi
+
+# Report some overall status
+if [ $exit_code -eq 0 ]; then
+ echo -e "\n${GREEN}### OVERALL STATUS: SUCCESS ###${NO_COLOR}"
+else
+ echo -e "\n${RED}### OVERALL STATUS: FAILURE ###${NO_COLOR}"
+fi
+
+exit $exit_code
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..7c2b287
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1 \ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..278e065
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+from __future__ import print_function
+from setuptools import setup, find_packages
+import io
+import re
+import os
+import platform
+import sys
+
+# There is an issue with building python packages in a shared vagrant directory because of how setuptools works
+# in python < 2.7.9. We solve this by deleting the filesystem hardlinking capability during build.
+# See: http://stackoverflow.com/a/22147112/381010
+try:
+ del os.link
+except:
+ pass # Not all OSes (e.g. windows) support os.link
+
+description = "Git commit message linter written in python, checks your commit messages for style."
+long_description = """
+Great for use as a commit-msg git hook or as part of your gating script in a CI pipeline (e.g. jenkins, gitlab).
+Many of the gitlint validations are based on `well-known`_ community_ `standards`_, others are based on checks that
+we've found useful throughout the years. Gitlint has sane defaults, but you can also easily customize it to your
+own liking.
+
+Demo and full documentation on `jorisroovers.github.io/gitlint`_.
+To see what's new in the latest release, visit the CHANGELOG_.
+
+Source code on `github.com/jorisroovers/gitlint`_.
+
+.. _well-known: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
+.. _community: http://addamhardy.com/blog/2013/06/05/good-commit-messages-and-enforcing-them-with-git-hooks/
+.. _standards: http://chris.beams.io/posts/git-commit/
+.. _jorisroovers.github.io/gitlint: https://jorisroovers.github.io/gitlint
+.. _CHANGELOG: https://github.com/jorisroovers/gitlint/blob/master/CHANGELOG.md
+.. _github.com/jorisroovers/gitlint: https://github.com/jorisroovers/gitlint
+"""
+
+
+# shamelessly stolen from mkdocs' setup.py: https://github.com/mkdocs/mkdocs/blob/master/setup.py
+def get_version(package):
+ """Return package version as listed in `__version__` in `init.py`."""
+ init_py = io.open(os.path.join(package, '__init__.py'), encoding="UTF-8").read()
+ return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1)
+
+
+setup(
+ name="gitlint",
+ version=get_version("gitlint"),
+ description=description,
+ long_description=long_description,
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 2.7",
+ "Programming Language :: Python :: 3.5",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: Software Development :: Testing",
+ "License :: OSI Approved :: MIT License"
+ ],
+ install_requires=[
+ 'Click==7.0',
+ 'arrow==0.15.5',
+ ],
+ extras_require={
+ ':sys_platform != "win32"': [
+ 'sh==1.12.14',
+ ],
+ },
+ keywords='gitlint git lint',
+ author='Joris Roovers',
+ url='https://github.com/jorisroovers/gitlint',
+ license='MIT',
+ package_data={
+ 'gitlint': ['files/*']
+ },
+ packages=find_packages(exclude=["examples"]),
+ entry_points={
+ "console_scripts": [
+ "gitlint = gitlint.cli:cli",
+ ],
+ },
+)
+
+# Print a red deprecation warning for python 2.6 users
+if sys.version_info[0] == 2 and sys.version_info[1] <= 6:
+ msg = "\033[31mDEPRECATION: Python 2.6 or below are no longer supported by gitlint or the Python core team." + \
+ "Please upgrade your Python to a later version.\033[0m"
+ print(msg)
+
+# Print a red deprecation warning for python 2.6 users
+PLATFORM_IS_WINDOWS = "windows" in platform.system().lower()
+if PLATFORM_IS_WINDOWS:
+ msg = "\n\n\n\n\n****************\n" + \
+ "WARNING: Gitlint support for Windows is still experimental and there are some known issues: " + \
+ "https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows " + \
+ "\n*******************"
+ print(msg)
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 0000000..3afab45
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,10 @@
+unittest2==1.1.0; python_version <= '2.7'
+flake8==3.7.9
+coverage==4.5.3
+python-coveralls==2.9.2
+radon==4.1.0
+mock==3.0.5 # mock 4.x no longer supports Python 2.7
+pytest==4.6.3; # pytest 5.x no longer supports Python 2.7
+pylint==1.9.4; python_version == '2.7'
+pylint==2.3.1; python_version >= '3.4'
+-e .
diff --git a/tools/create-test-repo.sh b/tools/create-test-repo.sh
new file mode 100755
index 0000000..79934d6
--- /dev/null
+++ b/tools/create-test-repo.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+
+RED="\033[31m"
+YELLOW="\033[33m"
+BLUE="\033[94m"
+GREEN="\033[32m"
+NO_COLOR="\033[0m"
+
+CWD="$(pwd)"
+echo "pwd=$CWD"
+# Create the repo
+cd /tmp
+reponame=$(date +gitlint-test-%Y-%m-%d_%H-%M-%S)
+git init $reponame
+cd $reponame
+
+# Do some basic config
+git config user.name gïtlint-test-user
+git config user.email gitlint@test.com
+git config core.quotePath false
+git config core.precomposeUnicode true
+
+# Add a test commit
+echo "tëst 123" > test.txt
+git add test.txt
+# commit -m -> use multiple -m args to add multiple paragraphs (/n in strings are ignored)
+git commit -m "test cömmit title" -m "test cömmit body that has a bit more text"
+cd $CWD
+
+# Let the user know
+echo ""
+echo -e "Created $GREEN/tmp/${reponame}$NO_COLOR"
+echo "Hit key up to access 'cd /tmp/$reponame'"
+echo "(Run this script using 'source' for this to work)"
+history -s "cd /tmp/$reponame"
diff --git a/tools/windows/create-test-repo.bat b/tools/windows/create-test-repo.bat
new file mode 100644
index 0000000..4220ad1
--- /dev/null
+++ b/tools/windows/create-test-repo.bat
@@ -0,0 +1,35 @@
+
+:: Use pushd, so we can popd back at the end (directory changes are not contained inside batch file)
+PUSHD C:\Windows\Temp
+
+:: Determine unique git repo name
+:: Note that date/time parsing on windows is locale dependent, so this might not work on every windows machine
+:: (see https://stackoverflow.com/questions/203090/how-do-i-get-current-date-time-on-the-windows-command-line-in-a-suitable-format)
+@echo off
+For /f "tokens=2-4 delims=/ " %%a in ('date /t') do (set mydate=%%c-%%a-%%b)
+For /f "tokens=1-2 delims=/:" %%a in ("%TIME%") do (set mytime=%%a-%%b)
+echo %mydate%_%mytime%
+
+set Reponame=gitlint-test-%mydate%_%mytime%
+echo %Reponame%
+
+:: Create git repo
+git init %Reponame%
+cd %Reponame%
+
+:: Do some basic config
+git config user.name gïtlint-test-user
+git config user.email gitlint@test.com
+git config core.quotePath false
+git config core.precomposeUnicode true
+
+:: Add a test commit
+echo "tëst 123" > test.txt
+git add test.txt
+git commit -m "test cömmit title" -m "test cömmit body that has a bit more text"
+
+:: echo. -> the dot allows us to print and empty line
+echo.
+echo Created C:\Windows\Temp\%Reponame%
+:: Move back to original dir
+POPD
diff --git a/tools/windows/run_tests.bat b/tools/windows/run_tests.bat
new file mode 100644
index 0000000..16ebc8b
--- /dev/null
+++ b/tools/windows/run_tests.bat
@@ -0,0 +1,15 @@
+@echo off
+
+set arg1=%1
+
+IF "%arg1%"=="-p" (
+ echo Running flake8...
+ flake8 --extend-ignore=H307,H405,H803,H904,H802,H701 --max-line-length=120 --exclude="*settings.py,*.venv/*.py" gitlint qa examples
+) ELSE (
+ :: Run passed arg, or all unit tests if passed arg is empty
+ IF "%arg1%" == "" (
+ pytest -rw -s gitlint
+ ) ELSE (
+ pytest -rw -s %arg1%
+ )
+) \ No newline at end of file