1
0
Fork 0

Adding upstream version 0.13.1.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-13 05:54:40 +01:00
parent 1805ece79d
commit d8f166e6bb
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
167 changed files with 15302 additions and 0 deletions

2
.coveragerc Normal file
View file

@ -0,0 +1,2 @@
[run]
omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv*

11
.flake8 Normal file
View file

@ -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

View file

@ -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

113
.github/workflows/checks.yml vendored Normal file
View file

@ -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

68
.gitignore vendored Normal file
View file

@ -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/

5
.pre-commit-hooks.yaml Normal file
View file

@ -0,0 +1,5 @@
- id: gitlint
name: gitlint
language: python
entry: gitlint --staged --msg-filename
stages: [commit-msg]

48
.pylintrc Normal file
View file

@ -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

271
CHANGELOG.md Normal file
View file

@ -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)

6
CONTRIBUTING.md Normal file
View file

@ -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/).

15
Dockerfile Normal file
View file

@ -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"]

17
Dockerfile.dev Normal file
View file

@ -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"]

22
LICENSE Normal file
View file

@ -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.

7
MANIFEST.in Normal file
View file

@ -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 *

21
README.md Normal file
View file

@ -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).

47
Vagrantfile vendored Normal file
View file

@ -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

1
doc-requirements.txt Normal file
View file

@ -0,0 +1 @@
mkdocs==1.0.4

432
docs/configuration.md Normal file
View file

@ -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
```

67
docs/contrib_rules.md Normal file
View file

@ -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.

132
docs/contributing.md Normal file
View file

@ -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.

3798
docs/demos/asciicinema.json Normal file

File diff suppressed because it is too large Load diff

75
docs/demos/scenario.txt Normal file
View file

@ -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
------------------------------

4
docs/extra.css Normal file
View file

@ -0,0 +1,4 @@
a.toctree-l3 {
margin-left: 10px;
/* display: none; */
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

351
docs/index.md Normal file
View file

@ -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

243
docs/rules.md Normal file
View file

@ -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
```

312
docs/user_defined_rules.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,6 @@
This h@s $pecialCh@rs!
Commit body
with more
than 3 lines
and no signed off by line

View file

@ -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 #.

View file

@ -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.

View file

@ -0,0 +1,3 @@
This title has a leading tab whitespace
tooshort

View file

@ -0,0 +1 @@
US1234: This commit message has no body

View file

@ -0,0 +1 @@
Merge "US1234: This merge has no body and that's OK"

View file

@ -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

View file

@ -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

View file

@ -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'

58
examples/gitlint Normal file
View file

@ -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

View file

@ -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

45
examples/my_line_rules.py Normal file
View file

@ -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

1
gitlint/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.13.1"

57
gitlint/cache.py Normal file
View file

@ -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

338
gitlint/cli.py Normal file
View file

@ -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

482
gitlint/config.py Normal file
View file

@ -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)

View file

View file

View file

@ -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

View file

@ -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)]

46
gitlint/display.py Normal file
View file

@ -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)

81
gitlint/files/commit-msg Normal file
View file

@ -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 ###

106
gitlint/files/gitlint Normal file
View file

@ -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

395
gitlint/git.py Normal file
View file

@ -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

62
gitlint/hooks.py Normal file
View file

@ -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)

108
gitlint/lint.py Normal file
View file

@ -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)

122
gitlint/options.py Normal file
View file

@ -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)

137
gitlint/rule_finder.py Normal file
View file

@ -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)

363
gitlint/rules.py Normal file
View file

@ -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

76
gitlint/shell.py Normal file
View file

@ -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)

View file

169
gitlint/tests/base.py Normal file
View file

@ -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)))

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

View file

@ -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()

View file

@ -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)

View file

@ -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])

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,2 @@
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-filename tïtle"
3: B6 Body message is missing

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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ä")

View file

@ -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")

View file

@ -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)

View file

View file

@ -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)

View file

@ -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"

View file

@ -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)])

View file

@ -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"])

View file

@ -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])

View file

@ -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))

View file

@ -0,0 +1 @@
fixup! WIP: This is a fixup cömmit with violations.

View file

@ -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.

View file

@ -0,0 +1,3 @@
Revert "WIP: this is a tïtle"
This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c.

View file

@ -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 @@

View file

@ -0,0 +1 @@
Just a title contåining WIP

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
squash! WIP: This is a squash cömmit with violations.
Body töo short

Some files were not shown because too many files have changed in this diff Show more