Adding upstream version 0.13.1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
1805ece79d
commit
d8f166e6bb
167 changed files with 15302 additions and 0 deletions
2
.coveragerc
Normal file
2
.coveragerc
Normal file
|
@ -0,0 +1,2 @@
|
|||
[run]
|
||||
omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv*
|
11
.flake8
Normal file
11
.flake8
Normal 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
|
22
.github/ISSUE_TEMPLATE/issue-template.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/issue-template.md
vendored
Normal 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
113
.github/workflows/checks.yml
vendored
Normal 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
68
.gitignore
vendored
Normal 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
5
.pre-commit-hooks.yaml
Normal file
|
@ -0,0 +1,5 @@
|
|||
- id: gitlint
|
||||
name: gitlint
|
||||
language: python
|
||||
entry: gitlint --staged --msg-filename
|
||||
stages: [commit-msg]
|
48
.pylintrc
Normal file
48
.pylintrc
Normal 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
271
CHANGELOG.md
Normal 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
6
CONTRIBUTING.md
Normal 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
15
Dockerfile
Normal 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
17
Dockerfile.dev
Normal 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
22
LICENSE
Normal 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
7
MANIFEST.in
Normal 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
21
README.md
Normal 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
47
Vagrantfile
vendored
Normal 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
1
doc-requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
mkdocs==1.0.4
|
432
docs/configuration.md
Normal file
432
docs/configuration.md
Normal 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
67
docs/contrib_rules.md
Normal 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
132
docs/contributing.md
Normal 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
3798
docs/demos/asciicinema.json
Normal file
File diff suppressed because it is too large
Load diff
75
docs/demos/scenario.txt
Normal file
75
docs/demos/scenario.txt
Normal 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
4
docs/extra.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
a.toctree-l3 {
|
||||
margin-left: 10px;
|
||||
/* display: none; */
|
||||
}
|
BIN
docs/images/RuleViolation.png
Normal file
BIN
docs/images/RuleViolation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
BIN
docs/images/RuleViolations.graffle
Normal file
BIN
docs/images/RuleViolations.graffle
Normal file
Binary file not shown.
351
docs/index.md
Normal file
351
docs/index.md
Normal 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
243
docs/rules.md
Normal 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 < 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 < 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 < 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 < 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
312
docs/user_defined_rules.md
Normal 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.
|
5
examples/commit-message-1
Normal file
5
examples/commit-message-1
Normal 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
|
6
examples/commit-message-10
Normal file
6
examples/commit-message-10
Normal file
|
@ -0,0 +1,6 @@
|
|||
This h@s $pecialCh@rs!
|
||||
|
||||
Commit body
|
||||
with more
|
||||
than 3 lines
|
||||
and no signed off by line
|
5
examples/commit-message-2
Normal file
5
examples/commit-message-2
Normal 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 #.
|
3
examples/commit-message-3
Normal file
3
examples/commit-message-3
Normal 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.
|
3
examples/commit-message-4
Normal file
3
examples/commit-message-4
Normal file
|
@ -0,0 +1,3 @@
|
|||
This title has a leading tab whitespace
|
||||
|
||||
tooshort
|
1
examples/commit-message-5
Normal file
1
examples/commit-message-5
Normal file
|
@ -0,0 +1 @@
|
|||
US1234: This commit message has no body
|
1
examples/commit-message-6
Normal file
1
examples/commit-message-6
Normal file
|
@ -0,0 +1 @@
|
|||
Merge "US1234: This merge has no body and that's OK"
|
4
examples/commit-message-7
Normal file
4
examples/commit-message-7
Normal 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
|
6
examples/commit-message-8
Normal file
6
examples/commit-message-8
Normal 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
|
7
examples/commit-message-9
Normal file
7
examples/commit-message-9
Normal 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
58
examples/gitlint
Normal 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
|
87
examples/my_commit_rules.py
Normal file
87
examples/my_commit_rules.py
Normal 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
45
examples/my_line_rules.py
Normal 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
1
gitlint/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "0.13.1"
|
57
gitlint/cache.py
Normal file
57
gitlint/cache.py
Normal 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
338
gitlint/cli.py
Normal 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
482
gitlint/config.py
Normal 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)
|
0
gitlint/contrib/__init__.py
Normal file
0
gitlint/contrib/__init__.py
Normal file
0
gitlint/contrib/rules/__init__.py
Normal file
0
gitlint/contrib/rules/__init__.py
Normal file
39
gitlint/contrib/rules/conventional_commit.py
Normal file
39
gitlint/contrib/rules/conventional_commit.py
Normal 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
|
18
gitlint/contrib/rules/signedoff_by.py
Normal file
18
gitlint/contrib/rules/signedoff_by.py
Normal 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
46
gitlint/display.py
Normal 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
81
gitlint/files/commit-msg
Normal 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
106
gitlint/files/gitlint
Normal 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
395
gitlint/git.py
Normal 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
62
gitlint/hooks.py
Normal 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
108
gitlint/lint.py
Normal 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
122
gitlint/options.py
Normal 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
137
gitlint/rule_finder.py
Normal 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
363
gitlint/rules.py
Normal 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
76
gitlint/shell.py
Normal 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)
|
0
gitlint/tests/__init__.py
Normal file
0
gitlint/tests/__init__.py
Normal file
169
gitlint/tests/base.py
Normal file
169
gitlint/tests/base.py
Normal 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)))
|
541
gitlint/tests/cli/test_cli.py
Normal file
541
gitlint/tests/cli/test_cli.py
Normal 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)
|
96
gitlint/tests/cli/test_cli_hooks.py
Normal file
96
gitlint/tests/cli/test_cli_hooks.py
Normal 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)
|
263
gitlint/tests/config/test_config.py
Normal file
263
gitlint/tests/config/test_config.py
Normal 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")
|
203
gitlint/tests/config/test_config_builder.py
Normal file
203
gitlint/tests/config/test_config_builder.py
Normal 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)
|
100
gitlint/tests/config/test_config_precedence.py
Normal file
100
gitlint/tests/config/test_config_precedence.py
Normal 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)
|
64
gitlint/tests/config/test_rule_collection.py
Normal file
64
gitlint/tests/config/test_rule_collection.py
Normal 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")
|
0
gitlint/tests/contrib/__init__.py
Normal file
0
gitlint/tests/contrib/__init__.py
Normal file
72
gitlint/tests/contrib/test_contrib_rules.py
Normal file
72
gitlint/tests/contrib/test_contrib_rules.py
Normal 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()
|
47
gitlint/tests/contrib/test_conventional_commit.py
Normal file
47
gitlint/tests/contrib/test_conventional_commit.py
Normal 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)
|
32
gitlint/tests/contrib/test_signedoff_by.py
Normal file
32
gitlint/tests/contrib/test_signedoff_by.py
Normal 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])
|
3
gitlint/tests/expected/test_cli/test_contrib_1
Normal file
3
gitlint/tests/expected/test_cli/test_contrib_1
Normal 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"
|
102
gitlint/tests/expected/test_cli/test_debug_1
Normal file
102
gitlint/tests/expected/test_cli/test_debug_1
Normal 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
|
3
gitlint/tests/expected/test_cli/test_input_stream_1
Normal file
3
gitlint/tests/expected/test_cli/test_input_stream_1
Normal 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
|
|
@ -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
|
71
gitlint/tests/expected/test_cli/test_input_stream_debug_2
Normal file
71
gitlint/tests/expected/test_cli/test_input_stream_debug_2
Normal 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
|
|
@ -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"
|
|
@ -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"
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-filename tïtle"
|
||||
3: B6 Body message is missing
|
|
@ -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
|
3
gitlint/tests/expected/test_cli/test_lint_staged_stdin_1
Normal file
3
gitlint/tests/expected/test_cli/test_lint_staged_stdin_1
Normal 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
|
72
gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
Normal file
72
gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
Normal 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
|
115
gitlint/tests/git/test_git.py
Normal file
115
gitlint/tests/git/test_git.py
Normal 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ä")
|
535
gitlint/tests/git/test_git_commit.py
Normal file
535
gitlint/tests/git/test_git_commit.py
Normal 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")
|
89
gitlint/tests/git/test_git_context.py
Normal file
89
gitlint/tests/git/test_git_context.py
Normal 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)
|
0
gitlint/tests/rules/__init__.py
Normal file
0
gitlint/tests/rules/__init__.py
Normal file
180
gitlint/tests/rules/test_body_rules.py
Normal file
180
gitlint/tests/rules/test_body_rules.py
Normal 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)
|
71
gitlint/tests/rules/test_configuration_rules.py
Normal file
71
gitlint/tests/rules/test_configuration_rules.py
Normal 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"
|
50
gitlint/tests/rules/test_meta_rules.py
Normal file
50
gitlint/tests/rules/test_meta_rules.py
Normal 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)])
|
18
gitlint/tests/rules/test_rules.py
Normal file
18
gitlint/tests/rules/test_rules.py
Normal 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"])
|
154
gitlint/tests/rules/test_title_rules.py
Normal file
154
gitlint/tests/rules/test_title_rules.py
Normal 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])
|
223
gitlint/tests/rules/test_user_rules.py
Normal file
223
gitlint/tests/rules/test_user_rules.py
Normal 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))
|
1
gitlint/tests/samples/commit_message/fixup
Normal file
1
gitlint/tests/samples/commit_message/fixup
Normal file
|
@ -0,0 +1 @@
|
|||
fixup! WIP: This is a fixup cömmit with violations.
|
3
gitlint/tests/samples/commit_message/merge
Normal file
3
gitlint/tests/samples/commit_message/merge
Normal 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.
|
3
gitlint/tests/samples/commit_message/revert
Normal file
3
gitlint/tests/samples/commit_message/revert
Normal file
|
@ -0,0 +1,3 @@
|
|||
Revert "WIP: this is a tïtle"
|
||||
|
||||
This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c.
|
14
gitlint/tests/samples/commit_message/sample1
Normal file
14
gitlint/tests/samples/commit_message/sample1
Normal 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 @@
|
1
gitlint/tests/samples/commit_message/sample2
Normal file
1
gitlint/tests/samples/commit_message/sample2
Normal file
|
@ -0,0 +1 @@
|
|||
Just a title contåining WIP
|
6
gitlint/tests/samples/commit_message/sample3
Normal file
6
gitlint/tests/samples/commit_message/sample3
Normal 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
|
7
gitlint/tests/samples/commit_message/sample4
Normal file
7
gitlint/tests/samples/commit_message/sample4
Normal 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
|
7
gitlint/tests/samples/commit_message/sample5
Normal file
7
gitlint/tests/samples/commit_message/sample5
Normal 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
|
3
gitlint/tests/samples/commit_message/squash
Normal file
3
gitlint/tests/samples/commit_message/squash
Normal 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
Loading…
Add table
Reference in a new issue