diff options
118 files changed, 4227 insertions, 2074 deletions
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b429c19 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,21 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/python-3/.devcontainer/base.Dockerfile + +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT="3.10-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends <your-package-list-here> + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
\ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5889037 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,56 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/python-3 +{ + "name": "Python 3", + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3.10", + // Options + "NODE_VERSION": "none" + } + }, + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + "features": { + "git": "latest", + "github-cli": "latest", + "sshd": "latest", + "homebrew": "latest" + }, + "postCreateCommand": "./.devcontainer/postCreateCommand.sh" +}
\ No newline at end of file diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh new file mode 100755 index 0000000..4bbaf05 --- /dev/null +++ b/.devcontainer/postCreateCommand.sh @@ -0,0 +1,23 @@ +#!/bin/sh -x + +brew install asdf +source "$(brew --prefix asdf)/libexec/asdf.sh" + +# Install latest python +asdf plugin add python +asdf install python 3.11.0 +asdf global python 3.11.0 + +# You can easily install other python versions like so: +# asdf install python 3.6.15 +# asdf install python 3.7.15 +# asdf install python 3.8.15 +# asdf install python 3.9.15 +# asdf install python 3.10.8 +# asdf install python pypy3.9-7.3.9 + +# Setup virtualenv, install all dependencies +cd /workspaces/gitlint +$(asdf which python) -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt -r test-requirements.txt -r doc-requirements.txt
\ No newline at end of file diff --git a/.flake8 b/.flake8 deleted file mode 100644 index df7800e..0000000 --- a/.flake8 +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -# H307: like imports should be grouped together -# H405: multi line docstring summary not separated with an empty line -# H803: git title must end with a period -# H904: Wrap long lines in parentheses instead of a backslash -# H802: git commit title should be under 50 chars -# H701: empty localization string -extend-ignore = H307,H405,H803,H904,H802,H701 -# exclude settings files and virtualenvs -exclude = *settings.py,*.venv/*.py -max-line-length = 120
\ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1ca2a9c..39b3782 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,21 +7,21 @@ jobs: runs-on: "ubuntu-latest" strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", pypy3] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", pypy-3.9] os: ["macos-latest", "ubuntu-latest"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.0.2 with: ref: ${{ github.event.pull_request.head.sha }} # Checkout pull request HEAD commit instead of merge commit # Because gitlint is a tool that uses git itself under the hood, we remove git tracking from the checked out - # code by temporarily renaming the .git directory. + # code by temporarily renaming the .git directory. # This is to ensure that the tests don't have a dependency on the version control of gitlint itself. - name: Temporarily remove git version control from code run: mv .git ._git - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4.2.0 with: python-version: ${{ matrix.python-version }} @@ -40,16 +40,28 @@ jobs: # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} # run: coveralls + # Patch the commit-msg hook to make it work in GH CI + # Specifically, within the commit-msg hook, wrap the invocation of gitlint with `script` + + - name: Patch commit-msg hook + run: | + # Escape " to \" + sed -i -E '/^gitlint/ s/"/\\"/g' gitlint-core/gitlint/files/commit-msg + # Replace `gitlint <args>` with `script -e -q -c "gitlint <args>"` + sed -i -E 's/^gitlint(.*)/script -e -q -c "\0"/' gitlint-core/gitlint/files/commit-msg + - name: Integration Tests run: ./run_tests.sh -i - - name: Integration Tests (GITLINT_USE_SH_LIB=0) + # Gitlint no longer uses `sh` by default, but for now we're still supporting the manual enablement of it. + # By setting GITLINT_USE_SH_LIB=1, we test whether this still works. + - name: Integration Tests (GITLINT_USE_SH_LIB=1) env: - GITLINT_USE_SH_LIB: 0 + GITLINT_USE_SH_LIB: 1 run: ./run_tests.sh -i - - name: PEP8 - run: ./run_tests.sh -p + - name: Code formatting (black) + run: ./run_tests.sh -f - name: PyLint run: ./run_tests.sh -l @@ -79,25 +91,25 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: [3.6] + python-version: ["3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.0.2 with: ref: ${{ github.event.pull_request.head.sha }} # Checkout pull request HEAD commit instead of merge commit # Because gitlint is a tool that uses git itself under the hood, we remove git tracking from the checked out - # code by temporarily renaming the .git directory. + # code by temporarily renaming the .git directory. # This is to ensure that the tests don't have a dependency on the version control of gitlint itself. - name: Temporarily remove git version control from code run: Rename-Item .git ._git - + - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4.2.0 with: python-version: ${{ matrix.python-version }} - name: "Upgrade pip on Python 3" - if: matrix.python-version == '3.6' + if: matrix.python-version == '3.10' run: python -m pip install --upgrade pip - name: Install requirements @@ -126,8 +138,8 @@ jobs: run: pytest -rw -s qa continue-on-error: true # Known to fail at this point - - name: PEP8 - run: flake8 gitlint-core qa examples + - name: Code formatting (black) + run: black . - name: PyLint run: pylint gitlint-core\gitlint qa --rcfile=".pylintrc" -r n diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index f2ccc7f..24fd745 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,7 +1,16 @@ -- id: gitlint - name: gitlint - language: python - additional_dependencies: ["./gitlint-core[trusted-deps]"] - entry: gitlint - args: [--staged, --msg-filename] - stages: [commit-msg] +- id: gitlint + name: gitlint + description: Checks your git commit messages for style. + language: python + additional_dependencies: ["./gitlint-core[trusted-deps]"] + entry: gitlint + args: [--staged, --msg-filename] + stages: [commit-msg] +- id: gitlint-ci + name: gitlint + language: python + additional_dependencies: ["./gitlint-core[trusted-deps]"] + entry: gitlint + always_run: true + pass_filenames: false + stages: [manual] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2546579..091b2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog # + +## v0.18.0 (2022-11-16) ## +Contributors: +Special thanks to all contributors for this release - details inline! + +- Python 3.11 support +- Last release to support Python 3.6 ([EOL since 2021-12-23](https://endoflife.date/python)) +- **Behavior Change**: In a future release, gitlint will be switching to use `re.search` instead of `re.match` semantics for all rules. Your rule regexes might need updating as a result, gitlint will print a warning if so. [More details are in the docs](https://jorisroovers.com/gitlint/configuration/#regex-style-search). ([#254](https://github.com/jorisroovers/gitlint/issues/254)) +- gitlint no longer uses the [sh](https://amoffat.github.io/sh/) library by default in an attempt to reduce external dependencies. In case of issues, the use of `sh` can be re-enabled by setting the env var `GITLINT_USE_SH_LIB=1`. This fallback will be removed entirely in a future gitlint release. ([#351](https://github.com/jorisroovers/gitlint/issues/351)) +- `--commits` now also accepts a comma-separated list of commit hashes, making it possible to lint a list of non-contiguous commits without invoking gitlint multiple times ([#283](https://github.com/jorisroovers/gitlint/issues/283)) +- Improved handling of branches that have no commits ([#188](https://github.com/jorisroovers/gitlint/issues/189)) - thanks [domsekotill](https://github.com/domsekotill) +- Support for `GITLINT_CONFIG` env variable ([#189](https://github.com/jorisroovers/gitlint/issues/188)) - thanks [Notgnoshi](https://github.com/Notgnoshi) +- Added [a new `gitlint-ci` pre-commit hook](https://jorisroovers.com/gitlint/#gitlint-and-pre-commit-in-ci), making it easier to run gitlint through pre-commit in CI ([#191](https://github.com/jorisroovers/gitlint/issues/191)) - thanks [guillaumelambert](https://github.com/guillaumelambert) +- Contrib Rules: + - New [contrib-disallow-cleanup-commits](https://jorisroovers.com/gitlint/contrib_rules/#cc2-contrib-disallow-cleanup-commits) rule ([#312](https://github.com/jorisroovers/gitlint/issues/312)) - thanks [matthiasbeyer](https://github.com/matthiasbeyer) + - New [contrib-allowed-authors](https://jorisroovers.com/gitlint/contrib_rules/#cc3-contrib-allowed-authors) rule ([#358](https://github.com/jorisroovers/gitlint/issues/358)) - thanks [stauchert](https://github.com/stauchert) +- User Defined rules: + - Gitlint now recognizes `fixup=amend` commits (see related [git documentation](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt)), available as `commit.is_fixup_amend_commit=True` + - Gitlint now parses diff **stat** information, available in `commit.changed_files_stats` ([#314](https://github.com/jorisroovers/gitlint/issues/314)) +- Bugfixes: + - Use correct encoding when using `--msg-filename` parameter ([#310](https://github.com/jorisroovers/gitlint/issues/310)) + - Various documentation fixes ([#244](https://github.com/jorisroovers/gitlint/issues/244)) ([#263](https://github.com/jorisroovers/gitlint/issues/263)) ([#266](https://github.com/jorisroovers/gitlint/issues/266)) ([#294](https://github.com/jorisroovers/gitlint/issues/294)) ([#295](https://github.com/jorisroovers/gitlint/issues/295)) ([#347](https://github.com/jorisroovers/gitlint/issues/347)) ([#364](https://github.com/jorisroovers/gitlint/issues/364)) - thanks [scop](https://github.com/scop), [OrBin](https://github.com/OrBin), [jtaylor100](https://github.com/jtaylor100), [stauchert](https://github.com/stauchert) +- Under-the-hood: + - Dependencies updated + - Moved to [black](https://github.com/psf/black) for formatting + - Fixed nasty CI issue ([#298](https://github.com/jorisroovers/gitlint/issues/298)) + - Unit tests fix ([#256](https://github.com/jorisroovers/gitlint/issues/256)) - thanks [carlsmedstad](https://github.com/carlsmedstad) + - Vagrant box removed in favor of github dev containers ([#348](https://github.com/jorisroovers/gitlint/issues/348)) + - Removed a few lingering references to the `master` branch in favor of `main` + - Moved [roadmap and project planning](https://github.com/users/jorisroovers/projects/1) to github projects + - Thanks to [sigmavirus24](https://github.com/sigmavirus24) for continued overall help and support + ## v0.17.0 (2021-11-28) ## Contributors: Special thanks to all contributors for this release, in particular [andersk](https://github.com/andersk) and [sigmavirus24](https://github.com/sigmavirus24). @@ -13,12 +45,12 @@ Special thanks to all contributors for this release, in particular [sigmavirus24 - Python 3.10 support - **New Rule**: [ignore-by-author-name](http://jorisroovers.github.io/gitlint/rules/#i4-ignore-by-author-name) allows users to skip linting commit messages made by specific authors -- `--commit <SHA>` flag to more easily lint a single commit message ([#141](https://github.com/jorisroovers/gitlint/issues/141)) +- `--commit <ref>` flag to more easily lint a single commit message ([#141](https://github.com/jorisroovers/gitlint/issues/141)) - `--fail-without-commits` flag will force gitlint to fail ([exit code 253](https://jorisroovers.com/gitlint/#exit-codes)) when the target commit range is empty (typically when using `--commits`) ([#193](https://github.com/jorisroovers/gitlint/issues/193)) - Bugfixes: - [contrib-title-conventional-commits (CT1)](https://jorisroovers.com/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits) now properly enforces the commit type ([#185](https://github.com/jorisroovers/gitlint/issues/185)) - [contrib-title-conventional-commits (CT1)](https://jorisroovers.com/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits) now supports the BREAKING CHANGE symbol "!" ([#186](https://github.com/jorisroovers/gitlint/issues/186)) -- Heads-up: [Python 3.6 will become EOL at the end of 2021](https://endoflife.date/python). It's likely that future gitlint releases will stop supporting Python 3.6 as a result. We will continue to support Python 3.6 as long as its easily doable, which in practice usually means as long as our dependencies support it. +- Heads-up: [Python 3.6 will become EOL at the end of 2021](https://endoflife.date/python). It's likely that future gitlint releases will stop supporting Python 3.6 as a result. We will continue to support Python 3.6 as long as it's easily doable, which in practice usually means as long as our dependencies support it. - Under-the-hood: dependencies updated, test and github action improvements. ## v0.15.1 (2021-04-16) ## @@ -168,8 +200,8 @@ and [AlexMooney](https://github.com/AlexMooney) for their contributions. [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 linting a single commit using `gitlint --commits <ref>` won't work anymore. Instead, for single commits, + users now need to specificy `gitlint --commits <ref>^...<ref>`. 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 @@ -9,7 +9,7 @@ # NOTE: --ulimit is required to work around a limitation in Docker # Details: https://github.com/jorisroovers/gitlint/issues/129 -FROM python:3.10-alpine +FROM python:3.11.0-alpine ARG GITLINT_VERSION RUN apk add git @@ -20,4 +20,4 @@ All contributions are welcome and very much appreciated! 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). +We maintain a [loose project plan on Github Projects](https://github.com/users/jorisroovers/projects/1/views/1). diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index f913248..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,49 +0,0 @@ -# -*- 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 python3.6-dev python3.7-dev python3.8-dev python3.9-dev -sudo apt-get install -y --allow-unauthenticated python3.8-distutils python3.9-distutils # Needed to work around python3.8/9+virtualenv issue -sudo apt-get install -y git python3-pip ripgrep jq -sudo apt-get install -y build-essential libssl-dev libffi-dev # for rebuilding cryptography (required for pypy2) -sudo apt-get install -y python3-pip -pip3 install -U pip -pip3 install 'virtualenv!=20.1.0' - -./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 .venv36/bin/activate' /home/vagrant/.bashrc || echo 'source .venv36/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/focal64" - - config.vm.define "dev" do |dev| - dev.vm.provision "gitlint", type: "shell", inline: "#{INSTALL_DEPS}" - # Use 'vagrant provision --provision-with jenkins' to only run jenkins install - dev.vm.provision "jenkins", type: "shell", inline: "#{INSTALL_JENKINS}" - end - - config.vm.network "forwarded_port", guest: 8080, host: 9080 - - if Vagrant.has_plugin?("vagrant-cachier") - config.cache.scope = :box - end - -end diff --git a/doc-requirements.txt b/doc-requirements.txt index 33ce51e..40febbe 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -1 +1 @@ -mkdocs==1.2.3
\ No newline at end of file +mkdocs==1.4.1
\ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index addf0c0..af49d7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ gitlint generate-config You can also use a different config file like so: ```sh -gitlint --config myconfigfile.ini +gitlint --config myconfigfile.ini ``` The block below shows a sample `.gitlint` file. Details about rule config options can be found on the @@ -39,16 +39,17 @@ ignore=title-trailing-punctuation, T3 # precedence over this verbosity = 2 -# By default gitlint will ignore merge, revert, fixup and squash commits. +# By default gitlint will ignore merge, revert, fixup, fixup=amend, and squash commits. ignore-merge-commits=true ignore-revert-commits=true ignore-fixup-commits=true +ignore-fixup-amend-commits=true ignore-squash-commits=true -# Ignore any data send to gitlint via stdin +# Ignore any data sent to gitlint via stdin ignore-stdin=true -# Fetch additional meta-data from the local repository when manually passing a +# 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 @@ -58,6 +59,11 @@ staged=true # Disabled by default. fail-without-commits=true +# Whether to use Python `search` instead of `match` semantics in rules that use +# regexes. Context: https://github.com/jorisroovers/gitlint/issues/254 +# Disabled by default, but will be enabled by default in the future. +regex-style-search=true + # Enable debug mode (prints more output). Disabled by default. debug=true @@ -187,7 +193,7 @@ 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: +You can also specify specific rules to be ignored as follows: ``` WIP: This is my commit message @@ -201,7 +207,7 @@ gitlint-ignore: T1, body-hard-tab 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)) +2. Configuration Rules (e.g.: [ignore-by-title](rules.md#i1-ignore-by-title)) 3. Commandline convenience flags (e.g.: `-vv`, `--silent`, `--ignore`) 4. Environment variables (e.g.: `GITLINT_VERBOSITY=3`) 5. Commandline configuration flags (e.g.: `-c title-max-length=123`) @@ -216,9 +222,9 @@ using commandline flags or in `[general]` section in a `.gitlint` configuration Enable silent mode (no output). Use [exit](index.md#exit-codes) code to determine result. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- -`False` | >= 0.1.0 | `--silent` | `GITLINT_SILENT` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| `False` | >= 0.1.0 | `--silent` | `GITLINT_SILENT` | #### Examples ```sh @@ -226,14 +232,15 @@ Default value | gitlint version | commandline flag | environment variable gitlint --silent GITLINT_SILENT=1 gitlint # using env variable ``` +------------------------------------------------------------------------------------------------------------------------ ### verbosity Amount of output gitlint will show when printing errors. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- -3 | >= 0.1.0 | `-v` | `GITLINT_VERBOSITY` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| 3 | >= 0.1.0 | `-v` | `GITLINT_VERBOSITY` | #### Examples @@ -252,14 +259,15 @@ GITLINT_VERBOSITY=2 gitlint # using env variable [general] verbosity=2 ``` +------------------------------------------------------------------------------------------------------------------------ ### ignore Comma separated list of rules to ignore (by name or id). -Default value | gitlint version | commandline flag | environment variable ----------------------------|------------------|-------------------|----------------------- - [] (=empty list) | >= 0.1.0 | `--ignore` | `GITLINT_IGNORE` +| Default value | gitlint version | commandline flag | environment variable | +| ---------------- | --------------- | ---------------- | -------------------- | +| [] (=empty list) | >= 0.1.0 | `--ignore` | `GITLINT_IGNORE` | #### Examples ```sh @@ -274,14 +282,15 @@ GITLINT_IGNORE=T1,body-min-length gitlint # using env variable [general] ignore=T1,body-min-length ``` +------------------------------------------------------------------------------------------------------------------------ ### debug Enable debugging output. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- - false | >= 0.7.1 | `--debug` | `GITLINT_DEBUG` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| false | >= 0.7.1 | `--debug` | `GITLINT_DEBUG` | #### Examples ```sh @@ -291,14 +300,15 @@ GITLINT_DEBUG=1 gitlint # using env variable # --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 | environment variable ----------------------------|------------------|-------------------|----------------------- -(empty) | >= 0.8.0 | `--target` | `GITLINT_TARGET` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| (empty) | >= 0.8.0 | `--target` | `GITLINT_TARGET` | #### Examples ```sh @@ -312,14 +322,31 @@ GITLINT_TARGET=/home/joe/myrepo/ gitlint # using env variable [general] target=/home/joe/myrepo/ ``` +------------------------------------------------------------------------------------------------------------------------ + +### config + +Path where gitlint looks for a config file. + +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| `.gitlint` | >= 0.1.0 | `--config` | `GITLINT_CONFIG` | + +#### Examples +```sh +gitlint --config=/home/joe/gitlint.ini +gitlint -C /home/joe/gitlint.ini # different way of doing the same +GITLINT_CONFIG=/home/joe/gitlint.ini # using env variable +``` +------------------------------------------------------------------------------------------------------------------------ ### extra-path Path where gitlint looks for [user-defined rules](user_defined_rules.md). -Default value | gitlint version | commandline flag | environment variable ----------------------------|------------------|-------------------|----------------------- - (empty) | >= 0.8.0 | `--extra-path` | `GITLINT_EXTRA_PATH` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| (empty) | >= 0.8.0 | `--extra-path` | `GITLINT_EXTRA_PATH` | #### Examples ```sh @@ -333,14 +360,14 @@ GITLINT_EXTRA_PATH=/home/joe/rules/ gitlint # using env variable [general] extra-path=/home/joe/rules/ ``` - +------------------------------------------------------------------------------------------------------------------------ ### contrib -Comma-separated list of [Contrib rules](contrib_rules) to enable (by name or id). +Comma-separated list of [Contrib rules](contrib_rules.md) to enable (by name or id). -Default value | gitlint version | commandline flag | environment variable ----------------------------|------------------|-------------------|----------------------- - (empty) | >= 0.12.0 | `--contrib` | `GITLINT_CONTRIB` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| (empty) | >= 0.12.0 | `--contrib` | `GITLINT_CONTRIB` | #### Examples ```sh @@ -349,21 +376,31 @@ gitlint --contrib=contrib-title-conventional-commits,CC1 # different way of doing the same gitlint -c general.contrib=contrib-title-conventional-commits,CC1 # using env variable -GITLINT_CONTRIB=contrib-title-conventional-commits,CC1 gitlint +GITLINT_CONTRIB=contrib-title-conventional-commits,CC1 gitlint ``` ```ini #.gitlint [general] contrib=contrib-title-conventional-commits,CC1 ``` +------------------------------------------------------------------------------------------------------------------------ ### staged -Fetch additional meta-data from the local repository when manually passing a commit message to gitlint via stdin or `--commit-msg`. +Attempt smart guesses about meta info (like author name, email, branch, changed files, etc) when manually passing a +commit message to gitlint via stdin or `--commit-msg`. + +Since in such cases no actual git commit exists (yet) for the message being linted, gitlint +needs to apply some heuristics (like checking `git config` and any staged changes) to make a smart guess about what the +likely author name, email, commit date, changed files and branch of the ensuing commit would be. + +When not using the `--staged` flag while linting a commit message via stdin or `--commit-msg`, gitlint will only have +access to the commit message itself for linting and won't be able to enforce rules like +[M1:author-valid-email](rules.md#m1-author-valid-email). -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- - false | >= 0.13.0 | `--staged` | `GITLINT_STAGED` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| false | >= 0.13.0 | `--staged` | `GITLINT_STAGED` | #### Examples ```sh @@ -377,6 +414,7 @@ GITLINT_STAGED=1 gitlint # using env variable [general] staged=true ``` +------------------------------------------------------------------------------------------------------------------------ ### fail-without-commits @@ -384,15 +422,15 @@ Hard fail when the target commit range is empty. Note that gitlint will already fail by default on invalid commit ranges. This option is specifically to tell gitlint to fail on **valid but empty** commit ranges. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|---------------------------|----------------------- - false | >= 0.15.2 | `--fail-without-commits` | `GITLINT_FAIL_WITHOUT_COMMITS` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ------------------------ | ------------------------------ | +| false | >= 0.15.2 | `--fail-without-commits` | `GITLINT_FAIL_WITHOUT_COMMITS` | #### Examples ```sh # CLI # The following will cause gitlint to hard fail (i.e. exit code > 0) -# since HEAD..HEAD is a valid but empty commit range. +# since HEAD..HEAD is a valid but empty commit range. gitlint --fail-without-commits --commits HEAD..HEAD GITLINT_FAIL_WITHOUT_COMMITS=1 gitlint # using env variable ``` @@ -402,13 +440,79 @@ GITLINT_FAIL_WITHOUT_COMMITS=1 gitlint # using env variable fail-without-commits=true ``` +--- +### regex-style-search + +Whether to use Python `re.search()` instead of `re.match()` semantics in all built-in rules that use regular expressions. + +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| false | >= 0.18.0 | Not Available | Not Available | + +!!! important + At this time, `regex-style-search` is **disabled** by default, but it will be **enabled** by default in the future. + + + +Gitlint will log a warning when you're using a rule that uses a custom regex and this option is not enabled: + +```plain +WARNING: I1 - ignore-by-title: gitlint will be switching from using Python regex 'match' (match beginning) to +'search' (match anywhere) semantics. Please review your ignore-by-title.regex option accordingly. +To remove this warning, set general.regex-style-search=True. +More details: https://jorisroovers.github.io/gitlint/configuration/#regex-style-search +``` + +*If you don't have any custom regex specified, gitlint will not log a warning and no action is needed.* + +**To remove the warning:** + +1. Review your regex in the rules gitlint warned for and ensure it's still accurate when using [`re.search()` semantics](https://docs.python.org/3/library/re.html#search-vs-match). +2. Enable `regex-style-search` in your `.gitlint` file (or using [any other way to configure gitlint](http://127.0.0.1:8000/gitlint/configuration/)): + +```ini +[general] +regex-style-search=true +``` + +#### More context +Python offers [two different primitive operations based on regular expressions](https://docs.python.org/3/library/re.html#search-vs-match): +`re.match()` checks for a match only at the beginning of the string, while `re.search()` checks for a match anywhere +in the string. + + + +Most rules in gitlint already use `re.search()` instead of `re.match()`, but there's a few notable exceptions that +use `re.match()`, which can lead to unexpected matching behavior. + +- M1 - author-valid-email +- I1 - ignore-by-title +- I2 - ignore-by-body +- I3 - ignore-body-lines +- I4 - ignore-by-author-name + +The `regex-style-search` option is meant to fix this inconsistency. Setting it to `true` will force the above rules to +use `re.search()` instead of `re.match()`. For detailed context, see [issue #254](https://github.com/jorisroovers/gitlint/issues/254). + + +#### Examples +```sh +# CLI +gitlint -c general.regex-style-search=true +``` +```ini +#.gitlint +[general] +regex-style-search=true +``` +------------------------------------------------------------------------------------------------------------------------ ### ignore-stdin Ignore any stdin data. Sometimes useful when running gitlint in a CI server. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- - false | >= 0.12.0 | `--ignore-stdin` | `GITLINT_IGNORE_STDIN` +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | ---------------------- | +| false | >= 0.12.0 | `--ignore-stdin` | `GITLINT_IGNORE_STDIN` | #### Examples ```sh @@ -422,14 +526,15 @@ GITLINT_IGNORE_STDIN=1 gitlint # using env variable [general] ignore-stdin=true ``` +------------------------------------------------------------------------------------------------------------------------ ### ignore-merge-commits Whether or not to ignore merge commits. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- - true | >= 0.7.0 | Not Available | Not Available +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| true | >= 0.7.0 | Not Available | Not Available | #### Examples ```sh @@ -441,14 +546,15 @@ gitlint -c general.ignore-merge-commits=false [general] ignore-merge-commits=false ``` +------------------------------------------------------------------------------------------------------------------------ ### ignore-revert-commits Whether or not to ignore revert commits. -Default value | gitlint version | commandline flag | environment variable ----------------|------------------|-------------------|----------------------- - true | >= 0.13.0 | Not Available | Not Available +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| true | >= 0.13.0 | Not Available | Not Available | #### Examples ```sh @@ -460,14 +566,15 @@ gitlint -c general.ignore-revert-commits=false [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 | environment variable ----------------|------------------|-------------------|----------------------- - true | >= 0.9.0 | Not Available | Not Available +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| true | >= 0.9.0 | Not Available | Not Available | #### Examples ```sh @@ -479,14 +586,35 @@ gitlint -c general.ignore-fixup-commits=false [general] ignore-fixup-commits=false ``` +------------------------------------------------------------------------------------------------------------------------ + +### ignore-fixup-amend-commits + +Whether or not to ignore [fixup=amend](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) commits. + +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| true | >= 0.18.0 | Not Available | Not Available | + +#### Examples +```sh +# CLI +gitlint -c general.ignore-fixup-amend-commits=false +``` +```ini +#.gitlint +[general] +ignore-fixup-amend-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 | environment variable ----------------|------------------|-------------------|----------------------- - true | >= 0.9.0 | Not Available | Not Available +| Default value | gitlint version | commandline flag | environment variable | +| ------------- | --------------- | ---------------- | -------------------- | +| true | >= 0.9.0 | Not Available | Not Available | #### Examples ```sh diff --git a/docs/contrib_rules.md b/docs/contrib_rules.md index 336e42a..e085f23 100644 --- a/docs/contrib_rules.md +++ b/docs/contrib_rules.md @@ -1,11 +1,12 @@ # 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. +re-implement these commonly used rules themselves as [user-defined](user_defined_rules.md) rules. To enable certain contrib rules, you can use the `--contrib` flag. ```sh @@ -42,6 +43,8 @@ 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-body-requires-signed-off-by | >= 0.12.0 | Commit body must contain a `Signed-off-by` line. +CC2 | contrib-disallow-cleanup-commits | >= 0.18.0 | Commit title must not contain `fixup!`, `squash!`, `amend!`. +CC3 | contrib-allowed-authors | >= 0.18.0 | Enforce that only authors listed in the `AUTHORS` file are allowed to commit. ## CT1: contrib-title-conventional-commits ## @@ -63,5 +66,18 @@ ID | Name | gitlint version | Description CC1 | contrib-body-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. +## CC2: contrib-disallow-cleanup-commits ## + +ID | Name | gitlint version | Description +------|----------------------------------|--------------------|------------------------------------------- +CC2 | contrib-disallow-cleanup-commits | >= 0.18.0 | Commit title must not contain `fixup!`, `squash!` or `amend!`. This means `git commit --fixup` and `git commit --squash` commits are not allowed. + +## CC3: contrib-allowed-authors ## + +ID | Name | gitlint version | Description +------|----------------------------------|--------------------|------------------------------------------- +CC3 | contrib-allowed-authors | >= 0.18.0 | The commit author must be listed in an `AUTHORS` file to be allowed to commit. Possible file names are also `AUTHORS.txt` and `AUTHORS.md`. + ## 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. + +We'd love for you to contribute new Contrib rules to gitlint or improve existing ones! Please visit the [Contributing](contributing.md) page on how to get started. diff --git a/docs/contributing.md b/docs/contributing.md index 1002676..254e856 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,7 +6,7 @@ The [source-code and issue tracker](https://github.com/jorisroovers/gitlint) are 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 +We maintain a [loose project plan on github projects](https://github.com/users/jorisroovers/projects/1/), but that's open to a lot of change and input. ## Guidelines @@ -19,11 +19,15 @@ When contributing code, please consider all the parts that are typically require - [Integration tests](https://github.com/jorisroovers/gitlint/tree/main/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/main/docs) +- [Documentation](https://github.com/jorisroovers/gitlint/tree/main/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! +can make it as part of a release. **Gitlint commits and pull requests are gated on all of our tests and checks as well as +code-review**. 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. + +It's also a good idea to open an issue before submitting a PR for non-trivial changes, so we can discuss what you have +in mind before you spend the effort. Thanks! !!! Important **On the topic of releases**: Gitlint releases typically go out when there's either enough new features and fixes @@ -32,55 +36,105 @@ and it's likely that your PR will be merged and released a lot sooner. Thanks! or months before merged code actually gets released - we know that can be frustrating but please understand it's a well-considered trade-off based on available time. -## Development +## Local setup -There is a Vagrantfile (Ubuntu) in this repository that can be used for development. -It comes pre-installed with all Python versions that gitlint supports. -```sh -vagrant up -vagrant ssh -``` - -Or you can choose to use your local environment: +To install gitlint for local development: ```sh python -m venv .venv . .venv/bin/activate -pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt -r doc-requirements.txt python setup.py develop ``` -To run tests: +## Github Devcontainer + +We provide a devcontainer on github to make it easier to get started with gitlint development using VSCode. + +To start one, click the plus button under the *Code* dropdown on +[the gitlint repo on github](https://github.com/jorisroovers/gitlint). + +**It can take ~15min for all post installation steps to finish.** + +![Gitlint Dev Container Instructions](images/dev-container.png) + + +After setup has finished, you should be able to just activate the virtualenv in the home dir and run the tests: +```sh +. ~/.venv/bin/activate +./run_tests.sh +``` + +By default we have python 3.11 installed in the dev container, but you can also use [asdf](https://asdf-vm.com/) +(preinstalled) to install additional python versions: + +```sh +# Ensure ASDF overrides system python in PATH +# You can also append this line to your ~/.bash_profile in the devcontainer to have this happen automatically on login +source "$(brew --prefix asdf)/libexec/asdf.sh" + +# Install python 3.9.15 +asdf install python 3.9.15 +# List all available python versions +asdf list all python +# List installed python versions +asdf list python +``` + +## Running tests ```sh ./run_tests.sh # run unit tests and print test coverage -./run_tests.sh gitlint/tests/rules/test_body_rules.py::BodyRuleTests::test_body_missing # run a single test +./run_tests.sh gitlint-core/gitlint/tests/rules/test_body_rules.py::BodyRuleTests::test_body_missing # run a single test +pytest -k test_body_missing # Alternative way to run a specific test by invoking pytest directly with a keyword expression ./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 --format # format checks (black) ./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 +./run_tests.sh --all # Run unit, integration, format and gitlint checks +``` +## Formatting + +We use [black](https://black.readthedocs.io/en/stable/) for code formatting. +To use it, just run black against the code you modified: + +```sh +black . # format all python code +black gitlint-core/gitlint/lint.py # format a specific file ``` -The `Vagrantfile` comes with `virtualenv`s for python 3.6, 3.7, 3.8, 3.9 and pypy3.6. -You can easily run tests against specific python environments by using the following commands *inside* of the Vagrant VM: +## Documentation +We use [mkdocs](https://www.mkdocs.org/) for generating our documentation from markdown. + +To use it: ```sh -./run_tests.sh --envs 36 # Run the unit tests against Python 3.6 -./run_tests.sh --envs 36,37,pypy36 # Run the unit tests against Python 3.6, Python 3.7 and Pypy3.6 -./run_tests.sh --envs 36,37 --pep8 # Run pep8 checks against Python 3.6 and Python 3.7 (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 +pip install -r doc-requirements.txt # install doc requirements +mkdocs serve ``` -!!! important - Gitlint commits and pull requests are gated on all of our tests and checks. +Then access the documentation website on [http://localhost:8000](). ## Packaging +Gitlint consists of 2 python packages: [gitlint](https://pypi.org/project/gitlint/) +and [gitlint-core](https://pypi.org/project/gitlint-core/). + +The `gitlint` package is just a wrapper package around `gitlint-core[trusted-deps]` which strictly pins gitlint +dependencies to known working versions. + +There are scenarios where users (or OS package managers) may want looser dependency requirements. +In these cases, users can just install `gitlint-core` directly (`pip install gitlint-core`). + +[Issue 162](https://github.com/jorisroovers/gitlint/issues/162) has all the background of how we got to the decision +to split gitlint in 2 packages. + +![Gitlint package structure](images/gitlint-packages.png) + +### Packaging description + To see the package description in HTML format ```sh pip install docutils @@ -89,16 +143,6 @@ 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): -```sh -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: @@ -110,13 +154,13 @@ 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 +Since gitlint 0.12.0, we support [Contrib rules](contrib_rules.md): 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 +1. **Write your rule as a [user-defined rule](user_defined_rules.md)**. 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/main/gitlint-core/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/main/gitlint-core/gitlint/tests/contrib/rules). @@ -129,7 +173,7 @@ If you follow the steps above and follow the existing gitlint conventions wrt na 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. +- Since Contrib rules are really just user-defined rules that live within the gitlint code-base, all the [user-rule requirements](user_defined_rules.md#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. @@ -137,4 +181,4 @@ In case you're looking for a slightly more formal spec, here's what gitlint requ - 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. +- Contrib rules **should** use [options](user_defined_rules.md#options) to make rules configurable. diff --git a/docs/demos/asciicinema.json b/docs/demos/asciicinema.json index c6e2747..a5664c7 100644 --- a/docs/demos/asciicinema.json +++ b/docs/demos/asciicinema.json @@ -1448,7 +1448,7 @@ ], [ 0.002767, - "\u001b[1;1H\u001b[93m 1 \r\n 2 \u001b[m\u001b[96m# Please enter the commit message for your changes. Lines starting\u001b[m\r\n\u001b[93m 3 \u001b[m\u001b[96m# with '#' will be ignored, and an empty message aborts the commit.\u001b[m\r\n\u001b[93m 4 \u001b[m\u001b[96m# On branch \u001b[m\u001b[38;5;224mmaster\u001b[m\r\n\u001b[93m 5 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;81mChanges to be committed:\u001b[m\r\n\u001b[93m 6 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;121mnew file\u001b[m\u001b[96m: \u001b[m\u001b[95m foo.txt\u001b[m\r\n\u001b[93m 7 \u001b[m\u001b[96m#\u001b[m\r\n\u001b[94m~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ " + "\u001b[1;1H\u001b[93m 1 \r\n 2 \u001b[m\u001b[96m# Please enter the commit message for your changes. Lines starting\u001b[m\r\n\u001b[93m 3 \u001b[m\u001b[96m# with '#' will be ignored, and an empty message aborts the commit.\u001b[m\r\n\u001b[93m 4 \u001b[m\u001b[96m# On branch \u001b[m\u001b[38;5;224mmain\u001b[m\r\n\u001b[93m 5 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;81mChanges to be committed:\u001b[m\r\n\u001b[93m 6 \u001b[m\u001b[96m# \u001b[m\u001b[38;5;121mnew file\u001b[m\u001b[96m: \u001b[m\u001b[95m foo.txt\u001b[m\r\n\u001b[93m 7 \u001b[m\u001b[96m#\u001b[m\r\n\u001b[94m~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ " ], [ 0.000062, @@ -2404,7 +2404,7 @@ ], [ 0.052844, - "1: T3 Title has trailing punctuation (!): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n" + "1: T3 Title has trailing punctuation (!): \"WIP: This is a patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is a patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n" ], [ 0.006075, @@ -2432,7 +2432,7 @@ ], [ 0.004763, - "[master 4b1f92d] WIP: This is an patchset that I need to continue working on!\r\n" + "[main 4b1f92d] WIP: This is a patchset that I need to continue working on!\r\n" ], [ 0.001504, @@ -3108,11 +3108,11 @@ ], [ 0.050694, - "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is an patchset that I need to continue working on!\"\r\n" + "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is a patchset that I need to continue working on!\"\r\n" ], [ 0.000006, - "1: T3 Title has trailing punctuation (!): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n" + "1: T3 Title has trailing punctuation (!): \"WIP: This is a patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is a patchset that I need to continue working on!\"\r\n3: B6 Body message is missing\r\n" ], [ 0.005418, @@ -3508,7 +3508,7 @@ ], [ 0.050989, - "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is an patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is an patchset that I need to continue working on!\"\r\n" + "1: T1 Title exceeds max length (60\u003e50): \"WIP: This is a patchset that I need to continue working on!\"\r\n1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is a patchset that I need to continue working on!\"\r\n" ], [ 0.000025, @@ -3795,4 +3795,4 @@ "exit\r\n" ] ] -} +}
\ No newline at end of file diff --git a/docs/extra.css b/docs/extra.css index 5643925..12a7663 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -2,3 +2,11 @@ a.toctree-l3 { margin-left: 10px; /* display: none; */ } + +.wy-nav-content { + max-width: 1000px; +} + +.document hr { + border-top: 1px solid #666; +}
\ No newline at end of file diff --git a/docs/images/dev-container.png b/docs/images/dev-container.png Binary files differnew file mode 100644 index 0000000..6cac5a2 --- /dev/null +++ b/docs/images/dev-container.png diff --git a/docs/images/gitlint-packages.drawio.svg b/docs/images/gitlint-packages.drawio.svg new file mode 100644 index 0000000..6098e3d --- /dev/null +++ b/docs/images/gitlint-packages.drawio.svg @@ -0,0 +1,351 @@ +<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="491px" height="391px" viewBox="-0.5 -0.5 491 391" content="<mxfile><diagram id="x7jBp0SZ1TbX-vMHKkT6" name="Page-1">7Vtbb+o4EP41SGcfQEmccHks0O4e6axUbbW3p5VLDFg1MccxBc6vX9+SkNiUAEkvu62q4owvib/5ZjwzoR0wWe1+ZnC9/JXGiHQCL951wLQTBFHoi79SsNeCsB9owYLhWIv8QvCAfyAj9Ix0g2OUlgZySgnH67JwRpMEzXhJBhmj2/KwOSXlu67hAlmChxkktvRPHPOllg6DQSH/BeHFMruz3x/pnhXMBpudpEsY0+2BCNx2wIRRynVrtZsgIrHLcNHz7o705g/GUMLrTAB6wjMkG7O3TtAnYur4UTQWsrHAnOCEd2eUoaxTLJj3m43wfYbOdok5eljDmbzeCgKIQUu+IuLKF01I8CIRbaYBGj8jxrHA9sbIOZUTUjEfJ4vfDIogv5EcjnZHt+vnIAryIbpCnO3FEDMhZ5AhHsj0sC3UGAyNbHmgwiAyQmios8jXLtAVDQOwG+zQAfZl2BE05wVI39TVtN8QRiAqYzSwMfI9B0a58BqMoqOExAXhtGBOxYakkRPK1ND+9w3VA8B87nnycQqRnsvZJuUo7sZIuIqCzHqp8vJCjC/luJvTDSgHDN5SOYPT3gLtOIP/MPR9gxlKa/kLgQZ303wmUEHMgecKx7GcPhb3wD/go1rKE9drKlyV2mE07kRTudaG01SfIHLplDP6hCaGMwlN5CpzTEhF1IiuKs4mqqmroAFVDU+rCicph4R8KkthHozeTlkjS1kPdMOEbwm8iYidOsFEtr59FX/FdtRjK+x0B+Kzc/1TI8dEmd3hyHGUhpENWL8BwLJj3EHvGD9n5J0QPHuSvWA67Hk9kFsAq/p6cZAmmUwFiHqW3wt6vj2r1+sd2Mrh3APxwXOcYUTmYP+QJgSGFUZ4NiNGDgtqIrTy/fqECPpwJe1B60Zw4xxeVCd/0qO2wwhP02PQFj3sPOd+f/9VPf/sSWZ7ZzpQRjdJjOLm3KlfMR7g1zOeJo4fv386WDBZ4H8tAfRHvn1quRLAUdQAzsFHY6E/tOFpjYV2FHQps14zPRYBjc0gV6AYNsCg4Hjc8xnW1wnrA8/WVmthvW8nYaViWpQHBG9a25Aqkr9ezx/0vGPUeIWMIgRlXQHQi2xtjRw5BWhCW6G1dRQv0IO5pIwv6YImkNwWUhsBOeXl/Qceh2yBsujGDQlDBHL8XF7KtTsz9V6ng3koHpX9uJV0pSq/NLMKjG4Yg/uDYcaUz77PXc3xYbk8LRr6CQqF5ZjU848jS4cHGpKWcAdXmMi7/4FYDBNoxJPc0MDdnTIvMFap9l/GqamLv5WVRNnldHfYOd2bq8uIAAatMCF3bpmGwmEtJtgLDU4spDdjLXSJFl3JXEWt6RKuZRPOuFTcKfdUjpSOabwBJ1Y1iCB0+LDQFR4MGggP7ADzowIXOvKf1mA7/tJBZ+8H+GWHrkSiOzfO5EY9FHlGMkBScHiqX8c8stcP1ruDjq3ZgewKDYIeQVxEWl0TterOhLIVJLpfHsRdE5bJvjwyy/qw0FRiVvWy+6kezmCSzsVa2aomvvK2lMXlO+YTY5yuCTS7w4kIW8ycOaGQVxaqxiJWYLPGa7WMik1Fy84nrRjIKp5cFJg44sr6oW2F755n+E7gIyJjkbQtlH2UTEL+NBQQVXzJ0DYJZ+zaRDT0wguET5to1yaqb9rbMowj3K5vHa9jBX453w5dbzxBS1YAjpeV350VvCOm/54iBQ3DMxlBioQUyYebYVmN8L6saCrFmxSx9KdmSW2R1+H+r+HzMaO5hud5rc1B7H5bxHZFip/ErkdsQqn6LPO6+hroC5Qk34v0SnwwnD41TPV377+zNxn91+Q1sNC8vADQqZnIt560h0Ul7Ny0PbCXKi/UXNoO+hb2ZxXQ8npL1+t5Xl5l0UUXH5youqire8SweGJJeyWsp/CKFz+3chO2woHqm6qwah4NlfCqWUZ2n6OEqowHQ1DhzXUlPFDjW5JiBl6n6NzqRc3z/WzCXJnkVUuirqp34PCXTbyiAC8WTC+tgLZmdprznYOCadkU9TvEK2yxNm6ur0L+j1gqzL4tjorL4vvv2oEU/0QAbv8F</diagram></mxfile>"> + <defs/> + <g> + <rect x="210" y="140" width="280" height="250" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-end; width: 275px; height: 1px; padding-top: 147px; margin-left: 210px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: right;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + <b> + gitlint-core + </b> + </div> + </div> + </div> + </foreignObject> + <text x="485" y="159" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="end"> + gitlint-core + </text> + </switch> + </g> + <rect x="235" y="190" width="100" height="100" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <rect x="375" y="190" width="100" height="100" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 197px; margin-left: 376px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + <i> + <font color="#ff0000"> + trusted-deps + </font> + </i> + </div> + </div> + </div> + </foreignObject> + <text x="425" y="209" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + trusted-deps + </text> + </switch> + </g> + <rect x="370" y="170" width="100" height="20" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 180px; margin-left: 420px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"> + <b> + extra_requires + </b> + </div> + </div> + </div> + </foreignObject> + <text x="420" y="184" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + extra_requires + </text> + </switch> + </g> + <rect x="229" y="170" width="100" height="20" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 180px; margin-left: 279px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"> + <b> + install_requires + </b> + </div> + </div> + </div> + </foreignObject> + <text x="279" y="184" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + install_requires + </text> + </switch> + </g> + <rect x="230" y="310" width="245" height="60" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 243px; height: 1px; padding-top: 340px; margin-left: 231px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + Source Code, CLI entry point, etc + </div> + </div> + </div> + </foreignObject> + <text x="353" y="344" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + Source Code, CLI entry point, etc + </text> + </switch> + </g> + <rect x="380" y="220" width="90" height="50" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 245px; margin-left: 382px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: left;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"> + <div> + Click==8.0.3 + <br/> + <span> + arrow==1.2.1 + <br/> + ... + </span> + </div> + </div> + </div> + </div> + </foreignObject> + <text x="382" y="249" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px"> + Click==8.0.3... + </text> + </switch> + </g> + <rect x="240" y="220" width="70" height="50" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 245px; margin-left: 242px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: left;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"> + <div> + Click>=8 + <br/> + <span> + arrow>=1 + <br/> + ... + </span> + </div> + </div> + </div> + </div> + </foreignObject> + <text x="242" y="249" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px"> + Click>=8... + </text> + </switch> + </g> + <rect x="180" y="130" width="90" height="20" rx="3" ry="3" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 140px; margin-left: 181px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + PyPI package + </div> + </div> + </div> + </foreignObject> + <text x="225" y="144" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + PyPI package + </text> + </switch> + </g> + <rect x="210" y="11" width="280" height="95" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-end; width: 275px; height: 1px; padding-top: 18px; margin-left: 210px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: right;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + <b> + gitlint + </b> + </div> + </div> + </div> + </foreignObject> + <text x="485" y="30" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="end"> + gitlint + </text> + </switch> + </g> + <rect x="180" y="1" width="90" height="20" rx="3" ry="3" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 11px; margin-left: 181px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + PyPI package + </div> + </div> + </div> + </foreignObject> + <text x="225" y="15" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + PyPI package + </text> + </switch> + </g> + <rect x="235" y="46" width="200" height="45" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <rect x="229" y="26" width="100" height="20" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 36px; margin-left: 279px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"> + <b> + install_requires + </b> + </div> + </div> + </div> + </foreignObject> + <text x="279" y="40" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle"> + install_requires + </text> + </switch> + </g> + <rect x="243" y="53.5" width="195" height="30" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 193px; height: 1px; padding-top: 61px; margin-left: 245px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: left;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + gitlint-core[ + <i> + <font color="#ff0000"> + trusted-deps + </font> + </i> + ]==0.17.0 + </div> + </div> + </div> + </foreignObject> + <text x="245" y="73" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px"> + gitlint-core[trusted-deps]==0.17... + </text> + </switch> + </g> + <path d="M 350 80 L 350 230 Q 350 240 359.32 240 L 368.63 240" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="M 373.88 240 L 366.88 243.5 L 368.63 240 L 366.88 236.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <path d="M 100 68 L 193.63 68" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="M 198.88 68 L 191.88 71.5 L 193.63 68 L 191.88 64.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <path d="M 50 91.5 C 50 72.7 50 63.3 70 63.3 C 56.67 63.3 56.67 44.5 70 44.5 C 83.33 44.5 83.33 63.3 70 63.3 C 90 63.3 90 72.7 90 91.5 Z" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <path d="M 50 277 C 50 258.2 50 248.8 70 248.8 C 56.67 248.8 56.67 230 70 230 C 83.33 230 83.33 248.8 70 248.8 C 90 248.8 90 258.2 90 277 Z" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <rect x="20" y="100" width="100" height="30" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 115px; margin-left: 21px;"> + <div data-drawio-colors="color: #000000; background-color: #FFFFFF; " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: normal; overflow-wrap: normal;"> + <span style="font-family: "helvetica" ; font-size: 12px ; font-weight: 400 ; letter-spacing: normal ; text-align: center ; text-indent: 0px ; text-transform: none ; word-spacing: 0px ; display: inline ; float: none"> + <i> + pip install gitlint + </i> + </span> + </div> + </div> + </div> + </foreignObject> + <text x="70" y="119" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle"> + pip install gitl... + </text> + </switch> + </g> + <rect x="15" y="290" width="130" height="30" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 128px; height: 1px; padding-top: 305px; margin-left: 16px;"> + <div data-drawio-colors="color: #000000; background-color: #FFFFFF; " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: normal; overflow-wrap: normal;"> + <span style="font-family: "helvetica" ; font-size: 12px ; font-weight: 400 ; letter-spacing: normal ; text-align: center ; text-indent: 0px ; text-transform: none ; word-spacing: 0px ; display: inline ; float: none"> + <i> + pip install gitlint-core + </i> + </span> + </div> + </div> + </div> + </foreignObject> + <text x="80" y="309" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle"> + pip install gitlint-c... + </text> + </switch> + </g> + <rect x="0" y="0" width="160" height="30" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 15px; margin-left: 1px;"> + <div data-drawio-colors="color: #000000; background-color: #FFFFFF; " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: normal; overflow-wrap: normal;"> + <span style="font-family: "helvetica" ; font-size: 12px ; font-weight: 400 ; letter-spacing: normal ; text-indent: 0px ; text-transform: none ; word-spacing: 0px ; display: inline ; float: none"> + Use strict dependencies (most users) + </span> + </div> + </div> + </div> + </foreignObject> + <text x="80" y="19" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle"> + Use strict dependencies (m... + </text> + </switch> + </g> + <rect x="0" y="180" width="160" height="30" fill="none" stroke="none" pointer-events="all"/> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 195px; margin-left: 1px;"> + <div data-drawio-colors="color: #000000; background-color: #FFFFFF; " style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: normal; overflow-wrap: normal;"> + <span style="font-family: "helvetica" ; font-size: 12px ; font-weight: 400 ; letter-spacing: normal ; text-indent: 0px ; text-transform: none ; word-spacing: 0px ; display: inline ; float: none"> + Use loose dependencies + <br/> + (at your risk) + </span> + </div> + </div> + </div> + </foreignObject> + <text x="80" y="199" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle"> + Use loose dependencies... + </text> + </switch> + </g> + <path d="M 100 253.5 L 193.63 253.03" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="M 198.88 253.01 L 191.9 256.54 L 193.63 253.03 L 191.86 249.54 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <path d="M 210 250 L 215 250 Q 220 250 220 240 L 220 213 Q 220 203 224.07 203 L 228.13 203" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="M 233.38 203 L 226.38 206.5 L 228.13 203 L 226.38 199.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <ellipse cx="210" cy="253.5" rx="10" ry="10" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + <path d="M 220 68 L 228.64 68.29" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="M 233.88 68.46 L 226.77 71.73 L 228.64 68.29 L 227 64.73 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/> + <ellipse cx="210" cy="68" rx="10" ry="10" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/> + </g> + <switch> + <g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/> + <a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"> + <text text-anchor="middle" font-size="10px" x="50%" y="100%"> + Viewer does not support full SVG 1.1 + </text> + </a> + </switch> +</svg>
\ No newline at end of file diff --git a/docs/images/gitlint-packages.png b/docs/images/gitlint-packages.png Binary files differnew file mode 100644 index 0000000..00d3ec1 --- /dev/null +++ b/docs/images/gitlint-packages.png diff --git a/docs/index.md b/docs/index.md index 398b4e5..801a16e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,11 +22,11 @@ Great for use as a [commit-msg git hook](#using-gitlint-as-a-commit-msg-hook) or - **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), +[community](https://addamhardy.com/2013-06-05-good-commit-messages-and-enforcing-them-with-git-hooks), [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). + - **Community contributed rules**: Conventions that are common but not universal [can be selectively enabled](contrib_rules.md). - **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). - **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, @@ -38,6 +38,10 @@ useful throughout the years. # Pip is recommended to install the latest version pip install gitlint +# Alternative: by default, gitlint is installed with pinned dependencies. +# To install gitlint with looser dependency requirements, only install gitlint-core. +pip install gitlint-core + # Community maintained packages: brew install gitlint # Homebrew (macOS) sudo port install gitlint # Macports (macOS) @@ -81,6 +85,19 @@ $ cat examples/commit-message-2 | gitlint !!! note The returned exit code equals the number of errors found. [Some exit codes are special](index.md#exit-codes). +### Shell completion + +```sh +# Bash: add to ~/.bashrc +eval "$(_GITLINT_COMPLETE=bash_source gitlint)" + +# Zsh: add to ~/.zshrc +eval "$(_GITLINT_COMPLETE=zsh_source gitlint)" + +# Fish: add to ~/.config/fish/completions/foo-bar.fish +eval (env _GITLINT_COMPLETE=fish_source gitlint) +``` + ## 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. @@ -93,7 +110,7 @@ Short example `.gitlint` file ([full reference](configuration.md)): # their id or by their full name ignore=body-is-missing,T3 -# Ignore any data send to gitlint via stdin +# Ignore any data sent to gitlint via stdin ignore-stdin=true # Configure title-max-length rule, set title length to 80 (72 = default) @@ -136,7 +153,8 @@ Options: (e.g.: -c T1.line-length=80). Flag can be used multiple times to set multiple config values. --commit TEXT Hash (SHA) of specific commit to lint. - --commits TEXT The range of commits to lint. [default: HEAD] + --commits TEXT The range of commits (refspec or comma-separated + hashes) 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). @@ -145,8 +163,9 @@ Options: --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. + --staged Attempt smart guesses about meta info (like + author name, email, branch, changed files, etc) + for staged commits. --fail-without-commits Hard fail when the target commit range is empty. -v, --verbose Verbosity, more v's for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv] @@ -218,7 +237,7 @@ In case you want to change gitlint's behavior, you should either use a `.gitlint your `.pre-commit-config.yaml` file like so: ```yaml - repo: https://github.com/jorisroovers/gitlint - rev: # Fill in a tag / sha here + rev: # Fill in a tag / sha here (e.g. v0.18.0) hooks: - id: gitlint args: [--contrib=CT1, --msg-filename] @@ -229,6 +248,36 @@ your `.pre-commit-config.yaml` file like so: You need to add `--msg-filename` at the end of your custom `args` list as the gitlint-hook will fail otherwise. +### gitlint and pre-commit in CI +gitlint also supports a `gitlint-ci` pre-commit hook that can be used in CI environments. + +Configure it like so: +```yaml +- repo: https://github.com/jorisroovers/gitlint + rev: # insert ref, e.g. v0.18.0 + hooks: + - id: gitlint # this is the regular commit-msg hook + - id: gitlint-ci # hook for CI environments +``` + +And invoke it in your CI environment like this: + +```sh +pre-commit run --hook-stage manual gitlint-ci +``` + +By default this will only lint the latest commit. +If you want to lint more commits you can modify the `gitlint-ci` hook like so: + +```yaml +- repo: https://github.com/jorisroovers/gitlint + rev: v0.17.0 + hooks: + - id: gitlint + - id: gitlint-ci + args: [--debug, --commits, mybranch] # enable debug mode, lint all commits in mybranch +``` + ## 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. @@ -248,33 +297,48 @@ 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 specific commits +## Linting specific commits or branches -Gitlint allows users to lint a specific commit: +Gitlint can lint specific commits using `--commit`: ```sh gitlint --commit 019cf40580a471a3958d3c346aa8bfd265fe5e16 -gitlint --commit 019cf40 # short SHAs work too +gitlint --commit 019cf40 # short SHAs work too +gitlint --commit HEAD~2 # as do special references +gitlint --commit mybranch # lint latest commit on a branch ``` -You can also lint multiple commits at once like so: +You can also lint multiple commits using `--commits` (plural): ```sh # Lint a specific commit range: gitlint --commits "019cf40...d6bc75a" -# You can also use git's special references: +# Lint all commits on a branch +gitlint --commits mybranch +# Lint all commits that are different between a branch and your main branch +gitlint --commits "main..mybranch" +# Use git's special references gitlint --commits "origin..HEAD" + +# You can also pass multiple, comma separated commit hashes: +gitlint --commits 019cf40,c50eb150,d6bc75a +# These can include special references as well +gitlint --commits HEAD~1,mybranch-name,origin/main,d6bc75a ``` 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. +Alternatively, you can pass `--commits` a comma-separated list of commit hashes (both short and full-length SHAs work, +as well as special references such as `HEAD` and branch names). +Gitlint will treat these as pointers to **single** commits and lint these in the order you passed. + For cases where the `--commits` option doesn't provide the flexibility you need, you can always use a simple shell script to lint an arbitrary set of commits, like shown in the example below. ```sh #!/bin/sh -for commit in $(git rev-list master); do +for commit in $(git rev-list my-branch); do echo "Commit $commit" gitlint --commit $commit echo "--------" @@ -283,14 +347,14 @@ 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 + This means you'll incur the gitlint startup time once per commit, making it 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, squash and revert commits -_Introduced in gitlint v0.7.0 (merge), v0.9.0 (fixup, squash) and v0.13.0 (revert)_ +_Introduced in gitlint v0.7.0 (merge), v0.9.0 (fixup, squash), v0.13.0 (revert) and v0.18.0 (fixup=amend)_ -**Gitlint ignores merge, revert, fixup and squash commits by default.** +**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]"*). @@ -300,14 +364,14 @@ 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 +For [squash](https://git-scm.com/docs/git-commit#git-commit---squashltcommitgt) and [fixup](https://git-scm.com/docs/git-commit#git-commit---fixupltcommitgt) (including [fixup=amend](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt)) 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 +short-lived and not intended to make it into the final commit history. In addition, by prepending *"fixup!"*, +*"amend!"* 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 +general `ignore-merge-commits`, `ignore-revert-commits`, `ignore-fixup-commits`, `ignore-fixup-amend-commits` or `ignore-squash-commits` option to `false` [using one of the various ways to configure gitlint](configuration.md). @@ -374,7 +438,7 @@ additional unique identifier (i.e. the rule *name*) during configuration. For example, by defining 2 `body-max-line-length` rules with different `line-length` options, you obviously create a conflicting situation. Gitlint does not do any resolution of such conflicts, it's up to you to make sure any configuration is non-conflicting. So caution advised! - + Defining a named rule is easy, for example using your `.gitlint` file: ```ini @@ -400,7 +464,7 @@ When executing gitlint, you will see the violations from the default `title-must the violations caused by the additional Named Rules. ```sh -$ gitlint +$ gitlint 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: foo wonderwoman hur bar" 1: T5:This-Can_Be*Whatever$YouWant Title contains the word 'wonderwoman' (case-insensitive): "WIP: foo wonderwoman hur bar" 1: T5:extra-words Title contains the word 'foo' (case-insensitive): "WIP: foo wonderwoman hur bar" @@ -431,8 +495,8 @@ of violations counted by the exit code is 252. Note that gitlint does not have a 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 +| Exit Code | Description | +| --------- | ------------------------------------------ | +| 253 | Wrong invocation of the `gitlint` command. | +| 254 | Something went wrong when invoking git. | +| 255 | Invalid gitlint configuration | diff --git a/docs/rules.md b/docs/rules.md index eb4b65e..a992f26 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -8,43 +8,43 @@ In addition, you can also [write your own user-defined rule](user_defined_rules. what you're looking for. -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: None) -T8 | title-min-length | >= 0.14.0 | Title length must be > 5 chars. -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 -B8 | body-match-regex | >= 0.14.0 | Title must match a given regex (default: None) -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 -I3 | ignore-body-lines | >= 0.14.0 | Ignore certain lines in a commit body that match a regex -I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name +| 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: None) | +| T8 | title-min-length | >= 0.14.0 | Title length must be >= 5 chars. | +| 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 | +| B8 | body-match-regex | >= 0.14.0 | Body must match a given regex (default: None) | +| 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 | +| I3 | ignore-body-lines | >= 0.14.0 | Ignore certain lines in a commit body that match a regex | +| I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name | ## T1: title-max-length -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -T1 | title-max-length | >= 0.1 | Title length must be < 72 chars. +| 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 +| Name | gitlint version | Default | Description | +| ----------- | --------------- | ------- | ---------------------------- | +| line-length | >= 0.2 | 72 | Maximum allowed title length | ### Examples @@ -59,39 +59,43 @@ line-length=72 [title-max-length] line-length=120 ``` +------------------------------------------------------------------------------------------------------------------------ ## T2: title-trailing-whitespace -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -T2 | title-trailing-whitespace | >= 0.1 | Title cannot have trailing whitespace (space or tab) +| 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 (?:!.,;) +| 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) +| 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") +| 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 +| 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 | ### Examples @@ -102,25 +106,28 @@ words | >= 0.3 | WIP | Comma-separated list of words that [title-must-not-contain-word] words=crap,darn,damn ``` +------------------------------------------------------------------------------------------------------------------------ ## T6: title-leading-whitespace -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -T6 | title-leading-whitespace | >= 0.4 | Title cannot have leading whitespace (space or tab) +| 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: .*) +| 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 regex](https://docs.python.org/library/re.html) that the title should match. +| Name | gitlint version | Default | Description | +| ----- | --------------- | ------- | ------------------------------------------------------------------------------------ | +| regex | >= 0.5 | .* | [Python regex](https://docs.python.org/library/re.html) that the title should match. | ### Examples @@ -131,19 +138,20 @@ regex | >= 0.5 | .* | [Python regex](https://docs.python. [title-match-regex] regex=^US[1-9][0-9]* ``` +------------------------------------------------------------------------------------------------------------------------ ## T8: title-min-length ## -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -T1 | title-min-length | >= | Title length must be > 5 chars. +| ID | Name | gitlint version | Description | +| --- | ---------------- | --------------- | ----------------------------------- | +| T8 | title-min-length | >= 0.14.0 | Title length must be >= 5 chars. | ### Options -Name | gitlint version | Default | Description ----------------|-----------------|---------|---------------------------------- -min-length | >= 0.14.0 | 5 | Minimum required title length +| Name | gitlint version | Default | Description | +| ---------- | --------------- | ------- | ----------------------------- | +| min-length | >= 0.14.0 | 5 | Minimum required title length | ### Examples @@ -154,18 +162,19 @@ min-length | >= 0.14.0 | 5 | Minimum required title length [title-min-length] min-length=3 ``` +------------------------------------------------------------------------------------------------------------------------ ## 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 +| 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 +| Name | gitlint version | Default | Description | +| ----------- | --------------- | ------- | ------------------------------------------------------ | +| line-length | >= 0.2 | 80 | Maximum allowed line length in the commit message body | ### Examples @@ -180,38 +189,43 @@ line-length=120 [body-max-line-length] line-length=72 ``` +------------------------------------------------------------------------------------------------------------------------ ## B2: body-trailing-whitespace -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -B2 | body-trailing-whitespace | >= 0.1 | Body cannot have trailing whitespace (space or tab) +| 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) +| 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 +| 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. +| 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 +| Name | gitlint version | Default | Description | +| ---------- | --------------- | ------- | --------------------------------------------- | +| min-length | >= 0.4 | 20 | Minimum number of required characters in body | ### Examples @@ -226,31 +240,34 @@ min-length=5 [body-min-length] min-length=100 ``` +------------------------------------------------------------------------------------------------------------------------ ## B6: body-is-missing -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -B6 | body-is-missing | >= 0.4 | Body message must be specified +| 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. +| 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 +| 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. +| 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. | ### Examples @@ -262,18 +279,19 @@ files | >= 0.4 | (empty) | Comma-separated list o [body-changed-file-mention] files=generated.xml,secrets.txt,private-key.pem ``` +------------------------------------------------------------------------------------------------------------------------ ## B8: body-match-regex -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -B8 | body-match-regex | >= 0.14 | Body must match a given regex +| ID | Name | gitlint version | Description | +| --- | ---------------- | --------------- | ----------------------------- | +| B8 | body-match-regex | >= 0.14 | Body must match a given regex | ### Options -Name | gitlint version | Default | Description -----------------------|-----------------|--------------|---------------------------------- -regex | >= 0.14 | None | [Python regex](https://docs.python.org/library/re.html) that the title should match. +| Name | gitlint version | Default | Description | +| ----- | --------------- | ------- | ----------------------------------------------------------------------------------- | +| regex | >= 0.14 | None | [Python regex](https://docs.python.org/library/re.html) that the body should match. | ### Examples @@ -288,12 +306,13 @@ regex=Reviewed-By:(.*)$ [body-match-regex] regex=(*.)Foo(.*) ``` +------------------------------------------------------------------------------------------------------------------------ ## 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 +| 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). @@ -303,9 +322,9 @@ M1 | author-valid-email | >= 0.8.3 | Author email address mus ### Options -Name | gitlint version | Default | Description -----------------------|-------------------|------------------------------|---------------------------------- -regex | >= 0.9.0 | `[^@ ]+@[^@ ]+\.[^@ ]+` | [Python regex](https://docs.python.org/library/re.html) the commit author email address is matched against +| Name | gitlint version | Default | Description | +| ----- | --------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- | +| regex | >= 0.9.0 | `[^@ ]+@[^@ ]+\.[^@ ]+` | [Python regex](https://docs.python.org/library/re.html) the commit author email address is matched against | ### Examples @@ -317,20 +336,21 @@ regex | >= 0.9.0 | `[^@ ]+@[^@ ]+\.[^@ ]+` | [Python [author-valid-email] regex=[^@]+@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. +| 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 | [Python regex](https://docs.python.org/library/re.html) to match against commit title. On match, the commit will be ignored. -ignore | >= 0.10.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. +| Name | gitlint version | Default | Description | +| ------ | --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | +| regex | >= 0.10.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against commit title. On match, the commit will be ignored. | +| ignore | >= 0.10.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. | ### Examples @@ -345,20 +365,21 @@ 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. +| 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 | [Python regex](https://docs.python.org/library/re.html) to match against each line of the body. On match, the commit will be ignored. -ignore | >= 0.10.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. +| Name | gitlint version | Default | Description | +| ------ | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| regex | >= 0.10.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against each line of the body. On match, the commit will be ignored. | +| ignore | >= 0.10.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. | ### Examples @@ -376,19 +397,20 @@ ignore=all regex=(.*)release(.*) ignore=T1,body-min-length,B6 ``` +------------------------------------------------------------------------------------------------------------------------ ## I3: ignore-body-lines -ID | Name | gitlint version | Description -------|-----------------------------|-----------------|------------------------------------------- -I3 | ignore-body-lines | >= 0.14.0 | Ignore certain lines in a commit body that match a regex. +| ID | Name | gitlint version | Description | +| --- | ----------------- | --------------- | --------------------------------------------------------- | +| I3 | ignore-body-lines | >= 0.14.0 | Ignore certain lines in a commit body that match a regex. | ### Options -Name | gitlint version | Default | Description -----------------------|-------------------|------------------------------|---------------------------------- -regex | >= 0.14.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against each line of the body. On match, that line will be ignored by gitlint (the rest of the body will still be linted). +| Name | gitlint version | Default | Description | +| ----- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| regex | >= 0.14.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against each line of the body. On match, that line will be ignored by gitlint (the rest of the body will still be linted). | ### Examples @@ -407,19 +429,20 @@ regex=(^Co-Authored-By)|(^Signed-off-by) [ignore-body-lines] regex=(.*)foobar(.*) ``` +------------------------------------------------------------------------------------------------------------------------ ## I4: ignore-by-author-name -ID | Name | gitlint version | Description -------|---------------------------|-----------------|------------------------------------------- -I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name. +| ID | Name | gitlint version | Description | +| --- | --------------------- | --------------- | -------------------------------------------------- | +| I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name. | ### Options -Name | gitlint version | Default | Description -----------------------|-------------------|------------------------------|---------------------------------- -regex | >= 0.16.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against the commit author name. On match, the commit will be ignored. -ignore | >= 0.16.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. +| Name | gitlint version | Default | Description | +| ------ | --------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| regex | >= 0.16.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against the commit author name. On match, the commit will be ignored. | +| ignore | >= 0.16.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched. | ### Examples @@ -435,4 +458,4 @@ regex=dependabot [ignore-by-author-name] regex=(.*)\[bot\](.*) ignore=T1,body-min-length,B6 -```
\ No newline at end of file +``` diff --git a/docs/user_defined_rules.md b/docs/user_defined_rules.md index f58dcc7..db21809 100644 --- a/docs/user_defined_rules.md +++ b/docs/user_defined_rules.md @@ -179,27 +179,33 @@ Both `CommitRule`s and `LineRule`s take a `commit` object in their `validate(... 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. +| Property | Type | Description | +| -------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| commit | `GitCommit` | Python object representing the commit | +| commit.message | `GitCommitMessage` | Python object representing the commit message | +| commit.message.original | `str` | Original commit message as returned by git | +| commit.message.full | `str` | Full commit message, with comments (lines starting with #) removed. | +| commit.message.title | `str` | Title/subject of the commit message: the first line | +| commit.message.body | `str[]` | List of lines in the body of the commit message (i.e. starting from the second line) | +| commit.author_name | `str` | Name of the author, result of `git log --pretty=%aN` | +| commit.author_email | `str` | Email of the author, result of `git log --pretty=%aE` | +| commit.date | `datetime.datetime` | Python `datetime` object representing the time of commit | +| commit.is_merge_commit | `bool` | Boolean indicating whether the commit is a merge commit or not. | +| commit.is_revert_commit | `bool` | Boolean indicating whether the commit is a revert commit or not. | +| commit.is_fixup_commit | `bool` | Boolean indicating whether the commit is a fixup commit or not. | +| commit.is_fixup_amend_commit | `bool` | Boolean indicating whether the commit is a (fixup) amend commit or not. | +| commit.is_squash_commit | `bool` | Boolean indicating whether the commit is a squash commit or not. | +| commit.parents | `str[]` | List of parent commit `sha`s (only for merge commits). | +| commit.changed_files | `str[]` | List of files changed in the commit (relative paths). | +| commit.changed_files_stats | `dict[str, GitChangedFilesStats]` | Dictionary mapping the changed files to a `GitChangedFilesStats` objects | +| commit.changed_files_stats["path"].filepath | `pathlib.Path` | Relative path (compared to repo root) of the file that was changed. | +| commit.changed_files_stats["path"].additions | `int` | Number of additions in the file. | +| commit.changed_files_stats["path"].deletions | `int` | Number of deletions in the file. | +| commit.branches | `str[]` | List of branch names the commit is part of | +| commit.context | `GitContext` | Object pointing to the bigger git context that the commit is part of | +| commit.context.current_branch | `str` | Name of the currently active branch (of local repo) | +| commit.context.repository_path | `str` | Absolute path pointing to the git repository being linted | +| commit.context.commits | `GitCommit[]` | 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(...)` @@ -216,12 +222,12 @@ 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.** +| Parameter | Type | Description | +| --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| rule_id | `str` | Rule's unique string id | +| message | `str` | Short description of the violation | +| content | `str` | (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 @@ -281,14 +287,14 @@ As `options_spec` is a list, you can obviously have multiple options per rule. T 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`). -`RegexOption` | String representing a [Python-style regex](https://docs.python.org/library/re.html) - compiled and validated before rules are applied. +| 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`). | +| `RegexOption` | String representing a [Python-style regex](https://docs.python.org/library/re.html) - compiled and validated before rules are applied. | !!! note Gitlint currently does not support options for all possible types (e.g. float, list of int, etc). diff --git a/examples/my_commit_rules.py b/examples/my_commit_rules.py index 2805501..35bb836 100644 --- a/examples/my_commit_rules.py +++ b/examples/my_commit_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import CommitRule, RuleViolation from gitlint.options import IntOption, ListOption @@ -27,20 +25,20 @@ class BodyMaxLineCount(CommitRule): 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")] + options_spec = [IntOption("max-line-count", 3, "Maximum body line count")] def validate(self, commit): self.log.debug("BodyMaxLineCount: This will be visible when running `gitlint --debug`") line_count = len(commit.message.body) - max_line_count = self.options['max-line-count'].value + max_line_count = self.options["max-line-count"].value if line_count > max_line_count: message = f"Body contains too many lines ({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. + """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". """ @@ -61,8 +59,8 @@ class SignedOffBy(CommitRule): 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/ + """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 @@ -72,13 +70,13 @@ class BranchNamingConventions(CommitRule): 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")] + options_spec = [ListOption("branch-prefixes", ["feature/", "hotfix/", "release/"], "Allowed branch prefixes")] def validate(self, commit): self.log.debug("BranchNamingConventions: This line will be visible when running `gitlint --debug`") violations = [] - allowed_branch_prefixes = self.options['branch-prefixes'].value + allowed_branch_prefixes = self.options["branch-prefixes"].value for branch in commit.branches: valid_branch_name = False diff --git a/examples/my_configuration_rules.py b/examples/my_configuration_rules.py index 7c00707..7715c0b 100644 --- a/examples/my_configuration_rules.py +++ b/examples/my_configuration_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import ConfigurationRule from gitlint.options import IntOption @@ -36,7 +34,7 @@ class ReleaseConfigurationRule(ConfigurationRule): id = "UCR1" # A rule MAY have an option_spec if its behavior should be configurable. - options_spec = [IntOption('custom-verbosity', 2, "Gitlint verbosity for release commits")] + options_spec = [IntOption("custom-verbosity", 2, "Gitlint verbosity for release commits")] def apply(self, config, commit): self.log.debug("ReleaseConfigurationRule: This will be visible when running `gitlint --debug`") @@ -44,7 +42,6 @@ class ReleaseConfigurationRule(ConfigurationRule): # If the commit title starts with 'Release', we want to modify # how all subsequent rules interpret that commit if commit.message.title.startswith("Release"): - # If your Release commit messages are auto-generated, the # body might contain trailing whitespace. Let's ignore that config.ignore.append("body-trailing-whitespace") @@ -60,7 +57,7 @@ class ReleaseConfigurationRule(ConfigurationRule): # config.set_general_option(<general-option>, <value>) config.set_general_option("verbosity", 2) # Wwe can also use custom options to make this configurable - config.set_general_option("verbosity", self.options['custom-verbosity'].value) + config.set_general_option("verbosity", self.options["custom-verbosity"].value) # Strip any lines starting with $ from the commit message # (this only affects how gitlint sees your commit message, it does diff --git a/examples/my_line_rules.py b/examples/my_line_rules.py index 3a1ef36..58b0108 100644 --- a/examples/my_line_rules.py +++ b/examples/my_line_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle from gitlint.options import ListOption @@ -21,8 +19,8 @@ that fits your needs. class SpecialChars(LineRule): - """ This rule will enforce that the commit message title does not contain any of the following characters: - $^%@!*() """ + """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" @@ -35,15 +33,20 @@ class SpecialChars(LineRule): 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")] + options_spec = [ + ListOption( + "special-chars", + ["$", "^", "%", "@", "!", "*", "(", ")"], + "Comma separated list of characters that should not occur in the title", + ) + ] def validate(self, line, _commit): self.log.debug("SpecialChars: This will be visible when running `gitlint --debug`") violations = [] # options can be accessed by looking them up by their name in self.options - for char in self.options['special-chars'].value: + for char in self.options["special-chars"].value: if char in line: msg = f"Title contains the special character '{char}'" violation = RuleViolation(self.id, msg, line) diff --git a/gitlint-core/gitlint/__init__.py b/gitlint-core/gitlint/__init__.py index fd86b3e..1317d75 100644 --- a/gitlint-core/gitlint/__init__.py +++ b/gitlint-core/gitlint/__init__.py @@ -1 +1 @@ -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/gitlint-core/gitlint/cache.py b/gitlint-core/gitlint/cache.py index 1b6558f..b84c904 100644 --- a/gitlint-core/gitlint/cache.py +++ b/gitlint-core/gitlint/cache.py @@ -1,31 +1,31 @@ class PropertyCache: - """ Mixin class providing a simple cache. """ + """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. """ + """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): # pylint: disable=unused-argument - """ 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): - ... + """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, see some of the links below for details. @@ -41,6 +41,7 @@ def cache(original_func=None, cachekey=None): # pylint: disable=unused-argument def cache_func_result(): # Call decorated function and store its result in the cache args[0]._cache[cachekey] = func(*args) + return args[0]._try_cache(cachekey, cache_func_result) return wrapped diff --git a/gitlint-core/gitlint/cli.py b/gitlint-core/gitlint/cli.py index 19676b3..387072e 100644 --- a/gitlint-core/gitlint/cli.py +++ b/gitlint-core/gitlint/cli.py @@ -11,6 +11,7 @@ import click import gitlint from gitlint.lint import GitLinter from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator +from gitlint.deprecation import LOG as DEPRECATED_LOG, DEPRECATED_LOG_FORMAT from gitlint.git import GitContext, GitContextError, git_version from gitlint import hooks from gitlint.shell import shell @@ -37,19 +38,29 @@ LOG = logging.getLogger("gitlint.cli") class GitLintUsageError(GitlintError): - """ Exception indicating there is an issue with how gitlint is used. """ + """Exception indicating there is an issue with how gitlint is used.""" + pass def setup_logging(): - """ Setup gitlint logging """ + """Setup gitlint logging""" + + # Root log, mostly used for debug root_log = logging.getLogger("gitlint") root_log.propagate = False # Don't propagate to child loggers, the gitlint root logger handles everything + root_log.setLevel(logging.ERROR) handler = logging.StreamHandler() formatter = logging.Formatter(LOG_FORMAT) handler.setFormatter(formatter) root_log.addHandler(handler) - root_log.setLevel(logging.ERROR) + + # Deprecated log, to log deprecation warnings + DEPRECATED_LOG.propagate = False # Don't propagate to child logger + DEPRECATED_LOG.setLevel(logging.WARNING) + deprecated_log_handler = logging.StreamHandler() + deprecated_log_handler.setFormatter(logging.Formatter(DEPRECATED_LOG_FORMAT)) + DEPRECATED_LOG.addHandler(deprecated_log_handler) def log_system_info(): @@ -62,10 +73,20 @@ def log_system_info(): def build_config( # pylint: disable=too-many-arguments - target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, fail_without_commits, verbose, - silent, debug + target, + config_path, + c, + extra_path, + ignore, + contrib, + ignore_stdin, + staged, + fail_without_commits, + verbose, + silent, + debug, ): - """ Creates a LintConfig object based on a set of commandline parameters. """ + """Creates a LintConfig object based on a set of commandline parameters.""" config_builder = LintConfigBuilder() # Config precedence: # First, load default config or config from configfile @@ -79,33 +100,33 @@ def build_config( # pylint: disable=too-many-arguments # Finally, overwrite with any convenience commandline flags if ignore: - config_builder.set_option('general', 'ignore', ignore) + config_builder.set_option("general", "ignore", ignore) if contrib: - config_builder.set_option('general', 'contrib', contrib) + config_builder.set_option("general", "contrib", contrib) if ignore_stdin: - config_builder.set_option('general', 'ignore-stdin', ignore_stdin) + config_builder.set_option("general", "ignore-stdin", ignore_stdin) if silent: - config_builder.set_option('general', 'verbosity', 0) + config_builder.set_option("general", "verbosity", 0) elif verbose > 0: - config_builder.set_option('general', 'verbosity', verbose) + config_builder.set_option("general", "verbosity", verbose) if extra_path: - config_builder.set_option('general', 'extra-path', extra_path) + config_builder.set_option("general", "extra-path", extra_path) if target: - config_builder.set_option('general', 'target', target) + config_builder.set_option("general", "target", target) if debug: - config_builder.set_option('general', 'debug', debug) + config_builder.set_option("general", "debug", debug) if staged: - config_builder.set_option('general', 'staged', staged) + config_builder.set_option("general", "staged", staged) if fail_without_commits: - config_builder.set_option('general', 'fail-without-commits', fail_without_commits) + config_builder.set_option("general", "fail-without-commits", fail_without_commits) config = config_builder.build() @@ -113,7 +134,7 @@ def build_config( # pylint: disable=too-many-arguments def get_stdin_data(): - """ Helper function that returns data send to stdin or False if nothing is send """ + """Helper function that returns data sent to stdin or False if nothing is sent""" # 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) @@ -145,13 +166,17 @@ def get_stdin_data(): def build_git_context(lint_config, msg_filename, commit_hash, refspec): - """ Builds a git context based on passed parameters and order of precedence """ + """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 + from_commit_msg = ( + lambda message: GitContext.from_staged_commit( # pylint: disable=unnecessary-lambda-assignment + message, lint_config.target + ) + ) # Order of precedence: # 1. Any data specified via --msg-filename @@ -168,8 +193,10 @@ def build_git_context(lint_config, msg_filename, commit_hash, refspec): return from_commit_msg(stdin_input) if lint_config.staged: - raise GitLintUsageError("The 'staged' option (--staged) can only be used when using '--msg-filename' or " - "when piping data to gitlint via stdin.") + raise GitLintUsageError( + "The 'staged' option (--staged) can only be used when using '--msg-filename' or " + "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.") @@ -177,11 +204,25 @@ def build_git_context(lint_config, msg_filename, commit_hash, refspec): if commit_hash and refspec: raise GitLintUsageError("--commit and --commits are mutually exclusive, use one or the other.") - return GitContext.from_local_repository(lint_config.target, refspec=refspec, commit_hash=commit_hash) + # 3.1 Linting a range of commits + if refspec: + # 3.1.1 Not real refspec, but comma-separated list of commit hashes + if "," in refspec: + commit_hashes = [hash.strip() for hash in refspec.split(",")] + return GitContext.from_local_repository(lint_config.target, commit_hashes=commit_hashes) + # 3.1.2 Real refspec + return GitContext.from_local_repository(lint_config.target, refspec=refspec) + + # 3.2 Linting a specific commit + if commit_hash: + return GitContext.from_local_repository(lint_config.target, commit_hashes=[commit_hash]) + + # 3.3 Fallback to linting the current HEAD + return GitContext.from_local_repository(lint_config.target) def handle_gitlint_error(ctx, exc): - """ Helper function to handle exceptions """ + """Helper function to handle exceptions""" if isinstance(exc, GitContextError): click.echo(exc) ctx.exit(GIT_CONTEXT_ERROR_CODE) @@ -194,7 +235,7 @@ def handle_gitlint_error(ctx, exc): class ContextObj: - """ Simple class to hold data that is passed between Click commands via the Click context. """ + """Simple class to hold data that is passed between Click commands via the Click context.""" def __init__(self, config, config_builder, commit_hash, refspec, msg_filename, gitcontext=None): self.config = config @@ -205,29 +246,34 @@ class ContextObj: self.gitcontext = gitcontext +# fmt: off @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', envvar='GITLINT_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), +@click.option('-C', '--config', envvar='GITLINT_CONFIG', + type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True), help=f"Config file location [default: {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('--commit', envvar='GITLINT_COMMIT', default=None, help="Hash (SHA) of specific commit to lint.") -@click.option('--commits', envvar='GITLINT_COMMITS', default=None, help="The range of commits to lint. [default: HEAD]") +@click.option('--commits', envvar='GITLINT_COMMITS', default=None, + help="The range of commits (refspec or comma-separated hashes) to lint. [default: HEAD]") @click.option('-e', '--extra-path', envvar='GITLINT_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', envvar='GITLINT_IGNORE', default="", help="Ignore rules (comma-separated by id or name).") @click.option('--contrib', envvar='GITLINT_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('--msg-filename', type=click.File(encoding=gitlint.utils.DEFAULT_ENCODING), + help="Path to a file containing a commit-msg.") @click.option('--ignore-stdin', envvar='GITLINT_IGNORE_STDIN', is_flag=True, help="Ignore any stdin data. Useful for running in CI server.") @click.option('--staged', envvar='GITLINT_STAGED', is_flag=True, - help="Read staged commit meta-info from the local repository.") + help="Attempt smart guesses about meta info (like author name, email, branch, changed files, etc) " + + "for staged commits.") @click.option('--fail-without-commits', envvar='GITLINT_FAIL_WITHOUT_COMMITS', is_flag=True, help="Hard fail when the target commit range is empty.") @click.option('-v', '--verbose', envvar='GITLINT_VERBOSITY', count=True, default=0, @@ -246,18 +292,18 @@ def cli( # pylint: disable=too-many-arguments Documentation: http://jorisroovers.github.io/gitlint """ - try: if debug: logging.getLogger("gitlint").setLevel(logging.DEBUG) + DEPRECATED_LOG.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, - fail_without_commits, verbose, silent, debug) + config, config_builder = build_config(target, config, c, extra_path, ignore, contrib, ignore_stdin, + staged, fail_without_commits, verbose, silent, debug) LOG.debug("Configuration\n%s", config) ctx.obj = ContextObj(config, config_builder, commit, commits, msg_filename) @@ -268,12 +314,13 @@ def cli( # pylint: disable=too-many-arguments except GitlintError as e: handle_gitlint_error(ctx, e) +# fmt: on @cli.command("lint") @click.pass_context def lint(ctx): - """ Lints a git repository [default command] """ + """Lints a git repository [default command]""" lint_config = ctx.obj.config refspec = ctx.obj.refspec commit_hash = ctx.obj.commit_hash @@ -295,7 +342,7 @@ def lint(ctx): raise GitLintUsageError(f'No commits in range "{refspec}"') ctx.exit(GITLINT_SUCCESS) - LOG.debug('Linting %d commit(s)', number_of_commits) + LOG.debug("Linting %d commit(s)", number_of_commits) general_config_builder = ctx.obj.config_builder last_commit = gitcontext.commits[-1] @@ -334,7 +381,7 @@ def lint(ctx): @cli.command("install-hook") @click.pass_context def install_hook(ctx): - """ Install gitlint as a git commit-msg hook. """ + """Install gitlint as a git commit-msg hook.""" try: hooks.GitHookInstaller.install_commit_msg_hook(ctx.obj.config) hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config) @@ -348,7 +395,7 @@ def install_hook(ctx): @cli.command("uninstall-hook") @click.pass_context def uninstall_hook(ctx): - """ Uninstall gitlint commit-msg hook. """ + """Uninstall gitlint commit-msg hook.""" try: hooks.GitHookInstaller.uninstall_commit_msg_hook(ctx.obj.config) hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config) @@ -362,7 +409,7 @@ def uninstall_hook(ctx): @cli.command("run-hook") @click.pass_context def run_hook(ctx): - """ Runs the gitlint commit-msg hook. """ + """Runs the gitlint commit-msg hook.""" exit_code = 1 while exit_code > 0: @@ -378,16 +425,18 @@ def run_hook(ctx): exit_code = e.exit_code if exit_code == GITLINT_SUCCESS: - click.echo("gitlint: " + click.style("OK", fg='green') + " (no violations in commit message)") + click.echo("gitlint: " + click.style("OK", fg="green") + " (no violations in commit message)") continue click.echo("-----------------------------------------------") - click.echo("gitlint: " + click.style("Your commit message contains violations.", fg='red')) + click.echo("gitlint: " + click.style("Your commit message contains violations.", fg="red")) value = None while value not in ["y", "n", "e"]: - click.echo("Continue with commit anyways (this keeps the current commit message)? " - "[y(es)/n(no)/e(dit)] ", nl=False) + click.echo( + "Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] ", + nl=False, + ) # Ideally, we'd want to use click.getchar() or click.prompt() to get user's input here instead of # input(). However, those functions currently don't support getting answers from stdin. @@ -431,15 +480,15 @@ def run_hook(ctx): @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) + """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(f"Error: Directory '{dir_name}' does not exist.", err=True) ctx.exit(USAGE_ERROR_CODE) elif os.path.exists(path): - click.echo(f"Error: File \"{path}\" already exists.", err=True) + click.echo(f'Error: File "{path}" already exists.', err=True) ctx.exit(USAGE_ERROR_CODE) LintConfigGenerator.generate_config(path) diff --git a/gitlint-core/gitlint/config.py b/gitlint-core/gitlint/config.py index 49c380a..f038d4a 100644 --- a/gitlint-core/gitlint/config.py +++ b/gitlint-core/gitlint/config.py @@ -1,7 +1,6 @@ from configparser import ConfigParser, Error as ConfigParserError import copy -import io import re import os import shutil @@ -16,8 +15,8 @@ from gitlint.exception import GitlintError def handle_option_error(func): - """ Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a - LintConfigError. """ + """Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a + LintConfigError.""" def wrapped(*args): try: @@ -32,53 +31,62 @@ class LintConfigError(GitlintError): pass -class LintConfig: - """ Class representing gitlint configuration. - Contains active config as well as number of methods to easily get/set the config. +class LintConfig: # pylint: disable=too-many-instance-attributes + """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.IgnoreBodyLines, - rules.IgnoreByAuthorName, - rules.TitleMaxLength, - rules.TitleTrailingWhitespace, - rules.TitleLeadingWhitespace, - rules.TitleTrailingPunctuation, - rules.TitleHardTab, - rules.TitleMustNotContainWord, - rules.TitleRegexMatches, - rules.TitleMinLength, - rules.BodyMaxLineLength, - rules.BodyMinLength, - rules.BodyMissing, - rules.BodyTrailingWhitespace, - rules.BodyHardTab, - rules.BodyFirstLineEmpty, - rules.BodyChangedFileMention, - rules.BodyRegexMatches, - rules.AuthorValidEmail) + default_rule_classes = ( + rules.IgnoreByTitle, + rules.IgnoreByBody, + rules.IgnoreBodyLines, + rules.IgnoreByAuthorName, + rules.TitleMaxLength, + rules.TitleTrailingWhitespace, + rules.TitleLeadingWhitespace, + rules.TitleTrailingPunctuation, + rules.TitleHardTab, + rules.TitleMustNotContainWord, + rules.TitleRegexMatches, + rules.TitleMinLength, + rules.BodyMaxLineLength, + rules.BodyMinLength, + rules.BodyMissing, + rules.BodyTrailingWhitespace, + rules.BodyHardTab, + rules.BodyFirstLineEmpty, + rules.BodyChangedFileMention, + rules.BodyRegexMatches, + 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._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_fixup_amend_commits = options.BoolOption( + "ignore-fixup-amend-commits", True, "Ignore fixup amend 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._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.") - self._fail_without_commits = options.BoolOption('fail-without-commits', False, - "Hard fail when the target commit range is empty") + 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.") + self._fail_without_commits = options.BoolOption( + "fail-without-commits", False, "Hard fail when the target commit range is empty" + ) + self._regex_style_search = options.BoolOption( + "regex-style-search", False, "Use `search` instead of `match` semantics for regex rules" + ) @property def target(self): @@ -119,6 +127,15 @@ class LintConfig: return self._ignore_fixup_commits.set(value) @property + def ignore_fixup_amend_commits(self): + return self._ignore_fixup_amend_commits.value + + @ignore_fixup_amend_commits.setter + @handle_option_error + def ignore_fixup_amend_commits(self, value): + return self._ignore_fixup_amend_commits.set(value) + + @property def ignore_squash_commits(self): return self._ignore_squash_commits.value @@ -183,6 +200,15 @@ class LintConfig: return self._fail_without_commits.set(value) @property + def regex_style_search(self): + return self._regex_style_search.value + + @regex_style_search.setter + @handle_option_error + def regex_style_search(self, value): + return self._regex_style_search.set(value) + + @property def extra_path(self): return self._extra_path.value if self._extra_path else None @@ -193,9 +219,7 @@ class LintConfig: 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' + "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 @@ -203,7 +227,7 @@ class LintConfig: # 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}) + self.rules.add_rules(rule_classes, {"is_user_defined": True}) except (options.RuleOptionError, rules.UserRuleError) as e: raise LintConfigError(str(e)) from e @@ -226,12 +250,11 @@ class LintConfig: # 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 - rule_id_or_name in (rc.id, rc.name)), False) + rule_class = next((rc for rc in rule_classes if rule_id_or_name in (rc.id, rc.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}) + self.rules.add_rule(rule_class, rule_class.id, {"is_contrib": True}) else: raise LintConfigError(f"No contrib rule with id or name '{rule_id_or_name}' found.") @@ -250,14 +273,14 @@ class LintConfig: 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. """ + """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. """ + """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) @@ -275,45 +298,53 @@ class LintConfig: 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.fail_without_commits == other.fail_without_commits and \ - self.debug == other.debug and \ - self.ignore == other.ignore and \ - self._config_path == other._config_path # noqa + 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_fixup_amend_commits == other.ignore_fixup_amend_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.fail_without_commits == other.fail_without_commits + and self.regex_style_search == other.regex_style_search + and self.debug == other.debug + and self.ignore == other.ignore + and self._config_path == other._config_path + ) def __str__(self): # config-path is not a user exposed variable, so don't print it under the general section - return (f"config-path: {self._config_path}\n" - f"[GENERAL]\n" - f"extra-path: {self.extra_path}\n" - f"contrib: {self.contrib}\n" - f"ignore: {','.join(self.ignore)}\n" - f"ignore-merge-commits: {self.ignore_merge_commits}\n" - f"ignore-fixup-commits: {self.ignore_fixup_commits}\n" - f"ignore-squash-commits: {self.ignore_squash_commits}\n" - f"ignore-revert-commits: {self.ignore_revert_commits}\n" - f"ignore-stdin: {self.ignore_stdin}\n" - f"staged: {self.staged}\n" - f"fail-without-commits: {self.fail_without_commits}\n" - f"verbosity: {self.verbosity}\n" - f"debug: {self.debug}\n" - f"target: {self.target}\n" - f"[RULES]\n{self.rules}") + return ( + f"config-path: {self._config_path}\n" + "[GENERAL]\n" + f"extra-path: {self.extra_path}\n" + f"contrib: {self.contrib}\n" + f"ignore: {','.join(self.ignore)}\n" + f"ignore-merge-commits: {self.ignore_merge_commits}\n" + f"ignore-fixup-commits: {self.ignore_fixup_commits}\n" + f"ignore-fixup-amend-commits: {self.ignore_fixup_amend_commits}\n" + f"ignore-squash-commits: {self.ignore_squash_commits}\n" + f"ignore-revert-commits: {self.ignore_revert_commits}\n" + f"ignore-stdin: {self.ignore_stdin}\n" + f"staged: {self.staged}\n" + f"fail-without-commits: {self.fail_without_commits}\n" + f"regex-style-search: {self.regex_style_search}\n" + f"verbosity: {self.verbosity}\n" + f"debug: {self.debug}\n" + f"target: {self.target}\n" + f"[RULES]\n{self.rules}" + ) class RuleCollection: - """ Class representing an ordered list of rules. Methods are provided to easily retrieve, add or delete rules. """ + """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 @@ -329,13 +360,13 @@ class RuleCollection: 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 + """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 @@ -345,12 +376,12 @@ class RuleCollection: 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. """ + """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 """ + """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()]: # pylint: disable=unnecessary-comprehension @@ -358,8 +389,7 @@ class RuleCollection: del self._rules[rule.id] def __iter__(self): - for rule in self._rules.values(): - yield rule + yield from self._rules.values() def __eq__(self, other): return isinstance(other, RuleCollection) and self._rules == other._rules @@ -385,7 +415,7 @@ class RuleCollection: class LintConfigBuilder: - """ Factory class that can build gitlint config. + """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. @@ -403,19 +433,19 @@ class LintConfigBuilder: 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 + """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)) + 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. """ + """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) @@ -425,17 +455,18 @@ class LintConfigBuilder: self.set_option(rule_name, option_name, option_value) except ValueError as e: # raised if the config string is invalid raise LintConfigError( - f"'{config_option}' is an invalid configuration option. Use '<rule>.<option>=<value>'") from e + f"'{config_option}' is an invalid configuration option. Use '<rule>.<option>=<value>'" + ) from e def set_from_config_file(self, filename): - """ Loads lint config from an ini-style config file """ + """Loads lint config from an ini-style config file""" if not os.path.exists(filename): raise LintConfigError(f"Invalid file path: {filename}") self._config_path = os.path.realpath(filename) try: parser = ConfigParser() - with io.open(filename, encoding=DEFAULT_ENCODING) as config_file: + with open(filename, encoding=DEFAULT_ENCODING) as config_file: parser.read_file(config_file, filename) for section_name in parser.sections(): @@ -446,8 +477,8 @@ class LintConfigBuilder: raise LintConfigError(str(e)) from e def _add_named_rule(self, config, qualified_rule_name): - """ Adds a Named Rule to a given LintConfig object. - IMPORTANT: This method does *NOT* overwrite existing Named Rules with the same canonical id. + """Adds a Named Rule to a given LintConfig object. + IMPORTANT: This method does *NOT* overwrite existing Named Rules with the same canonical id. """ # Split up named rule in its parts: the name/id that specifies the parent rule, @@ -475,13 +506,13 @@ class LintConfigBuilder: # Add the rule to the collection of rules if it's not there already if not config.rules.find_rule(canonical_id): - config.rules.add_rule(parent_rule.__class__, canonical_id, {'is_named': True, 'name': canonical_name}) + config.rules.add_rule(parent_rule.__class__, canonical_id, {"is_named": True, "name": canonical_name}) return canonical_id def build(self, config=None): - """ Build a real LintConfig object by normalizing and validating the options that were previously set on this - factory. """ + """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: @@ -490,7 +521,7 @@ class LintConfigBuilder: 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') + 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) @@ -499,7 +530,6 @@ class LintConfigBuilder: 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": - # If the section name contains a colon (:), then this section is defining a Named Rule # Which means we need to instantiate that Named Rule in the config. if self.RULE_QUALIFIER_SYMBOL in section_name: @@ -510,7 +540,7 @@ class LintConfigBuilder: return config def clone(self): - """ Creates an exact copy of a LintConfigBuilder. """ + """Creates an exact copy of a LintConfigBuilder.""" builder = LintConfigBuilder() builder._config_blueprint = copy.deepcopy(self._config_blueprint) builder._config_path = self._config_path @@ -523,6 +553,6 @@ GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath class LintConfigGenerator: @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. """ + """Generates a gitlint config file at the given destination location. + Expects that the given ```dest``` points to a valid destination.""" shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest) diff --git a/gitlint-core/gitlint/contrib/rules/authors_commit.py b/gitlint-core/gitlint/contrib/rules/authors_commit.py new file mode 100644 index 0000000..ce11663 --- /dev/null +++ b/gitlint-core/gitlint/contrib/rules/authors_commit.py @@ -0,0 +1,46 @@ +import re +from pathlib import Path +from typing import Tuple + + +from gitlint.rules import CommitRule, RuleViolation + + +class AllowedAuthors(CommitRule): + """Enforce that only authors listed in the AUTHORS file are allowed to commit.""" + + authors_file_names = ("AUTHORS", "AUTHORS.txt", "AUTHORS.md") + parse_authors = re.compile(r"^(?P<name>.*) <(?P<email>.*)>$", re.MULTILINE) + + name = "contrib-allowed-authors" + + id = "CC3" + + @classmethod + def _read_authors_from_file(cls, git_ctx) -> Tuple[str, str]: + for file_name in cls.authors_file_names: + path = Path(git_ctx.repository_path) / file_name + if path.exists(): + authors_file = path + break + else: + raise FileNotFoundError("No AUTHORS file found!") + + authors_file_content = authors_file.read_text("utf-8") + authors = re.findall(cls.parse_authors, authors_file_content) + + return set(authors), authors_file.name + + def validate(self, commit): + registered_authors, authors_file_name = AllowedAuthors._read_authors_from_file(commit.message.context) + + author = (commit.author_name, commit.author_email.lower()) + + if author not in registered_authors: + return [ + RuleViolation( + self.id, + f"Author not in '{authors_file_name}' file: " f'"{commit.author_name} <{commit.author_email}>"', + ) + ] + return [] diff --git a/gitlint-core/gitlint/contrib/rules/conventional_commit.py b/gitlint-core/gitlint/contrib/rules/conventional_commit.py index 9c9d5cb..705b083 100644 --- a/gitlint-core/gitlint/contrib/rules/conventional_commit.py +++ b/gitlint-core/gitlint/contrib/rules/conventional_commit.py @@ -7,7 +7,7 @@ RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+") class ConventionalCommit(LineRule): - """ This rule enforces the spec at https://www.conventionalcommits.org/. """ + """This rule enforces the spec at https://www.conventionalcommits.org/.""" name = "contrib-title-conventional-commits" id = "CT1" @@ -31,7 +31,7 @@ class ConventionalCommit(LineRule): else: line_commit_type = match.group(1) if line_commit_type not in self.options["types"].value: - opt_str = ', '.join(self.options['types'].value) + opt_str = ", ".join(self.options["types"].value) violations.append(RuleViolation(self.id, f"Title does not start with one of {opt_str}", line)) return violations diff --git a/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py b/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py new file mode 100644 index 0000000..7f62dee --- /dev/null +++ b/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py @@ -0,0 +1,22 @@ +from gitlint.rules import CommitRule, RuleViolation + + +class DisallowCleanupCommits(CommitRule): + """This rule checks the commits for "fixup!"/"squash!"/"amend!" commits + and rejects them. + """ + + name = "contrib-disallow-cleanup-commits" + id = "CC2" + + def validate(self, commit): + if commit.is_fixup_commit: + return [RuleViolation(self.id, "Fixup commits are not allowed", line_nr=1)] + + if commit.is_squash_commit: + return [RuleViolation(self.id, "Squash commits are not allowed", line_nr=1)] + + if commit.is_fixup_amend_commit: + return [RuleViolation(self.id, "Amend commits are not allowed", line_nr=1)] + + return [] diff --git a/gitlint-core/gitlint/contrib/rules/signedoff_by.py b/gitlint-core/gitlint/contrib/rules/signedoff_by.py index 139a1b1..5ea8217 100644 --- a/gitlint-core/gitlint/contrib/rules/signedoff_by.py +++ b/gitlint-core/gitlint/contrib/rules/signedoff_by.py @@ -1,9 +1,8 @@ - from gitlint.rules import CommitRule, RuleViolation class SignedOffBy(CommitRule): - """ This rule will enforce that each commit body contains a "Signed-off-by" line. + """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". """ diff --git a/gitlint-core/gitlint/deprecation.py b/gitlint-core/gitlint/deprecation.py new file mode 100644 index 0000000..bf13460 --- /dev/null +++ b/gitlint-core/gitlint/deprecation.py @@ -0,0 +1,40 @@ +import logging + + +LOG = logging.getLogger("gitlint.deprecated") +DEPRECATED_LOG_FORMAT = "%(levelname)s: %(message)s" + + +class Deprecation: + """Singleton class that handles deprecation warnings and behavior.""" + + # LintConfig class that is used to determine deprecation behavior + config = None + + # Set of warning messages that have already been logged, to prevent duplicate warnings + warning_msgs = set() + + @classmethod + def get_regex_method(cls, rule, regex_option): + """Returns the regex method to be used for a given rule based on general.regex-style-search option. + Logs a warning if the deprecated re.match method is returned.""" + + # if general.regex-style-search is set, just return re.search + if cls.config.regex_style_search: + return regex_option.value.search + + warning_msg = ( + f"{rule.id} - {rule.name}: gitlint will be switching from using Python regex 'match' (match beginning) to " + "'search' (match anywhere) semantics. " + f"Please review your {rule.name}.regex option accordingly. " + "To remove this warning, set general.regex-style-search=True. " + "More details: https://jorisroovers.github.io/gitlint/configuration/#regex-style-search" + ) + + # Only log warnings once + if warning_msg not in cls.warning_msgs: + log = logging.getLogger("gitlint.deprecated.regex_style_search") + log.warning(warning_msg) + cls.warning_msgs.add(warning_msg) + + return regex_option.value.match diff --git a/gitlint-core/gitlint/display.py b/gitlint-core/gitlint/display.py index c9bcb01..d21b6c3 100644 --- a/gitlint-core/gitlint/display.py +++ b/gitlint-core/gitlint/display.py @@ -2,14 +2,14 @@ from sys import stdout, stderr class Display: - """ Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity """ + """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. """ + """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") diff --git a/gitlint-core/gitlint/exception.py b/gitlint-core/gitlint/exception.py index aee3fe2..bcba54e 100644 --- a/gitlint-core/gitlint/exception.py +++ b/gitlint-core/gitlint/exception.py @@ -1,4 +1,4 @@ - class GitlintError(Exception): - """ Based Exception class for all gitlint exceptions """ + """Based Exception class for all gitlint exceptions""" + pass diff --git a/gitlint-core/gitlint/files/gitlint b/gitlint-core/gitlint/files/gitlint index c4d2122..3d9f273 100644 --- a/gitlint-core/gitlint/files/gitlint +++ b/gitlint-core/gitlint/files/gitlint @@ -14,13 +14,14 @@ # 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. +# By default gitlint will ignore merge, revert, fixup, fixup=amend, and squash commits. # ignore-merge-commits=true # ignore-revert-commits=true # ignore-fixup-commits=true +# ignore-fixup-amend-commits=true # ignore-squash-commits=true -# Ignore any data send to gitlint via stdin +# Ignore any data sent to gitlint via stdin # ignore-stdin=true # Fetch additional meta-data from the local repository when manually passing a @@ -33,6 +34,11 @@ # Disabled by default. # fail-without-commits=true +# Whether to use Python `search` instead of `match` semantics in rules that use +# regexes. Context: https://github.com/jorisroovers/gitlint/issues/254 +# Disabled by default, but will be enabled by default in the future. +# regex-style-search=true + # Enable debug mode (prints more output). Disabled by default. # debug=true diff --git a/gitlint-core/gitlint/git.py b/gitlint-core/gitlint/git.py index 2ac8b3d..4b292f0 100644 --- a/gitlint-core/gitlint/git.py +++ b/gitlint-core/gitlint/git.py @@ -1,9 +1,11 @@ import logging import os +from pathlib import Path 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 @@ -18,15 +20,17 @@ LOG = logging.getLogger(__name__) class GitContextError(GitlintError): - """ Exception indicating there is an issue with the git context """ + """Exception indicating there is an issue with the git context""" + pass class GitNotInstalledError(GitContextError): def __init__(self): super().__init__( - "'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.") + "'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." + ) class GitExitCodeError(GitContextError): @@ -37,8 +41,8 @@ class GitExitCodeError(GitContextError): def _git(*command_parts, **kwargs): - """ Convenience function for running git commands. Automatically deals with exceptions and unicode. """ - git_kwargs = {'_tty_out': False} + """Convenience function for running git commands. Automatically deals with exceptions and unicode.""" + git_kwargs = {"_tty_out": False} git_kwargs.update(kwargs) try: LOG.debug(command_parts) @@ -46,7 +50,7 @@ def _git(*command_parts, **kwargs): # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting # a non-zero exit code -> just return the entire result - if hasattr(result, 'exit_code') and result.exit_code > 0: + if hasattr(result, "exit_code") and result.exit_code > 0: return result return str(result) except CommandNotFound as e: @@ -54,11 +58,13 @@ def _git(*command_parts, **kwargs): 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: + if "_cwd" in git_kwargs and b"not a git repository" in error_msg_lower: raise GitContextError(f"{git_kwargs['_cwd']} is not a git repository.") from e - if (b"does not have any commits yet" in error_msg_lower or - b"ambiguous argument 'head': unknown revision" in error_msg_lower): + if ( + b"does not have any commits yet" in error_msg_lower + or b"ambiguous argument 'head': unknown revision" in error_msg_lower + ): msg = "Current branch has no commits. Gitlint requires at least one commit to function." raise GitContextError(msg) from e @@ -66,34 +72,54 @@ def _git(*command_parts, **kwargs): def git_version(): - """ Determine the git version installed on this host by calling git --version""" + """Determine the git version installed on this host by calling git --version""" return _git("--version").replace("\n", "") def git_commentchar(repository_path=None): - """ Shortcut for retrieving comment char from git config """ + """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 + if hasattr(commentchar, "exit_code") and commentchar.exit_code == 1: # pylint: disable=no-member commentchar = "#" return commentchar.replace("\n", "") def git_hooks_dir(repository_path): - """ Determine hooks directory for a given target dir """ + """Determine hooks directory for a given target dir""" hooks_dir = _git("rev-parse", "--git-path", "hooks", _cwd=repository_path) hooks_dir = hooks_dir.replace("\n", "") return os.path.realpath(os.path.join(repository_path, hooks_dir)) +def _parse_git_changed_file_stats(changed_files_stats_raw): + """Parse the output of git diff --numstat and return a dict of: + dict[filename: GitChangedFileStats(filename, additions, deletions)]""" + changed_files_stats_lines = changed_files_stats_raw.split("\n") + changed_files_stats = {} + for line in changed_files_stats_lines[:-1]: # drop last empty line + line_stats = line.split() + + # If the file is binary, numstat will show "-" + # See https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---numstat + additions = int(line_stats[0]) if line_stats[0] != "-" else None + deletions = int(line_stats[1]) if line_stats[1] != "-" else None + + changed_file_stat = GitChangedFileStats(line_stats[2], additions, deletions) + changed_files_stats[line_stats[2]] = changed_file_stat + + return changed_files_stats + + class GitCommitMessage: - """ 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 + """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 @@ -103,7 +129,7 @@ class GitCommitMessage: @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 """ + """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 = f"{context.commentchar} ------------------------ >8 ------------------------" try: @@ -120,19 +146,59 @@ class GitCommitMessage: return self.full 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 + return ( + isinstance(other, GitCommitMessage) + and self.original == other.original + and self.full == other.full + and self.title == other.title + and self.body == other.body + ) + + +class GitChangedFileStats: + """Class representing the stats for a changed file in git""" + + def __init__(self, filepath, additions, deletions): + self.filepath = Path(filepath) + self.additions = additions + self.deletions = deletions + + def __eq__(self, other): + return ( + isinstance(other, GitChangedFileStats) + and self.filepath == other.filepath + and self.additions == other.additions + and self.deletions == other.deletions + ) + + def __str__(self) -> str: + return f"{self.filepath}: {self.additions} additions, {self.deletions} deletions" + + def __repr__(self) -> str: + return ( + f'GitChangedFileStats(filepath="{self.filepath}", additions={self.additions}, deletions={self.deletions})' + ) class GitCommit: - """ 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. + """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): + def __init__( + self, + context, + message, + sha=None, + date=None, + author_name=None, # pylint: disable=too-many-arguments + author_email=None, + parents=None, + changed_files_stats=None, + branches=None, + ): self.context = context self.message = message self.sha = sha @@ -140,7 +206,7 @@ class GitCommit: self.author_name = author_name self.author_email = author_email self.parents = parents or [] # parent commit hashes - self.changed_files = changed_files or [] + self.changed_files_stats = changed_files_stats or {} self.branches = branches or [] @property @@ -156,56 +222,86 @@ class GitCommit: return self.message.title.startswith("squash!") @property + def is_fixup_amend_commit(self): + return self.message.title.startswith("amend!") + + @property def is_revert_commit(self): return self.message.title.startswith("Revert") + @property + def changed_files(self): + return list(self.changed_files_stats.keys()) + def __str__(self): date_str = arrow.get(self.date).format(GIT_TIMEFORMAT) if self.date else None - return (f"--- Commit Message ----\n{self.message}\n" - "--- Meta info ---------\n" - f"Author: {self.author_name} <{self.author_email}>\n" - f"Date: {date_str}\n" - f"is-merge-commit: {self.is_merge_commit}\n" - f"is-fixup-commit: {self.is_fixup_commit}\n" - f"is-squash-commit: {self.is_squash_commit}\n" - f"is-revert-commit: {self.is_revert_commit}\n" - f"Branches: {self.branches}\n" - f"Changed Files: {self.changed_files}\n" - "-----------------------") + + if len(self.changed_files_stats) > 0: + changed_files_stats_str = "\n " + "\n ".join([str(stats) for stats in self.changed_files_stats.values()]) + else: + changed_files_stats_str = " {}" + + return ( + f"--- Commit Message ----\n{self.message}\n" + "--- Meta info ---------\n" + f"Author: {self.author_name} <{self.author_email}>\n" + f"Date: {date_str}\n" + f"is-merge-commit: {self.is_merge_commit}\n" + f"is-fixup-commit: {self.is_fixup_commit}\n" + f"is-fixup-amend-commit: {self.is_fixup_amend_commit}\n" + f"is-squash-commit: {self.is_squash_commit}\n" + f"is-revert-commit: {self.is_revert_commit}\n" + f"Parents: {self.parents}\n" + f"Branches: {self.branches}\n" + f"Changed Files: {self.changed_files}\n" + f"Changed Files Stats:{changed_files_stats_str}\n" + "-----------------------" + ) 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 + 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_fixup_amend_commit == other.is_fixup_amend_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.changed_files_stats == other.changed_files_stats + and self.branches == other.branches + ) 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. - """ + """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. """ + """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:]) + (name, email, date, parents), commit_msg = raw_commit[0].split("\x00"), "\n".join(raw_commit[1:]) - commit_parents = parents.split(" ") + commit_parents = [] if parents == "" else parents.split(" ") commit_is_merge_commit = len(commit_parents) > 1 # "YYYY-MM-DD HH:mm:ss Z" -> ISO 8601-like format @@ -216,8 +312,16 @@ class LocalGitCommit(GitCommit, PropertyCache): # 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}) + 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): @@ -251,7 +355,7 @@ class LocalGitCommit(GitCommit, PropertyCache): # 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'] = [branch.replace("*", "").strip() for branch in branches[:-1]] + self._cache["branches"] = [branch.replace("*", "").strip() for branch in branches[:-1]] return self._try_cache("branches", cache_branches) @@ -260,20 +364,22 @@ class LocalGitCommit(GitCommit, PropertyCache): 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() + def changed_files_stats(self): + def cache_changed_files_stats(): + changed_files_stats_raw = _git( + "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", self.sha, _cwd=self.context.repository_path + ) + self._cache["changed_files_stats"] = _parse_git_changed_file_stats(changed_files_stats_raw) - return self._try_cache("changed_files", cache_changed_files) + return self._try_cache("changed_files_stats", cache_changed_files_stats) class StagedLocalGitCommit(GitCommit, PropertyCache): - """ Class representing a git commit that has been staged, but not committed. + """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. + 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 @@ -315,12 +421,16 @@ class StagedLocalGitCommit(GitCommit, PropertyCache): return [self.context.current_branch] @property - def changed_files(self): - return _git("diff", "--staged", "--name-only", "-r", _cwd=self.context.repository_path).split() + def changed_files_stats(self): + def cache_changed_files_stats(): + changed_files_stats_raw = _git("diff", "--staged", "--numstat", "-r", _cwd=self.context.repository_path) + self._cache["changed_files_stats"] = _parse_git_changed_file_stats(changed_files_stats_raw) + + return self._try_cache("changed_files_stats", cache_changed_files_stats) class GitContext(PropertyCache): - """ Class representing the git context in which gitlint is operating: a data object storing information about + """Class representing the git context in which gitlint is operating: a data object storing information about the git repository that gitlint is linting. """ @@ -337,12 +447,16 @@ class GitContext(PropertyCache): @property @cache def current_branch(self): - current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip() + try: + current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip() + except GitContextError: + # Maybe there is no commit. Try another way to get current branch (need Git 2.22+) + current_branch = _git("branch", "--show-current", _cwd=self.repository_path).strip() return current_branch @staticmethod def from_commit_msg(commit_msg_str): - """ Determines git context based on a commit message. + """Determines git context based on a commit message. :param commit_msg_str: Full git commit message. """ context = GitContext() @@ -353,7 +467,7 @@ class GitContext(PropertyCache): @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. + """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 """ @@ -364,8 +478,8 @@ class GitContext(PropertyCache): return context @staticmethod - def from_local_repository(repository_path, refspec=None, commit_hash=None): - """ Retrieves the git context from a local git repository. + def from_local_repository(repository_path, refspec=None, commit_hashes=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 (mutually exclusive with `commit_hash`) :param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`) @@ -375,11 +489,13 @@ class GitContext(PropertyCache): if refspec: sha_list = _git("rev-list", refspec, _cwd=repository_path).split() - elif commit_hash: # Single commit, just pass it to `git log -1` + elif commit_hashes: # One or more commit hashes, just pass it to `git log -1` # Even though we have already been passed the commit hash, we ask git to retrieve this hash and # return it to us. This way we verify that the passed hash is a valid hash for the target repo and we # also convert it to the full hash format (we might have been passed a short hash). - sha_list = [_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")] + sha_list = [] + for commit_hash in commit_hashes: + sha_list.append(_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")) else: # If no refspec is defined, fallback to the last commit on the current branch # 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 @@ -393,6 +509,10 @@ class GitContext(PropertyCache): 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 + 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 + ) diff --git a/gitlint-core/gitlint/hooks.py b/gitlint-core/gitlint/hooks.py index 87611e0..78c5e46 100644 --- a/gitlint-core/gitlint/hooks.py +++ b/gitlint-core/gitlint/hooks.py @@ -1,4 +1,3 @@ -import io import shutil import os import stat @@ -17,7 +16,7 @@ class GitHookInstallerError(GitlintError): class GitHookInstaller: - """ Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook. """ + """Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook.""" @staticmethod def commit_msg_hook_path(lint_config): @@ -25,7 +24,7 @@ class GitHookInstaller: @staticmethod def _assert_git_repo(target): - """ Asserts that a given target directory is a git repository """ + """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(f"{target} is not a git repository.") @@ -36,8 +35,9 @@ class GitHookInstaller: dest_path = GitHookInstaller.commit_msg_hook_path(lint_config) if os.path.exists(dest_path): raise GitHookInstallerError( - f"There is already a commit-msg hook file present in {dest_path}.\n" + - "gitlint currently does not support appending to an existing commit-msg file.") + f"There is already a commit-msg hook file present in {dest_path}.\n" + "gitlint currently does not support appending to an existing commit-msg file." + ) # copy hook file shutil.copy(COMMIT_MSG_HOOK_SRC_PATH, dest_path) @@ -52,11 +52,13 @@ class GitHookInstaller: if not os.path.exists(dest_path): raise GitHookInstallerError(f"There is no commit-msg hook present in {dest_path}.") - with io.open(dest_path, encoding=DEFAULT_ENCODING) as fp: + with open(dest_path, encoding=DEFAULT_ENCODING) as fp: lines = fp.readlines() if len(lines) < 2 or lines[1] != GITLINT_HOOK_IDENTIFIER: - msg = f"The commit-msg hook in {dest_path} was not installed by gitlint (or it was modified).\n" + \ - "Uninstallation of 3th party or modified gitlint hooks is not supported." + msg = ( + f"The commit-msg hook in {dest_path} was not installed by gitlint (or it was modified).\n" + "Uninstallation of 3th party or modified gitlint hooks is not supported." + ) raise GitHookInstallerError(msg) # If we are sure it's a gitlint hook, go ahead and remove it diff --git a/gitlint-core/gitlint/lint.py b/gitlint-core/gitlint/lint.py index 4b6c8a3..3bc1945 100644 --- a/gitlint-core/gitlint/lint.py +++ b/gitlint-core/gitlint/lint.py @@ -2,13 +2,14 @@ import logging from gitlint import rules as gitlint_rules from gitlint import display +from gitlint.deprecation import Deprecation LOG = logging.getLogger(__name__) logging.basicConfig() class GitLinter: - """ Main linter class. This is where rules actually get applied. See the lint() method. """ + """Main linter class. This is where rules actually get applied. See the lint() method.""" def __init__(self, config): self.config = config @@ -16,34 +17,48 @@ class GitLinter: 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 """ + """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)] + 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)] + 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)] + 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)] + 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 """ + """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: @@ -58,7 +73,7 @@ class GitLinter: @staticmethod def _apply_commit_rules(rules, commit): - """ Applies a set of rules against a given commit and gitcontext """ + """Applies a set of rules against a given commit and gitcontext""" all_violations = [] for rule in rules: violations = rule.validate(commit) @@ -67,19 +82,21 @@ class GitLinter: 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. """ + """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" + str(commit)) + # Ensure the Deprecation class has a reference to the config currently being used + Deprecation.config = self.config + # 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"] + ignore_commit_types = ["merge", "squash", "fixup", "fixup_amend", "revert"] for commit_type in ignore_commit_types: - if getattr(commit, f"is_{commit_type}_commit") and \ - getattr(self.config, f"ignore_{commit_type}_commits"): + if getattr(commit, f"is_{commit_type}_commit") and getattr(self.config, f"ignore_{commit_type}_commits"): return [] violations = [] @@ -95,12 +112,12 @@ class GitLinter: return violations def print_violations(self, violations): - """ Print a given set of violations to the standard error output """ + """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(f"{line_nr}: {v.rule_id}", exact=True) self.display.ee(f"{line_nr}: {v.rule_id} {v.message}", exact=True) if v.content: - self.display.eee(f"{line_nr}: {v.rule_id} {v.message}: \"{v.content}\"", exact=True) + self.display.eee(f'{line_nr}: {v.rule_id} {v.message}: "{v.content}"', exact=True) else: self.display.eee(f"{line_nr}: {v.rule_id} {v.message}", exact=True) diff --git a/gitlint-core/gitlint/options.py b/gitlint-core/gitlint/options.py index e5b7335..50565ea 100644 --- a/gitlint-core/gitlint/options.py +++ b/gitlint-core/gitlint/options.py @@ -6,7 +6,7 @@ from gitlint.exception import GitlintError def allow_none(func): - """ Decorator that sets option value to None if the passed value is None, otherwise calls the regular set method """ + """Decorator that sets option value to None if the passed value is None, otherwise calls the regular set method""" def wrapped(obj, value): if value is None: @@ -22,10 +22,10 @@ class RuleOptionError(GitlintError): class RuleOption: - """ 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. + """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): @@ -36,7 +36,7 @@ class RuleOption: @abstractmethod def set(self, value): - """ Validates and sets the option's value """ + """Validates and sets the option's value""" pass # pragma: no cover def __str__(self): @@ -76,18 +76,16 @@ class IntOption(RuleOption): class BoolOption(RuleOption): - # explicit choice to not annotate with @allow_none: Booleans must be False or True, they cannot be unset. def set(self, value): value = str(value).strip().lower() - if value not in ['true', 'false']: + if value not in ["true", "false"]: raise RuleOptionError(f"Option '{self.name}' must be either 'true' or 'false'") - self.value = value == 'true' + self.value = value == "true" class ListOption(RuleOption): - """ Option that is either a given list or a comma-separated string that can be split into a list when being set. - """ + """Option that is either a given list or a comma-separated string that can be split into a list when being set.""" @allow_none def set(self, value): @@ -100,7 +98,7 @@ class ListOption(RuleOption): class PathOption(RuleOption): - """ Option that accepts either a directory or both a directory and a file. """ + """Option that accepts either a directory or both a directory and a file.""" def __init__(self, name, value, description, type="dir"): self.type = type @@ -112,16 +110,17 @@ class PathOption(RuleOption): error_msg = "" - if self.type == 'dir': + if self.type == "dir": if not os.path.isdir(value): error_msg = f"Option {self.name} must be an existing directory (current value: '{value}')" - elif self.type == 'file': + elif self.type == "file": if not os.path.isfile(value): error_msg = f"Option {self.name} must be an existing file (current value: '{value}')" - elif self.type == 'both': + elif self.type == "both": if not os.path.isdir(value) and not os.path.isfile(value): - error_msg = (f"Option {self.name} must be either an existing directory or file " - f"(current value: '{value}')") + error_msg = ( + f"Option {self.name} must be either an existing directory or file (current value: '{value}')" + ) else: error_msg = f"Option {self.name} type must be one of: 'file', 'dir', 'both' (current: '{self.type}')" @@ -132,7 +131,6 @@ class PathOption(RuleOption): class RegexOption(RuleOption): - @allow_none def set(self, value): try: diff --git a/gitlint-core/gitlint/rule_finder.py b/gitlint-core/gitlint/rule_finder.py index e1c5e77..11665cf 100644 --- a/gitlint-core/gitlint/rule_finder.py +++ b/gitlint-core/gitlint/rule_finder.py @@ -31,7 +31,7 @@ def find_rule_classes(extra_path): # Filter out files that are not python modules for filename in files: - if fnmatch.fnmatch(filename, '*.py'): + 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": @@ -61,13 +61,19 @@ def find_rule_classes(extra_path): # 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) or - issubclass(clazz, rules.ConfigurationRule))]) + rule_classes.extend( + [ + clazz + for _, clazz in inspect.getmembers(sys.modules[module]) + if inspect.isclass(clazz) # check isclass to ensure clazz.__module__ exists + and clazz.__module__ == module # ignore imported classes + and ( + issubclass(clazz, rules.LineRule) + or issubclass(clazz, rules.CommitRule) + or issubclass(clazz, rules.ConfigurationRule) + ) + ] + ) # validate that the rule classes are valid user-defined rules for rule_class in rule_classes: @@ -91,55 +97,66 @@ def assert_valid_rule_class(clazz, rule_type="User-defined"): # pylint: disable """ # Rules must extend from LineRule, CommitRule or ConfigurationRule - if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule) - or issubclass(clazz, rules.ConfigurationRule)): - msg = f"{rule_type} rule class '{clazz.__name__}' " + \ - f"must extend from {rules.CommitRule.__module__}.{rules.LineRule.__name__}, " + \ - f"{rules.CommitRule.__module__}.{rules.CommitRule.__name__} or " + \ - f"{rules.CommitRule.__module__}.{rules.ConfigurationRule.__name__}" + if not ( + issubclass(clazz, rules.LineRule) + or issubclass(clazz, rules.CommitRule) + or issubclass(clazz, rules.ConfigurationRule) + ): + msg = ( + f"{rule_type} rule class '{clazz.__name__}' " + f"must extend from {rules.CommitRule.__module__}.{rules.LineRule.__name__}, " + f"{rules.CommitRule.__module__}.{rules.CommitRule.__name__} or " + f"{rules.CommitRule.__module__}.{rules.ConfigurationRule.__name__}" + ) raise rules.UserRuleError(msg) # Rules must have an id attribute - if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id: + if not hasattr(clazz, "id") or clazz.id is None or not clazz.id: raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have an 'id' attribute") # Rule id's cannot start with gitlint reserved letters - if clazz.id[0].upper() in ['R', 'T', 'B', 'M', 'I']: + if clazz.id[0].upper() in ["R", "T", "B", "M", "I"]: msg = f"The id '{clazz.id[0]}' of '{clazz.__name__}' is invalid. Gitlint reserves ids starting with R,T,B,M,I" raise rules.UserRuleError(msg) # Rules must have a name attribute - if not hasattr(clazz, 'name') or clazz.name is None or not clazz.name: + if not hasattr(clazz, "name") or clazz.name is None or not clazz.name: raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have a 'name' attribute") # if set, options_spec must be a list of RuleOption if not isinstance(clazz.options_spec, list): - msg = f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + \ - f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}" + msg = ( + f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}" + ) raise rules.UserRuleError(msg) # check that all items in options_spec are actual gitlint options for option in clazz.options_spec: if not isinstance(option, options.RuleOption): - msg = f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + \ - f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}" + msg = ( + f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}" + ) raise rules.UserRuleError(msg) # Line/Commit rules must have a `validate` method # We use isroutine() as it's both python 2 and 3 compatible. Details: http://stackoverflow.com/a/17019998/381010 - if (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)): - if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate): + if issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule): + if not hasattr(clazz, "validate") or not inspect.isroutine(clazz.validate): raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have a 'validate' method") # Configuration rules must have an `apply` method elif issubclass(clazz, rules.ConfigurationRule): - if not hasattr(clazz, 'apply') or not inspect.isroutine(clazz.apply): + if not hasattr(clazz, "apply") or not inspect.isroutine(clazz.apply): msg = f"{rule_type} Configuration rule class '{clazz.__name__}' must have an 'apply' method" raise rules.UserRuleError(msg) # 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 = f"The target attribute of the {rule_type.lower()} LineRule class '{clazz.__name__}' " + \ - f"must be either {rules.CommitMessageTitle.__module__}.{rules.CommitMessageTitle.__name__} " + \ - f"or {rules.CommitMessageTitle.__module__}.{rules.CommitMessageBody.__name__}" + msg = ( + f"The target attribute of the {rule_type.lower()} LineRule class '{clazz.__name__}' " + f"must be either {rules.CommitMessageTitle.__module__}.{rules.CommitMessageTitle.__name__} " + f"or {rules.CommitMessageTitle.__module__}.{rules.CommitMessageBody.__name__}" + ) raise rules.UserRuleError(msg) diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py index 1c5a618..6d486a5 100644 --- a/gitlint-core/gitlint/rules.py +++ b/gitlint-core/gitlint/rules.py @@ -5,15 +5,18 @@ import re from gitlint.options import IntOption, BoolOption, StrOption, ListOption, RegexOption from gitlint.exception import GitlintError +from gitlint.deprecation import Deprecation class Rule: - """ Class representing gitlint rules. """ + """Class representing gitlint rules.""" + options_spec = [] id = None name = None target = None _log = None + _log_deprecated_regex_style_search = None def __init__(self, opts=None): if not opts: @@ -33,48 +36,58 @@ class Rule: return self._log 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 + return ( + self.id == other.id + and self.name == other.name + and self.options == other.options + and self.target == other.target + ) def __str__(self): return f"{self.id} {self.name}" # pragma: no cover class ConfigurationRule(Rule): - """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """ + """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 """ + """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 """ + """Class representing rules that act on a line by line basis""" + pass class LineRuleTarget: - """ Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied + """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. """ + Each LineRule MUST have a target specified.""" + pass class CommitMessageTitle(LineRuleTarget): - """ Target class used for rules that apply to a commit message title """ + """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 """ + """Target class used for rules that apply to a commit message body""" + pass class RuleViolation: - """ 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. """ + """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 @@ -88,22 +101,23 @@ class RuleViolation: return equal def __str__(self): - return f"{self.line_nr}: {self.rule_id} {self.message}: \"{self.content}\"" + return f'{self.line_nr}: {self.rule_id} {self.message}: "{self.content}"' class UserRuleError(GitlintError): - """ Error used to indicate that an error occurred while trying to load a user rule """ + """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")] + 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 + 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)] @@ -130,15 +144,16 @@ class HardTab(LineRule): 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.) """ + """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")] + options_spec = [ListOption("words", [], "Comma separated list of words that should not be found")] violation_message = "Line contains {0}" def validate(self, line, _commit): - strings = self.options['words'].value + strings = self.options["words"].value violations = [] for string in strings: regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE) @@ -163,7 +178,7 @@ class TitleMaxLength(MaxLineLength): name = "title-max-length" id = "T1" target = CommitMessageTitle - options_spec = [IntOption('line-length', 72, "Max line length")] + options_spec = [IntOption("line-length", 72, "Max line length")] violation_message = "Title exceeds max length ({0}>{1})" @@ -180,7 +195,7 @@ class TitleTrailingPunctuation(LineRule): target = CommitMessageTitle def validate(self, title, _commit): - punctuation_marks = '?:!.,;' + punctuation_marks = "?:!.,;" for punctuation_mark in punctuation_marks: if title.endswith(punctuation_mark): return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)] @@ -197,7 +212,7 @@ class TitleMustNotContainWord(LineMustNotContainWord): name = "title-must-not-contain-word" id = "T5" target = CommitMessageTitle - options_spec = [ListOption('words', ["WIP"], "Must not contain word")] + options_spec = [ListOption("words", ["WIP"], "Must not contain word")] violation_message = "Title contains the word '{0}' (case-insensitive)" @@ -212,14 +227,14 @@ class TitleRegexMatches(LineRule): name = "title-match-regex" id = "T7" target = CommitMessageTitle - options_spec = [RegexOption('regex', None, "Regex the title should match")] + options_spec = [RegexOption("regex", None, "Regex the title should match")] def validate(self, title, _commit): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return - if not self.options['regex'].value.search(title): + if not self.options["regex"].value.search(title): violation_msg = f"Title does not match regex ({self.options['regex'].value.pattern})" return [RuleViolation(self.id, violation_msg, title)] @@ -228,10 +243,10 @@ class TitleMinLength(LineRule): name = "title-min-length" id = "T8" target = CommitMessageTitle - options_spec = [IntOption('min-length', 5, "Minimum required title length")] + options_spec = [IntOption("min-length", 5, "Minimum required title length")] def validate(self, title, _commit): - min_length = self.options['min-length'].value + min_length = self.options["min-length"].value actual_length = len(title) if actual_length < min_length: violation_message = f"Title is too short ({actual_length}<{min_length})" @@ -270,10 +285,10 @@ class BodyFirstLineEmpty(CommitRule): class BodyMinLength(CommitRule): name = "body-min-length" id = "B5" - options_spec = [IntOption('min-length', 20, "Minimum body length")] + options_spec = [IntOption("min-length", 20, "Minimum body length")] def validate(self, commit): - min_length = self.options['min-length'].value + 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: @@ -284,24 +299,24 @@ class BodyMinLength(CommitRule): class BodyMissing(CommitRule): name = "body-is-missing" id = "B6" - options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")] + 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: + if self.options["ignore-merge-commits"].value and commit.is_merge_commit: return - if len(commit.message.body) < 2 or not ''.join(commit.message.body).strip(): + if len(commit.message.body) < 2 or not "".join(commit.message.body).strip(): 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")] + options_spec = [ListOption("files", [], "Files that need to be mentioned")] def validate(self, commit): violations = [] - for needs_mentioned_file in self.options['files'].value: + 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: @@ -314,11 +329,11 @@ class BodyChangedFileMention(CommitRule): class BodyRegexMatches(CommitRule): name = "body-match-regex" id = "B8" - options_spec = [RegexOption('regex', None, "Regex the body should match")] + options_spec = [RegexOption("regex", None, "Regex the body should match")] def validate(self, commit): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return # We intentionally ignore the first line in the body as that's the empty line after the title, @@ -334,7 +349,7 @@ class BodyRegexMatches(CommitRule): full_body = "\n".join(body_lines) - if not self.options['regex'].value.search(full_body): + if not self.options["regex"].value.search(full_body): violation_msg = f"Body does not match regex ({self.options['regex'].value.pattern})" return [RuleViolation(self.id, violation_msg, None, len(commit.message.body) + 1)] @@ -342,33 +357,51 @@ class BodyRegexMatches(CommitRule): class AuthorValidEmail(CommitRule): name = "author-valid-email" id = "M1" - options_spec = [RegexOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")] + DEFAULT_AUTHOR_VALID_EMAIL_REGEX = r"^[^@ ]+@[^@ ]+\.[^@ ]+" + options_spec = [ + RegexOption("regex", DEFAULT_AUTHOR_VALID_EMAIL_REGEX, "Regex that author email address should match") + ] def validate(self, commit): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return - if commit.author_email and not self.options['regex'].value.match(commit.author_email): + # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254 + # In case the user is using the default regex, we can silently change to using search + # If not, it depends on config (handled by Deprecation class) + if self.DEFAULT_AUTHOR_VALID_EMAIL_REGEX == self.options["regex"].value.pattern: + regex_method = self.options["regex"].value.search + else: + regex_method = Deprecation.get_regex_method(self, self.options["regex"]) + + if commit.author_email and not regex_method(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 = [RegexOption('regex', None, "Regex matching the titles of commits this rule should apply to"), - StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + options_spec = [ + RegexOption("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): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return - if self.options['regex'].value.match(commit.message.title): - config.ignore = self.options['ignore'].value + # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254 + regex_method = Deprecation.get_regex_method(self, self.options["regex"]) + + if regex_method(commit.message.title): + config.ignore = self.options["ignore"].value - message = f"Commit title '{commit.message.title}' matches the regex " + \ - f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}" + message = ( + f"Commit title '{commit.message.title}' matches the regex " + f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}" + ) self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) @@ -376,20 +409,27 @@ class IgnoreByTitle(ConfigurationRule): class IgnoreByBody(ConfigurationRule): name = "ignore-by-body" id = "I2" - options_spec = [RegexOption('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")] + options_spec = [ + RegexOption("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): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return + # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254 + regex_method = Deprecation.get_regex_method(self, self.options["regex"]) + for line in commit.message.body: - if self.options['regex'].value.match(line): - config.ignore = self.options['ignore'].value + if regex_method(line): + config.ignore = self.options["ignore"].value - message = f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}'," + \ - f" ignoring rules: {self.options['ignore'].value}" + message = ( + f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}'," + f" ignoring rules: {self.options['ignore'].value}" + ) self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) # No need to check other lines if we found a match @@ -399,18 +439,21 @@ class IgnoreByBody(ConfigurationRule): class IgnoreBodyLines(ConfigurationRule): name = "ignore-body-lines" id = "I3" - options_spec = [RegexOption('regex', None, "Regex matching lines of the body that should be ignored")] + options_spec = [RegexOption("regex", None, "Regex matching lines of the body that should be ignored")] def apply(self, _, commit): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return + # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254 + regex_method = Deprecation.get_regex_method(self, self.options["regex"]) + new_body = [] for line in commit.message.body: - if self.options['regex'].value.match(line): + if regex_method(line): debug_msg = "Ignoring line '%s' because it matches '%s'" - self.log.debug(debug_msg, line, self.options['regex'].value.pattern) + self.log.debug(debug_msg, line, self.options["regex"].value.pattern) else: new_body.append(line) @@ -421,19 +464,25 @@ class IgnoreBodyLines(ConfigurationRule): class IgnoreByAuthorName(ConfigurationRule): name = "ignore-by-author-name" id = "I4" - options_spec = [RegexOption('regex', None, "Regex matching the author name of commits this rule should apply to"), - StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + options_spec = [ + RegexOption("regex", None, "Regex matching the author name of commits this rule should apply to"), + StrOption("ignore", "all", "Comma-separated list of rules to ignore"), + ] def apply(self, config, commit): # If no regex is specified, immediately return - if not self.options['regex'].value: + if not self.options["regex"].value: return - if self.options['regex'].value.match(commit.author_name): - config.ignore = self.options['ignore'].value + regex_method = Deprecation.get_regex_method(self, self.options["regex"]) + + if regex_method(commit.author_name): + config.ignore = self.options["ignore"].value - message = (f"Commit Author Name '{commit.author_name}' matches the regex " - f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}") + message = ( + f"Commit Author Name '{commit.author_name}' matches the regex " + f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}" + ) self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) # No need to check other lines if we found a match diff --git a/gitlint-core/gitlint/shell.py b/gitlint-core/gitlint/shell.py index 365c65d..c378c1c 100644 --- a/gitlint-core/gitlint/shell.py +++ b/gitlint-core/gitlint/shell.py @@ -1,4 +1,3 @@ - """ 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 altogether in the future, but 'sh' does provide a few @@ -10,26 +9,28 @@ from gitlint.utils import USE_SH_LIB, DEFAULT_ENCODING def shell(cmd): - """ Convenience function that opens a given command in a shell. Does not use 'sh' library. """ + """Convenience function that opens a given command in a shell. Does not use 'sh' library.""" with subprocess.Popen(cmd, shell=True) as p: p.communicate() 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 """ + """Exception indicating a command was not found during execution""" + pass class ShResult: - """ Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using - the builtin subprocess module """ + """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): + def __init__(self, full_cmd, stdout, stderr="", exitcode=0): self.full_cmd = full_cmd self.stdout = stdout self.stderr = stderr @@ -39,22 +40,23 @@ else: return self.stdout class ErrorReturnCode(ShResult, Exception): - """ ShResult subclass for unexpected results (acts as an exception). """ + """ShResult subclass for unexpected results (acts as an exception).""" + pass def git(*command_parts, **kwargs): - """ Git shell wrapper. + """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) + args = ["git"] + list(command_parts) return _exec(*args, **kwargs) def _exec(*args, **kwargs): pipe = subprocess.PIPE - popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)} - if '_cwd' in kwargs: - popen_kwargs['cwd'] = kwargs['_cwd'] + popen_kwargs = {"stdout": pipe, "stderr": pipe, "shell": kwargs.get("_tty_out", False)} + if "_cwd" in kwargs: + popen_kwargs["cwd"] = kwargs["_cwd"] try: with subprocess.Popen(args, **popen_kwargs) as p: @@ -65,10 +67,10 @@ else: exit_code = p.returncode stdout = result[0].decode(DEFAULT_ENCODING) stderr = result[1] # 'sh' does not decode the stderr bytes to unicode - full_cmd = '' if args is None else ' '.join(args) + 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]) + ok_exit_codes = kwargs.get("_ok_code", [0]) if exit_code in ok_exit_codes: return ShResult(full_cmd, stdout, stderr, exit_code) diff --git a/gitlint-core/gitlint/tests/base.py b/gitlint-core/gitlint/tests/base.py index 9d2d165..710efe2 100644 --- a/gitlint-core/gitlint/tests/base.py +++ b/gitlint-core/gitlint/tests/base.py @@ -1,8 +1,5 @@ -# -*- coding: utf-8 -*- - import contextlib import copy -import io import logging import os import re @@ -13,12 +10,22 @@ import unittest from unittest.mock import patch -from gitlint.git import GitContext +from gitlint.config import LintConfig +from gitlint.deprecation import Deprecation, LOG as DEPRECATION_LOG +from gitlint.git import GitContext, GitChangedFileStats from gitlint.utils import LOG_FORMAT, DEFAULT_ENCODING +EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING = ( + "WARNING: gitlint.deprecated.regex_style_search {0} - {1}: gitlint will be switching from using " + "Python regex 'match' (match beginning) to 'search' (match anywhere) semantics. " + "Please review your {1}.regex option accordingly. " + "To remove this warning, set general.regex-style-search=True. More details: " + "https://jorisroovers.github.io/gitlint/configuration/#regex-style-search" +) + class BaseTestCase(unittest.TestCase): - """ Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods. """ + """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 @@ -30,13 +37,24 @@ class BaseTestCase(unittest.TestCase): 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] + logging.getLogger("gitlint").setLevel(logging.DEBUG) + logging.getLogger("gitlint").handlers = [self.logcapture] + DEPRECATION_LOG.handlers = [self.logcapture] # Make sure we don't propagate anything to child loggers, we need to do this explicitly 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 + # Example test where this matters (for DEPRECATION_LOG): + # gitlint-core/gitlint/tests/rules/test_configuration_rules.py::ConfigurationRuleTests::test_ignore_by_title + logging.getLogger("gitlint").propagate = False + DEPRECATION_LOG.propagate = False + + # Make sure Deprecation has a clean config set at the start of each test. + # Tests that want to specifically test deprecation should override this. + Deprecation.config = LintConfig() + # Normally Deprecation only logs messages once per process. + # For tests we want to log every time, so we reset the warning_msgs set per test. + Deprecation.warning_msgs = set() @staticmethod @contextlib.contextmanager @@ -57,25 +75,25 @@ class BaseTestCase(unittest.TestCase): @staticmethod def get_sample(filename=""): - """ Read and return the contents of a file in gitlint/tests/samples """ + """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: + with open(sample_path, encoding=DEFAULT_ENCODING) as content: sample = content.read() return sample @staticmethod def patch_input(side_effect): - """ Patches the built-in input() with a provided side-effect """ + """Patches the built-in input() with a provided side-effect""" module_path = "builtins.input" patched_module = patch(module_path, side_effect=side_effect) return patched_module @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. """ + """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: + with open(expected_path, encoding=DEFAULT_ENCODING) as content: expected = content.read() if variable_dict: @@ -87,20 +105,21 @@ class BaseTestCase(unittest.TestCase): 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 + 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 = "#" gitcontext = GitContext.from_commit_msg(commit_msg_str) commit = gitcontext.commits[-1] if changed_files: - commit.changed_files = changed_files + changed_file_stats = {filename: GitChangedFileStats(filename, 8, 3) for filename in changed_files} + commit.changed_files_stats = changed_file_stats 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""" + """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(): @@ -108,31 +127,31 @@ class BaseTestCase(unittest.TestCase): 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. """ + """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 """ + """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. + """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().assertRaisesRegex(expected_exception, re.escape(expected_regex), *args, **kwargs) def clearlog(self): - """ Clears the log capture """ + """Clears the log capture""" self.logcapture.clear() @contextlib.contextmanager def assertRaisesMessage(self, expected_exception, expected_msg): # pylint: disable=invalid-name - """ Asserts an exception has occurred with a given error message """ + """Asserts an exception has occurred with a given error message""" try: yield except expected_exception as exc: @@ -149,10 +168,10 @@ class BaseTestCase(unittest.TestCase): raise self.fail(f"Expected to raise {expected_exception.__name__}, didn't get an exception at all") 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__`. + """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 = {} @@ -178,7 +197,7 @@ class BaseTestCase(unittest.TestCase): class LogCapture(logging.Handler): - """ Mock logging handler used to capture any log messages during tests.""" + """Mock logging handler used to capture any log messages during tests.""" def __init__(self, *args, **kwargs): logging.Handler.__init__(self, *args, **kwargs) diff --git a/gitlint-core/gitlint/tests/cli/test_cli.py b/gitlint-core/gitlint/tests/cli/test_cli.py index 59ec7af..d18efe9 100644 --- a/gitlint-core/gitlint/tests/cli/test_cli.py +++ b/gitlint-core/gitlint/tests/cli/test_cli.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- - - import io import os import sys @@ -29,11 +26,11 @@ class CLITests(BaseTestCase): GITLINT_SUCCESS_CODE = 0 def setUp(self): - super(CLITests, self).setUp() + super().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') + 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" @@ -42,39 +39,44 @@ class CLITests(BaseTestCase): @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()), - 'DEFAULT_ENCODING': DEFAULT_ENCODING} + """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()), + "DEFAULT_ENCODING": DEFAULT_ENCODING, + } def test_version(self): - """ Test for --version option """ + """Test for --version option""" result = self.cli.invoke(cli.cli, ["--version"]) self.assertEqual(result.output.split("\n")[0], f"cli, version {__version__}") - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_lint(self, sh, _): - """ Test for basic simple linting functionality """ + """Test for basic simple linting functionality""" sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360", - "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "commït-title\n\ncommït-body", + "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncommït-title\n\ncommït-body", "#", # git config --get core.commentchar + "1\t4\tfile1.txt\n3\t5\tpåth/to/file2.txt\n", "commit-1-branch-1\ncommit-1-branch-2\n", - "file1.txt\npåth/to/file2.txt\n" ] - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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(stderr.getvalue(), '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') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_lint_multiple_commits(self, sh, _): - """ Test for --commits option """ + """Test for --commits option""" + # fmt: off sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA> "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" + @@ -83,30 +85,32 @@ class CLITests(BaseTestCase): "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" "commït-title1\n\ncommït-body1", "#", # git config --get core.commentchar + "3\t5\tcommit-1/file-1\n1\t4\tcommit-1/file-2\n", # git diff-tree "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n" "commït-title2\n\ncommït-body2", + "8\t3\tcommit-2/file-1\n1\t5\tcommit-2/file-2\n", # git diff-tree "commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha> - "commit-2/file-1\ncommit-2/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n" "commït-title3\n\ncommït-body3", + "7\t2\tcommit-3/file-1\n1\t7\tcommit-3/file-2\n", # git diff-tree "commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha> - "commit-3/file-1\ncommit-3/file-2\n", # git diff-tree ] + # fmt: on - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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("cli/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') + @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 """ + """Test for --commits option where some of the commits have gitlint config in the commit message""" + # fmt: off # 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> @@ -116,32 +120,33 @@ class CLITests(BaseTestCase): "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" "commït-title1\n\ncommït-body1", "#", # git config --get core.commentchar + "9\t4\tcommit-1/file-1\n0\t2\tcommit-1/file-2\n", # git diff-tree "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n" "commït-title2.\n\ncommït-body2\ngitlint-ignore: T3\n", + "3\t7\tcommit-2/file-1\n4\t6\tcommit-2/file-2\n", # git diff-tree "commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha> - "commit-2/file-1\ncommit-2/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n" "commït-title3.\n\ncommït-body3", + "3\t8\tcommit-3/file-1\n1\t4\tcommit-3/file-2\n", # git diff-tree "commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha> - "commit-3/file-1\ncommit-3/file-2\n", # git diff-tree ] + # fmt: on - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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("cli/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') + @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 - """ + """Test for --commits option where where we have configured gitlint to ignore certain rules for certain commits""" + # fmt: off # Note that the second commit sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA> @@ -151,62 +156,78 @@ class CLITests(BaseTestCase): "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" "commït-title1\n\ncommït-body1", "#", # git config --get core.commentchar + "5\t9\tcommit-1/file-1\n1\t4\tcommit-1/file-2\n", # git diff-tree "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "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 "commït-title2.\n\ncommït-body2\n", + "4\t7\tcommit-2/file-1\n1\t4\tcommit-2/file-2\n", # git diff-tree "commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha> - "commit-2/file-1\ncommit-2/file-2\n", # git diff-tree # git log --pretty <FORMAT> <SHA> "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 "commït-title3.\n\ncommït-body3 foo", + "1\t9\tcommit-3/file-1\n3\t7\tcommit-3/file-2\n", # git diff-tree "commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha> - "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"]) + # fmt: on + + 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 = ("Commit 6f29bf81a8:\n" - u'3: B5 Body message is too short (12<20): "commït-body1"\n\n' - "Commit 4da2656b0d:\n" - u'1: T3 Title has trailing punctuation (.): "commït-title3."\n') + expected = ( + "Commit 6f29bf81a8:\n" + '3: B5 Body message is too short (12<20): "commït-body1"\n\n' + "Commit 4da2656b0d:\n" + '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=False) - @patch('gitlint.git.sh') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_lint_commit(self, sh, _): - """ Test for --commit option """ + """Test for --commit option""" + # fmt: off sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360\n", # git log -1 <SHA> --pretty=%H # git log --pretty <FORMAT> <SHA> "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" "WIP: commït-title1\n\ncommït-body1", "#", # git config --get core.commentchar + "4\t5\tcommit-1/file-1\n1\t4\tcommit-1/file-2\n", # git diff-tree "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree ] + # fmt: on - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--commit", "foo"]) self.assertEqual(result.output, "") self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_commit_1")) self.assertEqual(result.exit_code, 2) - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_lint_commit_negative(self, sh, _): - """ Negative test for --commit option """ + """Negative test for --commit option""" # Try using --commit and --commits at the same time (not allowed) result = self.cli.invoke(cli.cli, ["--commit", "foo", "--commits", "foo...bar"]) @@ -214,275 +235,309 @@ class CLITests(BaseTestCase): self.assertEqual(result.output, expected_output) self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) - @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n') + @patch("gitlint.cli.get_stdin_data", return_value="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: + """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("cli/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') + @patch("gitlint.cli.get_stdin_data", return_value="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: + """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("cli/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('cli/test_cli/test_input_stream_debug_2', expected_kwargs) + expected_logs = self.get_expected("cli/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') + @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""" + """Test for ignoring stdin when --ignore-stdin flag is enabled""" sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360", - "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "commït-title\n\ncommït-body", - "#", # git config --get core.commentchar + "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncommït-title\n\ncommït-body", + "#", # git config --get core.commentchar + "3\t12\tfile1.txt\n8\t5\tpåth/to/file2.txt\n", # git diff-tree "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "file1.txt\npåth/to/file2.txt\n" # git diff-tree ] - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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(stderr.getvalue(), '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') + @patch("gitlint.cli.get_stdin_data", return_value="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""" + """Test for ignoring stdin when --ignore-stdin flag is enabled""" sh.git.side_effect = [ - "#", # git config --get core.commentchar - "föo user\n", # git config --get user.name - "föo@bar.com\n", # git config --get user.email - "my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch) - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree + "#", # git config --get core.commentchar + "1\t5\tcommit-1/file-1\n8\t9\tcommit-1/file-2\n", # git diff-tree + "föo user\n", # git config --get user.name + "föo@bar.com\n", # git config --get user.email + "my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch) ] - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--debug", "--staged"]) self.assertEqual(stderr.getvalue(), self.get_expected("cli/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('cli/test_cli/test_lint_staged_stdin_2', expected_kwargs) + expected_logs = self.get_expected("cli/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') + @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""" + """Test for ignoring stdin when --ignore-stdin flag is enabled""" + # fmt: off sh.git.side_effect = [ "#", # git config --get core.commentchar + "3\t4\tcommit-1/file-1\n4\t7\tcommit-1/file-2\n", # git diff-tree "föo user\n", # git config --get user.name "föo@bar.com\n", # git config --get user.email "my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch) - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree ] + # fmt: on with self.tempdir() as tmpdir: msg_filename = os.path.join(tmpdir, "msg") - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write("WIP: msg-filename tïtle\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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("cli/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('cli/test_cli/test_lint_staged_msg_filename_2', expected_kwargs) + expected_logs = self.get_expected("cli/test_cli/test_lint_staged_msg_filename_2", expected_kwargs) self.assert_logged(expected_logs) - @patch('gitlint.cli.get_stdin_data', return_value=False) + @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, ("Error: The 'staged' option (--staged) can only be used when using " - "'--msg-filename' or when piping data to gitlint via stdin.\n")) - - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + self.assertEqual( + result.output, + "Error: The 'staged' option (--staged) can only be used when using " + "'--msg-filename' or when piping data to gitlint via stdin.\n", + ) + + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_fail_without_commits(self, sh, _): - """ Test for --debug option """ + """Test for --debug option""" - sh.git.side_effect = [ - "", # First invocation of git rev-list - "" # Second invocation of git rev-list - ] + sh.git.side_effect = ["", ""] # First invocation of git rev-list # Second invocation of git rev-list - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: # By default, gitlint should silently exit with code GITLINT_SUCCESS when there are no commits result = self.cli.invoke(cli.cli, ["--commits", "foo..bar"]) self.assertEqual(stderr.getvalue(), "") self.assertEqual(result.exit_code, cli.GITLINT_SUCCESS) - self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"") + self.assert_log_contains('DEBUG: gitlint.cli No commits in range "foo..bar"') # When --fail-without-commits is set, gitlint should hard fail with code USAGE_ERROR_CODE self.clearlog() result = self.cli.invoke(cli.cli, ["--commits", "foo..bar", "--fail-without-commits"]) self.assertEqual(result.output, 'Error: No commits in range "foo..bar"\n') self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) - self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"") + self.assert_log_contains('DEBUG: gitlint.cli No commits in range "foo..bar"') - @patch('gitlint.cli.get_stdin_data', return_value=False) + @patch("gitlint.cli.get_stdin_data", return_value=False) def test_msg_filename(self, _): expected_output = "3: B6 Body message is missing\n" with self.tempdir() as tmpdir: msg_filename = os.path.join(tmpdir, "msg") - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write("Commït title\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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="WIP: tïtle \n") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: tïtle \n") def test_silent_mode(self, _): - """ Test for --silent option """ - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + """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="WIP: tïtle \n") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: tïtle \n") def test_verbosity(self, _): - """ Test for --verbosity option """ + """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: + 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" + 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: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["-vv"], input="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') + with patch("gitlint.display.stderr", new=StringIO()) as stderr: + result = self.cli.invoke(cli.cli, ["-vvvv"], input="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') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_debug(self, sh, _): - """ Test for --debug option """ + """Test for --debug option""" + # fmt: off sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360\n" # git rev-list <SHA> "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n", # git log --pretty <FORMAT> <SHA> - "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00abc\n" + "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00a123\n" "commït-title1\n\ncommït-body1", - "#", # git config --get core.commentchar - "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> - "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree - "test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00abc\n" + "#", # git config --get core.commentchar + "5\t8\tcommit-1/file-1\n2\t9\tcommit-1/file-2\n", # git diff-tree + "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> + "test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00b123\n" "commït-title2.\n\ncommït-body2", - "commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha> - "commit-2/file-1\ncommit-2/file-2\n", # git diff-tree - "test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00abc\n" + "5\t8\tcommit-2/file-1\n7\t9\tcommit-2/file-2\n", # git diff-tree + "commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha> + "test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00c123\n" "föobar\nbar", - "commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha> - "commit-3/file-1\ncommit-3/file-2\n", # git diff-tree + "1\t4\tcommit-3/file-1\n3\t4\tcommit-3/file-2\n", # git diff-tree + "commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha> ] + # fmt: on - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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"]) + 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" + 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('cli/test_cli/test_debug_1', expected_kwargs) + expected_kwargs.update({"config_path": config_path}) + expected_logs = self.get_expected("cli/test_cli/test_debug_1", expected_kwargs) self.assert_logged(expected_logs) - @patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n") + @patch("gitlint.cli.get_stdin_data", return_value="Test tïtle\n") def test_extra_path(self, _): - """ Test for --extra-path flag """ + """Test for --extra-path flag""" # Test extra-path pointing to a directory - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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]) - expected_output = "1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \ - "3: B6 Body message is missing\n" + expected_output = '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: + 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]) - expected_output = "1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \ - "3: B6 Body message is missing\n" + expected_output = '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="Test tïtle\n") + def test_extra_path_environment(self, _): + """Test for GITLINT_EXTRA_PATH environment variable""" + # Test setting extra-path to a directory from an environment variable + with patch("gitlint.display.stderr", new=StringIO()) as stderr: + extra_path = self.get_sample_path("user_rules") + result = self.cli.invoke(cli.cli, env={"GITLINT_EXTRA_PATH": extra_path}) + + expected_output = '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 from an environment variable + 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, env={"GITLINT_EXTRA_PATH": extra_path}) + expected_output = '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="Test tïtle\n\nMy body that is long enough") + @patch("gitlint.cli.get_stdin_data", return_value="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: + 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('cli/test_cli/test_contrib_1') + expected_output = self.get_expected("cli/test_cli/test_contrib_1") self.assertEqual(stderr.getvalue(), expected_output) self.assertEqual(result.exit_code, 2) - @patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n") + @patch("gitlint.cli.get_stdin_data", return_value="Test tïtle\n") def test_contrib_negative(self, _): result = self.cli.invoke(cli.cli, ["--contrib", "föobar,CC1"]) self.assertEqual(result.output, "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="WIP: tëst") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: tëst") def test_config_file(self, _): - """ Test for --config option """ - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + """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) + @patch("gitlint.cli.get_stdin_data", return_value="WIP: tëst") + def test_config_file_environment(self, _): + """Test for GITLINT_CONFIG environment variable""" + 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, env={"GITLINT_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 """ + """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]) @@ -502,9 +557,30 @@ class CLITests(BaseTestCase): 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_config_file_negative_environment(self): + """Negative test for GITLINT_CONFIG environment variable""" + # Directory as config file + config_path = self.get_sample_path("config") + result = self.cli.invoke(cli.cli, env={"GITLINT_CONFIG": config_path}) + expected_string = f"Error: Invalid value for '-C' / '--config': File '{config_path}' is a directory." + 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("föo") + result = self.cli.invoke(cli.cli, env={"GITLINT_CONFIG": config_path}) + expected_string = f"Error: Invalid value for '-C' / '--config': File '{config_path}' does not exist." + 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, env={"GITLINT_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 """ + """Test for the --target option""" with self.tempdir() as tmpdir: tmpdir_path = os.path.realpath(tmpdir) os.environ["LANGUAGE"] = "C" # Force language to english so we can check for error message @@ -515,7 +591,7 @@ class CLITests(BaseTestCase): self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE) def test_target_negative(self): - """ Negative test for the --target option """ + """Negative test for the --target option""" # try setting a non-existing target result = self.cli.invoke(cli.cli, ["--target", "/föo/bar"]) self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) @@ -529,57 +605,63 @@ class CLITests(BaseTestCase): expected_msg = f"Error: Invalid value for '--target': Directory '{target_path}' is a file." self.assertEqual(result.output.split("\n")[3], expected_msg) - @patch('gitlint.config.LintConfigGenerator.generate_config') + @patch("gitlint.config.LintConfigGenerator.generate_config") def test_generate_config(self, generate_config): - """ Test for the generate-config subcommand """ + """Test for the generate-config subcommand""" result = self.cli.invoke(cli.cli, ["generate-config"], input="tëstfile\n") self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE) - expected_msg = "Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + \ - f"Successfully generated {os.path.realpath('tëstfile')}\n" + expected_msg = ( + "Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + + f"Successfully generated {os.path.realpath('tëstfile')}\n" + ) self.assertEqual(result.output, expected_msg) generate_config.assert_called_once_with(os.path.realpath("tëstfile")) def test_generate_config_negative(self): - """ Negative test for the generate-config subcommand """ + """Negative test for the generate-config subcommand""" # Non-existing directory fake_dir = os.path.abspath("/föo") fake_path = os.path.join(fake_dir, "bar") result = self.cli.invoke(cli.cli, ["generate-config"], input=fake_path) self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) - expected_msg = f"Please specify a location for the sample gitlint config file [.gitlint]: {fake_path}\n" + \ - f"Error: Directory '{fake_dir}' does not exist.\n" + expected_msg = ( + f"Please specify a location for the sample gitlint config file [.gitlint]: {fake_path}\n" + + f"Error: Directory '{fake_dir}' does not exist.\n" + ) 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 " + \ - f"config file [.gitlint]: {sample_path}\n" + \ - f"Error: File \"{sample_path}\" already exists.\n" + expected_msg = ( + "Please specify a location for the sample gitlint " + f"config file [.gitlint]: {sample_path}\n" + f'Error: File "{sample_path}" already exists.\n' + ) self.assertEqual(result.output, expected_msg) - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + @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 """ + """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') + @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. """ + """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"]) + result = self.cli.invoke(cli.cli, ["--commits", "main...HEAD"]) - self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"master...HEAD\"") + self.assert_log_contains('DEBUG: gitlint.cli No commits in range "main...HEAD"') self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE) - @patch('gitlint.cli.get_stdin_data', return_value="WIP: tëst tïtle") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: tëst tïtle") def test_named_rules(self, _): - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: config_path = self.get_sample_path(os.path.join("config", "named-rules")) result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug"]) self.assertEqual(result.output, "") @@ -588,6 +670,6 @@ class CLITests(BaseTestCase): # Assert debug logs are correct expected_kwargs = self.get_system_info_dict() - expected_kwargs.update({'config_path': config_path}) - expected_logs = self.get_expected('cli/test_cli/test_named_rules_2', expected_kwargs) + expected_kwargs.update({"config_path": config_path}) + expected_logs = self.get_expected("cli/test_cli/test_named_rules_2", expected_kwargs) self.assert_logged(expected_logs) diff --git a/gitlint-core/gitlint/tests/cli/test_cli_hooks.py b/gitlint-core/gitlint/tests/cli/test_cli_hooks.py index 825345f..d4311c6 100644 --- a/gitlint-core/gitlint/tests/cli/test_cli_hooks.py +++ b/gitlint-core/gitlint/tests/cli/test_cli_hooks.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import io from io import StringIO import os @@ -23,21 +21,21 @@ class CLIHookTests(BaseTestCase): CONFIG_ERROR_CODE = 255 def setUp(self): - super(CLIHookTests, self).setUp() + super().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') + 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("/hür", "dur")) + @patch("gitlint.hooks.GitHookInstaller.install_commit_msg_hook") + @patch("gitlint.hooks.git_hooks_dir", return_value=os.path.join("/hür", "dur")) def test_install_hook(self, _, install_hook): - """ Test for install-hook subcommand """ + """Test for install-hook subcommand""" result = self.cli.invoke(cli.cli, ["install-hook"]) expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH) expected = f"Successfully installed gitlint commit-msg hook in {expected_path}\n" @@ -47,10 +45,10 @@ class CLIHookTests(BaseTestCase): 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("/hür", "dur")) + @patch("gitlint.hooks.GitHookInstaller.install_commit_msg_hook") + @patch("gitlint.hooks.git_hooks_dir", return_value=os.path.join("/hür", "dur")) def test_install_hook_target(self, _, install_hook): - """ Test for install-hook subcommand with a specific --target option specified """ + """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("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH) @@ -62,9 +60,9 @@ class CLIHookTests(BaseTestCase): 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("tëst")) + @patch("gitlint.hooks.GitHookInstaller.install_commit_msg_hook", side_effect=hooks.GitHookInstallerError("tëst")) def test_install_hook_negative(self, install_hook): - """ Negative test for install-hook subcommand """ + """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, "tëst\n") @@ -72,10 +70,10 @@ class CLIHookTests(BaseTestCase): 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("/hür", "dur")) + @patch("gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook") + @patch("gitlint.hooks.git_hooks_dir", return_value=os.path.join("/hür", "dur")) def test_uninstall_hook(self, _, uninstall_hook): - """ Test for uninstall-hook subcommand """ + """Test for uninstall-hook subcommand""" result = self.cli.invoke(cli.cli, ["uninstall-hook"]) expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH) expected = f"Successfully uninstalled gitlint commit-msg hook from {expected_path}\n" @@ -85,9 +83,9 @@ class CLIHookTests(BaseTestCase): 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("tëst")) + @patch("gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook", side_effect=hooks.GitHookInstallerError("tëst")) def test_uninstall_hook_negative(self, uninstall_hook): - """ Negative test for uninstall-hook subcommand """ + """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, "tëst\n") @@ -96,8 +94,8 @@ class CLIHookTests(BaseTestCase): uninstall_hook.assert_called_once_with(expected_config) def test_run_hook_no_tty(self): - """ Test for run-hook subcommand. - When no TTY is available (like is the case for this test), the hook will abort after the first check. + """Test for run-hook subcommand. + When no TTY is available (like is the case for this test), the hook will abort after the first check. """ # No need to patch git as we're passing a msg-filename to run-hook, so no git calls are made. @@ -110,20 +108,20 @@ class CLIHookTests(BaseTestCase): with self.tempdir() as tmpdir: msg_filename = os.path.join(tmpdir, "hür") - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write("WIP: tïtle\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"]) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_no_tty_1_stdout')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_no_tty_1_stdout")) self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_no_tty_1_stderr")) # exit code is 1 because aborted (no stdin available) self.assertEqual(result.exit_code, 1) - @patch('gitlint.cli.shell') + @patch("gitlint.cli.shell") def test_run_hook_edit(self, shell): - """ Test for run-hook subcommand, answering 'e(dit)' after commit-hook """ + """Test for run-hook subcommand, answering 'e(dit)' after commit-hook""" set_editors = [None, "myeditor"] expected_editors = ["vim -n", "myeditor"] @@ -131,20 +129,28 @@ class CLIHookTests(BaseTestCase): for i in range(0, len(set_editors)): if set_editors[i]: - os.environ['EDITOR'] = set_editors[i] + os.environ["EDITOR"] = set_editors[i] + else: + # When set_editors[i] == None, ensure we don't fallback to EDITOR set in shell invocating the tests + os.environ.pop("EDITOR", None) - with self.patch_input(['e', 'e', 'n']): + with self.patch_input(["e", "e", "n"]): with self.tempdir() as tmpdir: msg_filename = os.path.realpath(os.path.join(tmpdir, "hür")) - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write(commit_messages[i] + "\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"]) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_edit_1_stdout', - {"commit_msg": commit_messages[i]})) - expected = self.get_expected("cli/test_cli_hooks/test_hook_edit_1_stderr", - {"commit_msg": commit_messages[i]}) + self.assertEqual( + result.output, + self.get_expected( + "cli/test_cli_hooks/test_hook_edit_1_stdout", {"commit_msg": commit_messages[i]} + ), + ) + expected = self.get_expected( + "cli/test_cli_hooks/test_hook_edit_1_stderr", {"commit_msg": commit_messages[i]} + ) self.assertEqual(stderr.getvalue(), expected) # exit code = number of violations @@ -155,17 +161,17 @@ class CLIHookTests(BaseTestCase): self.assert_log_contains(f"DEBUG: gitlint.cli run-hook: {expected_editors[i]} {msg_filename}") def test_run_hook_no(self): - """ Test for run-hook subcommand, answering 'n(o)' after commit-hook """ + """Test for run-hook subcommand, answering 'n(o)' after commit-hook""" - with self.patch_input(['n']): + with self.patch_input(["n"]): with self.tempdir() as tmpdir: msg_filename = os.path.join(tmpdir, "hür") - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write("WIP: höok no\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"]) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_no_1_stdout')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_no_1_stdout")) self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_no_1_stderr")) # We decided not to keep the commit message: hook returns number of violations (>0) @@ -174,16 +180,16 @@ class CLIHookTests(BaseTestCase): self.assert_log_contains("DEBUG: gitlint.cli run-hook: commit message declined") def test_run_hook_yes(self): - """ Test for run-hook subcommand, answering 'y(es)' after commit-hook """ - with self.patch_input(['y']): + """Test for run-hook subcommand, answering 'y(es)' after commit-hook""" + with self.patch_input(["y"]): with self.tempdir() as tmpdir: msg_filename = os.path.join(tmpdir, "hür") - with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f: + with open(msg_filename, "w", encoding=DEFAULT_ENCODING) as f: f.write("WIP: höok yes\n") - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"]) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_yes_1_stdout')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_yes_1_stdout")) self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_yes_1_stderr")) # Exit code is 0 because we decide to keep the commit message @@ -191,23 +197,23 @@ class CLIHookTests(BaseTestCase): self.assertEqual(result.exit_code, 0) self.assert_log_contains("DEBUG: gitlint.cli run-hook: commit message accepted") - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_run_hook_negative(self, sh, _): - """ Negative test for the run-hook subcommand: testing whether exceptions are correctly handled when + """Negative test for the run-hook subcommand: testing whether exceptions are correctly handled when running `gitlint run-hook`. """ # GIT_CONTEXT_ERROR_CODE: git error error_msg = b"fatal: not a git repository (or any of the parent directories): .git" sh.git.side_effect = ErrorReturnCode("full command", b"stdout", error_msg) result = self.cli.invoke(cli.cli, ["run-hook"]) - expected = self.get_expected('cli/test_cli_hooks/test_run_hook_negative_1', {'git_repo': os.getcwd()}) + expected = self.get_expected("cli/test_cli_hooks/test_run_hook_negative_1", {"git_repo": os.getcwd()}) self.assertEqual(result.output, expected) self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE) # USAGE_ERROR_CODE: incorrect use of gitlint result = self.cli.invoke(cli.cli, ["--staged", "run-hook"]) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_run_hook_negative_2')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_run_hook_negative_2")) self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) # CONFIG_ERROR_CODE: incorrect config. Note that this is handled before the hook even runs @@ -215,67 +221,66 @@ class CLIHookTests(BaseTestCase): self.assertEqual(result.output, "Config Error: No such rule 'föo'\n") self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE) - @patch('gitlint.cli.get_stdin_data', return_value="WIP: Test hook stdin tïtle\n") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: Test hook stdin tïtle\n") def test_run_hook_stdin_violations(self, _): - """ Test for passing stdin data to run-hook, expecting some violations. Equivalent of: - $ echo "WIP: Test hook stdin tïtle" | gitlint run-hook + """Test for passing stdin data to run-hook, expecting some violations. Equivalent of: + $ echo "WIP: Test hook stdin tïtle" | gitlint run-hook """ - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["run-hook"]) - expected_stderr = self.get_expected('cli/test_cli_hooks/test_hook_stdin_violations_1_stderr') + expected_stderr = self.get_expected("cli/test_cli_hooks/test_hook_stdin_violations_1_stderr") self.assertEqual(stderr.getvalue(), expected_stderr) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_stdin_violations_1_stdout')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_stdin_violations_1_stdout")) # Hook will auto-abort because we're using stdin. Abort = exit code 1 self.assertEqual(result.exit_code, 1) - @patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n\nTest bödy that is long enough") + @patch("gitlint.cli.get_stdin_data", return_value="Test tïtle\n\nTest bödy that is long enough") def test_run_hook_stdin_no_violations(self, _): - """ Test for passing stdin data to run-hook, expecting *NO* violations, Equivalent of: - $ echo -e "Test tïtle\n\nTest bödy that is long enough" | gitlint run-hook + """Test for passing stdin data to run-hook, expecting *NO* violations, Equivalent of: + $ echo -e "Test tïtle\n\nTest bödy that is long enough" | gitlint run-hook """ - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["run-hook"]) self.assertEqual(stderr.getvalue(), "") # no errors = no stderr output - expected_stdout = self.get_expected('cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout') + expected_stdout = self.get_expected("cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout") self.assertEqual(result.output, expected_stdout) self.assertEqual(result.exit_code, 0) - @patch('gitlint.cli.get_stdin_data', return_value="WIP: Test hook config tïtle\n") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: Test hook config tïtle\n") def test_run_hook_config(self, _): - """ Test that gitlint still respects config when running run-hook, equivalent of: - $ echo "WIP: Test hook config tïtle" | gitlint -c title-max-length.line-length=5 --ignore B6 run-hook + """Test that gitlint still respects config when running run-hook, equivalent of: + $ echo "WIP: Test hook config tïtle" | gitlint -c title-max-length.line-length=5 --ignore B6 run-hook """ - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["-c", "title-max-length.line-length=5", "--ignore", "B6", "run-hook"]) - self.assertEqual(stderr.getvalue(), self.get_expected('cli/test_cli_hooks/test_hook_config_1_stderr')) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_config_1_stdout')) + self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_config_1_stderr")) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_config_1_stdout")) # Hook will auto-abort because we're using stdin. Abort = exit code 1 self.assertEqual(result.exit_code, 1) - @patch('gitlint.cli.get_stdin_data', return_value=False) - @patch('gitlint.git.sh') + @patch("gitlint.cli.get_stdin_data", return_value=False) + @patch("gitlint.git.sh") def test_run_hook_local_commit(self, sh, _): - """ Test running the hook on the last commit-msg from the local repo, equivalent of: - $ gitlint run-hook - and then choosing 'e' + """Test running the hook on the last commit-msg from the local repo, equivalent of: + $ gitlint run-hook + and then choosing 'e' """ sh.git.side_effect = [ "6f29bf81a8322a04071bb794666e48c443a90360", - "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "WIP: commït-title\n\ncommït-body", + "test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\nWIP: commït-title\n\ncommït-body", "#", # git config --get core.commentchar + "1\t5\tfile1.txt\n3\t4\tpåth/to/file2.txt\n", "commit-1-branch-1\ncommit-1-branch-2\n", - "file1.txt\npåth/to/file2.txt\n" ] - with self.patch_input(['e']): - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with self.patch_input(["e"]): + with patch("gitlint.display.stderr", new=StringIO()) as stderr: result = self.cli.invoke(cli.cli, ["run-hook"]) - expected = self.get_expected('cli/test_cli_hooks/test_hook_local_commit_1_stderr') + expected = self.get_expected("cli/test_cli_hooks/test_hook_local_commit_1_stderr") self.assertEqual(stderr.getvalue(), expected) - self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_local_commit_1_stdout')) + self.assertEqual(result.output, self.get_expected("cli/test_cli_hooks/test_hook_local_commit_1_stdout")) # If we can't edit the message, run-hook follows regular gitlint behavior and exit code = # violations self.assertEqual(result.exit_code, 2) diff --git a/gitlint-core/gitlint/tests/config/test_config.py b/gitlint-core/gitlint/tests/config/test_config.py index c3fd78a..852bf75 100644 --- a/gitlint-core/gitlint/tests/config/test_config.py +++ b/gitlint-core/gitlint/tests/config/test_config.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from unittest.mock import patch from gitlint import rules @@ -9,16 +7,15 @@ from gitlint.tests.base import BaseTestCase 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) + 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) + 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() @@ -26,18 +23,20 @@ class LintConfigTests(BaseTestCase): # non-existing rule expected_error_msg = "No such rule 'föobar'" with self.assertRaisesMessage(LintConfigError, expected_error_msg): - config.set_rule_option(u'föobar', u'lïne-length', 60) + config.set_rule_option("föobar", "lïne-length", 60) # non-existing option expected_error_msg = "Rule 'title-max-length' has no option 'föobar'" with self.assertRaisesMessage(LintConfigError, expected_error_msg): - config.set_rule_option('title-max-length', u'föobar', 60) + config.set_rule_option("title-max-length", "föobar", 60) # invalid option value - expected_error_msg = "'föo' is not a valid value for option 'title-max-length.line-length'. " + \ - "Option 'line-length' must be a positive integer (current value: 'föo')." + expected_error_msg = ( + "'föo' is not a valid value for option 'title-max-length.line-length'. " + "Option 'line-length' must be a positive integer (current value: 'föo')." + ) with self.assertRaisesMessage(LintConfigError, expected_error_msg): - config.set_rule_option('title-max-length', 'line-length', "föo") + config.set_rule_option("title-max-length", "line-length", "föo") def test_set_general_option(self): config = LintConfig() @@ -45,12 +44,14 @@ class LintConfigTests(BaseTestCase): # Check that default general options are correct self.assertTrue(config.ignore_merge_commits) self.assertTrue(config.ignore_fixup_commits) + self.assertTrue(config.ignore_fixup_amend_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.fail_without_commits) + self.assertFalse(config.regex_style_search) self.assertFalse(config.debug) self.assertEqual(config.verbosity, 3) active_rule_classes = tuple(type(rule) for rule in config.rules) @@ -76,6 +77,10 @@ class LintConfigTests(BaseTestCase): config.set_general_option("ignore-fixup-commits", "false") self.assertFalse(config.ignore_fixup_commits) + # ignore_fixup_amend_commit + config.set_general_option("ignore-fixup-amend-commits", "false") + self.assertFalse(config.ignore_fixup_amend_commits) + # ignore_squash_commit config.set_general_option("ignore-squash-commits", "false") self.assertFalse(config.ignore_squash_commits) @@ -100,6 +105,10 @@ class LintConfigTests(BaseTestCase): config.set_general_option("fail-without-commits", "true") self.assertTrue(config.fail_without_commits) + # regex-style-search + config.set_general_option("regex-style-search", "true") + self.assertTrue(config.regex_style_search) + # target config.set_general_option("target", self.SAMPLES_DIR) self.assertEqual(config.target, self.SAMPLES_DIR) @@ -118,8 +127,8 @@ class LintConfigTests(BaseTestCase): self.assertTrue(actual_rule.is_contrib) self.assertEqual(str(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.id, "CT1") + self.assertEqual(actual_rule.name, "contrib-title-conventional-commits") self.assertEqual(actual_rule.target, rules.CommitMessageTitle) expected_rule_option = options.ListOption( @@ -129,15 +138,15 @@ class LintConfigTests(BaseTestCase): ) self.assertListEqual(actual_rule.options_spec, [expected_rule_option]) - self.assertDictEqual(actual_rule.options, {'types': 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(str(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') + self.assertEqual(actual_rule.id, "CC1") + self.assertEqual(actual_rule.name, "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") @@ -157,7 +166,7 @@ class LintConfigTests(BaseTestCase): # UserRuleError, RuleOptionError should be re-raised as LintConfigErrors side_effects = [rules.UserRuleError("üser-rule"), options.RuleOptionError("rüle-option")] for side_effect in side_effects: - with patch('gitlint.config.rule_finder.find_rule_classes', side_effect=side_effect): + with patch("gitlint.config.rule_finder.find_rule_classes", side_effect=side_effect): with self.assertRaisesMessage(LintConfigError, str(side_effect)): config.contrib = "contrib-title-conventional-commits" @@ -166,15 +175,15 @@ class LintConfigTests(BaseTestCase): 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') + actual_rule = config.rules.find_rule("UC1") self.assertTrue(actual_rule.is_user_defined) self.assertEqual(str(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.id, "UC1") + self.assertEqual(actual_rule.name, "my-üser-commit-rule") self.assertEqual(actual_rule.target, None) - expected_rule_option = options.IntOption('violation-count', 1, "Number of violåtions to return") + expected_rule_option = options.IntOption("violation-count", 1, "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}) + 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) @@ -189,8 +198,9 @@ class LintConfigTests(BaseTestCase): config.extra_path = "föo/bar" # extra path contains classes with errors - with self.assertRaisesMessage(LintConfigError, - "User-defined rule class 'MyUserLineRule' must have a 'validate' method"): + with self.assertRaisesMessage( + 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): @@ -218,31 +228,37 @@ class LintConfigTests(BaseTestCase): config.verbosity = value # invalid ignore_xxx_commits - ignore_attributes = ["ignore_merge_commits", "ignore_fixup_commits", "ignore_squash_commits", - "ignore_revert_commits"] + ignore_attributes = [ + "ignore_merge_commits", + "ignore_fixup_commits", + "ignore_fixup_amend_commits", + "ignore_squash_commits", + "ignore_revert_commits", + ] incorrect_values = [-1, 4, "föo"] for attribute in ignore_attributes: for value in incorrect_values: option_name = attribute.replace("_", "-") - with self.assertRaisesMessage(LintConfigError, - f"Option '{option_name}' must be either 'true' or 'false'"): + with self.assertRaisesMessage( + LintConfigError, f"Option '{option_name}' must be either 'true' or 'false'" + ): 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', 'fail_without_commits']: + for attribute in ["debug", "staged", "ignore_stdin", "fail_without_commits", "regex_style_search"]: option_name = attribute.replace("_", "-") - with self.assertRaisesMessage(LintConfigError, - f"Option '{option_name}' must be either 'true' or 'false'"): + with self.assertRaisesMessage(LintConfigError, f"Option '{option_name}' must be either 'true' or 'false'"): setattr(config, attribute, "föobar") # extra-path has its own negative test # invalid target - with self.assertRaisesMessage(LintConfigError, - "Option target must be an existing directory (current value: 'föo/bar')"): + with self.assertRaisesMessage( + LintConfigError, "Option target must be an existing directory (current value: 'föo/bar')" + ): config.target = "föo/bar" def test_ignore_independent_from_rules(self): @@ -259,12 +275,25 @@ class LintConfigTests(BaseTestCase): self.assertNotEqual(LintConfig(), LintConfigGenerator()) # Ensure LintConfig are not equal if they differ on their attributes - attrs = [("verbosity", 1), ("rules", []), ("ignore_stdin", True), ("debug", True), - ("ignore", ["T1"]), ("staged", True), ("_config_path", self.get_sample_path()), - ("ignore_merge_commits", False), ("ignore_fixup_commits", False), - ("ignore_squash_commits", False), ("ignore_revert_commits", False), - ("extra_path", self.get_sample_path("user_rules")), ("target", self.get_sample_path()), - ("contrib", ["CC1"])] + attrs = [ + ("verbosity", 1), + ("rules", []), + ("ignore_stdin", True), + ("fail_without_commits", True), + ("regex_style_search", True), + ("debug", True), + ("ignore", ["T1"]), + ("staged", True), + ("_config_path", self.get_sample_path()), + ("ignore_merge_commits", False), + ("ignore_fixup_commits", False), + ("ignore_fixup_amend_commits", False), + ("ignore_squash_commits", False), + ("ignore_revert_commits", False), + ("extra_path", self.get_sample_path("user_rules")), + ("target", self.get_sample_path()), + ("contrib", ["CC1"]), + ] for attr, val in attrs: config = LintConfig() setattr(config, attr, val) @@ -281,7 +310,7 @@ class LintConfigTests(BaseTestCase): class LintConfigGeneratorTests(BaseTestCase): @staticmethod - @patch('gitlint.config.shutil.copyfile') + @patch("gitlint.config.shutil.copyfile") def test_install_commit_msg_hook_negative(copy): LintConfigGenerator.generate_config("föo/bar/test") copy.assert_called_with(GITLINT_CONFIG_TEMPLATE_SRC_PATH, "föo/bar/test") diff --git a/gitlint-core/gitlint/tests/config/test_config_builder.py b/gitlint-core/gitlint/tests/config/test_config_builder.py index e0d7f9b..dfb77cd 100644 --- a/gitlint-core/gitlint/tests/config/test_config_builder.py +++ b/gitlint-core/gitlint/tests/config/test_config_builder.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import copy from gitlint.tests.base import BaseTestCase @@ -14,24 +13,27 @@ class LintConfigBuilderTests(BaseTestCase): 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.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}} + 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.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): @@ -82,8 +84,8 @@ class LintConfigBuilderTests(BaseTestCase): 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) + 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() @@ -129,8 +131,10 @@ class LintConfigBuilderTests(BaseTestCase): path = self.get_sample_path("config/invalid-option-value") config_builder = LintConfigBuilder() config_builder.set_from_config_file(path) - expected_error_msg = "'föo' is not a valid value for option 'title-max-length.line-length'. " + \ - "Option 'line-length' must be a positive integer (current value: 'föo')." + expected_error_msg = ( + "'föo' is not a valid value for option 'title-max-length.line-length'. " + "Option 'line-length' must be a positive integer (current value: 'föo')." + ) with self.assertRaisesMessage(LintConfigError, expected_error_msg): config_builder.build() @@ -139,14 +143,19 @@ class LintConfigBuilderTests(BaseTestCase): # 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', - "title-must-not-contain-word.words=håha"]) + config_builder.set_config_from_string_list( + [ + "general.verbosity=1", + "title-max-length.line-length=60", + "body-max-line-length.line-length=120", + "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'), ["håha"]) + 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"), ["håha"]) self.assertEqual(config.verbosity, 1) def test_set_config_from_string_list_negative(self): @@ -175,12 +184,12 @@ class LintConfigBuilderTests(BaseTestCase): # no period between rule and option names expected_msg = "'föobar=1' is an invalid configuration option. Use '<rule>.<option>=<value>'" with self.assertRaisesMessage(LintConfigError, expected_msg): - config_builder.set_config_from_string_list([u'föobar=1']) + config_builder.set_config_from_string_list(["föobar=1"]) def test_rebuild_config(self): # normal config build config_builder = LintConfigBuilder() - config_builder.set_option('general', 'verbosity', 3) + config_builder.set_option("general", "verbosity", 3) lint_config = config_builder.build() self.assertEqual(lint_config.verbosity, 3) @@ -193,9 +202,9 @@ class LintConfigBuilderTests(BaseTestCase): 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}} + 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 @@ -203,7 +212,7 @@ class LintConfigBuilderTests(BaseTestCase): 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) + config_builder.set_option("title-max-length", "line-length", 120) self.assertDictEqual(cloned_builder._config_blueprint, expected) def test_named_rules(self): @@ -215,17 +224,22 @@ class LintConfigBuilderTests(BaseTestCase): # Add a named rule by setting an option in the config builder that follows the named rule pattern # Assert that whitespace in the rule name is stripped - rule_qualifiers = [u'T7:my-extra-rüle', u' T7 : my-extra-rüle ', u'\tT7:\tmy-extra-rüle\t', - u'T7:\t\n \tmy-extra-rüle\t\n\n', "title-match-regex:my-extra-rüle"] + rule_qualifiers = [ + "T7:my-extra-rüle", + " T7 : my-extra-rüle ", + "\tT7:\tmy-extra-rüle\t", + "T7:\t\n \tmy-extra-rüle\t\n\n", + "title-match-regex:my-extra-rüle", + ] for rule_qualifier in rule_qualifiers: config_builder = LintConfigBuilder() - config_builder.set_option(rule_qualifier, 'regex', "föo") + config_builder.set_option(rule_qualifier, "regex", "föo") expected_rules = copy.deepcopy(default_rules) - my_rule = rules.TitleRegexMatches({'regex': "föo"}) + my_rule = rules.TitleRegexMatches({"regex": "föo"}) my_rule.id = rules.TitleRegexMatches.id + ":my-extra-rüle" my_rule.name = rules.TitleRegexMatches.name + ":my-extra-rüle" - expected_rules._rules[u'T7:my-extra-rüle'] = my_rule + expected_rules._rules["T7:my-extra-rüle"] = my_rule self.assertEqual(config_builder.build().rules, expected_rules) # assert that changing an option on the newly added rule is passed correctly to the RuleCollection @@ -233,20 +247,20 @@ class LintConfigBuilderTests(BaseTestCase): # to the same rule for other_rule_qualifier in rule_qualifiers: cb = config_builder.clone() - cb.set_option(other_rule_qualifier, 'regex', other_rule_qualifier + "bōr") + cb.set_option(other_rule_qualifier, "regex", other_rule_qualifier + "bōr") # before setting the expected rule option value correctly, the RuleCollection should be different self.assertNotEqual(cb.build().rules, expected_rules) # after setting the option on the expected rule, it should be equal - my_rule.options['regex'].set(other_rule_qualifier + "bōr") + my_rule.options["regex"].set(other_rule_qualifier + "bōr") self.assertEqual(cb.build().rules, expected_rules) - my_rule.options['regex'].set("wrong") + my_rule.options["regex"].set("wrong") def test_named_rules_negative(self): # T7 = title-match-regex # Invalid rule name for invalid_name in ["", " ", " ", "\t", "\n", "å b", "å:b", "åb:", ":åb"]: config_builder = LintConfigBuilder() - config_builder.set_option(f"T7:{invalid_name}", 'regex', "tëst") + config_builder.set_option(f"T7:{invalid_name}", "regex", "tëst") expected_msg = f"The rule-name part in 'T7:{invalid_name}' cannot contain whitespace, colons or be empty" with self.assertRaisesMessage(LintConfigError, expected_msg): config_builder.build() diff --git a/gitlint-core/gitlint/tests/config/test_config_precedence.py b/gitlint-core/gitlint/tests/config/test_config_precedence.py index aa4de88..22197e8 100644 --- a/gitlint-core/gitlint/tests/config/test_config_precedence.py +++ b/gitlint-core/gitlint/tests/config/test_config_precedence.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from io import StringIO from click.testing import CliRunner @@ -13,9 +11,10 @@ from gitlint.config import LintConfigBuilder class LintConfigPrecedenceTests(BaseTestCase): def setUp(self): + super().setUp() self.cli = CliRunner() - @patch('gitlint.cli.get_stdin_data', return_value="WIP:fö\n\nThis is å test message\n") + @patch("gitlint.cli.get_stdin_data", return_value="WIP:fö\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 @@ -28,60 +27,63 @@ class LintConfigPrecedenceTests(BaseTestCase): config_path = self.get_sample_path("config/gitlintconfig") # 1. commandline convenience flags - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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:fö\"\n") # 2. environment variables - with patch('gitlint.display.stderr', new=StringIO()) as stderr: - result = self.cli.invoke(cli.cli, ["-c", "general.verbosity=2", "--config", config_path], - env={"GITLINT_VERBOSITY": "3"}) + with patch("gitlint.display.stderr", new=StringIO()) as stderr: + result = self.cli.invoke( + cli.cli, ["-c", "general.verbosity=2", "--config", config_path], env={"GITLINT_VERBOSITY": "3"} + ) self.assertEqual(result.output, "") self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n") # 3. commandline -c flags - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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") # 4. config file - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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") # 5. default config - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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:fö\"\n") - @patch('gitlint.cli.get_stdin_data', return_value="WIP: This is å test") + @patch("gitlint.cli.get_stdin_data", return_value="WIP: This is å test") def test_ignore_precedence(self, get_stdin_data): - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + 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(), - "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is å test\"\n") + self.assertEqual( + stderr.getvalue(), "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: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: get_stdin_data.return_value = "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"]) + 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(), "1: T1 Title exceeds max length (14>5): \"This is å test\"\n") + self.assertEqual(stderr.getvalue(), '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 @@ -89,10 +91,10 @@ class LintConfigPrecedenceTests(BaseTestCase): # 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) + config_builder.set_option("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_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) + self.assertEqual(config.get_rule_option("my-üser-commit-rule", "violation-count"), 3) diff --git a/gitlint-core/gitlint/tests/config/test_rule_collection.py b/gitlint-core/gitlint/tests/config/test_rule_collection.py index 17b50cc..ea7039f 100644 --- a/gitlint-core/gitlint/tests/config/test_rule_collection.py +++ b/gitlint-core/gitlint/tests/config/test_rule_collection.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from collections import OrderedDict from gitlint import rules from gitlint.config import RuleCollection @@ -7,7 +5,6 @@ from gitlint.tests.base import BaseTestCase class RuleCollectionTests(BaseTestCase): - def test_add_rule(self): collection = RuleCollection() collection.add_rule(rules.TitleMaxLength, "my-rüle", {"my_attr": "föo", "my_attr2": 123}) @@ -29,18 +26,18 @@ class RuleCollectionTests(BaseTestCase): # find by id expected = rules.TitleMaxLength() - rule = collection.find_rule('T1') + rule = collection.find_rule("T1") self.assertEqual(rule, expected) self.assertEqual(rule.my_attr, "föo") # find by name expected2 = rules.TitleTrailingWhitespace() - rule = collection.find_rule('title-trailing-whitespace') + rule = collection.find_rule("title-trailing-whitespace") self.assertEqual(rule, expected2) self.assertEqual(rule.my_attr, "föo") # find non-existing - rule = collection.find_rule(u'föo') + rule = collection.find_rule("föo") self.assertIsNone(rule) def test_delete_rules_by_attr(self): diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_authors_commit.py b/gitlint-core/gitlint/tests/contrib/rules/test_authors_commit.py new file mode 100644 index 0000000..5ea9d8f --- /dev/null +++ b/gitlint-core/gitlint/tests/contrib/rules/test_authors_commit.py @@ -0,0 +1,106 @@ +from collections import namedtuple +from unittest.mock import patch +from gitlint.tests.base import BaseTestCase +from gitlint.rules import RuleViolation +from gitlint.config import LintConfig + +from gitlint.contrib.rules.authors_commit import AllowedAuthors + + +class ContribAuthorsCommitTests(BaseTestCase): + def setUp(self): + author = namedtuple("Author", "name, email") + self.author_1 = author("John Doe", "john.doe@mail.com") + self.author_2 = author("Bob Smith", "bob.smith@mail.com") + self.rule = AllowedAuthors() + self.gitcontext = self.get_gitcontext() + + def get_gitcontext(self): + gitcontext = self.gitcontext(self.get_sample("commit_message/sample1")) + gitcontext.repository_path = self.get_sample_path("config") + return gitcontext + + def get_commit(self, name, email): + commit = self.gitcommit("commit_message/sample1", author_name=name, author_email=email) + commit.message.context = self.gitcontext + return commit + + def test_enable(self): + for rule_ref in ["CC3", "contrib-allowed-authors"]: + config = LintConfig() + config.contrib = [rule_ref] + self.assertIn(AllowedAuthors(), config.rules) + + def test_authors_succeeds(self): + for author in [self.author_1, self.author_2]: + commit = self.get_commit(author.name, author.email) + violations = self.rule.validate(commit) + self.assertListEqual([], violations) + + def test_authors_email_is_case_insensitive(self): + for email in [ + self.author_2.email.capitalize(), + self.author_2.email.lower(), + self.author_2.email.upper(), + ]: + commit = self.get_commit(self.author_2.name, email) + violations = self.rule.validate(commit) + self.assertListEqual([], violations) + + def test_authors_name_is_case_sensitive(self): + for name in [self.author_2.name.lower(), self.author_2.name.upper()]: + commit = self.get_commit(name, self.author_2.email) + violations = self.rule.validate(commit) + expected_violation = RuleViolation( + "CC3", + f"Author not in 'AUTHORS' file: " f'"{name} <{self.author_2.email}>"', + ) + self.assertListEqual([expected_violation], violations) + + def test_authors_bad_name_fails(self): + for name in ["", "root"]: + commit = self.get_commit(name, self.author_2.email) + violations = self.rule.validate(commit) + expected_violation = RuleViolation( + "CC3", + f"Author not in 'AUTHORS' file: " f'"{name} <{self.author_2.email}>"', + ) + self.assertListEqual([expected_violation], violations) + + def test_authors_bad_email_fails(self): + for email in ["", "root@example.com"]: + commit = self.get_commit(self.author_2.name, email) + violations = self.rule.validate(commit) + expected_violation = RuleViolation( + "CC3", + f"Author not in 'AUTHORS' file: " f'"{self.author_2.name} <{email}>"', + ) + self.assertListEqual([expected_violation], violations) + + def test_authors_invalid_combination_fails(self): + commit = self.get_commit(self.author_1.name, self.author_2.email) + violations = self.rule.validate(commit) + expected_violation = RuleViolation( + "CC3", + f"Author not in 'AUTHORS' file: " f'"{self.author_1.name} <{self.author_2.email}>"', + ) + self.assertListEqual([expected_violation], violations) + + @patch( + "gitlint.contrib.rules.authors_commit.Path.read_text", + return_value="John Doe <john.doe@mail.com>", + ) + def test_read_authors_file(self, _mock_read_text): + authors, authors_file_name = AllowedAuthors._read_authors_from_file(self.gitcontext) + self.assertEqual(authors_file_name, "AUTHORS") + self.assertEqual(len(authors), 1) + self.assertEqual(authors, {self.author_1}) + + @patch( + "gitlint.contrib.rules.authors_commit.Path.exists", + return_value=False, + ) + def test_read_authors_file_missing_file(self, _mock_iterdir): + with self.assertRaises(FileNotFoundError) as err: + AllowedAuthors._read_authors_from_file(self.gitcontext) + self.assertEqual(err.exception.args[0], "AUTHORS file not found") diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py index 5da5cd5..7ce9c89 100644 --- a/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py +++ b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py @@ -1,5 +1,3 @@ - -# -*- coding: utf-8 -*- from gitlint.tests.base import BaseTestCase from gitlint.rules import RuleViolation from gitlint.contrib.rules.conventional_commit import ConventionalCommit @@ -7,10 +5,9 @@ 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']: + for rule_ref in ["CT1", "contrib-title-conventional-commits"]: config = LintConfig() config.contrib = [rule_ref] self.assertIn(ConventionalCommit(), config.rules) @@ -24,28 +21,38 @@ class ContribConventionalCommitTests(BaseTestCase): 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, ci, build", "bår: foo") + expected_violation = RuleViolation( + "CT1", + "Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build", + "bår: foo", + ) violations = rule.validate("bår: foo", None) self.assertListEqual([expected_violation], violations) # assert violation when use strange chars after correct type - expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs," - " style, refactor, perf, test, revert, ci, build", - "feat_wrong_chars: föo") + expected_violation = RuleViolation( + "CT1", + "Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build", + "feat_wrong_chars: föo", + ) violations = rule.validate("feat_wrong_chars: föo", None) self.assertListEqual([expected_violation], violations) # assert violation when use strange chars after correct type - expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs," - " style, refactor, perf, test, revert, ci, build", - "feat_wrong_chars(scope): föo") + expected_violation = RuleViolation( + "CT1", + "Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build", + "feat_wrong_chars(scope): föo", + ) violations = rule.validate("feat_wrong_chars(scope): föo", 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'", "fix föo") + expected_violation = RuleViolation( + "CT1", + "Title does not follow ConventionalCommits.org format 'type(optional-scope): description'", + "fix föo", + ) violations = rule.validate("fix föo", None) self.assertListEqual([expected_violation], violations) @@ -58,7 +65,7 @@ class ContribConventionalCommitTests(BaseTestCase): self.assertListEqual([], violations) # assert no violation when adding new type - rule = ConventionalCommit({'types': ["föo", "bär"]}) + rule = ConventionalCommit({"types": ["föo", "bär"]}) for typ in ["föo", "bär"]: violations = rule.validate(typ + ": hür dur", None) self.assertListEqual([], violations) @@ -69,7 +76,7 @@ class ContribConventionalCommitTests(BaseTestCase): self.assertListEqual([expected_violation], violations) # assert no violation when adding new type named with numbers - rule = ConventionalCommit({'types': ["föo123", "123bär"]}) + rule = ConventionalCommit({"types": ["föo123", "123bär"]}) for typ in ["föo123", "123bär"]: violations = rule.validate(typ + ": hür dur", None) self.assertListEqual([], violations) diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py b/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py new file mode 100644 index 0000000..841640a --- /dev/null +++ b/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py @@ -0,0 +1,35 @@ +from gitlint.tests.base import BaseTestCase +from gitlint.rules import RuleViolation +from gitlint.contrib.rules.disallow_cleanup_commits import DisallowCleanupCommits + +from gitlint.config import LintConfig + + +class ContribDisallowCleanupCommitsTest(BaseTestCase): + def test_enable(self): + # Test that rule can be enabled in config + for rule_ref in ["CC2", "contrib-disallow-cleanup-commits"]: + config = LintConfig() + config.contrib = [rule_ref] + self.assertIn(DisallowCleanupCommits(), config.rules) + + def test_disallow_fixup_squash_commit(self): + # No violations when no 'fixup!' line and no 'squash!' line is present + rule = DisallowCleanupCommits() + violations = rule.validate(self.gitcommit("Föobar\n\nMy Body")) + self.assertListEqual(violations, []) + + # Assert violation when 'fixup!' in title + violations = rule.validate(self.gitcommit("fixup! Föobar\n\nMy Body")) + expected_violation = RuleViolation("CC2", "Fixup commits are not allowed", line_nr=1) + self.assertListEqual(violations, [expected_violation]) + + # Assert violation when 'squash!' in title + violations = rule.validate(self.gitcommit("squash! Föobar\n\nMy Body")) + expected_violation = RuleViolation("CC2", "Squash commits are not allowed", line_nr=1) + self.assertListEqual(violations, [expected_violation]) + + # Assert violation when 'amend!' in title + violations = rule.validate(self.gitcommit("amend! Föobar\n\nMy Body")) + expected_violation = RuleViolation("CC2", "Amend commits are not allowed", line_nr=1) + self.assertListEqual(violations, [expected_violation]) diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py b/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py index 0369cdc..88ff1db 100644 --- a/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py +++ b/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py @@ -1,5 +1,3 @@ - -# -*- coding: utf-8 -*- from gitlint.tests.base import BaseTestCase from gitlint.rules import RuleViolation from gitlint.contrib.rules.signedoff_by import SignedOffBy @@ -8,10 +6,9 @@ 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']: + for rule_ref in ["CC1", "contrib-body-requires-signed-off-by"]: config = LintConfig() config.contrib = [rule_ref] self.assertIn(SignedOffBy(), config.rules) diff --git a/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py b/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py index 8ab6539..bd098c6 100644 --- a/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py +++ b/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os from gitlint.tests.base import BaseTestCase @@ -8,13 +7,12 @@ from gitlint import rule_finder, rules 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. """ + """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) @@ -22,16 +20,18 @@ class ContribRuleTests(BaseTestCase): # 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 = "test_" + filename - error_msg = "Every Contrib Rule must have associated tests. " + \ - f"Expected test file {os.path.join(contrib_tests_dir, expected_test_file)} not found." + expected_test_file = f"test_{filename}" + error_msg = ( + "Every Contrib Rule must have associated tests. " + f"Expected test file {os.path.join(contrib_tests_dir, expected_test_file)} not found." + ) 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. + """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) @@ -47,10 +47,10 @@ class ContribRuleTests(BaseTestCase): 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. + """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) @@ -61,7 +61,7 @@ class ContribRuleTests(BaseTestCase): 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. """ + """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 :-) diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1 index fcd5d7e..4bd3b7d 100644 --- a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1 +++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1 @@ -13,11 +13,13 @@ contrib: [] ignore: title-trailing-whitespace,B2 ignore-merge-commits: False ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False fail-without-commits: False +regex-style-search: False verbosity: 1 debug: True target: {target} @@ -59,7 +61,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo. DEBUG: gitlint.git ('rev-list', 'foo...bar') @@ -67,8 +69,8 @@ DEBUG: gitlint.cli Linting 3 commit(s) DEBUG: gitlint.git ('log', '6f29bf81a8322a04071bb794666e48c443a90360', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.lint Linting commit 6f29bf81a8322a04071bb794666e48c443a90360 +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '6f29bf81a8322a04071bb794666e48c443a90360') DEBUG: gitlint.git ('branch', '--contains', '6f29bf81a8322a04071bb794666e48c443a90360') -DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '6f29bf81a8322a04071bb794666e48c443a90360') DEBUG: gitlint.lint Commit Object --- Commit Message ---- commït-title1 @@ -79,15 +81,20 @@ 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-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: ['a123'] Branches: ['commit-1-branch-1', 'commit-1-branch-2'] Changed Files: ['commit-1/file-1', 'commit-1/file-2'] +Changed Files Stats: + commit-1/file-1: 5 additions, 8 deletions + commit-1/file-2: 2 additions, 9 deletions ----------------------- DEBUG: gitlint.git ('log', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') DEBUG: gitlint.lint Linting commit 25053ccec5e28e1bb8f7551fdbb5ab213ada2401 +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401') DEBUG: gitlint.git ('branch', '--contains', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401') -DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401') DEBUG: gitlint.lint Commit Object --- Commit Message ---- commït-title2. @@ -98,15 +105,20 @@ 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-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: ['b123'] Branches: ['commit-2-branch-1', 'commit-2-branch-2'] Changed Files: ['commit-2/file-1', 'commit-2/file-2'] +Changed Files Stats: + commit-2/file-1: 5 additions, 8 deletions + commit-2/file-2: 7 additions, 9 deletions ----------------------- DEBUG: gitlint.git ('log', '4da2656b0dadc76c7ee3fd0243a96cb64007f125', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') DEBUG: gitlint.lint Linting commit 4da2656b0dadc76c7ee3fd0243a96cb64007f125 +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '4da2656b0dadc76c7ee3fd0243a96cb64007f125') DEBUG: gitlint.git ('branch', '--contains', '4da2656b0dadc76c7ee3fd0243a96cb64007f125') -DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '4da2656b0dadc76c7ee3fd0243a96cb64007f125') DEBUG: gitlint.lint Commit Object --- Commit Message ---- föobar @@ -116,9 +128,14 @@ 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-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: ['c123'] Branches: ['commit-3-branch-1', 'commit-3-branch-2'] Changed Files: ['commit-3/file-1', 'commit-3/file-2'] +Changed Files Stats: + commit-3/file-1: 1 additions, 4 deletions + commit-3/file-2: 3 additions, 4 deletions ----------------------- DEBUG: gitlint.cli Exit Code = 6
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 index 7c94b45..6d6da43 100644 --- a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 +++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 @@ -13,11 +13,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -59,7 +61,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Stdin data: 'WIP: tïtle ' @@ -75,9 +77,12 @@ Author: None <None> Date: None is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: [] Branches: [] Changed Files: [] +Changed Files Stats: {{}} ----------------------- DEBUG: gitlint.cli Exit Code = 3
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 index f37ffa0..59b2414 100644 --- a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 +++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 @@ -13,11 +13,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -59,17 +61,17 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Fetching additional meta-data from staged commit DEBUG: gitlint.cli Using --msg-filename. DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.lint Linting commit [SHA UNKNOWN] +DEBUG: gitlint.git ('diff', '--staged', '--numstat', '-r') DEBUG: gitlint.git ('config', '--get', 'user.name') DEBUG: gitlint.git ('config', '--get', 'user.email') DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD') -DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: msg-filename tïtle @@ -78,9 +80,14 @@ 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-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: [] Branches: ['my-branch'] Changed Files: ['commit-1/file-1', 'commit-1/file-2'] +Changed Files Stats: + commit-1/file-1: 3 additions, 4 deletions + commit-1/file-2: 4 additions, 7 deletions ----------------------- DEBUG: gitlint.cli Exit Code = 2
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 index 1d1020a..23df7b2 100644 --- a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 +++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 @@ -13,11 +13,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -59,7 +61,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Fetching additional meta-data from staged commit DEBUG: gitlint.cli Stdin data: 'WIP: tïtle @@ -68,10 +70,10 @@ DEBUG: gitlint.cli Stdin detected and not ignored. Using as input. DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.lint Linting commit [SHA UNKNOWN] +DEBUG: gitlint.git ('diff', '--staged', '--numstat', '-r') DEBUG: gitlint.git ('config', '--get', 'user.name') DEBUG: gitlint.git ('config', '--get', 'user.email') DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD') -DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: tïtle @@ -80,9 +82,14 @@ 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-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: [] Branches: ['my-branch'] Changed Files: ['commit-1/file-1', 'commit-1/file-2'] +Changed Files Stats: + commit-1/file-1: 1 additions, 5 deletions + commit-1/file-2: 8 additions, 9 deletions ----------------------- DEBUG: gitlint.cli Exit Code = 3
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_2 index 83c4bf2..c4491f1 100644 --- a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_2 +++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_2 @@ -13,11 +13,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -59,7 +61,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ T5:extra-wörds: title-must-not-contain-word:extra-wörds words=hür,tëst T5:even-more-wörds: title-must-not-contain-word:even-more-wörds @@ -78,9 +80,12 @@ Author: None <None> Date: None is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False +Parents: [] Branches: [] Changed Files: [] +Changed Files Stats: {{}} ----------------------- DEBUG: gitlint.cli Exit Code = 4
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/git/test_git.py b/gitlint-core/gitlint/tests/git/test_git.py index 7b9b7c6..9c73bd9 100644 --- a/gitlint-core/gitlint/tests/git/test_git.py +++ b/gitlint-core/gitlint/tests/git/test_git.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- import os -from unittest.mock import patch +from unittest.mock import patch, call from gitlint.shell import ErrorReturnCode, CommandNotFound @@ -10,25 +9,23 @@ from gitlint.git import GitContext, GitContextError, GitNotInstalledError, git_c class GitTests(BaseTestCase): - # Expected special_args passed to 'sh' - expected_sh_special_args = { - '_tty_out': False, - '_cwd': "fåke/path" - } + expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"} - @patch('gitlint.git.sh') + @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." + 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.assertRaisesMessage(GitNotInstalledError, expected_msg): GitContext.from_local_repository("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') + @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" @@ -51,10 +48,10 @@ class GitTests(BaseTestCase): # 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') + @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" + err = b"fatal: your current branch 'main' does not have any commits yet" sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err) @@ -64,25 +61,38 @@ class GitTests(BaseTestCase): # 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() + @patch("gitlint.git.sh") + def test_git_no_commits_get_branch(self, sh): + """Check that we can still read the current branch name when there's no commits. This is useful when + when trying to lint the first commit using the --staged flag. + """ # 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>...]'") + 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 = [ "#\n", # git config --get core.commentchar - ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err) + ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err), + "test-branch", # git branch --show-current ] - with self.assertRaisesMessage(GitContextError, expected_msg): - context = GitContext.from_commit_msg("test") - context.current_branch + context = GitContext.from_commit_msg("test") + self.assertEqual(context.current_branch, "test-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) + # assert that we try using `git rev-parse` first, and if that fails (as will be the case with the first commit), + # we fallback to `git branch --show-current` to determine the current branch name. + expected_calls = [ + call("config", "--get", "core.commentchar", _tty_out=False, _cwd=None, _ok_code=[0, 1]), + call("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None), + call("branch", "--show-current", _tty_out=False, _cwd=None), + ] + + self.assertEqual(sh.git.mock_calls, expected_calls) @patch("gitlint.git._git") def test_git_commentchar(self, git): @@ -93,11 +103,10 @@ class GitTests(BaseTestCase): git.return_value = "ä" self.assertEqual(git_commentchar(), "ä") - git.return_value = ';\n' - self.assertEqual(git_commentchar(os.path.join("/föo", "bar")), ';') + git.return_value = ";\n" + self.assertEqual(git_commentchar(os.path.join("/föo", "bar")), ";") - git.assert_called_with("config", "--get", "core.commentchar", _ok_code=[0, 1], - _cwd=os.path.join("/föo", "bar")) + git.assert_called_with("config", "--get", "core.commentchar", _ok_code=[0, 1], _cwd=os.path.join("/föo", "bar")) @patch("gitlint.git._git") def test_git_hooks_dir(self, git): diff --git a/gitlint-core/gitlint/tests/git/test_git_commit.py b/gitlint-core/gitlint/tests/git/test_git_commit.py index 02c5795..b27deaf 100644 --- a/gitlint-core/gitlint/tests/git/test_git_commit.py +++ b/gitlint-core/gitlint/tests/git/test_git_commit.py @@ -1,6 +1,6 @@ -# -*- coding: utf-8 -*- import copy import datetime +from pathlib import Path import dateutil @@ -9,29 +9,33 @@ import arrow from unittest.mock import patch, call from gitlint.tests.base import BaseTestCase -from gitlint.git import GitContext, GitCommit, GitContextError, LocalGitCommit, StagedLocalGitCommit, GitCommitMessage +from gitlint.git import ( + GitChangedFileStats, + GitContext, + GitCommit, + GitContextError, + LocalGitCommit, + StagedLocalGitCommit, + GitCommitMessage, + GitChangedFileStats, +) from gitlint.shell import ErrorReturnCode class GitCommitTests(BaseTestCase): - # Expected special_args passed to 'sh' - expected_sh_special_args = { - '_tty_out': False, - '_cwd': "fåke/path" - } + expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"} - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_get_latest_commit(self, sh): sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9" sh.git.side_effect = [ sample_sha, - "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "cömmit-title\n\ncömmit-body", + "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body", "#", # git config --get core.commentchar - "file1.txt\npåth/to/file2.txt\n", - "foöbar\n* hürdur\n" + "4\t15\tfile1.txt\n-\t-\tpåth/to/file2.bin\n", + "foöbar\n* hürdur\n", ] context = GitContext.from_local_repository("fåke/path") @@ -39,10 +43,17 @@ class GitCommitTests(BaseTestCase): 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) + call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args), + call( + "diff-tree", + "--no-commit-id", + "--numstat", + "-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 @@ -55,18 +66,26 @@ class GitCommitTests(BaseTestCase): self.assertEqual(last_commit.message.body, ["", "cömmit-body"]) self.assertEqual(last_commit.author_name, "test åuthor") self.assertEqual(last_commit.author_email, "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.assertEqual( + last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) self.assertListEqual(last_commit.parents, ["åbc"]) self.assertFalse(last_commit.is_merge_commit) self.assertFalse(last_commit.is_fixup_commit) + self.assertFalse(last_commit.is_fixup_amend_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", "påth/to/file2.txt"]) + self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.bin"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 4, 15), + "påth/to/file2.bin": GitChangedFileStats("påth/to/file2.bin", None, None), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) + # 'git diff-tree' should have happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) @@ -74,18 +93,17 @@ class GitCommitTests(BaseTestCase): # All expected calls should've happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_from_local_repository_specific_refspec(self, sh): sample_refspec = "åbc123..def456" sample_sha = "åbc123" sh.git.side_effect = [ sample_sha, # git rev-list <sample_refspec> - "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "cömmit-title\n\ncömmit-body", + "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body", "#", # git config --get core.commentchar - "file1.txt\npåth/to/file2.txt\n", - "foöbar\n* hürdur\n" + "7\t10\tfile1.txt\n9\t12\tpåth/to/file2.txt\n", + "foöbar\n* hürdur\n", ] context = GitContext.from_local_repository("fåke/path", refspec=sample_refspec) @@ -93,10 +111,17 @@ class GitCommitTests(BaseTestCase): expected_calls = [ call("rev-list", sample_refspec, **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) + call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args), + call( + "diff-tree", + "--no-commit-id", + "--numstat", + "-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 @@ -109,11 +134,13 @@ class GitCommitTests(BaseTestCase): self.assertEqual(last_commit.message.body, ["", "cömmit-body"]) self.assertEqual(last_commit.author_name, "test åuthor") self.assertEqual(last_commit.author_email, "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.assertEqual( + last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) self.assertListEqual(last_commit.parents, ["åbc"]) self.assertFalse(last_commit.is_merge_commit) self.assertFalse(last_commit.is_fixup_commit) + self.assertFalse(last_commit.is_fixup_amend_commit) self.assertFalse(last_commit.is_squash_commit) self.assertFalse(last_commit.is_revert_commit) @@ -121,6 +148,12 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls[:3]) self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 7, 10), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 9, 12), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) + # 'git diff-tree' should have happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) @@ -128,28 +161,34 @@ class GitCommitTests(BaseTestCase): # All expected calls should've happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_from_local_repository_specific_commit_hash(self, sh): sample_hash = "åbc123" sh.git.side_effect = [ sample_hash, # git log -1 <sample_hash> - "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - "cömmit-title\n\ncömmit-body", + "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\ncömmit-title\n\ncömmit-body", "#", # git config --get core.commentchar - "file1.txt\npåth/to/file2.txt\n", - "foöbar\n* hürdur\n" + "8\t3\tfile1.txt\n1\t4\tpåth/to/file2.txt\n", + "foöbar\n* hürdur\n", ] - context = GitContext.from_local_repository("fåke/path", commit_hash=sample_hash) + context = GitContext.from_local_repository("fåke/path", commit_hashes=[sample_hash]) # assert that commit info was read using git command expected_calls = [ - call("log", "-1", sample_hash, "--pretty=%H", **self.expected_sh_special_args), + call("log", "-1", sample_hash, "--pretty=%H", **self.expected_sh_special_args), call("log", sample_hash, "-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_hash, - **self.expected_sh_special_args), - call('branch', '--contains', sample_hash, **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", + "--numstat", + "-r", + "--root", + sample_hash, + **self.expected_sh_special_args, + ), + call("branch", "--contains", sample_hash, **self.expected_sh_special_args), ] # Only first 'git log' call should've happened at this point @@ -162,11 +201,13 @@ class GitCommitTests(BaseTestCase): self.assertEqual(last_commit.message.body, ["", "cömmit-body"]) self.assertEqual(last_commit.author_name, "test åuthor") self.assertEqual(last_commit.author_email, "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.assertEqual( + last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) self.assertListEqual(last_commit.parents, ["åbc"]) self.assertFalse(last_commit.is_merge_commit) self.assertFalse(last_commit.is_fixup_commit) + self.assertFalse(last_commit.is_fixup_amend_commit) self.assertFalse(last_commit.is_squash_commit) self.assertFalse(last_commit.is_revert_commit) @@ -174,6 +215,11 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls[:3]) self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 8, 3), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 1, 4), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) # 'git diff-tree' should have happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) @@ -181,17 +227,103 @@ class GitCommitTests(BaseTestCase): # All expected calls should've happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") + def test_from_local_repository_multiple_commit_hashes(self, sh): + hashes = ["åbc123", "dęf456", "ghí789"] + sh.git.side_effect = [ + *hashes, + f"test åuthor {hashes[0]}\x00test-emåil-{hashes[0]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" + f"cömmit-title {hashes[0]}\n\ncömmit-body {hashes[0]}", + "#", # git config --get core.commentchar + f"test åuthor {hashes[1]}\x00test-emåil-{hashes[1]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" + f"cömmit-title {hashes[1]}\n\ncömmit-body {hashes[1]}", + f"test åuthor {hashes[2]}\x00test-emåil-{hashes[2]}@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" + f"cömmit-title {hashes[2]}\n\ncömmit-body {hashes[2]}", + f"2\t5\tfile1-{hashes[0]}.txt\n7\t1\tpåth/to/file2.txt\n", + f"2\t5\tfile1-{hashes[1]}.txt\n7\t1\tpåth/to/file2.txt\n", + f"2\t5\tfile1-{hashes[2]}.txt\n7\t1\tpåth/to/file2.txt\n", + f"foöbar-{hashes[0]}\n* hürdur\n", + f"foöbar-{hashes[1]}\n* hürdur\n", + f"foöbar-{hashes[2]}\n* hürdur\n", + ] + + expected_calls = [ + call("log", "-1", hashes[0], "--pretty=%H", **self.expected_sh_special_args), + call("log", "-1", hashes[1], "--pretty=%H", **self.expected_sh_special_args), + call("log", "-1", hashes[2], "--pretty=%H", **self.expected_sh_special_args), + call("log", hashes[0], "-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("log", hashes[1], "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args), + call("log", hashes[2], "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args), + call( + "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[0], **self.expected_sh_special_args + ), + call( + "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[1], **self.expected_sh_special_args + ), + call( + "diff-tree", "--no-commit-id", "--numstat", "-r", "--root", hashes[2], **self.expected_sh_special_args + ), + call("branch", "--contains", hashes[0], **self.expected_sh_special_args), + call("branch", "--contains", hashes[1], **self.expected_sh_special_args), + call("branch", "--contains", hashes[2], **self.expected_sh_special_args), + ] + + context = GitContext.from_local_repository("fåke/path", commit_hashes=hashes) + + # Only first set of 'git log' calls should've happened at this point + self.assertEqual(sh.git.mock_calls, expected_calls[:3]) + + for i, commit in enumerate(context.commits): + expected_hash = hashes[i] + self.assertIsInstance(commit, LocalGitCommit) + self.assertEqual(commit.sha, expected_hash) + self.assertEqual(commit.message.title, f"cömmit-title {expected_hash}") + self.assertEqual(commit.message.body, ["", f"cömmit-body {expected_hash}"]) + self.assertEqual(commit.author_name, f"test åuthor {expected_hash}") + self.assertEqual(commit.author_email, f"test-emåil-{expected_hash}@foo.com") + self.assertEqual( + commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) + self.assertListEqual(commit.parents, ["åbc"]) + self.assertFalse(commit.is_merge_commit) + self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_commit) + self.assertFalse(commit.is_squash_commit) + self.assertFalse(commit.is_revert_commit) + + # All 'git log' calls should've happened at this point + self.assertListEqual(sh.git.mock_calls, expected_calls[:7]) + + for i, commit in enumerate(context.commits): + expected_hash = hashes[i] + self.assertListEqual(commit.changed_files, [f"file1-{expected_hash}.txt", "påth/to/file2.txt"]) + expected_file_stats = { + f"file1-{expected_hash}.txt": GitChangedFileStats(f"file1-{expected_hash}.txt", 2, 5), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 7, 1), + } + self.assertDictEqual(commit.changed_files_stats, expected_file_stats) + + # 'git diff-tree' should have happened at this point + self.assertListEqual(sh.git.mock_calls, expected_calls[:10]) + + for i, commit in enumerate(context.commits): + expected_hash = hashes[i] + self.assertListEqual(commit.branches, [f"foöbar-{expected_hash}", "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, - "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\n" - "Merge \"foo bår commit\"", + 'test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\nMerge "foo bår commit"', "#", # git config --get core.commentchar - "file1.txt\npåth/to/file2.txt\n", - "foöbar\n* hürdur\n" + "6\t2\tfile1.txt\n1\t4\tpåth/to/file2.txt\n", + "foöbar\n* hürdur\n", ] context = GitContext.from_local_repository("fåke/path") @@ -199,10 +331,17 @@ class GitCommitTests(BaseTestCase): 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) + call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args), + call( + "diff-tree", + "--no-commit-id", + "--numstat", + "-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 @@ -211,15 +350,17 @@ class GitCommitTests(BaseTestCase): last_commit = context.commits[-1] self.assertIsInstance(last_commit, LocalGitCommit) self.assertEqual(last_commit.sha, sample_sha) - self.assertEqual(last_commit.message.title, "Merge \"foo bår commit\"") + self.assertEqual(last_commit.message.title, 'Merge "foo bår commit"') self.assertEqual(last_commit.message.body, []) self.assertEqual(last_commit.author_name, "test åuthor") self.assertEqual(last_commit.author_email, "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.assertEqual( + last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) self.assertListEqual(last_commit.parents, ["åbc", "def"]) self.assertTrue(last_commit.is_merge_commit) self.assertFalse(last_commit.is_fixup_commit) + self.assertFalse(last_commit.is_fixup_amend_commit) self.assertFalse(last_commit.is_squash_commit) self.assertFalse(last_commit.is_revert_commit) @@ -227,6 +368,11 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls[:3]) self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 6, 2), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 1, 4), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) # 'git diff-tree' should have happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) @@ -234,19 +380,19 @@ class GitCommitTests(BaseTestCase): # All expected calls should've happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_get_latest_commit_fixup_squash_commit(self, sh): - commit_types = ["fixup", "squash"] - for commit_type in commit_types: + commit_prefixes = {"fixup": "is_fixup_commit", "squash": "is_squash_commit", "amend": "is_fixup_amend_commit"} + for commit_type in commit_prefixes.keys(): sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9" sh.git.side_effect = [ sample_sha, "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" - f"{commit_type}! \"foo bår commit\"", + f'{commit_type}! "foo bår commit"', "#", # git config --get core.commentchar - "file1.txt\npåth/to/file2.txt\n", - "foöbar\n* hürdur\n" + "8\t2\tfile1.txt\n7\t3\tpåth/to/file2.txt\n", + "foöbar\n* hürdur\n", ] context = GitContext.from_local_repository("fåke/path") @@ -254,10 +400,17 @@ class GitCommitTests(BaseTestCase): 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) + call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args), + call( + "diff-tree", + "--no-commit-id", + "--numstat", + "-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 @@ -266,27 +419,31 @@ class GitCommitTests(BaseTestCase): last_commit = context.commits[-1] self.assertIsInstance(last_commit, LocalGitCommit) self.assertEqual(last_commit.sha, sample_sha) - self.assertEqual(last_commit.message.title, f"{commit_type}! \"foo bår commit\"") + self.assertEqual(last_commit.message.title, f'{commit_type}! "foo bår commit"') self.assertEqual(last_commit.message.body, []) self.assertEqual(last_commit.author_name, "test åuthor") self.assertEqual(last_commit.author_email, "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.assertEqual( + last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, tzinfo=dateutil.tz.tzoffset("+0100", 3600)) + ) self.assertListEqual(last_commit.parents, ["å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" + for type, attr in commit_prefixes.items(): 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", "påth/to/file2.txt"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 8, 2), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 7, 3), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) - self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) # 'git diff-tree' should have happened at this point self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) @@ -302,14 +459,16 @@ class GitCommitTests(BaseTestCase): gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample1")) expected_title = "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.", - "This line has a tråiling space. ", - "This line has a trailing tab.\t"] + 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.", + "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 + ( - "\n# This is a cömmented line\n" + expected_original = ( + expected_full + "\n# This is a cömmented line\n" "# ------------------------ >8 ------------------------\n" "# Anything after this line should be cleaned up\n" "# this line appears on `git commit -v` command\n" @@ -335,6 +494,7 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(commit.branches, []) self.assertFalse(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_commit) self.assertFalse(commit.is_squash_commit) self.assertFalse(commit.is_revert_commit) self.assertEqual(len(gitcontext.commits), 1) @@ -355,6 +515,7 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(commit.branches, []) self.assertFalse(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_commit) self.assertFalse(commit.is_squash_commit) self.assertFalse(commit.is_revert_commit) self.assertEqual(len(gitcontext.commits), 1) @@ -376,6 +537,7 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(commit.branches, []) self.assertFalse(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_commit) self.assertFalse(commit.is_squash_commit) self.assertFalse(commit.is_revert_commit) self.assertEqual(len(gitcontext.commits), 1) @@ -400,6 +562,7 @@ class GitCommitTests(BaseTestCase): self.assertFalse(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) self.assertFalse(commit.is_squash_commit) + self.assertFalse(commit.is_fixup_amend_commit) self.assertFalse(commit.is_revert_commit) self.assertEqual(len(gitcontext.commits), 1) @@ -421,18 +584,19 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(commit.branches, []) self.assertTrue(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_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." + 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.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) @@ -443,13 +607,16 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(commit.branches, []) self.assertFalse(commit.is_merge_commit) self.assertFalse(commit.is_fixup_commit) + self.assertFalse(commit.is_fixup_amend_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: + def test_from_commit_msg_fixup_squash_amend_commit(self): + # mapping between cleanup commit prefixes and the commit object attribute + commit_prefixes = {"fixup": "is_fixup_commit", "squash": "is_squash_commit", "amend": "is_fixup_amend_commit"} + + for commit_type in commit_prefixes.keys(): commit_msg = f"{commit_type}! Test message" gitcontext = GitContext.from_commit_msg(commit_msg) commit = gitcontext.commits[-1] @@ -469,34 +636,33 @@ class GitCommitTests(BaseTestCase): 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) + for type, commit_attr_name in commit_prefixes.items(): + self.assertEqual(getattr(commit, commit_attr_name), commit_type == type) - @patch('gitlint.git.sh') - @patch('arrow.now') + @patch("gitlint.git.sh") + @patch("arrow.now") def test_staged_commit(self, now, sh): # StagedLocalGitCommit() sh.git.side_effect = [ - "#", # git config --get core.commentchar - "test åuthor\n", # git config --get user.name - "test-emåil@foo.com\n", # git config --get user.email - "my-brånch\n", # git rev-parse --abbrev-ref HEAD - "file1.txt\npåth/to/file2.txt\n", + "#", # git config --get core.commentchar + "test åuthor\n", # git config --get user.name + "test-emåil@foo.com\n", # git config --get user.email + "my-brånch\n", # git rev-parse --abbrev-ref HEAD + "4\t2\tfile1.txt\n13\t9\tpå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("fixup! Foōbar 123\n\ncömmit-body\n", "fåke/path") - # git calls we're expexting + # git calls we're expecting 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("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) + call("diff", "--staged", "--numstat", "-r", **self.expected_sh_special_args), ] last_commit = context.commits[-1] @@ -513,13 +679,15 @@ class GitCommitTests(BaseTestCase): self.assertEqual(last_commit.author_email, "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))) + 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_fixup_amend_commit) self.assertFalse(last_commit.is_squash_commit) self.assertFalse(last_commit.is_revert_commit) @@ -527,42 +695,48 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls[0:4]) self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) + expected_file_stats = { + "file1.txt": GitChangedFileStats("file1.txt", 4, 2), + "påth/to/file2.txt": GitChangedFileStats("påth/to/file2.txt", 13, 9), + } + self.assertDictEqual(last_commit.changed_files_stats, expected_file_stats) + self.assertListEqual(sh.git.mock_calls, expected_calls[0:5]) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_staged_commit_with_missing_username(self, sh): - # StagedLocalGitCommit() - sh.git.side_effect = [ - "#", # git config --get core.commentchar - ErrorReturnCode('git config --get user.name', b"", b""), + "#", # git config --get core.commentchar + ErrorReturnCode("git config --get user.name", b"", b""), ] expected_msg = "Missing git configuration: please set user.name" with self.assertRaisesMessage(GitContextError, expected_msg): ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path") - [str(commit) for commit in ctx.commits] + ctx.commits[0].author_name # accessing this attribute should raise an exception - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_staged_commit_with_missing_email(self, sh): - # StagedLocalGitCommit() - sh.git.side_effect = [ - "#", # git config --get core.commentchar - "test åuthor\n", # git config --get user.name - ErrorReturnCode('git config --get user.name', b"", b""), + "#", # git config --get core.commentchar + ErrorReturnCode("git config --get user.email", b"", b""), ] expected_msg = "Missing git configuration: please set user.email" with self.assertRaisesMessage(GitContextError, expected_msg): ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path") - [str(commit) for commit in ctx.commits] + ctx.commits[0].author_email # accessing this attribute should raise an exception def test_gitcommitmessage_equality(self): commit_message1 = GitCommitMessage(GitContext(), "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"]) - attrs = ['original', 'full', 'title', 'body'] + attrs = ["original", "full", "title", "body"] self.object_equality_test(commit_message1, attrs, {"context": commit_message1.context}) + def test_gitchangedfilestats_equality(self): + changed_file_stats = GitChangedFileStats(Path("foö/bar"), 5, 13) + attrs = ["filepath", "additions", "deletions"] + self.object_equality_test(changed_file_stats, attrs) + @patch("gitlint.git._git") def test_gitcommit_equality(self, git): # git will be called to setup the context (commentchar and current_branch), just return the same value @@ -573,14 +747,32 @@ class GitCommitTests(BaseTestCase): now = datetime.datetime.utcnow() context1 = GitContext() commit_message1 = GitCommitMessage(context1, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"]) - commit1 = GitCommit(context1, commit_message1, "shä", now, "Jöhn Smith", "jöhn.smith@test.com", None, - ["föo/bar"], ["brånch1", "brånch2"]) + commit1 = GitCommit( + context1, + commit_message1, + "shä", + now, + "Jöhn Smith", + "jöhn.smith@test.com", + None, + {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)}, + ["brånch1", "brånch2"], + ) context1.commits = [commit1] context2 = GitContext() commit_message2 = GitCommitMessage(context2, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"]) - commit2 = GitCommit(context2, commit_message1, "shä", now, "Jöhn Smith", "jöhn.smith@test.com", None, - ["föo/bar"], ["brånch1", "brånch2"]) + commit2 = GitCommit( + context2, + commit_message1, + "shä", + now, + "Jöhn Smith", + "jöhn.smith@test.com", + None, + {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)}, + ["brånch1", "brånch2"], + ) context2.commits = [commit2] self.assertEqual(context1, context2) @@ -588,15 +780,29 @@ class GitCommitTests(BaseTestCase): 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}) + kwargs = { + "message": commit1.message, + "sha": commit1.sha, + "date": commit1.date, + "author_name": commit1.author_name, + "author_email": commit1.author_email, + "parents": commit1.parents, + "branches": commit1.branches, + } + + self.object_equality_test( + commit1, + kwargs.keys(), + {"context": commit1.context, "changed_files_stats": {"föo/bar": GitChangedFileStats("föo/bar", 5, 13)}}, + ) # Check that the is_* attributes that are affected by the commit message affect equality - special_messages = {'is_merge_commit': "Merge: foöbar", 'is_fixup_commit': "fixup! foöbar", - 'is_squash_commit': "squash! foöbar", 'is_revert_commit': "Revert: foöbar"} + special_messages = { + "is_merge_commit": "Merge: foöbar", + "is_fixup_commit": "fixup! foöbar", + "is_squash_commit": "squash! foöbar", + "is_revert_commit": "Revert: foöbar", + } for key in special_messages: kwargs_copy = copy.deepcopy(kwargs) clone1 = GitCommit(context=commit1.context, **kwargs_copy) @@ -607,6 +813,10 @@ class GitCommitTests(BaseTestCase): clone2.message = GitCommitMessage.from_full_message(context1, "foöbar") self.assertNotEqual(clone1, clone2) + # Check changed_files and changed_files_stats + commit2.changed_files_stats = {"föo/bar2": GitChangedFileStats("föo/bar2", 5, 13)} + self.assertNotEqual(commit1, commit2) + @patch("gitlint.git.git_commentchar") def test_commit_msg_custom_commentchar(self, patched): patched.return_value = "ä" diff --git a/gitlint-core/gitlint/tests/git/test_git_context.py b/gitlint-core/gitlint/tests/git/test_git_context.py index bb05236..3dcbe4a 100644 --- a/gitlint-core/gitlint/tests/git/test_git_context.py +++ b/gitlint-core/gitlint/tests/git/test_git_context.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from unittest.mock import patch, call from gitlint.tests.base import BaseTestCase @@ -7,24 +5,16 @@ from gitlint.git import GitContext class GitContextTests(BaseTestCase): - # Expected special_args passed to 'sh' - expected_sh_special_args = { - '_tty_out': False, - '_cwd': "fåke/path" - } + expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"} - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_gitcontext(self, sh): - - sh.git.side_effect = [ - "#", # git config --get core.commentchar - "\nfoöbar\n" - ] + sh.git.side_effect = ["#", "\nfoöbar\n"] # git config --get core.commentchar 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) + call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args), ] context = GitContext("fåke/path") @@ -38,12 +28,11 @@ class GitContextTests(BaseTestCase): self.assertEqual(context.current_branch, "foöbar") self.assertEqual(sh.git.mock_calls, expected_calls) - @patch('gitlint.git.sh') + @patch("gitlint.git.sh") def test_gitcontext_equality(self, sh): - sh.git.side_effect = [ - "û\n", # context1: git config --get core.commentchar - "û\n", # context2: git config --get core.commentchar + "û\n", # context1: git config --get core.commentchar + "û\n", # context2: git config --get core.commentchar "my-brånch\n", # context1: git rev-parse --abbrev-ref HEAD "my-brånch\n", # context2: git rev-parse --abbrev-ref HEAD ] @@ -68,17 +57,17 @@ class GitContextTests(BaseTestCase): # Different comment_char context3 = GitContext("fåke/path") context3.commits = ["fōo", "bår"] - sh.git.side_effect = ([ - "ç\n", # context3: git config --get core.commentchar - "my-brånch\n" # context3: git rev-parse --abbrev-ref HEAD - ]) + sh.git.side_effect = [ + "ç\n", # context3: git config --get core.commentchar + "my-brånch\n", # context3: git rev-parse --abbrev-ref HEAD + ] self.assertNotEqual(context1, context3) # Different current_branch context4 = GitContext("fåke/path") context4.commits = ["fōo", "bår"] - sh.git.side_effect = ([ - "û\n", # context4: git config --get core.commentchar - "different-brånch\n" # context4: git rev-parse --abbrev-ref HEAD - ]) + sh.git.side_effect = [ + "û\n", # context4: git config --get core.commentchar + "different-brånch\n", # context4: git rev-parse --abbrev-ref HEAD + ] self.assertNotEqual(context1, context4) diff --git a/gitlint-core/gitlint/tests/rules/test_body_rules.py b/gitlint-core/gitlint/tests/rules/test_body_rules.py index 812c74a..94b1edf 100644 --- a/gitlint-core/gitlint/tests/rules/test_body_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_body_rules.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from gitlint.tests.base import BaseTestCase from gitlint import rules @@ -17,7 +16,7 @@ class BodyRuleTests(BaseTestCase): self.assertListEqual(violations, [expected_violation]) # set line length to 120, and check no violation on length 73 - rule = rules.BodyMaxLineLength({'line-length': 120}) + rule = rules.BodyMaxLineLength({"line-length": 120}) violations = rule.validate("å" * 73, None) self.assertIsNone(violations) @@ -100,14 +99,14 @@ class BodyRuleTests(BaseTestCase): # set line length to 120, and check violation on length 21 expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", "å" * 21, 3) - rule = rules.BodyMinLength({'min-length': 120}) - commit = self.gitcommit("Title\n\n{0}\n".format("å" * 21)) # pylint: disable=consider-using-f-string + rule = rules.BodyMinLength({"min-length": 120}) + commit = self.gitcommit("Title\n\n{}\n".format("å" * 21)) # pylint: disable=consider-using-f-string 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("Tïtle\n\n{0}\n".format("å" * 8)) # pylint: disable=consider-using-f-string + rule = rules.BodyMinLength({"min-length": 8}) + commit = self.gitcommit("Tïtle\n\n{}\n".format("å" * 8)) # pylint: disable=consider-using-f-string violations = rule.validate(commit) self.assertIsNone(violations) @@ -145,7 +144,7 @@ class BodyRuleTests(BaseTestCase): self.assertIsNone(violations) # assert error for merge commits if ignore-merge-commits is disabled - rule = rules.BodyMissing({'ignore-merge-commits': False}) + 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]) @@ -159,7 +158,7 @@ class BodyRuleTests(BaseTestCase): self.assertIsNone(violations) # assert no error when no files have changed but certain files need to be mentioned on change - rule = rules.BodyChangedFileMention({'files': "bar.txt,föo/test.py"}) + rule = rules.BodyChangedFileMention({"files": "bar.txt,föo/test.py"}) commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py") violations = rule.validate(commit) self.assertIsNone(violations) @@ -201,29 +200,29 @@ class BodyRuleTests(BaseTestCase): # assert no violation on matching regex # (also note that first body line - in between title and rest of body - is ignored) - rule = rules.BodyRegexMatches({'regex': "^Bödy(.*)"}) + rule = rules.BodyRegexMatches({"regex": "^Bödy(.*)"}) violations = rule.validate(commit) self.assertIsNone(violations) # assert we can do end matching (and last empty line is ignored) # (also note that first body line - in between title and rest of body - is ignored) - rule = rules.BodyRegexMatches({'regex': "My-Commit-Tag: föo$"}) + rule = rules.BodyRegexMatches({"regex": "My-Commit-Tag: föo$"}) violations = rule.validate(commit) self.assertIsNone(violations) # common use-case: matching that a given line is present - rule = rules.BodyRegexMatches({'regex': "(.*)Föo(.*)"}) + rule = rules.BodyRegexMatches({"regex": "(.*)Föo(.*)"}) violations = rule.validate(commit) self.assertIsNone(violations) # assert violation on non-matching body - rule = rules.BodyRegexMatches({'regex': "^Tëst(.*)Foo"}) + rule = rules.BodyRegexMatches({"regex": "^Tëst(.*)Foo"}) violations = rule.validate(commit) expected_violation = rules.RuleViolation("B8", "Body does not match regex (^Tëst(.*)Foo)", None, 6) self.assertListEqual(violations, [expected_violation]) # assert no violation on None regex - rule = rules.BodyRegexMatches({'regex': None}) + rule = rules.BodyRegexMatches({"regex": None}) violations = rule.validate(commit) self.assertIsNone(violations) @@ -231,6 +230,6 @@ class BodyRuleTests(BaseTestCase): bodies = ["åbc", "åbc\n", "åbc\nföo\n", "åbc\n\n", "åbc\nföo\nblå", "åbc\nföo\nblå\n"] for body in bodies: commit = self.gitcommit(body) - rule = rules.BodyRegexMatches({'regex': ".*"}) + rule = rules.BodyRegexMatches({"regex": ".*"}) violations = rule.validate(commit) self.assertIsNone(violations) diff --git a/gitlint-core/gitlint/tests/rules/test_configuration_rules.py b/gitlint-core/gitlint/tests/rules/test_configuration_rules.py index 9302da5..9e3b07c 100644 --- a/gitlint-core/gitlint/tests/rules/test_configuration_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_configuration_rules.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -from gitlint.tests.base import BaseTestCase +from gitlint.tests.base import BaseTestCase, EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING from gitlint import rules from gitlint.config import LintConfig @@ -22,20 +21,25 @@ class ConfigurationRuleTests(BaseTestCase): rule.apply(config, commit) self.assertEqual(config, expected_config) - expected_log_message = "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \ - "Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: all" - self.assert_log_contains(expected_log_message) + expected_log_messages = [ + EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("I1", "ignore-by-title"), + "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + "Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: all", + ] + self.assert_logged(expected_log_messages) # Matching regex with specific ignore - rule = rules.IgnoreByTitle({"regex": "^Releäse(.*)", - "ignore": "T1,B2"}) + rule = rules.IgnoreByTitle({"regex": "^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 = "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \ + expected_log_messages += [ + "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " "Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: T1,B2" + ] + self.assert_logged(expected_log_messages) def test_ignore_by_body(self): commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line") @@ -54,22 +58,26 @@ class ConfigurationRuleTests(BaseTestCase): rule.apply(config, commit) self.assertEqual(config, expected_config) - expected_log_message = "DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " + \ - "Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)'," + \ - " ignoring rules: all" - self.assert_log_contains(expected_log_message) + expected_log_messages = [ + EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("I2", "ignore-by-body"), + "DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " + "Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)'," + " ignoring rules: all", + ] + self.assert_logged(expected_log_messages) # Matching regex with specific ignore - rule = rules.IgnoreByBody({"regex": "(.*)relëase(.*)", - "ignore": "T1,B2"}) + rule = rules.IgnoreByBody({"regex": "(.*)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 = "DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " + \ + expected_log_messages += [ + "DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " "Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)', ignoring rules: T1,B2" - self.assert_log_contains(expected_log_message) + ] + self.assert_logged(expected_log_messages) def test_ignore_by_author_name(self): commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line", author_name="Tëst nåme") @@ -88,10 +96,13 @@ class ConfigurationRuleTests(BaseTestCase): rule.apply(config, commit) self.assertEqual(config, expected_config) - expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " - "Commit Author Name 'Tëst nåme' matches the regex '(.*)ëst(.*)'," - " ignoring rules: all") - self.assert_log_contains(expected_log_message) + expected_log_messages = [ + EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("I4", "ignore-by-author-name"), + "DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " + "Commit Author Name 'Tëst nåme' matches the regex '(.*)ëst(.*)'," + " ignoring rules: all", + ] + self.assert_logged(expected_log_messages) # Matching regex with specific ignore rule = rules.IgnoreByAuthorName({"regex": "(.*)nåme", "ignore": "T1,B2"}) @@ -100,9 +111,11 @@ class ConfigurationRuleTests(BaseTestCase): rule.apply(config, commit) self.assertEqual(config, expected_config) - expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " - "Commit Author Name 'Tëst nåme' matches the regex '(.*)nåme', ignoring rules: T1,B2") - self.assert_log_contains(expected_log_message) + expected_log_messages += [ + "DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " + "Commit Author Name 'Tëst nåme' matches the regex '(.*)nåme', ignoring rules: T1,B2" + ] + self.assert_logged(expected_log_messages) def test_ignore_body_lines(self): commit1 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line") @@ -128,8 +141,11 @@ class ConfigurationRuleTests(BaseTestCase): expected_commit.message.original = commit1.message.original self.assertEqual(commit1, expected_commit) self.assertEqual(config, LintConfig()) # config shouldn't have been modified - self.assert_log_contains("DEBUG: gitlint.rules Ignoring line ' a relëase body' because it " + - "matches '(.*)relëase(.*)'") + expected_log_messages = [ + EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("I3", "ignore-body-lines"), + "DEBUG: gitlint.rules Ignoring line ' a relëase body' because it " + "matches '(.*)relëase(.*)'", + ] + self.assert_logged(expected_log_messages) # Non-Matching regex: no changes expected commit1 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line") diff --git a/gitlint-core/gitlint/tests/rules/test_meta_rules.py b/gitlint-core/gitlint/tests/rules/test_meta_rules.py index 568ca3f..0b8a10a 100644 --- a/gitlint-core/gitlint/tests/rules/test_meta_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_meta_rules.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -from gitlint.tests.base import BaseTestCase +from gitlint.tests.base import BaseTestCase, EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING from gitlint.rules import AuthorValidEmail, RuleViolation @@ -8,8 +7,13 @@ class MetaRuleTests(BaseTestCase): rule = AuthorValidEmail() # valid email addresses - valid_email_addresses = ["föo@bar.com", "Jöhn.Doe@bar.com", "jöhn+doe@bar.com", "jöhn/doe@bar.com", - "jöhn.doe@subdomain.bar.com"] + valid_email_addresses = [ + "föo@bar.com", + "Jöhn.Doe@bar.com", + "jöhn+doe@bar.com", + "jöhn/doe@bar.com", + "jöhn.doe@subdomain.bar.com", + ] for email in valid_email_addresses: commit = self.gitcommit("", author_email=email) violations = rule.validate(commit) @@ -22,19 +26,32 @@ class MetaRuleTests(BaseTestCase): self.assertIsNone(violations) # Invalid email addresses: no TLD, no domain, no @, space anywhere (=valid but not allowed by gitlint) - invalid_email_addresses = ["föo@bar", "JöhnDoe", "Jöhn Doe", "Jöhn Doe@foo.com", " JöhnDoe@foo.com", - "JöhnDoe@ foo.com", "JöhnDoe@foo. com", "JöhnDoe@foo. com", "@bår.com", - "föo@.com"] + invalid_email_addresses = [ + "föo@bar", + "JöhnDoe", + "Jöhn Doe", + "Jöhn Doe@foo.com", + " JöhnDoe@foo.com", + "JöhnDoe@ foo.com", + "JöhnDoe@foo. com", + "JöhnDoe@foo. com", + "@bår.com", + "föo@.com", + ] for email in invalid_email_addresses: commit = self.gitcommit("", author_email=email) violations = rule.validate(commit) - self.assertListEqual(violations, - [RuleViolation("M1", "Author email for commit is invalid", email)]) + self.assertListEqual(violations, [RuleViolation("M1", "Author email for commit is invalid", email)]) + + # Ensure nothing is logged, this relates specifically to a deprecation warning on the use of + # re.match vs re.search in the rules (see issue #254) + # If no custom regex is used, the rule uses the default regex in combination with re.search + self.assert_logged([]) def test_author_valid_email_rule_custom_regex(self): # regex=None -> the rule isn't applied rule = AuthorValidEmail() - rule.options['regex'].set(None) + rule.options["regex"].set(None) emailadresses = ["föo", None, "hür dür"] for email in emailadresses: commit = self.gitcommit("", author_email=email) @@ -42,9 +59,8 @@ class MetaRuleTests(BaseTestCase): self.assertIsNone(violations) # Custom domain - rule = AuthorValidEmail({'regex': "[^@]+@bår.com"}) - valid_email_addresses = [ - "föo@bår.com", "Jöhn.Doe@bår.com", "jöhn+doe@bår.com", "jöhn/doe@bår.com"] + rule = AuthorValidEmail({"regex": "[^@]+@bår.com"}) + valid_email_addresses = ["föo@bår.com", "Jöhn.Doe@bår.com", "jöhn+doe@bår.com", "jöhn/doe@bår.com"] for email in valid_email_addresses: commit = self.gitcommit("", author_email=email) violations = rule.validate(commit) @@ -55,5 +71,7 @@ class MetaRuleTests(BaseTestCase): for email in invalid_email_addresses: commit = self.gitcommit("", author_email=email) violations = rule.validate(commit) - self.assertListEqual(violations, - [RuleViolation("M1", "Author email for commit is invalid", email)]) + self.assertListEqual(violations, [RuleViolation("M1", "Author email for commit is invalid", email)]) + + # When a custom regex is used, a warning should be logged by default + self.assert_logged([EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("M1", "author-valid-email")]) diff --git a/gitlint-core/gitlint/tests/rules/test_rules.py b/gitlint-core/gitlint/tests/rules/test_rules.py index 6fcf9bc..199cc7e 100644 --- a/gitlint-core/gitlint/tests/rules/test_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_rules.py @@ -1,10 +1,8 @@ -# -*- 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 diff --git a/gitlint-core/gitlint/tests/rules/test_title_rules.py b/gitlint-core/gitlint/tests/rules/test_title_rules.py index 10b4aab..4796e54 100644 --- a/gitlint-core/gitlint/tests/rules/test_title_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_title_rules.py @@ -1,7 +1,15 @@ -# -*- coding: utf-8 -*- from gitlint.tests.base import BaseTestCase -from gitlint.rules import TitleMaxLength, TitleTrailingWhitespace, TitleHardTab, TitleMustNotContainWord, \ - TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation, TitleMinLength +from gitlint.rules import ( + TitleMaxLength, + TitleTrailingWhitespace, + TitleHardTab, + TitleMustNotContainWord, + TitleTrailingPunctuation, + TitleLeadingWhitespace, + TitleRegexMatches, + RuleViolation, + TitleMinLength, +) class TitleRuleTests(BaseTestCase): @@ -18,7 +26,7 @@ class TitleRuleTests(BaseTestCase): self.assertListEqual(violations, [expected_violation]) # set line length to 120, and check no violation on length 73 - rule = TitleMaxLength({'line-length': 120}) + rule = TitleMaxLength({"line-length": 120}) violations = rule.validate("å" * 73, None) self.assertIsNone(violations) @@ -85,31 +93,37 @@ class TitleRuleTests(BaseTestCase): # match literally violations = rule.validate("WIP This is å test", None) - expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "WIP This is å test") + expected_violation = RuleViolation( + "T5", "Title contains the word 'WIP' (case-insensitive)", "WIP This is å test" + ) self.assertListEqual(violations, [expected_violation]) # match case insensitive violations = rule.validate("wip This is å test", None) - expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "wip This is å test") + expected_violation = RuleViolation( + "T5", "Title contains the word 'WIP' (case-insensitive)", "wip This is å test" + ) self.assertListEqual(violations, [expected_violation]) # match if there is a colon after the word violations = rule.validate("WIP:This is å test", None) - expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "WIP:This is å test") + expected_violation = RuleViolation( + "T5", "Title contains the word 'WIP' (case-insensitive)", "WIP:This is å test" + ) self.assertListEqual(violations, [expected_violation]) # match multiple words - rule = TitleMustNotContainWord({'words': "wip,test,å"}) + rule = TitleMustNotContainWord({"words": "wip,test,å"}) violations = rule.validate("WIP:This is å test", None) - expected_violation = RuleViolation("T5", "Title contains the word 'wip' (case-insensitive)", - "WIP:This is å test") - expected_violation2 = RuleViolation("T5", "Title contains the word 'test' (case-insensitive)", - "WIP:This is å test") - expected_violation3 = RuleViolation("T5", "Title contains the word 'å' (case-insensitive)", - "WIP:This is å test") + expected_violation = RuleViolation( + "T5", "Title contains the word 'wip' (case-insensitive)", "WIP:This is å test" + ) + expected_violation2 = RuleViolation( + "T5", "Title contains the word 'test' (case-insensitive)", "WIP:This is å test" + ) + expected_violation3 = RuleViolation( + "T5", "Title contains the word 'å' (case-insensitive)", "WIP:This is å test" + ) self.assertListEqual(violations, [expected_violation, expected_violation2, expected_violation3]) def test_leading_whitespace(self): @@ -143,12 +157,12 @@ class TitleRuleTests(BaseTestCase): self.assertIsNone(violations) # assert no violation on matching regex - rule = TitleRegexMatches({'regex': "^US[0-9]*: å"}) + rule = TitleRegexMatches({"regex": "^US[0-9]*: å"}) violations = rule.validate(commit.message.title, commit) self.assertIsNone(violations) # assert violation when no matching regex - rule = TitleRegexMatches({'regex': "^UÅ[0-9]*"}) + rule = TitleRegexMatches({"regex": "^UÅ[0-9]*"}) violations = rule.validate(commit.message.title, commit) expected_violation = RuleViolation("T7", "Title does not match regex (^UÅ[0-9]*)", "US1234: åbc") self.assertListEqual(violations, [expected_violation]) @@ -166,12 +180,12 @@ class TitleRuleTests(BaseTestCase): self.assertListEqual(violations, [expected_violation]) # set line length to 3, and check no violation on length 4 - rule = TitleMinLength({'min-length': 3}) + rule = TitleMinLength({"min-length": 3}) violations = rule.validate("å" * 4, None) self.assertIsNone(violations) # assert no violations on length 3 (this asserts we've implemented a *strict* less than) - rule = TitleMinLength({'min-length': 3}) + rule = TitleMinLength({"min-length": 3}) violations = rule.validate("å" * 3, None) self.assertIsNone(violations) diff --git a/gitlint-core/gitlint/tests/rules/test_user_rules.py b/gitlint-core/gitlint/tests/rules/test_user_rules.py index d66a7cc..fc8d423 100644 --- a/gitlint-core/gitlint/tests/rules/test_user_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_user_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import sys @@ -33,7 +31,7 @@ class UserRuleTests(BaseTestCase): # Do some basic asserts on our user rule self.assertEqual(classes[0].id, "UC1") self.assertEqual(classes[0].name, "my-üser-commit-rule") - expected_option = options.IntOption('violation-count', 1, "Number of violåtions to return") + expected_option = options.IntOption("violation-count", 1, "Number of violåtions to return") self.assertListEqual(classes[0].options_spec, [expected_option]) self.assertTrue(hasattr(classes[0], "validate")) @@ -44,10 +42,15 @@ class UserRuleTests(BaseTestCase): self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1)]) # Have it return more violations - rule_class.options['violation-count'].value = 2 + rule_class.options["violation-count"].value = 2 violations = rule_class.validate("false-commit-object (ignored)") - self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1), - rules.RuleViolation("UC1", "Commit violåtion 2", "Contënt 2", 2)]) + self.assertListEqual( + violations, + [ + rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1), + rules.RuleViolation("UC1", "Commit violåtion 2", "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 @@ -67,7 +70,7 @@ class UserRuleTests(BaseTestCase): classes = find_rule_classes(user_rule_path) # convert classes to strings and sort them so we can compare them - class_strings = sorted([str(clazz) for clazz in classes]) + class_strings = sorted(str(clazz) for clazz in classes) expected = ["<class 'my_commit_rules.MyUserCommitRule'>", "<class 'parent_package.InitFileRule'>"] self.assertListEqual(class_strings, expected) @@ -96,23 +99,23 @@ class UserRuleTests(BaseTestCase): def test_assert_valid_rule_class(self): class MyLineRuleClass(rules.LineRule): - id = 'UC1' - name = 'my-lïne-rule' + id = "UC1" + name = "my-lïne-rule" target = rules.CommitMessageTitle def validate(self): pass class MyCommitRuleClass(rules.CommitRule): - id = 'UC2' - name = 'my-cömmit-rule' + id = "UC2" + name = "my-cömmit-rule" def validate(self): pass class MyConfigurationRuleClass(rules.ConfigurationRule): - id = 'UC3' - name = 'my-cönfiguration-rule' + id = "UC3" + name = "my-cönfiguration-rule" def apply(self): pass @@ -125,8 +128,9 @@ class UserRuleTests(BaseTestCase): 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.assertRaisesMessage(UserRuleError, - "User-defined rule class 'MyUserLineRule' must have a 'validate' method"): + with self.assertRaisesMessage( + 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): @@ -134,13 +138,14 @@ class UserRuleTests(BaseTestCase): class MyRuleClass: pass - expected_msg = "User-defined rule class 'MyRuleClass' must extend from gitlint.rules.LineRule, " + \ - "gitlint.rules.CommitRule or gitlint.rules.ConfigurationRule" + expected_msg = ( + "User-defined rule class 'MyRuleClass' must extend from gitlint.rules.LineRule, " + "gitlint.rules.CommitRule or gitlint.rules.ConfigurationRule" + ) with self.assertRaisesMessage(UserRuleError, expected_msg): assert_valid_rule_class(MyRuleClass) def test_assert_valid_rule_class_negative_id(self): - for parent_class in [rules.LineRule, rules.CommitRule]: class MyRuleClass(parent_class): @@ -159,8 +164,9 @@ class UserRuleTests(BaseTestCase): # Rule ids must not start with one of the reserved id letters for letter in ["T", "R", "B", "M", "I"]: MyRuleClass.id = letter + "1" - expected_msg = f"The id '{letter}' of 'MyRuleClass' is invalid. " + \ - "Gitlint reserves ids starting with R,T,B,M,I" + expected_msg = ( + f"The id '{letter}' of 'MyRuleClass' is invalid. Gitlint reserves ids starting with R,T,B,M,I" + ) with self.assertRaisesMessage(UserRuleError, expected_msg): assert_valid_rule_class(MyRuleClass) @@ -181,7 +187,6 @@ class UserRuleTests(BaseTestCase): assert_valid_rule_class(MyRuleClass) def test_assert_valid_rule_class_negative_option_spec(self): - for parent_class in [rules.LineRule, rules.CommitRule]: class MyRuleClass(parent_class): @@ -190,8 +195,10 @@ class UserRuleTests(BaseTestCase): # if set, option_spec must be a list of gitlint options MyRuleClass.options_spec = "föo" - expected_msg = "The options_spec attribute of user-defined rule class 'MyRuleClass' must be a list " + \ + expected_msg = ( + "The options_spec attribute of user-defined rule class 'MyRuleClass' must be a list " "of gitlint.options.RuleOption" + ) with self.assertRaisesMessage(UserRuleError, expected_msg): assert_valid_rule_class(MyRuleClass) @@ -201,21 +208,23 @@ class UserRuleTests(BaseTestCase): assert_valid_rule_class(MyRuleClass) def test_assert_valid_rule_class_negative_validate(self): - baseclasses = [rules.LineRule, rules.CommitRule] for clazz in baseclasses: + class MyRuleClass(clazz): id = "UC1" name = "my-rüle-class" - with self.assertRaisesMessage(UserRuleError, - "User-defined rule class 'MyRuleClass' must have a 'validate' method"): + with self.assertRaisesMessage( + UserRuleError, "User-defined rule class 'MyRuleClass' must have a 'validate' method" + ): assert_valid_rule_class(MyRuleClass) # validate attribute - not a method MyRuleClass.validate = "föo" - with self.assertRaisesMessage(UserRuleError, - "User-defined rule class 'MyRuleClass' must have a 'validate' method"): + with self.assertRaisesMessage( + UserRuleError, "User-defined rule class 'MyRuleClass' must have a 'validate' method" + ): assert_valid_rule_class(MyRuleClass) def test_assert_valid_rule_class_negative_apply(self): @@ -241,8 +250,10 @@ class UserRuleTests(BaseTestCase): 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" + expected_msg = ( + "The target attribute of the user-defined LineRule class 'MyRuleClass' must be either " + "gitlint.rules.CommitMessageTitle or gitlint.rules.CommitMessageBody" + ) with self.assertRaisesMessage(UserRuleError, expected_msg): assert_valid_rule_class(MyRuleClass) diff --git a/gitlint-core/gitlint/tests/samples/commit_message/fixup_amend b/gitlint-core/gitlint/tests/samples/commit_message/fixup_amend new file mode 100644 index 0000000..293a2b7 --- /dev/null +++ b/gitlint-core/gitlint/tests/samples/commit_message/fixup_amend @@ -0,0 +1 @@ +amend! WIP: This is a fixup cömmit with violations. diff --git a/gitlint-core/gitlint/tests/samples/config/AUTHORS b/gitlint-core/gitlint/tests/samples/config/AUTHORS new file mode 100644 index 0000000..1c355d6 --- /dev/null +++ b/gitlint-core/gitlint/tests/samples/config/AUTHORS @@ -0,0 +1,2 @@ +John Doe <john.doe@mail.com> +Bob Smith <bob.smith@mail.com>
\ No newline at end of file diff --git a/gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py b/gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py index e75fed3..a123a64 100644 --- a/gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py +++ b/gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py @@ -1,3 +1,2 @@ -# flake8: noqa # This is invalid python code which will cause an import exception class MyObject: diff --git a/gitlint-core/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py b/gitlint-core/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py index 004ef9d..b23b5bf 100644 --- a/gitlint-core/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py +++ b/gitlint-core/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import LineRule diff --git a/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py index 8b0907e..02c922d 100644 --- a/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py +++ b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import CommitRule, RuleViolation from gitlint.options import IntOption @@ -7,11 +5,11 @@ from gitlint.options import IntOption class MyUserCommitRule(CommitRule): name = "my-üser-commit-rule" id = "UC1" - options_spec = [IntOption('violation-count', 1, "Number of violåtions to return")] + options_spec = [IntOption("violation-count", 1, "Number of violåtions to return")] def validate(self, _commit): violations = [] - for i in range(1, self.options['violation-count'].value + 1): + for i in range(1, self.options["violation-count"].value + 1): violations.append(RuleViolation(self.id, "Commit violåtion %d" % i, "Contënt %d" % i, i)) return violations @@ -19,6 +17,7 @@ class MyUserCommitRule(CommitRule): # The below code is present so that we can test that we actually ignore it + def func_should_be_ignored(): pass diff --git a/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py index 9ea5371..22c3f65 100644 --- a/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py +++ b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # This file is meant to test that we can also load rules from __init__.py files, this was an issue with pypy before. from gitlint.rules import CommitRule diff --git a/gitlint-core/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py index b143e62..f91cb07 100644 --- a/gitlint-core/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py +++ b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import CommitRule diff --git a/gitlint-core/gitlint/tests/test_cache.py b/gitlint-core/gitlint/tests/test_cache.py index 4b1d47a..9c327dc 100644 --- a/gitlint-core/gitlint/tests/test_cache.py +++ b/gitlint-core/gitlint/tests/test_cache.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- from gitlint.tests.base import BaseTestCase from gitlint.cache import PropertyCache, cache class CacheTests(BaseTestCase): - class MyClass(PropertyCache): - """ Simple class that has cached properties, used for testing. """ + """Simple class that has cached properties, used for testing.""" def __init__(self): PropertyCache.__init__(self) diff --git a/gitlint-core/gitlint/tests/test_deprecation.py b/gitlint-core/gitlint/tests/test_deprecation.py new file mode 100644 index 0000000..d85593a --- /dev/null +++ b/gitlint-core/gitlint/tests/test_deprecation.py @@ -0,0 +1,23 @@ +from gitlint.config import LintConfig +from gitlint.deprecation import Deprecation +from gitlint.rules import IgnoreByTitle +from gitlint.tests.base import EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING, BaseTestCase + + +class DeprecationTests(BaseTestCase): + def test_get_regex_method(self): + config = LintConfig() + Deprecation.config = config + rule = IgnoreByTitle({"regex": "^Releäse(.*)"}) + + # When general.regex-style-search=True, we expect regex.search to be returned and no warning to be logged + config.regex_style_search = True + regex_method = Deprecation.get_regex_method(rule, rule.options["regex"]) + self.assertEqual(regex_method, rule.options["regex"].value.search) + self.assert_logged([]) + + # When general.regex-style-search=False, we expect regex.match to be returned and a warning to be logged + config.regex_style_search = False + regex_method = Deprecation.get_regex_method(rule, rule.options["regex"]) + self.assertEqual(regex_method, rule.options["regex"].value.match) + self.assert_logged([EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING.format("I1", "ignore-by-title")]) diff --git a/gitlint-core/gitlint/tests/test_display.py b/gitlint-core/gitlint/tests/test_display.py index 167ef96..1f759d2 100644 --- a/gitlint-core/gitlint/tests/test_display.py +++ b/gitlint-core/gitlint/tests/test_display.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from io import StringIO from unittest.mock import patch # pylint: disable=no-name-in-module, import-error @@ -14,9 +12,9 @@ class DisplayTests(BaseTestCase): display = Display(LintConfig()) display.config.verbosity = 2 - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: # Non exact outputting, should output both v and vv output - with patch('gitlint.display.stdout', new=StringIO()) as stdout: + with patch("gitlint.display.stdout", new=StringIO()) as stdout: display.v("tëst") display.vv("tëst2") # vvvv should be ignored regardless @@ -25,7 +23,7 @@ class DisplayTests(BaseTestCase): self.assertEqual("tëst\ntëst2\n", stdout.getvalue()) # exact outputting, should only output v - with patch('gitlint.display.stdout', new=StringIO()) as stdout: + with patch("gitlint.display.stdout", new=StringIO()) as stdout: display.v("tëst", exact=True) display.vv("tëst2", exact=True) # vvvv should be ignored regardless @@ -33,16 +31,16 @@ class DisplayTests(BaseTestCase): display.vvv("tëst3.2", exact=True) self.assertEqual("tëst2\n", stdout.getvalue()) - # standard error should be empty throughtout all of this - self.assertEqual('', stderr.getvalue()) + # standard error should be empty throughout all of this + self.assertEqual("", stderr.getvalue()) def test_e(self): display = Display(LintConfig()) display.config.verbosity = 2 - with patch('gitlint.display.stdout', new=StringIO()) as stdout: + with patch("gitlint.display.stdout", new=StringIO()) as stdout: # Non exact outputting, should output both v and vv output - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: display.e("tëst") display.ee("tëst2") # vvvv should be ignored regardless @@ -51,7 +49,7 @@ class DisplayTests(BaseTestCase): self.assertEqual("tëst\ntëst2\n", stderr.getvalue()) # exact outputting, should only output v - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: display.e("tëst", exact=True) display.ee("tëst2", exact=True) # vvvv should be ignored regardless @@ -59,5 +57,5 @@ class DisplayTests(BaseTestCase): display.eee("tëst3.2", exact=True) self.assertEqual("tëst2\n", stderr.getvalue()) - # standard output should be empty throughtout all of this - self.assertEqual('', stdout.getvalue()) + # standard output should be empty throughout all of this + self.assertEqual("", stdout.getvalue()) diff --git a/gitlint-core/gitlint/tests/test_hooks.py b/gitlint-core/gitlint/tests/test_hooks.py index 0ce5040..f92b148 100644 --- a/gitlint-core/gitlint/tests/test_hooks.py +++ b/gitlint-core/gitlint/tests/test_hooks.py @@ -1,18 +1,20 @@ -# -*- coding: utf-8 -*- - import os from unittest.mock import patch, ANY, mock_open from gitlint.tests.base import BaseTestCase from gitlint.config import LintConfig -from gitlint.hooks import GitHookInstaller, GitHookInstallerError, COMMIT_MSG_HOOK_SRC_PATH, COMMIT_MSG_HOOK_DST_PATH, \ - GITLINT_HOOK_IDENTIFIER +from gitlint.hooks import ( + GitHookInstaller, + GitHookInstallerError, + COMMIT_MSG_HOOK_SRC_PATH, + COMMIT_MSG_HOOK_DST_PATH, + GITLINT_HOOK_IDENTIFIER, +) class HookTests(BaseTestCase): - - @patch('gitlint.hooks.git_hooks_dir') + @patch("gitlint.hooks.git_hooks_dir") def test_commit_msg_hook_path(self, git_hooks_dir): git_hooks_dir.return_value = os.path.join("/föo", "bar") lint_config = LintConfig() @@ -24,12 +26,12 @@ class HookTests(BaseTestCase): self.assertEqual(path, expected_path) @staticmethod - @patch('os.chmod') - @patch('os.stat') - @patch('gitlint.hooks.shutil.copy') - @patch('os.path.exists', return_value=False) - @patch('os.path.isdir', return_value=True) - @patch('gitlint.hooks.git_hooks_dir') + @patch("os.chmod") + @patch("os.stat") + @patch("gitlint.hooks.shutil.copy") + @patch("os.path.exists", return_value=False) + @patch("os.path.isdir", return_value=True) + @patch("gitlint.hooks.git_hooks_dir") def test_install_commit_msg_hook(git_hooks_dir, isdir, path_exists, copy, stat, chmod): lint_config = LintConfig() lint_config.target = os.path.join("/hür", "dur") @@ -43,10 +45,10 @@ class HookTests(BaseTestCase): chmod.assert_called_once_with(expected_dst, ANY) git_hooks_dir.assert_called_with(lint_config.target) - @patch('gitlint.hooks.shutil.copy') - @patch('os.path.exists', return_value=False) - @patch('os.path.isdir', return_value=True) - @patch('gitlint.hooks.git_hooks_dir') + @patch("gitlint.hooks.shutil.copy") + @patch("os.path.exists", return_value=False) + @patch("os.path.isdir", return_value=True) + @patch("gitlint.hooks.git_hooks_dir") def test_install_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, copy): lint_config = LintConfig() lint_config.target = os.path.join("/hür", "dur") @@ -64,22 +66,24 @@ class HookTests(BaseTestCase): isdir.return_value = True path_exists.return_value = True expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH) - expected_msg = f"There is already a commit-msg hook file present in {expected_dst}.\n" + \ - "gitlint currently does not support appending to an existing commit-msg file." + expected_msg = ( + f"There is already a commit-msg hook file present in {expected_dst}.\n" + "gitlint currently does not support appending to an existing commit-msg file." + ) with self.assertRaisesMessage(GitHookInstallerError, expected_msg): GitHookInstaller.install_commit_msg_hook(lint_config) @staticmethod - @patch('os.remove') - @patch('os.path.exists', return_value=True) - @patch('os.path.isdir', return_value=True) - @patch('gitlint.hooks.git_hooks_dir') + @patch("os.remove") + @patch("os.path.exists", return_value=True) + @patch("os.path.isdir", return_value=True) + @patch("gitlint.hooks.git_hooks_dir") def test_uninstall_commit_msg_hook(git_hooks_dir, isdir, path_exists, remove): lint_config = LintConfig() git_hooks_dir.return_value = os.path.join("/föo", "bar", ".git", "hooks") lint_config.target = os.path.join("/hür", "dur") read_data = "#!/bin/sh\n" + GITLINT_HOOK_IDENTIFIER - with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True): + with patch("builtins.open", mock_open(read_data=read_data), create=True): GitHookInstaller.uninstall_commit_msg_hook(lint_config) expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH) @@ -88,10 +92,10 @@ class HookTests(BaseTestCase): remove.assert_called_with(expected_dst) git_hooks_dir.assert_called_with(lint_config.target) - @patch('os.remove') - @patch('os.path.exists', return_value=True) - @patch('os.path.isdir', return_value=True) - @patch('gitlint.hooks.git_hooks_dir') + @patch("os.remove") + @patch("os.path.exists", return_value=True) + @patch("os.path.isdir", return_value=True) + @patch("gitlint.hooks.git_hooks_dir") def test_uninstall_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, remove): lint_config = LintConfig() lint_config.target = os.path.join("/hür", "dur") @@ -122,10 +126,12 @@ class HookTests(BaseTestCase): path_exists.return_value = True read_data = "#!/bin/sh\nfoo" expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH) - expected_msg = f"The commit-msg hook in {expected_dst} was not installed by gitlint " + \ - "(or it was modified).\nUninstallation of 3th party or modified gitlint hooks " + \ - "is not supported." - with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True): + expected_msg = ( + f"The commit-msg hook in {expected_dst} was not installed by gitlint " + "(or it was modified).\nUninstallation of 3th party or modified gitlint hooks " + "is not supported." + ) + with patch("builtins.open", mock_open(read_data=read_data), create=True): with self.assertRaisesMessage(GitHookInstallerError, expected_msg): GitHookInstaller.uninstall_commit_msg_hook(lint_config) remove.assert_not_called() diff --git a/gitlint-core/gitlint/tests/test_lint.py b/gitlint-core/gitlint/tests/test_lint.py index b743389..2af4615 100644 --- a/gitlint-core/gitlint/tests/test_lint.py +++ b/gitlint-core/gitlint/tests/test_lint.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from io import StringIO from unittest.mock import patch # pylint: disable=no-name-in-module, import-error @@ -11,23 +9,26 @@ from gitlint.config import LintConfig, LintConfigBuilder class LintTests(BaseTestCase): - def test_lint_sample1(self): linter = GitLinter(LintConfig()) gitcontext = self.gitcontext(self.get_sample("commit_message/sample1")) violations = linter.lint(gitcontext.commits[-1]) - expected_errors = [RuleViolation("T3", "Title has trailing punctuation (.)", - "Commit title contåining 'WIP', as well as trailing punctuation.", 1), - RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "Commit title contåining 'WIP', as well as trailing punctuation.", 1), - RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), - RuleViolation("B1", "Line exceeds max length (135>80)", - "This is the first line of the commit message body and it is meant to test " + - "a line that exceeds the maximum line length of 80 characters.", 3), - RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling space. ", 4), - RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5), - RuleViolation("B3", "Line contains hard tab characters (\\t)", - "This line has a trailing tab.\t", 5)] + # fmt: off + expected_errors = [ + RuleViolation("T3", "Title has trailing punctuation (.)", + "Commit title contåining 'WIP', as well as trailing punctuation.", 1), + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", + "Commit title contåining 'WIP', as well as trailing punctuation.", 1), + RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), + RuleViolation("B1", "Line exceeds max length (135>80)", + "This is the first line of the commit message body and it is meant to test " + + "a line that exceeds the maximum line length of 80 characters.", 3), + RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling space. ", 4), + RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5), + RuleViolation("B3", "Line contains hard tab characters (\\t)", + "This line has a trailing tab.\t", 5) + ] + # fmt: on self.assertListEqual(violations, expected_errors) @@ -35,9 +36,10 @@ class LintTests(BaseTestCase): linter = GitLinter(LintConfig()) gitcontext = self.gitcontext(self.get_sample("commit_message/sample2")) violations = linter.lint(gitcontext.commits[-1]) - expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "Just a title contåining WIP", 1), - RuleViolation("B6", "Body message is missing", None, 3)] + expected = [ + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "Just a title contåining WIP", 1), + RuleViolation("B6", "Body message is missing", None, 3), + ] self.assertListEqual(violations, expected) @@ -46,20 +48,24 @@ class LintTests(BaseTestCase): gitcontext = self.gitcontext(self.get_sample("commit_message/sample3")) violations = linter.lint(gitcontext.commits[-1]) + # fmt: off title = " Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters." - expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1), - RuleViolation("T3", "Title has trailing punctuation (.)", title, 1), - RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1), - RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1), - RuleViolation("T6", "Title has leading whitespace", title, 1), - RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), - RuleViolation("B1", "Line exceeds max length (101>80)", - "This is the first line is meånt to test a line that exceeds the maximum line " + - "length of 80 characters.", 3), - RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing space. ", 4), - RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling tab.\t", 5), - RuleViolation("B3", "Line contains hard tab characters (\\t)", - "This line has a tråiling tab.\t", 5)] + expected = [ + RuleViolation("T1", "Title exceeds max length (95>72)", title, 1), + RuleViolation("T3", "Title has trailing punctuation (.)", title, 1), + RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1), + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1), + RuleViolation("T6", "Title has leading whitespace", title, 1), + RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), + RuleViolation("B1", "Line exceeds max length (101>80)", + "This is the first line is meånt to test a line that exceeds the maximum line " + + "length of 80 characters.", 3), + RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing space. ", 4), + RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling tab.\t", 5), + RuleViolation("B3", "Line contains hard tab characters (\\t)", + "This line has a tråiling tab.\t", 5) + ] + # fmt: on self.assertListEqual(violations, expected) @@ -82,26 +88,28 @@ class LintTests(BaseTestCase): title = " Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters." # expect only certain violations because sample5 has a 'gitlint-ignore: T3, T6, body-max-line-length' - expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1), - RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1), - RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1), - RuleViolation("B4", "Second line is not empty", "This line should be ëmpty", 2), - RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling space. ", 4), - RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5), - RuleViolation("B3", "Line contains hard tab characters (\\t)", - "This line has a trailing tab.\t", 5)] + expected = [ + RuleViolation("T1", "Title exceeds max length (95>72)", title, 1), + RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1), + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1), + RuleViolation("B4", "Second line is not empty", "This line should be ëmpty", 2), + RuleViolation("B2", "Line has trailing whitespace", "This line has a tråiling space. ", 4), + RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5), + RuleViolation("B3", "Line contains hard tab characters (\\t)", "This line has a trailing tab.\t", 5), + ] self.assertListEqual(violations, expected) def test_lint_meta(self): - """ Lint sample2 but also add some metadata to the commit so we that gets linted as well """ + """Lint sample2 but also add some metadata to the commit so we that gets linted as well""" linter = GitLinter(LintConfig()) gitcontext = self.gitcontext(self.get_sample("commit_message/sample2")) gitcontext.commits[0].author_email = "foo bår" violations = linter.lint(gitcontext.commits[-1]) - expected = [RuleViolation("M1", "Author email for commit is invalid", "foo bår", None), - RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "Just a title contåining WIP", 1), - RuleViolation("B6", "Body message is missing", None, 3)] + expected = [ + RuleViolation("M1", "Author email for commit is invalid", "foo bår", None), + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "Just a title contåining WIP", 1), + RuleViolation("B6", "Body message is missing", None, 3), + ] self.assertListEqual(violations, expected) @@ -111,9 +119,10 @@ class LintTests(BaseTestCase): linter = GitLinter(lint_config) violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample3"))) - expected = [RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), - RuleViolation("B3", "Line contains hard tab characters (\\t)", - "This line has a tråiling tab.\t", 5)] + expected = [ + RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), + RuleViolation("B3", "Line contains hard tab characters (\\t)", "This line has a tråiling tab.\t", 5), + ] self.assertListEqual(violations, expected) @@ -135,8 +144,9 @@ class LintTests(BaseTestCase): violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2"))) # Normally we'd expect a B6 violation, but that one is skipped because of the specific ignore set above - expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "Just a title contåining WIP", 1)] + expected = [ + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "Just a title contåining WIP", 1) + ] self.assertListEqual(violations, expected) @@ -145,22 +155,25 @@ class LintTests(BaseTestCase): linter = GitLinter(lint_config) lint_config.set_rule_option("I3", "regex", "(.*)tråiling(.*)") violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample1"))) - expected_errors = [RuleViolation("T3", "Title has trailing punctuation (.)", - "Commit title contåining 'WIP', as well as trailing punctuation.", 1), - RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", - "Commit title contåining 'WIP', as well as trailing punctuation.", 1), - RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), - RuleViolation("B1", "Line exceeds max length (135>80)", - "This is the first line of the commit message body and it is meant to test " + - "a line that exceeds the maximum line length of 80 characters.", 3), - RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 4), - RuleViolation("B3", "Line contains hard tab characters (\\t)", - "This line has a trailing tab.\t", 4)] - + # fmt: off + expected_errors = [ + RuleViolation("T3", "Title has trailing punctuation (.)", + "Commit title contåining 'WIP', as well as trailing punctuation.", 1), + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", + "Commit title contåining 'WIP', as well as trailing punctuation.", 1), + RuleViolation("B4", "Second line is not empty", "This line should be empty", 2), + RuleViolation("B1", "Line exceeds max length (135>80)", + "This is the first line of the commit message body and it is meant to test " + + "a line that exceeds the maximum line length of 80 characters.", 3), + RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 4), + RuleViolation("B3", "Line contains hard tab characters (\\t)", + "This line has a trailing tab.\t", 4) + ] + # fmt: on self.assertListEqual(violations, expected_errors) def test_lint_special_commit(self): - for commit_type in ["merge", "revert", "squash", "fixup"]: + for commit_type in ["merge", "revert", "squash", "fixup", "fixup_amend"]: commit = self.gitcommit(self.get_sample(f"commit_message/{commit_type}")) lintconfig = LintConfig() linter = GitLinter(lintconfig) @@ -176,7 +189,7 @@ class LintTests(BaseTestCase): self.assertTrue(len(violations) > 0) def test_lint_regex_rules(self): - """ Additional test for title-match-regex, body-match-regex """ + """Additional test for title-match-regex, body-match-regex""" commit = self.gitcommit(self.get_sample("commit_message/no-violations")) lintconfig = LintConfig() linter = GitLinter(lintconfig) @@ -192,46 +205,52 @@ class LintTests(BaseTestCase): self.assertListEqual(violations, []) # Non-matching regexes should return violations - rule_regexes = [("title-match-regex", ), ("body-match-regex",)] + rule_regexes = [("title-match-regex",), ("body-match-regex",)] lintconfig.set_rule_option("title-match-regex", "regex", "^Tïtle") lintconfig.set_rule_option("body-match-regex", "regex", "Sügned-Off-By: (.*)$") - expected_violations = [RuleViolation("T7", "Title does not match regex (^Tïtle)", "Normal Commit Tïtle", 1), - RuleViolation("B8", "Body does not match regex (Sügned-Off-By: (.*)$)", None, 6)] + expected_violations = [ + RuleViolation("T7", "Title does not match regex (^Tïtle)", "Normal Commit Tïtle", 1), + RuleViolation("B8", "Body does not match regex (Sügned-Off-By: (.*)$)", None, 6), + ] violations = linter.lint(commit) self.assertListEqual(violations, expected_violations) def test_print_violations(self): - violations = [RuleViolation("RULE_ID_1", "Error Messåge 1", "Violating Content 1", None), - RuleViolation("RULE_ID_2", "Error Message 2", "Violåting Content 2", 2)] + violations = [ + RuleViolation("RULE_ID_1", "Error Messåge 1", "Violating Content 1", None), + RuleViolation("RULE_ID_2", "Error Message 2", "Violåting Content 2", 2), + ] linter = GitLinter(LintConfig()) # test output with increasing verbosity - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: linter.config.verbosity = 0 linter.print_violations(violations) self.assertEqual("", stderr.getvalue()) - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: linter.config.verbosity = 1 linter.print_violations(violations) expected = "-: RULE_ID_1\n2: RULE_ID_2\n" self.assertEqual(expected, stderr.getvalue()) - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: linter.config.verbosity = 2 linter.print_violations(violations) expected = "-: RULE_ID_1 Error Messåge 1\n2: RULE_ID_2 Error Message 2\n" self.assertEqual(expected, stderr.getvalue()) - with patch('gitlint.display.stderr', new=StringIO()) as stderr: + with patch("gitlint.display.stderr", new=StringIO()) as stderr: linter.config.verbosity = 3 linter.print_violations(violations) - expected = "-: RULE_ID_1 Error Messåge 1: \"Violating Content 1\"\n" + \ - "2: RULE_ID_2 Error Message 2: \"Violåting Content 2\"\n" + expected = ( + '-: RULE_ID_1 Error Messåge 1: "Violating Content 1"\n' + + '2: RULE_ID_2 Error Message 2: "Violåting Content 2"\n' + ) self.assertEqual(expected, stderr.getvalue()) def test_named_rules(self): - """ Test that when named rules are present, both them and the original (non-named) rules executed """ + """Test that when named rules are present, both them and the original (non-named) rules executed""" lint_config = LintConfig() for rule_name in ["my-ïd", "another-rule-ïd"]: @@ -240,15 +259,15 @@ class LintTests(BaseTestCase): lint_config.set_rule_option(rule_id, "words", ["Föo"]) linter = GitLinter(lint_config) - violations = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "WIP: Föo bar", 1), - RuleViolation("T5:another-rule-ïd", "Title contains the word 'Föo' (case-insensitive)", - "WIP: Föo bar", 1), - RuleViolation("T5:my-ïd", "Title contains the word 'Föo' (case-insensitive)", - "WIP: Föo bar", 1)] + violations = [ + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "WIP: Föo bar", 1), + RuleViolation("T5:another-rule-ïd", "Title contains the word 'Föo' (case-insensitive)", "WIP: Föo bar", 1), + RuleViolation("T5:my-ïd", "Title contains the word 'Föo' (case-insensitive)", "WIP: Föo bar", 1), + ] self.assertListEqual(violations, linter.lint(self.gitcommit("WIP: Föo bar\n\nFoo bår hur dur bla bla"))) def test_ignore_named_rules(self): - """ Test that named rules can be ignored """ + """Test that named rules can be ignored""" # Add named rule to lint config config_builder = LintConfigBuilder() @@ -259,9 +278,10 @@ class LintTests(BaseTestCase): commit = self.gitcommit("WIP: Föo bar\n\nFoo bår hur dur bla bla") # By default, we expect both the violations of the regular rule as well as the named rule to show up - violations = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "WIP: Föo bar", 1), - RuleViolation("T5:my-ïd", "Title contains the word 'Föo' (case-insensitive)", - "WIP: Föo bar", 1)] + violations = [ + RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "WIP: Föo bar", 1), + RuleViolation("T5:my-ïd", "Title contains the word 'Föo' (case-insensitive)", "WIP: Föo bar", 1), + ] self.assertListEqual(violations, linter.lint(commit)) # ignore regular rule: only named rule violations show up diff --git a/gitlint-core/gitlint/tests/test_options.py b/gitlint-core/gitlint/tests/test_options.py index eabcfe1..7b146e7 100644 --- a/gitlint-core/gitlint/tests/test_options.py +++ b/gitlint-core/gitlint/tests/test_options.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import re @@ -9,8 +8,14 @@ from gitlint.options import IntOption, BoolOption, StrOption, ListOption, PathOp class RuleOptionTests(BaseTestCase): def test_option_equality(self): - options = {IntOption: 123, StrOption: "foöbar", BoolOption: False, ListOption: ["a", "b"], - PathOption: ".", RegexOption: "^foöbar(.*)"} + options = { + IntOption: 123, + StrOption: "foöbar", + BoolOption: False, + ListOption: ["a", "b"], + PathOption: ".", + RegexOption: "^foöbar(.*)", + } for clazz, val in options.items(): # 2 options are equal if their name, value and description match option1 = clazz("test-öption", val, "Test Dëscription") @@ -97,7 +102,7 @@ class RuleOptionTests(BaseTestCase): self.assertEqual(option.value, True) # error on incorrect value - incorrect_values = [1, -1, "foo", "bår", ["foo"], {'foo': "bar"}, None] + incorrect_values = [1, -1, "foo", "bår", ["foo"], {"foo": "bar"}, None] for value in incorrect_values: with self.assertRaisesMessage(RuleOptionError, "Option 'tëst-name' must be either 'true' or 'false'"): option.set(value) @@ -197,7 +202,7 @@ class RuleOptionTests(BaseTestCase): self.assertEqual(option.value, self.get_sample_path()) # Expect exception if path type is invalid - option.type = 'föo' + option.type = "föo" expected = "Option tëst-directory type must be one of: 'file', 'dir', 'both' (current: 'föo')" with self.assertRaisesMessage(RuleOptionError, expected): option.set("haha") diff --git a/gitlint-core/gitlint/tests/test_utils.py b/gitlint-core/gitlint/tests/test_utils.py index 4ec8bda..27036d3 100644 --- a/gitlint-core/gitlint/tests/test_utils.py +++ b/gitlint-core/gitlint/tests/test_utils.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from unittest.mock import patch from gitlint import utils @@ -7,13 +5,12 @@ from gitlint.tests.base import BaseTestCase class UtilsTests(BaseTestCase): - def tearDown(self): # Since we're messing around with `utils.PLATFORM_IS_WINDOWS` during these tests, we need to reset # its value after we're done this doesn't influence other tests utils.PLATFORM_IS_WINDOWS = utils.platform_is_windows() - @patch('os.environ') + @patch("os.environ") def test_use_sh_library(self, patched_env): patched_env.get.return_value = "1" self.assertEqual(utils.use_sh_library(), True) @@ -25,15 +22,11 @@ class UtilsTests(BaseTestCase): self.assertEqual(utils.use_sh_library(), False, invalid_val) patched_env.get.assert_called_once_with("GITLINT_USE_SH_LIB", None) - # Assert that when GITLINT_USE_SH_LIB is not set, we fallback to checking whether we're on Windows - utils.PLATFORM_IS_WINDOWS = True + # Assert that when GITLINT_USE_SH_LIB is not set, we fallback to False (not using) patched_env.get.return_value = None self.assertEqual(utils.use_sh_library(), False) - utils.PLATFORM_IS_WINDOWS = False - self.assertEqual(utils.use_sh_library(), True) - - @patch('gitlint.utils.locale') + @patch("gitlint.utils.locale") def test_default_encoding_non_windows(self, mocked_locale): utils.PLATFORM_IS_WINDOWS = False mocked_locale.getpreferredencoding.return_value = "foöbar" @@ -43,7 +36,7 @@ class UtilsTests(BaseTestCase): mocked_locale.getpreferredencoding.return_value = False self.assertEqual(utils.getpreferredencoding(), "UTF-8") - @patch('os.environ') + @patch("os.environ") def test_default_encoding_windows(self, patched_env): utils.PLATFORM_IS_WINDOWS = True # Mock out os.environ diff --git a/gitlint-core/gitlint/utils.py b/gitlint-core/gitlint/utils.py index c91184b..697b472 100644 --- a/gitlint-core/gitlint/utils.py +++ b/gitlint-core/gitlint/utils.py @@ -11,7 +11,7 @@ import locale # and just executed at import-time. ######################################################################################################################## -LOG_FORMAT = '%(levelname)s: %(name)s %(message)s' +LOG_FORMAT = "%(levelname)s: %(name)s %(message)s" ######################################################################################################################## # PLATFORM_IS_WINDOWS @@ -31,10 +31,10 @@ PLATFORM_IS_WINDOWS = platform_is_windows() def use_sh_library(): - gitlint_use_sh_lib_env = os.environ.get('GITLINT_USE_SH_LIB', None) + gitlint_use_sh_lib_env = os.environ.get("GITLINT_USE_SH_LIB", None) if gitlint_use_sh_lib_env: return gitlint_use_sh_lib_env == "1" - return not PLATFORM_IS_WINDOWS + return False USE_SH_LIB = use_sh_library() @@ -44,8 +44,8 @@ USE_SH_LIB = use_sh_library() def getpreferredencoding(): - """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars - on windows and falls back to UTF-8. """ + """Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars + on windows and falls back to UTF-8.""" fallback_encoding = "UTF-8" default_encoding = locale.getpreferredencoding() or fallback_encoding @@ -61,7 +61,7 @@ def getpreferredencoding(): # If encoding contains a dot: split and use second part, otherwise use everything dot_index = encoding.find(".") if dot_index != -1: - default_encoding = encoding[dot_index + 1:] + default_encoding = encoding[dot_index + 1 :] else: default_encoding = encoding break diff --git a/gitlint-core/setup.py b/gitlint-core/setup.py index 952dd7f..8917e27 100644 --- a/gitlint-core/setup.py +++ b/gitlint-core/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -from __future__ import print_function from setuptools import setup, find_packages import io import re @@ -32,7 +31,7 @@ Source code on `github.com/jorisroovers/gitlint`_. # shamelessly stolen from mkdocs' setup.py: https://github.com/mkdocs/mkdocs/blob/master/setup.py def get_version(package): """Return package version as listed in `__version__` in `init.py`.""" - init_py = io.open(os.path.join(package, '__init__.py'), encoding="UTF-8").read() + init_py = open(os.path.join(package, "__init__.py"), encoding="UTF-8").read() return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) @@ -50,38 +49,37 @@ setup( "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Environment :: Console", "Intended Audience :: Developers", "Topic :: Software Development :: Quality Assurance", "Topic :: Software Development :: Testing", - "License :: OSI Approved :: MIT License" + "License :: OSI Approved :: MIT License", ], python_requires=">=3.6", install_requires=[ - 'Click>=8', - 'arrow>=1', + "Click>=8", + "arrow>=1", 'sh>=1.13.0 ; sys_platform != "win32"', ], extras_require={ - 'trusted-deps': [ - 'Click==8.0.3', - 'arrow==1.2.1', + "trusted-deps": [ + "Click==8.0.3", + "arrow==1.2.1", 'sh==1.14.2 ; sys_platform != "win32"', ], }, - keywords='gitlint git lint', - author='Joris Roovers', - url='https://jorisroovers.github.io/gitlint', + keywords="gitlint git lint", + author="Joris Roovers", + url="https://jorisroovers.github.io/gitlint", project_urls={ - 'Documentation': 'https://jorisroovers.github.io/gitlint', - 'Source': 'https://github.com/jorisroovers/gitlint', - }, - license='MIT', - package_data={ - 'gitlint': ['files/*'] + "Documentation": "https://jorisroovers.github.io/gitlint", + "Source": "https://github.com/jorisroovers/gitlint", }, + license="MIT", + package_data={"gitlint": ["files/*"]}, packages=find_packages(exclude=["examples"]), entry_points={ "console_scripts": [ @@ -92,16 +90,20 @@ setup( # Print a red deprecation warning for python < 3.6 users if sys.version_info[:2] < (3, 6): - msg = "\033[31mDEPRECATION: You're using a python version that has reached end-of-life. " + \ - "Gitlint does not support Python < 3.6" + \ - "Please upgrade your Python to 3.6 or above.\033[0m" + msg = ( + "\033[31mDEPRECATION: You're using a python version that has reached end-of-life. " + + "Gitlint does not support Python < 3.6" + + "Please upgrade your Python to 3.6 or above.\033[0m" + ) print(msg) # Print a warning message for Windows users PLATFORM_IS_WINDOWS = "windows" in platform.system().lower() if PLATFORM_IS_WINDOWS: - msg = "\n\n\n\n\n****************\n" + \ - "WARNING: Gitlint support for Windows is still experimental and there are some known issues: " + \ - "https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows " + \ - "\n*******************" + msg = ( + "\n\n\n\n\n****************\n" + + "WARNING: Gitlint support for Windows is still experimental and there are some known issues: " + + "https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows " + + "\n*******************" + ) print(msg) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..495acd0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.black] +target_version = ['py36', 'py37', 'py38','py39','py310'] +line-length = 120 +# extend-exclude: keep excluding files from .gitignore in addition to the ones specified +extend-exclude = "gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py"
\ No newline at end of file @@ -1,8 +1,6 @@ -# -*- coding: utf-8 -*- # pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return, # pylint: disable=too-many-function-args,unexpected-keyword-arg -import io import os import platform import shutil @@ -20,8 +18,8 @@ from qa.utils import DEFAULT_ENCODING class BaseTestCase(TestCase): - """ Base class of which all gitlint integration test classes are derived. - Provides a number of convenience methods. """ + """Base class of which all gitlint integration test classes are derived. + Provides a number of convenience methods.""" # In case of assert failures, print the full error message maxDiff = None @@ -32,7 +30,7 @@ class BaseTestCase(TestCase): GITLINT_USAGE_ERROR = 253 def setUp(self): - """ Sets up the integration tests by creating a new temporary git repository """ + """Sets up the integration tests by creating a new temporary git repository""" self.tmpfiles = [] self.tmp_git_repos = [] self.tmp_git_repo = self.create_tmp_git_repo() @@ -47,7 +45,7 @@ class BaseTestCase(TestCase): def assertEqualStdout(self, output, expected): # pylint: disable=invalid-name self.assertIsInstance(output, RunningCommand) output = output.stdout.decode(DEFAULT_ENCODING) - output = output.replace('\r', '') + output = output.replace("\r", "") self.assertMultiLineEqual(output, expected) @staticmethod @@ -56,11 +54,11 @@ class BaseTestCase(TestCase): return os.path.realpath(f"/tmp/gitlint-test-{timestamp}") def create_tmp_git_repo(self): - """ Creates a temporary git repository and returns its directory path """ + """Creates a temporary git repository and returns its directory path""" tmp_git_repo = self.generate_temp_path() self.tmp_git_repos.append(tmp_git_repo) - git("init", tmp_git_repo) + git("init", "--initial-branch", "main", tmp_git_repo) # configuring name and email is required in every git repot git("config", "user.name", "gitlint-test-user", _cwd=tmp_git_repo) git("config", "user.email", "gitlint@test.com", _cwd=tmp_git_repo) @@ -77,29 +75,43 @@ class BaseTestCase(TestCase): return tmp_git_repo @staticmethod - def create_file(parent_dir): - """ Creates a file inside a passed directory. Returns filename.""" + def create_file(parent_dir, content=None): + """Creates a file inside a passed directory. Returns filename.""" test_filename = "test-fïle-" + str(uuid4()) - # pylint: disable=consider-using-with - io.open(os.path.join(parent_dir, test_filename), 'a', encoding=DEFAULT_ENCODING).close() + full_path = os.path.join(parent_dir, test_filename) + + if content: + if isinstance(content, bytes): + open_kwargs = {"mode": "wb"} + else: + open_kwargs = {"mode": "w", "encoding": DEFAULT_ENCODING} + + with open(full_path, **open_kwargs) as f: # pylint: disable=unspecified-encoding + f.write(content) + else: + # pylint: disable=consider-using-with + open(full_path, "a", encoding=DEFAULT_ENCODING).close() + return test_filename @staticmethod def create_environment(envvars=None): - """ Creates a copy of the current os.environ and adds/overwrites a given set of variables to it """ + """Creates a copy of the current os.environ and adds/overwrites a given set of variables to it""" environment = os.environ.copy() if envvars: environment.update(envvars) return environment def create_tmp_git_config(self, contents): - """ Creates an environment with the GIT_CONFIG variable set to a file with the given contents. """ + """Creates an environment with the GIT_CONFIG variable set to a file with the given contents.""" tmp_config = self.create_tmpfile(contents) return self.create_environment({"GIT_CONFIG": tmp_config}) - def create_simple_commit(self, message, out=None, ok_code=None, env=None, git_repo=None, tty_in=False): - """ Creates a simple commit with an empty test file. - :param message: Commit message for the commit. """ + def create_simple_commit( + self, message, *, file_contents=None, out=None, ok_code=None, env=None, git_repo=None, tty_in=False + ): + """Creates a simple commit with an empty test file. + :param message: Commit message for the commit.""" git_repo = self.tmp_git_repo if git_repo is None else git_repo @@ -110,23 +122,39 @@ class BaseTestCase(TestCase): environment = self.create_environment(env) # Create file and add to git - test_filename = self.create_file(git_repo) + test_filename = self.create_file(git_repo, file_contents) git("add", test_filename, _cwd=git_repo) # https://amoffat.github.io/sh/#interactive-callbacks if not ok_code: ok_code = [0] - git("commit", "-m", message, _cwd=git_repo, _err_to_out=True, _out=out, _tty_in=tty_in, - _ok_code=ok_code, _env=environment) + git( + "commit", + "-m", + message, + _cwd=git_repo, + _err_to_out=True, + _out=out, + _tty_in=tty_in, + _ok_code=ok_code, + _env=environment, + ) return test_filename def create_tmpfile(self, content): - """ Utility method to create temp files. These are cleaned at the end of the test """ - # Not using a context manager to avoid unneccessary identation in test code + """Utility method to create temp files. These are cleaned at the end of the test""" + # Not using a context manager to avoid unnecessary indentation in test code tmpfile, tmpfilepath = tempfile.mkstemp() self.tmpfiles.append(tmpfilepath) - with io.open(tmpfile, "w", encoding=DEFAULT_ENCODING) as f: + + if isinstance(content, bytes): + open_kwargs = {"mode": "wb"} + else: + open_kwargs = {"mode": "w", "encoding": DEFAULT_ENCODING} + + with open(tmpfile, **open_kwargs) as f: # pylint: disable=unspecified-encoding f.write(content) + return tmpfilepath @staticmethod @@ -149,11 +177,11 @@ class BaseTestCase(TestCase): @staticmethod def get_expected(filename="", variable_dict=None): - """ Utility method to read an 'expected' file and return it as a string. Optionally replace template variables - specified by variable_dict. """ + """Utility method to read an 'expected' file and return it as a string. Optionally replace template variables + specified by variable_dict.""" expected_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected") expected_path = os.path.join(expected_dir, filename) - with io.open(expected_path, encoding=DEFAULT_ENCODING) as file: + with open(expected_path, encoding=DEFAULT_ENCODING) as file: expected = file.read() if variable_dict: @@ -162,20 +190,25 @@ class BaseTestCase(TestCase): @staticmethod def get_system_info_dict(): - """ Returns a dict with items related to system values logged by `gitlint --debug` """ + """Returns a dict with items related to system values logged by `gitlint --debug`""" expected_gitlint_version = gitlint("--version").replace("gitlint, version ", "").strip() expected_git_version = git("--version").strip() - return {'platform': platform.platform(), 'python_version': sys.version, - 'git_version': expected_git_version, 'gitlint_version': expected_gitlint_version, - 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB, 'DEFAULT_ENCODING': DEFAULT_ENCODING} + return { + "platform": platform.platform(), + "python_version": sys.version, + "git_version": expected_git_version, + "gitlint_version": expected_gitlint_version, + "GITLINT_USE_SH_LIB": BaseTestCase.GITLINT_USE_SH_LIB, + "DEFAULT_ENCODING": DEFAULT_ENCODING, + } def get_debug_vars_last_commit(self, git_repo=None): - """ Returns a dict with items related to `gitlint --debug` output for the last commit. """ + """Returns a dict with items related to `gitlint --debug` output for the last commit.""" target_repo = git_repo if git_repo else self.tmp_git_repo commit_sha = self.get_last_commit_hash(git_repo=target_repo) expected_date = git("log", "-1", "--pretty=%ai", _tty_out=False, _cwd=target_repo) expected_date = arrow.get(str(expected_date), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z") expected_kwargs = self.get_system_info_dict() - expected_kwargs.update({'target': target_repo, 'commit_sha': commit_sha, 'commit_date': expected_date}) + expected_kwargs.update({"target": target_repo, "commit_sha": commit_sha, "commit_date": expected_date}) return expected_kwargs diff --git a/qa/expected/test_commits/test_csv_hash_list_1 b/qa/expected/test_commits/test_csv_hash_list_1 new file mode 100644 index 0000000..bbd9f51 --- /dev/null +++ b/qa/expected/test_commits/test_csv_hash_list_1 @@ -0,0 +1,11 @@ +Commit {commit_sha2}: +1: T3 Title has trailing punctuation (.): "Sïmple title2." +3: B6 Body message is missing + +Commit {commit_sha1}: +1: T3 Title has trailing punctuation (.): "Sïmple title1." +3: B6 Body message is missing + +Commit {commit_sha4}: +1: T3 Title has trailing punctuation (.): "Sïmple title4." +3: B6 Body message is missing diff --git a/qa/expected/test_commits/test_ignore_commits_1 b/qa/expected/test_commits/test_ignore_commits_1 index f9062c1..01cf8bd 100644 --- a/qa/expected/test_commits/test_ignore_commits_1 +++ b/qa/expected/test_commits/test_ignore_commits_1 @@ -1,3 +1,5 @@ +WARNING: I1 - ignore-by-title: gitlint will be switching from using Python regex 'match' (match beginning) to 'search' (match anywhere) semantics. Please review your ignore-by-title.regex option accordingly. To remove this warning, set general.regex-style-search=True. More details: https://jorisroovers.github.io/gitlint/configuration/#regex-style-search +WARNING: I2 - ignore-by-body: gitlint will be switching from using Python regex 'match' (match beginning) to 'search' (match anywhere) semantics. Please review your ignore-by-body.regex option accordingly. To remove this warning, set general.regex-style-search=True. More details: https://jorisroovers.github.io/gitlint/configuration/#regex-style-search Commit {commit_sha0}: 1: T3 Title has trailing punctuation (.): "Sïmple title4." diff --git a/qa/expected/test_commits/test_lint_staged_msg_filename_1 b/qa/expected/test_commits/test_lint_staged_msg_filename_1 index 901ea27..f2ab49e 100644 --- a/qa/expected/test_commits/test_lint_staged_msg_filename_1 +++ b/qa/expected/test_commits/test_lint_staged_msg_filename_1 @@ -14,11 +14,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -60,17 +62,17 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Fetching additional meta-data from staged commit DEBUG: gitlint.cli Using --msg-filename. DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.lint Linting commit [SHA UNKNOWN] +DEBUG: gitlint.git ('diff', '--staged', '--numstat', '-r') DEBUG: gitlint.git ('config', '--get', 'user.name') DEBUG: gitlint.git ('config', '--get', 'user.email') DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD') -DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: from fïle test. @@ -79,10 +81,14 @@ Author: gitlint-test-user <gitlint@test.com> Date: {staged_date} is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False -Branches: ['master'] +Parents: [] +Branches: ['main'] Changed Files: {changed_files} +Changed Files Stats: + {changed_files_stats} ----------------------- 1: T3 Title has trailing punctuation (.): "WIP: from fïle test." 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: from fïle test." diff --git a/qa/expected/test_commits/test_lint_staged_stdin_1 b/qa/expected/test_commits/test_lint_staged_stdin_1 index e4677c3..cf34b8b 100644 --- a/qa/expected/test_commits/test_lint_staged_stdin_1 +++ b/qa/expected/test_commits/test_lint_staged_stdin_1 @@ -14,11 +14,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True fail-without-commits: False +regex-style-search: False verbosity: 3 debug: True target: {target} @@ -60,7 +62,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Fetching additional meta-data from staged commit DEBUG: gitlint.cli Stdin data: 'WIP: Pïpe test. @@ -69,10 +71,10 @@ DEBUG: gitlint.cli Stdin detected and not ignored. Using as input. DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.lint Linting commit [SHA UNKNOWN] +DEBUG: gitlint.git ('diff', '--staged', '--numstat', '-r') DEBUG: gitlint.git ('config', '--get', 'user.name') DEBUG: gitlint.git ('config', '--get', 'user.email') DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD') -DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: Pïpe test. @@ -81,10 +83,14 @@ Author: gitlint-test-user <gitlint@test.com> Date: {staged_date} is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False -Branches: ['master'] +Parents: [] +Branches: ['main'] Changed Files: {changed_files} +Changed Files Stats: + {changed_files_stats} ----------------------- 1: T3 Title has trailing punctuation (.): "WIP: Pïpe test." 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Pïpe test." diff --git a/qa/expected/test_config/test_config_from_env_1 b/qa/expected/test_config/test_config_from_env_1 index 60f6690..38fba21 100644 --- a/qa/expected/test_config/test_config_from_env_1 +++ b/qa/expected/test_config/test_config_from_env_1 @@ -14,11 +14,13 @@ contrib: ['CC1', 'CT1'] ignore: T1,T2 ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: True staged: False fail-without-commits: True +regex-style-search: False verbosity: 2 debug: True target: {target} @@ -60,7 +62,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ CC1: contrib-body-requires-signed-off-by CT1: contrib-title-conventional-commits types=fix,feat,chore,docs,style,refactor,perf,test,revert,ci,build @@ -71,8 +73,8 @@ DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.git ('log', '{commit_sha}', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.lint Linting commit {commit_sha} +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '{commit_sha}') DEBUG: gitlint.git ('branch', '--contains', '{commit_sha}') -DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '{commit_sha}') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: Thïs is a title thåt is a bit longer. @@ -84,10 +86,14 @@ Author: gitlint-test-user <gitlint@test.com> Date: {commit_date} is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False -Branches: ['master'] +Parents: [] +Branches: ['main'] Changed Files: {changed_files} +Changed Files Stats: + {changed_files_stats} ----------------------- 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, ci, build diff --git a/qa/expected/test_config/test_config_from_env_2 b/qa/expected/test_config/test_config_from_env_2 index e9ebd67..50d1e3f 100644 --- a/qa/expected/test_config/test_config_from_env_2 +++ b/qa/expected/test_config/test_config_from_env_2 @@ -14,11 +14,13 @@ contrib: [] ignore: ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True fail-without-commits: False +regex-style-search: False verbosity: 0 debug: True target: {target} @@ -60,17 +62,17 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli Fetching additional meta-data from staged commit DEBUG: gitlint.cli Using --msg-filename. DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.lint Linting commit [SHA UNKNOWN] +DEBUG: gitlint.git ('diff', '--staged', '--numstat', '-r') DEBUG: gitlint.git ('config', '--get', 'user.name') DEBUG: gitlint.git ('config', '--get', 'user.email') DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD') -DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: msg-fïlename test. @@ -79,9 +81,12 @@ Author: gitlint-test-user <gitlint@test.com> Date: {date} is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False -Branches: ['master'] +Parents: [] +Branches: ['main'] Changed Files: [] +Changed Files Stats: {{}} ----------------------- DEBUG: gitlint.cli Exit Code = 3 diff --git a/qa/expected/test_config/test_config_from_file_debug_1 b/qa/expected/test_config/test_config_from_file_debug_1 index 6ad5ec4..39bdf52 100644 --- a/qa/expected/test_config/test_config_from_file_debug_1 +++ b/qa/expected/test_config/test_config_from_file_debug_1 @@ -14,11 +14,13 @@ contrib: [] ignore: title-trailing-punctuation,B2 ignore-merge-commits: True ignore-fixup-commits: True +ignore-fixup-amend-commits: True ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False fail-without-commits: False +regex-style-search: False verbosity: 2 debug: True target: {target} @@ -60,7 +62,7 @@ target: {target} B8: body-match-regex regex=None M1: author-valid-email - regex=[^@ ]+@[^@ ]+\.[^@ ]+ + regex=^[^@ ]+@[^@ ]+\.[^@ ]+ DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo. DEBUG: gitlint.git ('log', '-1', '--pretty=%H') @@ -68,8 +70,8 @@ DEBUG: gitlint.cli Linting 1 commit(s) DEBUG: gitlint.git ('log', '{commit_sha}', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') DEBUG: gitlint.git ('config', '--get', 'core.commentchar') DEBUG: gitlint.lint Linting commit {commit_sha} +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '{commit_sha}') DEBUG: gitlint.git ('branch', '--contains', '{commit_sha}') -DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '{commit_sha}') DEBUG: gitlint.lint Commit Object --- Commit Message ---- WIP: Thïs is a title thåt is a bit longer. @@ -81,10 +83,14 @@ Author: gitlint-test-user <gitlint@test.com> Date: {commit_date} is-merge-commit: False is-fixup-commit: False +is-fixup-amend-commit: False is-squash-commit: False is-revert-commit: False -Branches: ['master'] +Parents: [] +Branches: ['main'] Changed Files: {changed_files} +Changed Files Stats: + {changed_files_stats} ----------------------- 1: T1 Title exceeds max length (42>20) 1: T5 Title contains the word 'WIP' (case-insensitive) diff --git a/qa/expected/test_gitlint/test_commit_binary_file_1 b/qa/expected/test_gitlint/test_commit_binary_file_1 new file mode 100644 index 0000000..6bc119b --- /dev/null +++ b/qa/expected/test_gitlint/test_commit_binary_file_1 @@ -0,0 +1,94 @@ +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.git ('--version',) +DEBUG: gitlint.cli Git version: {git_version} +DEBUG: gitlint.cli Gitlint version: {gitlint_version} +DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB} +DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING} +DEBUG: gitlint.cli Configuration +config-path: None +[GENERAL] +extra-path: None +contrib: [] +ignore: +ignore-merge-commits: True +ignore-fixup-commits: True +ignore-fixup-amend-commits: True +ignore-squash-commits: True +ignore-revert-commits: True +ignore-stdin: False +staged: False +fail-without-commits: False +regex-style-search: False +verbosity: 3 +debug: True +target: {target} +[RULES] + I1: ignore-by-title + ignore=all + regex=None + I2: ignore-by-body + ignore=all + regex=None + I3: ignore-body-lines + regex=None + I4: ignore-by-author-name + 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=None + T8: title-min-length + min-length=5 + 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= + B8: body-match-regex + regex=None + 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.git ('log', '-1', '--pretty=%H') +DEBUG: gitlint.cli Linting 1 commit(s) +DEBUG: gitlint.git ('log', '{commit_sha}', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B') +DEBUG: gitlint.git ('config', '--get', 'core.commentchar') +DEBUG: gitlint.lint Linting commit {commit_sha} +DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '{commit_sha}') +DEBUG: gitlint.git ('branch', '--contains', '{commit_sha}') +DEBUG: gitlint.lint Commit Object +--- Commit Message ---- +Sïmple commit + +--- Meta info --------- +Author: gitlint-test-user <gitlint@test.com> +Date: {commit_date} +is-merge-commit: False +is-fixup-commit: False +is-fixup-amend-commit: False +is-squash-commit: False +is-revert-commit: False +Parents: [] +Branches: ['main'] +Changed Files: {changed_files} +Changed Files Stats: + {changed_files_stats} +----------------------- +3: B6 Body message is missing +DEBUG: gitlint.cli Exit Code = 1 diff --git a/qa/expected/test_user_defined/test_user_defined_rules_examples_1 b/qa/expected/test_user_defined/test_user_defined_rules_examples_1 index 44add31..e675d7b 100644 --- a/qa/expected/test_user_defined/test_user_defined_rules_examples_1 +++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_1 @@ -1,5 +1,5 @@ 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title" 1: UC2 Body does not contain a 'Signed-off-by' line -1: UC3 Branch name 'master' does not start with one of ['feature/', 'hotfix/', 'release/'] +1: UC3 Branch name 'main' does not start with one of ['feature/', 'hotfix/', 'release/'] 1: UL1 Title contains the special character '$': "WIP: Thi$ is å title" 2: B4 Second line is not empty: "Content on the second line" diff --git a/qa/expected/test_user_defined/test_user_defined_rules_examples_2 b/qa/expected/test_user_defined/test_user_defined_rules_examples_2 index 11766fc..9b96423 100644 --- a/qa/expected/test_user_defined/test_user_defined_rules_examples_2 +++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_2 @@ -1,4 +1,4 @@ 1: UC2 Body does not contain a 'Signed-off-by' line -1: UC3 Branch name 'master' does not start with one of ['feature/', 'hotfix/', 'release/'] +1: UC3 Branch name 'main' does not start with one of ['feature/', 'hotfix/', 'release/'] 1: UL1 Title contains the special character '$' 2: B4 Second line is not empty diff --git a/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1 b/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1 index f514f5d..6e0d4cd 100644 --- a/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1 +++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_1 @@ -1,6 +1,6 @@ 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title" 1: UC1 Body contains too many lines (2 > 1) 1: UC2 Body does not contain a 'Signed-off-by' line -1: UC3 Branch name 'master' does not start with one of ['feature/', 'hotfix/', 'release/'] +1: UC3 Branch name 'main' does not start with one of ['feature/', 'hotfix/', 'release/'] 1: UL1 Title contains the special character '$': "WIP: Thi$ is å title" 2: B4 Second line is not empty: "Content on the second line" diff --git a/qa/expected/test_user_defined/test_user_defined_rules_extra_1 b/qa/expected/test_user_defined/test_user_defined_rules_extra_1 index 1f48fad..77642dc 100644 --- a/qa/expected/test_user_defined/test_user_defined_rules_extra_1 +++ b/qa/expected/test_user_defined/test_user_defined_rules_extra_1 @@ -1,7 +1,7 @@ 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Thi$ is å title" -1: UC1 GitContext.current_branch: master +1: UC1 GitContext.current_branch: main 1: UC1 GitContext.commentchar: # -1: UC2 GitCommit.branches: ['master'] +1: UC2 GitCommit.branches: ['main'] 1: UC2 GitCommit.custom_prop: foöbar 1: UC4 int-öption: 2 1: UC4 str-öption: föo diff --git a/qa/requirements.txt b/qa/requirements.txt index 76d6afc..cf6baa5 100644 --- a/qa/requirements.txt +++ b/qa/requirements.txt @@ -1,4 +1,4 @@ -sh==1.14.2 -pytest==6.2.5; -arrow==1.2.1; +sh==1.14.3 +pytest==7.0.1; +arrow==1.2.3; gitlint # no version as you want to test the currently installed version diff --git a/qa/samples/user_rules/extra/extra_rules.py b/qa/samples/user_rules/extra/extra_rules.py index 9a0ae6d..cad531b 100644 --- a/qa/samples/user_rules/extra/extra_rules.py +++ b/qa/samples/user_rules/extra/extra_rules.py @@ -1,25 +1,25 @@ -# -*- coding: utf-8 -*- - from gitlint.rules import CommitRule, RuleViolation, ConfigurationRule from gitlint.options import IntOption, StrOption, ListOption class GitContextRule(CommitRule): - """ Rule that tests whether we can correctly access certain gitcontext properties """ + """Rule that tests whether we can correctly access certain gitcontext properties""" + name = "gïtcontext" id = "UC1" def validate(self, commit): violations = [ RuleViolation(self.id, f"GitContext.current_branch: {commit.context.current_branch}", line_nr=1), - RuleViolation(self.id, f"GitContext.commentchar: {commit.context.commentchar}", line_nr=1) + RuleViolation(self.id, f"GitContext.commentchar: {commit.context.commentchar}", line_nr=1), ] return violations class GitCommitRule(CommitRule): - """ Rule that tests whether we can correctly access certain commit properties """ + """Rule that tests whether we can correctly access certain commit properties""" + name = "gïtcommit" id = "UC2" @@ -33,7 +33,8 @@ class GitCommitRule(CommitRule): class GitlintConfigurationRule(ConfigurationRule): - """ Rule that tests whether we can correctly access the config as well as modify the commit message """ + """Rule that tests whether we can correctly access the config as well as modify the commit message""" + name = "cönfigrule" id = "UC3" @@ -50,13 +51,16 @@ class GitlintConfigurationRule(ConfigurationRule): class ConfigurableCommitRule(CommitRule): - """ Rule that tests that we can add configuration to user-defined rules """ + """Rule that tests that we can add configuration to user-defined rules""" + name = "configürable" id = "UC4" - options_spec = [IntOption("int-öption", 2, "int-öption description"), - StrOption("str-öption", "föo", "int-öption description"), - ListOption("list-öption", ["foo", "bar"], "list-öption description")] + options_spec = [ + IntOption("int-öption", 2, "int-öption description"), + StrOption("str-öption", "föo", "int-öption description"), + ListOption("list-öption", ["foo", "bar"], "list-öption description"), + ] def validate(self, _): violations = [ diff --git a/qa/shell.py b/qa/shell.py index 06ebfed..44716c0 100644 --- a/qa/shell.py +++ b/qa/shell.py @@ -1,4 +1,3 @@ - # This code is mostly duplicated from the `gitlint.shell` module. We consciously duplicate this code as to not depend # on gitlint internals for our integration testing framework. @@ -7,6 +6,7 @@ from qa.utils import USE_SH_LIB, DEFAULT_ENCODING if USE_SH_LIB: from sh import git, echo, gitlint # pylint: disable=unused-import,no-name-in-module,import-error + gitlint = gitlint.bake(_unify_ttys=True, _tty_in=True) # pylint: disable=invalid-name # import exceptions separately, this makes it a little easier to mock them out in the unit tests @@ -14,17 +14,18 @@ if USE_SH_LIB: else: class CommandNotFound(Exception): - """ Exception indicating a command was not found during execution """ + """Exception indicating a command was not found during execution""" + pass class RunningCommand: pass class ShResult(RunningCommand): - """ Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using - the builtin subprocess module. """ + """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): + def __init__(self, full_cmd, stdout, stderr="", exitcode=0): self.full_cmd = full_cmd # TODO(jorisroovers): The 'sh' library by default will merge stdout and stderr. We mimic this behavior # for now until we fully remove the 'sh' library. @@ -36,7 +37,8 @@ else: return self.stdout class ErrorReturnCode(ShResult, Exception): - """ ShResult subclass for unexpected results (acts as an exception). """ + """ShResult subclass for unexpected results (acts as an exception).""" + pass def git(*command_parts, **kwargs): @@ -54,17 +56,17 @@ else: # 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: + if hasattr(result, "exit_code") and result.exit_code > 0: return result return str(result) def _exec(*args, **kwargs): pipe = subprocess.PIPE - popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)} - if '_cwd' in kwargs: - popen_kwargs['cwd'] = kwargs['_cwd'] - if '_env' in kwargs: - popen_kwargs['env'] = kwargs['_env'] + popen_kwargs = {"stdout": pipe, "stderr": pipe, "shell": kwargs.get("_tty_out", False)} + if "_cwd" in kwargs: + popen_kwargs["cwd"] = kwargs["_cwd"] + if "_env" in kwargs: + popen_kwargs["env"] = kwargs["_env"] try: with subprocess.Popen(args, **popen_kwargs) as p: @@ -75,10 +77,10 @@ else: exit_code = p.returncode stdout = result[0].decode(DEFAULT_ENCODING) stderr = result[1] # 'sh' does not decode the stderr bytes to unicode - full_cmd = '' if args is None else ' '.join(args) + 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]) + ok_exit_codes = kwargs.get("_ok_code", [0]) if exit_code in ok_exit_codes: return ShResult(full_cmd, stdout, stderr, exit_code) diff --git a/qa/test_commits.py b/qa/test_commits.py index 92e1087..d40c211 100644 --- a/qa/test_commits.py +++ b/qa/test_commits.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg import re @@ -9,22 +8,22 @@ from qa.base import BaseTestCase class CommitsTests(BaseTestCase): - """ Integration tests for the --commits argument, i.e. linting multiple commits at once or linting specific commits - """ + """Integration tests for the --commits argument, i.e. linting multiple commits or linting specific commits""" def test_successful(self): - """ Test linting multiple commits without violations """ + """Test linting multiple commits without violations""" git("checkout", "-b", "test-branch-commits-base", _cwd=self.tmp_git_repo) self.create_simple_commit("Sïmple title\n\nSimple bödy describing the commit") git("checkout", "-b", "test-branch-commits", _cwd=self.tmp_git_repo) self.create_simple_commit("Sïmple title2\n\nSimple bödy describing the commit2") self.create_simple_commit("Sïmple title3\n\nSimple bödy describing the commit3") - output = gitlint("--commits", "test-branch-commits-base...test-branch-commits", - _cwd=self.tmp_git_repo, _tty_in=True) + output = gitlint( + "--commits", "test-branch-commits-base...test-branch-commits", _cwd=self.tmp_git_repo, _tty_in=True + ) self.assertEqualStdout(output, "") def test_violations(self): - """ Test linting multiple commits with violations """ + """Test linting multiple commits with violations""" git("checkout", "-b", "test-branch-commits-violations-base", _cwd=self.tmp_git_repo) self.create_simple_commit("Sïmple title.\n") git("checkout", "-b", "test-branch-commits-violations", _cwd=self.tmp_git_repo) @@ -33,15 +32,46 @@ class CommitsTests(BaseTestCase): commit_sha1 = self.get_last_commit_hash()[:10] self.create_simple_commit("Sïmple title3.\n") commit_sha2 = self.get_last_commit_hash()[:10] - output = gitlint("--commits", "test-branch-commits-violations-base...test-branch-commits-violations", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4]) + output = gitlint( + "--commits", + "test-branch-commits-violations-base...test-branch-commits-violations", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[4], + ) self.assertEqual(output.exit_code, 4) - expected_kwargs = {'commit_sha1': commit_sha1, 'commit_sha2': commit_sha2} + expected_kwargs = {"commit_sha1": commit_sha1, "commit_sha2": commit_sha2} self.assertEqualStdout(output, self.get_expected("test_commits/test_violations_1", expected_kwargs)) + def test_csv_hash_list(self): + """Test linting multiple commits (comma-separated) with violations""" + git("checkout", "-b", "test-branch-commits-violations-base", _cwd=self.tmp_git_repo) + self.create_simple_commit("Sïmple title1.\n") + commit_sha1 = self.get_last_commit_hash()[:10] + git("checkout", "-b", "test-branch-commits-violations", _cwd=self.tmp_git_repo) + + self.create_simple_commit("Sïmple title2.\n") + commit_sha2 = self.get_last_commit_hash()[:10] + self.create_simple_commit("Sïmple title3.\n") + self.create_simple_commit("Sïmple title4.\n") + commit_sha4 = self.get_last_commit_hash()[:10] + + # Lint subset of the commits in a specific order, passed in via csv list + output = gitlint( + "--commits", + f"{commit_sha2},{commit_sha1},{commit_sha4}", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[6], + ) + + self.assertEqual(output.exit_code, 6) + expected_kwargs = {"commit_sha1": commit_sha1, "commit_sha2": commit_sha2, "commit_sha4": commit_sha4} + self.assertEqualStdout(output, self.get_expected("test_commits/test_csv_hash_list_1", expected_kwargs)) + def test_lint_empty_commit_range(self): - """ Tests `gitlint --commits <sha>^...<sha>` --fail-without-commits where the provided range is empty. """ + """Tests `gitlint --commits <sha>^...<sha>` --fail-without-commits where the provided range is empty.""" self.create_simple_commit("Sïmple title.\n") self.create_simple_commit("Sïmple title2.\n") commit_sha = self.get_last_commit_hash() @@ -54,13 +84,19 @@ class CommitsTests(BaseTestCase): self.assertEqualStdout(output, "") # Gitlint should fail when --fail-without-commits is used - output = gitlint("--commits", refspec, "--fail-without-commits", _cwd=self.tmp_git_repo, _tty_in=True, - _ok_code=[self.GITLINT_USAGE_ERROR]) + output = gitlint( + "--commits", + refspec, + "--fail-without-commits", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[self.GITLINT_USAGE_ERROR], + ) self.assertEqual(output.exit_code, self.GITLINT_USAGE_ERROR) - self.assertEqualStdout(output, f"Error: No commits in range \"{refspec}\"\n") + self.assertEqualStdout(output, f'Error: No commits in range "{refspec}"\n') def test_lint_single_commit(self): - """ Tests `gitlint --commits <sha>^...<same sha>` """ + """Tests `gitlint --commits <sha>^...<same sha>`""" self.create_simple_commit("Sïmple title.\n") first_commit_sha = self.get_last_commit_hash() self.create_simple_commit("Sïmple title2.\n") @@ -68,8 +104,7 @@ class CommitsTests(BaseTestCase): refspec = f"{commit_sha}^...{commit_sha}" self.create_simple_commit("Sïmple title3.\n") - expected = ("1: T3 Title has trailing punctuation (.): \"Sïmple title2.\"\n" + - "3: B6 Body message is missing\n") + expected = '1: T3 Title has trailing punctuation (.): "Sïmple title2."\n' + "3: B6 Body message is missing\n" # Lint using --commit <commit sha> output = gitlint("--commit", commit_sha, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2]) @@ -83,8 +118,7 @@ class CommitsTests(BaseTestCase): # Lint the first commit in the repository. This is a use-case that is not supported by --commits # As <sha>^...<sha> is not correct refspec in case <sha> points to the initial commit (which has no parents) - expected = ("1: T3 Title has trailing punctuation (.): \"Sïmple title.\"\n" + - "3: B6 Body message is missing\n") + expected = '1: T3 Title has trailing punctuation (.): "Sïmple title."\n' + "3: B6 Body message is missing\n" output = gitlint("--commit", first_commit_sha, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2]) self.assertEqual(output.exit_code, 2) self.assertEqualStdout(output, expected) @@ -95,10 +129,10 @@ class CommitsTests(BaseTestCase): self.assertEqual(output.exit_code, 254) def test_lint_staged_stdin(self): - """ Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data - from the underlying repository. The easiest way to test this is by inspecting `--debug` output. - This is the equivalent of doing: - echo "WIP: Pïpe test." | gitlint --staged --debug + """Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data + from the underlying repository. The easiest way to test this is by inspecting `--debug` output. + This is the equivalent of doing: + echo "WIP: Pïpe test." | gitlint --staged --debug """ # Create a commit first, before we stage changes. This ensures the repo is properly initialized. self.create_simple_commit("Sïmple title.\n") @@ -109,30 +143,45 @@ class CommitsTests(BaseTestCase): filename2 = self.create_file(self.tmp_git_repo) git("add", filename2, _cwd=self.tmp_git_repo) - output = gitlint(echo("WIP: Pïpe test."), "--staged", "--debug", - _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3]) + output = gitlint( + echo("WIP: Pïpe test."), + "--staged", + "--debug", + _cwd=self.tmp_git_repo, + _tty_in=False, + _err_to_out=True, + _ok_code=[3], + ) # Determine variable parts of expected output expected_kwargs = self.get_debug_vars_last_commit() - expected_kwargs.update({'changed_files': sorted([filename1, filename2])}) + filenames = sorted([filename1, filename2]) + expected_kwargs.update( + { + "changed_files": filenames, + "changed_files_stats": ( + f"{filenames[0]}: 0 additions, 0 deletions\n {filenames[1]}: 0 additions, 0 deletions" + ), + } + ) # It's not really possible to determine the "Date: ..." line that is part of the debug output as this date # is not taken from git but instead generated by gitlint itself. As a workaround, we extract the date from the # gitlint output using a regex, parse the date to ensure the format is correct, and then pass that as an # expected variable. - matches = re.search(r'^Date:\s+(.*)', str(output), re.MULTILINE) + matches = re.search(r"^Date:\s+(.*)", str(output), re.MULTILINE) if matches: expected_date = arrow.get(str(matches.group(1)), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z") - expected_kwargs['staged_date'] = expected_date + expected_kwargs["staged_date"] = expected_date self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_staged_stdin_1", expected_kwargs)) self.assertEqual(output.exit_code, 3) def test_lint_staged_msg_filename(self): - """ Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data - from the underlying repository. The easiest way to test this is by inspecting `--debug` output. - This is the equivalent of doing: - gitlint --msg-filename /tmp/my-commit-msg --staged --debug + """Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data + from the underlying repository. The easiest way to test this is by inspecting `--debug` output. + This is the equivalent of doing: + gitlint --msg-filename /tmp/my-commit-msg --staged --debug """ # Create a commit first, before we stage changes. This ensures the repo is properly initialized. self.create_simple_commit("Sïmple title.\n") @@ -145,28 +194,44 @@ class CommitsTests(BaseTestCase): tmp_commit_msg_file = self.create_tmpfile("WIP: from fïle test.") - output = gitlint("--msg-filename", tmp_commit_msg_file, "--staged", "--debug", - _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3]) + output = gitlint( + "--msg-filename", + tmp_commit_msg_file, + "--staged", + "--debug", + _cwd=self.tmp_git_repo, + _tty_in=False, + _err_to_out=True, + _ok_code=[3], + ) # Determine variable parts of expected output expected_kwargs = self.get_debug_vars_last_commit() - expected_kwargs.update({'changed_files': sorted([filename1, filename2])}) + filenames = sorted([filename1, filename2]) + expected_kwargs.update( + { + "changed_files": filenames, + "changed_files_stats": ( + f"{filenames[0]}: 0 additions, 0 deletions\n {filenames[1]}: 0 additions, 0 deletions" + ), + } + ) # It's not really possible to determine the "Date: ..." line that is part of the debug output as this date # is not taken from git but instead generated by gitlint itself. As a workaround, we extract the date from the # gitlint output using a regex, parse the date to ensure the format is correct, and then pass that as an # expected variable. - matches = re.search(r'^Date:\s+(.*)', str(output), re.MULTILINE) + matches = re.search(r"^Date:\s+(.*)", str(output), re.MULTILINE) if matches: expected_date = arrow.get(str(matches.group(1)), "YYYY-MM-DD HH:mm:ss Z").format("YYYY-MM-DD HH:mm:ss Z") - expected_kwargs['staged_date'] = expected_date + expected_kwargs["staged_date"] = expected_date expected = self.get_expected("test_commits/test_lint_staged_msg_filename_1", expected_kwargs) self.assertEqualStdout(output, expected) self.assertEqual(output.exit_code, 3) def test_lint_head(self): - """ Testing whether we can also recognize special refs like 'HEAD' """ + """Testing whether we can also recognize special refs like 'HEAD'""" tmp_git_repo = self.create_tmp_git_repo() self.create_simple_commit("Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo) self.create_simple_commit("Sïmple title", git_repo=tmp_git_repo) @@ -174,13 +239,16 @@ class CommitsTests(BaseTestCase): output = gitlint("--commits", "HEAD", _cwd=tmp_git_repo, _tty_in=True, _ok_code=[3]) revlist = git("rev-list", "HEAD", _tty_in=True, _cwd=tmp_git_repo).split() - expected_kwargs = {"commit_sha0": revlist[0][:10], "commit_sha1": revlist[1][:10], - "commit_sha2": revlist[2][:10]} + expected_kwargs = { + "commit_sha0": revlist[0][:10], + "commit_sha1": revlist[1][:10], + "commit_sha2": revlist[2][:10], + } self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_head_1", expected_kwargs)) def test_ignore_commits(self): - """ Tests multiple commits of which some rules get ignored because of ignore-* rules """ + """Tests multiple commits of which some rules get ignored because of ignore-* rules""" # Create repo and some commits tmp_git_repo = self.create_tmp_git_repo() self.create_simple_commit("Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo) @@ -188,14 +256,17 @@ class CommitsTests(BaseTestCase): # But in this case only B5 because T3 and T5 are being ignored because of config self.create_simple_commit("Release: WIP tïtle.\n\nShort", git_repo=tmp_git_repo) # In the following 2 commits, the T3 violations are as normal - self.create_simple_commit( - "Sïmple WIP title3.\n\nThis is \ta relëase commit\nMore info", git_repo=tmp_git_repo) + self.create_simple_commit("Sïmple WIP title3.\n\nThis is \ta relëase commit\nMore info", git_repo=tmp_git_repo) self.create_simple_commit("Sïmple title4.\n\nSimple bödy describing the commit4", git_repo=tmp_git_repo) revlist = git("rev-list", "HEAD", _tty_in=True, _cwd=tmp_git_repo).split() config_path = self.get_sample_path("config/ignore-release-commits") output = gitlint("--commits", "HEAD", "--config", config_path, _cwd=tmp_git_repo, _tty_in=True, _ok_code=[4]) - expected_kwargs = {"commit_sha0": revlist[0][:10], "commit_sha1": revlist[1][:10], - "commit_sha2": revlist[2][:10], "commit_sha3": revlist[3][:10]} + expected_kwargs = { + "commit_sha0": revlist[0][:10], + "commit_sha1": revlist[1][:10], + "commit_sha2": revlist[2][:10], + "commit_sha3": revlist[3][:10], + } self.assertEqualStdout(output, self.get_expected("test_commits/test_ignore_commits_1", expected_kwargs)) diff --git a/qa/test_config.py b/qa/test_config.py index 432a2c5..1225f6a 100644 --- a/qa/test_config.py +++ b/qa/test_config.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg import re @@ -9,19 +8,24 @@ from qa.utils import DEFAULT_ENCODING class ConfigTests(BaseTestCase): - """ Integration tests for gitlint configuration and configuration precedence. """ + """Integration tests for gitlint configuration and configuration precedence.""" def test_ignore_by_id(self): self.create_simple_commit("WIP: Thïs is a title.\nContënt on the second line") output = gitlint("--ignore", "T5,B4", _tty_in=True, _cwd=self.tmp_git_repo, _ok_code=[1]) - expected = "1: T3 Title has trailing punctuation (.): \"WIP: Thïs is a title.\"\n" + expected = '1: T3 Title has trailing punctuation (.): "WIP: Thïs is a title."\n' self.assertEqualStdout(output, expected) def test_ignore_by_name(self): self.create_simple_commit("WIP: Thïs is a title.\nContënt on the second line") - output = gitlint("--ignore", "title-must-not-contain-word,body-first-line-empty", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1]) - expected = "1: T3 Title has trailing punctuation (.): \"WIP: Thïs is a title.\"\n" + output = gitlint( + "--ignore", + "title-must-not-contain-word,body-first-line-empty", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[1], + ) + expected = '1: T3 Title has trailing punctuation (.): "WIP: Thïs is a title."\n' self.assertEqualStdout(output, expected) def test_verbosity(self): @@ -47,8 +51,10 @@ class ConfigTests(BaseTestCase): self.assertEqualStdout(output, self.get_expected("test_config/test_set_rule_option_1")) def test_config_from_file(self): - commit_msg = "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \ - "This line of the body is here because we need it" + commit_msg = ( + "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + "This line of the body is here because we need it" + ) self.create_simple_commit(commit_msg) config_path = self.get_sample_path("config/gitlintconfig") output = gitlint("--config", config_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5]) @@ -58,44 +64,66 @@ class ConfigTests(BaseTestCase): # Test both on existing and new repo (we've had a bug in the past that was unique to empty repos) repos = [self.tmp_git_repo, self.create_tmp_git_repo()] for target_repo in repos: - commit_msg = "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \ - "This line of the body is here because we need it" + commit_msg = ( + "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + "This line of the body is here because we need it" + ) filename = self.create_simple_commit(commit_msg, git_repo=target_repo) config_path = self.get_sample_path("config/gitlintconfig") output = gitlint("--config", config_path, "--debug", _cwd=target_repo, _tty_in=True, _ok_code=[5]) expected_kwargs = self.get_debug_vars_last_commit(git_repo=target_repo) - expected_kwargs.update({'config_path': config_path, 'changed_files': [filename]}) - self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_file_debug_1", - expected_kwargs)) + expected_kwargs.update( + { + "config_path": config_path, + "changed_files": [filename], + "changed_files_stats": f"{filename}: 0 additions, 0 deletions", + } + ) + self.assertEqualStdout( + output, self.get_expected("test_config/test_config_from_file_debug_1", expected_kwargs) + ) def test_config_from_env(self): - """ Test for configuring gitlint from environment variables """ + """Test for configuring gitlint from environment variables""" # We invoke gitlint, configuring it via env variables, we can check whether gitlint picks these up correctly # by comparing the debug output with what we'd expect target_repo = self.create_tmp_git_repo() - commit_msg = "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \ - "This line of the body is here because we need it" + commit_msg = ( + "WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + "This line of the body is here because we need it" + ) filename = self.create_simple_commit(commit_msg, git_repo=target_repo) - env = self.create_environment({"GITLINT_DEBUG": "1", "GITLINT_VERBOSITY": "2", - "GITLINT_IGNORE": "T1,T2", "GITLINT_CONTRIB": "CC1,CT1", - "GITLINT_FAIL_WITHOUT_COMMITS": "1", "GITLINT_IGNORE_STDIN": "1", - "GITLINT_TARGET": target_repo, - "GITLINT_COMMITS": self.get_last_commit_hash(git_repo=target_repo)}) + env = self.create_environment( + { + "GITLINT_DEBUG": "1", + "GITLINT_VERBOSITY": "2", + "GITLINT_IGNORE": "T1,T2", + "GITLINT_CONTRIB": "CC1,CT1", + "GITLINT_FAIL_WITHOUT_COMMITS": "1", + "GITLINT_IGNORE_STDIN": "1", + "GITLINT_TARGET": target_repo, + "GITLINT_COMMITS": self.get_last_commit_hash(git_repo=target_repo), + } + ) output = gitlint(_env=env, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5]) expected_kwargs = self.get_debug_vars_last_commit(git_repo=target_repo) - expected_kwargs.update({'changed_files': [filename]}) + expected_kwargs.update( + {"changed_files": [filename], "changed_files_stats": f"{filename}: 0 additions, 0 deletions"} + ) self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_env_1", expected_kwargs)) # For some env variables, we need a separate test ast they are mutually exclusive with the ones tested above tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename test.") - env = self.create_environment({"GITLINT_DEBUG": "1", "GITLINT_TARGET": target_repo, - "GITLINT_SILENT": "1", "GITLINT_STAGED": "1"}) + env = self.create_environment( + {"GITLINT_DEBUG": "1", "GITLINT_TARGET": target_repo, "GITLINT_SILENT": "1", "GITLINT_STAGED": "1"} + ) - output = gitlint("--msg-filename", tmp_commit_msg_file, - _env=env, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3]) + output = gitlint( + "--msg-filename", tmp_commit_msg_file, _env=env, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3] + ) # Extract date from actual output to insert it into the expected output # We have to do this since there's no way for us to deterministically know that date otherwise diff --git a/qa/test_contrib.py b/qa/test_contrib.py index d71229a..129e576 100644 --- a/qa/test_contrib.py +++ b/qa/test_contrib.py @@ -1,23 +1,29 @@ -# -*- coding: utf-8 -*- # pylint: disable= from qa.shell import gitlint from qa.base import BaseTestCase class ContribRuleTests(BaseTestCase): - """ Integration tests for contrib rules.""" + """Integration tests for contrib rules.""" def test_contrib_rules(self): self.create_simple_commit("WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars") - output = gitlint("--contrib", "contrib-title-conventional-commits,CC1", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3]) + output = gitlint( + "--contrib", "contrib-title-conventional-commits,CC1", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3] + ) self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_1")) def test_contrib_rules_with_config(self): self.create_simple_commit("WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars") - output = gitlint("--contrib", "contrib-title-conventional-commits,CC1", - "-c", "contrib-title-conventional-commits.types=föo,bår", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3]) + output = gitlint( + "--contrib", + "contrib-title-conventional-commits,CC1", + "-c", + "contrib-title-conventional-commits.types=föo,bår", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[3], + ) self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_with_config_1")) def test_invalid_contrib_rules(self): diff --git a/qa/test_gitlint.py b/qa/test_gitlint.py index 0200d76..6c45196 100644 --- a/qa/test_gitlint.py +++ b/qa/test_gitlint.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg -import io import os from qa.shell import echo, git, gitlint from qa.base import BaseTestCase @@ -8,7 +6,7 @@ from qa.utils import DEFAULT_ENCODING class IntegrationTests(BaseTestCase): - """ Simple set of integration tests for gitlint """ + """Simple set of integration tests for gitlint""" def test_successful(self): # Test for STDIN with and without a TTY attached @@ -17,8 +15,8 @@ class IntegrationTests(BaseTestCase): self.assertEqualStdout(output, "") def test_successful_gitconfig(self): - """ Test gitlint when the underlying repo has specific git config set. - In the past, we've had issues with gitlint failing on some of these, so this acts as a regression test. """ + """Test gitlint when the underlying repo has specific git config set. + In the past, we've had issues with gitlint failing on some of these, so this acts as a regression test.""" # Different commentchar (Note: tried setting this to a special unicode char, but git doesn't like that) git("config", "--add", "core.commentchar", "$", _cwd=self.tmp_git_repo) @@ -27,8 +25,8 @@ class IntegrationTests(BaseTestCase): self.assertEqualStdout(output, "") def test_successful_merge_commit(self): - # Create branch on master - self.create_simple_commit("Cömmit on master\n\nSimple bödy") + # Create branch on main + self.create_simple_commit("Cömmit on main\n\nSimple bödy") # Create test branch, add a commit and determine the commit hash git("checkout", "-b", "test-branch", _cwd=self.tmp_git_repo) @@ -37,10 +35,10 @@ class IntegrationTests(BaseTestCase): self.create_simple_commit(f"{commit_title}\n\nSïmple body") hash = self.get_last_commit_hash() - # Checkout master and merge the commit + # Checkout main and merge the commit # We explicitly set the title of the merge commit to the title of the previous commit as this or similar # behavior is what many tools do that handle merges (like github, gerrit, etc). - git("checkout", "master", _cwd=self.tmp_git_repo) + git("checkout", "main", _cwd=self.tmp_git_repo) git("merge", "--no-ff", "-m", f"Merge '{commit_title}'", hash, _cwd=self.tmp_git_repo) # Run gitlint and assert output is empty @@ -54,18 +52,14 @@ class IntegrationTests(BaseTestCase): def test_fixup_commit(self): # Create a normal commit and assert that it has a violation - test_filename = self.create_simple_commit("Cömmit on WIP master\n\nSimple bödy that is long enough") + test_filename = self.create_simple_commit("Cömmit on WIP main\n\nSimple bödy that is long enough") output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1]) - expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP master\"\n" + expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP main\"\n" self.assertEqualStdout(output, expected) # Make a small modification to the commit and commit it using fixup commit - with io.open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh: - # Wanted to write a unicode string, but that's obnoxious if you want to do it across Python 2 and 3. - # https://stackoverflow.com/questions/22392377/ - # error-writing-a-file-with-file-write-in-python-unicodeencodeerror - # So just keeping it simple - ASCII will here - fh.write("Appending some stuff\n") + with open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh: + fh.write("Appending söme stuff\n") git("add", test_filename, _cwd=self.tmp_git_repo) @@ -78,13 +72,44 @@ class IntegrationTests(BaseTestCase): # Make sure that if we set the ignore-fixup-commits option to false that we do still see the violations output = gitlint("-c", "general.ignore-fixup-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2]) - expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"fixup! Cömmit on WIP master\"\n" + \ + expected = ( + "1: T5 Title contains the word 'WIP' (case-insensitive): \"fixup! Cömmit on WIP main\"\n" "3: B6 Body message is missing\n" + ) + + self.assertEqualStdout(output, expected) + + def test_fixup_amend_commit(self): + # Create a normal commit and assert that it has a violation + test_filename = self.create_simple_commit("Cömmit on WIP main\n\nSimple bödy that is long enough") + output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1]) + expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP main\"\n" + self.assertEqualStdout(output, expected) + + # Make a small modification to the commit and commit it using fixup=amend commit + with open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh: + fh.write("Appending söme stuff\n") + + git("add", test_filename, _cwd=self.tmp_git_repo) + + # We have to use --no-edit to avoid git starting $EDITOR to modify the commit message that is being amended + git("commit", "--no-edit", f"--fixup=amend:{self.get_last_commit_hash()}", _cwd=self.tmp_git_repo) + + # Assert that gitlint does not show an error for the fixup commit + output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True) + # No need to check exit code, the command above throws an exception on > 0 exit codes + self.assertEqualStdout(output, "") + + # Make sure that if we set the ignore-fixup-commits option to false that we do still see the violations + output = gitlint( + "-c", "general.ignore-fixup-amend-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1] + ) + expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"amend! Cömmit on WIP main\"\n" self.assertEqualStdout(output, expected) def test_revert_commit(self): - self.create_simple_commit("WIP: Cömmit on master.\n\nSimple bödy") + self.create_simple_commit("WIP: Cömmit on main.\n\nSimple bödy") hash = self.get_last_commit_hash() git("revert", hash, _cwd=self.tmp_git_repo) @@ -93,21 +118,22 @@ class IntegrationTests(BaseTestCase): self.assertEqualStdout(output, "") # Assert that we do see the error if we disable the ignore-revert-commits option - output = gitlint("-c", "general.ignore-revert-commits=false", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1]) + output = gitlint( + "-c", "general.ignore-revert-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1] + ) self.assertEqual(output.exit_code, 1) - expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Revert \"WIP: Cömmit on master.\"\"\n" + expected = '1: T5 Title contains the word \'WIP\' (case-insensitive): "Revert "WIP: Cömmit on main.""\n' self.assertEqualStdout(output, expected) def test_squash_commit(self): # Create a normal commit and assert that it has a violation - test_filename = self.create_simple_commit("Cömmit on WIP master\n\nSimple bödy that is long enough") + test_filename = self.create_simple_commit("Cömmit on WIP main\n\nSimple bödy that is long enough") output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1]) - expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP master\"\n" + expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"Cömmit on WIP main\"\n" self.assertEqualStdout(output, expected) # Make a small modification to the commit and commit it using squash commit - with io.open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh: + with open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=DEFAULT_ENCODING) as fh: # Wanted to write a unicode string, but that's obnoxious if you want to do it across Python 2 and 3. # https://stackoverflow.com/questions/22392377/ # error-writing-a-file-with-file-write-in-python-unicodeencodeerror @@ -124,10 +150,13 @@ class IntegrationTests(BaseTestCase): self.assertEqualStdout(output, "") # Make sure that if we set the ignore-squash-commits option to false that we do still see the violations - output = gitlint("-c", "general.ignore-squash-commits=false", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2]) - expected = "1: T5 Title contains the word 'WIP' (case-insensitive): \"squash! Cömmit on WIP master\"\n" + \ - "3: B5 Body message is too short (14<20): \"Töo short body\"\n" + output = gitlint( + "-c", "general.ignore-squash-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2] + ) + expected = ( + "1: T5 Title contains the word 'WIP' (case-insensitive): \"squash! Cömmit on WIP main\"\n" + '3: B5 Body message is too short (14<20): "Töo short body"\n' + ) self.assertEqualStdout(output, expected) @@ -139,11 +168,11 @@ class IntegrationTests(BaseTestCase): def test_msg_filename(self): tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename test.") - output = gitlint("--msg-filename", tmp_commit_msg_file, _tty_in=True, _ok_code=[3]) + output = gitlint("--msg-filename", tmp_commit_msg_file, _tty_in=True, _cwd=self.tmp_git_repo, _ok_code=[3]) self.assertEqualStdout(output, self.get_expected("test_gitlint/test_msg_filename_1")) def test_msg_filename_no_tty(self): - """ Make sure --msg-filename option also works with no TTY attached """ + """Make sure --msg-filename option also works with no TTY attached""" tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename NO TTY test.") # We need to set _err_to_out explicitly for sh to merge stdout and stderr output in case there's @@ -151,34 +180,51 @@ class IntegrationTests(BaseTestCase): # http://amoffat.github.io/sh/sections/special_arguments.html?highlight=_tty_in#err-to-out # We need to pass some whitespace to _in as sh will otherwise hang, see # https://github.com/amoffat/sh/issues/427 - output = gitlint("--msg-filename", tmp_commit_msg_file, _in=" ", - _tty_in=False, _err_to_out=True, _ok_code=[3]) + output = gitlint( + "--msg-filename", + tmp_commit_msg_file, + _cwd=self.tmp_git_repo, + _in=" ", + _tty_in=False, + _err_to_out=True, + _ok_code=[3], + ) self.assertEqualStdout(output, self.get_expected("test_gitlint/test_msg_filename_no_tty_1")) def test_no_git_name_set(self): - """ Ensure we print out a helpful message if user.name is not set """ + """Ensure we print out a helpful message if user.name is not set""" tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename NO name test.") # Name is checked before email so this isn't strictly # necessary but seems good for consistency. env = self.create_tmp_git_config("[user]\n email = test-emåil@foo.com\n") - output = gitlint("--staged", "--msg-filename", tmp_commit_msg_file, - _ok_code=[self.GIT_CONTEXT_ERROR_CODE], - _env=env) + output = gitlint( + "--staged", + "--msg-filename", + tmp_commit_msg_file, + _ok_code=[self.GIT_CONTEXT_ERROR_CODE], + _env=env, + _cwd=self.tmp_git_repo, + ) expected = "Missing git configuration: please set user.name\n" self.assertEqualStdout(output, expected) def test_no_git_email_set(self): - """ Ensure we print out a helpful message if user.email is not set """ + """Ensure we print out a helpful message if user.email is not set""" tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename NO email test.") env = self.create_tmp_git_config("[user]\n name = test åuthor\n") - output = gitlint("--staged", "--msg-filename", tmp_commit_msg_file, - _ok_code=[self.GIT_CONTEXT_ERROR_CODE], - _env=env) + output = gitlint( + "--staged", + "--msg-filename", + tmp_commit_msg_file, + _ok_code=[self.GIT_CONTEXT_ERROR_CODE], + _env=env, + _cwd=self.tmp_git_repo, + ) expected = "Missing git configuration: please set user.email\n" self.assertEqualStdout(output, expected) - def test_git_errors(self): + def test_git_empty_repo(self): # Repo has no commits: caused by `git log` empty_git_repo = self.create_tmp_git_repo() output = gitlint(_cwd=empty_git_repo, _tty_in=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE]) @@ -186,7 +232,36 @@ class IntegrationTests(BaseTestCase): expected = "Current branch has no commits. Gitlint requires at least one commit to function.\n" self.assertEqualStdout(output, expected) - # Repo has no commits: caused by `git rev-parse` - output = gitlint(echo("WIP: Pïpe test."), "--staged", _cwd=empty_git_repo, _tty_in=False, - _err_to_out=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE]) + def test_git_empty_repo_staged(self): + """When repo is empty, we can still use gitlint when using --staged flag and piping a message into it""" + empty_git_repo = self.create_tmp_git_repo() + expected = ( + '1: T3 Title has trailing punctuation (.): "WIP: Pïpe test."\n' + "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: Pïpe test.\"\n" + "3: B6 Body message is missing\n" + ) + + output = gitlint( + echo("WIP: Pïpe test."), "--staged", _cwd=empty_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3] + ) + self.assertEqualStdout(output, expected) + + def test_commit_binary_file(self): + """When committing a binary file, git shows somewhat different output in diff commands, + this test ensures gitlint deals with that correctly""" + binary_filename = self.create_simple_commit("Sïmple commit", file_contents=bytes([0x48, 0x00, 0x49, 0x00])) + output = gitlint( + "--debug", + _ok_code=1, + _cwd=self.tmp_git_repo, + ) + + expected_kwargs = self.get_debug_vars_last_commit() + expected_kwargs.update( + { + "changed_files": [binary_filename], + "changed_files_stats": (f"{binary_filename}: None additions, None deletions"), + } + ) + expected = self.get_expected("test_gitlint/test_commit_binary_file_1", expected_kwargs) self.assertEqualStdout(output, expected) diff --git a/qa/test_hooks.py b/qa/test_hooks.py index b78100e..19edeb2 100644 --- a/qa/test_hooks.py +++ b/qa/test_hooks.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg import os from qa.shell import git, gitlint @@ -6,15 +5,17 @@ from qa.base import BaseTestCase class HookTests(BaseTestCase): - """ Integration tests for gitlint commitmsg hooks""" - - VIOLATIONS = ['gitlint: checking commit message...\n', - '1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n', - '1: T5 Title contains the word \'WIP\' (case-insensitive): "WIP: This ïs a title."\n', - '2: B4 Second line is not empty: "Contënt on the second line"\n', - '3: B6 Body message is missing\n', - '-----------------------------------------------\n', - 'gitlint: \x1b[31mYour commit message contains violations.\x1b[0m\n'] + """Integration tests for gitlint commitmsg hooks""" + + VIOLATIONS = [ + "gitlint: checking commit message...\n", + '1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n', + "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This ïs a title.\"\n", + '2: B4 Second line is not empty: "Contënt on the second line"\n', + "3: B6 Body message is missing\n", + "-----------------------------------------------\n", + "gitlint: \x1b[31mYour commit message contains violations.\x1b[0m\n", + ] def setUp(self): super().setUp() @@ -29,16 +30,18 @@ class HookTests(BaseTestCase): # install git commit-msg hook and assert output output_installed = gitlint("install-hook", _cwd=self.tmp_git_repo) - expected_installed = ("Successfully installed gitlint commit-msg hook in " - f"{self.tmp_git_repo}/.git/hooks/commit-msg\n") + expected_installed = ( + f"Successfully installed gitlint commit-msg hook in {self.tmp_git_repo}/.git/hooks/commit-msg\n" + ) self.assertEqualStdout(output_installed, expected_installed) def tearDown(self): # uninstall git commit-msg hook and assert output output_uninstalled = gitlint("uninstall-hook", _cwd=self.tmp_git_repo) - expected_uninstalled = ("Successfully uninstalled gitlint commit-msg hook from " - f"{self.tmp_git_repo}/.git/hooks/commit-msg\n") + expected_uninstalled = ( + f"Successfully uninstalled gitlint commit-msg hook from {self.tmp_git_repo}/.git/hooks/commit-msg\n" + ) self.assertEqualStdout(output_uninstalled, expected_uninstalled) super().tearDown() @@ -58,63 +61,72 @@ class HookTests(BaseTestCase): self.response_index = (self.response_index + 1) % len(self.responses) def test_commit_hook_no_violations(self): - test_filename = self.create_simple_commit("This ïs a title\n\nBody contënt that should work", - out=self._interact, tty_in=True) + test_filename = self.create_simple_commit( + "This ïs a title\n\nBody contënt that should work", out=self._interact, tty_in=True + ) short_hash = self.get_last_commit_short_hash() - expected_output = ["gitlint: checking commit message...\n", - "gitlint: \x1b[32mOK\x1b[0m (no violations in commit message)\n", - f"[master {short_hash}] This ïs a title\n", - " 1 file changed, 0 insertions(+), 0 deletions(-)\n", - f" create mode 100644 {test_filename}\n"] - self.assertListEqual(expected_output, self.githook_output) + expected_output = [ + "gitlint: checking commit message...\n", + "gitlint: \x1b[32mOK\x1b[0m (no violations in commit message)\n", + f"[main {short_hash}] This ïs a title\n", + " 1 file changed, 0 insertions(+), 0 deletions(-)\n", + f" create mode 100644 {test_filename}\n", + ] + for output, expected in zip(self.githook_output, expected_output): + self.assertMultiLineEqual(output.replace("\r", ""), expected.replace("\r", "")) def test_commit_hook_continue(self): self.responses = ["y"] - test_filename = self.create_simple_commit("WIP: This ïs a title.\nContënt on the second line", - out=self._interact, tty_in=True) + test_filename = self.create_simple_commit( + "WIP: This ïs a title.\nContënt on the second line", out=self._interact, tty_in=True + ) # Determine short commit-msg hash, needed to determine expected output short_hash = self.get_last_commit_short_hash() expected_output = self._violations() - expected_output += ["Continue with commit anyways (this keeps the current commit message)? " + - "[y(es)/n(no)/e(dit)] " + - f"[master {short_hash}] WIP: This ïs a title. Contënt on the second line\n", - " 1 file changed, 0 insertions(+), 0 deletions(-)\n", - f" create mode 100644 {test_filename}\n"] + expected_output += [ + "Continue with commit anyways (this keeps the current commit message)? " + "[y(es)/n(no)/e(dit)] " + f"[main {short_hash}] WIP: This ïs a title. Contënt on the second line\n", + " 1 file changed, 0 insertions(+), 0 deletions(-)\n", + f" create mode 100644 {test_filename}\n", + ] - assert len(self.githook_output) == len(expected_output) for output, expected in zip(self.githook_output, expected_output): - self.assertMultiLineEqual( - output.replace('\r', ''), - expected.replace('\r', '')) + self.assertMultiLineEqual(output.replace("\r", ""), expected.replace("\r", "")) def test_commit_hook_abort(self): self.responses = ["n"] - test_filename = self.create_simple_commit("WIP: This ïs a title.\nContënt on the second line", - out=self._interact, ok_code=1, tty_in=True) + test_filename = self.create_simple_commit( + "WIP: This ïs a title.\nContënt on the second line", out=self._interact, ok_code=1, tty_in=True + ) git("rm", "-f", test_filename, _cwd=self.tmp_git_repo) # Determine short commit-msg hash, needed to determine expected output expected_output = self._violations() - expected_output += ["Continue with commit anyways (this keeps the current commit message)? " + - "[y(es)/n(no)/e(dit)] " + - "Commit aborted.\n", - "Your commit message: \n", - "-----------------------------------------------\n", - "WIP: This ïs a title.\n", - "Contënt on the second line\n", - "-----------------------------------------------\n"] + expected_output += [ + "Continue with commit anyways (this keeps the current commit message)? " + "[y(es)/n(no)/e(dit)] " + "Commit aborted.\n", + "Your commit message: \n", + "-----------------------------------------------\n", + "WIP: This ïs a title.\n", + "Contënt on the second line\n", + "-----------------------------------------------\n", + ] - self.assertListEqual(expected_output, self.githook_output) + for output, expected in zip(self.githook_output, expected_output): + self.assertMultiLineEqual(output.replace("\r", ""), expected.replace("\r", "")) def test_commit_hook_edit(self): self.responses = ["e", "y"] env = {"EDITOR": ":"} - test_filename = self.create_simple_commit("WIP: This ïs a title.\nContënt on the second line", - out=self._interact, env=env, tty_in=True) + test_filename = self.create_simple_commit( + "WIP: This ïs a title.\nContënt on the second line", out=self._interact, env=env, tty_in=True + ) git("rm", "-f", test_filename, _cwd=self.tmp_git_repo) short_hash = git("rev-parse", "--short", "HEAD", _cwd=self.tmp_git_repo, _tty_in=True).replace("\n", "") @@ -122,23 +134,23 @@ class HookTests(BaseTestCase): # Determine short commit-msg hash, needed to determine expected output expected_output = self._violations() - expected_output += ['Continue with commit anyways (this keeps the current commit message)? ' + - '[y(es)/n(no)/e(dit)] ' + self._violations()[0]] + expected_output += [ + "Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] " + + self._violations()[0] + ] expected_output += self._violations()[1:] - expected_output += ['Continue with commit anyways (this keeps the current commit message)? ' + - "[y(es)/n(no)/e(dit)] " + - f"[master {short_hash}] WIP: This ïs a title. Contënt on the second line\n", - " 1 file changed, 0 insertions(+), 0 deletions(-)\n", - f" create mode 100644 {test_filename}\n"] + expected_output += [ + "Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] " + f"[main {short_hash}] WIP: This ïs a title. Contënt on the second line\n", + " 1 file changed, 0 insertions(+), 0 deletions(-)\n", + f" create mode 100644 {test_filename}\n", + ] - assert len(self.githook_output) == len(expected_output) for output, expected in zip(self.githook_output, expected_output): - self.assertMultiLineEqual( - output.replace('\r', ''), - expected.replace('\r', '')) + self.assertMultiLineEqual(output.replace("\r", ""), expected.replace("\r", "")) def test_commit_hook_worktree(self): - """ Tests that hook installation and un-installation also work in git worktrees. + """Tests that hook installation and un-installation also work in git worktrees. Test steps: ```sh git init <tmpdir> diff --git a/qa/test_named_rules.py b/qa/test_named_rules.py index 92e968b..75cd9a1 100644 --- a/qa/test_named_rules.py +++ b/qa/test_named_rules.py @@ -1,10 +1,9 @@ -# -*- coding: utf-8 -*- from qa.shell import gitlint from qa.base import BaseTestCase class NamedRuleTests(BaseTestCase): - """ Integration tests for named rules.""" + """Integration tests for named rules.""" def test_named_rule(self): commit_msg = "WIP: thåt dûr bår\n\nSïmple commit body" @@ -18,6 +17,7 @@ class NamedRuleTests(BaseTestCase): self.create_simple_commit(commit_msg) config_path = self.get_sample_path("config/named-user-rules") extra_path = self.get_sample_path("user_rules/extra") - output = gitlint("--extra-path", extra_path, "--config", config_path, _cwd=self.tmp_git_repo, _tty_in=True, - _ok_code=[9]) + output = gitlint( + "--extra-path", extra_path, "--config", config_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[9] + ) self.assertEqualStdout(output, self.get_expected("test_named_rules/test_named_user_rule_1")) diff --git a/qa/test_stdin.py b/qa/test_stdin.py index cee5f0c..8ed4cb1 100644 --- a/qa/test_stdin.py +++ b/qa/test_stdin.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg -import io import subprocess from qa.shell import echo, gitlint from qa.base import BaseTestCase @@ -8,25 +6,24 @@ from qa.utils import DEFAULT_ENCODING class StdInTests(BaseTestCase): - """ Integration tests for various STDIN scenarios for gitlint """ + """Integration tests for various STDIN scenarios for gitlint""" def test_stdin_pipe(self): - """ Test piping input into gitlint. - This is the equivalent of doing: - $ echo "foo" | gitlint + """Test piping input into gitlint. + This is the equivalent of doing: + $ echo "foo" | gitlint """ # NOTE: There is no use in testing this with _tty_in=True, because if you pipe something into a command # there never is a TTY connected to stdin (per definition). We're setting _tty_in=False here to be explicit # but note that this is always true when piping something into a command. - output = gitlint(echo("WIP: Pïpe test."), - _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3]) + output = gitlint(echo("WIP: Pïpe test."), _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3]) self.assertEqualStdout(output, self.get_expected("test_stdin/test_stdin_pipe_1")) def test_stdin_pipe_empty(self): - """ Test the scenario where no TTY is attached and nothing is piped into gitlint. This occurs in - CI runners like Jenkins and Gitlab, see https://github.com/jorisroovers/gitlint/issues/42 for details. - This is the equivalent of doing: - $ echo -n "" | gitlint + """Test the scenario where no TTY is attached and nothing is piped into gitlint. This occurs in + CI runners like Jenkins and Gitlab, see https://github.com/jorisroovers/gitlint/issues/42 for details. + This is the equivalent of doing: + $ echo -n "" | gitlint """ commit_msg = "WIP: This ïs a title.\nContent on the sëcond line" self.create_simple_commit(commit_msg) @@ -39,18 +36,18 @@ class StdInTests(BaseTestCase): self.assertEqual(output, self.get_expected("test_stdin/test_stdin_pipe_empty_1")) def test_stdin_file(self): - """ Test the scenario where STDIN is a regular file (stat.S_ISREG = True) - This is the equivalent of doing: - $ gitlint < myfile + """Test the scenario where STDIN is a regular file (stat.S_ISREG = True) + This is the equivalent of doing: + $ gitlint < myfile """ tmp_commit_msg_file = self.create_tmpfile("WIP: STDIN ïs a file test.") - with io.open(tmp_commit_msg_file, encoding=DEFAULT_ENCODING) as file_handle: - + with open(tmp_commit_msg_file, encoding=DEFAULT_ENCODING) as file_handle: # We need to use subprocess.Popen() here instead of sh because when passing a file_handle to sh, it will # deal with reading the file itself instead of passing it on to gitlint as a STDIN. Since we're trying to # test for the condition where stat.S_ISREG == True that won't work for us here. - with subprocess.Popen("gitlint", stdin=file_handle, cwd=self.tmp_git_repo, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as p: + with subprocess.Popen( + "gitlint", stdin=file_handle, cwd=self.tmp_git_repo, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) as p: output, _ = p.communicate() self.assertEqual(output.decode(DEFAULT_ENCODING), self.get_expected("test_stdin/test_stdin_file_1")) diff --git a/qa/test_user_defined.py b/qa/test_user_defined.py index 378ab36..a003f3e 100644 --- a/qa/test_user_defined.py +++ b/qa/test_user_defined.py @@ -1,14 +1,13 @@ -# -*- coding: utf-8 -*- # pylint: disable=too-many-function-args,unexpected-keyword-arg from qa.shell import gitlint from qa.base import BaseTestCase class UserDefinedRuleTests(BaseTestCase): - """ Integration tests for user-defined rules.""" + """Integration tests for user-defined rules.""" def test_user_defined_rules_examples1(self): - """ Test the user defined rules in the top-level `examples/` directory """ + """Test the user defined rules in the top-level `examples/` directory""" extra_path = self.get_example_path() commit_msg = "WIP: Thi$ is å title\nContent on the second line" self.create_simple_commit(commit_msg) @@ -16,7 +15,7 @@ class UserDefinedRuleTests(BaseTestCase): self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_examples_1")) def test_user_defined_rules_examples2(self): - """ Test the user defined rules in the top-level `examples/` directory """ + """Test the user defined rules in the top-level `examples/` directory""" extra_path = self.get_example_path() commit_msg = "Release: Thi$ is å title\nContent on the second line\n$This line is ignored \nThis isn't\t\n" self.create_simple_commit(commit_msg) @@ -24,12 +23,19 @@ class UserDefinedRuleTests(BaseTestCase): self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_examples_2")) def test_user_defined_rules_examples_with_config(self): - """ Test the user defined rules in the top-level `examples/` directory """ + """Test the user defined rules in the top-level `examples/` directory""" extra_path = self.get_example_path() commit_msg = "WIP: Thi$ is å title\nContent on the second line" self.create_simple_commit(commit_msg) - output = gitlint("--extra-path", extra_path, "-c", "body-max-line-count.max-line-count=1", - _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[6]) + output = gitlint( + "--extra-path", + extra_path, + "-c", + "body-max-line-count.max-line-count=1", + _cwd=self.tmp_git_repo, + _tty_in=True, + _ok_code=[6], + ) expected_path = "test_user_defined/test_user_defined_rules_examples_with_config_1" self.assertEqualStdout(output, self.get_expected(expected_path)) @@ -38,12 +44,15 @@ class UserDefinedRuleTests(BaseTestCase): commit_msg = "WIP: Thi$ is å title\nContent on the second line" self.create_simple_commit(commit_msg) output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[9]) - self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_extra_1", - {'repo-path': self.tmp_git_repo})) + self.assertEqualStdout( + output, + self.get_expected("test_user_defined/test_user_defined_rules_extra_1", {"repo-path": self.tmp_git_repo}), + ) def test_invalid_user_defined_rules(self): extra_path = self.get_sample_path("user_rules/incorrect_linerule") self.create_simple_commit("WIP: test") output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[255]) - self.assertEqualStdout(output, - "Config Error: User-defined rule class 'MyUserLineRule' must have a 'validate' method\n") + self.assertEqualStdout( + output, "Config Error: User-defined rule class 'MyUserLineRule' must have a 'validate' method\n" + ) diff --git a/qa/utils.py b/qa/utils.py index c75872b..89292cd 100644 --- a/qa/utils.py +++ b/qa/utils.py @@ -22,7 +22,7 @@ PLATFORM_IS_WINDOWS = platform_is_windows() def use_sh_library(): - gitlint_use_sh_lib_env = os.environ.get('GITLINT_QA_USE_SH_LIB', None) + gitlint_use_sh_lib_env = os.environ.get("GITLINT_QA_USE_SH_LIB", None) if gitlint_use_sh_lib_env: return gitlint_use_sh_lib_env == "1" return not PLATFORM_IS_WINDOWS @@ -35,8 +35,8 @@ USE_SH_LIB = use_sh_library() def getpreferredencoding(): - """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars - on windows and falls back to UTF-8. """ + """Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars + on windows and falls back to UTF-8.""" default_encoding = locale.getpreferredencoding() or "UTF-8" # On Windows, we mimic git/linux by trying to read the LC_ALL, LC_CTYPE, LANG env vars manually @@ -51,7 +51,7 @@ def getpreferredencoding(): # If encoding contains a dot: split and use second part, otherwise use everything dot_index = encoding.find(".") if dot_index != -1: - default_encoding = encoding[dot_index + 1:] + default_encoding = encoding[dot_index + 1 :] else: default_encoding = encoding break diff --git a/requirements.txt b/requirements.txt index 270d9ff..99b12de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ setuptools -wheel==0.37.0 +wheel==0.37.1 -e . -e ./gitlint-core[trusted-deps] diff --git a/run_tests.sh b/run_tests.sh index 09386dc..2a95a92 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -6,15 +6,15 @@ help(){ echo "Run gitlint's test suite(s) or some convience commands" echo " -h, --help Show this help output" echo " -c, --clean Clean the project of temporary files" - echo " -p, --pep8 Run pep8 checks" + echo " -f, --format Run format checks" echo " -l, --lint Run pylint checks" echo " -g, --git Run gitlint checks" echo " -i, --integration Run integration tests" echo " -b, --build Run build tests" - echo " -a, --all Run all tests and checks (unit, integration, pep8, git)" + echo " -a, --all Run all tests and checks (unit, integration, formatting, git)" echo " -e, --envs [ENV1],[ENV2] Run tests against specified python environments" echo " (envs: 36,37,38,39,pypy37)." - echo " Also works for integration, pep8 and lint tests." + echo " Also works for integration, formatting and lint tests." echo " -C, --container Run the specified command in the container for the --envs specified" echo " --all-env Run all tests against all python environments" echo " --install Install virtualenvs for the --envs specified" @@ -74,11 +74,11 @@ handle_test_result(){ echo -e "${NO_COLOR}" } -run_pep8_check(){ - # FLAKE 8 - target=${testargs:-"gitlint-core qa examples"} - echo -ne "Running flake8..." - RESULT=$(flake8 $target) +run_formatting_check(){ + # BLACK + target=${testargs:-"."} + echo -ne "Running black --check..." + RESULT=$(black --check --diff $target) local exit_code=$? handle_test_result $exit_code "$RESULT" return $exit_code @@ -89,11 +89,14 @@ run_unit_tests(){ # py.test -s => print standard output (i.e. show print statement output) # -rw => print warnings target=${testargs:-"gitlint-core"} - coverage run -m pytest -rw -s $target - TEST_RESULT=$? if [ $include_coverage -eq 1 ]; then + coverage run -m pytest -rw -s $target + TEST_RESULT=$? COVERAGE_REPORT=$(coverage report -m) echo "$COVERAGE_REPORT" + else + pytest -rw -s $target + TEST_RESULT=$? fi return $TEST_RESULT; @@ -144,7 +147,7 @@ run_build_test(){ # Copy gitlint to a new temp dir echo -n "Copying gitlint to $temp_dir..." mkdir "$temp_dir" - rsync -az --exclude ".vagrant" --exclude ".git" --exclude ".venv*" . "$temp_dir" + rsync -az --exclude ".git" --exclude ".venv*" . "$temp_dir" echo -e "${GREEN}DONE${NO_COLOR}" # Update the version to include a timestamp @@ -214,7 +217,7 @@ clean(){ echo -n "Cleaning the *.pyc, site/, build/, dist/ and all __pycache__ directories..." find gitlint-core qa -type d -name "__pycache__" -exec rm -rf {} \; 2> /dev/null find gitlint-core qa -iname "*.pyc" -exec rm -rf {} \; 2> /dev/null - rm -rf "site" "dist" "build" + rm -rf "site" "dist" "build" "gitlint-core/dist" "gitlint-core/build" echo -e "${GREEN}DONE${NO_COLOR}" } @@ -230,7 +233,7 @@ run_all(){ run_build_test exit_code=$((exit_code + $?)) subtitle "# STYLE CHECKS ($(python --version 2>&1), $(which python)) #" - run_pep8_check + run_formatting_check exit_code=$((exit_code + $?)) run_lint_check exit_code=$((exit_code + $?)) @@ -379,7 +382,7 @@ switch_env(){ export PATH=$(echo $PATH | tr ":" "\n" | grep -v "$VIRTUAL_ENV" | tr "\n" ":"); fi set -e # Let's error out if you try executing against a non-existing env - source "/vagrant/.venv${1}/bin/activate" + source ".venv${1}/bin/activate" set +e fi title "### PYTHON ($(python --version 2>&1), $(which python)) ###" @@ -401,7 +404,7 @@ run_in_container(){ # default behavior -just_pep8=0 +just_formatting=0 just_lint=0 just_git=0 just_integration_tests=0 @@ -424,7 +427,7 @@ while [ "$#" -gt 0 ]; do case "$1" in -h|--help) shift; help;; -c|--clean) shift; just_clean=1;; - -p|--pep8) shift; just_pep8=1;; + -f|--format) shift; just_formatting=1;; -l|--lint) shift; just_lint=1;; -g|--git) shift; just_git=1;; -b|--build) shift; just_build_tests=1;; @@ -465,9 +468,9 @@ for environment in $envs; do if [ $container_enabled -eq 1 ]; then run_in_container "$environment" "$original_envs" "$original_args" - elif [ $just_pep8 -eq 1 ]; then + elif [ $just_formatting -eq 1 ]; then switch_env "$environment" - run_pep8_check + run_formatting_check elif [ $just_stats -eq 1 ]; then switch_env "$environment" run_stats @@ -23,7 +23,7 @@ Source code on `github.com/jorisroovers/gitlint`_. """ -version = "0.17.0" +version = "0.18.0" setup( name="gitlint", @@ -39,24 +39,26 @@ setup( "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Environment :: Console", "Intended Audience :: Developers", "Topic :: Software Development :: Quality Assurance", "Topic :: Software Development :: Testing", - "License :: OSI Approved :: MIT License" + "License :: OSI Approved :: MIT License", ], python_requires=">=3.6", install_requires=[ - 'gitlint-core[trusted-deps]==' + version, + "gitlint-core[trusted-deps]==" + version, ], - keywords='gitlint git lint', - author='Joris Roovers', - url='https://jorisroovers.github.io/gitlint', + keywords="gitlint git lint", + author="Joris Roovers", + url="https://jorisroovers.github.io/gitlint", project_urls={ - 'Documentation': 'https://jorisroovers.github.io/gitlint', - 'Source': 'https://github.com/jorisroovers/gitlint', + "Documentation": "https://jorisroovers.github.io/gitlint", + "Source": "https://github.com/jorisroovers/gitlint", + "Changelog": "https://github.com/jorisroovers/gitlint/blob/main/CHANGELOG.md", }, - license='MIT', + license="MIT", ) diff --git a/test-requirements.txt b/test-requirements.txt index ef3c711..149c36a 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,8 +1,10 @@ -flake8==4.0.1 -coverage==6.1.2 +black==22.8.0 +coverage==6.2; python_version == '3.6' +coverage==6.4.4; python_version != '3.6' python-coveralls==2.9.3 radon==5.1.0 -flake8-polyfill==1.0.2 # Required when installing both flake8 and radon>=4.3.1 -pytest==6.2.5; -pylint==2.12.1; +pytest==7.0.1 +pylint==2.13.7; python_version == '3.6' +pylint==2.15.3; python_version != '3.6' +pdbr==0.6.6; sys_platform != "win32" -r requirements.txt diff --git a/tools/create-test-repo.sh b/tools/create-test-repo.sh index 79934d6..5fddf8c 100755 --- a/tools/create-test-repo.sh +++ b/tools/create-test-repo.sh @@ -11,7 +11,7 @@ echo "pwd=$CWD" # Create the repo cd /tmp reponame=$(date +gitlint-test-%Y-%m-%d_%H-%M-%S) -git init $reponame +git init --initial-branch main $reponame cd $reponame # Do some basic config diff --git a/tools/windows/create-test-repo.bat b/tools/windows/create-test-repo.bat index 27e3394..54cf146 100644 --- a/tools/windows/create-test-repo.bat +++ b/tools/windows/create-test-repo.bat @@ -15,7 +15,7 @@ set Reponame=gitlint-test-%datetime% echo %Reponame% :: Create git repo -git init %Reponame% +git init --initial-branch main %Reponame% cd %Reponame% :: Do some basic config |