summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.coveragerc2
-rw-r--r--.devcontainer/Dockerfile21
-rw-r--r--.devcontainer/devcontainer.json61
-rwxr-xr-x.devcontainer/postCreateCommand.sh24
-rw-r--r--.flake811
-rw-r--r--.github/PULL_REQUEST_TEMPLATE.md13
-rw-r--r--.github/dependabot.yml18
-rw-r--r--.github/workflows/checks.yml113
-rw-r--r--.github/workflows/ci.yml142
-rw-r--r--.github/workflows/github-release.yml14
-rw-r--r--.github/workflows/publish-docker.yml79
-rw-r--r--.github/workflows/publish-release.yml176
-rw-r--r--.github/workflows/test-release.yml95
-rw-r--r--.gitignore4
-rw-r--r--.gitlint9
-rw-r--r--.pre-commit-hooks.yaml21
-rw-r--r--.pylintrc48
-rw-r--r--CHANGELOG.md516
-rw-r--r--Dockerfile12
-rw-r--r--Dockerfile.dev17
-rw-r--r--MANIFEST.in7
-rw-r--r--README.md11
-rw-r--r--Vagrantfile47
-rw-r--r--debian/changelog116
-rw-r--r--debian/control10
-rw-r--r--debian/copyright6
-rw-r--r--debian/patches/debian/0001-python3-hook.patch22
-rwxr-xr-xdebian/rules16
-rw-r--r--debian/watch2
-rw-r--r--doc-requirements.txt1
-rw-r--r--docs/configuration.md482
-rw-r--r--docs/contrib_rules.md38
-rw-r--r--docs/contributing.md217
-rw-r--r--docs/demos/asciicinema.json16
-rw-r--r--docs/extra.css8
-rw-r--r--docs/extra.js5
-rw-r--r--docs/images/dev-container.pngbin0 -> 212226 bytes
-rw-r--r--docs/images/gitlint-packages.drawio.svg351
-rw-r--r--docs/images/gitlint-packages.pngbin0 -> 51975 bytes
-rw-r--r--docs/images/readme-gitlint.pngbin0 -> 348007 bytes
-rw-r--r--docs/index.md339
-rw-r--r--docs/rules.md494
-rw-r--r--docs/user_defined_rules.md337
-rw-r--r--examples/commit-message-117
-rw-r--r--examples/gitlint2
-rw-r--r--examples/my_commit_rules.py35
-rw-r--r--examples/my_configuration_rules.py69
-rw-r--r--examples/my_line_rules.py22
-rw-r--r--gitlint-core/LICENSE22
-rw-r--r--gitlint-core/README.md26
-rw-r--r--gitlint-core/gitlint/__init__.py8
-rw-r--r--gitlint-core/gitlint/cache.py54
-rw-r--r--gitlint-core/gitlint/cli.py499
-rw-r--r--gitlint-core/gitlint/config.py561
-rw-r--r--gitlint-core/gitlint/contrib/__init__.py (renamed from gitlint/contrib/__init__.py)0
-rw-r--r--gitlint-core/gitlint/contrib/rules/__init__.py (renamed from gitlint/contrib/rules/__init__.py)0
-rw-r--r--gitlint-core/gitlint/contrib/rules/authors_commit.py45
-rw-r--r--gitlint-core/gitlint/contrib/rules/conventional_commit.py37
-rw-r--r--gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py22
-rw-r--r--gitlint-core/gitlint/contrib/rules/signedoff_by.py (renamed from gitlint/contrib/rules/signedoff_by.py)9
-rw-r--r--gitlint-core/gitlint/deprecation.py39
-rw-r--r--gitlint-core/gitlint/display.py36
-rw-r--r--gitlint-core/gitlint/exception.py2
-rw-r--r--gitlint-core/gitlint/files/commit-msg35
-rw-r--r--gitlint-core/gitlint/files/gitlint (renamed from gitlint/files/gitlint)54
-rw-r--r--gitlint-core/gitlint/git.py510
-rw-r--r--gitlint-core/gitlint/hooks.py (renamed from gitlint/hooks.py)35
-rw-r--r--gitlint-core/gitlint/lint.py (renamed from gitlint/lint.py)73
-rw-r--r--gitlint-core/gitlint/options.py146
-rw-r--r--gitlint-core/gitlint/rule_finder.py155
-rw-r--r--gitlint-core/gitlint/rules.py485
-rw-r--r--gitlint-core/gitlint/shell.py78
-rw-r--r--gitlint-core/gitlint/tests/__init__.py (renamed from gitlint/tests/__init__.py)0
-rw-r--r--gitlint-core/gitlint/tests/base.py227
-rw-r--r--gitlint-core/gitlint/tests/cli/test_cli.py736
-rw-r--r--gitlint-core/gitlint/tests/cli/test_cli_hooks.py277
-rw-r--r--gitlint-core/gitlint/tests/config/test_config.py (renamed from gitlint/tests/config/test_config.py)205
-rw-r--r--gitlint-core/gitlint/tests/config/test_config_builder.py275
-rw-r--r--gitlint-core/gitlint/tests/config/test_config_precedence.py (renamed from gitlint/tests/config/test_config_precedence.py)84
-rw-r--r--gitlint-core/gitlint/tests/config/test_rule_collection.py (renamed from gitlint/tests/config/test_rule_collection.py)34
-rw-r--r--gitlint-core/gitlint/tests/contrib/__init__.py (renamed from gitlint/tests/contrib/__init__.py)0
-rw-r--r--gitlint-core/gitlint/tests/contrib/rules/__init__.py (renamed from gitlint/tests/rules/__init__.py)0
-rw-r--r--gitlint-core/gitlint/tests/contrib/rules/test_authors_commit.py105
-rw-r--r--gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py82
-rw-r--r--gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py34
-rw-r--r--gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py (renamed from gitlint/tests/contrib/test_signedoff_by.py)26
-rw-r--r--gitlint-core/gitlint/tests/contrib/test_contrib_rules.py (renamed from gitlint/tests/contrib/test_contrib_rules.py)43
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_contrib_12
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1 (renamed from gitlint/tests/expected/test_cli/test_debug_1)43
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_1 (renamed from gitlint/tests/expected/test_cli/test_input_stream_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_1 (renamed from gitlint/tests/expected/test_cli/test_input_stream_debug_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 (renamed from gitlint/tests/expected/test_cli/test_input_stream_debug_2)22
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_commit_12
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_1 (renamed from gitlint/tests/expected/test_cli/test_lint_multiple_commits_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_config_1 (renamed from gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_csv_18
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_1 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2)27
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_1 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_stdin_1)0
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_stdin_2)27
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_14
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_292
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout5
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr6
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout14
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout4
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout8
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout5
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout5
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr2
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout4
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_12
-rw-r--r--gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_22
-rw-r--r--gitlint-core/gitlint/tests/git/test_git.py121
-rw-r--r--gitlint-core/gitlint/tests/git/test_git_commit.py825
-rw-r--r--gitlint-core/gitlint/tests/git/test_git_context.py73
-rw-r--r--gitlint-core/gitlint/tests/rules/__init__.py (renamed from qa/samples/config/contrib-enabled)0
-rw-r--r--gitlint-core/gitlint/tests/rules/test_body_rules.py235
-rw-r--r--gitlint-core/gitlint/tests/rules/test_configuration_rules.py178
-rw-r--r--gitlint-core/gitlint/tests/rules/test_meta_rules.py80
-rw-r--r--gitlint-core/gitlint/tests/rules/test_rules.py32
-rw-r--r--gitlint-core/gitlint/tests/rules/test_title_rules.py200
-rw-r--r--gitlint-core/gitlint/tests/rules/test_user_rules.py266
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/fixup (renamed from gitlint/tests/samples/commit_message/fixup)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/fixup_amend1
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/merge (renamed from gitlint/tests/samples/commit_message/merge)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/no-violations6
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/revert (renamed from gitlint/tests/samples/commit_message/revert)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/sample1 (renamed from gitlint/tests/samples/commit_message/sample1)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/sample2 (renamed from gitlint/tests/samples/commit_message/sample2)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/sample3 (renamed from gitlint/tests/samples/commit_message/sample3)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/sample4 (renamed from gitlint/tests/samples/commit_message/sample4)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/sample5 (renamed from gitlint/tests/samples/commit_message/sample5)0
-rw-r--r--gitlint-core/gitlint/tests/samples/commit_message/squash (renamed from gitlint/tests/samples/commit_message/squash)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/AUTHORS2
-rw-r--r--gitlint-core/gitlint/tests/samples/config/gitlintconfig (renamed from gitlint/tests/samples/config/gitlintconfig)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/invalid-option-value (renamed from gitlint/tests/samples/config/invalid-option-value)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/named-rules8
-rw-r--r--gitlint-core/gitlint/tests/samples/config/no-sections (renamed from gitlint/tests/samples/config/no-sections)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/nonexisting-general-option (renamed from gitlint/tests/samples/config/nonexisting-general-option)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/nonexisting-option (renamed from gitlint/tests/samples/config/nonexisting-option)0
-rw-r--r--gitlint-core/gitlint/tests/samples/config/nonexisting-rule (renamed from gitlint/tests/samples/config/nonexisting-rule)0
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/bogus-file.txt (renamed from gitlint/tests/samples/user_rules/bogus-file.txt)0
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py (renamed from gitlint/tests/samples/user_rules/import_exception/invalid_python.py)1
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py (renamed from gitlint/tests/samples/user_rules/incorrect_linerule/my_line_rule.py)2
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.foo (renamed from gitlint/tests/samples/user_rules/my_commit_rules.foo)0
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py (renamed from gitlint/tests/samples/user_rules/my_commit_rules.py)15
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py (renamed from gitlint/tests/samples/user_rules/parent_package/__init__.py)5
-rw-r--r--gitlint-core/gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py (renamed from gitlint/tests/samples/user_rules/parent_package/my_commit_rules.py)4
-rw-r--r--gitlint-core/gitlint/tests/test_cache.py (renamed from gitlint/tests/test_cache.py)28
-rw-r--r--gitlint-core/gitlint/tests/test_deprecation.py26
-rw-r--r--gitlint-core/gitlint/tests/test_display.py60
-rw-r--r--gitlint-core/gitlint/tests/test_hooks.py139
-rw-r--r--gitlint-core/gitlint/tests/test_lint.py296
-rw-r--r--gitlint-core/gitlint/tests/test_options.py240
-rw-r--r--gitlint-core/gitlint/tests/test_utils.py (renamed from gitlint/tests/test_utils.py)56
-rw-r--r--gitlint-core/gitlint/utils.py (renamed from gitlint/utils.py)84
-rw-r--r--gitlint-core/pyproject.toml71
-rw-r--r--gitlint/__init__.py1
-rw-r--r--gitlint/cache.py57
-rw-r--r--gitlint/cli.py338
-rw-r--r--gitlint/config.py482
-rw-r--r--gitlint/contrib/rules/conventional_commit.py39
-rw-r--r--gitlint/display.py46
-rw-r--r--gitlint/files/commit-msg81
-rw-r--r--gitlint/git.py395
-rw-r--r--gitlint/options.py122
-rw-r--r--gitlint/rule_finder.py137
-rw-r--r--gitlint/rules.py363
-rw-r--r--gitlint/shell.py76
-rw-r--r--gitlint/tests/base.py169
-rw-r--r--gitlint/tests/cli/test_cli.py541
-rw-r--r--gitlint/tests/cli/test_cli_hooks.py96
-rw-r--r--gitlint/tests/config/test_config_builder.py203
-rw-r--r--gitlint/tests/contrib/test_conventional_commit.py47
-rw-r--r--gitlint/tests/expected/test_cli/test_contrib_13
-rw-r--r--gitlint/tests/git/test_git.py115
-rw-r--r--gitlint/tests/git/test_git_commit.py535
-rw-r--r--gitlint/tests/git/test_git_context.py89
-rw-r--r--gitlint/tests/rules/test_body_rules.py180
-rw-r--r--gitlint/tests/rules/test_configuration_rules.py71
-rw-r--r--gitlint/tests/rules/test_meta_rules.py50
-rw-r--r--gitlint/tests/rules/test_rules.py18
-rw-r--r--gitlint/tests/rules/test_title_rules.py154
-rw-r--r--gitlint/tests/rules/test_user_rules.py223
-rw-r--r--gitlint/tests/test_display.py74
-rw-r--r--gitlint/tests/test_hooks.py136
-rw-r--r--gitlint/tests/test_lint.py197
-rw-r--r--gitlint/tests/test_options.py179
-rw-r--r--hatch_build.py13
-rw-r--r--mkdocs.yml12
-rw-r--r--pyproject.toml203
-rw-r--r--qa/base.py179
-rw-r--r--qa/expected/test_commits/test_csv_hash_list_111
-rw-r--r--qa/expected/test_commits/test_ignore_commits_12
-rw-r--r--qa/expected/test_commits/test_lint_staged_msg_filename_130
-rw-r--r--qa/expected/test_commits/test_lint_staged_stdin_130
-rw-r--r--qa/expected/test_config/test_config_from_env_1104
-rw-r--r--qa/expected/test_config/test_config_from_env_293
-rw-r--r--qa/expected/test_config/test_config_from_file_debug_130
-rw-r--r--qa/expected/test_contrib/test_contrib_rules_13
-rw-r--r--qa/expected/test_contrib/test_contrib_rules_with_config_13
-rw-r--r--qa/expected/test_gitlint/test_commit_binary_file_195
-rw-r--r--qa/expected/test_named_rules/test_named_rule_15
-rw-r--r--qa/expected/test_named_rules/test_named_user_rule_19
-rw-r--r--qa/expected/test_rules/test_ignore_rules_13
-rw-r--r--qa/expected/test_rules/test_ignore_rules_22
-rw-r--r--qa/expected/test_rules/test_match_regex_rules_12
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_examples_14
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_examples_25
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_examples_with_config_14
-rw-r--r--qa/expected/test_user_defined/test_user_defined_rules_extra_110
-rw-r--r--qa/requirements.txt4
-rw-r--r--qa/samples/config/named-rules8
-rw-r--r--qa/samples/config/named-user-rules15
-rw-r--r--qa/samples/user_rules/extra/extra_rules.py61
-rw-r--r--qa/shell.py110
-rw-r--r--qa/test_commits.py237
-rw-r--r--qa/test_config.py112
-rw-r--r--qa/test_contrib.py31
-rw-r--r--qa/test_gitlint.py204
-rw-r--r--qa/test_hooks.py149
-rw-r--r--qa/test_named_rules.py23
-rw-r--r--qa/test_rules.py61
-rw-r--r--qa/test_stdin.py51
-rw-r--r--qa/test_user_defined.py47
-rw-r--r--qa/utils.py79
-rw-r--r--requirements.txt5
-rwxr-xr-xrun_tests.sh539
-rw-r--r--setup.cfg2
-rw-r--r--setup.py105
-rw-r--r--test-requirements.txt10
-rwxr-xr-xtools/changelog.py51
-rwxr-xr-xtools/create-test-repo.sh2
-rwxr-xr-xtools/stats.sh45
-rw-r--r--tools/windows/create-test-repo.bat22
242 files changed, 12790 insertions, 7717 deletions
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index a2e4c8f..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,2 +0,0 @@
-[run]
-omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv* \ No newline at end of file
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..65fcc1c
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,61 @@
+// 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.formatting.provider": "black",
+ "python.formatting.blackArgs": [
+ "--config",
+ "./pyproject.toml"
+ ],
+ "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",
+ },
+ // Add the IDs of extensions you want installed when the container is created.
+ "extensions": [
+ "ms-python.python",
+ "ms-python.vscode-pylance",
+ "charliermarsh.ruff",
+ "tamasfe.even-better-toml"
+ ]
+ }
+ },
+ // 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..e2f0f76
--- /dev/null
+++ b/.devcontainer/postCreateCommand.sh
@@ -0,0 +1,24 @@
+#!/bin/sh -x
+
+brew install asdf
+brew install hatch
+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/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..db7f144
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,13 @@
+<!--- THIS IS A COMMENT BLOCK, REMOVE IT BEFORE SUBMITTING YOUR PR
+
+Thank you for your interest in gitlint and putting in the effort to create a PR!
+
+A few quick notes:
+
+- It's really just me (https://github.com/jorisroovers) maintaining gitlint, and I do so in a hobby capacity. More recently it has become harder for me to find time to maintain gitlint on a regular basis, which in practice means that it might take me a while (sometimes months) to get back to you. Rest assured though, I absolutely look at all PRs as soon as they come in - I just tend to only "work" on gitlint a few times a year.
+- Similarly, after your code is merged, it typically still takes months before I do another release that contains your code. Please don't let that deter you from contributing - I appreciate your patience and understanding!
+- Please review: http://jorisroovers.github.io/gitlint/contributing/
+
+-->
+
+Enter your PR details here
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..a781b54
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,18 @@
+version: 2
+updates:
+ - package-ecosystem: docker
+ directory: /
+ schedule:
+ interval: daily
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: daily
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: daily
+ - package-ecosystem: pip
+ directory: /gitlint-core
+ schedule:
+ interval: daily
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
deleted file mode 100644
index 348fb47..0000000
--- a/.github/workflows/checks.yml
+++ /dev/null
@@ -1,113 +0,0 @@
-name: Tests and Checks
-
-on: [push]
-
-jobs:
- checks:
- runs-on: "ubuntu-latest"
- strategy:
- matrix:
- python-version: [2.7, 3.5, 3.6, 3.7, 3.8, pypy2, pypy3]
- os: ["macos-latest", "ubuntu-latest"]
- steps:
- - uses: actions/checkout@v2
-
- - name: Setup python
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install requirements
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
- pip install -r test-requirements.txt
-
- - name: Unit Tests
- run: ./run_tests.sh
-
- # Coveralls integration doesn't properly work at this point, also see below
- # - name: Coveralls
- # env:
- # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
- # run: coveralls
-
- - name: Integration Tests
- run: ./run_tests.sh -i
-
- - name: Integration Tests (GITLINT_USE_SH_LIB=0)
- env:
- GITLINT_USE_SH_LIB: 0
- run: ./run_tests.sh -i
-
- - name: PEP8
- run: ./run_tests.sh -p
-
- - name: PyLint
- run: ./run_tests.sh -l
-
- - name: Build tests
- run: ./run_tests.sh --build
-
- # Coveralls GH Action currently doesn't support current non-LCOV reporting format
- # For now, still using Travis for unit test coverage reporting
- # https://github.com/coverallsapp/github-action/issues/30
- # - name: Coveralls
- # uses: coverallsapp/github-action@master
- # with:
- # github-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Gitlint check
- run: ./run_tests.sh -g
-
- windows-checks:
- runs-on: windows-latest
- strategy:
- matrix:
- python-version: [2.7, 3.5]
- steps:
- - uses: actions/checkout@v2
-
- - name: Setup python
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: "Upgrade pip on Python 3"
- if: matrix.python-version == '3.5'
- run: python -m pip install --upgrade pip
-
- - name: Install requirements
- run: |
- pip install -r requirements.txt
- pip install -r test-requirements.txt
-
- - name: gitlint --version
- run: gitlint --version
-
- - name: Tests (sanity)
- run: tools\windows\run_tests.bat "gitlint\tests\cli\test_cli.py::CLITests::test_lint"
-
- - name: Tests (ignore test_cli.py)
- run: pytest --ignore gitlint\tests\cli\test_cli.py -rw -s gitlint
-
- - name: Tests (test_cli.py only - continue-on-error:true)
- run: tools\windows\run_tests.bat "gitlint\tests\cli\test_cli.py"
- continue-on-error: true # Known to fail at this point
-
- - name: Tests (all - continue-on-error:true)
- run: tools\windows\run_tests.bat
- continue-on-error: true # Known to fail at this point
-
- - name: Integration tests (continue-on-error:true)
- run: pytest -rw -s qa
- continue-on-error: true # Known to fail at this point
-
- - name: PEP8
- run: flake8 gitlint qa examples
-
- - name: PyLint
- run: pylint gitlint qa --rcfile=".pylintrc" -r n
-
- - name: Gitlint check
- run: gitlint --debug
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..403dcc4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,142 @@
+name: Tests and Checks
+
+# Only run CI on pushes to main and pull requests
+# We don't run CI on other branches, but those should be merged into main via a PR anyways which will trigger CI before the merge.
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ci-${{ github.ref }}-1
+ cancel-in-progress: true
+
+jobs:
+ checks:
+ runs-on: "ubuntu-latest"
+ strategy:
+ matrix:
+ python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", pypy-3.9]
+ os: ["macos-latest", "ubuntu-latest", "windows-latest"]
+ steps:
+ - uses: actions/checkout@v3.3.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha }} # Checkout pull request HEAD commit instead of merge commit
+ fetch-depth: 0 # checkout all history, needed for hatch versioning
+
+ - name: Setup python
+ uses: actions/setup-python@v4.5.0
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install pypa/build
+ run: python -m pip install build==0.10.0
+
+ - name: Install Hatch
+ run: python -m pip install hatch==1.6.3
+
+ - name: Unit Tests
+ run: hatch run test:unit-tests
+
+ - name: Code formatting (black)
+ run: hatch run test:format
+
+ - name: Code linting (ruff)
+ run: hatch run test:lint
+
+ - name: Install local gitlint for integration tests
+ run: |
+ hatch run qa:install-local
+
+ - name: Integration tests (default -> GITLINT_USE_SH_LIB=1)
+ run: |
+ hatch run qa:integration-tests
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (GITLINT_USE_SH_LIB=1)
+ run: |
+ hatch run qa:integration-tests
+ env:
+ GITLINT_USE_SH_LIB: 1
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (GITLINT_QA_USE_SH_LIB=0)
+ run: |
+ hatch run qa:integration-tests -k "not(test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit)" qa
+ env:
+ GITLINT_QA_USE_SH_LIB: 0
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (Windows)
+ run: |
+ hatch run qa:integration-tests -k "not (test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit or test_lint_staged_stdin or test_stdin_file or test_stdin_pipe_empty)" qa
+ if: matrix.os == 'windows-latest'
+
+ - name: Build test (gitlint)
+ run: |
+ python -m build
+ hatch clean
+
+ - name: Build test (gitlint-core)
+ run: |
+ python -m build
+ hatch clean
+ working-directory: ./gitlint-core
+
+ - name: Docs build (mkdocs)
+ run: hatch run docs:build
+
+ # Run gitlint. Skip during PR runs, since PR commit messages are transient and usually full of gitlint violations.
+ # PRs get squashed and get a proper commit message during merge.
+ - name: gitlint --debug
+ run: hatch run dev:gitlint --debug
+ continue-on-error: ${{ github.event_name == 'pull_request' }} # Don't enforce gitlint in PRs
+
+ - name: Code Coverage (coveralls)
+ uses: coverallsapp/github-action@master
+ with:
+ path-to-lcov: ".coverage.lcov"
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ git-commit: ${{ github.event.pull_request.head.sha }}
+ flag-name: gitlint-${{ matrix.os }}-${{ matrix.python-version }}
+ parallel: true
+
+ upload_coveralls:
+ needs: checks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Upload coverage to coveralls
+ uses: coverallsapp/github-action@master
+ with:
+ path-to-lcov: ".coverage.lcov"
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ parallel-finished: true
+
+ check: # This job does nothing and is only used for the branch protection
+ if: always() # Ref: https://github.com/marketplace/actions/alls-green#why
+
+ needs:
+ - upload_coveralls
+ - checks
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Decide whether the needed jobs succeeded or failed
+ uses: re-actors/alls-green@release/v1
+ with:
+ jobs: ${{ toJSON(needs) }}
+
+ # When on main, auto publish dev build
+ auto-publish-dev:
+ needs:
+ - check
+ if: github.ref == 'refs/heads/main'
+ uses: ./.github/workflows/publish-release.yml
+ secrets: inherit # pass all secrets (required to access secrets in a called workflow)
+ with:
+ pypi_target: "pypi.org"
+ repo_release_ref: "main"
diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml
new file mode 100644
index 0000000..e5e40c9
--- /dev/null
+++ b/.github/workflows/github-release.yml
@@ -0,0 +1,14 @@
+name: Github Release Publish
+run-name: "Github Release Publish (tag=${{github.ref_name}})"
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ publish-release:
+ uses: ./.github/workflows/publish-release.yml
+ secrets: inherit # pass all secrets (required to access secrets in a called workflow)
+ with:
+ pypi_target: "pypi.org"
+ repo_release_ref: ${{ github.ref_name }}
diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml
new file mode 100644
index 0000000..092b6b3
--- /dev/null
+++ b/.github/workflows/publish-docker.yml
@@ -0,0 +1,79 @@
+name: Publish Docker
+run-name: "Publish Docker (gitlint_version=${{ inputs.gitlint_version }})"
+
+on:
+ workflow_call:
+ inputs:
+ gitlint_version:
+ description: "Gitlint version to build docker image for"
+ required: true
+ type: string
+ docker_image_tag:
+ description: "Docker image tag"
+ required: true
+ type: string
+ push_to_dockerhub:
+ description: "Push to dockerhub.com"
+ required: false
+ type: boolean
+ default: false
+ workflow_dispatch:
+ inputs:
+ gitlint_version:
+ description: "Gitlint version to build docker image for"
+ type: string
+ docker_image_tag:
+ description: "Docker image tag"
+ required: true
+ type: choice
+ options:
+ - "latest_dev"
+ - "latest"
+ - "Use $gitlint_version"
+ default: "Use $gitlint_version"
+ push_to_dockerhub:
+ description: "Push to dockerhub.com"
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+ publish_docker:
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: Determine docker tag
+ id: set_tag
+ run: |
+ if [[ "${{ inputs.docker_image_tag }}" == "Use $gitlint_version" ]]; then
+ echo "docker_image_tag=${{ inputs.gitlint_version }}" >> $GITHUB_OUTPUT
+ else
+ echo "docker_image_tag=${{ inputs.docker_image_tag }}" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ username: jorisroovers
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build docker image
+ uses: docker/build-push-action@v4
+ with:
+ build-args: GITLINT_VERSION=${{ inputs.gitlint_version }}
+ tags: jorisroovers/gitlint:${{ steps.set_tag.outputs.docker_image_tag }}
+
+ - name: Test docker image
+ run: |
+ gitlint_version=$(docker run --ulimit nofile=1024 -v $(pwd):/repo jorisroovers/gitlint:${{ steps.set_tag.outputs.docker_image_tag }} --version)
+ [ "$gitlint_version" == "gitlint, version ${{ inputs.gitlint_version }}" ]
+
+
+ # This won't actually rebuild the docker image, but just push the previously built and cached image
+ - name: Push docker image
+ uses: docker/build-push-action@v4
+ with:
+ push: ${{ inputs.push_to_dockerhub }}
+ build-args: GITLINT_VERSION=${{ inputs.gitlint_version }}
+ tags: jorisroovers/gitlint:${{ steps.set_tag.outputs.docker_image_tag }}
+ if: inputs.push_to_dockerhub
+ \ No newline at end of file
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
new file mode 100644
index 0000000..22ac4be
--- /dev/null
+++ b/.github/workflows/publish-release.yml
@@ -0,0 +1,176 @@
+name: Publish Release
+run-name: "Publish Release (pypi_target=${{ inputs.pypi_target }}, repo_release_ref=${{ inputs.repo_release_ref }})"
+
+on:
+ # Trigger release workflow from other workflows (e.g. release dev build as part of CI)
+ workflow_call:
+ inputs:
+ pypi_target:
+ description: "PyPI repository to publish to"
+ required: true
+ type: string
+ default: "test.pypi.org"
+ repo_release_ref:
+ description: "Gitlint git reference to publish release for"
+ type: string
+ default: "main"
+
+ # Manually trigger a release
+ workflow_dispatch:
+ inputs:
+ pypi_target:
+ description: "PyPI repository to publish to"
+ required: true
+ type: choice
+ options:
+ - "pypi.org"
+ - "test.pypi.org"
+ default: "test.pypi.org"
+ repo_release_ref:
+ description: "Gitlint git reference to publish release for"
+ type: string
+ default: "main"
+
+jobs:
+ publish:
+ timeout-minutes: 15
+ runs-on: "ubuntu-latest"
+ outputs:
+ gitlint_version: ${{ steps.set_version.outputs.gitlint_version }}
+ steps:
+ - name: Setup python
+ uses: actions/setup-python@v4.5.0
+ with:
+ python-version: "3.11"
+
+ - name: Install pypa/build
+ run: python -m pip install build==0.10.0
+
+ - name: Install Hatch
+ run: python -m pip install hatch==1.6.3
+
+ - uses: actions/checkout@v3.3.0
+ with:
+ ref: ${{ inputs.repo_release_ref }}
+ fetch-depth: 0 # checkout all history, needed for hatch versioning
+
+ # Run hatch version once to avoid additional output ("Setting up build environment for missing dependencies")
+ # during the next step
+ - name: Hatch version
+ run: hatch version
+
+ # Hatch versioning is based on git (using hatch-vcs). If there is no explicit tag for the commit we're trying to
+ # publish, hatch versioning strings will have this format: 0.19.0.dev52+g9f7dc7d
+ # With the string after '+' being the 'g<short-sha>' of the commit.
+ #
+ # However, PyPI doesn't allow '+' in version numbers (no PEP440 local versions allowed on PyPI).
+ # To work around this, we override the version string by setting the SETUPTOOLS_SCM_PRETEND_VERSION env var
+ # to the version string without the '+' and everything after it.
+ # We do this by setting the `gitlint_version` step output here and re-using it later to
+ # set SETUPTOOLS_SCM_PRETEND_VERSION.
+ #
+ # We only actually publish such releases on the main branch to guarantee the dev numbering scheme remains
+ # unique.
+ # Note that when a tag *is* present (i.e. v0.19.0), hatch versioning will return the tag name (i.e. 0.19.0)
+ # and this step has no effect, ie. SETUPTOOLS_SCM_PRETEND_VERSION will be the same as `hatch version`.
+ - name: Set SETUPTOOLS_SCM_PRETEND_VERSION
+ id: set_version
+ run: |
+ echo "gitlint_version=$(hatch version | cut -d+ -f1)" >> $GITHUB_OUTPUT
+
+ - name: Build (gitlint-core)
+ run: python -m build
+ working-directory: ./gitlint-core
+ env:
+ SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.set_version.outputs.gitlint_version }}
+
+ - name: Build (gitlint)
+ run: python -m build
+ env:
+ SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.set_version.outputs.gitlint_version }}
+
+ - name: Publish gitlint-core (pypi.org)
+ run: hatch publish
+ working-directory: ./gitlint-core
+ env:
+ HATCH_INDEX_USER: ${{ secrets.PYPI_GITLINT_CORE_USERNAME }}
+ HATCH_INDEX_AUTH: ${{ secrets.PYPI_GITLINT_CORE_PASSWORD }}
+ if: inputs.pypi_target == 'pypi.org'
+
+ - name: Publish gitlint (pypi.org)
+ run: hatch publish
+ env:
+ HATCH_INDEX_USER: ${{ secrets.PYPI_GITLINT_USERNAME }}
+ HATCH_INDEX_AUTH: ${{ secrets.PYPI_GITLINT_PASSWORD }}
+ if: inputs.pypi_target == 'pypi.org'
+
+ - name: Publish gitlint-core (test.pypi.org)
+ run: hatch publish -r test
+ working-directory: ./gitlint-core
+ env:
+ HATCH_INDEX_USER: ${{ secrets.TEST_PYPI_GITLINT_CORE_USERNAME }}
+ HATCH_INDEX_AUTH: ${{ secrets.TEST_PYPI_GITLINT_CORE_PASSWORD }}
+ if: inputs.pypi_target == 'test.pypi.org'
+
+ - name: Publish gitlint (test.pypi.org)
+ run: hatch publish -r test
+ env:
+ HATCH_INDEX_USER: ${{ secrets.TEST_PYPI_GITLINT_USERNAME }}
+ HATCH_INDEX_AUTH: ${{ secrets.TEST_PYPI_GITLINT_PASSWORD }}
+ if: inputs.pypi_target == 'test.pypi.org'
+
+ # Wait for gitlint package to be available in PyPI for installation
+ wait-for-package:
+ needs:
+ - publish
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: Install gitlint
+ uses: nick-fields/retry@v2.8.3
+ with:
+ timeout_minutes: 1
+ max_attempts: 10
+ command: |
+ python -m pip install gitlint==${{ needs.publish.outputs.gitlint_version }}
+ if: inputs.pypi_target == 'pypi.org'
+
+ - name: Install gitlint (test.pypi.org)
+ uses: nick-fields/retry@v2.8.3
+ with:
+ timeout_minutes: 1
+ max_attempts: 10
+ command: |
+ pip install --no-cache-dir -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple gitlint==${{ needs.publish.outputs.gitlint_version }}
+ if: inputs.pypi_target == 'test.pypi.org'
+
+ - name: gitlint --version
+ run: |
+ gitlint --version
+ [ "$(gitlint --version)" == "gitlint, version ${{ needs.publish.outputs.gitlint_version }}" ]
+
+ # Unfortunately, it's not because the newly published package installation worked once that replication
+ # has finished amongst all PyPI servers (subsequent installations might still fail). We sleep for 10 min here
+ # to increase the odds that replication has finished.
+ - name: Sleep
+ run: sleep 600
+
+ test-release:
+ needs:
+ - publish
+ - wait-for-package
+ uses: ./.github/workflows/test-release.yml
+ with:
+ gitlint_version: ${{ needs.publish.outputs.gitlint_version }}
+ pypi_source: ${{ inputs.pypi_target }}
+ repo_test_ref: ${{ inputs.repo_release_ref }}
+
+ publish-docker:
+ needs:
+ - publish
+ - test-release
+ uses: ./.github/workflows/publish-docker.yml
+ secrets: inherit # pass all secrets (required to access secrets in a called workflow)
+ with:
+ gitlint_version: ${{ needs.publish.outputs.gitlint_version }}
+ docker_image_tag: "latest_dev"
+ push_to_dockerhub: true
diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml
new file mode 100644
index 0000000..caf00dd
--- /dev/null
+++ b/.github/workflows/test-release.yml
@@ -0,0 +1,95 @@
+name: Test Release
+run-name: "Test Release (${{ inputs.gitlint_version }}, pypi_source=${{ inputs.pypi_source }}, repo_test_ref=${{ inputs.repo_test_ref }})"
+on:
+ workflow_call:
+ inputs:
+ gitlint_version:
+ description: "Gitlint version to test"
+ required: true
+ default: "0.18.0"
+ type: string
+ pypi_source:
+ description: "PyPI repository to use"
+ required: true
+ type: string
+ repo_test_ref:
+ description: "Git reference to checkout for integration tests"
+ default: "main"
+ type: string
+ workflow_dispatch:
+ inputs:
+ gitlint_version:
+ description: "Gitlint version to test"
+ required: true
+ default: "0.18.0"
+ pypi_source:
+ description: "PyPI repository to use"
+ required: true
+ type: choice
+ options:
+ - "pypi.org"
+ - "test.pypi.org"
+ default: "pypi.org"
+ repo_test_ref:
+ description: "Git reference to checkout for integration tests"
+ default: "main"
+
+jobs:
+ test-release:
+ timeout-minutes: 10
+ runs-on: "ubuntu-latest"
+ strategy:
+ matrix:
+ python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", pypy-3.9]
+ os: ["macos-latest", "ubuntu-latest", "windows-latest"]
+ steps:
+ - name: Setup python
+ uses: actions/setup-python@v4.5.0
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Hatch
+ run: python -m pip install hatch==1.6.3
+
+ - name: Install gitlint
+ run: |
+ python -m pip install gitlint==${{ inputs.gitlint_version }}
+ if: inputs.pypi_source == 'pypi.org'
+
+ - name: Install gitlint (test.pypi.org)
+ run: |
+ pip install --no-cache-dir -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple gitlint==${{ inputs.gitlint_version }}
+ if: inputs.pypi_source == 'test.pypi.org'
+
+ - name: gitlint --version
+ run: |
+ gitlint --version
+ [ "$(gitlint --version)" == "gitlint, version ${{ inputs.gitlint_version }}" ]
+
+ - uses: actions/checkout@v3.3.0
+ with:
+ ref: ${{ inputs.repo_test_ref }}
+
+ - name: Integration tests (default -> GITLINT_USE_SH_LIB=1)
+ run: |
+ hatch run qa:integration-tests
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (GITLINT_USE_SH_LIB=1)
+ run: |
+ hatch run qa:integration-tests
+ env:
+ GITLINT_USE_SH_LIB: 1
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (GITLINT_QA_USE_SH_LIB=0)
+ run: |
+ hatch run qa:integration-tests -k "not(test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit)" qa
+ env:
+ GITLINT_QA_USE_SH_LIB: 0
+ if: matrix.os != 'windows-latest'
+
+ - name: Integration tests (Windows)
+ run: |
+ hatch run qa:integration-tests -k "not (test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit or test_lint_staged_stdin or test_stdin_file or test_stdin_pipe_empty)" qa
+ if: matrix.os == 'windows-latest'
diff --git a/.gitignore b/.gitignore
index c350158..e81c924 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,6 +63,8 @@ virtualenv
# Vagrant
.vagrant
-
# mkdocs
site/
+
+# pyenv
+.python-version \ No newline at end of file
diff --git a/.gitlint b/.gitlint
new file mode 100644
index 0000000..011b0a3
--- /dev/null
+++ b/.gitlint
@@ -0,0 +1,9 @@
+[general]
+# See https://jorisroovers.com/gitlint/configuration/#regex-style-search
+regex-style-search=True
+
+# Dependabot tends to generate lines that exceed the default 80 char limit.
+[ignore-by-author-name]
+regex=dependabot
+ignore=all
+
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 5b3d51a..24fd745 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -1,5 +1,16 @@
-- id: gitlint
- name: gitlint
- language: python
- entry: gitlint --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/.pylintrc b/.pylintrc
deleted file mode 100644
index dc54455..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,48 +0,0 @@
-# The format of this file isn't really documented; just use --generate-rcfile
-[MASTER]
-
-[Messages Control]
-# C0111: Don't require docstrings on every method
-# W0511: TODOs in code comments are fine.
-# W0142: *args and **kwargs are fine.
-# W0223: abstract methods don't need to be overwritten (i.e. when overwriting a Django REST serializer)
-# W0622: Redefining id is fine.
-# R0901: Too many ancestors (i.e. when subclassing test classes)
-# R0801: Similar lines in files
-# I0011: Informational: locally disabled pylint
-# I0013: Informational: Ignoring entire file
-disable=bad-option-value,C0111,W0511,W0142,W0622,W0223,W0212,R0901,R0801,I0011,I0013,anomalous-backslash-in-string,useless-object-inheritance,unnecessary-pass
-
-[Format]
-max-line-length=120
-
-[Basic]
-# Variable names can be 1 to 31 characters long, with lowercase and underscores
-variable-rgx=[a-z_][a-z0-9_]{0,30}$
-
-# Argument names can be 2 to 31 characters long, with lowercase and underscores
-argument-rgx=[a-z_][a-z0-9_]{1,30}$
-
-# Method names should be at least 3 characters long
-# and be lower-cased with underscores
-method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|tearDown)$
-
-# Allow 'id' as variable name everywhere
-good-names=id,c,_
-
-bad-names=__author__
-
-# Ignore all variables that start with an underscore (e.g. unused _request variable in a view)
-dummy-variables-rgx=_
-
-[Design]
-max-public-methods=100
-min-public-methods=0
-# Maximum number of attributes of a class
-max-attributes=15
-max-args=10
-max-locals=20
-
-[Typecheck]
-# Allow the use of the Django 'objects' members
-generated-members=sh.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a3991d..f26f670 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,100 +1,310 @@
-# Changelog #
+# Changelog
-## v0.13.1 (2020-02-26)
+This file documents notable changes introduced in gitlint releases.
+# v0.20.0 (Unreleased)
+
+# v0.19.1 (2023-03-10)
+
+## Development
+- Fix issue that prevented homebrew packages from being built ([#460](https://github.com/jorisroovers/gitlint/issues/460))
+- Switch to using pypa/build in CI ([#463](https://github.com/jorisroovers/gitlint/issues/463)) - thanks @webknjaz
+
+
+# v0.19.0 (2023-03-07)
+
+This release was primarily focussed on modernizing gitlint's build and test tooling (details: [#378](https://github.com/jorisroovers/gitlint/issues/378)).
+
+## General
+ - Python 3.6 no longer supported ([EOL since 2021-12-23](https://endoflife.date/python)) ([#379](https://github.com/jorisroovers/gitlint/issues/379))
+ - This is the last release to support the [sh](https://amoffat.github.io/sh/) library (used under-the-hood to execute git commands) by setting `GITLINT_USE_SH_LIB=1`. This is already disabled by default since v0.18.0.
+
+## Features
+ - Allow for a single commit in the `--commits` cmd-line param ([#412](https://github.com/jorisroovers/gitlint/issues/412)) - thanks [carlescufi](https://github.com/carlescufi)
+ - Gitlint now separates `FILE_ENCODING` (always UTF-8) from `TERMINAL_ENCODING` (terminal dependent), this should improve issues with unicode. Use `gitlint --debug` to inspect these values. ([#424](https://github.com/jorisroovers/gitlint/issues/424))
+
+## Bugfixes
+ - `ignore-by-author-name` crashes without --staged ([#445](https://github.com/jorisroovers/gitlint/issues/445))
+ - Various documentation fixes ([#401](https://github.com/jorisroovers/gitlint/issues/401), [#433](https://github.com/jorisroovers/gitlint/issues/433)) - Thanks [scop](https://github.com/scop)
+
+## Development
+ - Adopted [hatch](https://hatch.pypa.io/latest/) for project management ([#384](https://github.com/jorisroovers/gitlint/issues/384)).
+ This significantly improves the developer workflow, please read the updated [CONTRIBUTING](https://jorisroovers.com/gitlint/contributing/) page.
+ - Adopted [ruff](https://github.com/charliermarsh/ruff) for linting, replacing pylint ([#404](https://github.com/jorisroovers/gitlint/issues/404))
+ - Gitlint now publishes [dev builds on every commit to main](https://jorisroovers.github.io/gitlint/contributing/#dev-builds) ([#429](https://github.com/jorisroovers/gitlint/issues/429))
+ - Gitlint now publishes a [`latest_dev` docker image](https://hub.docker.com/r/jorisroovers/gitlint/tags?name=latest_dev) on every commit to main ([#451](https://github.com/jorisroovers/gitlint/issues/452)) ([#452](https://github.com/jorisroovers/gitlint/issues/451))
+ - Dependencies updated
+ - Many improvements to the [CI/CD worfklows](https://github.com/jorisroovers/gitlint/tree/main/.github/workflows)
+ - Fixed coveralls integration: [coveralls.io/github/jorisroovers/gitlint](https://coveralls.io/github/jorisroovers/gitlint)
+ - Improve unit test coverage ([#453](https://github.com/jorisroovers/gitlint/issues/453))
+ - Integration test fixes on windows ([#392](https://github.com/jorisroovers/gitlint/issues/392), [#397](https://github.com/jorisroovers/gitlint/issues/397))
+ - Devcontainer improvements ([#428](https://github.com/jorisroovers/gitlint/issues/428))
+ - Removal of Dockerfile.dev ([#390](https://github.com/jorisroovers/gitlint/issues/390))
+ - Fix most integration tests on Windows
+ - Fix Windows unit tests ([#383](https://github.com/jorisroovers/gitlint/issues/383))
+ - Introduce a gate/check GHA job ([#375](https://github.com/jorisroovers/gitlint/issues/375)) - Thanks [webknjaz](https://github.com/webknjaz)
+ - Thanks to [sigmavirus24](https://github.com/sigmavirus24) for continued overall help and support
+
+
+# v0.18.0 (2022-11-16)
+Contributors:
+Special thanks to all contributors for this release - details inline!
+
+## General
+- 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))
+
+## Features
+- `--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)
+
+## Development
+ - 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).
+
+## General
+- Gitlint is now split in 2 packages: `gitlint` and `gitlint-core`. This allows users to install gitlint without pinned dependencies (which is the default) ([#162](https://github.com/jorisroovers/gitlint/issues/162))
+- Under-the-hood: dependencies updated
+
+# v0.16.0 (2021-10-08)
+
+Contributors:
+Special thanks to all contributors for this release, in particular [sigmavirus24](https://github.com/sigmavirus24), [l0b0](https://github.com/l0b0) and [rafaelbubach](https://github.com/rafaelbubach).
+
+## General
+- Python 3.10 support
+- 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.
+
+## Features
+- `--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))
+
+## Rules
+- **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
+
+## 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))
+
+## Development
+- Dependencies updated
+- Test and github action improvements
+
+# v0.15.1 (2021-04-16)
+
+Contributors:
+Special thanks to all contributors for this release, in particular [PW999](https://github.com/PW999), [gsemet](https://github.com/gsemet) and [Lorac](https://github.com/Lorac).
+
+## Bugfixes
+ - Git commit message body with only new lines is not longer considered empty by `body-is-missing` ([#176](https://github.com/jorisroovers/gitlint/issues/176))
+ - Added compatibility with `git commit -s` for `contrib-requires-signed-off-by` rule ([#178](https://github.com/jorisroovers/gitlint/pull/178))
+- Minor tweak to gitlint commit-hook output ([#173](https://github.com/jorisroovers/gitlint/pull/173))
+- All dependencies have been upgraded to the latest available versions (`Click==7.1.2`, `arrow==1.0.3`, `sh==1.14.1`).
+- Minor doc fixes
+
+# v0.15.0 (2020-11-27)
+
+Contributors:
+Special thanks to [BrunIF](https://github.com/BrunIF), [lukech](https://github.com/lukech), [Cielquan](https://github.com/Cielquan), [harens](https://github.com/harens) and [sigmavirus24](https://github.com/sigmavirus24).
+
+## General
+
+- **This release drops support for Python 2.7 and Python 3.5 ([both are EOL](https://endoflife.date/python)). Other than a few minor fixes, there are no functional differences from the 0.14.0 release.**
+- **Mac users**: Gitlint can now be installed using both homebrew (upgraded to latest) and macports. Special thanks to [@harens](https://github.com/harens) for maintaining these packages (best-effort).
+
+## Bugfixes
+- Gitlint now properly handles exceptions when using its built-in commit-msg hook ([#166](https://github.com/jorisroovers/gitlint/issues/166)).
+
+## Development
+- All dependencies have been upgraded to the latest available versions (`Click==7.1.2`, `arrow==0.17.0`, `sh==1.14.1`).
+- Much under-the-hood refactoring as a result of dropping Python 2.7
+
+# v0.14.0 (2020-10-24)
+
+Contributors:
+Special thanks to all contributors for this release, in particular [mrshu](https://github.com/mrshu), [glasserc](https://github.com/glasserc), [strk](https://github.com/strk), [chgl](https://github.com/chgl), [melg8](https://github.com/melg8) and [sigmavirus24](https://github.com/sigmavirus24).
+
+## General
+- **IMPORTANT: Gitlint 0.14.x will be the last gitlint release to support Python 2.7 and Python 3.5, as [both are EOL](https://endoflife.date/python) which makes it difficult to keep supporting them.**
+- Python 3.9 support
+- [Named Rules](http://jorisroovers.github.io/gitlint/#named-rules) allow users to have multiple instances of the same rule active at the same time. This is useful when you want to enforce the same rule multiple times but with different options ([#113](https://github.com/jorisroovers/gitlint/issues/113), [#66](https://github.com/jorisroovers/gitlint/issues/66))
+- [User-defined Configuration Rules](http://jorisroovers.github.io/gitlint/user_defined_rules/#configuration-rules) allow users to dynamically change gitlint's configuration and/or the commit *before* any other rules are applied.
+- The `commit-msg` hook has been re-written in Python (it contained a lot of Bash before), fixing a number of platform specific issues. Existing users will need to reinstall their hooks (`gitlint uninstall-hook; gitlint install-hook`) to make use of this.
+- Most general options can now be set through environment variables (e.g. set the `general.ignore` option via `GITLINT_IGNORE=T1,T2`). The list of available environment variables can be found in the [configuration documentation](http://jorisroovers.github.io/gitlint/configuration).
+- Users can now use `self.log.debug("my message")` for debugging purposes in their user-defined rules. Debug messages will show up when running `gitlint --debug`.
+- **Breaking**: User-defined rule id's can no longer start with 'I', as those are reserved for [built-in gitlint ignore rules](http://jorisroovers.github.io/gitlint/rules/#i1-ignore-by-title).
+- New `RegexOption` rule [option type for use in user-defined rules](http://jorisroovers.github.io/gitlint/user_defined_rules/#options). By using the `RegexOption`, regular expressions are pre-validated at gitlint startup and compiled only once which is much more efficient when linting multiple commits.
+
+## Rules
+- **New Rule**: [title-min-length](http://jorisroovers.github.io/gitlint/rules/#t8-title-min-length) enforces a minimum length on titles (default: 5 chars) ([#138](https://github.com/jorisroovers/gitlint/issues/138))
+- **New Rule**: [body-match-regex](http://jorisroovers.github.io/gitlint/rules/#b8-body-match-regex) allows users to enforce that the commit-msg body matches a given regex ([#130](https://github.com/jorisroovers/gitlint/issues/130))
+- **New Rule**: [ignore-body-lines](http://jorisroovers.github.io/gitlint/rules/#i3-ignore-body-lines) allows users to
+[ignore parts of a commit](http://jorisroovers.github.io/gitlint/gitlint/#ignoring-commits) by matching a regex against
+the lines in a commit message body ([#126](https://github.com/jorisroovers/gitlint/issues/126))
+
+## Contrib Rules
+- Added 'ci' and 'build' to conventional commit types ([#135](https://github.com/jorisroovers/gitlint/issues/135))
+
+## Bugfixes
+- Improved UTF-8 fallback on Windows (ongoing - [#96](https://github.com/jorisroovers/gitlint/issues/96))
+- Windows users can now use the 'edit' function of the `commit-msg` hook ([#94](https://github.com/jorisroovers/gitlint/issues/94))
+- Doc update: Users should use `--ulimit nofile=1024` when invoking gitlint using Docker ([#129](https://github.com/jorisroovers/gitlint/issues/129))
+- The `commit-msg` hook was broken in Ubuntu's gitlint package due to a python/python3 mismatch ([#127](https://github.com/jorisroovers/gitlint/issues/127))
+- Better error message when no git username is set ([#149](https://github.com/jorisroovers/gitlint/issues/149))
+- Options can now actually be set to `None` (from code) to make them optional.
+- Ignore rules no longer have `"None"` as default regex, but an empty regex - effectively disabling them by default (as intended).
+
+## Development
+- Minor performance improvements (removed some unnecessary regex matching),
+- Test improvements,
+- Improved debug logging,
+- CI runs on pull requests
+- PR request template
+
+# v0.13.1 (2020-02-26)
+
+## Bugfixes
- Patch to enable `--staged` flag for pre-commit.
- Minor doc updates ([#109](https://github.com/jorisroovers/gitlint/issues/109))
-## v0.13.0 (2020-02-25)
+# v0.13.0 (2020-02-25)
-- **Behavior Change**: Revert Commits are now recognized and ignored by default ([#99](https://github.com/jorisroovers/gitlint/issues/99))
-- ```--staged``` flag: gitlint can now detect meta-data (such as author details, changed files, etc) of staged/pre-commits. Useful when you use [gitlint's commit-msg hook](https://jorisroovers.github.io/gitlint/#using-gitlint-as-a-commit-msg-hook) or [precommit](https://jorisroovers.github.io/gitlint/#using-gitlint-through-pre-commit) ([#105](https://github.com/jorisroovers/gitlint/issues/105))
-- New branch properties on ```GitCommit``` and ```GitContext```, useful when writing your own user-defined rules: ```commit.branches``` and ```commit.context.current_branch``` ([#108](https://github.com/jorisroovers/gitlint/issues/108))
+## General
- Python 3.8 support
- Python 3.4 no longer supported. Python 3.4 has [reached EOL](https://www.python.org/dev/peps/pep-0429/#id4) and an increasing
of gitlint's dependencies have dropped support which makes it hard to maintain.
- Improved Windows support: better unicode handling. [Issues remain](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows) but the basic functionality works.
-- Bugfixes:
+- **Behavior Change**: Revert Commits are now recognized and ignored by default ([#99](https://github.com/jorisroovers/gitlint/issues/99))
+
+## Features
+- `--staged` flag: gitlint can now detect meta-data (such as author details, changed files, etc) of staged/pre-commits. Useful when you use [gitlint's commit-msg hook](https://jorisroovers.github.io/gitlint/#using-gitlint-as-a-commit-msg-hook) or [precommit](https://jorisroovers.github.io/gitlint/#using-gitlint-through-pre-commit) ([#105](https://github.com/jorisroovers/gitlint/issues/105))
+- New branch properties on `GitCommit` and `GitContext`, useful when writing your own user-defined rules: `commit.branches` and `commit.context.current_branch` ([#108](https://github.com/jorisroovers/gitlint/issues/108))
+
+## Bugfixes
- Gitlint no longer crashes when acting on empty repositories (this only occurred in specific circumstances).
- Changed files are now better detected in repos that only have a root commit
- Improved performance and memory (gitlint now caches git properties)
- Improved `--debug` output
- Improved documentation
-- Under-the-hood: dependencies updated, unit and integration test improvements, migrated from TravisCI to Github Actions.
-## v0.12.0 (2019-07-15) ##
+## Development
+- Dependencies updated
+- Unit and integration test improvements
+- Migrated from TravisCI to Github Actions.
+
+# v0.12.0 (2019-07-15)
Contributors:
Special thanks to all contributors for this release, in particular [@rogalksi](https://github.com/rogalski) and [@byrney](https://github.com/byrney).
+## General
+- Python 3.3 no longer supported. Python 3.4 is likely to follow in a future release as it has [reached EOL](https://www.python.org/dev/peps/pep-0429/#id4) as well.
+- PyPy 3.5 support
+
+
+## Features
- [Contrib Rules](http://jorisroovers.github.io/gitlint/contrib_rules): community-contributed rules that are disabled
by default, but can be enabled through configuration. Contrib rules are meant to augment default gitlint behavior by
providing users with rules for common use-cases without forcing these rules on all gitlint users.
- - **New Contrib Rule**: ```contrib-title-conventional-commits``` enforces the [Conventional Commits](https://www.conventionalcommits.org) spec. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits).
- - **New Contrib Rule**: ```cc1-contrib-requires-signed-off-by``` ensures that all commit messages contain a ```Sign-Off-By``` line. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#cc1-contrib-requires-signed-off-by).
+ - **New Contrib Rule**: `contrib-title-conventional-commits` enforces the [Conventional Commits](https://www.conventionalcommits.org) spec. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits).
+ - **New Contrib Rule**: `cc1-contrib-requires-signed-off-by` ensures that all commit messages contain a `Sign-Off-By` line. Details in our [documentation](http://jorisroovers.github.io/gitlint/contrib_rules/#cc1-contrib-requires-signed-off-by).
- If you're interested in adding new Contrib rules to gitlint, please start by reading the
[Contributing](http://jorisroovers.github.io/gitlint/contributing/) page. Thanks for considering!
- *Experimental (!)* Windows support: Basic functionality is working, but there are still caveats. For more details, please refer to [#20](https://github.com/jorisroovers/gitlint/issues/20) and the [open issues related to Windows](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows).
-- Python 3.3 no longer supported. Python 3.4 is likely to follow in a future release as it has [reached EOL](https://www.python.org/dev/peps/pep-0429/#id4) as well.
-- PyPy 3.5 support
-- Support for ```--ignore-stdin``` command-line flag to ignore any text send via stdin. ([#56](https://github.com/jorisroovers/gitlint/issues/56), [#89](https://github.com/jorisroovers/gitlint/issues/89))
-- Bugfixes:
+- Support for `--ignore-stdin` command-line flag to ignore any text send via stdin. ([#56](https://github.com/jorisroovers/gitlint/issues/56), [#89](https://github.com/jorisroovers/gitlint/issues/89))
+
+## Bugfixes
- [#68: Can't use install-hooks in with git worktree](https://github.com/jorisroovers/gitlint/issues/68)
- [#59: gitlint failed with configured commentchar](https://github.com/jorisroovers/gitlint/issues/59)
-- Under-the-hood: dependencies updated, experimental Dockerfile, github issue template.
-## v0.11.0 (2019-03-13) ##
+## Development
+- Dependencies updated
+- Experimental Dockerfile
+- Github issue template.
+
+# v0.11.0 (2019-03-13)
+## General
- Python 3.7 support
- Python 2.6 no longer supported
+
+## Development
- Various dependency updates and under the hood fixes (see [#76](https://github.com/jorisroovers/gitlint/pull/76) for details).
Special thanks to @pbregener for his contributions related to python 3.7 support and test fixes.
-## v0.10.0 (2018-04-15) ##
+# v0.10.0 (2018-04-15)
The 0.10.0 release adds the ability to ignore commits based on their contents,
support for [pre-commit](https://pre-commit.com/), and important fix for running gitlint in CI environments
(such as Jenkins, Gitlab, etc).
Special thanks to [asottile](https://github.com/asottile), [bdrung](https://github.com/bdrung), [pbregener](https://github.com/pbregener), [torwald-sergesson](https://github.com/torwald-sergesson), [RykHawthorn](https://github.com/RykHawthorn), [SteffenKockel](https://github.com/SteffenKockel) and [tommyip](https://github.com/tommyip) for their contributions.
-**Since it's becoming increasingly hard to support Python 2.6 and 3.3, we'd like to encourage our users to upgrade their
-python version to 2.7 or 3.3+. Future versions of gitlint are likely to drop support for Python 2.6 and 3.3.**
-
-Full Changelog:
+## General
+- **Since it's becoming increasingly hard to support Python 2.6 and 3.3, we'd like to encourage our users to upgrade their
+ python version to 2.7 or 3.3+. Future versions of gitlint are likely to drop support for Python 2.6 and 3.3.**
-- **New Rule**: ```ignore-by-title``` allows users to
-[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
-a commit message title. ([#54](https://github.com/jorisroovers/gitlint/issues/54), [#57](https://github.com/jorisroovers/gitlint/issues/57)).
-- **New Rule**: ```ignore-by-body``` allows users to
-[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
-a line in a commit message body.
+## Features
- Gitlint now supports [pre-commit.com](https://pre-commit.com).
[Details in our documentation](http://jorisroovers.github.io/gitlint/#using-gitlint-through-pre-commit)
([#62](https://github.com/jorisroovers/gitlint/issues/62)).
-- Gitlint now has a ```--msg-filename``` commandline flag that allows you to specify the commit message to lint via
+- Gitlint now has a `--msg-filename` commandline flag that allows you to specify the commit message to lint via
a file ([#39](https://github.com/jorisroovers/gitlint/issues/39)).
- Gitlint will now be silent by default when a specified commit range is empty ([#46](https://github.com/jorisroovers/gitlint/issues/46)).
- Gitlint can now be installed on MacOS by brew via the [homebrew-devops](https://github.com/rockyluke/homebrew-devops) tap. To get the latest version of gitlint, always use pip for installation.
- If all goes well,
[gitlint will also be available as a package in the Ubuntu 18.04 repositories](https://launchpad.net/ubuntu/+source/gitlint).
-- Bugfixes:
- - We fixed a nasty and recurring issue with running gitlint in CI. Hopefully that's the end of it :-) ([#40](https://github.com/jorisroovers/gitlint/issues/40)).
- - Fix for custom git comment characters ([#48](https://github.com/jorisroovers/gitlint/issues/48)).
-## v0.9.0 (2017-12-03) ##
-The 0.9.0 release adds a new default ```author-valid-email``` rule, important bugfixes and special case handling.
+## Rules
+- **New Rule**: `ignore-by-title` allows users to
+[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
+a commit message title. ([#54](https://github.com/jorisroovers/gitlint/issues/54), [#57](https://github.com/jorisroovers/gitlint/issues/57)).
+- **New Rule**: `ignore-by-body` allows users to
+[ignore certain commits](http://jorisroovers.github.io/gitlint/#ignoring-commits) by matching a regex against
+a line in a commit message body.
+
+## Bugfixes
+- We fixed a nasty and recurring issue with running gitlint in CI. Hopefully that's the end of it :-) ([#40](https://github.com/jorisroovers/gitlint/issues/40)).
+- Fix for custom git comment characters ([#48](https://github.com/jorisroovers/gitlint/issues/48)).
+
+# v0.9.0 (2017-12-03)
+The 0.9.0 release adds a new default `author-valid-email` rule, important bugfixes and special case handling.
Special thanks to [joshholl](https://github.com/joshholl), [ron8mcr](https://github.com/ron8mcr),
[omarkohl](https://github.com/omarkohl), [domo141](https://github.com/domo141), [nud](https://github.com/nud)
and [AlexMooney](https://github.com/AlexMooney) for their contributions.
-- New Rule: ```author-valid-email``` enforces a valid author email address. Details can be found in the
- [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/#m1-author-valid-email).
-- **Breaking change**: The ```--commits``` commandline flag now strictly follows the refspec format as interpreted
- by the [```git rev-list <refspec>```](https://git-scm.com/docs/git-rev-list) command. This means
- that linting a single commit using ```gitlint --commits <SHA>``` won't work anymore. Instead, for single commits,
- users now need to specificy ```gitlint --commits <SHA>^...<SHA>```. On the upside, this change also means
- that gitlint will now understand all refspec formatters, including ```gitlint --commits HEAD``` to lint all commits
+## General
+- **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 <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
reading a commit message from STDIN if one is passed. Before, gitlint only read from the local git repository when
@@ -104,168 +314,232 @@ and [AlexMooney](https://github.com/AlexMooney) for their contributions.
- **Behavior Change**: Gitlint will now by default
[ignore squash and fixup commits](http://jorisroovers.github.io/gitlint/#merge-fixup-and-squash-commits)
(fix for [#33: fixup messages should not trigger a gitlint violation](https://github.com/jorisroovers/gitlint/issues/33))
+
+## Features
- Support for custom comment characters ([#34](https://github.com/jorisroovers/gitlint/issues/34))
-- Support for [```git commit --cleanup=scissors```](https://git-scm.com/docs/git-commit#git-commit---cleanupltmodegt)
+- Support for [`git commit --cleanup=scissors`](https://git-scm.com/docs/git-commit#git-commit---cleanupltmodegt)
([#34](https://github.com/jorisroovers/gitlint/issues/34))
-- Bugfix: [#37: Prevent Commas in text fields from breaking git log printing](https://github.com/jorisroovers/gitlint/issues/37)
+
+## Rules
+- New Rule: `author-valid-email` enforces a valid author email address. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/#m1-author-valid-email).
+
+## Bugfixes
+- [#37: Prevent Commas in text fields from breaking git log printing](https://github.com/jorisroovers/gitlint/issues/37)
+
+## Development
- Debug output improvements
-## v0.8.2 (2017-04-25) ##
+# v0.8.2 (2017-04-25)
The 0.8.2 release brings minor improvements, bugfixes and some under-the-hood changes. Special thanks to
[tommyip](https://github.com/tommyip) for his contributions.
-- ```--extra-path``` now also accepts a file path (in the past only directory paths where accepted).
+## Features
+- `--extra-path` now also accepts a file path (in the past only directory paths where accepted).
Thanks to [tommyip](https://github.com/tommyip) for implementing this!
-- gitlint will now show more information when using the ```--debug``` flag. This is initial work and will continue to
+- gitlint will now show more information when using the `--debug` flag. This is initial work and will continue to
be improved upon in later releases.
-- Bugfixes:
+
+## Bugfixes
- [#24: --commits doesn't take commit specific config into account](https://github.com/jorisroovers/gitlint/issues/24)
- [#27: --commits returns the wrong exit code](https://github.com/jorisroovers/gitlint/issues/27)
-- Development: better unit and integration test coverage for ```--commits```
+## Development
+- Better unit and integration test coverage for `--commits`
-## v0.8.1 (2017-03-16) ##
+# v0.8.1 (2017-03-16)
The 0.8.1 release brings minor tweaks and some experimental features. Special thanks to
[tommyip](https://github.com/tommyip) for his contributions.
+## General
+- Experimental: Python 3.6 support
+- Improved Windows error messaging: gitlint will now show a more descriptive error message when ran on windows.
+ See [#20](https://github.com/jorisroovers/gitlint/issues/20) for details on the lack of Windows support.
+
+## Features
- Experimental: Linting a range of commits.
[Documentation](http://jorisroovers.github.io/gitlint/#linting-a-range-of-commits).
Known Caveats: [#23](https://github.com/jorisroovers/gitlint/issues/23),
[#24](https://github.com/jorisroovers/gitlint/issues/24).
Closes [#14](https://github.com/jorisroovers/gitlint/issues/14). Thanks to [tommyip](https://github.com/tommyip)
for implementing this!
-- Experimental: Python 3.6 support
-- Improved Windows error messaging: gitlint will now show a more descriptive error message when ran on windows.
- See [#20](https://github.com/jorisroovers/gitlint/issues/20) for details on the lack of Windows support.
-
-## v0.8.0 (2016-12-30) ##
+
+# v0.8.0 (2016-12-30)
The 0.8.0 release is a significant release that has been in the works for a long time. Special thanks to
[Claymore](https://github.com/Claymore), [gernd](https://github.com/gernd) and
[ZhangYaxu](https://github.com/ZhangYaxu) for submitting bug reports and pull requests.
+## General
- Full unicode support: you can now lint messages in any language! This fixes
[#16](https://github.com/jorisroovers/gitlint/issues/16) and [#18](https://github.com/jorisroovers/gitlint/pull/18).
+- Pypy2 support!
+- Various documentation improvements
+
+## Features
- User-defined rules: you can now
[define your own custom rules](http://jorisroovers.github.io/gitlint/user_defined_rules/)
if you want to extend gitlint's functionality.
-- Pypy2 support!
-- Debug output improvements: Gitlint will now print your active configuration when using ```--debug```
-- The ```general.target``` option can now also be set via ```-c``` flags or a ```.gitlint``` file
-- Bugfixes:
- - Various important fixes related to configuration precedence
- - [#17: Body MinLength is not working properly](https://github.com/jorisroovers/gitlint/issues/17).
- **Behavior Change**: Gitlint now always applies this rule, even if the body has just a single line of content.
- Also, gitlint now counts the body-length for the entire body, not just the length of the first line.
-- Various documentation improvements
-- Development:
- - Pylint compliance for all supported python versions
- - Updated dependencies to latest versions
- - Various ```run_tests.sh``` improvements for developer convenience
+- Debug output improvements: Gitlint will now print your active configuration when using `--debug`
+- The `general.target` option can now also be set via `-c` flags or a `.gitlint` file
-## v0.7.1 (2016-06-18) ##
-Bugfixes:
+## Bugfixes
+- Various important fixes related to configuration precedence
+- [#17: Body MinLength is not working properly](https://github.com/jorisroovers/gitlint/issues/17).
+ **Behavior Change**: Gitlint now always applies this rule, even if the body has just a single line of content.
+ Also, gitlint now counts the body-length for the entire body, not just the length of the first line.
-- **Behavior Change**: gitlint no longer prints the file path by default when using a ```.gitlint``` file. The path
-will still be printed when using the new ```--debug``` flag. Special thanks to [Slipcon](https://github.com/slipcon)
+## Development
+- Pylint compliance for all supported python versions
+- Updated dependencies to latest versions
+- Various `run_tests.sh` improvements for developer convenience
+
+# v0.7.1 (2016-06-18)
+## Bugfixes
+
+- **Behavior Change**: gitlint no longer prints the file path by default when using a `.gitlint` file. The path
+will still be printed when using the new `--debug` flag. Special thanks to [Slipcon](https://github.com/slipcon)
for submitting this.
-- Gitlint now prints a correct violation message for the ```title-match-regex``` rule. Special thanks to
+- Gitlint now prints a correct violation message for the `title-match-regex` rule. Special thanks to
[Slipcon](https://github.com/slipcon) for submitting this.
- Gitlint is now better at parsing commit messages cross-platform by taking platform specific line endings into account
- Minor documentation improvements
-## v0.7.0 (2016-04-20) ##
+# v0.7.0 (2016-04-20)
This release contains mostly bugfix and internal code improvements. Special thanks to
[William Turell](https://github.com/wturrell) and [Joe Grund](https://github.com/jgrund) for bug reports and pull
requests.
-- commit-msg hooks improvements: The new commit-msg hook now allows you to edit your message if it contains violations,
- prints the commit message on aborting and is more compatible with GUI-based git clients such as SourceTree.
- *You will need to uninstall and reinstall the commit-msg hook for these latest features*.
+## General
- Python 2.6 support
- **Behavior change**: merge commits are now ignored by default. The rationale is that the original commits
should already be linted and that many merge commits don't pass gitlint checks by default
(e.g. exceeding title length or empty body is very common). This behavior can be overwritten by setting the
- general option ```ignore-merge-commit=false```.
-- Bugfixes and enhancements:
- - [#7: Hook compatibility with SourceTree](https://github.com/jorisroovers/gitlint/issues/7)
- - [#8: Illegal option -e](https://github.com/jorisroovers/gitlint/issues/8)
- - [#9: print full commit msg to stdout if aborted](https://github.com/jorisroovers/gitlint/issues/9)
- - [#11 merge commit titles exceeding the max title length by default](https://github.com/jorisroovers/gitlint/issues/11)
- - Better error handling of invalid general options
-- Development: internal refactoring to extract more info from git. This will allow for more complex rules in the future.
-- Development: initial set of integration tests. Test gitlint end-to-end after it is installed.
-- Development: pylint compliance for python 2.7
+ general option `ignore-merge-commit=false`.
+
+## Features
+- commit-msg hooks improvements: The new commit-msg hook now allows you to edit your message if it contains violations,
+ prints the commit message on aborting and is more compatible with GUI-based git clients such as SourceTree.
+ *You will need to uninstall and reinstall the commit-msg hook for these latest features*.
+
+## Bugfixes
+- [#7: Hook compatibility with SourceTree](https://github.com/jorisroovers/gitlint/issues/7)
+- [#8: Illegal option -e](https://github.com/jorisroovers/gitlint/issues/8)
+- [#9: print full commit msg to stdout if aborted](https://github.com/jorisroovers/gitlint/issues/9)
+- [#11 merge commit titles exceeding the max title length by default](https://github.com/jorisroovers/gitlint/issues/11)
+- Better error handling of invalid general options
+
+## Development
+- internal refactoring to extract more info from git. This will allow for more complex rules in the future.
+- initial set of integration tests. Test gitlint end-to-end after it is installed.
+- pylint compliance for python 2.7
-## v0.6.1 (2015-11-22) ##
+# v0.6.1 (2015-11-22)
-- Fix: ```install-hook``` and ```generate-config``` commands not working when gitlint is installed from pypi.
+## Bugfixes
-## v0.6.0 (2015-11-22) ##
+- `install-hook` and `generate-config` commands not working when gitlint is installed from pypi.
+# v0.6.0 (2015-11-22)
+
+## General
- Python 3 (3.3+) support!
- All documentation is now hosted on [http://jorisroovers.github.io/gitlint/]()
-- New ```generate-config``` command generates a sample gitlint config file
-- New ```--target``` flag allows users to lint different directories than the current working directory
- **Breaking change**: exit code behavior has changed. More details in the
[Exit codes section of the documentation](http://jorisroovers.github.io/gitlint/#exit-codes).
-- **Breaking change**: ```--install-hook``` and ```--uninstall-hook``` have been renamed to ```install-hook``` and
- ```uninstall-hook``` respectively to better express that they are commands instead of options.
+- **Breaking change**: `--install-hook` and `--uninstall-hook` have been renamed to `install-hook` and
+ `uninstall-hook` respectively to better express that they are commands instead of options.
+
+## Features
+- New `generate-config` command generates a sample gitlint config file
+- New `--target` flag allows users to lint different directories than the current working directory
- Better error handling when gitlint is executed in a directory that is not a git repository or
when git is not installed.
- The git commit message hook now uses pretty colored output
-- Fix: ```--config``` option no longer accepts directories as value
-- Development: unit tests are now ran using py.test
-## v0.5.0 (2015-10-04) ##
+## Bugfixes
+- `--config` option no longer accepts directories as value
-- New Rule: ```title-match-regex```. Details can be found in the
- [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
-- Uninstall previously installed gitlint git commit hooks using: ```gitlint --uninstall-hook```
-- Ignore rules on a per commit basis by adding e.g.: ```gitlint-ignore: T1, body-hard-tab``` to your git commit message.
- Use ```gitlint-ignore: all``` to disable gitlint all together for a specific commit.
-- ```body-is-missing``` will now automatically be disabled for merge commits (use the ```ignore-merge-commit: false```
+## Development
+- Unit tests are now ran using py.test
+
+# v0.5.0 (2015-10-04)
+
+## Features
+
+- Uninstall previously installed gitlint git commit hooks using: `gitlint --uninstall-hook`
+- Ignore rules on a per commit basis by adding e.g.: `gitlint-ignore: T1, body-hard-tab` to your git commit message.
+ Use `gitlint-ignore: all` to disable gitlint all together for a specific commit.
+- `body-is-missing` will now automatically be disabled for merge commits (use the `ignore-merge-commit: false`
option to disable this behavior)
- Violations are now sorted by line number first and then by rule id (previously the order of violations on the
same line was arbitrary).
-## v0.4.1 (2015-09-19) ##
+## Rules
+- New Rule: `title-match-regex`. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
-- Internal fix: added missing comma to setup.py which prevented pypi upload
+# v0.4.1 (2015-09-19)
-## v0.4.0 (2015-09-19) ##
+## Bugfixes
+- Added missing comma to setup.py which prevented pypi upload
-- New rules: ```body-is-missing```, ```body-min-length```, ```title-leading-whitespace```,
- ```body-changed-file-mention```. Details can be found in the
- [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
-- The git ```commit-msg``` hook now allows you to keep or discard the commit when it fails gitlint validation
+# v0.4.0 (2015-09-19)
+
+## General
- gitlint is now also released as a [python wheel](http://pythonwheels.com/) on pypi.
+
+## Features
+- The git `commit-msg` hook now allows you to keep or discard the commit when it fails gitlint validation
+
+## Rules
+
+- New rules: `body-is-missing`, `body-min-length`, `title-leading-whitespace`,
+ `body-changed-file-mention`. Details can be found in the
+ [Rules section of the documentation](http://jorisroovers.github.io/gitlint/rules/).
+
+## Development
- Internal: rule classes now have access to a gitcontext containing body the commit message and the files changed in the
last commit.
-## v0.3.0 (2015-09-11) ##
-- ```title-must-not-contain-word``` now has a ```words``` option that can be used to specify which words should not
- occur in the title
+# v0.3.0 (2015-09-11)
+## Features
- gitlint violations are now printed to the stderr instead of stdout
-- Various minor bugfixes
- gitlint now ignores commented out lines (i.e. starting with #) in your commit messages
- Experimental: git commit-msg hook support
+
+## Rules
+- `title-must-not-contain-word` now has a `words` option that can be used to specify which words should not
+ occur in the title
+
+## Bugfixes
+- Various minor bugfixes
+
+## Development
- Under-the-hood: better test coverage :-)
-## v0.2.0 (2015-09-10) ##
- - Rules can now have their behavior configured through options.
- For example, the ```title-max-length``` rule now has a ```line-length``` option.
- - Under-the-hood: The codebase now has a basic level of unit test coverage, increasing overall quality assurance
+# v0.2.0 (2015-09-10)
+
+## Features
+- Rules can now have their behavior configured through options.
+ For example, the `title-max-length` rule now has a `line-length` option.
+
+## Development
+ - The codebase now has a basic level of unit test coverage, increasing overall quality assurance
-## v0.1.1 (2015-09-08) ##
-- Bugfix: added missing ```sh``` dependency
+# v0.1.1 (2015-09-08)
+
+## Bugfixes
+- Added missing `sh` dependency
+
+# v0.1.0 (2015-09-08)
-## v0.1.0 (2015-09-08) ##
+## General
- Initial gitlint release
- Initial set of rules: title-max-length, title-trailing-whitespace, title-trailing-punctuation , title-hard-tab,
title-must-not-contain-word, body-max-line-length, body-trailing-whitespace, body-hard-tab
-- General gitlint configuration through a ```gitlint``` file
+- General gitlint configuration through a `gitlint` file
- Silent and verbose mode
- Vagrantfile for easy development
- gitlint is available on [pypi](https://pypi.python.org/pypi/gitlint)
diff --git a/Dockerfile b/Dockerfile
index b66bb71..641296e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,17 @@
-# User-facing Dockerfile. For development, see Dockerfile.dev and ./run_tests.sh -h
-
# To lint your current working directory:
-# docker run -v $(pwd):/repo jorisroovers/gitlint
+# docker run --ulimit nofile=1024 -v $(pwd):/repo jorisroovers/gitlint
# With arguments:
-# docker run -v $(pwd):/repo jorisroovers/gitlint --debug --ignore T1
+# docker --ulimit nofile=1024 run -v $(pwd):/repo jorisroovers/gitlint --debug --ignore T1
+
+# NOTE: --ulimit is required to work around a limitation in Docker
+# Details: https://github.com/jorisroovers/gitlint/issues/129
-FROM python:3.8-alpine
+FROM python:3.11.2-alpine
ARG GITLINT_VERSION
RUN apk add git
RUN pip install gitlint==$GITLINT_VERSION
+RUN git config --global --add safe.directory /repo
ENTRYPOINT ["gitlint", "--target", "/repo"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
deleted file mode 100644
index 5cd1739..0000000
--- a/Dockerfile.dev
+++ /dev/null
@@ -1,17 +0,0 @@
-# Note: development using the local Dockerfile is still work-in-progress
-# Getting started: http://jorisroovers.github.io/gitlint/contributing/
-ARG python_version_dotted
-
-FROM python:${python_version_dotted}-stretch
-
-RUN apt-get update
-# software-properties-common contains 'add-apt-repository'
-RUN apt-get install -y git silversearcher-ag jq curl
-
-ADD . /gitlint
-WORKDIR /gitlint
-
-RUN pip install --ignore-requires-python -r requirements.txt
-RUN pip install --ignore-requires-python -r test-requirements.txt
-
-CMD ["/bin/bash"]
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 51a5598..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,7 +0,0 @@
-include README.md
-include LICENSE
-exclude Vagrantfile
-exclude *.yml *.sh *.txt
-recursive-exclude examples *
-recursive-exclude gitlint/tests *
-recursive-exclude qa * \ No newline at end of file
diff --git a/README.md b/README.md
index 81f2ac9..05787be 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,24 @@
# gitlint: [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) #
[![Tests](https://github.com/jorisroovers/gitlint/workflows/Tests%20and%20Checks/badge.svg)](https://github.com/jorisroovers/gitlint/actions?query=workflow%3A%22Tests+and+Checks%22)
+[![Coverage Status](https://coveralls.io/repos/github/jorisroovers/gitlint/badge.svg?branch=fix-coveralls)](https://coveralls.io/github/jorisroovers/gitlint?branch=fix-coveralls)
[![PyPi Package](https://img.shields.io/pypi/v/gitlint.png)](https://pypi.python.org/pypi/gitlint)
![Supported Python Versions](https://img.shields.io/pypi/pyversions/gitlint.svg)
-Git commit message linter written in python (for Linux and Mac, experimental on Windows), checks your commit messages for style.
+Git commit message linter written in python, checks your commit messages for style.
**See [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) for full documentation.**
-<a href="http://jorisroovers.github.io/gitlint/" target="_blank"><img src="https://asciinema.org/a/30477.png" width="640"/></a>
+<a href="http://jorisroovers.github.io/gitlint/" target="_blank">
+<img src="https://raw.githubusercontent.com/jorisroovers/gitlint/main/docs/images/readme-gitlint.png" />
+</a>
## Contributing ##
All contributions are welcome and very much appreciated!
-**I'm looking for contributors that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please open a PR if you're interested - Thanks!**
+**I'm [looking for contributors](https://github.com/jorisroovers/gitlint/issues/134) that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please leave a comment in [#134](https://github.com/jorisroovers/gitlint/issues/134) if you're interested!**
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 2a26aab..0000000
--- a/Vagrantfile
+++ /dev/null
@@ -1,47 +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 python2.7-dev python3.5-dev python3.6-dev python3.7-dev python3.8-dev
-sudo apt-get install -y --allow-unauthenticated python3.8-distutils # Needed to work around python3.8+virtualenv issue
-sudo apt-get install -y python-virtualenv git ipython python-pip python3-pip silversearcher-ag jq
-sudo apt-get purge -y python3-virtualenv
-sudo pip3 install virtualenv
-
-./run_tests.sh --uninstall --envs all
-./run_tests.sh --install --envs all
-
-grep 'cd /vagrant' /home/vagrant/.bashrc || echo 'cd /vagrant' >> /home/vagrant/.bashrc
-grep 'source .venv27/bin/activate' /home/vagrant/.bashrc || echo 'source .venv27/bin/activate' >> /home/vagrant/.bashrc
-EOF
-
-INSTALL_JENKINS=<<EOF
-wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -
-sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
-sudo apt-get update
-sudo apt-get install -y openjdk-8-jre
-sudo apt-get install -y jenkins
-EOF
-
-Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
-
- config.vm.box = "ubuntu/xenial64"
-
- config.vm.define "dev" do |dev|
- dev.vm.provision "gitlint", type: "shell", inline: "#{INSTALL_DEPS}"
- # Use 'vagrant provision --provision-with jenkins' to only run jenkins install
- dev.vm.provision "jenkins", type: "shell", inline: "#{INSTALL_JENKINS}"
- end
-
- config.vm.network "forwarded_port", guest: 8080, host: 9080
-
- if Vagrant.has_plugin?("vagrant-cachier")
- config.cache.scope = :box
- end
-
-end
diff --git a/debian/changelog b/debian/changelog
index b156d0a..97273e5 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,119 @@
+gitlint (0.19.1-2) sid; urgency=medium
+
+ * Uploading to sid.
+ * Uploading without changes after bookworm release.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 11 Jun 2023 14:13:53 +0200
+
+gitlint (0.19.1-1) experimental; urgency=medium
+
+ * Uploading to experimental.
+ * Merging upstream version 0.19.1.
+ * Adding build-depends to pybuild-plugin-pyproject for PEP517 support.
+ * Adding hatch build-depends for new usptream version.
+ * Workarounding hatch-vcs as debian upstream tarball doesn't contain
+ upstreams git vcs files.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 11 Mar 2023 09:03:12 +0100
+
+gitlint (0.19.0~dev-1) experimental; urgency=medium
+
+ * Uploading to experimental.
+ * Merging upstream version 0.19.0~dev.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 30 Jan 2023 18:11:16 +0100
+
+gitlint (0.18.0-2) sid; urgency=medium
+
+ * Uploading to sid.
+ * Updating to standards version 4.6.2.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 30 Jan 2023 17:12:56 +0100
+
+gitlint (0.18.0-1) sid; urgency=medium
+
+ * Uploading to sid.
+ * Merging upstream version 0.18.0.
+ * Updating copyright for 2022.
+ * Updating to standards version 4.6.1.
+ * Adding further build-depends used for manpage generation to make build
+ reproducible, thanks to Vagrant Cascadian <vagrant@reproducible-
+ builds.org> (Closes: #1024404).
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 19 Nov 2022 15:55:10 +0100
+
+gitlint (0.17.0-1) sid; urgency=medium
+
+ * Uploading to sid.
+ * Merging upstream version 0.17.0.
+ * Refreshing python3-hook.patch.
+ * Disabling now non-working test target temporarily.
+ * Updating rules for gitlint and gitlint-core changes.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 04 Dec 2021 04:31:53 +0100
+
+gitlint (0.16.0-1) sid; urgency=medium
+
+ * Uploading to sid.
+ * Merging upstream version 0.16.0.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Wed, 13 Oct 2021 07:35:03 +0200
+
+gitlint (0.15.1-3) sid; urgency=medium
+
+ * Uploading to sid.
+ * Updating to standards version 4.6.0.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 09 Oct 2021 10:15:53 +0200
+
+gitlint (0.15.1-2) sid; urgency=medium
+
+ * Uploading to sid.
+ * Updating watch file.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 15 Aug 2021 17:36:42 +0200
+
+gitlint (0.15.1-1) experimental; urgency=medium
+
+ * Uploading to experimental.
+ * Merging upstream version 0.15.1.
+ * Updating python debhelper sequence.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 19 Apr 2021 15:27:48 +0200
+
+gitlint (0.15.0-1) sid; urgency=medium
+
+ * Uploading to sid.
+ * Merging upstream version 0.15.0.
+ * Updating years in copyright file for 2021.
+ * Updating to standards version 4.5.1.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 25 Jan 2021 14:27:59 +0100
+
+gitlint (0.14.0-1) sid; urgency=medium
+
+ * Uploading to sid.
+ * Merging upstream version 0.14.0.
+ * Refreshing python3-hook.patch.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 03 Nov 2020 07:07:57 +0100
+
+gitlint (0.13.1-6) sid; urgency=medium
+
+ * Uploading to sid.
+ * Adding Rules-Requires-Root field.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 30 Aug 2020 15:56:29 +0200
+
+gitlint (0.13.1-5) sid; urgency=medium
+
+ * Uploading to sid.
+ * Updating to debhelper version 13.
+ * Using python3 in commit-msg hook (Closes: #891538).
+ * Removing fix-test-with-python3.patch, not needed anymore.
+
+ -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 19 Apr 2020 13:31:34 +0200
+
gitlint (0.13.1-4) sid; urgency=medium
* Uploading to sid.
diff --git a/debian/control b/debian/control
index 1c3c22a..efbdda3 100644
--- a/debian/control
+++ b/debian/control
@@ -4,17 +4,19 @@ Priority: optional
Maintainer: Daniel Baumann <daniel.baumann@progress-linux.org>
Build-Depends:
debhelper-compat (= 13),
- dh-python,
+ dh-sequence-python3,
git,
help2man,
- pylint,
+ pybuild-plugin-pyproject,
python3-all,
python3-arrow,
python3-click,
- python3-coverage,
+ python3-hatch-vcs,
+ python3-hatchling,
python3-setuptools,
python3-sh,
-Standards-Version: 4.5.0
+Rules-Requires-Root: no
+Standards-Version: 4.6.2
Homepage: https://jorisroovers.com/gitlint/
Vcs-Browser: https://git.progress-linux.org/users/daniel.baumann/debian/packages/gitlint
Vcs-Git: https://git.progress-linux.org/users/daniel.baumann/debian/packages/gitlint
diff --git a/debian/copyright b/debian/copyright
index bef9d7e..358a320 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -4,12 +4,12 @@ Upstream-Contact: Joris Roovers <jroovers@cisco.com>
Source: https://github.com/jorisroovers/gitlint
Files: *
-Copyright: 2015-2020 Joris Roovers <jroovers@cisco.com>
+Copyright: 2015-2022 Joris Roovers <jroovers@cisco.com>
License: MIT
Files: debian/*
-Copyright: 2017-2018 Patrik Hagedorn <patrik.hagedorn@gmx.net>
- 2020 Daniel Baumann <daniel.baumann@progress-linux.org>
+Copyright: 2020-2024 Daniel Baumann <daniel.baumann@progress-linux.org>
+ 2017-2018 Patrik Hagedorn <patrik.hagedorn@gmx.net>
License: MIT
License: MIT
diff --git a/debian/patches/debian/0001-python3-hook.patch b/debian/patches/debian/0001-python3-hook.patch
index 74cdcea..bbd6b08 100644
--- a/debian/patches/debian/0001-python3-hook.patch
+++ b/debian/patches/debian/0001-python3-hook.patch
@@ -1,15 +1,15 @@
Author: Daniel Baumann <daniel.baumann@progress-linux.org>
Description: Using python3 in commit-msg hook (Closes: #891538).
-diff -Naurp gitlint.orig/gitlint/files/commit-msg gitlint/gitlint/files/commit-msg
---- gitlint.orig/gitlint/files/commit-msg
-+++ gitlint/gitlint/files/commit-msg
-@@ -26,7 +26,7 @@ fi
-
- run_gitlint(){
- echo "gitlint: checking commit message..."
-- python -m gitlint.cli --staged --msg-filename "$1"
-+ python3 -m gitlint.cli --staged --msg-filename "$1"
- gitlint_exit_code=$?
- }
+diff -Naurp gitlint.orig/gitlint-core/gitlint/files/commit-msg gitlint/gitlint-core/gitlint/files/commit-msg
+--- gitlint.orig/gitlint-core/gitlint/files/commit-msg
++++ gitlint/gitlint-core/gitlint/files/commit-msg
+@@ -26,7 +26,7 @@ exit_code=$?
+ # This is the case for Atlassian SourceTree, where $PATH deviates from the user's shell $PATH.
+ if [ $exit_code -eq 127 ]; then
+ echo "Fallback to python module execution"
+- python -m gitlint.cli --staged --msg-filename "$1" run-hook
++ python3 -m gitlint.cli --staged --msg-filename "$1" run-hook
+ exit_code=$?
+ fi
diff --git a/debian/rules b/debian/rules
index 50d8599..3df7be3 100755
--- a/debian/rules
+++ b/debian/rules
@@ -1,13 +1,18 @@
#!/usr/bin/make -f
-export LC_ALL=C.UTF-8
-export PYBUILD_TEST_ARGS=python{version} -m coverage run --omit='/usr/*,$(CURDIR)/gitlint/tests/*,$(CURDIR)/gitlint/qa/*' -m unittest discover -v -s $(CURDIR)/gitlint/tests
+VERSION := $(shell dpkg-parsechangelog -SVersion)
%:
- dh ${@} --buildsystem=pybuild --with python3
+ dh ${@} --buildsystem=pybuild --sourcedir=$(CURDIR)/gitlint-core
+
+execute_after_dh_auto_clean:
+ rm -f gitlint-core/_version.py
+
+execute_before_dh_auto_build:
+ echo "__version__ = version = '$(VERSION)'" > gitlint-core/_version.py
override_dh_auto_test:
- PYBUILD_SYSTEM=custom dh_auto_test
+ # disabled
execute_after_dh_auto_install:
# manpage
@@ -22,3 +27,6 @@ execute_after_dh_auto_install:
execute_after_dh_fixperms:
find debian/gitlint/usr/lib -name commit-msg -exec chmod +x {} \;
+
+override_dh_missing:
+ # disabled
diff --git a/debian/watch b/debian/watch
index 5d6e75d..0ad4bc1 100644
--- a/debian/watch
+++ b/debian/watch
@@ -1,3 +1,3 @@
version=4
opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/gitlint-$1\.tar\.gz/ \
-https://github.com/jorisroovers/gitlint/releases .*/v?(\d\S+)\.tar\.gz
+https://github.com/jorisroovers/gitlint/tags .*/v?(\d\S+)\.tar\.gz
diff --git a/doc-requirements.txt b/doc-requirements.txt
deleted file mode 100644
index baf208d..0000000
--- a/doc-requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-mkdocs==1.0.4 \ No newline at end of file
diff --git a/docs/configuration.md b/docs/configuration.md
index 641b361..af49d7c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,21 +1,21 @@
# Configuration
Gitlint can be configured through different means.
-# Config files #
-You can modify gitlint's behavior by adding a ```.gitlint``` file to your git repository.
+## The .gitlint file
+You can modify gitlint's behavior by adding a `.gitlint` file to your git repository.
-Generate a default ```.gitlint``` config file by running:
-```bash
+Generate a default `.gitlint` config file by running:
+```sh
gitlint generate-config
```
You can also use a different config file like so:
-```bash
-gitlint --config myconfigfile.ini
+```sh
+gitlint --config myconfigfile.ini
```
-The block below shows a sample ```.gitlint``` file. Details about rule config options can be found on the
-[Rules](rules.md) page, details about the ```[general]``` section can be found in the
+The block below shows a sample `.gitlint` file. Details about rule config options can be found on the
+[Rules](rules.md) page, details about the `[general]` section can be found in the
[General Configuration](configuration.md#general-configuration) section of this page.
```ini
@@ -25,7 +25,7 @@ The block below shows a sample ```.gitlint``` file. Details about rule config op
# one rule and each key in it is an option for that specific rule.
#
# Rules and sections can be referenced by their full name or by id. For example
-# section "[body-max-line-length]" could be written as "[B1]". Full section names are
+# section "[body-max-line-length]" could also be written as "[B1]". Full section names are
# used in here for clarity.
# Rule reference documentation: http://jorisroovers.github.io/gitlint/rules/
#
@@ -39,19 +39,31 @@ 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
+# 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.
+# 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
@@ -68,6 +80,11 @@ extra-path=examples/
[title-max-length]
line-length=80
+# Conversely, you can also enforce minimal length of a title with the
+# "title-min-length" rule:
+[title-min-length]
+min-length=5
+
[title-must-not-contain-word]
# Comma-separated list of words that should not occur in the title. Matching is case
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
@@ -75,7 +92,7 @@ line-length=80
words=wip
[title-match-regex]
-# python like regex (https://docs.python.org/2/library/re.html) that the
+# python like regex (https://docs.python.org/3/library/re.html) that the
# commit-msg title must be matched to.
# Note that the regex can contradict with other rules if not used correctly
# (e.g. title-must-not-contain-word).
@@ -95,14 +112,20 @@ ignore-merge-commits=false
[body-changed-file-mention]
# List of files that need to be explicitly mentioned in the body when they are changed
# This is useful for when developers often erroneously edit certain files or git submodules.
-# By specifying this rule, developers can only change the file when they explicitly reference
-# it in the commit message.
-files=gitlint/rules.py,README.md
+# By specifying this rule, developers can only change the file when they explicitly
+# reference it in the commit message.
+files=gitlint-core/gitlint/rules.py,README.md
+
+[body-match-regex]
+# python-style regex that the commit-msg body must match.
+# E.g. body must end in My-Commit-Tag: foo
+regex=My-Commit-Tag: foo$
[author-valid-email]
-# python like regex (https://docs.python.org/2/library/re.html) that the
+# python like regex (https://docs.python.org/3/library/re.html) that the
# commit author email address should be matched to
-# For example, use the following regex if you only want to allow email addresses from foo.com
+# E.g.: For example, use the following regex if you only want to allow email
+# addresses from foo.com
regex=[^@]+@foo.com
[ignore-by-title]
@@ -117,12 +140,26 @@ ignore=T1,body-min-length
[ignore-by-body]
# Ignore certain rules for commits of which the body has a line that matches a regex
# E.g. Match bodies that have a line that that contain "release"
-# regex=(.*)release(.*)
+regex=(.*)release(.*)
#
# Ignore certain rules, you can reference them by their id or by their full name
# Use 'all' to ignore all rules
ignore=T1,body-min-length
+[ignore-body-lines]
+# Ignore certain lines in a commit body that match a regex.
+# E.g. Ignore all lines that start with 'Co-Authored-By'
+regex=^Co-Authored-By
+
+[ignore-by-author-name]
+# Ignore certain rules for commits of which the author name matches a regex
+# E.g. Match commits made by dependabot
+regex=(.*)dependabot(.*)
+
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+ignore=T1,body-min-length
+
# This is a contrib rule - a community contributed rule. These are disabled by default.
# You need to explicitly enable them one-by-one by adding them to the "contrib" option
# under [general] section above.
@@ -131,20 +168,20 @@ ignore=T1,body-min-length
types = bugfix,user-story,epic
```
-# Commandline config #
+## Commandline config
-You can also use one or more ```-c``` flags like so:
+You can also use one or more `-c` flags like so:
```
$ gitlint -c general.verbosity=2 -c title-max-length.line-length=80 -c B1.line-length=100
```
-The generic config flag format is ```-c <rule>.<option>=<value>``` and supports all the same rules and options which
-you can also use in a ```.gitlint``` config file.
+The generic config flag format is `-c <rule>.<option>=<value>` and supports all the same rules and options which
+you can also use in a `.gitlint` config file.
-# Commit specific config #
+## Commit specific config
You can also configure gitlint by adding specific lines to your commit message.
-For now, we only support ignoring commits by adding ```gitlint-ignore: all``` to the commit
+For now, we only support ignoring commits by adding `gitlint-ignore: all` to the commit
message like so:
```
@@ -154,9 +191,9 @@ I want gitlint to ignore this entire commit message.
gitlint-ignore: all
```
-```gitlint-ignore: all``` can occur on any line, as long as it is at the start of the line.
+`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
@@ -166,44 +203,47 @@ gitlint-ignore: T1, body-hard-tab
-# Configuration precedence #
+## Configuration precedence
gitlint configuration is applied in the following order of precedence:
-1. Commit specific config (e.g.: ```gitlint-ignore: all``` in the commit message)
-2. Configuration Rules (e.g.: [ignore-by-title](/rules/#i1-ignore-by-title))
-3. Commandline convenience flags (e.g.: ```-vv```, ```--silent```, ```--ignore```)
-4. Commandline configuration flags (e.g.: ```-c title-max-length=123```)
-5. Configuration file (local ```.gitlint``` file, or file specified using ```-C```/```--config```)
-6. Default gitlint config
+1. Commit specific config (e.g.: `gitlint-ignore: all` in the commit message)
+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`)
+6. Configuration file (local `.gitlint` file, or file specified using `-C`/`--config`)
+7. Default gitlint config
-# General Options
+## General Options
Below we outline all configuration options that modify gitlint's overall behavior. These options can be specified
-using commandline flags or in ```[general]``` section in a ```.gitlint``` configuration file.
+using commandline flags or in `[general]` section in a `.gitlint` configuration file.
-## silent
+### silent
Enable silent mode (no output). Use [exit](index.md#exit-codes) code to determine result.
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- false | >= 0.1.0 | ```--silent```
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| `False` | >= 0.1.0 | `--silent` | `GITLINT_SILENT` |
-### Examples
+#### Examples
```sh
# CLI
gitlint --silent
+GITLINT_SILENT=1 gitlint # using env variable
```
+------------------------------------------------------------------------------------------------------------------------
-## verbosity
+### verbosity
Amount of output gitlint will show when printing errors.
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- 3 | >= 0.1.0 | `-v`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| 3 | >= 0.1.0 | `-v` | `GITLINT_VERBOSITY` |
-### Examples
+#### Examples
```sh
# CLI
gitlint -vvv # default (level 3)
@@ -212,221 +252,377 @@ gitlint -v # even less (level 1)
gitlint --silent # no output (level 0)
gitlint -c general.verbosity=1 # Set specific level
gitlint -c general.verbosity=0 # Same as --silent
+GITLINT_VERBOSITY=2 gitlint # using env variable
```
```ini
-.gitlint
+# .gitlint
[general]
verbosity=2
```
+------------------------------------------------------------------------------------------------------------------------
-## ignore-merge-commits
+### ignore
-Whether or not to ignore merge commits.
+Comma separated list of rules to ignore (by name or id).
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- true | >= 0.7.0 | Not Available
+| Default value | gitlint version | commandline flag | environment variable |
+| ---------------- | --------------- | ---------------- | -------------------- |
+| [] (=empty list) | >= 0.1.0 | `--ignore` | `GITLINT_IGNORE` |
-### Examples
+#### Examples
```sh
# CLI
-gitlint -c general.ignore-merge-commits=false
+gitlint --ignore=body-min-length # ignore single rule
+gitlint --ignore=T1,body-min-length # ignore multiple rule
+gitlint -c general.ignore=T1,body-min-length # different way of doing the same
+GITLINT_IGNORE=T1,body-min-length gitlint # using env variable
```
```ini
#.gitlint
[general]
-ignore-merge-commits=false
+ignore=T1,body-min-length
```
+------------------------------------------------------------------------------------------------------------------------
-## ignore-revert-commits
+### debug
-Whether or not to ignore revert commits.
+Enable debugging output.
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- true | >= 0.13.0 | Not Available
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| false | >= 0.7.1 | `--debug` | `GITLINT_DEBUG` |
-### Examples
+#### Examples
```sh
# CLI
-gitlint -c general.ignore-revert-commits=false
+gitlint --debug
+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` |
+
+#### Examples
+```sh
+# CLI
+gitlint --target=/home/joe/myrepo/
+gitlint -c general.target=/home/joe/myrepo/ # different way of doing the same
+GITLINT_TARGET=/home/joe/myrepo/ gitlint # using env variable
```
```ini
#.gitlint
[general]
-ignore-revert-commits=false
+target=/home/joe/myrepo/
```
+------------------------------------------------------------------------------------------------------------------------
-## ignore-fixup-commits
+### config
-Whether or not to ignore [fixup](https://git-scm.com/docs/git-commit#git-commit---fixupltcommitgt) commits.
+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
----------------|------------------|-------------------
- true | >= 0.9.0 | Not Available
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| (empty) | >= 0.8.0 | `--extra-path` | `GITLINT_EXTRA_PATH` |
-### Examples
+#### Examples
```sh
# CLI
-gitlint -c general.ignore-fixup-commits=false
+gitlint --extra-path=/home/joe/rules/
+gitlint -c general.extra-path=/home/joe/rules/ # different way of doing the same
+GITLINT_EXTRA_PATH=/home/joe/rules/ gitlint # using env variable
```
```ini
#.gitlint
[general]
-ignore-fixup-commits=false
+extra-path=/home/joe/rules/
```
+------------------------------------------------------------------------------------------------------------------------
+### contrib
-## ignore-squash-commits
+Comma-separated list of [Contrib rules](contrib_rules.md) to enable (by name or id).
-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 |
+| ------------- | --------------- | ---------------- | -------------------- |
+| (empty) | >= 0.12.0 | `--contrib` | `GITLINT_CONTRIB` |
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- true | >= 0.9.0 | Not Available
+#### Examples
+```sh
+# CLI
+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
+```
+```ini
+#.gitlint
+[general]
+contrib=contrib-title-conventional-commits,CC1
+```
+------------------------------------------------------------------------------------------------------------------------
+
+### staged
+
+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.
-### Examples
+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` |
+
+#### Examples
```sh
# CLI
-gitlint -c general.ignore-squash-commits=false
+gitlint --staged
+gitlint -c general.staged=true # different way of doing the same
+GITLINT_STAGED=1 gitlint # using env variable
```
```ini
#.gitlint
[general]
-ignore-squash-commits=false
+staged=true
```
+------------------------------------------------------------------------------------------------------------------------
-## ignore
+### fail-without-commits
-Comma separated list of rules to ignore (by name or id).
+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
----------------------------|------------------|-------------------
- [] (=empty list) | >= 0.1.0 | `--ignore`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ------------------------ | ------------------------------ |
+| false | >= 0.15.2 | `--fail-without-commits` | `GITLINT_FAIL_WITHOUT_COMMITS` |
-### Examples
+#### Examples
```sh
# CLI
-gitlint --ignore=body-min-length # ignore single rule
-gitlint --ignore=T1,body-min-length # ignore multiple rule
-gitlint -c general.ignore=T1,body-min-length # different way of doing the same
+# The following will cause gitlint to hard fail (i.e. exit code > 0)
+# 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
```
```ini
#.gitlint
[general]
-ignore=T1,body-min-length
+fail-without-commits=true
```
-## debug
+---
+### 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.
-Enable debugging output.
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- false | >= 0.7.1 | `--debug`
-### Examples
+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 --debug
-# --debug is special, the following does NOT work
-# gitlint -c general.debug=true
+gitlint -c general.regex-style-search=true
+```
+```ini
+#.gitlint
+[general]
+regex-style-search=true
```
+------------------------------------------------------------------------------------------------------------------------
+### ignore-stdin
-## target
+Ignore any stdin data. Sometimes useful when running gitlint in a CI server.
-Target git repository gitlint should be linting against.
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | ---------------------- |
+| false | >= 0.12.0 | `--ignore-stdin` | `GITLINT_IGNORE_STDIN` |
+
+#### Examples
+```sh
+# CLI
+gitlint --ignore-stdin
+gitlint -c general.ignore-stdin=true # different way of doing the same
+GITLINT_IGNORE_STDIN=1 gitlint # using env variable
+```
+```ini
+#.gitlint
+[general]
+ignore-stdin=true
+```
+------------------------------------------------------------------------------------------------------------------------
+
+### ignore-merge-commits
-Default value | gitlint version | commandline flag
----------------------------|------------------|-------------------
- (empty) | >= 0.8.0 | `--target`
+Whether or not to ignore merge commits.
-### Examples
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| true | >= 0.7.0 | Not Available | Not Available |
+
+#### Examples
```sh
# CLI
-gitlint --target=/home/joe/myrepo/
-gitlint -c general.target=/home/joe/myrepo/ # different way of doing the same
+gitlint -c general.ignore-merge-commits=false
```
```ini
#.gitlint
[general]
-target=/home/joe/myrepo/
+ignore-merge-commits=false
```
+------------------------------------------------------------------------------------------------------------------------
-## extra-path
+### ignore-revert-commits
-Path where gitlint looks for [user-defined rules](user_defined_rules.md).
+Whether or not to ignore revert commits.
-Default value | gitlint version | commandline flag
----------------------------|------------------|-------------------
- (empty) | >= 0.8.0 | `--extra-path`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| true | >= 0.13.0 | Not Available | Not Available |
-### Examples
+#### Examples
```sh
# CLI
-gitlint --extra-path=/home/joe/rules/
-gitlint -c general.extra-path=/home/joe/rules/ # different way of doing the same
+gitlint -c general.ignore-revert-commits=false
```
```ini
#.gitlint
[general]
-extra-path=/home/joe/rules/
+ignore-revert-commits=false
```
+------------------------------------------------------------------------------------------------------------------------
-## contrib
+### ignore-fixup-commits
-[Contrib rules](contrib_rules) to enable.
+Whether or not to ignore [fixup](https://git-scm.com/docs/git-commit#git-commit---fixupltcommitgt) commits.
-Default value | gitlint version | commandline flag
----------------------------|------------------|-------------------
- (empty) | >= 0.12.0 | `--contrib`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| true | >= 0.9.0 | Not Available | Not Available |
-### Examples
+#### Examples
```sh
# CLI
-gitlint --contrib=contrib-title-conventional-commits,CC1
-gitlint -c general.contrib=contrib-title-conventional-commits,CC1 # different way of doing the same
+gitlint -c general.ignore-fixup-commits=false
```
```ini
#.gitlint
[general]
-contrib=contrib-title-conventional-commits,CC1
+ignore-fixup-commits=false
```
-## ignore-stdin
+------------------------------------------------------------------------------------------------------------------------
-Ignore any stdin data. Sometimes useful when running gitlint in a CI server.
+### 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
----------------|------------------|-------------------
- false | >= 0.12.0 | `--ignore-stdin`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| true | >= 0.18.0 | Not Available | Not Available |
-### Examples
+#### Examples
```sh
# CLI
-gitlint --ignore-stdin
-gitlint -c general.ignore-stdin=true # different way of doing the same
+gitlint -c general.ignore-fixup-amend-commits=false
```
```ini
#.gitlint
[general]
-ignore-stdin=true
+ignore-fixup-amend-commits=false
```
+------------------------------------------------------------------------------------------------------------------------
-## staged
+### ignore-squash-commits
-Fetch additional meta-data from the local `repository when manually passing a commit message to gitlint via stdin or ```--commit-msg```.
+Whether or not to ignore [squash](https://git-scm.com/docs/git-commit#git-commit---squashltcommitgt) commits.
-Default value | gitlint version | commandline flag
----------------|------------------|-------------------
- false | >= 0.13.0 | `--staged`
+| Default value | gitlint version | commandline flag | environment variable |
+| ------------- | --------------- | ---------------- | -------------------- |
+| true | >= 0.9.0 | Not Available | Not Available |
-### Examples
+#### Examples
```sh
# CLI
-gitlint --staged
-gitlint -c general.staged=true # different way of doing the same
+gitlint -c general.ignore-squash-commits=false
```
```ini
#.gitlint
[general]
-staged=true
+ignore-squash-commits=false
``` \ No newline at end of file
diff --git a/docs/contrib_rules.md b/docs/contrib_rules.md
index a4f4f0d..e085f23 100644
--- a/docs/contrib_rules.md
+++ b/docs/contrib_rules.md
@@ -1,16 +1,17 @@
# 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.
+To enable certain contrib rules, you can use the `--contrib` flag.
```sh
$ cat examples/commit-message-1 | gitlint --contrib contrib-title-conventional-commits,CC1
-1: CC1 Body does not contain a 'Signed-Off-By' line
+1: CC1 Body does not contain a 'Signed-off-by' line
1: CL1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test: "WIP: This is the title of a commit message."
# These are the default violations
@@ -20,7 +21,7 @@ $ cat examples/commit-message-1 | gitlint --contrib contrib-title-conventional-c
3: B1 Line exceeds max length (123>80): "Lines typically need to have a max length, meaning that they can't exceed a preset number of characters, usually 80 or 120."
```
-Same thing using a ```.gitlint``` file:
+Same thing using a `.gitlint` file:
```ini
[general]
@@ -36,12 +37,14 @@ types = bugfix,user-story,epic
You can also configure contrib rules using [any of the other ways to configure gitlint](configuration.md).
-# Available Contrib Rules
+## Available Contrib Rules
ID | Name | gitlint version | Description
------|-------------------------------------|------------------ |-------------------------------------------
CT1 | contrib-title-conventional-commits | >= 0.12.0 | Enforces [Conventional Commits](https://www.conventionalcommits.org/) commit message style on the title.
-CC1 | contrib-requires-signed-off-by | >= 0.12.0 | Commit body must contain a `Signed-Off-By` line.
+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 ##
@@ -53,15 +56,28 @@ CT1 | contrib-title-conventional-commits | >= 0.12.0 | Enforces [C
Name | gitlint version | Default | Description
---------------|--------------------|--------------|----------------------------------
-types | >= 0.12.0 | `fix,feat,chore,docs,style,refactor,perf,test,revert` | Comma separated list of allowed commit types.
+types | >= 0.12.0 | `fix,feat,chore,docs,style,refactor,perf,test,revert,ci,build` | Comma separated list of allowed commit types.
-## CC1: contrib-requires-signed-off-by ##
+## CC1: contrib-body-requires-signed-off-by ##
ID | Name | gitlint version | Description
------|---------------------------------------|--------------------|-------------------------------------------
-CC1 | contrib-requires-signed-off-by | >= 0.12.0 | Commit body must contain a `Signed-Off-By` line. This means, a line that starts with the `Signed-Off-By` keyword.
+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
-# Contributing Contrib rules
-We'd love for you to contribute new Contrib rules to gitlint or improve existing ones! Please visit the [Contributing](contributing) page on how to get started. \ No newline at end of file
+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 0cd6eaf..d111bc6 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -3,97 +3,178 @@
We'd love for you to contribute to gitlint. Thanks for your interest!
The [source-code and issue tracker](https://github.com/jorisroovers/gitlint) are hosted on Github.
-Often it takes a while for us (well, actually just [me](https://github.com/jorisroovers)) to get back to you
-(sometimes up to a few months, this is a hobby project), but rest assured that we read your message and appreciate
-your interest!
-We maintain a [loose roadmap on our wiki](https://github.com/jorisroovers/gitlint/wiki/Roadmap), but
-that's open to a lot of change and input.
+!!! note
+ 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 project plan on github projects](https://github.com/users/jorisroovers/projects/1/), but
+ that's open to a lot of change and input.
-# Guidelines
+## Overall Guidelines
When contributing code, please consider all the parts that are typically required:
-- [Unit tests](https://github.com/jorisroovers/gitlint/tree/master/gitlint/tests) (automatically
+- [Unit tests](https://github.com/jorisroovers/gitlint/tree/main/gitlint-core/gitlint/tests) (automatically
[enforced by CI](https://github.com/jorisroovers/gitlint/actions)). Please consider writing
new ones for your functionality, not only updating existing ones to make the build pass.
-- [Integration tests](https://github.com/jorisroovers/gitlint/tree/master/qa) (also automatically
+- [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/master/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.
-# Development #
+!!! important
+ It's 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!
-There is a Vagrantfile in this repository that can be used for development.
-```bash
-vagrant up
-vagrant ssh
-```
+## Releases
+Gitlint releases typically go out when there's either enough new features and fixes
+to make it worthwhile or when there's a critical fix for a bug that fundamentally breaks gitlint.
-Or you can choose to use your local environment:
+While the amount of overhead of doing a release isn't huge, it's also not zero. In practice this means that it might
+take weeks 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.
-```bash
-virtualenv .venv
-pip install -r requirements.txt -r test-requirements.txt -r doc-requirements.txt
-python setup.py develop
-```
+### Dev Builds
+While final releases are usually months apart, we do dev builds on every commit to `main`:
-To run tests:
-```bash
-./run_tests.sh # run unit tests and print test coverage
-./run_test.sh gitlint/tests/test_body_rules.py::BodyRuleTests::test_body_missing # run a single test
-./run_tests.sh --no-coverage # run unit tests without test coverage
-./run_tests.sh --collect-only --no-coverage # Only collect, don't run unit tests
-./run_tests.sh --integration # Run integration tests (requires that you have gitlint installed)
-./run_tests.sh --build # Run build tests (=build python package)
-./run_tests.sh --pep8 # pep8 checks
-./run_tests.sh --stats # print some code stats
-./run_tests.sh --git # inception: run gitlint against itself
-./run_tests.sh --lint # run pylint checks
-./run_tests.sh --all # Run unit, integration, pep8 and gitlint checks
+- **gitlint**: [https://pypi.org/project/gitlint/#history](https://pypi.org/project/gitlint/#history)
+- **gitlint-core**: [https://pypi.org/project/gitlint-core/#history](https://pypi.org/project/gitlint-core/#history)
+It usually takes about 5 min after merging a PR to `main` for new dev builds to show up. Note that the installation
+of a recently published version can still fail for a few minutes after a new version shows up on PyPI while the package
+is replicated to all download mirrors.
+To install a dev build of gitlint:
+```sh
+# Find latest dev build on https://pypi.org/project/gitlint/#history
+pip install gitlint=="0.19.0.dev68"
```
-The ```Vagrantfile``` comes with ```virtualenv```s for python 2.7, 3.5, 3.6, 3.7 and pypy2.
-You can easily run tests against specific python environments by using the following commands *inside* of the Vagrant VM:
+
+## Environment setup
+### Local setup
+
+Gitlint uses [hatch](https://hatch.pypa.io/latest/) for project management.
+You do not need to setup a `virtualenv`, hatch will take care of that for you.
+
+```sh
+pip install hatch
```
-./run_tests.sh --envs 27 # Run the unit tests against Python 2.7
-./run_tests.sh --envs 27,35,pypy2 # Run the unit tests against Python 2.7, Python 3.5 and Pypy2
-./run_tests.sh --envs 27,35 --pep8 # Run pep8 checks against Python 2.7 and Python 3.5 (also works for ```--git```, ```--integration```, ```--pep8```, ```--stats``` and ```--lint```).
-./run_tests.sh --envs all --all # Run all tests against all environments
-./run_tests.sh --all-env --all # Idem: Run all tests against all environments
+
+### 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)
+
+
+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
```
-!!! important
- Gitlint commits and pull requests are gated on all of our tests and checks.
+## Running tests
+```sh
+# Gitlint
+hatch run dev:gitlint # run the local source copy of gitlint
+hatch run dev:gitlint --version # This is just the gitlint binary, any flag will work
+hatch run dev:gitlint --debug
-# Packaging #
+# Unit tests
+hatch run test:unit-tests # run unit tests
+hatch run test:unit-tests gitlint-core/gitlint/tests/rules/test_body_rules.py::BodyRuleTests::test_body_missing # run a single test
+hatch run test:unit-tests -k test_body_missing_merge_commit # Run a specific tests using a pytest keyword expression
+hatch run test:unit-tests-no-cov # run unit tests without test coverage
-To see the package description in HTML format
+# Integration tests
+hatch run qa:install-local # One-time install: install the local gitlint source copy for integration testing
+hatch run qa:integration-tests # Run integration tests
+
+# Formatting check (black)
+hatch run test:format # Run formatting checks
+
+# Linting (ruff)
+hatch run test:lint # Run Ruff
+
+# Project stats
+hatch run test:stats
+```
+## Autoformatting and autofixing
+
+We use [black](https://black.readthedocs.io/en/stable/) for code formatting.
+
+```sh
+hatch run test:autoformat # format all python code
+hatch run test:autoformat gitlint-core/gitlint/lint.py # format a specific file
```
-pip install docutils
-export LC_ALL=en_US.UTF-8
-export LANG=en_US.UTF-8
-python setup.py --long-description | rst2html.py > output.html
+
+We use [ruff](https://github.com/charliermarsh/ruff) for linting, it can autofix many of the issue it finds
+(although not always perfect).
+```sh
+hatch run test:autofix # Attempt to fix linting issues
```
-# Documentation #
+## Documentation
We use [mkdocs](https://www.mkdocs.org/) for generating our documentation from markdown.
-To use it, do the following outside of the vagrant box (on your host machine):
-```bash
-pip install -r doc-requirements.txt # install doc requirements
-mkdocs serve
+To use it:
+```sh
+hatch run docs:serve
```
-Then access the documentation website on your host machine on [http://localhost:8000]().
+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)
+
+To build the packages locally:
+```sh
+# gitlint
+hatch build
+hatch clean # cleanup
+
+# gitlint-core
+cd gitlint-core
+hatch build
+hatch clean # cleanup
+```
-# Tools #
-We keep a small set of scripts in the ```tools/``` directory:
+## Tools
+We keep a small set of scripts in the `tools/` directory:
```sh
tools/create-test-repo.sh # Create a test git repo in your /tmp directory
@@ -101,27 +182,27 @@ tools/windows/create-test-repo.bat # Windows: create git test repo
tools/windows/run_tests.bat # Windows run unit tests
```
-# Contrib rules
-Since gitlint 0.12.0, we support [Contrib rules](../contrib_rules): community contributed rules that are part of gitlint
+## Contrib rules
+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/master/gitlint/contrib/rules) directory.
-3. **Write unit tests**. The gitlint codebase contains [Contrib rule test files you can copy and modify](https://github.com/jorisroovers/gitlint/tree/master/gitlint/tests/contrib).
-4. **Write documentation**. In particular, you should update the [gitlint/docs/contrib_rules.md](https://github.com/jorisroovers/gitlint/blob/master/docs/contrib_rules.md) file with details on your Contrib rule.
+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).
+4. **Write documentation**. In particular, you should update the [gitlint/docs/contrib_rules.md](https://github.com/jorisroovers/gitlint/blob/main/docs/contrib_rules.md) file with details on your Contrib rule.
5. **Create a Pull Request**: code review typically requires a bit of back and forth. Thanks for your contribution!
-## Contrib rule requirements
+### Contrib rule requirements
If you follow the steps above and follow the existing gitlint conventions wrt naming things, you should already be fairly close to done.
In case you're looking for a slightly more formal spec, here's what gitlint requires of Contrib rules.
-- Since Contrib rules are really just user-defined rules that live within the gitlint code-base, all the [user-rule requirements](../user_defined_rules/#rule-requirements) also apply to Contrib rules.
+- 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.
@@ -129,4 +210,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 b499765..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,
@@ -2412,7 +2412,7 @@
],
[
0.000020,
- "gitlint: \u001b[31mYour commit message contains the above violations.\u001b[0m\r\n"
+ "gitlint: \u001b[31mYour commit message contains violations.\u001b[0m\r\n"
],
[
0.002541,
@@ -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/extra.js b/docs/extra.js
new file mode 100644
index 0000000..4af1fa4
--- /dev/null
+++ b/docs/extra.js
@@ -0,0 +1,5 @@
+document.addEventListener("DOMContentLoaded", function () {
+ document.querySelectorAll("table").forEach(function (table) {
+ table.classList.add("docutils");
+ });
+}); \ No newline at end of file
diff --git a/docs/images/dev-container.png b/docs/images/dev-container.png
new file mode 100644
index 0000000..6cac5a2
--- /dev/null
+++ b/docs/images/dev-container.png
Binary files differ
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="&lt;mxfile&gt;&lt;diagram id=&quot;x7jBp0SZ1TbX-vMHKkT6&quot; name=&quot;Page-1&quot;&gt;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&lt;/diagram&gt;&lt;/mxfile&gt;">
+ <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&gt;=8
+ <br/>
+ <span>
+ arrow&gt;=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&gt;=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: &quot;helvetica&quot; ; 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: &quot;helvetica&quot; ; 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: &quot;helvetica&quot; ; 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: &quot;helvetica&quot; ; 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
new file mode 100644
index 0000000..00d3ec1
--- /dev/null
+++ b/docs/images/gitlint-packages.png
Binary files differ
diff --git a/docs/images/readme-gitlint.png b/docs/images/readme-gitlint.png
new file mode 100644
index 0000000..516c915
--- /dev/null
+++ b/docs/images/readme-gitlint.png
Binary files differ
diff --git a/docs/index.md b/docs/index.md
index 3155b19..b735b6b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,4 +1,4 @@
-# Intro
+# Introduction
Gitlint is a git commit message linter written in python: it checks your commit messages for style.
Great for use as a [commit-msg git hook](#using-gitlint-as-a-commit-msg-hook) or as part of your gating script in a
@@ -7,46 +7,55 @@ Great for use as a [commit-msg git hook](#using-gitlint-as-a-commit-msg-hook) or
<script type="text/javascript" src="https://asciinema.org/a/30477.js" id="asciicast-30477" async></script>
!!! note
- **Gitlint support for Windows is experimental**, and [there are some known issues](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows).
+ **Gitlint works on Windows**, but [there are some known issues](https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows).
Also, gitlint is not the only git commit message linter out there, if you are looking for an alternative written in a different language,
have a look at [fit-commit](https://github.com/m1foley/fit-commit) (Ruby),
[node-commit-msg](https://github.com/clns/node-commit-msg) (Node.js) or [commitlint](http://marionebl.github.io/commitlint) (Node.js).
-## Features ##
+
+!!! important
+ **Gitlint requires Python 3.7 (or above). For Python 2.7 and Python 3.5 use `gitlint==0.14.0` (released 2020-10-24), for Python 3.6 `gitlint==0.18.0` (released 2022-11-16).**
+
+## Features
- **Commit message hook**: [Auto-trigger validations against new commit message right when you're committing](#using-gitlint-as-a-commit-msg-hook). Also [works with pre-commit](#using-gitlint-through-pre-commit).
- **Easily integrated**: Gitlint is designed to work [with your own scripts or CI system](#using-gitlint-in-a-ci-environment).
- **Sane defaults:** Many of gitlint's validations are based on
[well-known](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html),
-[community](http://addamhardy.com/2013/06/05/good-commit-messages-and-enforcing-them-with-git-hooks.html),
+[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).
- - **Broad python version support:** Gitlint supports python versions 2.7, 3.5+, PyPy2 and PyPy3.5.
- **Full unicode support:** Lint your Russian, Chinese or Emoji commit messages with ease!
- **Production-ready:** Gitlint checks a lot of the boxes you're looking for: actively maintained, high unit test coverage, integration tests,
- python code standards (pep8, pylint), good documentation, widely used, proven track record.
+ python code standards ([black](https://github.com/psf/black), [ruff](https://github.com/charliermarsh/ruff)),
+ good documentation, widely used, proven track record.
-# Getting Started
-## Installation
-```bash
+## Getting Started
+### Installation
+```sh
# Pip is recommended to install the latest version
pip install gitlint
-# macOS
-brew tap rockyluke/devops
-brew 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
-# Ubuntu
-apt-get install gitlint
+# Community maintained packages:
+brew install gitlint # Homebrew (macOS)
+sudo port install gitlint # Macports (macOS)
+apt-get install gitlint # Ubuntu
+# Other package managers, see https://repology.org/project/gitlint/versions
# Docker: https://hub.docker.com/r/jorisroovers/gitlint
-docker run -v $(pwd):/repo jorisroovers/gitlint
+docker run --ulimit nofile=1024 -v $(pwd):/repo jorisroovers/gitlint
+# NOTE: --ulimit is required to work around a limitation in Docker
+# Details: https://github.com/jorisroovers/gitlint/issues/129
```
-## Usage
+### Usage
```sh
# Check the last commit message
gitlint
@@ -64,7 +73,7 @@ gitlint install-hook
```
Output example:
-```bash
+```sh
$ cat examples/commit-message-2 | gitlint
1: T1 Title exceeds max length (134>80): "This is the title of a commit message that is over 80 characters and contains hard tabs and trailing whitespace and the word wiping "
1: T2 Title has trailing whitespace: "This is the title of a commit message that is over 80 characters and contains hard tabs and trailing whitespace and the word wiping "
@@ -77,11 +86,24 @@ $ 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).
-# Configuration
+### 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.
-Short example ```.gitlint``` file ([full reference](configuration.md)):
+Short example `.gitlint` file ([full reference](configuration.md)):
```ini
[general]
@@ -89,7 +111,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)
@@ -103,7 +125,7 @@ line-length=123
Example use of flags:
-```bash
+```sh
# Change gitlint's verbosity.
$ gitlint -v
# Ignore certain rules
@@ -129,9 +151,11 @@ Options:
current working directory]
-C, --config FILE Config file location [default: .gitlint]
-c TEXT Config flags in format <rule>.<option>=<value>
- (e.g.: -c T1.line-length=80). Flag can be used
- multiple times to set multiple config values.
- --commits TEXT The range of commits to lint. [default: HEAD]
+ (e.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 (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).
@@ -140,12 +164,14 @@ 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.
- -v, --verbose Verbosity, more v's for more verbose output (e.g.:
- -v, -vv, -vvv). [default: -vvv]
- -s, --silent Silent mode (no output). Takes precedence over -v,
- -vv, -vvv.
+ --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]
+ -s, --silent Silent mode (no output).
+ Takes precedence over -v, -vv, -vvv.
-d, --debug Enable debugging output.
--version Show the version and exit.
--help Show this message and exit.
@@ -154,19 +180,20 @@ Commands:
generate-config Generates a sample gitlint config file.
install-hook Install gitlint as a git commit-msg hook.
lint Lints a git repository [default command]
+ run-hook Runs the gitlint commit-msg hook.
uninstall-hook Uninstall gitlint commit-msg hook.
When no COMMAND is specified, gitlint defaults to 'gitlint lint'.
```
-# Using gitlint as a commit-msg hook ##
+## Using gitlint as a commit-msg hook
_Introduced in gitlint v0.4.0_
-You can also install gitlint as a git ```commit-msg``` hook so that gitlint checks your commit messages automatically
+You can also install gitlint as a git `commit-msg` hook so that gitlint checks your commit messages automatically
after each commit.
-```bash
+```sh
gitlint install-hook
# To remove the hook
gitlint uninstall-hook
@@ -174,13 +201,13 @@ gitlint uninstall-hook
!!! important
- Gitlint cannot work together with an existing hook. If you already have a ```.git/hooks/commit-msg```
- file in your local repository, gitlint will refuse to install the ```commit-msg``` hook. Gitlint will also only
+ Gitlint cannot work together with an existing hook. If you already have a `.git/hooks/commit-msg`
+ file in your local repository, gitlint will refuse to install the `commit-msg` hook. Gitlint will also only
uninstall unmodified commit-msg hooks that were installed by gitlint.
If you're looking to use gitlint in conjunction with other hooks, you should consider
[using gitlint with pre-commit](#using-gitlint-through-pre-commit).
-# Using gitlint through [pre-commit](https://pre-commit.com)
+## Using gitlint through [pre-commit](https://pre-commit.com)
`gitlint` can be configured as a plugin for the `pre-commit` git hooks
framework. Simply add the configuration to your `.pre-commit-config.yaml`:
@@ -198,10 +225,10 @@ pre-commit install --hook-type commit-msg
```
!!! important
- It's important that you run ```pre-commit install --hook-type commit-msg```, even if you've already used
- ```pre-commit install``` before. ```pre-commit install``` does **not** install commit-msg hooks by default!
+ It's important that you run `pre-commit install --hook-type commit-msg`, even if you've already used
+ `pre-commit install` before. `pre-commit install` does **not** install commit-msg hooks by default!
-To manually trigger gitlint using ```pre-commit``` for your last commit message, use the following command:
+To manually trigger gitlint using `pre-commit` for your last commit message, use the following command:
```sh
pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
```
@@ -211,16 +238,49 @@ 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
- stages: [commit-msg]
- entry: gitlint
args: [--contrib=CT1, --msg-filename]
```
-# Using gitlint in a CI environment ##
-By default, when just running ```gitlint``` without additional parameters, gitlint lints the last commit in the current
+!!! important
+
+ 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: # insert ref, e.g. v0.18.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.
This makes it easy to use gitlint in a CI environment (Jenkins, TravisCI, Github Actions, pre-commit, CircleCI, Gitlab, etc).
@@ -229,59 +289,76 @@ In fact, this is exactly what we do ourselves: on every commit,
This will cause the build to fail when we submit a bad commit message.
Alternatively, gitlint will also lint any commit message that you feed it via stdin like so:
-```bash
+```sh
# lint the last commit message
git log -1 --pretty=%B | gitlint
# lint a specific commit: 62c0519
git log -1 --pretty=%B 62c0519 | gitlint
```
-Note that gitlint requires that you specify ```--pretty=%B``` (=only print the log message, not the metadata),
-future versions of gitlint might fix this and not require the ```--pretty``` argument.
+Note that gitlint requires that you specify `--pretty=%B` (=only print the log message, not the metadata),
+future versions of gitlint might fix this and not require the `--pretty` argument.
-## Linting a range of commits ##
+## Linting specific commits or branches
-_Introduced in gitlint v0.9.0 (experimental in v0.8.0)_
+Gitlint can lint specific commits using `--commit`:
+```sh
+gitlint --commit 019cf40580a471a3958d3c346aa8bfd265fe5e16
+gitlint --commit 019cf40 # short SHAs work too
+gitlint --commit HEAD~2 # as do special references
+gitlint --commit mybranch # lint latest commit on a branch
+```
-Gitlint allows users to commit a number of commits at once like so:
+You can also lint multiple commits using `--commits` (plural):
-```bash
+```sh
# Lint a specific commit range:
gitlint --commits "019cf40...d6bc75a"
-# You can also use git's special references:
-gitlint --commits "origin..HEAD"
-# Or specify a single specific commit in refspec format, like so:
-gitlint --commits "019cf40^...019cf40"
+# 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/main..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
+# You can also lint a single commit with --commits:
+gitling --commits 019cf40,
```
-The ```--commits``` flag takes a **single** refspec argument or commit range. Basically, any range that is understood
+The `--commits` flag takes a **single** refspec argument or commit range. Basically, any range that is understood
by [git rev-list](https://git-scm.com/docs/git-rev-list) as a single argument will work.
-Prior to v0.8.1 gitlint didn't support this feature. However, older versions of gitlint can still lint a range or set
-of commits at once by creating a simple bash script that pipes the commit messages one by one into gitlint. This
-approach can still be used with newer versions of gitlint in case ```--commits``` doesn't provide the flexibility you
-are looking for.
+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.
+`--commits` also accepts a single commit SHA with a trailing comma.
-```bash
-#!/bin/bash
+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.
-for commit in $(git rev-list master); do
- commit_msg=$(git log -1 --pretty=%B $commit)
- echo "$commit"
- echo "$commit_msg" | gitlint
+```sh
+#!/bin/sh
+
+for commit in $(git rev-list my-branch); do
+ echo "Commit $commit"
+ gitlint --commit $commit
echo "--------"
done
```
!!! note
One downside to this approach is that you invoke gitlint once per commit vs. once per set of commits.
- This means you'll incur the gitlint startup time once per commit, making this approach rather slow if you want to
- lint a large set of commits. Always use ```--commits``` if you can to avoid this performance penalty.
+ 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 and squash commits ##
-_Introduced in gitlint v0.7.0 (merge), v0.9.0 (fixup, squash) and v0.13.0 (revert)_
+## Merge, fixup, squash and revert commits
+_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]"*).
@@ -291,28 +368,26 @@ 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
-```ignore-squash-commits``` option to ```false```
+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).
-# Ignoring commits ##
-_Introduced in gitlint v0.10.0_
+## Ignoring commits
-You can configure gitlint to ignore specific commits.
+You can configure gitlint to ignore specific commits or parts of a commit.
-One way to do this, is to by [adding a gitline-ignore line to your commit message](configuration.md#commit-specific-config).
+One way to do this, is by [adding a gitlint-ignore line to your commit message](configuration.md#commit-specific-config).
If you have a case where you want to ignore a certain type of commits all-together, you can
use gitlint's *ignore* rules.
-Here's an example gitlint file that configures gitlint to ignore rules ```title-max-length``` and ```body-min-length```
-for all commits with a title starting with *"Release"*.
+Here's a few examples snippets from a `.gitlint` file:
```ini
[ignore-by-title]
@@ -326,15 +401,95 @@ ignore=title-max-length,body-min-length
# Match commits message bodies that have a line that contains 'release'
regex=(.*)release(.*)
ignore=all
+
+[ignore-by-author-name]
+# Match commits by author name (e.g. ignore all rules when a commit is made by dependabot)
+regex=dependabot
+ignore=all
```
+If you just want to ignore certain lines in a commit, you can do that using the
+[ignore-body-lines](rules.md#i3-ignore-body-lines) rule.
+
+```ini
+# Ignore all lines that start with 'Co-Authored-By'
+[ignore-body-lines]
+regex=^Co-Authored-By
+```
+
+!!! warning
+
+ When ignoring specific lines, gitlint will no longer be aware of them while applying other rules.
+ This can sometimes be confusing for end-users, especially as line numbers of violations will typically no longer
+ match line numbers in the original commit message. Make sure to educate your users accordingly.
+
!!! note
- Right now it's not possible to write user-defined ignore rules to handle more complex use-cases.
- This is however something that we'd like to implement in a future version. If this is something you're interested in
- please let us know by [opening an issue](https://github.com/jorisroovers/gitlint/issues).
+ If you want to implement more complex ignore rules according to your own logic, you can do so using [user-defined
+ configuration rules](user_defined_rules.md#configuration-rules).
+
+## Named Rules
+
+Introduced in gitlint v0.14.0
+
+Named rules allow you to have multiple of the same rules active at the same time, which allows you to
+enforce the same rule multiple times but with different options. Named rules are so-called because they require an
+additional unique identifier (i.e. the rule *name*) during configuration.
+
+!!! warning
+
+ Named rules is an advanced topic. It's easy to make mistakes by defining conflicting instances of the same rule.
+ 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
+# By adding the following section, you will add a second instance of the
+# title-must-not-contain-word (T5) rule (in addition to the one that is enabled
+# by default) with the name 'extra-words'.
+[title-must-not-contain-word:extra-words]
+words=foo,bar
+
+# So the generic form is
+# [<rule-id-or-name>:<your-chosen-name>]
+# Another example, referencing the rule type by id
+[T5:more-words]
+words=hur,dur
+
+# You can add as many additional rules and you can name them whatever you want
+# The only requirement is that names cannot contain whitespace or colons (:)
+[title-must-not-contain-word:This-Can_Be*Whatever$YouWant]
+words=wonderwoman,batman,power ranger
+```
+
+When executing gitlint, you will see the violations from the default `title-must-not-contain-word (T5)` rule, as well as
+the violations caused by the additional Named Rules.
+
+```sh
+$ 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"
+1: T5:extra-words Title contains the word 'bar' (case-insensitive): "WIP: foo wonderwoman hur bar"
+1: T5:more-words Title contains the word 'hur' (case-insensitive): "WIP: foo wonderwoman hur bar"
+```
+
+Named rules are further treated identical to all other rules in gitlint:
+
+- You can reference them by their full name, when e.g. adding them to your `ignore` configuration
+```ini
+# .gitlint file example
+[general]
+ignore=T5:more-words,title-must-not-contain-word:extra-words
+```
+
+- You can use them to instantiate multiple of the same [user-defined rule](user_defined_rules.md)
+- You can configure them using [any of the ways you can configure regular gitlint rules](configuration.md)
+
-# Exit codes ##
+## Exit codes
Gitlint uses the exit code as a simple way to indicate the number of violations found.
Some exit codes are used to indicate special errors as indicated in the table below.
@@ -344,8 +499,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 173c5b1..a992f26 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -1,170 +1,318 @@
-# Overview #
+# Overview
-The table below shows an overview of all gitlint's built-in rules.
-Note that you can also [write your own user-defined rule](user_defined_rules.md) in case you don't find
+The table below shows an overview of all gitlint's built-in rules, with more specific details further down the page.
+
+Gitlint also has [community **contrib**uted rules](contrib_rules.md) which are not listed here as they're disabled by default.
+
+In addition, you can also [write your own user-defined rule](user_defined_rules.md) in case you don't find
what you're looking for.
-The rest of this page contains details on the available configuration options for each built-in rule.
-
-ID | Name | gitlint version | Description
-------|-----------------------------|-------------------|-------------------------------------------
-T1 | title-max-length | >= 0.1.0 | Title length must be &lt; 72 chars.
-T2 | title-trailing-whitespace | >= 0.1.0 | Title cannot have trailing whitespace (space or tab)
-T3 | title-trailing-punctuation | >= 0.1.0 | Title cannot have trailing punctuation (?:!.,;)
-T4 | title-hard-tab | >= 0.1.0 | Title cannot contain hard tab characters (\t)
-T5 | title-must-not-contain-word | >= 0.1.0 | Title cannot contain certain words (default: "WIP")
-T6 | title-leading-whitespace | >= 0.4.0 | Title cannot have leading whitespace (space or tab)
-T7 | title-match-regex | >= 0.5.0 | Title must match a given regex (default: .*)
-B1 | body-max-line-length | >= 0.1.0 | Lines in the body must be &lt; 80 chars
-B2 | body-trailing-whitespace | >= 0.1.0 | Body cannot have trailing whitespace (space or tab)
-B3 | body-hard-tab | >= 0.1.0 | Body cannot contain hard tab characters (\t)
-B4 | body-first-line-empty | >= 0.1.0 | First line of the body (second line of commit message) must be empty
-B5 | body-min-length | >= 0.4.0 | Body length must be at least 20 characters
-B6 | body-is-missing | >= 0.4.0 | Body message must be specified
-B7 | body-changed-file-mention | >= 0.4.0 | Body must contain references to certain files if those files are changed in the last commit
-M1 | author-valid-email | >= 0.9.0 | Author email address must be a valid email address
-I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title
-I2 | ignore-by-body | >= 0.10.0 | Ignore a commit based on matching its body
-
-## T1: title-max-length ##
-
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T1 | title-max-length | >= 0.1 | Title length must be &lt; 72 chars.
-### Options ###
-Name | gitlint version | Default | Description
----------------|-----------------|---------|----------------------------------
-line-length | >= 0.2 | 72 | Maximum allowed title length
+| ID | Name | gitlint version | Description |
+| --- | --------------------------- | --------------- | ------------------------------------------------------------------------------------------- |
+| T1 | title-max-length | >= 0.1.0 | Title length must be &lt;= 72 chars. |
+| T2 | title-trailing-whitespace | >= 0.1.0 | Title cannot have trailing whitespace (space or tab) |
+| T3 | title-trailing-punctuation | >= 0.1.0 | Title cannot have trailing punctuation (?:!.,;) |
+| T4 | title-hard-tab | >= 0.1.0 | Title cannot contain hard tab characters (\t) |
+| T5 | title-must-not-contain-word | >= 0.1.0 | Title cannot contain certain words (default: "WIP") |
+| T6 | title-leading-whitespace | >= 0.4.0 | Title cannot have leading whitespace (space or tab) |
+| T7 | title-match-regex | >= 0.5.0 | Title must match a given regex (default: None) |
+| T8 | title-min-length | >= 0.14.0 | Title length must be &gt;= 5 chars. |
+| B1 | body-max-line-length | >= 0.1.0 | Lines in the body must be &lt;= 80 chars |
+| B2 | body-trailing-whitespace | >= 0.1.0 | Body cannot have trailing whitespace (space or tab) |
+| B3 | body-hard-tab | >= 0.1.0 | Body cannot contain hard tab characters (\t) |
+| B4 | body-first-line-empty | >= 0.1.0 | First line of the body (second line of commit message) must be empty |
+| B5 | body-min-length | >= 0.4.0 | Body length must be at least 20 characters |
+| B6 | body-is-missing | >= 0.4.0 | Body message must be specified |
+| B7 | body-changed-file-mention | >= 0.4.0 | Body must contain references to certain files if those files are changed in the last commit |
+| 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 &lt;= 72 chars. |
+
+### Options
+
+| Name | gitlint version | Default | Description |
+| ----------- | --------------- | ------- | ---------------------------- |
+| line-length | >= 0.2 | 72 | Maximum allowed title length |
-## T2: title-trailing-whitespace ##
+### Examples
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T2 | title-trailing-whitespace | >= 0.1 | Title cannot have trailing whitespace (space or tab)
+#### .gitlint
+```ini
+# Titles should be max 72 chars
+[title-max-length]
+line-length=72
-## T3: title-trailing-punctuation ##
+# It's the 21st century, titles can be 120 chars long
+[title-max-length]
+line-length=120
+```
+------------------------------------------------------------------------------------------------------------------------
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T3 | title-trailing-punctuation | >= 0.1 | Title cannot have trailing punctuation (?:!.,;)
+## T2: title-trailing-whitespace
+| ID | Name | gitlint version | Description |
+| --- | ------------------------- | --------------- | ---------------------------------------------------- |
+| T2 | title-trailing-whitespace | >= 0.1 | Title cannot have trailing whitespace (space or tab) |
-## T4: title-hard-tab ##
+------------------------------------------------------------------------------------------------------------------------
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T4 | title-hard-tab | >= 0.1 | Title cannot contain hard tab characters (\t)
+## T3: title-trailing-punctuation
+| ID | Name | gitlint version | Description |
+| --- | -------------------------- | --------------- | ----------------------------------------------- |
+| T3 | title-trailing-punctuation | >= 0.1 | Title cannot have trailing punctuation (?:!.,;) |
-## 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")
+## T4: title-hard-tab
-### Options ###
+| ID | Name | gitlint version | Description |
+| --- | -------------- | --------------- | --------------------------------------------- |
+| T4 | title-hard-tab | >= 0.1 | Title cannot contain hard tab characters (\t) |
-Name | gitlint version | Default | Description
----------------|-----------------|---------|----------------------------------
-words | >= 0.3 | WIP | Comma-separated list of words that should not be used in the title. Matching is case insensitive
+------------------------------------------------------------------------------------------------------------------------
-## T6: title-leading-whitespace ##
+## T5: title-must-not-contain-word
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T6 | title-leading-whitespace | >= 0.4 | Title cannot have leading whitespace (space or tab)
+| ID | Name | gitlint version | Description |
+| --- | --------------------------- | --------------- | --------------------------------------------------- |
+| T5 | title-must-not-contain-word | >= 0.1 | Title cannot contain certain words (default: "WIP") |
-## T7: title-match-regex ##
+### Options
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-T7 | title-match-regex | >= 0.5 | Title must match a given regex (default: .*)
+| 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
-### Options ###
+#### .gitlint
-Name | gitlint version | Default | Description
----------------|-----------------|---------|----------------------------------
-regex | >= 0.5 | .* | [Python-style regular expression](https://docs.python.org/3.5/library/re.html) that the title should match.
+```ini
+# Ensure the title doesn't contain swear words
+[title-must-not-contain-word]
+words=crap,darn,damn
+```
+------------------------------------------------------------------------------------------------------------------------
-## B1: body-max-line-length ##
+## T6: title-leading-whitespace
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-B1 | body-max-line-length | >= 0.1 | Lines in the body must be &lt; 80 chars
+| ID | Name | gitlint version | Description |
+| --- | ------------------------ | --------------- | --------------------------------------------------- |
+| T6 | title-leading-whitespace | >= 0.4 | Title cannot have leading whitespace (space or tab) |
-### Options ###
+------------------------------------------------------------------------------------------------------------------------
-Name | gitlint version | Default | Description
----------------|-----------------|---------|----------------------------------
-line-length | >= 0.2 | 80 | Maximum allowed line length in the commit message body
+## T7: title-match-regex
-## B2: body-trailing-whitespace ##
+| ID | Name | gitlint version | Description |
+| --- | ----------------- | --------------- | -------------------------------------------- |
+| T7 | title-match-regex | >= 0.5 | Title must match a given regex (default: .*) |
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-B2 | body-trailing-whitespace | >= 0.1 | Body cannot have trailing whitespace (space or tab)
+### Options
-## B3: body-hard-tab ##
+| Name | gitlint version | Default | Description |
+| ----- | --------------- | ------- | ------------------------------------------------------------------------------------ |
+| regex | >= 0.5 | .* | [Python regex](https://docs.python.org/library/re.html) that the title should match. |
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-B3 | body-hard-tab | >= 0.1 | Body cannot contain hard tab characters (\t)
+### Examples
+#### .gitlint
-## B4: body-first-line-empty ##
+```ini
+# Ensure every title starts with a user-story like US123
+[title-match-regex]
+regex=^US[1-9][0-9]*
+```
+------------------------------------------------------------------------------------------------------------------------
-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
+## T8: title-min-length ##
-## B5: body-min-length ##
+| ID | Name | gitlint version | Description |
+| --- | ---------------- | --------------- | ----------------------------------- |
+| T8 | title-min-length | >= 0.14.0 | Title length must be &gt;= 5 chars. |
-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 ###
+### Options
+
+| Name | gitlint version | Default | Description |
+| ---------- | --------------- | ------- | ----------------------------- |
+| min-length | >= 0.14.0 | 5 | Minimum required title length |
+
+### Examples
-Name | gitlint version | Default | Description
----------------|-----------------|---------|----------------------------------
-min-length | >= 0.4 | 20 | Minimum number of required characters in body
+#### .gitlint
-## B6: body-is-missing ##
+```ini
+# Titles should be min 3 chars
+[title-min-length]
+min-length=3
+```
+------------------------------------------------------------------------------------------------------------------------
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-B6 | body-is-missing | >= 0.4 | Body message must be specified
+## B1: body-max-line-length
+| ID | Name | gitlint version | Description |
+| --- | -------------------- | --------------- | ---------------------------------------- |
+| B1 | body-max-line-length | >= 0.1 | Lines in the body must be &lt;= 80 chars |
-### Options ###
+### 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 |
+| ----------- | --------------- | ------- | ------------------------------------------------------ |
+| line-length | >= 0.2 | 80 | Maximum allowed line length in the commit message body |
-## B7: body-changed-file-mention ##
+### Examples
-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
+#### .gitlint
+
+```ini
+# It's the 21st century, lines can be 120 chars long
+[body-max-line-length]
+line-length=120
+
+# Your tool prefers 72
+[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) |
+
+------------------------------------------------------------------------------------------------------------------------
+
+## B3: body-hard-tab
+
+| ID | Name | gitlint version | Description |
+| --- | ------------- | --------------- | -------------------------------------------- |
+| B3 | body-hard-tab | >= 0.1 | Body cannot contain hard tab characters (\t) |
+
+------------------------------------------------------------------------------------------------------------------------
+
+## B4: body-first-line-empty
+
+| ID | Name | gitlint version | Description |
+| --- | --------------------- | --------------- | -------------------------------------------------------------------- |
+| B4 | body-first-line-empty | >= 0.1 | First line of the body (second line of commit message) must be empty |
+
+------------------------------------------------------------------------------------------------------------------------
+
+## B5: body-min-length
+
+| ID | Name | gitlint version | Description |
+| --- | --------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
+| B5 | body-min-length | >= 0.4 | Body length must be at least 20 characters. In versions >= 0.8.0, gitlint will not count newline characters. |
### Options ###
-Name | gitlint version | Default | Description
-----------------------|-----------------|--------------|----------------------------------
-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 |
+| ---------- | --------------- | ------- | --------------------------------------------- |
+| min-length | >= 0.4 | 20 | Minimum number of required characters in body |
+
+### Examples
+
+#### .gitlint
+
+```ini
+# You want *something* in every commit body, but doesn't have to be as long as 20 chars.
+[body-min-length]
+min-length=5
+
+# You want a more elaborate message in every commit body
+[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 |
+
+
+### Options
+
+| Name | gitlint version | Default | Description |
+| -------------------- | --------------- | ------- | ------------------------------------------------------------------------------------- |
+| ignore-merge-commits | >= 0.4 | true | Whether this rule should be ignored during merge commits. Allowed values: true,false. |
+
+------------------------------------------------------------------------------------------------------------------------
+## B7: body-changed-file-mention
+| ID | Name | gitlint version | Description |
+| --- | ------------------------- | --------------- | ------------------------------------------------------------------------------------------- |
+| B7 | body-changed-file-mention | >= 0.4 | Body must contain references to certain files if those files are changed in the last commit |
-## M1: author-valid-email ##
+### Options
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-M1 | author-valid-email | >= 0.8.3 | Author email address must be a valid email address
+| 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
+
+#### .gitlint
+
+```ini
+# Prevent that certain sensitive files are committed by mistake by forcing
+# users to mention them explicitly if they're deliberately changing them
+[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 |
+
+### Options
+
+| Name | gitlint version | Default | Description |
+| ----- | --------------- | ------- | ----------------------------------------------------------------------------------- |
+| regex | >= 0.14 | None | [Python regex](https://docs.python.org/library/re.html) that the body should match. |
+
+### Examples
+
+#### .gitlint
+
+```ini
+# Ensure the body ends with Reviewed-By: <some value>
+[body-match-regex]
+regex=Reviewed-By:(.*)$
+
+# Ensure body contains the word "Foo" somewhere
+[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 |
!!! 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).
@@ -172,30 +320,37 @@ M1 | author-valid-email | >= 0.8.3 | Author email address mus
-### Options ###
+### Options
-Name | gitlint version | Default | Description
-----------------------|-------------------|------------------------------|----------------------------------
-regex | >= 0.9.0 | ```[^@ ]+@[^@ ]+\.[^@ ]+``` | Regex 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 |
-!!! note
- An often recurring use-case is to only allow email addresses from a certain domain. The following regular expression achieves this: ```[^@]+@foo.com```
+### Examples
+#### .gitlint
-## I1: ignore-by-title ##
+```ini
+# Only allow email addresses from a foo.com domain
+[author-valid-email]
+regex=[^@]+@foo.com
+```
+------------------------------------------------------------------------------------------------------------------------
-ID | Name | gitlint version | Description
-------|-----------------------------|-----------------|-------------------------------------------
-I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title.
+## I1: ignore-by-title
+| ID | Name | gitlint version | Description |
+| --- | --------------- | --------------- | -------------------------------------------- |
+| I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title. |
-### Options ###
-Name | gitlint version | Default | Description
-----------------------|-------------------|------------------------------|----------------------------------
-regex | >= 0.10.0 | None | Regex to match against commit title. On match, the commit will be ignored.
-ignore | >= 0.10.0 | all | Comma-seperated list of rule names or ids to ignore when this rule is matched.
+### 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. |
### Examples
@@ -210,20 +365,21 @@ ignore=title-max-length,body-min-length
# ignore all rules by setting ignore to 'all'
# ignore=all
```
+------------------------------------------------------------------------------------------------------------------------
-## I2: ignore-by-body ##
+## 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 ###
+### Options
-Name | gitlint version | Default | Description
-----------------------|-------------------|------------------------------|----------------------------------
-regex | >= 0.10.0 | None | Regex to match against each line of the body. On match, the commit will be ignored.
-ignore | >= 0.10.0 | all | Comma-seperated list of rule names or ids to ignore when this rule is matched.
+| 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
@@ -240,4 +396,66 @@ ignore=all
[ignore-by-body]
regex=(.*)release(.*)
ignore=T1,body-min-length,B6
-``` \ No newline at end of file
+```
+------------------------------------------------------------------------------------------------------------------------
+
+## 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. |
+
+
+### 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). |
+
+### Examples
+
+#### .gitlint
+
+```ini
+# Ignore all lines that start with 'Co-Authored-By'
+[ignore-body-lines]
+regex=^Co-Authored-By
+
+# Ignore lines that start with 'Co-Authored-By' or with 'Signed-off-by'
+[ignore-body-lines]
+regex=(^Co-Authored-By)|(^Signed-off-by)
+
+# Ignore lines that contain 'foobar'
+[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. |
+
+### 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. |
+
+### Examples
+
+#### .gitlint
+
+```ini
+# Ignore all commits authored by dependabot
+[ignore-by-author-name]
+regex=dependabot
+
+# For commits made by anyone with "[bot]" in their name, ignore
+# rules T1, body-min-length and B6
+[ignore-by-author-name]
+regex=(.*)\[bot\](.*)
+ignore=T1,body-min-length,B6
+```
diff --git a/docs/user_defined_rules.md b/docs/user_defined_rules.md
index a8a51d5..db21809 100644
--- a/docs/user_defined_rules.md
+++ b/docs/user_defined_rules.md
@@ -3,28 +3,29 @@ _Introduced in gitlint v0.8.0_
Gitlint supports the concept of **user-defined** rules: the ability for users to write their own custom rules in python.
-In a nutshell, use ```--extra-path /home/joe/myextensions``` to point gitlint to a ```myextensions``` directory where it will search
+In a nutshell, use `--extra-path /home/joe/myextensions` to point gitlint to a `myextensions` directory where it will search
for python files containing gitlint rule classes. You can also specify a single python module, ie
-```--extra-path /home/joe/my_rules.py```.
+`--extra-path /home/joe/my_rules.py`.
-```bash
+```sh
cat examples/commit-message-1 | gitlint --extra-path examples/
-# Example output of a user-defined Signed-Off-By rule
-1: UC2 Body does not contain a 'Signed-Off-By Line'
+# Example output of a user-defined Signed-off-by rule
+1: UC2 Body does not contain a 'Signed-off-by Line'
# other violations were removed for brevity
```
-The `SignedOffBy` user-defined ```CommitRule``` was discovered by gitlint when it scanned
-[examples/gitlint/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py),
-which is part of the examples directory that was passed via ```--extra-path```:
+The `SignedOffBy` user-defined `CommitRule` was discovered by gitlint when it scanned
+[examples/gitlint/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py),
+which is part of the examples directory that was passed via `--extra-path`:
```python
+# -*- coding: utf-8 -*-
from gitlint.rules import CommitRule, RuleViolation
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".
+ line that starts with "Signed-off-by".
"""
# A rule MUST have a human friendly name
@@ -35,20 +36,22 @@ class SignedOffBy(CommitRule):
id = "UC2"
def validate(self, commit):
+ self.log.debug("SignedOffBy: This will be visible when running `gitlint --debug`")
+
for line in commit.message.body:
- if line.startswith("Signed-Off-By"):
+ if line.startswith("Signed-off-by"):
return
- msg = "Body does not contain a 'Signed-Off-By' line"
+ msg = "Body does not contain a 'Signed-off-by' line"
return [RuleViolation(self.id, msg, line_nr=1)]
```
-As always, ```--extra-path``` can also be set by adding it under the ```[general]``` section in your ```.gitlint``` file or using
+As always, `--extra-path` can also be set by adding it under the `[general]` section in your `.gitlint` file or using
[one of the other ways to configure gitlint](configuration.md).
-If you want to check whether your rules are properly discovered by gitlint, you can use the ```--debug``` flag:
+If you want to check whether your rules are properly discovered by gitlint, you can use the `--debug` flag:
-```bash
+```sh
$ gitlint --debug --extra-path examples/
# [output cut for brevity]
UC1: body-max-line-count
@@ -60,40 +63,41 @@ $ gitlint --debug --extra-path examples/
!!! Note
In most cases it's really the easiest to just copy an example from the
- [examples](https://github.com/jorisroovers/gitlint/tree/master/examples) directory and modify it to your needs.
+ [examples](https://github.com/jorisroovers/gitlint/tree/main/examples) directory and modify it to your needs.
The remainder of this page contains the technical details, mostly for reference.
-# Line and Commit Rules ##
-The ```SignedOffBy``` class above was an example of a user-defined ```CommitRule```. Commit rules are gitlint rules that
+## Line and Commit Rules
+The `SignedOffBy` class above was an example of a user-defined `CommitRule`. Commit rules are gitlint rules that
act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
to the entire commit. This happens exactly once per commit.
-A ```CommitRule``` contrasts with a ```LineRule```
-(see e.g.: [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_line_rules.py))
-in that a ```CommitRule``` is only applied once on an entire commit while a ```LineRule``` is applied for every line in the commit
-(you can also apply it once to the title using a ```target``` - see the examples section below).
+A `CommitRule` contrasts with a `LineRule`
+(see e.g.: [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_line_rules.py))
+in that a `CommitRule` is only applied once on an entire commit while a `LineRule` is applied for every line in the commit
+(you can also apply it once to the title using a `target` - see the examples section below).
The benefit of a commit rule is that it allows commit rules to implement more complex checks that span multiple lines and/or checks
that should only be done once per commit.
-While every ```LineRule``` can be implemented as a ```CommitRule```, it's usually easier and more concise to go with a ```LineRule``` if
+While every `LineRule` can be implemented as a `CommitRule`, it's usually easier and more concise to go with a `LineRule` if
that fits your needs.
-## Examples ##
+### Examples
-In terms of code, writing your own ```CommitRule``` or ```LineRule``` is very similar.
-The only 2 differences between a ```CommitRule``` and a ```LineRule``` are the parameters of the ```validate(...)``` method and the extra
-```target``` attribute that ```LineRule``` requires.
+In terms of code, writing your own `CommitRule` or `LineRule` is very similar.
+The only 2 differences between a `CommitRule` and a `LineRule` are the parameters of the `validate(...)` method and the extra
+`target` attribute that `LineRule` requires.
-Consider the following ```CommitRule``` that can be found in [examples/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py):
+Consider the following `CommitRule` that can be found in [examples/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py):
```python
+# -*- coding: utf-8 -*-
from gitlint.rules import CommitRule, RuleViolation
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".
+ line that starts with "Signed-off-by".
"""
# A rule MUST have a human friendly name
@@ -104,18 +108,21 @@ class SignedOffBy(CommitRule):
id = "UC2"
def validate(self, commit):
+ self.log.debug("SignedOffBy: This will be visible when running `gitlint --debug`")
+
for line in commit.message.body:
- if line.startswith("Signed-Off-By"):
- return []
+ if line.startswith("Signed-off-by"):
+ return
- msg = "Body does not contain a 'Signed-Off-By Line'"
+ msg = "Body does not contain a 'Signed-off-by' line"
return [RuleViolation(self.id, msg, line_nr=1)]
```
-Note the use of the ```name``` and ```id``` class attributes and the ```validate(...)``` method taking a single ```commit``` parameter.
+Note the use of the `name` and `id` class attributes and the `validate(...)` method taking a single `commit` parameter.
-Contrast this with the following ```LineRule``` that can be found in [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/master/examples/my_line_rules.py):
+Contrast this with the following `LineRule` that can be found in [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_line_rules.py):
```python
+# -*- coding: utf-8 -*-
from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle
from gitlint.options import ListOption
@@ -138,81 +145,91 @@ class SpecialChars(LineRule):
options_spec = [ListOption('special-chars', ['$', '^', '%', '@', '!', '*', '(', ')'],
"Comma separated list of characters that should not occur in the title")]
- def validate(self, line, commit):
+ def validate(self, line, _commit):
+ self.log.debug("SpecialChars: This will be visible when running `gitlint --debug`")
+
violations = []
- # option values can be accessed via self.options
+ # options can be accessed by looking them up by their name in self.options
for char in self.options['special-chars'].value:
if char in line:
- violation = RuleViolation(self.id, "Title contains the special character '{}'".format(char), line)
+ msg = f"Title contains the special character '{char}'"
+ violation = RuleViolation(self.id, msg, line)
violations.append(violation)
return violations
+
```
Note the following 2 differences:
-- **extra ```target``` class attribute**: in this example set to ```CommitMessageTitle``` indicating that this ```LineRule```
-should only be applied once to the commit message title. The alternative value for ```target``` is ```CommitMessageBody```,
+- **extra `target` class attribute**: in this example set to `CommitMessageTitle` indicating that this `LineRule`
+should only be applied once to the commit message title. The alternative value for `target` is `CommitMessageBody`,
in which case gitlint will apply
your rule to **every** line in the commit message body.
-- **```validate(...)``` takes 2 parameters**: Line rules get the ```line``` against which they are applied as the first parameter and
-the ```commit``` object of which the line is part of as second.
+- **`validate(...)` takes 2 parameters**: Line rules get the `line` against which they are applied as the first parameter and
+the `commit` object of which the line is part of as second.
-In addition, you probably also noticed the extra ```options_spec``` class attribute which allows you to make your rules configurable.
-Options are not unique to ```LineRule```s, they can also be used by ```CommitRule```s and are further explained in the
+In addition, you probably also noticed the extra `options_spec` class attribute which allows you to make your rules configurable.
+Options are not unique to `LineRule`s, they can also be used by `CommitRule`s and are further explained in the
[Options](user_defined_rules.md#options) section below.
-# The commit object ##
-Both ```CommitRule```s and ```LineRule```s take a ```commit``` object in their ```validate(...)``` methods.
+## The commit object
+Both `CommitRule`s and `LineRule`s take a `commit` object in their `validate(...)` methods.
The table below outlines the various attributes of that commit object that can be used during validation.
-Property | Type | Description
--------------------------------| ---------------|-------------------
-commit.message | object | Python object representing the commit message
-commit.message.original | string | Original commit message as returned by git
-commit.message.full | string | Full commit message, with comments (lines starting with #) removed.
-commit.message.title | string | Title/subject of the commit message: the first line
-commit.message.body | string[] | List of lines in the body of the commit message (i.e. starting from the second line)
-commit.author_name | string | Name of the author, result of ```git log --pretty=%aN```
-commit.author_email | string | Email of the author, result of ```git log --pretty=%aE```
-commit.date | datetime | Python ```datetime``` object representing the time of commit
-commit.is_merge_commit | boolean | Boolean indicating whether the commit is a merge commit or not.
-commit.is_revert_commit | boolean | Boolean indicating whether the commit is a revert commit or not.
-commit.is_fixup_commit | boolean | Boolean indicating whether the commit is a fixup commit or not.
-commit.is_squash_commit | boolean | Boolean indicating whether the commit is a squash commit or not.
-commit.parents | string[] | List of parent commit ```sha```s (only for merge commits).
-commit.changed_files | string[] | List of files changed in the commit (relative paths).
-commit.branches | string[] | List of branch names the commit is part of
-commit.context | object | Object pointing to the bigger git context that the commit is part of
-commit.context.current_branch | string | Name of the currently active branch (of local repo)
-commit.context.repository_path | string | Absolute path pointing to the git repository being linted
-commit.context.commits | object[] | List of commits gitlint is acting on, NOT all commits in the repo.
-
-# Violations ##
-In order to let gitlint know that there is a violation in the commit being linted, users should have the ```validate(...)```
-method in their rules return a list of ```RuleViolation```s.
+| 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(...)`
+method in their rules return a list of `RuleViolation`s.
!!! important
- The ```validate(...)``` method doesn't always need to return a list, you can just skip the return statement in case there are no violations.
+ The `validate(...)` method doesn't always need to return a list, you can just skip the return statement in case there are no violations.
However, in case of a single violation, validate should return a **list** with a single item.
-The ```RuleViolation``` class has the following generic signature:
+The `RuleViolation` class has the following generic signature:
-```
+```python
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:
+A typical `validate(...)` implementation for a `CommitRule` would then be as follows:
```python
def validate(self, commit)
for line_nr, line in commit.message.body:
@@ -222,16 +239,17 @@ def validate(self, commit)
return []
```
-The parameters of this ```RuleViolation``` can be directly mapped onto gitlint's output as follows:
+The parameters of this `RuleViolation` can be directly mapped onto gitlint's output as follows:
![How Rule violations map to gitlint output](images/RuleViolation.png)
-# Options ##
+## Options
-In order to make your own rules configurable, you can add an optional ```options_spec``` attribute to your rule class
-(supported for both ```LineRule``` and ```CommitRule```).
+In order to make your own rules configurable, you can add an optional `options_spec` attribute to your rule class
+(supported for both `LineRule` and `CommitRule`).
```python
+# -*- coding: utf-8 -*-
from gitlint.rules import CommitRule, RuleViolation
from gitlint.options import IntOption
@@ -250,63 +268,148 @@ class BodyMaxLineCount(CommitRule):
line_count = len(commit.message.body)
max_line_count = self.options['max-line-count'].value
if line_count > max_line_count:
- message = "Body contains too many lines ({0} > {1})".format(line_count,
- max_line_count)
+ message = f"Body contains too many lines ({line_count} > {max_line_count})"
return [RuleViolation(self.id, message, line_nr=1)]
```
-By using ```options_spec```, you make your option available to be configured through a ```.gitlint``` file
+By using `options_spec`, you make your option available to be configured through a `.gitlint` file
or one of the [other ways to configure gitlint](configuration.md). Gitlint automatically takes care of the parsing and input validation.
-For example, to change the value of the ```max-line-count``` option, add the following to your ```.gitlint``` file:
+For example, to change the value of the `max-line-count` option, add the following to your `.gitlint` file:
```ini
[body-max-line-count]
body-max-line-count=1
```
-As ```options_spec``` is a list, you can obviously have multiple options per rule. The general signature of an option is:
-```Option(name, default_value, description)```.
+As `options_spec` is a list, you can obviously have multiple options per rule. The general signature of an option is:
+`Option(name, default_value, description)`.
-Gitlint supports a variety of different option types, all can be imported from ```gitlint.options```:
+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```).
+| 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).
[We could use a hand getting those implemented](contributing.md)!
-# Rule requirements ##
+## Configuration Rules
+
+_Introduced in gitlint v0.14.0_
+
+Configuration rules are special rules that are applied once per commit and *BEFORE* any other rules are run.
+Configuration rules are meant to dynamically change gitlint's configuration and/or the commit that is about to be
+linted.
+A typically use-case for this is when you want to modifying gitlint's behavior for all rules against a commit matching
+specific circumstances.
+
+!!! warning
+ Configuration rules can drastically change the way gitlint behaves and are typically only needed for more advanced
+ use-cases. We recommend you double check:
+
+ 1. Whether gitlint already supports your use-case out-of-the-box (special call-out for [ignore rules](rules.md#i1-ignore-by-title) which allow you to ignore (parts) of your commit message).
+ 2. Whether there's a [Contrib Rule](contrib_rules.md) that implements your use-case.
+ 3. Whether you can implement your use-case using a regular Commit or Line user-defined rule (see above).
+
+
+As with other user-defined rules, the easiest way to get started is by copying [`my_configuration.py` from the examples directory](https://github.com/jorisroovers/gitlint/tree/main/examples/my_configuration_rules.py) and modifying it to fit your need.
+
+```python
+# -*- coding: utf-8 -*-
+from gitlint.rules import ConfigurationRule
+from gitlint.options import IntOption
+
+class ReleaseConfigurationRule(ConfigurationRule):
+ """
+ This rule will modify gitlint's behavior for Release Commits.
+
+ This example might not be the most realistic for a real-world scenario,
+ but is meant to give an overview of what's possible.
+ """
+
+ # A rule MUST have a human friendly name
+ name = "release-configuration-rule"
+
+ # A rule MUST have a *unique* id, we recommend starting with UCR
+ # (for User-defined Configuration-Rule), but this can really be anything.
+ 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")]
+
+ def apply(self, config, commit):
+ self.log.debug("ReleaseConfigurationRule: This will be visible when running `gitlint --debug`")
+
+ # 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")
+
+ # Similarly, the body lines might exceed 80 chars,
+ # let's set gitlint's limit to 200
+ # To set rule options use:
+ # config.set_rule_option(<rule-name>, <rule-option>, <value>)
+ config.set_rule_option("body-max-line-length", "line-length", 200)
+
+ # For kicks, let's set gitlint's verbosity to 2
+ # To set general options use
+ # config.set_general_option(<general-option>, <value>)
+ config.set_general_option("verbosity", 2)
+ # We can also use custom options to make this configurable
+ 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
+ # NOT modify your actual commit in git)
+ commit.message.body = [line for line in commit.message.body if not line.startswith("$")]
+
+ # You can add any extra properties you want to the commit object,
+ # these will be available later on in all rules.
+ commit.my_property = "This is my property"
+```
+
+For all available properties and methods on the `config` object, have a look at the
+[LintConfig class](https://github.com/jorisroovers/gitlint/blob/main/gitlint-core/gitlint/config.py). Please do not use any
+properties or methods starting with an underscore, as those are subject to change.
+
+
+## Rule requirements
As long as you stick with simple rules that are similar to the sample user-defined rules (see the
-[examples](https://github.com/jorisroovers/gitlint/blob/master/examples/my_commit_rules.py) directory), gitlint
+[examples](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py) directory), gitlint
should be able to discover and execute them. While clearly you can run any python code you want in your rules,
you might run into some issues if you don't follow the conventions that gitlint requires.
-While the [rule finding source-code](https://github.com/jorisroovers/gitlint/blob/master/gitlint/rule_finder.py) is the
+While the [rule finding source-code](https://github.com/jorisroovers/gitlint/blob/main/gitlint-core/gitlint/rule_finder.py) is the
ultimate source of truth, here are some of the requirements that gitlint enforces.
-## Rule class requirements ###
+### Rule class requirements
-- Rules **must** extend from ```LineRule``` or ```CommitRule```
-- Rule classes **must** have ```id``` and ```name``` string attributes. The ```options_spec``` is optional,
+- Rules **must** extend from `LineRule`, `CommitRule` or `ConfigurationRule`
+- Rule classes **must** have `id` and `name` string attributes. The `options_spec` is optional,
but if set, it **must** be a list of gitlint Options.
-- Rule classes **must** have a ```validate``` method. In case of a ```CommitRule```, ```validate``` **must** take a single ```commit``` parameter.
- In case of ```LineRule```, ```validate``` **must** take ```line``` and ```commit``` as first and second parameters.
-- LineRule classes **must** have a ```target``` class attributes that is set to either ```CommitMessageTitle``` or ```CommitMessageBody```.
-- User Rule id's **cannot** start with ```R```, ```T```, ```B``` or ```M``` as these rule ids are reserved for gitlint itself.
-- Rules **should** have a case-insensitive unique id as only one rule can exist with a given id. While gitlint does not enforce this, having multiple rules with
- the same id might lead to unexpected or undeterministic behavior.
-
-## extra-path requirements ###
-- If ```extra-path``` is a directory, it does **not** need to be a proper python package, i.e. it doesn't require an ```__init__.py``` file.
-- Python files containing user-defined rules must have a ```.py``` extension. Files with a different extension will be ignored.
-- The ```extra-path``` will be searched non-recursively, i.e. all rule classes must be present at the top level ```extra-path``` directory.
-- User rule classes must be defined in the modules that are part of ```extra-path```, rules that are imported from outside the ```extra-path``` will be ignored.
+- `CommitRule` and `LineRule` classes **must** have a `validate` method.
+- In case of a `CommitRule`, `validate` **must** take a single `commit` parameter.
+- In case of `LineRule`, `validate` **must** take `line` and `commit` as first and second parameters.
+- `ConfigurationRule` classes **must** have an `apply` method that take `config` and `commit` as first and second parameters.
+- LineRule classes **must** have a `target` class attributes that is set to either `CommitMessageTitle` or `CommitMessageBody`.
+- User Rule id's **cannot** start with `R`, `T`, `B`, `M` or `I` as these rule ids are reserved for gitlint itself.
+- Rules **should** have a case-insensitive unique id as only one rule can exist with a given id. While gitlint does not
+ enforce this, having multiple rules with the same id might lead to unexpected or undeterministic behavior.
+
+### extra-path requirements
+- If `extra-path` is a directory, it does **not** need to be a proper python package, i.e. it doesn't require an `__init__.py` file.
+- Python files containing user-defined rules must have a `.py` extension. Files with a different extension will be ignored.
+- The `extra-path` will be searched non-recursively, i.e. all rule classes must be present at the top level `extra-path` directory.
+- User rule classes must be defined in the modules that are part of `extra-path`, rules that are imported from outside the `extra-path` will be ignored.
diff --git a/examples/commit-message-11 b/examples/commit-message-11
new file mode 100644
index 0000000..72c7fa8
--- /dev/null
+++ b/examples/commit-message-11
@@ -0,0 +1,7 @@
+Release: Holy Smokes, Batman!
+
+This release contains a bunch of features.
+
+- Here's a description of a feature that exceeds the default maximum line length of 80 characters
+
+$ my-fancy-tool: this line is auto-generated by our release tool and is always too long $ \ No newline at end of file
diff --git a/examples/gitlint b/examples/gitlint
index b722023..0261752 100644
--- a/examples/gitlint
+++ b/examples/gitlint
@@ -55,4 +55,4 @@ ignore-merge-commits=false
# This is useful for when developers often erroneously edit certain files or git submodules.
# By specifying this rule, developers can only change the file when they explicitly reference
# it in the commit message.
-files=gitlint/rules.py,README.md
+files=gitlint-core/gitlint/rules.py,README.md
diff --git a/examples/my_commit_rules.py b/examples/my_commit_rules.py
index e12e02d..35bb836 100644
--- a/examples/my_commit_rules.py
+++ b/examples/my_commit_rules.py
@@ -1,9 +1,9 @@
from gitlint.rules import CommitRule, RuleViolation
from gitlint.options import IntOption, ListOption
-from gitlint import utils
-
"""
+Full details on user-defined rules: https://jorisroovers.com/gitlint/user_defined_rules
+
The classes below are examples of user-defined CommitRules. Commit rules are gitlint rules that
act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
to the entire commit. This happens exactly once per commit.
@@ -25,19 +25,21 @@ 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 = "Body contains too many lines ({0} > {1})".format(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.
- We keep things simple here and just check whether the commit body contains a line that starts with "Signed-Off-By".
+ """This rule will enforce that each commit contains a "Signed-off-by" line.
+ We keep things simple here and just check whether the commit body contains a line that starts with "Signed-off-by".
"""
# A rule MUST have a human friendly name
@@ -47,16 +49,18 @@ class SignedOffBy(CommitRule):
id = "UC2"
def validate(self, commit):
+ self.log.debug("SignedOffBy: This will be visible when running `gitlint --debug`")
+
for line in commit.message.body:
- if line.startswith("Signed-Off-By"):
+ if line.startswith("Signed-off-by"):
return
- return [RuleViolation(self.id, "Body does not contain a 'Signed-Off-By' line", line_nr=1)]
+ return [RuleViolation(self.id, "Body does not contain a 'Signed-off-by' line", line_nr=1)]
class BranchNamingConventions(CommitRule):
- """ This rule will enforce that a commit is part of a branch that meets certain naming conventions.
- See GitFlow for real-world example of this: https://nvie.com/posts/a-successful-git-branching-model/
+ """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
@@ -66,11 +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
@@ -80,8 +86,7 @@ class BranchNamingConventions(CommitRule):
break
if not valid_branch_name:
- msg = "Branch name '{0}' does not start with one of {1}".format(branch,
- utils.sstr(allowed_branch_prefixes))
+ msg = f"Branch name '{branch}' does not start with one of {allowed_branch_prefixes}"
violations.append(RuleViolation(self.id, msg, line_nr=1))
return violations
diff --git a/examples/my_configuration_rules.py b/examples/my_configuration_rules.py
new file mode 100644
index 0000000..7715c0b
--- /dev/null
+++ b/examples/my_configuration_rules.py
@@ -0,0 +1,69 @@
+from gitlint.rules import ConfigurationRule
+from gitlint.options import IntOption
+
+
+"""
+Full details on user-defined rules: https://jorisroovers.com/gitlint/user_defined_rules
+
+The ReleaseConfigurationRule class below is an example of a user-defined ConfigurationRule. Configuration rules are
+gitlint rules that are applied once per commit and BEFORE any other rules are run. Configuration Rules are meant to
+dynamically change gitlint's configuration and/or the commit that is about to be linted. A typically use-case for this
+is modifying the behavior of gitlint's rules based on a commit contents.
+
+Notes:
+- Modifying the commit object DOES NOT modify the actual git commit message in the target repo, only gitlint's copy of
+ it.
+- Modifying the config object only has effect on the commit that is being linted, subsequent commits will not
+ automatically inherit this configuration.
+"""
+
+
+class ReleaseConfigurationRule(ConfigurationRule):
+ """
+ This rule will modify gitlint's behavior for Release Commits.
+
+ This example might not be the most realistic for a real-world scenario,
+ but is meant to give an overview of what's possible.
+ """
+
+ # A rule MUST have a human friendly name
+ name = "release-configuration-rule"
+
+ # A rule MUST have a *unique* id, we recommend starting with UCR
+ # (for User-defined Configuration-Rule), but this can really be anything.
+ 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")]
+
+ def apply(self, config, commit):
+ self.log.debug("ReleaseConfigurationRule: This will be visible when running `gitlint --debug`")
+
+ # 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")
+
+ # Similarly, the body lines might exceed 80 chars,
+ # let's set gitlint's limit to 200
+ # To set rule options use:
+ # config.set_rule_option(<rule-name>, <rule-option>, <value>)
+ config.set_rule_option("body-max-line-length", "line-length", 200)
+
+ # For kicks, let's set gitlint's verbosity to 2
+ # To set general options use
+ # 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)
+
+ # Strip any lines starting with $ from the commit message
+ # (this only affects how gitlint sees your commit message, it does
+ # NOT modify your actual commit in git)
+ commit.message.body = [line for line in commit.message.body if not line.startswith("$")]
+
+ # You can add any extra properties you want to the commit object, these will be available later on
+ # in all rules.
+ commit.my_property = "This is my property"
diff --git a/examples/my_line_rules.py b/examples/my_line_rules.py
index cc69fb9..58b0108 100644
--- a/examples/my_line_rules.py
+++ b/examples/my_line_rules.py
@@ -2,6 +2,8 @@ from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle
from gitlint.options import ListOption
"""
+Full details on user-defined rules: https://jorisroovers.com/gitlint/user_defined_rules
+
The SpecialChars class below is an example of a user-defined LineRule. Line rules are gitlint rules that only act on a
single line at once. Once the rule is discovered, gitlint will automatically take care of applying this rule
against each line of the commit message title or body (whether it is applied to the title or body is determined by the
@@ -17,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"
@@ -31,15 +33,23 @@ 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:
- violation = RuleViolation(self.id, "Title contains the special character '{0}'".format(char), line)
+ msg = f"Title contains the special character '{char}'"
+ violation = RuleViolation(self.id, msg, line)
violations.append(violation)
return violations
diff --git a/gitlint-core/LICENSE b/gitlint-core/LICENSE
new file mode 100644
index 0000000..122bd28
--- /dev/null
+++ b/gitlint-core/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Joris Roovers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/gitlint-core/README.md b/gitlint-core/README.md
new file mode 100644
index 0000000..dfbbe7f
--- /dev/null
+++ b/gitlint-core/README.md
@@ -0,0 +1,26 @@
+# Gitlint-core
+
+# gitlint: [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) #
+
+[![Tests](https://github.com/jorisroovers/gitlint/workflows/Tests%20and%20Checks/badge.svg)](https://github.com/jorisroovers/gitlint/actions?query=workflow%3A%22Tests+and+Checks%22)
+[![Coverage Status](https://coveralls.io/repos/github/jorisroovers/gitlint/badge.svg?branch=fix-coveralls)](https://coveralls.io/github/jorisroovers/gitlint?branch=fix-coveralls)
+[![PyPi Package](https://img.shields.io/pypi/v/gitlint.png)](https://pypi.python.org/pypi/gitlint)
+![Supported Python Versions](https://img.shields.io/pypi/pyversions/gitlint.svg)
+
+Git commit message linter written in python, checks your commit messages for style.
+
+**See [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) for full documentation.**
+
+<a href="http://jorisroovers.github.io/gitlint/" target="_blank">
+<img src="https://raw.githubusercontent.com/jorisroovers/gitlint/main/docs/images/readme-gitlint.png" />
+</a>
+
+## Contributing ##
+All contributions are welcome and very much appreciated!
+
+**I'm [looking for contributors](https://github.com/jorisroovers/gitlint/issues/134) that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please leave a comment in [#134](https://github.com/jorisroovers/gitlint/issues/134) if you're interested!**
+
+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 project plan on Github Projects](https://github.com/users/jorisroovers/projects/1/views/1).
diff --git a/gitlint-core/gitlint/__init__.py b/gitlint-core/gitlint/__init__.py
new file mode 100644
index 0000000..a2339fd
--- /dev/null
+++ b/gitlint-core/gitlint/__init__.py
@@ -0,0 +1,8 @@
+import sys
+
+if sys.version_info >= (3, 8):
+ from importlib import metadata # pragma: nocover
+else:
+ import importlib_metadata as metadata # pragma: nocover
+
+__version__ = metadata.version("gitlint-core")
diff --git a/gitlint-core/gitlint/cache.py b/gitlint-core/gitlint/cache.py
new file mode 100644
index 0000000..a3dd0c8
--- /dev/null
+++ b/gitlint-core/gitlint/cache.py
@@ -0,0 +1,54 @@
+class PropertyCache:
+ """Mixin class providing a simple cache."""
+
+ def __init__(self):
+ self._cache = {}
+
+ def _try_cache(self, cache_key, cache_populate_func):
+ """Tries to get a value from the cache identified by `cache_key`.
+ If no value is found in the cache, do a function call to `cache_populate_func` to populate the cache
+ and then return the value from the cache."""
+ if cache_key not in self._cache:
+ cache_populate_func()
+ return self._cache[cache_key]
+
+
+def cache(original_func=None, cachekey=None):
+ """Cache decorator. Caches function return values.
+ Requires the parent class to extend and initialize PropertyCache.
+ Usage:
+ # Use function name as cache key
+ @cache
+ def myfunc(args):
+ ...
+
+ # Specify cache key
+ @cache(cachekey="foobar")
+ def myfunc(args):
+ ...
+ """
+
+ # Decorators with optional arguments are a bit convoluted in python, see some of the links below for details.
+
+ def cache_decorator(func):
+ # Use 'nonlocal' keyword to access parent function variable:
+ # https://stackoverflow.com/a/14678445/381010
+ nonlocal cachekey
+ if not cachekey:
+ cachekey = func.__name__
+
+ def wrapped(*args):
+ 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
+
+ # To support optional kwargs for decorators, we need to check if a function is passed as first argument or not.
+ # https://stackoverflow.com/a/24617244/381010
+ if original_func:
+ return cache_decorator(original_func)
+
+ return cache_decorator
diff --git a/gitlint-core/gitlint/cli.py b/gitlint-core/gitlint/cli.py
new file mode 100644
index 0000000..619f006
--- /dev/null
+++ b/gitlint-core/gitlint/cli.py
@@ -0,0 +1,499 @@
+import copy
+import logging
+import os
+import platform
+import stat
+import sys
+
+import click
+
+import gitlint
+from gitlint import hooks
+from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator
+from gitlint.deprecation import DEPRECATED_LOG_FORMAT
+from gitlint.deprecation import LOG as DEPRECATED_LOG
+from gitlint.exception import GitlintError
+from gitlint.git import GitContext, GitContextError, git_version
+from gitlint.lint import GitLinter
+from gitlint.shell import shell
+from gitlint.utils import LOG_FORMAT
+
+# Error codes
+GITLINT_SUCCESS = 0
+MAX_VIOLATION_ERROR_CODE = 252
+USAGE_ERROR_CODE = 253
+GIT_CONTEXT_ERROR_CODE = 254
+CONFIG_ERROR_CODE = 255
+
+DEFAULT_CONFIG_FILE = ".gitlint"
+# -n: disable swap files. This fixes a vim error on windows (E303: Unable to open swap file for <path>)
+DEFAULT_COMMIT_MSG_EDITOR = "vim -n"
+
+# Since we use the return code to denote the amount of errors, we need to change the default click usage error code
+click.UsageError.exit_code = USAGE_ERROR_CODE
+
+# We don't use logging.getLogger(__main__) here because that will cause DEBUG output to be lost
+# when invoking gitlint as a python module (python -m gitlint.cli)
+LOG = logging.getLogger("gitlint.cli")
+
+
+class GitLintUsageError(GitlintError):
+ """Exception indicating there is an issue with how gitlint is used."""
+
+
+def setup_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.WARN)
+ handler = logging.StreamHandler()
+ formatter = logging.Formatter(LOG_FORMAT)
+ handler.setFormatter(formatter)
+ root_log.addHandler(handler)
+
+ # 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():
+ LOG.debug("Platform: %s", platform.platform())
+ LOG.debug("Python version: %s", sys.version)
+ LOG.debug("Git version: %s", git_version())
+ LOG.debug("Gitlint version: %s", gitlint.__version__)
+ LOG.debug("GITLINT_USE_SH_LIB: %s", os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]"))
+ LOG.debug("TERMINAL_ENCODING: %s", gitlint.utils.TERMINAL_ENCODING)
+ LOG.debug("FILE_ENCODING: %s", gitlint.utils.FILE_ENCODING)
+
+
+def build_config(
+ 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."""
+ config_builder = LintConfigBuilder()
+ # Config precedence:
+ # First, load default config or config from configfile
+ if config_path:
+ config_builder.set_from_config_file(config_path)
+ elif os.path.exists(DEFAULT_CONFIG_FILE):
+ config_builder.set_from_config_file(DEFAULT_CONFIG_FILE)
+
+ # Then process any commandline configuration flags
+ config_builder.set_config_from_string_list(c)
+
+ # Finally, overwrite with any convenience commandline flags
+ if ignore:
+ config_builder.set_option("general", "ignore", ignore)
+
+ if contrib:
+ config_builder.set_option("general", "contrib", contrib)
+
+ if ignore_stdin:
+ config_builder.set_option("general", "ignore-stdin", ignore_stdin)
+
+ if silent:
+ config_builder.set_option("general", "verbosity", 0)
+ elif verbose > 0:
+ config_builder.set_option("general", "verbosity", verbose)
+
+ if extra_path:
+ config_builder.set_option("general", "extra-path", extra_path)
+
+ if target:
+ config_builder.set_option("general", "target", target)
+
+ if debug:
+ config_builder.set_option("general", "debug", debug)
+
+ if staged:
+ config_builder.set_option("general", "staged", staged)
+
+ if fail_without_commits:
+ config_builder.set_option("general", "fail-without-commits", fail_without_commits)
+
+ config = config_builder.build()
+
+ return config, config_builder
+
+
+def get_stdin_data():
+ """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)
+ # 3. A regular file (stat.S_ISREG)
+ # Technically, STDIN can also be other device type like a named unix socket (stat.S_ISSOCK), but we don't
+ # support that in gitlint (at least not today).
+ #
+ # Now, the behavior that we want is the following:
+ # If someone sends something directly to gitlint via a pipe or a regular file, read it. If not, read from the
+ # local repository.
+ # Note that we don't care about whether STDIN is a TTY or not, we only care whether data is via a pipe or regular
+ # file.
+ # However, in case STDIN is not a TTY, it HAS to be one of the 2 other things (pipe or regular file), even if
+ # no-one is actually sending anything to gitlint over them. In this case, we still want to read from the local
+ # repository.
+ # To support this use-case (which is common in CI runners such as Jenkins and Gitlab), we need to actually attempt
+ # to read from STDIN in case it's a pipe or regular file. In case that fails, then we'll fall back to reading
+ # from the local repo.
+
+ mode = os.fstat(sys.stdin.fileno()).st_mode
+ stdin_is_pipe_or_file = stat.S_ISFIFO(mode) or stat.S_ISREG(mode)
+ if stdin_is_pipe_or_file:
+ input_data = sys.stdin.read()
+ # Only return the input data if there's actually something passed
+ # i.e. don't consider empty piped data
+ if input_data:
+ return str(input_data)
+ return False
+
+
+def build_git_context(lint_config, msg_filename, commit_hash, refspec):
+ """Builds a git context based on passed parameters and order of precedence"""
+
+ # Determine which GitContext method to use if a custom message is passed
+ from_commit_msg = GitContext.from_commit_msg
+ if lint_config.staged:
+ LOG.debug("Fetching additional meta-data from staged commit")
+
+ def from_commit_msg(message):
+ return GitContext.from_staged_commit(message, lint_config.target)
+
+ # Order of precedence:
+ # 1. Any data specified via --msg-filename
+ if msg_filename:
+ LOG.debug("Using --msg-filename.")
+ return from_commit_msg(str(msg_filename.read()))
+
+ # 2. Any data sent to stdin (unless stdin is being ignored)
+ if not lint_config.ignore_stdin:
+ stdin_input = get_stdin_data()
+ if stdin_input:
+ LOG.debug("Stdin data: '%s'", stdin_input)
+ LOG.debug("Stdin detected and not ignored. Using as input.")
+ return from_commit_msg(stdin_input)
+
+ if lint_config.staged:
+ raise GitLintUsageError(
+ "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.")
+
+ if commit_hash and refspec:
+ raise GitLintUsageError("--commit and --commits are mutually exclusive, use one or the other.")
+
+ # 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(",") if hash]
+ 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"""
+ if isinstance(exc, GitContextError):
+ click.echo(exc)
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+ elif isinstance(exc, GitLintUsageError):
+ click.echo(f"Error: {exc}")
+ ctx.exit(USAGE_ERROR_CODE)
+ elif isinstance(exc, LintConfigError):
+ click.echo(f"Config Error: {exc}")
+ ctx.exit(CONFIG_ERROR_CODE)
+
+
+class ContextObj:
+ """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
+ self.config_builder = config_builder
+ self.commit_hash = commit_hash
+ self.refspec = refspec
+ self.msg_filename = msg_filename
+ 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", 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.")
+@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 (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(encoding=gitlint.utils.FILE_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="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,
+ help="Verbosity, use multiple times for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv]", )
+@click.option("-s", "--silent", envvar="GITLINT_SILENT", is_flag=True,
+ help="Silent mode (no output). Takes precedence over -v, -vv, -vvv.")
+@click.option("-d", "--debug", envvar="GITLINT_DEBUG", help="Enable debugging output.", is_flag=True)
+@click.version_option(version=gitlint.__version__)
+@click.pass_context
+def cli(
+ ctx, target, config, c, commit, commits, extra_path, ignore, contrib,
+ msg_filename, ignore_stdin, staged, fail_without_commits, verbose,
+ silent, debug,
+):
+ """ Git lint tool, checks your git commit messages for styling issues
+
+ Documentation: http://jorisroovers.github.io/gitlint
+ """
+ try:
+ if debug:
+ logging.getLogger("gitlint").setLevel(logging.DEBUG)
+ 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)
+ LOG.debug("Configuration\n%s", config)
+
+ ctx.obj = ContextObj(config, config_builder, commit, commits, msg_filename)
+
+ # If no subcommand is specified, then just lint
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(lint)
+
+ 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]"""
+ lint_config = ctx.obj.config
+ refspec = ctx.obj.refspec
+ commit_hash = ctx.obj.commit_hash
+ msg_filename = ctx.obj.msg_filename
+
+ gitcontext = build_git_context(lint_config, msg_filename, commit_hash, refspec)
+ # Set gitcontext in the click context, so we can use it in command that are ran after this
+ # in particular, this is used by run-hook
+ ctx.obj.gitcontext = gitcontext
+
+ number_of_commits = len(gitcontext.commits)
+ # Exit if we don't have commits in the specified range. Use a 0 exit code, since a popular use-case is one
+ # where users are using --commits in a check job to check the commit messages inside a CI job. By returning 0, we
+ # ensure that these jobs don't fail if for whatever reason the specified commit range is empty.
+ # This behavior can be overridden by using the --fail-without-commits flag.
+ if number_of_commits == 0:
+ LOG.debug('No commits in range "%s"', refspec)
+ if lint_config.fail_without_commits:
+ raise GitLintUsageError(f'No commits in range "{refspec}"')
+ ctx.exit(GITLINT_SUCCESS)
+
+ LOG.debug("Linting %d commit(s)", number_of_commits)
+ general_config_builder = ctx.obj.config_builder
+ last_commit = gitcontext.commits[-1]
+
+ # Let's get linting!
+ first_violation = True
+ exit_code = GITLINT_SUCCESS
+ for commit in gitcontext.commits:
+ # Build a config_builder taking into account the commit specific config (if any)
+ config_builder = general_config_builder.clone()
+ config_builder.set_config_from_commit(commit)
+
+ # Create a deepcopy from the original config, so we have a unique config object per commit
+ # This is important for configuration rules to be able to modifying the config on a per commit basis
+ commit_config = config_builder.build(copy.deepcopy(lint_config))
+
+ # Actually do the linting
+ linter = GitLinter(commit_config)
+ violations = linter.lint(commit)
+ # exit code equals the total number of violations in all commits
+ exit_code += len(violations)
+ if violations:
+ # Display the commit hash & new lines intelligently
+ if number_of_commits > 1 and commit.sha:
+ commit_separator = "\n" if not first_violation or commit is last_commit else ""
+ linter.display.e(f"{commit_separator}Commit {commit.sha[:10]}:")
+ linter.print_violations(violations)
+ first_violation = False
+
+ # cap actual max exit code because bash doesn't like exit codes larger than 255:
+ # http://tldp.org/LDP/abs/html/exitcodes.html
+ exit_code = min(MAX_VIOLATION_ERROR_CODE, exit_code)
+ LOG.debug("Exit Code = %s", exit_code)
+ ctx.exit(exit_code)
+
+
+@cli.command("install-hook")
+@click.pass_context
+def install_hook(ctx):
+ """Install gitlint as a git commit-msg hook."""
+ try:
+ hooks.GitHookInstaller.install_commit_msg_hook(ctx.obj.config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
+ click.echo(f"Successfully installed gitlint commit-msg hook in {hook_path}")
+ ctx.exit(GITLINT_SUCCESS)
+ except hooks.GitHookInstallerError as e:
+ click.echo(e, err=True)
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+
+
+@cli.command("uninstall-hook")
+@click.pass_context
+def uninstall_hook(ctx):
+ """Uninstall gitlint commit-msg hook."""
+ try:
+ hooks.GitHookInstaller.uninstall_commit_msg_hook(ctx.obj.config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
+ click.echo(f"Successfully uninstalled gitlint commit-msg hook from {hook_path}")
+ ctx.exit(GITLINT_SUCCESS)
+ except hooks.GitHookInstallerError as e:
+ click.echo(e, err=True)
+ ctx.exit(GIT_CONTEXT_ERROR_CODE)
+
+
+@cli.command("run-hook")
+@click.pass_context
+def run_hook(ctx):
+ """Runs the gitlint commit-msg hook."""
+
+ exit_code = 1
+ while exit_code > 0:
+ try:
+ click.echo("gitlint: checking commit message...")
+ ctx.invoke(lint)
+ except GitlintError as e:
+ handle_gitlint_error(ctx, e)
+ except click.exceptions.Exit as e:
+ # Flush stderr andstdout, this resolves an issue with output ordering in Cygwin
+ sys.stderr.flush()
+ sys.stdout.flush()
+
+ exit_code = e.exit_code
+ if exit_code == GITLINT_SUCCESS:
+ 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"))
+
+ 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,
+ )
+
+ # 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.
+ # This wouldn't be a huge issue since this is unlikely to occur in the real world,
+ # were it not that we use a stdin to pipe answers into gitlint in our integration tests.
+ # If that ever changes, we can revisit this.
+ # Related click pointers:
+ # - https://github.com/pallets/click/issues/1370
+ # - https://github.com/pallets/click/pull/1372
+ # - From https://click.palletsprojects.com/en/7.x/utils/#getting-characters-from-terminal
+ # Note that this function will always read from the terminal, even if stdin is instead a pipe.
+ value = input()
+
+ if value == "y":
+ LOG.debug("run-hook: commit message accepted")
+ exit_code = GITLINT_SUCCESS
+ elif value == "e":
+ LOG.debug("run-hook: editing commit message")
+ msg_filename = ctx.obj.msg_filename
+ if msg_filename:
+ msg_filename.seek(0)
+ editor = os.environ.get("EDITOR", DEFAULT_COMMIT_MSG_EDITOR)
+ msg_filename_path = os.path.realpath(msg_filename.name)
+ LOG.debug("run-hook: %s %s", editor, msg_filename_path)
+ shell(editor + " " + msg_filename_path)
+ else:
+ click.echo("Editing only possible when --msg-filename is specified.")
+ ctx.exit(exit_code)
+ elif value == "n":
+ LOG.debug("run-hook: commit message declined")
+ click.echo("Commit aborted.")
+ click.echo("Your commit message: ")
+ click.echo("-----------------------------------------------")
+ click.echo(ctx.obj.gitcontext.commits[0].message.full)
+ click.echo("-----------------------------------------------")
+ ctx.exit(exit_code)
+
+ ctx.exit(exit_code)
+
+
+@cli.command("generate-config")
+@click.pass_context
+def generate_config(ctx):
+ """Generates a sample gitlint config file."""
+ path = click.prompt("Please specify a location for the sample gitlint config file", default=DEFAULT_CONFIG_FILE)
+ path = os.path.realpath(path)
+ dir_name = os.path.dirname(path)
+ if not os.path.exists(dir_name):
+ click.echo(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)
+ ctx.exit(USAGE_ERROR_CODE)
+
+ LintConfigGenerator.generate_config(path)
+ click.echo(f"Successfully generated {path}")
+ ctx.exit(GITLINT_SUCCESS)
+
+
+# Let's Party!
+setup_logging()
+if __name__ == "__main__":
+ cli() # pragma: no cover
diff --git a/gitlint-core/gitlint/config.py b/gitlint-core/gitlint/config.py
new file mode 100644
index 0000000..4b38d90
--- /dev/null
+++ b/gitlint-core/gitlint/config.py
@@ -0,0 +1,561 @@
+import copy
+import os
+import re
+import shutil
+from collections import OrderedDict
+from configparser import ConfigParser
+from configparser import Error as ConfigParserError
+
+from gitlint import (
+ options,
+ rule_finder,
+ rules,
+)
+from gitlint.contrib import rules as contrib_rules
+from gitlint.exception import GitlintError
+from gitlint.utils import FILE_ENCODING
+
+
+def handle_option_error(func):
+ """Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a
+ LintConfigError."""
+
+ def wrapped(*args):
+ try:
+ return func(*args)
+ except options.RuleOptionError as e:
+ raise LintConfigError(str(e)) from e
+
+ return wrapped
+
+
+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.
+ """
+
+ # 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,
+ )
+
+ 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_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._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._regex_style_search = options.BoolOption(
+ "regex-style-search", False, "Use `search` instead of `match` semantics for regex rules"
+ )
+
+ @property
+ def target(self):
+ return self._target.value if self._target else None
+
+ @target.setter
+ @handle_option_error
+ def target(self, value):
+ return self._target.set(value)
+
+ @property
+ def verbosity(self):
+ return self._verbosity.value
+
+ @verbosity.setter
+ @handle_option_error
+ def verbosity(self, value):
+ self._verbosity.set(value)
+ if self.verbosity < 0 or self.verbosity > 3: # noqa: PLR2004 (Magic value used in comparison)
+ raise LintConfigError("Option 'verbosity' must be set between 0 and 3")
+
+ @property
+ def ignore_merge_commits(self):
+ return self._ignore_merge_commits.value
+
+ @ignore_merge_commits.setter
+ @handle_option_error
+ def ignore_merge_commits(self, value):
+ return self._ignore_merge_commits.set(value)
+
+ @property
+ def ignore_fixup_commits(self):
+ return self._ignore_fixup_commits.value
+
+ @ignore_fixup_commits.setter
+ @handle_option_error
+ def ignore_fixup_commits(self, value):
+ return self._ignore_fixup_commits.set(value)
+
+ @property
+ def ignore_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
+
+ @ignore_squash_commits.setter
+ @handle_option_error
+ def ignore_squash_commits(self, value):
+ return self._ignore_squash_commits.set(value)
+
+ @property
+ def ignore_revert_commits(self):
+ return self._ignore_revert_commits.value
+
+ @ignore_revert_commits.setter
+ @handle_option_error
+ def ignore_revert_commits(self, value):
+ return self._ignore_revert_commits.set(value)
+
+ @property
+ def debug(self):
+ return self._debug.value
+
+ @debug.setter
+ @handle_option_error
+ def debug(self, value):
+ return self._debug.set(value)
+
+ @property
+ def ignore(self):
+ return self._ignore.value
+
+ @ignore.setter
+ def ignore(self, value):
+ if value == "all":
+ value = [rule.id for rule in self.rules]
+ return self._ignore.set(value)
+
+ @property
+ def ignore_stdin(self):
+ return self._ignore_stdin.value
+
+ @ignore_stdin.setter
+ @handle_option_error
+ def ignore_stdin(self, value):
+ return self._ignore_stdin.set(value)
+
+ @property
+ def staged(self):
+ return self._staged.value
+
+ @staged.setter
+ @handle_option_error
+ def staged(self, value):
+ return self._staged.set(value)
+
+ @property
+ def fail_without_commits(self):
+ return self._fail_without_commits.value
+
+ @fail_without_commits.setter
+ @handle_option_error
+ def fail_without_commits(self, value):
+ 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
+
+ @extra_path.setter
+ def extra_path(self, value):
+ try:
+ if self.extra_path:
+ self._extra_path.set(value)
+ else:
+ self._extra_path = options.PathOption(
+ "extra-path", value, "Path to a directory or module with extra user-defined rules", type="both"
+ )
+
+ # Make sure we unload any previously loaded extra-path rules
+ self.rules.delete_rules_by_attr("is_user_defined", True)
+
+ # Find rules in the new extra-path and add them to the existing rules
+ rule_classes = rule_finder.find_rule_classes(self.extra_path)
+ self.rules.add_rules(rule_classes, {"is_user_defined": True})
+
+ except (options.RuleOptionError, rules.UserRuleError) as e:
+ raise LintConfigError(str(e)) from e
+
+ @property
+ def contrib(self):
+ return self._contrib.value
+
+ @contrib.setter
+ def contrib(self, value):
+ try:
+ self._contrib.set(value)
+
+ # Make sure we unload any previously loaded contrib rules when re-setting the value
+ self.rules.delete_rules_by_attr("is_contrib", True)
+
+ # Load all classes from the contrib directory
+ contrib_dir_path = os.path.dirname(os.path.realpath(contrib_rules.__file__))
+ rule_classes = rule_finder.find_rule_classes(contrib_dir_path)
+
+ # For each specified contrib rule, check whether it exists among the contrib classes
+ for rule_id_or_name in self.contrib:
+ rule_class = next((rc for rc in rule_classes if 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})
+ else:
+ raise LintConfigError(f"No contrib rule with id or name '{rule_id_or_name}' found.")
+
+ except (options.RuleOptionError, rules.UserRuleError) as e:
+ raise LintConfigError(str(e)) from e
+
+ def _get_option(self, rule_name_or_id, option_name):
+ rule = self.rules.find_rule(rule_name_or_id)
+ if not rule:
+ raise LintConfigError(f"No such rule '{rule_name_or_id}'")
+
+ option = rule.options.get(option_name)
+ if not option:
+ raise LintConfigError(f"Rule '{rule_name_or_id}' has no option '{option_name}'")
+
+ return option
+
+ def get_rule_option(self, rule_name_or_id, option_name):
+ """Returns the value of a given option for a given rule. LintConfigErrors will be raised if the
+ rule or option don't exist."""
+ option = self._get_option(rule_name_or_id, option_name)
+ return option.value
+
+ def set_rule_option(self, rule_name_or_id, option_name, option_value):
+ """Attempts to set a given value for a given option for a given rule.
+ LintConfigErrors will be raised if the rule or option don't exist or if the value is invalid."""
+ option = self._get_option(rule_name_or_id, option_name)
+ try:
+ option.set(option_value)
+ except options.RuleOptionError as e:
+ msg = f"'{option_value}' is not a valid value for option '{rule_name_or_id}.{option_name}'. {e}."
+ raise LintConfigError(msg) from e
+
+ def set_general_option(self, option_name, option_value):
+ attr_name = option_name.replace("-", "_")
+ # only allow setting general options that exist and don't start with an underscore
+ if not hasattr(self, attr_name) or attr_name[0] == "_":
+ raise LintConfigError(f"'{option_name}' is not a valid gitlint option")
+
+ # else
+ setattr(self, attr_name, option_value)
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, LintConfig)
+ and self.rules == other.rules
+ and self.verbosity == other.verbosity
+ and self.target == other.target
+ and self.extra_path == other.extra_path
+ and self.contrib == other.contrib
+ and self.ignore_merge_commits == other.ignore_merge_commits
+ and self.ignore_fixup_commits == other.ignore_fixup_commits
+ and self.ignore_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"
+ "[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."""
+
+ def __init__(self, rule_classes=None, rule_attrs=None):
+ # Use an ordered dict so that the order in which rules are applied is always the same
+ self._rules = OrderedDict()
+ if rule_classes:
+ self.add_rules(rule_classes, rule_attrs)
+
+ def find_rule(self, rule_id_or_name):
+ rule = self._rules.get(rule_id_or_name)
+ # if not found, try finding rule by name
+ if not rule:
+ rule = next((rule for rule in self._rules.values() if rule.name == rule_id_or_name), None)
+ return rule
+
+ def add_rule(self, rule_class, rule_id, rule_attrs=None):
+ """Instantiates and adds a rule to RuleCollection.
+ Note: There can be multiple instantiations of the same rule_class in the RuleCollection, as long as the
+ rule_id is unique.
+ :param rule_class python class representing the rule
+ :param rule_id unique identifier for the rule. If not unique, it will
+ overwrite the existing rule with that id
+ :param rule_attrs dictionary of attributes to set on the instantiated rule obj
+ """
+ rule_obj = rule_class()
+ rule_obj.id = rule_id
+ if rule_attrs:
+ for key, val in rule_attrs.items():
+ setattr(rule_obj, key, val)
+ self._rules[rule_obj.id] = rule_obj
+
+ def add_rules(self, rule_classes, rule_attrs=None):
+ """Convenience method to add multiple rules at once based on a list of rule classes."""
+ for rule_class in rule_classes:
+ self.add_rule(rule_class, rule_class.id, rule_attrs)
+
+ def delete_rules_by_attr(self, attr_name, attr_val):
+ """Deletes all rules from the collection that match a given attribute name and value"""
+ # Create a new list based on _rules.values() because in python 3, values() is a ValuesView as opposed to a list
+ # This means you can't modify the ValueView while iterating over it.
+ for rule in list(self._rules.values()):
+ if hasattr(rule, attr_name) and (getattr(rule, attr_name) == attr_val):
+ del self._rules[rule.id]
+
+ def __iter__(self):
+ yield from self._rules.values()
+
+ def __eq__(self, other):
+ return isinstance(other, RuleCollection) and self._rules == other._rules
+
+ def __len__(self):
+ return len(self._rules)
+
+ def __str__(self):
+ return_str = ""
+ for rule in self._rules.values():
+ return_str += f" {rule.id}: {rule.name}\n"
+ for option_name, option_value in sorted(rule.options.items()):
+ if option_value.value is None:
+ option_val_repr = None
+ elif isinstance(option_value.value, list):
+ option_val_repr = ",".join(option_value.value)
+ elif isinstance(option_value, options.RegexOption):
+ option_val_repr = option_value.value.pattern
+ else:
+ option_val_repr = option_value.value
+ return_str += f" {option_name}={option_val_repr}\n"
+ return return_str
+
+
+class LintConfigBuilder:
+ """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.
+ """
+
+ RULE_QUALIFIER_SYMBOL = ":"
+
+ def __init__(self):
+ self._config_blueprint = OrderedDict()
+ self._config_path = None
+
+ def set_option(self, section, option_name, option_value):
+ if section not in self._config_blueprint:
+ self._config_blueprint[section] = OrderedDict()
+ self._config_blueprint[section][option_name] = option_value
+
+ def set_config_from_commit(self, commit):
+ """Given a git commit, applies config specified in the commit message.
+ Supported:
+ - gitlint-ignore: all
+ """
+ for line in commit.message.body:
+ pattern = re.compile(r"^gitlint-ignore:\s*(.*)")
+ matches = pattern.match(line)
+ if matches and len(matches.groups()) == 1:
+ self.set_option("general", "ignore", matches.group(1))
+
+ def set_config_from_string_list(self, config_options):
+ """Given a list of config options of the form "<rule>.<option>=<value>", parses out the correct rule and option
+ and sets the value accordingly in this factory object."""
+ for config_option in config_options:
+ try:
+ config_name, option_value = config_option.split("=", 1)
+ if not option_value:
+ raise ValueError()
+ rule_name, option_name = config_name.split(".", 1)
+ self.set_option(rule_name, option_name, option_value)
+ except ValueError 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
+
+ def set_from_config_file(self, filename):
+ """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 open(filename, encoding=FILE_ENCODING) as config_file:
+ parser.read_file(config_file, filename)
+
+ for section_name in parser.sections():
+ for option_name, option_value in parser.items(section_name):
+ self.set_option(section_name, option_name, str(option_value))
+
+ except ConfigParserError as e:
+ 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.
+ """
+
+ # Split up named rule in its parts: the name/id that specifies the parent rule,
+ # And the name of the rule instance itself
+ rule_name_parts = qualified_rule_name.split(self.RULE_QUALIFIER_SYMBOL, 1)
+ rule_name = rule_name_parts[1].strip()
+ parent_rule_specifier = rule_name_parts[0].strip()
+
+ # assert that the rule name is valid:
+ # - not empty
+ # - no whitespace or colons
+ if rule_name == "" or bool(re.search("\\s|:", rule_name, re.UNICODE)):
+ msg = f"The rule-name part in '{qualified_rule_name}' cannot contain whitespace, colons or be empty"
+ raise LintConfigError(msg)
+
+ # find parent rule
+ parent_rule = config.rules.find_rule(parent_rule_specifier)
+ if not parent_rule:
+ msg = f"No such rule '{parent_rule_specifier}' (named rule: '{qualified_rule_name}')"
+ raise LintConfigError(msg)
+
+ # Determine canonical id and name by recombining the parent id/name and instance name parts.
+ canonical_id = parent_rule.__class__.id + self.RULE_QUALIFIER_SYMBOL + rule_name
+ canonical_name = parent_rule.__class__.name + self.RULE_QUALIFIER_SYMBOL + rule_name
+
+ # 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})
+
+ 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."""
+ # If we are passed a config object, then rebuild that object instead of building a new lintconfig object from
+ # scratch
+ if not config:
+ config = LintConfig()
+
+ config._config_path = self._config_path
+
+ # Set general options first as this might change the behavior or validity of the other options
+ general_section = self._config_blueprint.get("general")
+ if general_section:
+ for option_name, option_value in general_section.items():
+ config.set_general_option(option_name, option_value)
+
+ for section_name, section_dict in self._config_blueprint.items():
+ for option_name, option_value in section_dict.items():
+ qualified_section_name = section_name
+ # Skip over the general section, as we've already done that above
+ if qualified_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:
+ qualified_section_name = self._add_named_rule(config, qualified_section_name)
+
+ config.set_rule_option(qualified_section_name, option_name, option_value)
+
+ return config
+
+ def clone(self):
+ """Creates an exact copy of a LintConfigBuilder."""
+ builder = LintConfigBuilder()
+ builder._config_blueprint = copy.deepcopy(self._config_blueprint)
+ builder._config_path = self._config_path
+ return builder
+
+
+GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/gitlint")
+
+
+class LintConfigGenerator:
+ @staticmethod
+ def generate_config(dest):
+ """Generates a gitlint config file at the given destination location.
+ Expects that the given ```dest``` points to a valid destination."""
+ shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest)
diff --git a/gitlint/contrib/__init__.py b/gitlint-core/gitlint/contrib/__init__.py
index e69de29..e69de29 100644
--- a/gitlint/contrib/__init__.py
+++ b/gitlint-core/gitlint/contrib/__init__.py
diff --git a/gitlint/contrib/rules/__init__.py b/gitlint-core/gitlint/contrib/rules/__init__.py
index e69de29..e69de29 100644
--- a/gitlint/contrib/rules/__init__.py
+++ b/gitlint-core/gitlint/contrib/rules/__init__.py
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..5c4a150
--- /dev/null
+++ b/gitlint-core/gitlint/contrib/rules/authors_commit.py
@@ -0,0 +1,45 @@
+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
new file mode 100644
index 0000000..705b083
--- /dev/null
+++ b/gitlint-core/gitlint/contrib/rules/conventional_commit.py
@@ -0,0 +1,37 @@
+import re
+
+from gitlint.options import ListOption
+from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
+
+RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+")
+
+
+class ConventionalCommit(LineRule):
+ """This rule enforces the spec at https://www.conventionalcommits.org/."""
+
+ name = "contrib-title-conventional-commits"
+ id = "CT1"
+ target = CommitMessageTitle
+
+ options_spec = [
+ ListOption(
+ "types",
+ ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"],
+ "Comma separated list of allowed commit types.",
+ )
+ ]
+
+ def validate(self, line, _commit):
+ violations = []
+ match = RULE_REGEX.match(line)
+
+ if not match:
+ msg = "Title does not follow ConventionalCommits.org format 'type(optional-scope): description'"
+ violations.append(RuleViolation(self.id, msg, line))
+ else:
+ line_commit_type = match.group(1)
+ if line_commit_type not in 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/contrib/rules/signedoff_by.py b/gitlint-core/gitlint/contrib/rules/signedoff_by.py
index c2034e7..5ea8217 100644
--- a/gitlint/contrib/rules/signedoff_by.py
+++ b/gitlint-core/gitlint/contrib/rules/signedoff_by.py
@@ -1,10 +1,9 @@
-
from gitlint.rules import CommitRule, RuleViolation
class SignedOffBy(CommitRule):
- """ This rule will enforce that each commit body contains a "Signed-Off-By" line.
- We keep things simple here and just check whether the commit body contains a line that starts with "Signed-Off-By".
+ """This rule will enforce that each commit body contains a "Signed-off-by" line.
+ We keep things simple here and just check whether the commit body contains a line that starts with "Signed-off-by".
"""
name = "contrib-body-requires-signed-off-by"
@@ -12,7 +11,7 @@ class SignedOffBy(CommitRule):
def validate(self, commit):
for line in commit.message.body:
- if line.startswith("Signed-Off-By"):
+ if line.lower().startswith("signed-off-by"):
return []
- return [RuleViolation(self.id, "Body does not contain a 'Signed-Off-By' line", line_nr=1)]
+ return [RuleViolation(self.id, "Body does not contain a 'Signed-off-by' line", line_nr=1)]
diff --git a/gitlint-core/gitlint/deprecation.py b/gitlint-core/gitlint/deprecation.py
new file mode 100644
index 0000000..b7c2f42
--- /dev/null
+++ b/gitlint-core/gitlint/deprecation.py
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 0000000..1de8d08
--- /dev/null
+++ b/gitlint-core/gitlint/display.py
@@ -0,0 +1,36 @@
+from sys import stderr, stdout
+
+
+class Display:
+ """Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity"""
+
+ def __init__(self, lint_config):
+ self.config = lint_config
+
+ def _output(self, message, verbosity, exact, stream):
+ """Output a message if the config's verbosity is >= to the given verbosity. If exact == True, the message
+ will only be outputted if the given verbosity exactly matches the config's verbosity."""
+ if exact:
+ if self.config.verbosity == verbosity:
+ stream.write(message + "\n")
+ else:
+ if self.config.verbosity >= verbosity:
+ stream.write(message + "\n")
+
+ def v(self, message, exact=False):
+ self._output(message, 1, exact, stdout)
+
+ def vv(self, message, exact=False):
+ self._output(message, 2, exact, stdout)
+
+ def vvv(self, message, exact=False):
+ self._output(message, 3, exact, stdout)
+
+ def e(self, message, exact=False):
+ self._output(message, 1, exact, stderr)
+
+ def ee(self, message, exact=False):
+ self._output(message, 2, exact, stderr)
+
+ def eee(self, message, exact=False):
+ self._output(message, 3, exact, stderr)
diff --git a/gitlint-core/gitlint/exception.py b/gitlint-core/gitlint/exception.py
new file mode 100644
index 0000000..d1e8c9c
--- /dev/null
+++ b/gitlint-core/gitlint/exception.py
@@ -0,0 +1,2 @@
+class GitlintError(Exception):
+ """Based Exception class for all gitlint exceptions"""
diff --git a/gitlint-core/gitlint/files/commit-msg b/gitlint-core/gitlint/files/commit-msg
new file mode 100644
index 0000000..e754e8d
--- /dev/null
+++ b/gitlint-core/gitlint/files/commit-msg
@@ -0,0 +1,35 @@
+#!/bin/sh
+### gitlint commit-msg hook start ###
+
+# Determine whether we have a tty available by trying to access it.
+# This allows us to deal with UI based gitclient's like Atlassian SourceTree.
+# NOTE: "exec < /dev/tty" sets stdin to the keyboard
+stdin_available=1
+(exec < /dev/tty) 2> /dev/null || stdin_available=0
+
+if [ $stdin_available -eq 1 ]; then
+ # Now that we know we have a functional tty, set stdin to it so we can ask the user questions :-)
+ exec < /dev/tty
+
+ # On Windows, we need to explicitly set our stdout to the tty to make terminal editing work (e.g. vim)
+ # See SO for windows detection in bash (slight modified to work on plain shell (not bash)):
+ # https://stackoverflow.com/questions/394230/how-to-detect-the-os-from-a-bash-script
+ if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] || [ "$OSTYPE" = "win32" ]; then
+ exec > /dev/tty
+ fi
+fi
+
+gitlint --staged --msg-filename "$1" run-hook
+exit_code=$?
+
+# If we fail to find the gitlint binary (command not found), let's retry by executing as a python module.
+# This is the case for Atlassian SourceTree, where $PATH deviates from the user's shell $PATH.
+if [ $exit_code -eq 127 ]; then
+ echo "Fallback to python module execution"
+ python -m gitlint.cli --staged --msg-filename "$1" run-hook
+ exit_code=$?
+fi
+
+exit $exit_code
+
+### gitlint commit-msg hook end ###
diff --git a/gitlint/files/gitlint b/gitlint-core/gitlint/files/gitlint
index 15a6626..3d9f273 100644
--- a/gitlint/files/gitlint
+++ b/gitlint-core/gitlint/files/gitlint
@@ -4,7 +4,7 @@
# one rule and each key in it is an option for that specific rule.
#
# Rules and sections can be referenced by their full name or by id. For example
-# section "[body-max-line-length]" could be written as "[B1]". Full section names are
+# section "[body-max-line-length]" could also be written as "[B1]". Full section names are
# used in here for clarity.
#
# [general]
@@ -14,19 +14,31 @@
# 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
# commit message to gitlint via stdin or --commit-msg. Disabled by default.
# staged=true
+# 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.
+# 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
@@ -39,10 +51,15 @@
# extra-path=examples/
# This is an example of how to configure the "title-max-length" rule and
-# set the line-length it enforces to 80
+# set the line-length it enforces to 50
# [title-max-length]
# line-length=50
+# Conversely, you can also enforce minimal length of a title with the
+# "title-min-length" rule:
+# [title-min-length]
+# min-length=5
+
# [title-must-not-contain-word]
# Comma-separated list of words that should not occur in the title. Matching is case
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
@@ -50,8 +67,7 @@
# words=wip
# [title-match-regex]
-# python like regex (https://docs.python.org/2/library/re.html) that the
-# commit-msg title must be matched to.
+# python-style regex that the commit-msg title must match
# Note that the regex can contradict with other rules if not used correctly
# (e.g. title-must-not-contain-word).
# regex=^US[0-9]*
@@ -72,11 +88,15 @@
# This is useful for when developers often erroneously edit certain files or git submodules.
# By specifying this rule, developers can only change the file when they explicitly reference
# it in the commit message.
-# files=gitlint/rules.py,README.md
+# files=gitlint-core/gitlint/rules.py,README.md
+
+# [body-match-regex]
+# python-style regex that the commit-msg body must match.
+# E.g. body must end in My-Commit-Tag: foo
+# regex=My-Commit-Tag: foo$
# [author-valid-email]
-# python like regex (https://docs.python.org/2/library/re.html) that the
-# commit author email address should be matched to
+# python-style regex that the commit author email address must match.
# For example, use the following regex if you only want to allow email addresses from foo.com
# regex=[^@]+@foo.com
@@ -98,9 +118,23 @@
# Use 'all' to ignore all rules
# ignore=T1,body-min-length
+# [ignore-body-lines]
+# Ignore certain lines in a commit body that match a regex.
+# E.g. Ignore all lines that start with 'Co-Authored-By'
+# regex=^Co-Authored-By
+
+# [ignore-by-author-name]
+# Ignore certain rules for commits of which the author name matches a regex
+# E.g. Match commits made by dependabot
+# regex=(.*)dependabot(.*)
+#
+# Ignore certain rules, you can reference them by their id or by their full name
+# Use 'all' to ignore all rules
+# ignore=T1,body-min-length
+
# This is a contrib rule - a community contributed rule. These are disabled by default.
# You need to explicitly enable them one-by-one by adding them to the "contrib" option
# under [general] section above.
# [contrib-title-conventional-commits]
# Specify allowed commit types. For details see: https://www.conventionalcommits.org/
-# types = bugfix,user-story,epic \ No newline at end of file
+# types = bugfix,user-story,epic
diff --git a/gitlint-core/gitlint/git.py b/gitlint-core/gitlint/git.py
new file mode 100644
index 0000000..6612a7d
--- /dev/null
+++ b/gitlint-core/gitlint/git.py
@@ -0,0 +1,510 @@
+import logging
+import os
+from pathlib import Path
+
+import arrow
+
+from gitlint import shell as sh
+from gitlint.cache import PropertyCache, cache
+from gitlint.exception import GitlintError
+
+# import exceptions separately, this makes it a little easier to mock them out in the unit tests
+from gitlint.shell import CommandNotFound, ErrorReturnCode
+
+# For now, the git date format we use is fixed, but technically this format is determined by `git config log.date`
+# We should fix this at some point :-)
+GIT_TIMEFORMAT = "YYYY-MM-DD HH:mm:ss Z"
+
+LOG = logging.getLogger(__name__)
+
+
+class GitContextError(GitlintError):
+ """Exception indicating there is an issue with the git context"""
+
+
+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."
+ )
+
+
+class GitExitCodeError(GitContextError):
+ def __init__(self, command, stderr):
+ self.command = command
+ self.stderr = stderr
+ super().__init__(f"An error occurred while executing '{command}': {stderr}")
+
+
+def _git(*command_parts, **kwargs):
+ """Convenience function for running git commands. Automatically deals with exceptions and unicode."""
+ git_kwargs = {"_tty_out": False}
+ git_kwargs.update(kwargs)
+ try:
+ LOG.debug(command_parts)
+ result = sh.git(*command_parts, **git_kwargs)
+ # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
+ # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
+ # a non-zero exit code -> just return the entire result
+ if hasattr(result, "exit_code") and result.exit_code > 0:
+ return result
+ return str(result)
+ except CommandNotFound as e:
+ raise GitNotInstalledError from e
+ 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:
+ 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
+ ):
+ msg = "Current branch has no commits. Gitlint requires at least one commit to function."
+ raise GitContextError(msg) from e
+
+ raise GitExitCodeError(e.full_cmd, error_msg) from e
+
+
+def 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"""
+ 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:
+ commentchar = "#"
+ return commentchar.replace("\n", "")
+
+
+def git_hooks_dir(repository_path):
+ """Determine hooks directory for a given target dir"""
+ hooks_dir = _git("rev-parse", "--git-path", "hooks", _cwd=repository_path)
+ hooks_dir = 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
+ """
+
+ def __init__(self, context, original=None, full=None, title=None, body=None):
+ self.context = context
+ self.original = original
+ self.full = full
+ self.title = title
+ self.body = body
+
+ @staticmethod
+ def from_full_message(context, commit_msg_str):
+ """Parses a full git commit message by parsing a given string into the different parts of a commit message"""
+ all_lines = commit_msg_str.splitlines()
+ cutline = f"{context.commentchar} ------------------------ >8 ------------------------"
+ try:
+ cutline_index = all_lines.index(cutline)
+ except ValueError:
+ cutline_index = None
+ lines = [line for line in all_lines[:cutline_index] if not line.startswith(context.commentchar)]
+ full = "\n".join(lines)
+ title = lines[0] if lines else ""
+ body = lines[1:] if len(lines) > 1 else []
+ return GitCommitMessage(context=context, original=commit_msg_str, full=full, title=title, body=body)
+
+ def __str__(self):
+ 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
+ )
+
+
+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"
+
+
+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.
+ """
+
+ def __init__(
+ self,
+ context,
+ message,
+ sha=None,
+ date=None,
+ author_name=None,
+ author_email=None,
+ parents=None,
+ changed_files_stats=None,
+ branches=None,
+ ):
+ self.context = context
+ self.message = message
+ self.sha = sha
+ self.date = date
+ self.author_name = author_name
+ self.author_email = author_email
+ self.parents = parents or [] # parent commit hashes
+ self.changed_files_stats = changed_files_stats or {}
+ self.branches = branches or []
+
+ @property
+ def is_merge_commit(self):
+ return self.message.title.startswith("Merge")
+
+ @property
+ def is_fixup_commit(self):
+ return self.message.title.startswith("fixup!")
+
+ @property
+ def is_squash_commit(self):
+ 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
+
+ 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_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.
+ """
+
+ def __init__(self, context, sha):
+ PropertyCache.__init__(self)
+ self.context = context
+ self.sha = sha
+
+ def _log(self):
+ """Does a call to `git log` to determine a bunch of information about the commit."""
+ long_format = "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B"
+ raw_commit = _git("log", self.sha, "-1", long_format, _cwd=self.context.repository_path).split("\n")
+
+ (name, email, date, parents), commit_msg = raw_commit[0].split("\x00"), "\n".join(raw_commit[1:])
+
+ commit_parents = [] if parents == "" else parents.split(" ")
+ commit_is_merge_commit = len(commit_parents) > 1
+
+ # "YYYY-MM-DD HH:mm:ss Z" -> ISO 8601-like format
+ # Use arrow for datetime parsing, because apparently python is quirky around ISO-8601 dates:
+ # http://stackoverflow.com/a/30696682/381010
+ commit_date = arrow.get(date, GIT_TIMEFORMAT).datetime
+
+ # Create Git commit object with the retrieved info
+ commit_msg_obj = GitCommitMessage.from_full_message(self.context, commit_msg)
+
+ self._cache.update(
+ {
+ "message": commit_msg_obj,
+ "author_name": name,
+ "author_email": email,
+ "date": commit_date,
+ "parents": commit_parents,
+ "is_merge_commit": commit_is_merge_commit,
+ }
+ )
+
+ @property
+ def message(self):
+ return self._try_cache("message", self._log)
+
+ @property
+ def author_name(self):
+ return self._try_cache("author_name", self._log)
+
+ @property
+ def author_email(self):
+ return self._try_cache("author_email", self._log)
+
+ @property
+ def date(self):
+ return self._try_cache("date", self._log)
+
+ @property
+ def parents(self):
+ return self._try_cache("parents", self._log)
+
+ @property
+ def branches(self):
+ def cache_branches():
+ # We have to parse 'git branch --contains <sha>' instead of 'git for-each-ref' to be compatible with
+ # git versions < 2.7.0
+ # https://stackoverflow.com/questions/45173979/can-i-force-git-branch-contains-tag-to-not-print-the-asterisk
+ branches = _git("branch", "--contains", self.sha, _cwd=self.context.repository_path).split("\n")
+
+ # This means that we need to remove any leading * that indicates the current branch. Note that we can
+ # safely do this since git branches cannot contain '*' anywhere, so if we find an '*' we know it's output
+ # from the git CLI and not part of the branch name. See https://git-scm.com/docs/git-check-ref-format
+ # We also drop the last empty line from the output.
+ self._cache["branches"] = [branch.replace("*", "").strip() for branch in branches[:-1]]
+
+ return self._try_cache("branches", cache_branches)
+
+ @property
+ def is_merge_commit(self):
+ return self._try_cache("is_merge_commit", self._log)
+
+ @property
+ def changed_files_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_stats", cache_changed_files_stats)
+
+
+class StagedLocalGitCommit(GitCommit, PropertyCache):
+ """Class representing a git commit that has been staged, but not committed.
+
+ Other than the commit message itself (and changed files), a lot of information is actually not known at staging
+ time, since the commit hasn't happened yet. However, we can make educated guesses based on existing repository
+ information.
+ """
+
+ def __init__(self, context, commit_message):
+ PropertyCache.__init__(self)
+ self.context = context
+ self.message = commit_message
+ self.sha = None
+ self.parents = [] # Not really possible to determine before a commit
+
+ @property
+ @cache
+ def author_name(self):
+ try:
+ return _git("config", "--get", "user.name", _cwd=self.context.repository_path).strip()
+ except GitExitCodeError as e:
+ raise GitContextError("Missing git configuration: please set user.name") from e
+
+ @property
+ @cache
+ def author_email(self):
+ try:
+ return _git("config", "--get", "user.email", _cwd=self.context.repository_path).strip()
+ except GitExitCodeError as e:
+ raise GitContextError("Missing git configuration: please set user.email") from e
+
+ @property
+ @cache
+ def date(self):
+ # We don't know the actual commit date yet, but we make a pragmatic trade-off here by providing the current date
+ # We get current date from arrow, reformat in git date format, then re-interpret it as a date.
+ # This ensure we capture the same precision and timezone information that git does.
+ return arrow.get(arrow.now().format(GIT_TIMEFORMAT), GIT_TIMEFORMAT).datetime
+
+ @property
+ @cache
+ def branches(self):
+ # We don't know the branch this commit will be part of yet, but we're pragmatic here and just return the
+ # current branch, as for all intents and purposes, this will be what the user is looking for.
+ return [self.context.current_branch]
+
+ @property
+ def changed_files_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
+ the git repository that gitlint is linting.
+ """
+
+ def __init__(self, repository_path=None):
+ PropertyCache.__init__(self)
+ self.commits = []
+ self.repository_path = repository_path
+
+ @property
+ @cache
+ def commentchar(self):
+ return git_commentchar(self.repository_path)
+
+ @property
+ @cache
+ def current_branch(self):
+ 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.
+ :param commit_msg_str: Full git commit message.
+ """
+ context = GitContext()
+ commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
+ commit = GitCommit(context, commit_msg_obj)
+ context.commits.append(commit)
+ return context
+
+ @staticmethod
+ def from_staged_commit(commit_msg_str, repository_path):
+ """Determines git context based on a commit message that is a staged commit for a local git repository.
+ :param commit_msg_str: Full git commit message.
+ :param repository_path: Path to the git repository to retrieve the context from
+ """
+ context = GitContext(repository_path=repository_path)
+ commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
+ commit = StagedLocalGitCommit(context, commit_msg_obj)
+ context.commits.append(commit)
+ return context
+
+ @staticmethod
+ def from_local_repository(repository_path, refspec=None, 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`)
+ """
+
+ context = GitContext(repository_path=repository_path)
+
+ if refspec:
+ sha_list = _git("rev-list", refspec, _cwd=repository_path).split()
+ 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 = []
+ 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
+ # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`.
+ sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace("\n", "")]
+
+ for sha in sha_list:
+ commit = LocalGitCommit(context, sha)
+ context.commits.append(commit)
+
+ return context
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, GitContext)
+ and self.commits == other.commits
+ and self.repository_path == other.repository_path
+ and self.commentchar == other.commentchar
+ and self.current_branch == other.current_branch
+ )
diff --git a/gitlint/hooks.py b/gitlint-core/gitlint/hooks.py
index fc4dc4e..98ded18 100644
--- a/gitlint/hooks.py
+++ b/gitlint-core/gitlint/hooks.py
@@ -1,22 +1,22 @@
-import io
-import shutil
import os
+import shutil
import stat
-from gitlint.utils import DEFAULT_ENCODING
+from gitlint.exception import GitlintError
from gitlint.git import git_hooks_dir
+from gitlint.utils import FILE_ENCODING
COMMIT_MSG_HOOK_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files", "commit-msg")
COMMIT_MSG_HOOK_DST_PATH = "commit-msg"
GITLINT_HOOK_IDENTIFIER = "### gitlint commit-msg hook start ###\n"
-class GitHookInstallerError(Exception):
+class GitHookInstallerError(GitlintError):
pass
-class GitHookInstaller(object):
- """ Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook. """
+class GitHookInstaller:
+ """Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook."""
@staticmethod
def commit_msg_hook_path(lint_config):
@@ -24,10 +24,10 @@ class GitHookInstaller(object):
@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(u"{0} is not a git repository.".format(target))
+ raise GitHookInstallerError(f"{target} is not a git repository.")
@staticmethod
def install_commit_msg_hook(lint_config):
@@ -35,8 +35,9 @@ class GitHookInstaller(object):
dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
if os.path.exists(dest_path):
raise GitHookInstallerError(
- u"There is already a commit-msg hook file present in {0}.\n".format(dest_path) +
- u"gitlint currently does not support appending to an existing commit-msg file.")
+ 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)
@@ -49,14 +50,16 @@ class GitHookInstaller(object):
GitHookInstaller._assert_git_repo(lint_config.target)
dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
if not os.path.exists(dest_path):
- raise GitHookInstallerError(u"There is no commit-msg hook present in {0}.".format(dest_path))
+ 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=FILE_ENCODING) as fp:
lines = fp.readlines()
- if len(lines) < 2 or lines[1] != GITLINT_HOOK_IDENTIFIER:
- msg = u"The commit-msg hook in {0} was not installed by gitlint (or it was modified).\n" + \
- u"Uninstallation of 3th party or modified gitlint hooks is not supported."
- raise GitHookInstallerError(msg.format(dest_path))
+ if len(lines) < 2 or lines[1] != GITLINT_HOOK_IDENTIFIER: # noqa: PLR2004 (Magic value used in comparison)
+ 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
os.remove(dest_path)
diff --git a/gitlint/lint.py b/gitlint-core/gitlint/lint.py
index 6ef7174..420d3ad 100644
--- a/gitlint/lint.py
+++ b/gitlint-core/gitlint/lint.py
@@ -1,15 +1,15 @@
-# pylint: disable=logging-not-lazy
import logging
-from gitlint import rules as gitlint_rules
+
from gitlint import display
-from gitlint.utils import ustr
+from gitlint import rules as gitlint_rules
+from gitlint.deprecation import Deprecation
LOG = logging.getLogger(__name__)
logging.basicConfig()
-class GitLinter(object):
- """ Main linter class. This is where rules actually get applied. See the lint() method. """
+class GitLinter:
+ """Main linter class. This is where rules actually get applied. See the lint() method."""
def __init__(self, config):
self.config = config
@@ -17,34 +17,48 @@ class GitLinter(object):
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:
@@ -59,7 +73,7 @@ class GitLinter(object):
@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)
@@ -68,19 +82,21 @@ class GitLinter(object):
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" + ustr(commit))
+ 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, "is_{0}_commit".format(commit_type)) and \
- getattr(self.config, "ignore_{0}_commits".format(commit_type)):
+ if getattr(commit, f"is_{commit_type}_commit") and getattr(self.config, f"ignore_{commit_type}_commits"):
return []
violations = []
@@ -96,13 +112,12 @@ class GitLinter(object):
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(u"{0}: {1}".format(line_nr, v.rule_id), exact=True)
- self.display.ee(u"{0}: {1} {2}".format(line_nr, v.rule_id, v.message), exact=True)
+ 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(u"{0}: {1} {2}: \"{3}\"".format(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(u"{0}: {1} {2}".format(line_nr, v.rule_id, v.message), exact=True)
+ 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
new file mode 100644
index 0000000..ff7d9f1
--- /dev/null
+++ b/gitlint-core/gitlint/options.py
@@ -0,0 +1,146 @@
+import os
+import re
+from abc import abstractmethod
+
+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"""
+
+ def wrapped(obj, value):
+ if value is None:
+ obj.value = None
+ else:
+ func(obj, value)
+
+ return wrapped
+
+
+class RuleOptionError(GitlintError):
+ pass
+
+
+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.
+ """
+
+ def __init__(self, name, value, description):
+ self.name = name
+ self.description = description
+ self.value = None
+ self.set(value)
+
+ @abstractmethod
+ def set(self, value):
+ """Validates and sets the option's value"""
+
+ def __str__(self):
+ return f"({self.name}: {self.value} ({self.description}))"
+
+ def __eq__(self, other):
+ return self.name == other.name and self.description == other.description and self.value == other.value
+
+
+class StrOption(RuleOption):
+ @allow_none
+ def set(self, value):
+ self.value = str(value)
+
+
+class IntOption(RuleOption):
+ def __init__(self, name, value, description, allow_negative=False):
+ self.allow_negative = allow_negative
+ super().__init__(name, value, description)
+
+ def _raise_exception(self, value):
+ if self.allow_negative:
+ error_msg = f"Option '{self.name}' must be an integer (current value: '{value}')"
+ else:
+ error_msg = f"Option '{self.name}' must be a positive integer (current value: '{value}')"
+ raise RuleOptionError(error_msg)
+
+ @allow_none
+ def set(self, value):
+ try:
+ self.value = int(value)
+ except ValueError:
+ self._raise_exception(value)
+
+ if not self.allow_negative and self.value < 0:
+ self._raise_exception(value)
+
+
+class BoolOption(RuleOption):
+ # 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"]:
+ raise RuleOptionError(f"Option '{self.name}' must be either 'true' or 'false'")
+ 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."""
+
+ @allow_none
+ def set(self, value):
+ if isinstance(value, list):
+ the_list = value
+ else:
+ the_list = str(value).split(",")
+
+ self.value = [str(item.strip()) for item in the_list if item.strip() != ""]
+
+
+class PathOption(RuleOption):
+ """Option that accepts either a directory or both a directory and a file."""
+
+ def __init__(self, name, value, description, type="dir"):
+ self.type = type
+ super().__init__(name, value, description)
+
+ @allow_none
+ def set(self, value):
+ value = str(value)
+
+ error_msg = ""
+
+ 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":
+ if not os.path.isfile(value):
+ error_msg = f"Option {self.name} must be an existing file (current value: '{value}')"
+ 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 (current value: '{value}')"
+ )
+ else:
+ error_msg = f"Option {self.name} type must be one of: 'file', 'dir', 'both' (current: '{self.type}')"
+
+ if error_msg:
+ raise RuleOptionError(error_msg)
+
+ self.value = os.path.realpath(value)
+
+
+class RegexOption(RuleOption):
+ @allow_none
+ def set(self, value):
+ try:
+ self.value = re.compile(value, re.UNICODE)
+ except (re.error, TypeError) as exc:
+ raise RuleOptionError(f"Invalid regular expression: '{exc}'") from exc
+
+ def __deepcopy__(self, _):
+ # copy.deepcopy() - used in rules.py - doesn't support copying regex objects prior to Python 3.7
+ # To work around this, we have to implement this __deepcopy__ magic method
+ # Relevant SO thread:
+ # https://stackoverflow.com/questions/6279305/typeerror-cannot-deepcopy-this-pattern-object
+ value = None if self.value is None else self.value.pattern
+ return RegexOption(self.name, value, self.description)
diff --git a/gitlint-core/gitlint/rule_finder.py b/gitlint-core/gitlint/rule_finder.py
new file mode 100644
index 0000000..810faa9
--- /dev/null
+++ b/gitlint-core/gitlint/rule_finder.py
@@ -0,0 +1,155 @@
+import fnmatch
+import importlib
+import inspect
+import os
+import sys
+
+from gitlint import options, rules
+
+
+def find_rule_classes(extra_path):
+ """
+ Searches a given directory or python module for rule classes. This is done by
+ adding the directory path to the python path, importing the modules and then finding
+ any Rule class in those modules.
+
+ :param extra_path: absolute directory or file path to search for rule classes
+ :return: The list of rule classes that are found in the given directory or module
+ """
+
+ files = []
+ modules = []
+
+ if os.path.isfile(extra_path):
+ files = [os.path.basename(extra_path)]
+ directory = os.path.dirname(extra_path)
+ elif os.path.isdir(extra_path):
+ files = os.listdir(extra_path)
+ directory = extra_path
+ else:
+ raise rules.UserRuleError(f"Invalid extra-path: {extra_path}")
+
+ # Filter out files that are not python modules
+ for filename in files:
+ if fnmatch.fnmatch(filename, "*.py"):
+ # We have to treat __init__ files a bit special: add the parent dir instead of the filename, and also
+ # add their parent dir to the sys.path (this fixes import issues with pypy2).
+ if filename == "__init__.py":
+ modules.append(os.path.basename(directory))
+ sys.path.append(os.path.dirname(directory))
+ else:
+ modules.append(os.path.splitext(filename)[0])
+
+ # No need to continue if there are no modules specified
+ if not modules:
+ return []
+
+ # Append the extra rules path to python path so that we can import them
+ sys.path.append(directory)
+
+ # Find all the rule classes in the found python files
+ rule_classes = []
+ for module in modules:
+ # Import the module
+ try:
+ importlib.import_module(module)
+
+ except Exception as e:
+ raise rules.UserRuleError(f"Error while importing extra-path module '{module}': {e}") from e
+
+ # Find all rule classes in the module. We do this my inspecting all members of the module and checking
+ # 1) is it a class, if not, skip
+ # 2) is the parent path the current module. If not, we are dealing with an imported class, skip
+ # 3) is it a subclass of rule
+ rule_classes.extend(
+ [
+ clazz
+ for _, clazz in inspect.getmembers(sys.modules[module])
+ if inspect.isclass(clazz) # check isclass to ensure clazz.__module__ exists
+ and clazz.__module__ == module # ignore imported classes
+ and (issubclass(clazz, (rules.LineRule, rules.CommitRule, rules.ConfigurationRule)))
+ ]
+ )
+
+ # validate that the rule classes are valid user-defined rules
+ for rule_class in rule_classes:
+ assert_valid_rule_class(rule_class)
+
+ return rule_classes
+
+
+def assert_valid_rule_class(clazz, rule_type="User-defined"): # noqa: PLR0912 (too many branches)
+ """
+ Asserts that a given rule clazz is valid by checking a number of its properties:
+ - Rules must extend from LineRule, CommitRule or ConfigurationRule
+ - Rule classes must have id and name string attributes.
+ The options_spec is optional, but if set, it must be a list of gitlint Options.
+ - Rule classes must have a validate method. In case of a CommitRule, validate must take a single commit parameter.
+ In case of LineRule, validate must take line and commit as first and second parameters.
+ - LineRule classes must have a target class attributes that is set to either
+ - ConfigurationRule classes must have an apply method that take `config` and `commit` as parameters.
+ CommitMessageTitle or CommitMessageBody.
+ - Rule id's cannot start with R, T, B, M or I as these rule ids are reserved for gitlint itself.
+ """
+
+ # Rules must extend from LineRule, CommitRule or ConfigurationRule
+ if not issubclass(clazz, (rules.LineRule, rules.CommitRule, 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:
+ 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"]:
+ 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:
+ 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__}"
+ )
+ 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__}"
+ )
+ 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, 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): # noqa: SIM102
+ 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): # noqa: SIM102
+ 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__}"
+ )
+ raise rules.UserRuleError(msg)
diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py
new file mode 100644
index 0000000..ca4a05b
--- /dev/null
+++ b/gitlint-core/gitlint/rules.py
@@ -0,0 +1,485 @@
+import copy
+import logging
+import re
+
+from gitlint.deprecation import Deprecation
+from gitlint.exception import GitlintError
+from gitlint.options import BoolOption, IntOption, ListOption, RegexOption, StrOption
+
+
+class Rule:
+ """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:
+ opts = {}
+ self.options = {}
+ for op_spec in self.options_spec:
+ self.options[op_spec.name] = copy.deepcopy(op_spec)
+ actual_option = opts.get(op_spec.name)
+ if actual_option is not None:
+ self.options[op_spec.name].set(actual_option)
+
+ @property
+ def log(self):
+ if not self._log:
+ self._log = logging.getLogger(__name__)
+ logging.basicConfig()
+ 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
+ )
+
+ 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 CommitRule(Rule):
+ """Class representing rules that act on an entire commit at once"""
+
+
+class LineRule(Rule):
+ """Class representing rules that act on a line by line basis"""
+
+
+class LineRuleTarget:
+ """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."""
+
+
+class CommitMessageTitle(LineRuleTarget):
+ """Target class used for rules that apply to a commit message title"""
+
+
+class CommitMessageBody(LineRuleTarget):
+ """Target class used for rules that apply to a commit message body"""
+
+
+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."""
+
+ def __init__(self, rule_id, message, content=None, line_nr=None):
+ self.rule_id = rule_id
+ self.line_nr = line_nr
+ self.message = message
+ self.content = content
+
+ def __eq__(self, other):
+ equal = self.rule_id == other.rule_id and self.message == other.message
+ equal = equal and self.content == other.content and self.line_nr == other.line_nr
+ return equal
+
+ def __str__(self):
+ 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"""
+
+
+class MaxLineLength(LineRule):
+ name = "max-line-length"
+ id = "R1"
+ options_spec = [IntOption("line-length", 80, "Max line length")]
+ violation_message = "Line exceeds max length ({0}>{1})"
+
+ def validate(self, line, _commit):
+ max_length = self.options["line-length"].value
+ if len(line) > max_length:
+ return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
+
+
+class TrailingWhiteSpace(LineRule):
+ name = "trailing-whitespace"
+ id = "R2"
+ violation_message = "Line has trailing whitespace"
+ pattern = re.compile(r"\s$", re.UNICODE)
+
+ def validate(self, line, _commit):
+ if self.pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class HardTab(LineRule):
+ name = "hard-tab"
+ id = "R3"
+ violation_message = "Line contains hard tab characters (\\t)"
+
+ def validate(self, line, _commit):
+ if "\t" in line:
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class LineMustNotContainWord(LineRule):
+ """Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
+ a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.)"""
+
+ name = "line-must-not-contain"
+ id = "R5"
+ options_spec = [ListOption("words", [], "Comma separated list of words that should not be found")]
+ violation_message = "Line contains {0}"
+
+ def validate(self, line, _commit):
+ strings = self.options["words"].value
+ violations = []
+ for string in strings:
+ regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE)
+ match = regex.search(line.lower())
+ if match:
+ violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
+ return violations if violations else None
+
+
+class LeadingWhiteSpace(LineRule):
+ name = "leading-whitespace"
+ id = "R6"
+ violation_message = "Line has leading whitespace"
+
+ def validate(self, line, _commit):
+ pattern = re.compile(r"^\s", re.UNICODE)
+ if pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class TitleMaxLength(MaxLineLength):
+ name = "title-max-length"
+ id = "T1"
+ target = CommitMessageTitle
+ options_spec = [IntOption("line-length", 72, "Max line length")]
+ violation_message = "Title exceeds max length ({0}>{1})"
+
+
+class TitleTrailingWhitespace(TrailingWhiteSpace):
+ name = "title-trailing-whitespace"
+ id = "T2"
+ target = CommitMessageTitle
+ violation_message = "Title has trailing whitespace"
+
+
+class TitleTrailingPunctuation(LineRule):
+ name = "title-trailing-punctuation"
+ id = "T3"
+ target = CommitMessageTitle
+
+ def validate(self, title, _commit):
+ punctuation_marks = "?:!.,;"
+ for punctuation_mark in punctuation_marks:
+ if title.endswith(punctuation_mark):
+ return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)]
+
+
+class TitleHardTab(HardTab):
+ name = "title-hard-tab"
+ id = "T4"
+ target = CommitMessageTitle
+ violation_message = "Title contains hard tab characters (\\t)"
+
+
+class TitleMustNotContainWord(LineMustNotContainWord):
+ name = "title-must-not-contain-word"
+ id = "T5"
+ target = CommitMessageTitle
+ options_spec = [ListOption("words", ["WIP"], "Must not contain word")]
+ violation_message = "Title contains the word '{0}' (case-insensitive)"
+
+
+class TitleLeadingWhitespace(LeadingWhiteSpace):
+ name = "title-leading-whitespace"
+ id = "T6"
+ target = CommitMessageTitle
+ violation_message = "Title has leading whitespace"
+
+
+class TitleRegexMatches(LineRule):
+ name = "title-match-regex"
+ id = "T7"
+ target = CommitMessageTitle
+ options_spec = [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:
+ return
+
+ 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)]
+
+
+class TitleMinLength(LineRule):
+ name = "title-min-length"
+ id = "T8"
+ target = CommitMessageTitle
+ options_spec = [IntOption("min-length", 5, "Minimum required title length")]
+
+ def validate(self, title, _commit):
+ 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})"
+ return [RuleViolation(self.id, violation_message, title, 1)]
+
+
+class BodyMaxLineLength(MaxLineLength):
+ name = "body-max-line-length"
+ id = "B1"
+ target = CommitMessageBody
+
+
+class BodyTrailingWhitespace(TrailingWhiteSpace):
+ name = "body-trailing-whitespace"
+ id = "B2"
+ target = CommitMessageBody
+
+
+class BodyHardTab(HardTab):
+ name = "body-hard-tab"
+ id = "B3"
+ target = CommitMessageBody
+
+
+class BodyFirstLineEmpty(CommitRule):
+ name = "body-first-line-empty"
+ id = "B4"
+
+ def validate(self, commit):
+ if len(commit.message.body) >= 1:
+ first_line = commit.message.body[0]
+ if first_line != "":
+ return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]
+
+
+class BodyMinLength(CommitRule):
+ name = "body-min-length"
+ id = "B5"
+ options_spec = [IntOption("min-length", 20, "Minimum body length")]
+
+ def validate(self, commit):
+ min_length = self.options["min-length"].value
+ body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
+ actual_length = len(body_message_no_newline)
+ if 0 < actual_length < min_length:
+ violation_message = f"Body message is too short ({actual_length}<{min_length})"
+ return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]
+
+
+class BodyMissing(CommitRule):
+ name = "body-is-missing"
+ id = "B6"
+ options_spec = [BoolOption("ignore-merge-commits", True, "Ignore merge commits")]
+
+ def validate(self, commit):
+ # ignore merges when option tells us to, which may have no body
+ if self.options["ignore-merge-commits"].value and commit.is_merge_commit:
+ return
+ if len(commit.message.body) < 2 or not "".join(commit.message.body).strip(): # noqa: PLR2004 (Magic value)
+ return [RuleViolation(self.id, "Body message is missing", None, 3)]
+
+
+class BodyChangedFileMention(CommitRule):
+ name = "body-changed-file-mention"
+ id = "B7"
+ options_spec = [ListOption("files", [], "Files that need to be mentioned")]
+
+ def validate(self, commit):
+ violations = []
+ for needs_mentioned_file in self.options["files"].value:
+ # if a file that we need to look out for is actually changed, then check whether it occurs
+ # in the commit msg body
+ if needs_mentioned_file in commit.changed_files: # noqa: SIM102
+ if needs_mentioned_file not in " ".join(commit.message.body):
+ violation_message = f"Body does not mention changed file '{needs_mentioned_file}'"
+ violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
+ return violations if violations else None
+
+
+class BodyRegexMatches(CommitRule):
+ name = "body-match-regex"
+ id = "B8"
+ 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:
+ return
+
+ # We intentionally ignore the first line in the body as that's the empty line after the title,
+ # which most users are not going to expect to be part of the body when matching a regex.
+ # If this causes contention, we can always introduce an option to change the behavior in a backward-
+ # compatible way.
+ body_lines = commit.message.body[1:] if len(commit.message.body) > 1 else []
+
+ # Similarly, the last line is often empty, this has to do with how git returns commit messages
+ # User's won't expect this, so prune it off by default
+ if body_lines and body_lines[-1] == "":
+ body_lines.pop()
+
+ full_body = "\n".join(body_lines)
+
+ 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)]
+
+
+class AuthorValidEmail(CommitRule):
+ name = "author-valid-email"
+ id = "M1"
+ 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:
+ return
+
+ # 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.options["regex"].value.pattern == self.DEFAULT_AUTHOR_VALID_EMAIL_REGEX:
+ 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"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ 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"])
+
+ 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}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+
+
+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"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ 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 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}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ # No need to check other lines if we found a match
+ return
+
+
+class IgnoreBodyLines(ConfigurationRule):
+ name = "ignore-body-lines"
+ id = "I3"
+ 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:
+ 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 regex_method(line):
+ debug_msg = "Ignoring line '%s' because it matches '%s'"
+ self.log.debug(debug_msg, line, self.options["regex"].value.pattern)
+ else:
+ new_body.append(line)
+
+ commit.message.body = new_body
+ commit.message.full = "\n".join([commit.message.title, *new_body])
+
+
+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"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # If commit.author_name is not available, log warning and return
+ if commit.author_name is None:
+ warning_msg = (
+ "%s - %s: skipping - commit.author_name unknown. "
+ "Suggested fix: Use the --staged flag (or set general.staged=True in .gitlint). "
+ "More details: https://jorisroovers.com/gitlint/configuration/#staged"
+ )
+
+ self.log.warning(warning_msg, self.name, self.id)
+ return
+
+ 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}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ # No need to check other lines if we found a match
+ return
diff --git a/gitlint-core/gitlint/shell.py b/gitlint-core/gitlint/shell.py
new file mode 100644
index 0000000..fddece0
--- /dev/null
+++ b/gitlint-core/gitlint/shell.py
@@ -0,0 +1,78 @@
+"""
+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
+capabilities wrt dealing with more edge-case environments on *nix systems that are useful.
+"""
+
+import subprocess
+
+from gitlint.utils import TERMINAL_ENCODING, USE_SH_LIB
+
+
+def shell(cmd):
+ """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:
+ # import exceptions separately, this makes it a little easier to mock them out in the unit tests
+ from sh import (
+ CommandNotFound,
+ ErrorReturnCode,
+ git,
+ )
+else:
+
+ class CommandNotFound(Exception):
+ """Exception indicating a command was not found during execution"""
+
+ 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"""
+
+ def __init__(self, full_cmd, stdout, stderr="", exitcode=0):
+ self.full_cmd = full_cmd
+ self.stdout = stdout
+ self.stderr = stderr
+ self.exit_code = exitcode
+
+ def __str__(self):
+ return self.stdout
+
+ class ErrorReturnCode(ShResult, Exception):
+ """ShResult subclass for unexpected results (acts as an exception)."""
+
+ def git(*command_parts, **kwargs):
+ """Git shell wrapper.
+ Implemented as separate function here, so we can do a 'sh' style imports:
+ `from shell import git`
+ """
+ args = ["git", *list(command_parts)]
+ return _exec(*args, **kwargs)
+
+ def _exec(*args, **kwargs):
+ pipe = subprocess.PIPE
+ popen_kwargs = {"stdout": pipe, "stderr": pipe, "shell": kwargs.get("_tty_out", False)}
+ if "_cwd" in kwargs:
+ popen_kwargs["cwd"] = kwargs["_cwd"]
+
+ try:
+ with subprocess.Popen(args, **popen_kwargs) as p:
+ result = p.communicate()
+ except FileNotFoundError as e:
+ raise CommandNotFound from e
+
+ exit_code = p.returncode
+ stdout = result[0].decode(TERMINAL_ENCODING)
+ stderr = result[1] # 'sh' does not decode the stderr bytes to unicode
+ full_cmd = "" if args is None else " ".join(args)
+
+ # If not _ok_code is specified, then only a 0 exit code is allowed
+ ok_exit_codes = kwargs.get("_ok_code", [0])
+
+ if exit_code in ok_exit_codes:
+ return ShResult(full_cmd, stdout, stderr, exit_code)
+
+ # Unexpected error code => raise ErrorReturnCode
+ raise ErrorReturnCode(full_cmd, stdout, stderr, p.returncode)
diff --git a/gitlint/tests/__init__.py b/gitlint-core/gitlint/tests/__init__.py
index e69de29..e69de29 100644
--- a/gitlint/tests/__init__.py
+++ b/gitlint-core/gitlint/tests/__init__.py
diff --git a/gitlint-core/gitlint/tests/base.py b/gitlint-core/gitlint/tests/base.py
new file mode 100644
index 0000000..3899a5f
--- /dev/null
+++ b/gitlint-core/gitlint/tests/base.py
@@ -0,0 +1,227 @@
+import contextlib
+import copy
+import logging
+import os
+import re
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from gitlint.config import LintConfig
+from gitlint.deprecation import LOG as DEPRECATION_LOG
+from gitlint.deprecation import Deprecation
+from gitlint.git import GitChangedFileStats, GitContext
+from gitlint.utils import FILE_ENCODING, LOG_FORMAT
+
+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."""
+
+ # In case of assert failures, print the full error message
+ maxDiff = None
+
+ # Working directory in which tests in this class are executed
+ working_dir = None
+ # Originally working dir when the test was started
+ original_working_dir = None
+
+ SAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
+ EXPECTED_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
+ GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
+
+ @classmethod
+ def setUpClass(cls):
+ # Run tests a temporary directory to shield them from any local git config
+ cls.original_working_dir = os.getcwd()
+ cls.working_dir = tempfile.mkdtemp()
+ os.chdir(cls.working_dir)
+
+ @classmethod
+ def tearDownClass(cls):
+ # Go back to original working dir and remove our temp working dir
+ os.chdir(cls.original_working_dir)
+ shutil.rmtree(cls.working_dir)
+
+ 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]
+ 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
+ # 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
+ def tempdir():
+ tmpdir = tempfile.mkdtemp()
+ try:
+ yield tmpdir
+ finally:
+ shutil.rmtree(tmpdir)
+
+ @staticmethod
+ def get_sample_path(filename=""):
+ # Don't join up empty files names because this will add a trailing slash
+ if filename == "":
+ return BaseTestCase.SAMPLES_DIR
+
+ return os.path.join(BaseTestCase.SAMPLES_DIR, filename)
+
+ @staticmethod
+ def get_sample(filename=""):
+ """Read and return the contents of a file in gitlint/tests/samples"""
+ sample_path = BaseTestCase.get_sample_path(filename)
+ return Path(sample_path).read_text(encoding=FILE_ENCODING)
+
+ @staticmethod
+ def patch_input(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."""
+ expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename)
+ expected = Path(expected_path).read_text(encoding=FILE_ENCODING)
+
+ if variable_dict:
+ expected = expected.format(**variable_dict)
+ return expected
+
+ @staticmethod
+ def get_user_rules_path():
+ return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules")
+
+ @staticmethod
+ def gitcontext(commit_msg_str, changed_files=None):
+ """Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of
+ changed files"""
+ with patch("gitlint.git.git_commentchar") as comment_char:
+ comment_char.return_value = "#"
+ gitcontext = GitContext.from_commit_msg(commit_msg_str)
+ commit = gitcontext.commits[-1]
+ if 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"""
+ gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files)
+ commit = gitcontext.commits[-1]
+ for attr, value in kwargs.items():
+ setattr(commit, attr, value)
+ return commit
+
+ def assert_logged(self, expected):
+ """Asserts that the logs match an expected string or list.
+ This method knows how to compare a passed list of log lines as well as a newline concatenated string
+ of all loglines."""
+ if isinstance(expected, list):
+ self.assertListEqual(self.logcapture.messages, expected)
+ else:
+ self.assertEqual("\n".join(self.logcapture.messages), expected)
+
+ def assert_log_contains(self, line):
+ """Asserts that a certain line is in the logs"""
+ self.assertIn(line, self.logcapture.messages)
+
+ def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs):
+ """Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed
+ `expected_regex`. This is useful to automatically escape all file paths that might be present in the regex.
+ """
+ return super().assertRaisesRegex(expected_exception, re.escape(expected_regex), *args, **kwargs)
+
+ def clearlog(self):
+ """Clears the log capture"""
+ self.logcapture.clear()
+
+ @contextlib.contextmanager
+ def assertRaisesMessage(self, expected_exception, expected_msg):
+ """Asserts an exception has occurred with a given error message"""
+ try:
+ yield
+ except expected_exception as exc:
+ exception_msg = str(exc)
+ if exception_msg != expected_msg: # pragma: nocover
+ error = f"Right exception, wrong message:\n got: {exception_msg}\n expected: {expected_msg}"
+ raise self.fail(error) from exc
+ # else: everything is fine, just return
+ return
+ except Exception as exc: # pragma: nocover
+ raise self.fail(f"Expected '{expected_exception.__name__}' got '{exc.__class__.__name__}'") from exc
+
+ # No exception raised while we expected one
+ raise self.fail(
+ f"Expected to raise {expected_exception.__name__}, didn't get an exception at all"
+ ) # pragma: nocover
+
+ def object_equality_test(self, obj, attr_list, ctor_kwargs=None):
+ """Helper function to easily implement object equality tests.
+ Creates an object clone for every passed attribute and checks for (in)equality
+ of the original object with the clone based on those attributes' values.
+ This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`.
+ """
+ if not ctor_kwargs:
+ ctor_kwargs = {}
+
+ attr_kwargs = {}
+ for attr in attr_list:
+ attr_kwargs[attr] = getattr(obj, attr)
+
+ # For every attr, clone the object and assert the clone and the original object are equal
+ # Then, change the current attr and assert objects are unequal
+ for attr in attr_list:
+ attr_kwargs_copy = copy.deepcopy(attr_kwargs)
+ attr_kwargs_copy.update(ctor_kwargs)
+ clone = obj.__class__(**attr_kwargs_copy)
+ self.assertEqual(obj, clone)
+
+ # Change attribute and assert objects are different (via both attribute set and ctor)
+ setattr(clone, attr, "föo")
+ self.assertNotEqual(obj, clone)
+ attr_kwargs_copy[attr] = "föo"
+
+ self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy))
+
+
+class LogCapture(logging.Handler):
+ """Mock logging handler used to capture any log messages during tests."""
+
+ def __init__(self, *args, **kwargs):
+ logging.Handler.__init__(self, *args, **kwargs)
+ self.messages = []
+
+ def emit(self, record):
+ self.messages.append(self.format(record))
+
+ def clear(self):
+ self.messages = []
diff --git a/gitlint-core/gitlint/tests/cli/test_cli.py b/gitlint-core/gitlint/tests/cli/test_cli.py
new file mode 100644
index 0000000..c006375
--- /dev/null
+++ b/gitlint-core/gitlint/tests/cli/test_cli.py
@@ -0,0 +1,736 @@
+import os
+import platform
+import sys
+from io import StringIO
+from unittest.mock import patch
+
+import arrow
+from click.testing import CliRunner
+from gitlint import __version__, cli
+from gitlint.shell import CommandNotFound
+from gitlint.tests.base import BaseTestCase
+from gitlint.utils import FILE_ENCODING, TERMINAL_ENCODING
+
+
+class CLITests(BaseTestCase):
+ USAGE_ERROR_CODE = 253
+ GIT_CONTEXT_ERROR_CODE = 254
+ CONFIG_ERROR_CODE = 255
+ GITLINT_SUCCESS_CODE = 0
+
+ def setUp(self):
+ 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")
+ cli.git_version = self.git_version_path.start()
+ cli.git_version.return_value = "git version 1.2.3"
+
+ def tearDown(self):
+ self.git_version_path.stop()
+
+ @staticmethod
+ def get_system_info_dict():
+ """Returns a dict with items related to system values logged by `gitlint --debug`"""
+ return {
+ "platform": platform.platform(),
+ "python_version": sys.version,
+ "gitlint_version": __version__,
+ "GITLINT_USE_SH_LIB": BaseTestCase.GITLINT_USE_SH_LIB,
+ "target": os.path.realpath(os.getcwd()),
+ "TERMINAL_ENCODING": TERMINAL_ENCODING,
+ "FILE_ENCODING": FILE_ENCODING,
+ }
+
+ def test_version(self):
+ """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")
+ def test_lint(self, sh, _):
+ """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\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",
+ ]
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli)
+ 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")
+ def test_lint_multiple_commits(self, sh, _):
+ """Test for --commits 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\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>
+ # 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>
+ # 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>
+ ]
+ # fmt: on
+
+ 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")
+ def test_lint_multiple_commits_csv(self, sh, _):
+ """Test for --commits 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\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>
+ # 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>
+ # 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>
+ ]
+ # fmt: on
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--commits", "6f29bf81,25053cce,4da2656b"])
+ self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_multiple_commits_csv_1"))
+ self.assertEqual(result.exit_code, 3)
+
+ @patch("gitlint.cli.get_stdin_data", return_value=False)
+ @patch("gitlint.git.sh")
+ def test_lint_multiple_commits_config(self, sh, _):
+ """Test for --commits option where some of the commits have gitlint config in the commit message"""
+
+ # 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>
+ "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
+ "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
+ # git log --pretty <FORMAT> <SHA>
+ "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>
+ # 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>
+ # 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>
+ ]
+ # fmt: on
+
+ 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")
+ 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"""
+
+ # fmt: off
+ # Note that the second commit
+ 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\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>
+ # 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>
+ # 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>
+ ]
+ # 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"
+ '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")
+ def test_lint_commit(self, sh, _):
+ """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>
+ ]
+ # fmt: on
+
+ 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)
+ def test_lint_commit_negative(self, _):
+ """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"])
+ expected_output = "Error: --commit and --commits are mutually exclusive, use one or the other.\n"
+ self.assertEqual(result.output, expected_output)
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+
+ @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:
+ 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="WIP: tïtle \n")
+ def test_input_stream_debug(self, _):
+ """Test for linting when a message is passed via stdin, and debug is enabled.
+ This tests specifically that git commit meta is not fetched when not passing --staged"""
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--debug"])
+ self.assertEqual(stderr.getvalue(), self.get_expected("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)
+ self.assert_logged(expected_logs)
+
+ @patch("gitlint.cli.get_stdin_data", return_value="Should be ignored\n")
+ @patch("gitlint.git.sh")
+ def test_lint_ignore_stdin(self, sh, stdin_data):
+ """Test for ignoring stdin when --ignore-stdin flag is enabled"""
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360",
+ "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>
+ ]
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--ignore-stdin"])
+ 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="WIP: tïtle \n")
+ @patch("arrow.now", return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
+ @patch("gitlint.git.sh")
+ def test_lint_staged_stdin(self, sh, _, __):
+ """Test for ignoring stdin when --ignore-stdin flag is enabled"""
+
+ sh.git.side_effect = [
+ "#", # 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:
+ 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()
+ changed_files_stats = (
+ f" {os.path.join('commit-1', 'file-1')}: 1 additions, 5 deletions\n"
+ f" {os.path.join('commit-1', 'file-2')}: 8 additions, 9 deletions"
+ )
+ expected_kwargs.update({"changed_files_stats": changed_files_stats})
+ 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")
+ def test_lint_staged_msg_filename(self, sh, _):
+ """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)
+ ]
+ # fmt: on
+
+ with self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "msg")
+ with open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write("WIP: msg-filename tïtle\n")
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--debug", "--staged", "--msg-filename", msg_filename])
+ self.assertEqual(stderr.getvalue(), self.get_expected("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()
+ changed_files_stats = (
+ f" {os.path.join('commit-1', 'file-1')}: 3 additions, 4 deletions\n"
+ f" {os.path.join('commit-1', 'file-2')}: 4 additions, 7 deletions"
+ )
+ expected_kwargs.update({"changed_files_stats": changed_files_stats})
+ 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)
+ 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")
+ def test_fail_without_commits(self, sh, _):
+ """Test for --debug option"""
+
+ 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:
+ # 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"')
+
+ # 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"')
+
+ @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 open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write("Commït title\n")
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename])
+ self.assertEqual(stderr.getvalue(), expected_output)
+ self.assertEqual(result.exit_code, 1)
+ self.assertEqual(result.output, "")
+
+ @patch("gitlint.cli.get_stdin_data", return_value="WIP: tïtle \n")
+ def test_silent_mode(self, _):
+ """Test for --silent option"""
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--silent"])
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ @patch("gitlint.cli.get_stdin_data", return_value="WIP: tïtle \n")
+ def test_verbosity(self, _):
+ """Test for --verbosity option"""
+ # We only test -v and -vv, more testing is really not required here
+ # -v
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-v"])
+ self.assertEqual(stderr.getvalue(), "1: T2\n1: T5\n3: B6\n")
+ self.assertEqual(result.exit_code, 3)
+ self.assertEqual(result.output, "")
+
+ # -vv
+ expected_output = (
+ "1: T2 Title has trailing whitespace\n"
+ + "1: T5 Title contains the word 'WIP' (case-insensitive)\n"
+ + "3: B6 Body message is missing\n"
+ )
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["-vv"], input="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="WIP: tïtle \n")
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertEqual(result.exit_code, CLITests.CONFIG_ERROR_CODE)
+ self.assertEqual(result.output, "Config Error: Option 'verbosity' must be set between 0 and 3\n")
+
+ @patch("gitlint.cli.get_stdin_data", return_value=False)
+ @patch("gitlint.git.sh")
+ def test_debug(self, sh, _):
+ """Test for --debug option"""
+
+ # 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\x00a123\n"
+ "commït-title1\n\ncommït-body1",
+ "#", # 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",
+ "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",
+ "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:
+ config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug", "--commits", "foo...bar"])
+
+ expected = (
+ "Commit 6f29bf81a8:\n3: B5\n\n"
+ "Commit 25053ccec5:\n1: T3\n3: B5\n\n"
+ "Commit 4da2656b0d:\n2: B4\n3: B5\n3: B6\n"
+ )
+
+ self.assertEqual(stderr.getvalue(), expected)
+ self.assertEqual(result.exit_code, 6)
+
+ expected_kwargs = self.get_system_info_dict()
+ changed_files_stats1 = (
+ f" {os.path.join('commit-1', 'file-1')}: 5 additions, 8 deletions\n"
+ f" {os.path.join('commit-1', 'file-2')}: 2 additions, 9 deletions"
+ )
+ changed_files_stats2 = (
+ f" {os.path.join('commit-2', 'file-1')}: 5 additions, 8 deletions\n"
+ f" {os.path.join('commit-2', 'file-2')}: 7 additions, 9 deletions"
+ )
+ changed_files_stats3 = (
+ f" {os.path.join('commit-3', 'file-1')}: 1 additions, 4 deletions\n"
+ f" {os.path.join('commit-3', 'file-2')}: 3 additions, 4 deletions"
+ )
+ expected_kwargs.update(
+ {
+ "changed_files_stats1": changed_files_stats1,
+ "changed_files_stats2": changed_files_stats2,
+ "changed_files_stats3": changed_files_stats3,
+ }
+ )
+ 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")
+ def test_extra_path(self, _):
+ """Test for --extra-path flag"""
+ # Test extra-path pointing to a directory
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ extra_path = self.get_sample_path("user_rules")
+ result = self.cli.invoke(cli.cli, ["--extra-path", extra_path])
+ 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:
+ 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"
+ 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")
+ def test_contrib(self, _):
+ # Test enabled contrib rules
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["--contrib", "contrib-title-conventional-commits,CC1"])
+ expected_output = self.get_expected("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")
+ 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")
+ def test_config_file(self, _):
+ """Test for --config option"""
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--config", config_path])
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5\n3: B6\n")
+ self.assertEqual(result.exit_code, 2)
+
+ @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"""
+ # Directory as config file
+ config_path = self.get_sample_path("config")
+ result = self.cli.invoke(cli.cli, ["--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, ["--config", config_path])
+ expected_string = f"Error: Invalid value for '-C' / '--config': File {config_path!r} 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, ["--config", config_path])
+ self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
+
+ 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!r} 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)
+
+ def test_config_error(self):
+ result = self.cli.invoke(cli.cli, ["-c", "foo.bar=hur"])
+ self.assertEqual(result.output, "Config Error: No such rule 'foo'\n")
+ 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"""
+ 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
+ result = self.cli.invoke(cli.cli, ["--target", tmpdir_path])
+ # We expect gitlint to tell us that /tmp is not a git repo (this proves that it takes the target parameter
+ # into account).
+ self.assertEqual(result.output, "%s is not a git repository.\n" % tmpdir_path)
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+
+ def test_target_negative(self):
+ """Negative test for the --target option"""
+ # try setting a non-existing target
+ result = self.cli.invoke(cli.cli, ["--target", "/föo/bar"])
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = "Error: Invalid value for '--target': Directory '/föo/bar' does not exist."
+ self.assertEqual(result.output.split("\n")[3], expected_msg)
+
+ # try setting a file as target
+ target_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
+ result = self.cli.invoke(cli.cli, ["--target", target_path])
+ self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
+ expected_msg = f"Error: Invalid value for '--target': Directory {target_path!r} is a file."
+ self.assertEqual(result.output.split("\n")[3], expected_msg)
+
+ @patch("gitlint.config.LintConfigGenerator.generate_config")
+ def test_generate_config(self, generate_config):
+ """Test for the generate-config subcommand"""
+ result = self.cli.invoke(cli.cli, ["generate-config"], input="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"
+ )
+ 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"""
+ # 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"
+ )
+ 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'
+ )
+ self.assertEqual(result.output, expected_msg)
+
+ @patch("gitlint.cli.get_stdin_data", return_value=False)
+ @patch("gitlint.git.sh")
+ def test_git_error(self, sh, _):
+ """Tests that the cli handles git errors properly"""
+ sh.git.side_effect = CommandNotFound("git")
+ result = self.cli.invoke(cli.cli)
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+
+ @patch("gitlint.cli.get_stdin_data", return_value=False)
+ @patch("gitlint.git.sh")
+ def test_no_commits_in_range(self, sh, _):
+ """Test for --commits with the specified range being empty."""
+ sh.git.side_effect = lambda *_args, **_kwargs: ""
+ result = self.cli.invoke(cli.cli, ["--commits", "main...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")
+ def test_named_rules(self, _):
+ 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, "")
+ self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_named_rules_1"))
+ self.assertEqual(result.exit_code, 4)
+
+ # 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)
+ 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
new file mode 100644
index 0000000..c9e4eba
--- /dev/null
+++ b/gitlint-core/gitlint/tests/cli/test_cli_hooks.py
@@ -0,0 +1,277 @@
+import os
+from io import StringIO
+from unittest.mock import patch
+
+from click.testing import CliRunner
+from gitlint import cli, config, hooks
+from gitlint.shell import ErrorReturnCode
+from gitlint.tests.base import BaseTestCase
+from gitlint.utils import FILE_ENCODING
+
+
+class CLIHookTests(BaseTestCase):
+ USAGE_ERROR_CODE = 253
+ GIT_CONTEXT_ERROR_CODE = 254
+ CONFIG_ERROR_CODE = 255
+
+ def setUp(self):
+ 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")
+ 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"))
+ def test_install_hook(self, _, install_hook):
+ """Test for install-hook subcommand"""
+ result = self.cli.invoke(cli.cli, ["install-hook"])
+ expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = f"Successfully installed gitlint commit-msg hook in {expected_path}\n"
+ self.assertEqual(result.output, expected)
+ self.assertEqual(result.exit_code, 0)
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch("gitlint.hooks.GitHookInstaller.install_commit_msg_hook")
+ @patch("gitlint.hooks.git_hooks_dir", return_value=os.path.join("/hür", "dur"))
+ def test_install_hook_target(self, _, install_hook):
+ """Test for install-hook subcommand with a specific --target option specified"""
+ # Specified target
+ result = self.cli.invoke(cli.cli, ["--target", self.SAMPLES_DIR, "install-hook"])
+ expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = "Successfully installed gitlint commit-msg hook in %s\n" % expected_path
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.output, expected)
+
+ expected_config = config.LintConfig()
+ expected_config.target = self.SAMPLES_DIR
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch("gitlint.hooks.GitHookInstaller.install_commit_msg_hook", side_effect=hooks.GitHookInstallerError("tëst"))
+ def test_install_hook_negative(self, install_hook):
+ """Negative test for install-hook subcommand"""
+ result = self.cli.invoke(cli.cli, ["install-hook"])
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+ self.assertEqual(result.output, "tëst\n")
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ install_hook.assert_called_once_with(expected_config)
+
+ @patch("gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook")
+ @patch("gitlint.hooks.git_hooks_dir", return_value=os.path.join("/hür", "dur"))
+ def test_uninstall_hook(self, _, uninstall_hook):
+ """Test for uninstall-hook subcommand"""
+ result = self.cli.invoke(cli.cli, ["uninstall-hook"])
+ expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
+ expected = f"Successfully uninstalled gitlint commit-msg hook from {expected_path}\n"
+ self.assertEqual(result.exit_code, 0)
+ self.assertEqual(result.output, expected)
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ uninstall_hook.assert_called_once_with(expected_config)
+
+ @patch("gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook", side_effect=hooks.GitHookInstallerError("tëst"))
+ def test_uninstall_hook_negative(self, uninstall_hook):
+ """Negative test for uninstall-hook subcommand"""
+ result = self.cli.invoke(cli.cli, ["uninstall-hook"])
+ self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
+ self.assertEqual(result.output, "tëst\n")
+ expected_config = config.LintConfig()
+ expected_config.target = os.path.realpath(os.getcwd())
+ 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.
+ """
+
+ # No need to patch git as we're passing a msg-filename to run-hook, so no git calls are made.
+ # Note that this is the case when passing --staged as well, but that's tested as part of the integration tests
+ # (=end-to-end scenario).
+
+ # Ideally we'd be able to assert that run-hook internally calls the lint cli command, but couldn't make
+ # that work. Have tried many different variatons of mocking and patching without avail. For now, we just
+ # check the output which indirectly proves the same thing.
+
+ with self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "hür")
+ with open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write("WIP: tïtle\n")
+
+ 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(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")
+ def test_run_hook_edit(self, shell):
+ """Test for run-hook subcommand, answering 'e(dit)' after commit-hook"""
+
+ set_editors = [None, "myeditor"]
+ expected_editors = ["vim -n", "myeditor"]
+ commit_messages = ["WIP: höok edit 1", "WIP: höok edit 2"]
+
+ for i in range(0, len(set_editors)):
+ if 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"]), self.tempdir() as tmpdir:
+ msg_filename = os.path.realpath(os.path.join(tmpdir, "hür"))
+ with open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write(commit_messages[i] + "\n")
+
+ 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(stderr.getvalue(), expected)
+
+ # exit code = number of violations
+ self.assertEqual(result.exit_code, 2)
+
+ shell.assert_called_with(expected_editors[i] + " " + msg_filename)
+ self.assert_log_contains("DEBUG: gitlint.cli run-hook: editing commit message")
+ 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"""
+
+ with self.patch_input(["n"]), self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "hür")
+ with open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write("WIP: höok no\n")
+
+ 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(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)
+ # This will cause git to abort the commit
+ self.assertEqual(result.exit_code, 2)
+ 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"]), self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, "hür")
+ with open(msg_filename, "w", encoding=FILE_ENCODING) as f:
+ f.write("WIP: höok yes\n")
+
+ 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(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
+ # This will cause git to keep the commit
+ 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")
+ def test_run_hook_negative(self, sh, _):
+ """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_kwargs = {"git_repo": os.path.realpath(os.getcwd())}
+ expected = self.get_expected("cli/test_cli_hooks/test_run_hook_negative_1", expected_kwargs)
+ 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.exit_code, self.USAGE_ERROR_CODE)
+
+ # CONFIG_ERROR_CODE: incorrect config. Note that this is handled before the hook even runs
+ result = self.cli.invoke(cli.cli, ["-c", "föo.bár=1", "run-hook"])
+ 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")
+ 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
+ """
+
+ 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")
+ self.assertEqual(stderr.getvalue(), expected_stderr)
+ 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")
+ 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
+ """
+
+ 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")
+ 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")
+ 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
+ """
+
+ 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"))
+ # 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")
+ 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'
+ """
+ sh.git.side_effect = [
+ "6f29bf81a8322a04071bb794666e48c443a90360",
+ "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",
+ ]
+
+ with self.patch_input(["e"]), 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")
+ self.assertEqual(stderr.getvalue(), expected)
+ 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/tests/config/test_config.py b/gitlint-core/gitlint/tests/config/test_config.py
index d3fdc2c..439fd93 100644
--- a/gitlint/tests/config/test_config.py
+++ b/gitlint-core/gitlint/tests/config/test_config.py
@@ -1,48 +1,46 @@
-# -*- coding: utf-8 -*-
+from unittest.mock import patch
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint import rules
-from gitlint.config import LintConfig, LintConfigError, LintConfigGenerator, GITLINT_CONFIG_TEMPLATE_SRC_PATH
-from gitlint import options
-from gitlint.tests.base import BaseTestCase, ustr
+from gitlint import options, rules
+from gitlint.config import (
+ GITLINT_CONFIG_TEMPLATE_SRC_PATH,
+ LintConfig,
+ LintConfigError,
+ LintConfigGenerator,
+)
+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()
# non-existing rule
- expected_error_msg = u"No such rule 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config.set_rule_option(u'föobar', u'lïne-length', 60)
+ expected_error_msg = "No such rule 'föobar'"
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
+ config.set_rule_option("föobar", "lïne-length", 60)
# non-existing option
- expected_error_msg = u"Rule 'title-max-length' has no option 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config.set_rule_option('title-max-length', u'föobar', 60)
+ 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", "föobar", 60)
# invalid option value
- expected_error_msg = u"'föo' is not a valid value for option 'title-max-length.line-length'. " + \
- u"Option 'line-length' must be a positive integer (current value: 'föo')."
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config.set_rule_option('title-max-length', 'line-length', u"föo")
+ 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")
def test_set_general_option(self):
config = LintConfig()
@@ -50,11 +48,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)
@@ -80,6 +81,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,14 @@ class LintConfigTests(BaseTestCase):
config.set_general_option("staged", "true")
self.assertTrue(config.staged)
+ # fail-without-commits
+ 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)
@@ -117,27 +130,27 @@ class LintConfigTests(BaseTestCase):
actual_rule = config.rules.find_rule("contrib-title-conventional-commits")
self.assertTrue(actual_rule.is_contrib)
- self.assertEqual(ustr(type(actual_rule)), "<class 'conventional_commit.ConventionalCommit'>")
- self.assertEqual(actual_rule.id, 'CT1')
- self.assertEqual(actual_rule.name, u'contrib-title-conventional-commits')
+ self.assertEqual(str(type(actual_rule)), "<class 'conventional_commit.ConventionalCommit'>")
+ 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(
"types",
- ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"],
+ ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"],
"Comma separated list of allowed commit types.",
)
self.assertListEqual(actual_rule.options_spec, [expected_rule_option])
- self.assertDictEqual(actual_rule.options, {'types': expected_rule_option})
+ self.assertDictEqual(actual_rule.options, {"types": expected_rule_option})
# Check contrib-body-requires-signed-off-by contrib rule
actual_rule = config.rules.find_rule("contrib-body-requires-signed-off-by")
self.assertTrue(actual_rule.is_contrib)
- self.assertEqual(ustr(type(actual_rule)), "<class 'signedoff_by.SignedOffBy'>")
- self.assertEqual(actual_rule.id, 'CC1')
- self.assertEqual(actual_rule.name, u'contrib-body-requires-signed-off-by')
+ self.assertEqual(str(type(actual_rule)), "<class 'signedoff_by.SignedOffBy'>")
+ 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")
@@ -151,30 +164,30 @@ class LintConfigTests(BaseTestCase):
def test_contrib_negative(self):
config = LintConfig()
# non-existent contrib rule
- with self.assertRaisesRegex(LintConfigError, u"No contrib rule with id or name 'föo' found."):
- config.contrib = u"contrib-title-conventional-commits,föo"
+ with self.assertRaisesMessage(LintConfigError, "No contrib rule with id or name 'föo' found."):
+ config.contrib = "contrib-title-conventional-commits,föo"
# UserRuleError, RuleOptionError should be re-raised as LintConfigErrors
- side_effects = [rules.UserRuleError(u"üser-rule"), options.RuleOptionError(u"rüle-option")]
+ 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 self.assertRaisesRegex(LintConfigError, ustr(side_effect)):
- config.contrib = u"contrib-title-conventional-commits"
+ with patch("gitlint.config.rule_finder.find_rule_classes", side_effect=side_effect): # noqa: SIM117
+ with self.assertRaisesMessage(LintConfigError, str(side_effect)):
+ config.contrib = "contrib-title-conventional-commits"
def test_extra_path(self):
config = LintConfig()
config.set_general_option("extra-path", self.get_user_rules_path())
self.assertEqual(config.extra_path, self.get_user_rules_path())
- actual_rule = config.rules.find_rule('UC1')
+ actual_rule = config.rules.find_rule("UC1")
self.assertTrue(actual_rule.is_user_defined)
- self.assertEqual(ustr(type(actual_rule)), "<class 'my_commit_rules.MyUserCommitRule'>")
- self.assertEqual(actual_rule.id, 'UC1')
- self.assertEqual(actual_rule.name, u'my-üser-commit-rule')
+ self.assertEqual(str(type(actual_rule)), "<class 'my_commit_rules.MyUserCommitRule'>")
+ 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, u"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)
@@ -183,67 +196,74 @@ class LintConfigTests(BaseTestCase):
def test_extra_path_negative(self):
config = LintConfig()
- regex = u"Option extra-path must be either an existing directory or file (current value: 'föo/bar')"
+ regex = "Option extra-path must be either an existing directory or file (current value: 'föo/bar')"
# incorrect extra_path
- with self.assertRaisesRegex(LintConfigError, regex):
- config.extra_path = u"föo/bar"
+ with self.assertRaisesMessage(LintConfigError, regex):
+ config.extra_path = "föo/bar"
# extra path contains classes with errors
- with self.assertRaisesRegex(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):
config = LintConfig()
# Note that we shouldn't test whether we can set unicode because python just doesn't allow unicode attributes
- with self.assertRaisesRegex(LintConfigError, "'foo' is not a valid gitlint option"):
- config.set_general_option("foo", u"bår")
+ with self.assertRaisesMessage(LintConfigError, "'foo' is not a valid gitlint option"):
+ config.set_general_option("foo", "bår")
# try setting _config_path, this is a real attribute of LintConfig, but the code should prevent it from
# being set
- with self.assertRaisesRegex(LintConfigError, "'_config_path' is not a valid gitlint option"):
- config.set_general_option("_config_path", u"bår")
+ with self.assertRaisesMessage(LintConfigError, "'_config_path' is not a valid gitlint option"):
+ config.set_general_option("_config_path", "bår")
# invalid verbosity
- incorrect_values = [-1, u"föo"]
+ incorrect_values = [-1, "föo"]
for value in incorrect_values:
- expected_msg = u"Option 'verbosity' must be a positive integer (current value: '{0}')".format(value)
- with self.assertRaisesRegex(LintConfigError, expected_msg):
+ expected_msg = f"Option 'verbosity' must be a positive integer (current value: '{value}')"
+ with self.assertRaisesMessage(LintConfigError, expected_msg):
config.verbosity = value
incorrect_values = [4]
for value in incorrect_values:
- with self.assertRaisesRegex(LintConfigError, "Option 'verbosity' must be set between 0 and 3"):
+ with self.assertRaisesMessage(LintConfigError, "Option 'verbosity' must be set between 0 and 3"):
config.verbosity = value
# invalid ignore_xxx_commits
- ignore_attributes = ["ignore_merge_commits", "ignore_fixup_commits", "ignore_squash_commits",
- "ignore_revert_commits"]
- incorrect_values = [-1, 4, u"föo"]
+ 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.assertRaisesRegex(LintConfigError,
- "Option '{0}' must be either 'true' or 'false'".format(option_name)):
+ 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']:
+ for attribute in ["debug", "staged", "ignore_stdin", "fail_without_commits", "regex_style_search"]:
option_name = attribute.replace("_", "-")
- with self.assertRaisesRegex(LintConfigError,
- "Option '{0}' must be either 'true' or 'false'".format(option_name)):
- setattr(config, attribute, u"föobar")
+ 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.assertRaisesRegex(LintConfigError,
- u"Option target must be an existing directory (current value: 'föo/bar')"):
- config.target = u"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):
# Test that the lintconfig rules are not modified when setting config.ignore
@@ -254,10 +274,47 @@ class LintConfigTests(BaseTestCase):
self.assertEqual(config.ignore, ["T1", "T2"])
self.assertSequenceEqual(config.rules, original_rules)
+ def test_config_equality(self):
+ self.assertEqual(LintConfig(), LintConfig())
+ self.assertNotEqual(LintConfig(), LintConfigGenerator())
+
+ # Ensure LintConfig are not equal if they differ on their attributes
+ 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)
+ self.assertNotEqual(LintConfig(), config)
+
+ # Other attributes don't matter
+ config1 = LintConfig()
+ config2 = LintConfig()
+ config1.foo = "bår"
+ self.assertEqual(config1, config2)
+ config2.foo = "dūr"
+ self.assertEqual(config1, config2)
+
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(u"föo/bar/test")
- copy.assert_called_with(GITLINT_CONFIG_TEMPLATE_SRC_PATH, u"föo/bar/test")
+ 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
new file mode 100644
index 0000000..ac2a896
--- /dev/null
+++ b/gitlint-core/gitlint/tests/config/test_config_builder.py
@@ -0,0 +1,275 @@
+import copy
+
+from gitlint import rules
+from gitlint.config import LintConfig, LintConfigBuilder, LintConfigError
+from gitlint.tests.base import BaseTestCase
+
+
+class LintConfigBuilderTests(BaseTestCase):
+ def test_set_option(self):
+ config_builder = LintConfigBuilder()
+ config = config_builder.build()
+
+ # assert some defaults
+ self.assertEqual(config.get_rule_option("title-max-length", "line-length"), 72)
+ self.assertEqual(config.get_rule_option("body-max-line-length", "line-length"), 80)
+ self.assertListEqual(config.get_rule_option("title-must-not-contain-word", "words"), ["WIP"])
+ self.assertEqual(config.verbosity, 3)
+
+ # Make some changes and check blueprint
+ config_builder.set_option("title-max-length", "line-length", 100)
+ config_builder.set_option("general", "verbosity", 2)
+ config_builder.set_option("title-must-not-contain-word", "words", ["foo", "bar"])
+ expected_blueprint = {
+ "title-must-not-contain-word": {"words": ["foo", "bar"]},
+ "title-max-length": {"line-length": 100},
+ "general": {"verbosity": 2},
+ }
+ self.assertDictEqual(config_builder._config_blueprint, expected_blueprint)
+
+ # Build config and verify that the changes have occurred and no other changes
+ config = config_builder.build()
+ self.assertEqual(config.get_rule_option("title-max-length", "line-length"), 100)
+ self.assertEqual(config.get_rule_option("body-max-line-length", "line-length"), 80) # should be unchanged
+ self.assertListEqual(config.get_rule_option("title-must-not-contain-word", "words"), ["foo", "bar"])
+ self.assertEqual(config.verbosity, 2)
+
+ def test_set_from_commit_ignore_all(self):
+ config = LintConfig()
+ original_rules = config.rules
+ original_rule_ids = [rule.id for rule in original_rules]
+
+ config_builder = LintConfigBuilder()
+
+ # nothing gitlint
+ config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint\nfoo"))
+ config = config_builder.build()
+ self.assertSequenceEqual(config.rules, original_rules)
+ self.assertListEqual(config.ignore, [])
+
+ # ignore all rules
+ config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ # ignore all rules, no space
+ config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore:all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ # ignore all rules, more spacing
+ config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: \t all\nfoo"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, original_rule_ids)
+
+ def test_set_from_commit_ignore_specific(self):
+ # ignore specific rules
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: T1, body-hard-tab"))
+ config = config_builder.build()
+ self.assertEqual(config.ignore, ["T1", "body-hard-tab"])
+
+ def test_set_from_config_file(self):
+ # regular config file load, no problems
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(self.get_sample_path("config/gitlintconfig"))
+ config = config_builder.build()
+
+ # Do some assertions on the config
+ self.assertEqual(config.verbosity, 1)
+ self.assertFalse(config.debug)
+ self.assertFalse(config.ignore_merge_commits)
+ self.assertIsNone(config.extra_path)
+ self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
+
+ self.assertEqual(config.get_rule_option("title-max-length", "line-length"), 20)
+ self.assertEqual(config.get_rule_option("body-max-line-length", "line-length"), 30)
+
+ def test_set_from_config_file_negative(self):
+ config_builder = LintConfigBuilder()
+
+ # bad config file load
+ foo_path = self.get_sample_path("föo")
+ expected_error_msg = f"Invalid file path: {foo_path}"
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
+ config_builder.set_from_config_file(foo_path)
+
+ # error during file parsing
+ path = self.get_sample_path("config/no-sections")
+ expected_error_msg = "File contains no section headers."
+ # We only match the start of the message here, since the exact message can vary depending on platform
+ with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ config_builder.set_from_config_file(path)
+
+ # non-existing rule
+ path = self.get_sample_path("config/nonexisting-rule")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = "No such rule 'föobar'"
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # non-existing general option
+ path = self.get_sample_path("config/nonexisting-general-option")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = "'foo' is not a valid gitlint option"
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # non-existing option
+ path = self.get_sample_path("config/nonexisting-option")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = "Rule 'title-max-length' has no option 'föobar'"
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
+ config_builder.build()
+
+ # invalid option value
+ path = self.get_sample_path("config/invalid-option-value")
+ config_builder = LintConfigBuilder()
+ config_builder.set_from_config_file(path)
+ expected_error_msg = (
+ "'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()
+
+ def test_set_config_from_string_list(self):
+ config = LintConfig()
+
+ # change and assert changes
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_string_list(
+ [
+ "general.verbosity=1",
+ "title-max-length.line-length=60",
+ "body-max-line-length.line-length=120",
+ "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.verbosity, 1)
+
+ def test_set_config_from_string_list_negative(self):
+ config_builder = LintConfigBuilder()
+
+ # assert error on incorrect rule - this happens at build time
+ config_builder.set_config_from_string_list(["föo.bar=1"])
+ with self.assertRaisesMessage(LintConfigError, "No such rule 'föo'"):
+ config_builder.build()
+
+ # no equal sign
+ expected_msg = "'föo.bar' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesMessage(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list(["föo.bar"])
+
+ # missing value
+ expected_msg = "'föo.bar=' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesMessage(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list(["föo.bar="])
+
+ # space instead of equal sign
+ expected_msg = "'föo.bar 1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
+ with self.assertRaisesMessage(LintConfigError, expected_msg):
+ config_builder.set_config_from_string_list(["föo.bar 1"])
+
+ # 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(["föobar=1"])
+
+ def test_rebuild_config(self):
+ # normal config build
+ config_builder = LintConfigBuilder()
+ config_builder.set_option("general", "verbosity", 3)
+ lint_config = config_builder.build()
+ self.assertEqual(lint_config.verbosity, 3)
+
+ # check that existing config gets overwritten when we pass it to a configbuilder with different options
+ existing_lintconfig = LintConfig()
+ existing_lintconfig.verbosity = 2
+ lint_config = config_builder.build(existing_lintconfig)
+ self.assertEqual(lint_config.verbosity, 3)
+ self.assertEqual(existing_lintconfig.verbosity, 3)
+
+ def test_clone(self):
+ config_builder = LintConfigBuilder()
+ config_builder.set_option("general", "verbosity", 2)
+ config_builder.set_option("title-max-length", "line-length", 100)
+ expected = {"title-max-length": {"line-length": 100}, "general": {"verbosity": 2}}
+ self.assertDictEqual(config_builder._config_blueprint, expected)
+
+ # Clone and verify that the blueprint is the same as the original
+ cloned_builder = config_builder.clone()
+ self.assertDictEqual(cloned_builder._config_blueprint, expected)
+
+ # Modify the original and make sure we're not modifying the clone (i.e. check that the copy is a deep copy)
+ config_builder.set_option("title-max-length", "line-length", 120)
+ self.assertDictEqual(cloned_builder._config_blueprint, expected)
+
+ def test_named_rules(self):
+ # Store a copy of the default rules from the config, so we can reference it later
+ config_builder = LintConfigBuilder()
+ config = config_builder.build()
+ default_rules = copy.deepcopy(config.rules)
+ self.assertEqual(default_rules, config.rules) # deepcopy should be equal
+
+ # 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 = [
+ "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")
+
+ expected_rules = copy.deepcopy(default_rules)
+ 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["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
+ # we try this with all different rule qualifiers to ensure they all are normalized and map
+ # 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")
+ # 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")
+ self.assertEqual(cb.build().rules, expected_rules)
+ my_rule.options["regex"].set("wrong")
+
+ def test_named_rules_negative(self):
+ # Invalid rule name (T7 = title-match-regex)
+ for invalid_name in ["", " ", " ", "\t", "\n", "å b", "å:b", "åb:", ":åb"]:
+ config_builder = LintConfigBuilder()
+ 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()
+
+ # Invalid parent rule name
+ config_builder = LintConfigBuilder()
+ config_builder.set_option("Ž123:foöbar", "fåke-option", "fåke-value")
+ with self.assertRaisesMessage(LintConfigError, "No such rule 'Ž123' (named rule: 'Ž123:foöbar')"):
+ config_builder.build()
+
+ # Invalid option name (this is the same as with regular rules)
+ config_builder = LintConfigBuilder()
+ config_builder.set_option("T7:foöbar", "blå", "my-rëgex")
+ with self.assertRaisesMessage(LintConfigError, "Rule 'T7:foöbar' has no option 'blå'"):
+ config_builder.build()
diff --git a/gitlint/tests/config/test_config_precedence.py b/gitlint-core/gitlint/tests/config/test_config_precedence.py
index 9689e55..a7f94cf 100644
--- a/gitlint/tests/config/test_config_precedence.py
+++ b/gitlint-core/gitlint/tests/config/test_config_precedence.py
@@ -1,89 +1,87 @@
-# -*- coding: utf-8 -*-
-
-try:
- # python 2.x
- from StringIO import StringIO
-except ImportError:
- # python 3.x
- from io import StringIO
+from io import StringIO
+from unittest.mock import patch
from click.testing import CliRunner
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
from gitlint import cli
from gitlint.config import LintConfigBuilder
+from gitlint.tests.base import BaseTestCase
class LintConfigPrecedenceTests(BaseTestCase):
def setUp(self):
+ super().setUp()
self.cli = CliRunner()
- @patch('gitlint.cli.get_stdin_data', return_value=u"WIP\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
# Test that the config precedence is followed:
# 1. commandline convenience flags
- # 2. commandline -c flags
- # 3. config file
- # 4. default config
+ # 2. environment variables
+ # 3. commandline -c flags
+ # 4. config file
+ # 5. default config
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\"\n")
+ 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"}
+ )
+ self.assertEqual(result.output, "")
+ self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
- # 2. commandline -c flags
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ # 3. commandline -c flags
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
result = self.cli.invoke(cli.cli, ["-c", "general.verbosity=2", "--config", config_path])
self.assertEqual(result.output, "")
self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive)\n")
- # 3. config file
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ # 4. config file
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
result = self.cli.invoke(cli.cli, ["--config", config_path])
self.assertEqual(result.output, "")
self.assertEqual(stderr.getvalue(), "1: T5\n")
- # 4. default config
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ # 5. default config
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
result = self.cli.invoke(cli.cli)
self.assertEqual(result.output, "")
- self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP\"\n")
+ self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
- @patch('gitlint.cli.get_stdin_data', return_value=u"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(),
- u"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:
- get_stdin_data.return_value = u"This is å test"
+ 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(), u"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
@@ -91,10 +89,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/tests/config/test_rule_collection.py b/gitlint-core/gitlint/tests/config/test_rule_collection.py
index 089992c..2cb0e5c 100644
--- a/gitlint/tests/config/test_rule_collection.py
+++ b/gitlint-core/gitlint/tests/config/test_rule_collection.py
@@ -1,52 +1,50 @@
-# -*- coding: utf-8 -*-
-
from collections import OrderedDict
+
from gitlint import rules
from gitlint.config import RuleCollection
from gitlint.tests.base import BaseTestCase
class RuleCollectionTests(BaseTestCase):
-
def test_add_rule(self):
collection = RuleCollection()
- collection.add_rule(rules.TitleMaxLength, u"my-rüle", {"my_attr": u"föo", "my_attr2": 123})
+ collection.add_rule(rules.TitleMaxLength, "my-rüle", {"my_attr": "föo", "my_attr2": 123})
expected = rules.TitleMaxLength()
- expected.id = u"my-rüle"
- expected.my_attr = u"föo"
+ expected.id = "my-rüle"
+ expected.my_attr = "föo"
expected.my_attr2 = 123
self.assertEqual(len(collection), 1)
- self.assertDictEqual(collection._rules, OrderedDict({u"my-rüle": expected}))
- # Need to explicitely compare expected attributes as the rule.__eq__ method does not compare these attributes
+ self.assertDictEqual(collection._rules, OrderedDict({"my-rüle": expected}))
+ # Need to explicitly compare expected attributes as the rule.__eq__ method does not compare these attributes
self.assertEqual(collection._rules[expected.id].my_attr, expected.my_attr)
self.assertEqual(collection._rules[expected.id].my_attr2, expected.my_attr2)
def test_add_find_rule(self):
collection = RuleCollection()
- collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"my_attr": u"föo"})
+ collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"my_attr": "föo"})
# 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, u"föo")
+ 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, u"föo")
+ 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):
collection = RuleCollection()
- collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"foo": u"bår"})
- collection.add_rules([rules.BodyHardTab], {"hur": u"dûr"})
+ collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"foo": "bår"})
+ collection.add_rules([rules.BodyHardTab], {"hur": "dûr"})
# Assert all rules are there as expected
self.assertEqual(len(collection), 3)
@@ -54,11 +52,11 @@ class RuleCollectionTests(BaseTestCase):
self.assertEqual(collection.find_rule(expected_rule.id), expected_rule)
# Delete rules by attr, assert that we still have the right rules in the collection
- collection.delete_rules_by_attr("foo", u"bår")
+ collection.delete_rules_by_attr("foo", "bår")
self.assertEqual(len(collection), 1)
self.assertIsNone(collection.find_rule(rules.TitleMaxLength.id), None)
self.assertIsNone(collection.find_rule(rules.TitleTrailingWhitespace.id), None)
found = collection.find_rule(rules.BodyHardTab.id)
self.assertEqual(found, rules.BodyHardTab())
- self.assertEqual(found.hur, u"dûr")
+ self.assertEqual(found.hur, "dûr")
diff --git a/gitlint/tests/contrib/__init__.py b/gitlint-core/gitlint/tests/contrib/__init__.py
index e69de29..e69de29 100644
--- a/gitlint/tests/contrib/__init__.py
+++ b/gitlint-core/gitlint/tests/contrib/__init__.py
diff --git a/gitlint/tests/rules/__init__.py b/gitlint-core/gitlint/tests/contrib/rules/__init__.py
index e69de29..e69de29 100644
--- a/gitlint/tests/rules/__init__.py
+++ b/gitlint-core/gitlint/tests/contrib/rules/__init__.py
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..2bad2ed
--- /dev/null
+++ b/gitlint-core/gitlint/tests/contrib/rules/test_authors_commit.py
@@ -0,0 +1,105 @@
+from collections import namedtuple
+from unittest.mock import patch
+
+from gitlint.config import LintConfig
+from gitlint.contrib.rules.authors_commit import AllowedAuthors
+from gitlint.rules import RuleViolation
+from gitlint.tests.base import BaseTestCase
+
+
+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.assertRaisesMessage(FileNotFoundError, "No AUTHORS file found!"):
+ AllowedAuthors._read_authors_from_file(self.gitcontext)
diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py
new file mode 100644
index 0000000..cbab684
--- /dev/null
+++ b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py
@@ -0,0 +1,82 @@
+from gitlint.config import LintConfig
+from gitlint.contrib.rules.conventional_commit import ConventionalCommit
+from gitlint.rules import RuleViolation
+from gitlint.tests.base import BaseTestCase
+
+
+class ContribConventionalCommitTests(BaseTestCase):
+ def test_enable(self):
+ # Test that rule can be enabled in config
+ for rule_ref in ["CT1", "contrib-title-conventional-commits"]:
+ config = LintConfig()
+ config.contrib = [rule_ref]
+ self.assertIn(ConventionalCommit(), config.rules)
+
+ def test_conventional_commits(self):
+ rule = ConventionalCommit()
+
+ # No violations when using a correct type and format
+ for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"]:
+ violations = rule.validate(type + ": föo", None)
+ self.assertListEqual([], violations)
+
+ # assert violation on wrong type
+ expected_violation = RuleViolation(
+ "CT1",
+ "Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, 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",
+ )
+ 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",
+ )
+ 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",
+ )
+ violations = rule.validate("fix föo", None)
+ self.assertListEqual([expected_violation], violations)
+
+ # assert no violation when use ! for breaking changes without scope
+ violations = rule.validate("feat!: föo", None)
+ self.assertListEqual([], violations)
+
+ # assert no violation when use ! for breaking changes with scope
+ violations = rule.validate("fix(scope)!: föo", None)
+ self.assertListEqual([], violations)
+
+ # assert no violation when adding new type
+ 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)
+
+ # assert violation when using incorrect type when types have been reconfigured
+ violations = rule.validate("fix: hür dur", None)
+ expected_violation = RuleViolation("CT1", "Title does not start with one of föo, bär", "fix: hür dur")
+ self.assertListEqual([expected_violation], violations)
+
+ # assert no violation when adding new type named with numbers
+ 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..1983367
--- /dev/null
+++ b/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py
@@ -0,0 +1,34 @@
+from gitlint.config import LintConfig
+from gitlint.contrib.rules.disallow_cleanup_commits import DisallowCleanupCommits
+from gitlint.rules import RuleViolation
+from gitlint.tests.base import BaseTestCase
+
+
+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/tests/contrib/test_signedoff_by.py b/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py
index 934aec5..bf526a0 100644
--- a/gitlint/tests/contrib/test_signedoff_by.py
+++ b/gitlint-core/gitlint/tests/contrib/rules/test_signedoff_by.py
@@ -1,32 +1,28 @@
-
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rules import RuleViolation
-from gitlint.contrib.rules.signedoff_by import SignedOffBy
-
from gitlint.config import LintConfig
+from gitlint.contrib.rules.signedoff_by import SignedOffBy
+from gitlint.rules import RuleViolation
+from gitlint.tests.base import BaseTestCase
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)
def test_signedoff_by(self):
- # No violations when 'Signed-Off-By' line is present
+ # No violations when 'Signed-off-by' line is present
rule = SignedOffBy()
- violations = rule.validate(self.gitcommit(u"Föobar\n\nMy Body\nSigned-Off-By: John Smith"))
+ violations = rule.validate(self.gitcommit("Föobar\n\nMy Body\nSigned-off-by: John Smith"))
self.assertListEqual([], violations)
- # Assert violation when no 'Signed-Off-By' line is present
- violations = rule.validate(self.gitcommit(u"Föobar\n\nMy Body"))
- expected_violation = RuleViolation("CC1", "Body does not contain a 'Signed-Off-By' line", line_nr=1)
+ # Assert violation when no 'Signed-off-by' line is present
+ violations = rule.validate(self.gitcommit("Föobar\n\nMy Body"))
+ expected_violation = RuleViolation("CC1", "Body does not contain a 'Signed-off-by' line", line_nr=1)
self.assertListEqual(violations, [expected_violation])
- # Assert violation when no 'Signed-Off-By' in title but not in body
- violations = rule.validate(self.gitcommit(u"Signed-Off-By\n\nFöobar"))
+ # Assert violation when no 'Signed-off-by' in title but not in body
+ violations = rule.validate(self.gitcommit("Signed-off-by\n\nFöobar"))
self.assertListEqual(violations, [expected_violation])
diff --git a/gitlint/tests/contrib/test_contrib_rules.py b/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py
index 3fa4048..b0372d8 100644
--- a/gitlint/tests/contrib/test_contrib_rules.py
+++ b/gitlint-core/gitlint/tests/contrib/test_contrib_rules.py
@@ -1,22 +1,18 @@
-# -*- coding: utf-8 -*-
import os
-from gitlint.tests.base import BaseTestCase
-from gitlint.contrib import rules as contrib_rules
-from gitlint.tests import contrib as contrib_tests
from gitlint import rule_finder, rules
-
-from gitlint.utils import ustr
+from gitlint.contrib import rules as contrib_rules
+from gitlint.tests.base import BaseTestCase
+from gitlint.tests.contrib import rules as contrib_tests
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)
@@ -24,17 +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 = ustr(u"test_" + filename)
- error_msg = u"Every Contrib Rule must have associated tests. " + \
- "Expected test file {0} not found.".format(os.path.join(contrib_tests_dir,
- expected_test_file))
+ 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)
@@ -50,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)
@@ -64,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_contrib_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_contrib_1
new file mode 100644
index 0000000..b95433b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_contrib_1
@@ -0,0 +1,2 @@
+1: CC1 Body does not contain a 'Signed-off-by' line
+1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"
diff --git a/gitlint/tests/expected/test_cli/test_debug_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1
index 612f78e..046294c 100644
--- a/gitlint/tests/expected/test_cli/test_debug_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1
@@ -4,6 +4,8 @@ DEBUG: gitlint.cli Python version: {python_version}
DEBUG: gitlint.cli Git version: git version 1.2.3
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: {config_path}
[GENERAL]
@@ -12,10 +14,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}
@@ -26,6 +31,11 @@ target: {target}
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=20
T2: title-trailing-whitespace
@@ -35,7 +45,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP,bögus
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=30
B5: body-min-length
@@ -47,12 +59,19 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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')
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.lint Commit Object
--- Commit Message ----
commït-title1
@@ -63,12 +82,19 @@ 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:
+{changed_files_stats1}
-----------------------
+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.lint Commit Object
--- Commit Message ----
commït-title2.
@@ -79,24 +105,35 @@ 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:
+{changed_files_stats2}
-----------------------
+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.lint Commit Object
--- Commit Message ----
-föo
+föobar
bar
--- Meta info ---------
Author: test åuthor3 <test-email3@föo.com>
Date: 2016-12-05 15:28:15 +0100
is-merge-commit: False
is-fixup-commit: False
+is-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:
+{changed_files_stats3}
-----------------------
DEBUG: gitlint.cli Exit Code = 6 \ No newline at end of file
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_1
index 4326729..4326729 100644
--- a/gitlint/tests/expected/test_cli/test_input_stream_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_1
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_debug_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_1
index 4326729..4326729 100644
--- a/gitlint/tests/expected/test_cli/test_input_stream_debug_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_1
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_debug_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2
index a9028e1..46a8adf 100644
--- a/gitlint/tests/expected/test_cli/test_input_stream_debug_2
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2
@@ -4,6 +4,8 @@ DEBUG: gitlint.cli Python version: {python_version}
DEBUG: gitlint.cli Git version: git version 1.2.3
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -12,10 +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: False
+fail-without-commits: False
+regex-style-search: False
verbosity: 3
debug: True
target: {target}
@@ -26,6 +31,11 @@ target: {target}
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
@@ -35,7 +45,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=80
B5: body-min-length
@@ -47,12 +59,15 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ B8: body-match-regex
+ regex=None
M1: author-valid-email
- regex=[^@ ]+@[^@ ]+\.[^@ ]+
+ regex=^[^@ ]+@[^@ ]+\.[^@ ]+
DEBUG: gitlint.cli Stdin data: 'WIP: tïtle
'
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.lint Commit Object
@@ -63,9 +78,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_commit_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_commit_1
new file mode 100644
index 0000000..b9f0742
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_commit_1
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title1"
+3: B5 Body message is too short (12<20): "commït-body1"
diff --git a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_1
index be3288b..be3288b 100644
--- a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_1
diff --git a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_config_1
index 1bf0503..1bf0503 100644
--- a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_config_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_config_1
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_csv_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_csv_1
new file mode 100644
index 0000000..be3288b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_multiple_commits_csv_1
@@ -0,0 +1,8 @@
+Commit 6f29bf81a8:
+3: B5 Body message is too short (12<20): "commït-body1"
+
+Commit 25053ccec5:
+3: B5 Body message is too short (12<20): "commït-body2"
+
+Commit 4da2656b0d:
+3: B5 Body message is too short (12<20): "commït-body3"
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_1
index 9a9091b..9a9091b 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_1
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2
index 3e5dcb6..6b96a45 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2
@@ -4,6 +4,8 @@ DEBUG: gitlint.cli Python version: {python_version}
DEBUG: gitlint.cli Git version: git version 1.2.3
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -12,10 +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}
@@ -26,6 +31,11 @@ target: {target}
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
@@ -35,7 +45,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=80
B5: body-min-length
@@ -47,13 +59,20 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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.lint Commit Object
--- Commit Message ----
WIP: msg-filename tïtle
@@ -62,9 +81,13 @@ 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:
+{changed_files_stats}
-----------------------
DEBUG: gitlint.cli Exit Code = 2 \ No newline at end of file
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_1
index 4326729..4326729 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_1
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2
index 03fd8c3..45d94e2 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2
@@ -4,6 +4,8 @@ DEBUG: gitlint.cli Python version: {python_version}
DEBUG: gitlint.cli Git version: git version 1.2.3
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -12,10 +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}
@@ -26,6 +31,11 @@ target: {target}
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
@@ -35,7 +45,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=80
B5: body-min-length
@@ -47,15 +59,22 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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
'
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.lint Commit Object
--- Commit Message ----
WIP: tïtle
@@ -64,9 +83,13 @@ 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:
+{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_named_rules_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_1
new file mode 100644
index 0000000..a581d05
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_1
@@ -0,0 +1,4 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tëst tïtle"
+1: T5:even-more-wörds Title contains the word 'tïtle' (case-insensitive): "WIP: tëst tïtle"
+1: T5:extra-wörds Title contains the word 'tëst' (case-insensitive): "WIP: tëst tïtle"
+3: B6 Body message is missing
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
new file mode 100644
index 0000000..f4df46e
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli/test_named_rules_2
@@ -0,0 +1,92 @@
+DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
+DEBUG: gitlint.cli Platform: {platform}
+DEBUG: gitlint.cli Python version: {python_version}
+DEBUG: gitlint.cli Git version: git version 1.2.3
+DEBUG: gitlint.cli Gitlint version: {gitlint_version}
+DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
+DEBUG: gitlint.cli TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
+DEBUG: gitlint.cli Configuration
+config-path: {config_path}
+[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,bögus
+ 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=^[^@ ]+@[^@ ]+\.[^@ ]+
+ 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
+ words=hür,tïtle
+
+DEBUG: gitlint.cli Stdin data: 'WIP: tëst tïtle'
+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.lint Commit Object
+--- Commit Message ----
+WIP: tëst tïtle
+--- Meta info ---------
+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/expected/cli/test_cli_hooks/test_hook_config_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr
new file mode 100644
index 0000000..cfacd42
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr
@@ -0,0 +1,2 @@
+1: T1 Title exceeds max length (27>5): "WIP: Test hook config tïtle"
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Test hook config tïtle"
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout
new file mode 100644
index 0000000..bee014b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout
@@ -0,0 +1,5 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr
new file mode 100644
index 0000000..3eb8fca
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr
@@ -0,0 +1,6 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
+3: B6 Body message is missing
+1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
+3: B6 Body message is missing
+1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
+3: B6 Body message is missing
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout
new file mode 100644
index 0000000..b57a35a
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout
@@ -0,0 +1,14 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Commit aborted.
+Your commit message:
+-----------------------------------------------
+{commit_msg}
+-----------------------------------------------
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr
new file mode 100644
index 0000000..11c3cd8
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title"
+3: B5 Body message is too short (11<20): "commït-body"
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout
new file mode 100644
index 0000000..0b8e90e
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout
@@ -0,0 +1,4 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Editing only possible when --msg-filename is specified.
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr
new file mode 100644
index 0000000..6d0c9cf
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: höok no"
+3: B6 Body message is missing
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout
new file mode 100644
index 0000000..98a83b1
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout
@@ -0,0 +1,8 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Commit aborted.
+Your commit message:
+-----------------------------------------------
+WIP: höok no
+-----------------------------------------------
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr
new file mode 100644
index 0000000..a8d8760
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle"
+3: B6 Body message is missing
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout
new file mode 100644
index 0000000..bee014b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout
@@ -0,0 +1,5 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout
new file mode 100644
index 0000000..da1ef0b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout
@@ -0,0 +1,2 @@
+gitlint: checking commit message...
+gitlint: OK (no violations in commit message)
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr
new file mode 100644
index 0000000..1404f4a
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Test hook stdin tïtle"
+3: B6 Body message is missing
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout
new file mode 100644
index 0000000..bee014b
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout
@@ -0,0 +1,5 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr
new file mode 100644
index 0000000..da6f874
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: höok yes"
+3: B6 Body message is missing
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout
new file mode 100644
index 0000000..0414712
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout
@@ -0,0 +1,4 @@
+gitlint: checking commit message...
+-----------------------------------------------
+gitlint: Your commit message contains violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] \ No newline at end of file
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_1 b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_1
new file mode 100644
index 0000000..9082830
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_1
@@ -0,0 +1,2 @@
+gitlint: checking commit message...
+{git_repo} is not a git repository.
diff --git a/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_2 b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_2
new file mode 100644
index 0000000..bafbf29
--- /dev/null
+++ b/gitlint-core/gitlint/tests/expected/cli/test_cli_hooks/test_run_hook_negative_2
@@ -0,0 +1,2 @@
+gitlint: checking commit message...
+Error: The 'staged' option (--staged) can only be used when using '--msg-filename' or when piping data to gitlint via stdin.
diff --git a/gitlint-core/gitlint/tests/git/test_git.py b/gitlint-core/gitlint/tests/git/test_git.py
new file mode 100644
index 0000000..b6a146a
--- /dev/null
+++ b/gitlint-core/gitlint/tests/git/test_git.py
@@ -0,0 +1,121 @@
+import os
+from unittest.mock import call, patch
+
+from gitlint.git import (
+ GitContext,
+ GitContextError,
+ GitNotInstalledError,
+ git_commentchar,
+ git_hooks_dir,
+)
+from gitlint.shell import CommandNotFound, ErrorReturnCode
+from gitlint.tests.base import BaseTestCase
+
+
+class GitTests(BaseTestCase):
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"}
+
+ @patch("gitlint.git.sh")
+ def test_get_latest_commit_command_not_found(self, sh):
+ sh.git.side_effect = CommandNotFound("git")
+ expected_msg = (
+ "'git' command not found. You need to install git to use gitlint on a local repository. "
+ + "See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git."
+ )
+ with self.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")
+ def test_get_latest_commit_git_error(self, sh):
+ # Current directory not a git repo
+ err = b"fatal: Not a git repository (or any of the parent directories): .git"
+ sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
+
+ with self.assertRaisesMessage(GitContextError, "fåke/path is not a git repository."):
+ 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)
+ sh.git.reset_mock()
+
+ err = b"fatal: Random git error"
+ sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
+
+ expected_msg = f"An error occurred while executing 'git log -1 --pretty=%H': {err}"
+ with self.assertRaisesMessage(GitContextError, 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")
+ def test_git_no_commits_error(self, sh):
+ # No commits: returned by 'git log'
+ 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)
+
+ expected_msg = "Current branch has no commits. Gitlint requires at least one commit to function."
+ with self.assertRaisesMessage(GitContextError, 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")
+ 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>...]'"
+ )
+
+ sh.git.side_effect = [
+ "#\n", # git config --get core.commentchar
+ ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err),
+ "test-branch", # git branch --show-current
+ ]
+
+ context = GitContext.from_commit_msg("test")
+ self.assertEqual(context.current_branch, "test-branch")
+
+ # 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):
+ git.return_value.exit_code = 1
+ self.assertEqual(git_commentchar(), "#")
+
+ git.return_value.exit_code = 0
+ git.return_value = "ä"
+ self.assertEqual(git_commentchar(), "ä")
+
+ 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"))
+
+ @patch("gitlint.git._git")
+ def test_git_hooks_dir(self, git):
+ hooks_dir = os.path.join("föo", ".git", "hooks")
+ git.return_value = hooks_dir + "\n"
+ self.assertEqual(git_hooks_dir("/blä"), os.path.abspath(os.path.join("/blä", hooks_dir)))
+
+ git.assert_called_once_with("rev-parse", "--git-path", "hooks", _cwd="/blä")
diff --git a/gitlint-core/gitlint/tests/git/test_git_commit.py b/gitlint-core/gitlint/tests/git/test_git_commit.py
new file mode 100644
index 0000000..e6b0b2c
--- /dev/null
+++ b/gitlint-core/gitlint/tests/git/test_git_commit.py
@@ -0,0 +1,825 @@
+import copy
+import datetime
+from pathlib import Path
+from unittest.mock import call, patch
+
+import arrow
+import dateutil
+from gitlint.git import (
+ GitChangedFileStats,
+ GitCommit,
+ GitCommitMessage,
+ GitContext,
+ GitContextError,
+ LocalGitCommit,
+ StagedLocalGitCommit,
+)
+from gitlint.shell import ErrorReturnCode
+from gitlint.tests.base import BaseTestCase
+
+
+class GitCommitTests(BaseTestCase):
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"}
+
+ @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\ncömmit-title\n\ncömmit-body",
+ "#", # git config --get core.commentchar
+ "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")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
+ call(
+ "diff-tree",
+ "--no-commit-id",
+ "--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
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, "cömmit-title")
+ 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.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.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])
+
+ self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch("gitlint.git.sh")
+ def test_from_local_repository_specific_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\ncömmit-title\n\ncömmit-body",
+ "#", # git config --get core.commentchar
+ "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)
+ # assert that commit info was read using git command
+ 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",
+ "--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
+ self.assertEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, "cömmit-title")
+ 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.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"])
+ 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])
+
+ self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch("gitlint.git.sh")
+ def test_from_local_repository_specific_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\ncömmit-title\n\ncömmit-body",
+ "#", # git config --get core.commentchar
+ "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_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", 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",
+ "--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
+ self.assertEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_hash)
+ self.assertEqual(last_commit.message.title, "cömmit-title")
+ 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.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"])
+ 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])
+
+ self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch("gitlint.git.sh")
+ def test_from_local_repository_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\nMerge "foo bår commit"',
+ "#", # git config --get core.commentchar
+ "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")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
+ call(
+ "diff-tree",
+ "--no-commit-id",
+ "--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
+ self.assertEqual(sh.git.mock_calls, expected_calls[:1])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, '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.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)
+
+ # 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"])
+ 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])
+
+ self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ @patch("gitlint.git.sh")
+ def test_get_latest_commit_fixup_squash_commit(self, sh):
+ commit_prefixes = {"fixup": "is_fixup_commit", "squash": "is_squash_commit", "amend": "is_fixup_amend_commit"}
+ for commit_type in commit_prefixes:
+ 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"',
+ "#", # git config --get core.commentchar
+ "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")
+ # assert that commit info was read using git command
+ expected_calls = [
+ call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
+ call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
+ call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
+ call(
+ "diff-tree",
+ "--no-commit-id",
+ "--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
+ self.assertEqual(sh.git.mock_calls, expected_calls[:-4])
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, LocalGitCommit)
+ self.assertEqual(last_commit.sha, sample_sha)
+ self.assertEqual(last_commit.message.title, 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.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, 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)
+
+ # 'git diff-tree' should have happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
+
+ self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
+ # All expected calls should've happened at this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls)
+
+ sh.git.reset_mock()
+
+ @patch("gitlint.git.git_commentchar")
+ def test_from_commit_msg_full(self, commentchar):
+ commentchar.return_value = "#"
+ 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_full = expected_title + "\n" + "\n".join(expected_body)
+ 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"
+ "diff --git a/gitlint/tests/samples/commit_message/sample1 "
+ "b/gitlint/tests/samples/commit_message/sample1\n"
+ "index 82dbe7f..ae71a14 100644\n"
+ "--- a/gitlint/tests/samples/commit_message/sample1\n"
+ "+++ b/gitlint/tests/samples/commit_message/sample1\n"
+ "@@ -1 +1 @@\n"
+ )
+
+ commit = gitcontext.commits[-1]
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, expected_title)
+ self.assertEqual(commit.message.body, expected_body)
+ self.assertEqual(commit.message.full, expected_full)
+ self.assertEqual(commit.message.original, expected_original)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_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_just_title(self):
+ gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample2"))
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, "Just a title contåining WIP")
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, "Just a title contåining WIP")
+ self.assertEqual(commit.message.original, "Just a title contåining WIP")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_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_empty(self):
+ gitcontext = GitContext.from_commit_msg("")
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, "")
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, "")
+ self.assertEqual(commit.message.original, "")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_fixup_amend_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ @patch("gitlint.git.git_commentchar")
+ def test_from_commit_msg_comment(self, commentchar):
+ commentchar.return_value = "#"
+ gitcontext = GitContext.from_commit_msg("Tïtle\n\nBödy 1\n#Cömment\nBody 2")
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, "Tïtle")
+ self.assertEqual(commit.message.body, ["", "Bödy 1", "Body 2"])
+ self.assertEqual(commit.message.full, "Tïtle\n\nBödy 1\nBody 2")
+ self.assertEqual(commit.message.original, "Tïtle\n\nBödy 1\n#Cömment\nBody 2")
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_squash_commit)
+ self.assertFalse(commit.is_fixup_amend_commit)
+ self.assertFalse(commit.is_revert_commit)
+ self.assertEqual(len(gitcontext.commits), 1)
+
+ def test_from_commit_msg_merge_commit(self):
+ commit_msg = "Merge f919b8f34898d9b48048bcd703bc47139f4ff621 into 8b0409a26da6ba8a47c1fd2e746872a8dab15401"
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, commit_msg)
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertTrue(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_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.'
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, 'Revert "Prev commit message"')
+ self.assertEqual(commit.message.body, ["", "This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_fixup_commit)
+ self.assertFalse(commit.is_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_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:
+ commit_msg = f"{commit_type}! Test message"
+ gitcontext = GitContext.from_commit_msg(commit_msg)
+ commit = gitcontext.commits[-1]
+
+ self.assertIsInstance(commit, GitCommit)
+ self.assertFalse(isinstance(commit, LocalGitCommit))
+ self.assertEqual(commit.message.title, commit_msg)
+ self.assertEqual(commit.message.body, [])
+ self.assertEqual(commit.message.full, commit_msg)
+ self.assertEqual(commit.message.original, commit_msg)
+ self.assertEqual(commit.author_name, None)
+ self.assertEqual(commit.author_email, None)
+ self.assertEqual(commit.date, None)
+ self.assertListEqual(commit.parents, [])
+ self.assertListEqual(commit.branches, [])
+ self.assertEqual(len(gitcontext.commits), 1)
+ self.assertFalse(commit.is_merge_commit)
+ self.assertFalse(commit.is_revert_commit)
+ # Asserting that squash and fixup are correct
+ for type, commit_attr_name in commit_prefixes.items():
+ self.assertEqual(getattr(commit, commit_attr_name), commit_type == type)
+
+ @patch("gitlint.git.sh")
+ @patch("arrow.now")
+ def test_staged_commit(self, now, sh):
+ """Test for 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
+ "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 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("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args),
+ call("diff", "--staged", "--numstat", "-r", **self.expected_sh_special_args),
+ ]
+
+ last_commit = context.commits[-1]
+ self.assertIsInstance(last_commit, StagedLocalGitCommit)
+ self.assertIsNone(last_commit.sha, None)
+ self.assertEqual(last_commit.message.title, "fixup! Foōbar 123")
+ self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
+ # Only `git config --get core.commentchar` should've happened up until this point
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:1])
+
+ self.assertEqual(last_commit.author_name, "test åuthor")
+ self.assertListEqual(sh.git.mock_calls, expected_calls[0:2])
+
+ 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))
+ )
+ 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)
+
+ self.assertListEqual(last_commit.branches, ["my-brånch"])
+ 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")
+ def test_staged_commit_with_missing_username(self, sh):
+ sh.git.side_effect = [
+ "#", # 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")
+ ctx.commits[0].author_name # accessing this attribute should raise an exception
+
+ @patch("gitlint.git.sh")
+ def test_staged_commit_with_missing_email(self, sh):
+ sh.git.side_effect = [
+ "#", # 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")
+ 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"]
+ 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
+ # This only matters to test gitcontext equality, not gitcommit equality
+ git.return_value = "foöbar"
+
+ # Test simple equality case
+ now = datetime.datetime.now(datetime.timezone.utc)
+ 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": 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": GitChangedFileStats("föo/bar", 5, 13)},
+ ["brånch1", "brånch2"],
+ )
+ context2.commits = [commit2]
+
+ self.assertEqual(context1, context2)
+ self.assertEqual(commit_message1, commit_message2)
+ self.assertEqual(commit1, commit2)
+
+ # Check that objects are unequal when changing a single attribute
+ kwargs = {
+ "message": commit1.message,
+ "sha": commit1.sha,
+ "date": commit1.date,
+ "author_name": commit1.author_name,
+ "author_email": commit1.author_email,
+ "parents": commit1.parents,
+ "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",
+ }
+ for key in special_messages:
+ kwargs_copy = copy.deepcopy(kwargs)
+ clone1 = GitCommit(context=commit1.context, **kwargs_copy)
+ clone1.message = GitCommitMessage.from_full_message(context1, special_messages[key])
+ self.assertTrue(getattr(clone1, key))
+
+ clone2 = GitCommit(context=commit1.context, **kwargs_copy)
+ clone2.message = GitCommitMessage.from_full_message(context1, "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 = "ä"
+ context = GitContext()
+ message = GitCommitMessage.from_full_message(context, "Tïtle\n\nBödy 1\näCömment\nBody 2")
+
+ self.assertEqual(message.title, "Tïtle")
+ self.assertEqual(message.body, ["", "Bödy 1", "Body 2"])
+ self.assertEqual(message.full, "Tïtle\n\nBödy 1\nBody 2")
+ self.assertEqual(message.original, "Tïtle\n\nBödy 1\näCömment\nBody 2")
diff --git a/gitlint-core/gitlint/tests/git/test_git_context.py b/gitlint-core/gitlint/tests/git/test_git_context.py
new file mode 100644
index 0000000..751136c
--- /dev/null
+++ b/gitlint-core/gitlint/tests/git/test_git_context.py
@@ -0,0 +1,73 @@
+from unittest.mock import call, patch
+
+from gitlint.git import GitContext
+from gitlint.tests.base import BaseTestCase
+
+
+class GitContextTests(BaseTestCase):
+ # Expected special_args passed to 'sh'
+ expected_sh_special_args = {"_tty_out": False, "_cwd": "fåke/path"}
+
+ @patch("gitlint.git.sh")
+ def test_gitcontext(self, sh):
+ 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),
+ ]
+
+ context = GitContext("fåke/path")
+ self.assertEqual(sh.git.mock_calls, [])
+
+ # gitcontext.comment_branch
+ self.assertEqual(context.commentchar, "#")
+ self.assertEqual(sh.git.mock_calls, expected_calls[0:1])
+
+ # gitcontext.current_branch
+ self.assertEqual(context.current_branch, "foöbar")
+ self.assertEqual(sh.git.mock_calls, expected_calls)
+
+ @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
+ "my-brånch\n", # context1: git rev-parse --abbrev-ref HEAD
+ "my-brånch\n", # context2: git rev-parse --abbrev-ref HEAD
+ ]
+
+ context1 = GitContext("fåke/path")
+ context1.commits = ["fōo", "bår"] # we don't need real commits to check for equality
+
+ context2 = GitContext("fåke/path")
+ context2.commits = ["fōo", "bår"]
+ self.assertEqual(context1, context2)
+
+ # INEQUALITY
+ # Different commits
+ context2.commits = ["hür", "dür"]
+ self.assertNotEqual(context1, context2)
+
+ # Different repository_path
+ context2.commits = context1.commits
+ context2.repository_path = "ōther/path"
+ self.assertNotEqual(context1, context2)
+
+ # 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
+ ]
+ 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
+ ]
+ self.assertNotEqual(context1, context4)
diff --git a/qa/samples/config/contrib-enabled b/gitlint-core/gitlint/tests/rules/__init__.py
index e69de29..e69de29 100644
--- a/qa/samples/config/contrib-enabled
+++ b/gitlint-core/gitlint/tests/rules/__init__.py
diff --git a/gitlint-core/gitlint/tests/rules/test_body_rules.py b/gitlint-core/gitlint/tests/rules/test_body_rules.py
new file mode 100644
index 0000000..c142e6e
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_body_rules.py
@@ -0,0 +1,235 @@
+from gitlint import rules
+from gitlint.tests.base import BaseTestCase
+
+
+class BodyRuleTests(BaseTestCase):
+ def test_max_line_length(self):
+ rule = rules.BodyMaxLineLength()
+
+ # assert no error
+ violation = rule.validate("å" * 80, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length > 80
+ expected_violation = rules.RuleViolation("B1", "Line exceeds max length (81>80)", "å" * 81)
+ violations = rule.validate("å" * 81, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check no violation on length 73
+ rule = rules.BodyMaxLineLength({"line-length": 120})
+ violations = rule.validate("å" * 73, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 121
+ expected_violation = rules.RuleViolation("B1", "Line exceeds max length (121>120)", "å" * 121)
+ violations = rule.validate("å" * 121, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_whitespace(self):
+ rule = rules.BodyTrailingWhitespace()
+
+ # assert no error
+ violations = rule.validate("å", None)
+ self.assertIsNone(violations)
+
+ # trailing space
+ expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", "å ")
+ violations = rule.validate("å ", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # trailing tab
+ expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", "å\t")
+ violations = rule.validate("å\t", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_hard_tabs(self):
+ rule = rules.BodyHardTab()
+
+ # assert no error
+ violations = rule.validate("This is ã test", None)
+ self.assertIsNone(violations)
+
+ # contains hard tab
+ expected_violation = rules.RuleViolation("B3", "Line contains hard tab characters (\\t)", "This is å\ttest")
+ violations = rule.validate("This is å\ttest", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_first_line_empty(self):
+ rule = rules.BodyFirstLineEmpty()
+
+ # assert no error
+ commit = self.gitcommit("Tïtle\n\nThis is the secōnd body line")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # second line not empty
+ expected_violation = rules.RuleViolation("B4", "Second line is not empty", "nöt empty", 2)
+
+ commit = self.gitcommit("Tïtle\nnöt empty\nThis is the secönd body line")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_min_length(self):
+ rule = rules.BodyMinLength()
+
+ # assert no error - body is long enough
+ commit = self.gitcommit("Title\n\nThis is the second body line\n")
+
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error - no body
+ commit = self.gitcommit("Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # body is too short
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (8<20)", "töoshort", 3)
+
+ commit = self.gitcommit("Tïtle\n\ntöoshort\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # assert error - short across multiple lines
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (11<20)", "secöndthïrd", 3)
+ commit = self.gitcommit("Tïtle\n\nsecönd\nthïrd\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check violation on length 21
+ expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", "å" * 21, 3)
+
+ rule = rules.BodyMinLength({"min-length": 120})
+ commit = self.gitcommit("Title\n\n{}\n".format("å" * 21))
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ # Make sure we don't get the error if the body-length is exactly the min-length
+ rule = rules.BodyMinLength({"min-length": 8})
+ commit = self.gitcommit("Tïtle\n\n{}\n".format("å" * 8))
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ def test_body_missing(self):
+ rule = rules.BodyMissing()
+
+ # assert no error - body is present
+ commit = self.gitcommit("Tïtle\n\nThis ïs the first body line\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # body is too short
+ expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
+
+ commit = self.gitcommit("Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_missing_multiple_empty_new_lines(self):
+ rule = rules.BodyMissing()
+
+ # body is too short
+ expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
+
+ commit = self.gitcommit("Tïtle\n\n\n\n")
+ violations = rule.validate(commit)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_missing_merge_commit(self):
+ rule = rules.BodyMissing()
+
+ # assert no error - merge commit
+ commit = self.gitcommit("Merge: Tïtle\n")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert error for merge commits if ignore-merge-commits is disabled
+ rule = rules.BodyMissing({"ignore-merge-commits": False})
+ violations = rule.validate(commit)
+ expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_body_changed_file_mention(self):
+ rule = rules.BodyChangedFileMention()
+
+ # assert no error when no files have changed and no files need to be mentioned
+ commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error when no files have changed but certain files need to be mentioned on change
+ rule = rules.BodyChangedFileMention({"files": "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)
+
+ # assert no error if a file has changed and is mentioned
+ commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py", ["föo/test.py"])
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert no error if multiple files have changed and are mentioned
+ commit_msg = "This is a test\n\nHere is a mention of föo/test.py\nAnd here is a mention of bar.txt"
+ commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert error if file has changed and is not mentioned
+ commit_msg = "This is a test\n\nHere is å mention of\nAnd here is a mention of bar.txt"
+ commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ expected_violation = rules.RuleViolation("B7", "Body does not mention changed file 'föo/test.py'", None, 4)
+ self.assertEqual([expected_violation], violations)
+
+ # assert multiple errors if multiple files have changed and are not mentioned
+ commit_msg = "This is å test\n\nHere is a mention of\nAnd here is a mention of"
+ commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
+ violations = rule.validate(commit)
+ expected_violation_2 = rules.RuleViolation("B7", "Body does not mention changed file 'bar.txt'", None, 4)
+ self.assertEqual([expected_violation_2, expected_violation], violations)
+
+ def test_body_match_regex(self):
+ # We intentionally add 2 newlines at the end of our commit message as that's how git will pass the
+ # message. This way we also test that the rule strips off the last line.
+ commit = self.gitcommit("US1234: åbc\nIgnored\nBödy\nFöo\nMy-Commit-Tag: föo\n\n")
+
+ # assert no violation on default regex (=everything allowed)
+ rule = rules.BodyRegexMatches()
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # 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(.*)"})
+ 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$"})
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # common use-case: matching that a given line is present
+ 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"})
+ 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})
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # Assert no issues when there's no body or a weird body variation
+ 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": ".*"})
+ 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
new file mode 100644
index 0000000..5935a4a
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_configuration_rules.py
@@ -0,0 +1,178 @@
+from gitlint import rules
+from gitlint.config import LintConfig
+from gitlint.tests.base import (
+ EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING,
+ BaseTestCase,
+)
+
+
+class ConfigurationRuleTests(BaseTestCase):
+ def test_ignore_by_title(self):
+ commit = self.gitcommit("Releäse\n\nThis is the secōnd body line")
+
+ # No regex specified -> Config shouldn't be changed
+ rule = rules.IgnoreByTitle()
+ config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([]) # nothing logged -> nothing ignored
+
+ # Matching regex -> expect config to ignore all rules
+ rule = rules.IgnoreByTitle({"regex": "^Releäse(.*)"})
+ expected_config = LintConfig()
+ expected_config.ignore = "all"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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"})
+ expected_config = LintConfig()
+ expected_config.ignore = "T1,B2"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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")
+
+ # No regex specified -> Config shouldn't be changed
+ rule = rules.IgnoreByBody()
+ config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([]) # nothing logged -> nothing ignored
+
+ # Matching regex -> expect config to ignore all rules
+ rule = rules.IgnoreByBody({"regex": "(.*)relëase(.*)"})
+ expected_config = LintConfig()
+ expected_config.ignore = "all"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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"})
+ expected_config = LintConfig()
+ expected_config.ignore = "T1,B2"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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_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")
+
+ # No regex specified -> Config shouldn't be changed
+ rule = rules.IgnoreByAuthorName()
+ config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([]) # nothing logged -> nothing ignored
+
+ # No author available -> rule is skipped and warning logged
+ staged_commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")
+ rule = rules.IgnoreByAuthorName({"regex": "foo"})
+ config = LintConfig()
+ rule.apply(config, staged_commit)
+ self.assertEqual(config, LintConfig())
+ expected_log_messages = [
+ "WARNING: gitlint.rules ignore-by-author-name - I4: skipping - commit.author_name unknown. "
+ "Suggested fix: Use the --staged flag (or set general.staged=True in .gitlint). "
+ "More details: https://jorisroovers.com/gitlint/configuration/#staged"
+ ]
+ self.assert_logged(expected_log_messages)
+
+ # Non-Matching regex -> expect config to stay the same
+ rule = rules.IgnoreByAuthorName({"regex": "foo"})
+ expected_config = LintConfig()
+ rule.apply(config, commit)
+ self.assertEqual(config, LintConfig())
+
+ # Matching regex -> expect config to ignore all rules
+ rule = rules.IgnoreByAuthorName({"regex": "(.*)ëst(.*)"})
+ expected_config = LintConfig()
+ expected_config.ignore = "all"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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"})
+ expected_config = LintConfig()
+ expected_config.ignore = "T1,B2"
+ rule.apply(config, commit)
+ self.assertEqual(config, expected_config)
+
+ 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")
+ commit2 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")
+
+ # no regex specified, nothing should have happened:
+ # commit and config should remain identical, log should be empty
+ rule = rules.IgnoreBodyLines()
+ config = LintConfig()
+ rule.apply(config, commit1)
+ self.assertEqual(commit1, commit2)
+ self.assertEqual(config, LintConfig())
+ self.assert_logged([])
+
+ # Matching regex
+ rule = rules.IgnoreBodyLines({"regex": "(.*)relëase(.*)"})
+ config = LintConfig()
+ rule.apply(config, commit1)
+ # Our modified commit should be identical to a commit that doesn't contain the specific line
+ expected_commit = self.gitcommit("Tïtle\n\nThis is\n line")
+ # The original message isn't touched by this rule, this way we always have a way to reference back to it,
+ # so assert it's not modified by setting it to the same as commit1
+ expected_commit.message.original = commit1.message.original
+ self.assertEqual(commit1, expected_commit)
+ self.assertEqual(config, LintConfig()) # config shouldn't have been modified
+ 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")
+ rule = rules.IgnoreBodyLines({"regex": "(.*)föobar(.*)"})
+ config = LintConfig()
+ rule.apply(config, commit1)
+ self.assertEqual(commit1, commit2)
+ self.assertEqual(config, LintConfig()) # config shouldn't have been modified
diff --git a/gitlint-core/gitlint/tests/rules/test_meta_rules.py b/gitlint-core/gitlint/tests/rules/test_meta_rules.py
new file mode 100644
index 0000000..a574aa3
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_meta_rules.py
@@ -0,0 +1,80 @@
+from gitlint.rules import AuthorValidEmail, RuleViolation
+from gitlint.tests.base import (
+ EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING,
+ BaseTestCase,
+)
+
+
+class MetaRuleTests(BaseTestCase):
+ def test_author_valid_email_rule(self):
+ 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",
+ ]
+ for email in valid_email_addresses:
+ commit = self.gitcommit("", author_email=email)
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # No email address (=allowed for now, as gitlint also lints messages passed via stdin that don't have an
+ # email address)
+ commit = self.gitcommit("")
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # Invalid email addresses: no TLD, no domain, no @, space anywhere (=valid but not allowed by gitlint)
+ invalid_email_addresses = [
+ "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)])
+
+ # 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)
+ emailadresses = ["föo", None, "hür dür"]
+ for email in emailadresses:
+ commit = self.gitcommit("", author_email=email)
+ violations = rule.validate(commit)
+ 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"]
+ for email in valid_email_addresses:
+ commit = self.gitcommit("", author_email=email)
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # Invalid email addresses
+ invalid_email_addresses = ["föo@hur.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)])
+
+ # 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
new file mode 100644
index 0000000..b401372
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_rules.py
@@ -0,0 +1,32 @@
+from gitlint.rules import Rule, RuleViolation
+from gitlint.tests.base import BaseTestCase
+
+
+class RuleTests(BaseTestCase):
+ def test_ruleviolation__str__(self):
+ expected = '57: rule-ïd Tēst message: "Tēst content"'
+ self.assertEqual(str(RuleViolation("rule-ïd", "Tēst message", "Tēst content", 57)), expected)
+
+ def test_rule_equality(self):
+ self.assertEqual(Rule(), Rule())
+ # Ensure rules are not equal if they differ on their attributes
+ for attr in ["id", "name", "target", "options"]:
+ rule = Rule()
+ setattr(rule, attr, "åbc")
+ self.assertNotEqual(Rule(), rule)
+
+ def test_rule_log(self):
+ rule = Rule()
+ self.assertIsNone(rule._log)
+ rule.log.debug("Tēst message")
+ self.assert_log_contains("DEBUG: gitlint.rules Tēst message")
+
+ # Assert the same logger is reused when logging multiple messages
+ log = rule._log
+ rule.log.debug("Anöther message")
+ self.assertEqual(log, rule._log)
+ self.assert_log_contains("DEBUG: gitlint.rules Anöther message")
+
+ def test_rule_violation_equality(self):
+ violation1 = RuleViolation("ïd1", "My messåge", "My cöntent", 1)
+ self.object_equality_test(violation1, ["rule_id", "message", "content", "line_nr"])
diff --git a/gitlint-core/gitlint/tests/rules/test_title_rules.py b/gitlint-core/gitlint/tests/rules/test_title_rules.py
new file mode 100644
index 0000000..cba3851
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_title_rules.py
@@ -0,0 +1,200 @@
+from gitlint.rules import (
+ RuleViolation,
+ TitleHardTab,
+ TitleLeadingWhitespace,
+ TitleMaxLength,
+ TitleMinLength,
+ TitleMustNotContainWord,
+ TitleRegexMatches,
+ TitleTrailingPunctuation,
+ TitleTrailingWhitespace,
+)
+from gitlint.tests.base import BaseTestCase
+
+
+class TitleRuleTests(BaseTestCase):
+ def test_max_line_length(self):
+ rule = TitleMaxLength()
+
+ # assert no error
+ violation = rule.validate("å" * 72, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length > 72
+ expected_violation = RuleViolation("T1", "Title exceeds max length (73>72)", "å" * 73)
+ violations = rule.validate("å" * 73, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 120, and check no violation on length 73
+ rule = TitleMaxLength({"line-length": 120})
+ violations = rule.validate("å" * 73, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 121
+ expected_violation = RuleViolation("T1", "Title exceeds max length (121>120)", "å" * 121)
+ violations = rule.validate("å" * 121, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_whitespace(self):
+ rule = TitleTrailingWhitespace()
+
+ # assert no error
+ violations = rule.validate("å", None)
+ self.assertIsNone(violations)
+
+ # trailing space
+ expected_violation = RuleViolation("T2", "Title has trailing whitespace", "å ")
+ violations = rule.validate("å ", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # trailing tab
+ expected_violation = RuleViolation("T2", "Title has trailing whitespace", "å\t")
+ violations = rule.validate("å\t", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_hard_tabs(self):
+ rule = TitleHardTab()
+
+ # assert no error
+ violations = rule.validate("This is å test", None)
+ self.assertIsNone(violations)
+
+ # contains hard tab
+ expected_violation = RuleViolation("T4", "Title contains hard tab characters (\\t)", "This is å\ttest")
+ violations = rule.validate("This is å\ttest", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_trailing_punctuation(self):
+ rule = TitleTrailingPunctuation()
+
+ # assert no error
+ violations = rule.validate("This is å test", None)
+ self.assertIsNone(violations)
+
+ # assert errors for different punctuations
+ punctuation = "?:!.,;"
+ for char in punctuation:
+ line = "This is å test" + char # note that make sure to include some unicode!
+ gitcontext = self.gitcontext(line)
+ expected_violation = RuleViolation("T3", f"Title has trailing punctuation ({char})", line)
+ violations = rule.validate(line, gitcontext)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_title_must_not_contain_word(self):
+ rule = TitleMustNotContainWord()
+
+ # no violations
+ violations = rule.validate("This is å test", None)
+ self.assertIsNone(violations)
+
+ # no violation if WIP occurs inside a word
+ violations = rule.validate("This is å wiping test", None)
+ self.assertIsNone(violations)
+
+ # 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"
+ )
+ 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"
+ )
+ 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"
+ )
+ self.assertListEqual(violations, [expected_violation])
+
+ # match multiple words
+ 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"
+ )
+ self.assertListEqual(violations, [expected_violation, expected_violation2, expected_violation3])
+
+ def test_leading_whitespace(self):
+ rule = TitleLeadingWhitespace()
+
+ # assert no error
+ violations = rule.validate("a", None)
+ self.assertIsNone(violations)
+
+ # leading space
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", " a")
+ violations = rule.validate(" a", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # leading tab
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", "\ta")
+ violations = rule.validate("\ta", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # unicode test
+ expected_violation = RuleViolation("T6", "Title has leading whitespace", " ☺")
+ violations = rule.validate(" ☺", None)
+ self.assertListEqual(violations, [expected_violation])
+
+ def test_regex_matches(self):
+ commit = self.gitcommit("US1234: åbc\n")
+
+ # assert no violation on default regex (=everything allowed)
+ rule = TitleRegexMatches()
+ violations = rule.validate(commit.message.title, commit)
+ self.assertIsNone(violations)
+
+ # assert no violation on matching regex
+ rule = TitleRegexMatches({"regex": "^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]*"})
+ 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])
+
+ def test_min_line_length(self):
+ rule = TitleMinLength()
+
+ # assert no error
+ violation = rule.validate("å" * 72, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length < 5
+ expected_violation = RuleViolation("T8", "Title is too short (4<5)", "å" * 4, 1)
+ violations = rule.validate("å" * 4, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # set line length to 3, and check no violation on length 4
+ 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})
+ violations = rule.validate("å" * 3, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 2
+ expected_violation = RuleViolation("T8", "Title is too short (2<3)", "å" * 2, 1)
+ violations = rule.validate("å" * 2, None)
+ self.assertListEqual(violations, [expected_violation])
+
+ # assert raise on empty title
+ expected_violation = RuleViolation("T8", "Title is too short (0<3)", "", 1)
+ violations = rule.validate("", None)
+ self.assertListEqual(violations, [expected_violation])
diff --git a/gitlint-core/gitlint/tests/rules/test_user_rules.py b/gitlint-core/gitlint/tests/rules/test_user_rules.py
new file mode 100644
index 0000000..8086bea
--- /dev/null
+++ b/gitlint-core/gitlint/tests/rules/test_user_rules.py
@@ -0,0 +1,266 @@
+import os
+import sys
+
+from gitlint import options, rules
+from gitlint.rule_finder import assert_valid_rule_class, find_rule_classes
+from gitlint.rules import UserRuleError
+from gitlint.tests.base import BaseTestCase
+
+
+class UserRuleTests(BaseTestCase):
+ def test_find_rule_classes(self):
+ # Let's find some user classes!
+ user_rule_path = self.get_sample_path("user_rules")
+ classes = find_rule_classes(user_rule_path)
+
+ # Compare string representations because we can't import MyUserCommitRule here since samples/user_rules is not
+ # a proper python package
+ # Note that the following check effectively asserts that:
+ # - There is only 1 rule recognized and it is MyUserCommitRule
+ # - Other non-python files in the directory are ignored
+ # - Other members of the my_commit_rules module are ignored
+ # (such as func_should_be_ignored, global_variable_should_be_ignored)
+ # - Rules are loaded non-recursively (user_rules/import_exception directory is ignored)
+ self.assertEqual("[<class 'my_commit_rules.MyUserCommitRule'>]", str(classes))
+
+ # Assert that we added the new user_rules directory to the system path and modules
+ self.assertIn(user_rule_path, sys.path)
+ self.assertIn("my_commit_rules", sys.modules)
+
+ # Do some basic asserts on our user rule
+ self.assertEqual(classes[0].id, "UC1")
+ self.assertEqual(classes[0].name, "my-üser-commit-rule")
+ 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"))
+
+ # Test that we can instantiate the class and can execute run the validate method and that it returns the
+ # expected result
+ rule_class = classes[0]()
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1)])
+
+ # Have it return more violations
+ rule_class.options["violation-count"].value = 2
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(
+ violations,
+ [
+ rules.RuleViolation("UC1", "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
+ user_rule_path = self.get_sample_path("user_rules")
+ user_rule_module = os.path.join(user_rule_path, "my_commit_rules.py")
+ classes = find_rule_classes(user_rule_module)
+
+ rule_class = classes[0]()
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1)])
+
+ def test_rules_from_init_file(self):
+ # Test that we can import rules that are defined in __init__.py files
+ # This also tests that we can import rules from python packages. This use to cause issues with pypy
+ # So this is also a regression test for that.
+ user_rule_path = self.get_sample_path(os.path.join("user_rules", "parent_package"))
+ classes = find_rule_classes(user_rule_path)
+
+ # convert classes to strings and sort them so we can compare them
+ class_strings = sorted(str(clazz) for clazz in classes)
+ expected = ["<class 'my_commit_rules.MyUserCommitRule'>", "<class 'parent_package.InitFileRule'>"]
+ self.assertListEqual(class_strings, expected)
+
+ def test_empty_user_classes(self):
+ # Test that we don't find rules if we scan a different directory
+ user_rule_path = self.get_sample_path("config")
+ classes = find_rule_classes(user_rule_path)
+ self.assertListEqual(classes, [])
+
+ # Importantly, ensure that the directory is not added to the syspath as this happens only when we actually
+ # find modules
+ self.assertNotIn(user_rule_path, sys.path)
+
+ def test_failed_module_import(self):
+ # test importing a bogus module
+ user_rule_path = self.get_sample_path("user_rules/import_exception")
+ # We don't check the entire error message because that is different based on the python version and underlying
+ # operating system
+ expected_msg = "Error while importing extra-path module 'invalid_python'"
+ with self.assertRaisesRegex(UserRuleError, expected_msg):
+ find_rule_classes(user_rule_path)
+
+ def test_find_rule_classes_nonexisting_path(self):
+ with self.assertRaisesMessage(UserRuleError, "Invalid extra-path: föo/bar"):
+ find_rule_classes("föo/bar")
+
+ def test_assert_valid_rule_class(self):
+ class MyLineRuleClass(rules.LineRule):
+ id = "UC1"
+ name = "my-lïne-rule"
+ target = rules.CommitMessageTitle
+
+ def validate(self):
+ pass # pragma: nocover
+
+ class MyCommitRuleClass(rules.CommitRule):
+ id = "UC2"
+ name = "my-cömmit-rule"
+
+ def validate(self):
+ pass # pragma: nocover
+
+ class MyConfigurationRuleClass(rules.ConfigurationRule):
+ id = "UC3"
+ name = "my-cönfiguration-rule"
+
+ def apply(self):
+ pass # pragma: nocover
+
+ # Just assert that no error is raised
+ self.assertIsNone(assert_valid_rule_class(MyLineRuleClass))
+ self.assertIsNone(assert_valid_rule_class(MyCommitRuleClass))
+ self.assertIsNone(assert_valid_rule_class(MyConfigurationRuleClass))
+
+ 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"
+ ):
+ find_rule_classes(user_rule_path)
+
+ def test_assert_valid_rule_class_negative_parent(self):
+ # rule class must extend from LineRule or CommitRule
+ class MyRuleClass:
+ pass
+
+ 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):
+ pass
+
+ # Rule class must have an id
+ expected_msg = "User-defined rule class 'MyRuleClass' must have an 'id' attribute"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule ids must be non-empty
+ MyRuleClass.id = ""
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule ids must not start with one of the reserved id letters
+ for letter in ["T", "R", "B", "M", "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"
+ )
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_name(self):
+ for parent_class in [rules.LineRule, rules.CommitRule]:
+
+ class MyRuleClass(parent_class):
+ id = "UC1"
+
+ # Rule class must have a name
+ expected_msg = "User-defined rule class 'MyRuleClass' must have a 'name' attribute"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # Rule names must be non-empty
+ MyRuleClass.name = ""
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ 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):
+ id = "UC1"
+ name = "my-rüle-class"
+
+ # 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 "
+ "of gitlint.options.RuleOption"
+ )
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # option_spec is a list, but not of gitlint options
+ MyRuleClass.options_spec = ["föo", 123]
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ 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"
+ ):
+ 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"
+ ):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_apply(self):
+ class MyRuleClass(rules.ConfigurationRule):
+ id = "UCR1"
+ name = "my-rüle-class"
+
+ expected_msg = "User-defined Configuration rule class 'MyRuleClass' must have an 'apply' method"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # apply attribute - not a method
+ MyRuleClass.apply = "föo"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ def test_assert_valid_rule_class_negative_target(self):
+ class MyRuleClass(rules.LineRule):
+ id = "UC1"
+ name = "my-rüle-class"
+
+ def validate(self):
+ pass # pragma: nocover
+
+ # no target
+ expected_msg = (
+ "The target attribute of the user-defined LineRule class 'MyRuleClass' must be either "
+ "gitlint.rules.CommitMessageTitle or gitlint.rules.CommitMessageBody"
+ )
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # invalid target
+ MyRuleClass.target = "föo"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
+
+ # valid target, no exception should be raised
+ MyRuleClass.target = rules.CommitMessageTitle
+ self.assertIsNone(assert_valid_rule_class(MyRuleClass))
diff --git a/gitlint/tests/samples/commit_message/fixup b/gitlint-core/gitlint/tests/samples/commit_message/fixup
index 2539dd1..2539dd1 100644
--- a/gitlint/tests/samples/commit_message/fixup
+++ b/gitlint-core/gitlint/tests/samples/commit_message/fixup
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/tests/samples/commit_message/merge b/gitlint-core/gitlint/tests/samples/commit_message/merge
index 764e131..764e131 100644
--- a/gitlint/tests/samples/commit_message/merge
+++ b/gitlint-core/gitlint/tests/samples/commit_message/merge
diff --git a/gitlint-core/gitlint/tests/samples/commit_message/no-violations b/gitlint-core/gitlint/tests/samples/commit_message/no-violations
new file mode 100644
index 0000000..33c73b9
--- /dev/null
+++ b/gitlint-core/gitlint/tests/samples/commit_message/no-violations
@@ -0,0 +1,6 @@
+Normal Commit Tïtle
+
+Nörmal body that contains a few lines of text describing the changes in the
+commit without violating any of gitlint's rules.
+
+Sïgned-Off-By: foo@bar.com
diff --git a/gitlint/tests/samples/commit_message/revert b/gitlint-core/gitlint/tests/samples/commit_message/revert
index 6dc8368..6dc8368 100644
--- a/gitlint/tests/samples/commit_message/revert
+++ b/gitlint-core/gitlint/tests/samples/commit_message/revert
diff --git a/gitlint/tests/samples/commit_message/sample1 b/gitlint-core/gitlint/tests/samples/commit_message/sample1
index 646c0cb..646c0cb 100644
--- a/gitlint/tests/samples/commit_message/sample1
+++ b/gitlint-core/gitlint/tests/samples/commit_message/sample1
diff --git a/gitlint/tests/samples/commit_message/sample2 b/gitlint-core/gitlint/tests/samples/commit_message/sample2
index 356540c..356540c 100644
--- a/gitlint/tests/samples/commit_message/sample2
+++ b/gitlint-core/gitlint/tests/samples/commit_message/sample2
diff --git a/gitlint/tests/samples/commit_message/sample3 b/gitlint-core/gitlint/tests/samples/commit_message/sample3
index d67d70b..d67d70b 100644
--- a/gitlint/tests/samples/commit_message/sample3
+++ b/gitlint-core/gitlint/tests/samples/commit_message/sample3
diff --git a/gitlint/tests/samples/commit_message/sample4 b/gitlint-core/gitlint/tests/samples/commit_message/sample4
index c858d89..c858d89 100644
--- a/gitlint/tests/samples/commit_message/sample4
+++ b/gitlint-core/gitlint/tests/samples/commit_message/sample4
diff --git a/gitlint/tests/samples/commit_message/sample5 b/gitlint-core/gitlint/tests/samples/commit_message/sample5
index 77ccbe8..77ccbe8 100644
--- a/gitlint/tests/samples/commit_message/sample5
+++ b/gitlint-core/gitlint/tests/samples/commit_message/sample5
diff --git a/gitlint/tests/samples/commit_message/squash b/gitlint-core/gitlint/tests/samples/commit_message/squash
index 538a93a..538a93a 100644
--- a/gitlint/tests/samples/commit_message/squash
+++ b/gitlint-core/gitlint/tests/samples/commit_message/squash
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/tests/samples/config/gitlintconfig b/gitlint-core/gitlint/tests/samples/config/gitlintconfig
index 8c93f71..8c93f71 100644
--- a/gitlint/tests/samples/config/gitlintconfig
+++ b/gitlint-core/gitlint/tests/samples/config/gitlintconfig
diff --git a/gitlint/tests/samples/config/invalid-option-value b/gitlint-core/gitlint/tests/samples/config/invalid-option-value
index 92015aa..92015aa 100644
--- a/gitlint/tests/samples/config/invalid-option-value
+++ b/gitlint-core/gitlint/tests/samples/config/invalid-option-value
diff --git a/gitlint-core/gitlint/tests/samples/config/named-rules b/gitlint-core/gitlint/tests/samples/config/named-rules
new file mode 100644
index 0000000..73ab0d2
--- /dev/null
+++ b/gitlint-core/gitlint/tests/samples/config/named-rules
@@ -0,0 +1,8 @@
+[title-must-not-contain-word]
+words=WIP,bögus
+
+[title-must-not-contain-word:extra-wörds]
+words=hür,tëst
+
+[T5:even-more-wörds]
+words=hür,tïtle \ No newline at end of file
diff --git a/gitlint/tests/samples/config/no-sections b/gitlint-core/gitlint/tests/samples/config/no-sections
index ec82b25..ec82b25 100644
--- a/gitlint/tests/samples/config/no-sections
+++ b/gitlint-core/gitlint/tests/samples/config/no-sections
diff --git a/gitlint/tests/samples/config/nonexisting-general-option b/gitlint-core/gitlint/tests/samples/config/nonexisting-general-option
index d5cfef2..d5cfef2 100644
--- a/gitlint/tests/samples/config/nonexisting-general-option
+++ b/gitlint-core/gitlint/tests/samples/config/nonexisting-general-option
diff --git a/gitlint/tests/samples/config/nonexisting-option b/gitlint-core/gitlint/tests/samples/config/nonexisting-option
index 6964c77..6964c77 100644
--- a/gitlint/tests/samples/config/nonexisting-option
+++ b/gitlint-core/gitlint/tests/samples/config/nonexisting-option
diff --git a/gitlint/tests/samples/config/nonexisting-rule b/gitlint-core/gitlint/tests/samples/config/nonexisting-rule
index c0f0d2b..c0f0d2b 100644
--- a/gitlint/tests/samples/config/nonexisting-rule
+++ b/gitlint-core/gitlint/tests/samples/config/nonexisting-rule
diff --git a/gitlint/tests/samples/user_rules/bogus-file.txt b/gitlint-core/gitlint/tests/samples/user_rules/bogus-file.txt
index 2a56650..2a56650 100644
--- a/gitlint/tests/samples/user_rules/bogus-file.txt
+++ b/gitlint-core/gitlint/tests/samples/user_rules/bogus-file.txt
diff --git a/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/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/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/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/tests/samples/user_rules/my_commit_rules.foo b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.foo
index 605d704..605d704 100644
--- a/gitlint/tests/samples/user_rules/my_commit_rules.foo
+++ b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.foo
diff --git a/gitlint/tests/samples/user_rules/my_commit_rules.py b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py
index 5456487..c947250 100644
--- a/gitlint/tests/samples/user_rules/my_commit_rules.py
+++ b/gitlint-core/gitlint/tests/samples/user_rules/my_commit_rules.py
@@ -1,26 +1,25 @@
-# -*- coding: utf-8 -*-
-
-from gitlint.rules import CommitRule, RuleViolation
from gitlint.options import IntOption
+from gitlint.rules import CommitRule, RuleViolation
class MyUserCommitRule(CommitRule):
- name = u"my-üser-commit-rule"
+ name = "my-üser-commit-rule"
id = "UC1"
- options_spec = [IntOption('violation-count', 1, u"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):
- violations.append(RuleViolation(self.id, u"Commit violåtion %d" % i, u"Contënt %d" % i, i))
+ 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
# The below code is present so that we can test that we actually ignore it
+
def func_should_be_ignored():
- pass
+ pass # pragma: nocover
global_variable_should_be_ignored = True
diff --git a/gitlint/tests/samples/user_rules/parent_package/__init__.py b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py
index 32c05fc..c2863fe 100644
--- a/gitlint/tests/samples/user_rules/parent_package/__init__.py
+++ b/gitlint-core/gitlint/tests/samples/user_rules/parent_package/__init__.py
@@ -1,13 +1,12 @@
-# -*- coding: utf-8 -*-
# This file is meant to test that we can also load rules from __init__.py files, this was an issue with pypy before.
from gitlint.rules import CommitRule
class InitFileRule(CommitRule):
- name = u"my-init-cömmit-rule"
+ name = "my-init-cömmit-rule"
id = "UC1"
options_spec = []
def validate(self, _commit):
- return []
+ return [] # pragma: nocover
diff --git a/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 b73a305..f91cb07 100644
--- a/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,10 +1,8 @@
-# -*- coding: utf-8 -*-
-
from gitlint.rules import CommitRule
class MyUserCommitRule(CommitRule):
- name = u"my-user-cömmit-rule"
+ name = "my-user-cömmit-rule"
id = "UC2"
options_spec = []
diff --git a/gitlint/tests/test_cache.py b/gitlint-core/gitlint/tests/test_cache.py
index 5d78953..08b821e 100644
--- a/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
+from gitlint.tests.base import BaseTestCase
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)
@@ -16,13 +14,13 @@ class CacheTests(BaseTestCase):
@cache
def foo(self):
self.counter += 1
- return u"bår"
+ return "bår"
@property
- @cache(cachekey=u"hür")
+ @cache(cachekey="hür")
def bar(self):
self.counter += 1
- return u"fōo"
+ return "fōo"
def test_cache(self):
# Init new class with cached properties
@@ -31,14 +29,14 @@ class CacheTests(BaseTestCase):
self.assertDictEqual(myclass._cache, {})
# Assert that function is called on first access, cache is set
- self.assertEqual(myclass.foo, u"bår")
+ self.assertEqual(myclass.foo, "bår")
self.assertEqual(myclass.counter, 1)
- self.assertDictEqual(myclass._cache, {"foo": u"bår"})
+ self.assertDictEqual(myclass._cache, {"foo": "bår"})
# After function is not called on subsequent access, cache is still set
- self.assertEqual(myclass.foo, u"bår")
+ self.assertEqual(myclass.foo, "bår")
self.assertEqual(myclass.counter, 1)
- self.assertDictEqual(myclass._cache, {"foo": u"bår"})
+ self.assertDictEqual(myclass._cache, {"foo": "bår"})
def test_cache_custom_key(self):
# Init new class with cached properties
@@ -47,11 +45,11 @@ class CacheTests(BaseTestCase):
self.assertDictEqual(myclass._cache, {})
# Assert that function is called on first access, cache is set with custom key
- self.assertEqual(myclass.bar, u"fōo")
+ self.assertEqual(myclass.bar, "fōo")
self.assertEqual(myclass.counter, 1)
- self.assertDictEqual(myclass._cache, {u"hür": u"fōo"})
+ self.assertDictEqual(myclass._cache, {"hür": "fōo"})
# After function is not called on subsequent access, cache is still set
- self.assertEqual(myclass.bar, u"fōo")
+ self.assertEqual(myclass.bar, "fōo")
self.assertEqual(myclass.counter, 1)
- self.assertDictEqual(myclass._cache, {u"hür": u"fōo"})
+ self.assertDictEqual(myclass._cache, {"hür": "fōo"})
diff --git a/gitlint-core/gitlint/tests/test_deprecation.py b/gitlint-core/gitlint/tests/test_deprecation.py
new file mode 100644
index 0000000..bfe5934
--- /dev/null
+++ b/gitlint-core/gitlint/tests/test_deprecation.py
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 0000000..e669cdb
--- /dev/null
+++ b/gitlint-core/gitlint/tests/test_display.py
@@ -0,0 +1,60 @@
+from io import StringIO
+from unittest.mock import patch
+
+from gitlint.config import LintConfig
+from gitlint.display import Display
+from gitlint.tests.base import BaseTestCase
+
+
+class DisplayTests(BaseTestCase):
+ def test_v(self):
+ display = Display(LintConfig())
+ display.config.verbosity = 2
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ # Non exact outputting, should output both v and vv output
+ with patch("gitlint.display.stdout", new=StringIO()) as stdout:
+ display.v("tëst")
+ display.vv("tëst2")
+ # vvvv should be ignored regardless
+ display.vvv("tëst3.1")
+ display.vvv("tëst3.2", exact=True)
+ self.assertEqual("tëst\ntëst2\n", stdout.getvalue())
+
+ # exact outputting, should only output v
+ 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
+ display.vvv("tëst3.1")
+ display.vvv("tëst3.2", exact=True)
+ self.assertEqual("tëst2\n", stdout.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:
+ # Non exact outputting, should output both v and vv output
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ display.e("tëst")
+ display.ee("tëst2")
+ # vvvv should be ignored regardless
+ display.eee("tëst3.1")
+ display.eee("tëst3.2", exact=True)
+ self.assertEqual("tëst\ntëst2\n", stderr.getvalue())
+
+ # exact outputting, should only output v
+ 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
+ display.eee("tëst3.1")
+ display.eee("tëst3.2", exact=True)
+ self.assertEqual("tëst2\n", stderr.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
new file mode 100644
index 0000000..7390f14
--- /dev/null
+++ b/gitlint-core/gitlint/tests/test_hooks.py
@@ -0,0 +1,139 @@
+import os
+from unittest.mock import ANY, mock_open, patch
+
+from gitlint.config import LintConfig
+from gitlint.hooks import (
+ COMMIT_MSG_HOOK_DST_PATH,
+ COMMIT_MSG_HOOK_SRC_PATH,
+ GITLINT_HOOK_IDENTIFIER,
+ GitHookInstaller,
+ GitHookInstallerError,
+)
+from gitlint.tests.base import BaseTestCase
+
+
+class HookTests(BaseTestCase):
+ @patch("gitlint.hooks.git_hooks_dir")
+ def test_commit_msg_hook_path(self, git_hooks_dir):
+ git_hooks_dir.return_value = os.path.join("/föo", "bar")
+ lint_config = LintConfig()
+ lint_config.target = self.SAMPLES_DIR
+ expected_path = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ path = GitHookInstaller.commit_msg_hook_path(lint_config)
+
+ git_hooks_dir.assert_called_once_with(self.SAMPLES_DIR)
+ self.assertEqual(path, expected_path)
+
+ @staticmethod
+ @patch("os.chmod")
+ @patch("os.stat")
+ @patch("gitlint.hooks.shutil.copy")
+ @patch("os.path.exists", return_value=False)
+ @patch("os.path.isdir", return_value=True)
+ @patch("gitlint.hooks.git_hooks_dir")
+ def test_install_commit_msg_hook(git_hooks_dir, isdir, path_exists, copy, stat, chmod):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join("/hür", "dur")
+ git_hooks_dir.return_value = os.path.join("/föo", "bar", ".git", "hooks")
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ GitHookInstaller.install_commit_msg_hook(lint_config)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ copy.assert_called_once_with(COMMIT_MSG_HOOK_SRC_PATH, expected_dst)
+ stat.assert_called_once_with(expected_dst)
+ chmod.assert_called_once_with(expected_dst, ANY)
+ git_hooks_dir.assert_called_with(lint_config.target)
+
+ @patch("gitlint.hooks.shutil.copy")
+ @patch("os.path.exists", return_value=False)
+ @patch("os.path.isdir", return_value=True)
+ @patch("gitlint.hooks.git_hooks_dir")
+ def test_install_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, copy):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join("/hür", "dur")
+ git_hooks_dir.return_value = os.path.join("/föo", "bar", ".git", "hooks")
+ # mock that current dir is not a git repo
+ isdir.return_value = False
+ expected_msg = f"{lint_config.target} is not a git repository."
+ with self.assertRaisesMessage(GitHookInstallerError, expected_msg):
+ GitHookInstaller.install_commit_msg_hook(lint_config)
+
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_not_called()
+ copy.assert_not_called()
+
+ # mock that there is already a commit hook present
+ isdir.return_value = True
+ path_exists.return_value = True
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = (
+ 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")
+ 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("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)
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ remove.assert_called_with(expected_dst)
+ git_hooks_dir.assert_called_with(lint_config.target)
+
+ @patch("os.remove")
+ @patch("os.path.exists", return_value=True)
+ @patch("os.path.isdir", return_value=True)
+ @patch("gitlint.hooks.git_hooks_dir")
+ def test_uninstall_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, remove):
+ lint_config = LintConfig()
+ lint_config.target = os.path.join("/hür", "dur")
+ git_hooks_dir.return_value = os.path.join("/föo", "bar", ".git", "hooks")
+
+ # mock that the current directory is not a git repo
+ isdir.return_value = False
+ expected_msg = f"{lint_config.target} is not a git repository."
+ with self.assertRaisesMessage(GitHookInstallerError, expected_msg):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_not_called()
+ remove.assert_not_called()
+
+ # mock that there is no commit hook present
+ isdir.return_value = True
+ path_exists.return_value = False
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = f"There is no commit-msg hook present in {expected_dst}."
+ with self.assertRaisesMessage(GitHookInstallerError, expected_msg):
+ GitHookInstaller.uninstall_commit_msg_hook(lint_config)
+
+ isdir.assert_called_with(git_hooks_dir.return_value)
+ path_exists.assert_called_once_with(expected_dst)
+ remove.assert_not_called()
+
+ # mock that there is a different (=not gitlint) commit hook
+ isdir.return_value = True
+ path_exists.return_value = True
+ read_data = "#!/bin/sh\nfoo"
+ expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
+ expected_msg = (
+ 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
new file mode 100644
index 0000000..1cf3772
--- /dev/null
+++ b/gitlint-core/gitlint/tests/test_lint.py
@@ -0,0 +1,296 @@
+from io import StringIO
+from unittest.mock import patch
+
+from gitlint.config import LintConfig, LintConfigBuilder
+from gitlint.lint import GitLinter
+from gitlint.rules import RuleViolation, TitleMustNotContainWord
+from gitlint.tests.base import BaseTestCase
+
+
+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])
+ # 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)
+
+ def test_lint_sample2(self):
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample2"))
+ violations = linter.lint(gitcontext.commits[-1])
+ expected = [
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "Just a title contåining WIP", 1),
+ RuleViolation("B6", "Body message is missing", None, 3),
+ ]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample3(self):
+ linter = GitLinter(LintConfig())
+ gitcontext = self.gitcontext(self.get_sample("commit_message/sample3"))
+ violations = linter.lint(gitcontext.commits[-1])
+
+ # 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)
+ ]
+ # fmt: on
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample4(self):
+ commit = self.gitcommit(self.get_sample("commit_message/sample4"))
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(commit)
+ linter = GitLinter(config_builder.build())
+ violations = linter.lint(commit)
+ # expect no violations because sample4 has a 'gitlint: disable line'
+ expected = []
+ self.assertListEqual(violations, expected)
+
+ def test_lint_sample5(self):
+ commit = self.gitcommit(self.get_sample("commit_message/sample5"))
+ config_builder = LintConfigBuilder()
+ config_builder.set_config_from_commit(commit)
+ linter = GitLinter(config_builder.build())
+ violations = linter.lint(commit)
+
+ title = " 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),
+ ]
+ 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"""
+ 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),
+ ]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_ignore(self):
+ lint_config = LintConfig()
+ lint_config.ignore = ["T1", "T3", "T4", "T5", "T6", "B1", "B2"]
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample3")))
+
+ expected = [
+ RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)", "This line has a tråiling tab.\t", 5),
+ ]
+
+ self.assertListEqual(violations, expected)
+
+ def test_lint_configuration_rule(self):
+ # Test that all rules are ignored because of matching regex
+ lint_config = LintConfig()
+ lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
+
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
+ self.assertListEqual(violations, [])
+
+ # Test ignoring only certain rules
+ lint_config = LintConfig()
+ lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
+ lint_config.set_rule_option("I1", "ignore", "B6")
+
+ linter = GitLinter(lint_config)
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
+
+ # Normally we'd expect a B6 violation, but that one is skipped because of the specific ignore set above
+ expected = [
+ RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", "Just a title contåining WIP", 1)
+ ]
+
+ self.assertListEqual(violations, expected)
+
+ # Test ignoring body lines
+ lint_config = LintConfig()
+ linter = GitLinter(lint_config)
+ lint_config.set_rule_option("I3", "regex", "(.*)tråiling(.*)")
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample1")))
+ # 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", "fixup_amend"]:
+ commit = self.gitcommit(self.get_sample(f"commit_message/{commit_type}"))
+ lintconfig = LintConfig()
+ linter = GitLinter(lintconfig)
+ violations = linter.lint(commit)
+ # Even though there are a number of violations in the commit message, they are ignored because
+ # we are dealing with a merge commit
+ self.assertListEqual(violations, [])
+
+ # Check that we do see violations if we disable 'ignore-merge-commits'
+ setattr(lintconfig, f"ignore_{commit_type}_commits", False)
+ linter = GitLinter(lintconfig)
+ violations = linter.lint(commit)
+ self.assertTrue(len(violations) > 0)
+
+ def test_lint_regex_rules(self):
+ """Additional test for title-match-regex, body-match-regex"""
+ commit = self.gitcommit(self.get_sample("commit_message/no-violations"))
+ lintconfig = LintConfig()
+ linter = GitLinter(lintconfig)
+ violations = linter.lint(commit)
+ # No violations by default
+ self.assertListEqual(violations, [])
+
+ # Matching regexes shouldn't be a problem
+ rule_regexes = [("title-match-regex", "Tïtle$"), ("body-match-regex", "Sïgned-Off-By: (.*)$")]
+ for rule_regex in rule_regexes:
+ lintconfig.set_rule_option(rule_regex[0], "regex", rule_regex[1])
+ violations = linter.lint(commit)
+ self.assertListEqual(violations, [])
+
+ # Non-matching regexes should return violations
+ 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),
+ ]
+ 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),
+ ]
+ linter = GitLinter(LintConfig())
+
+ # test output with increasing verbosity
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ linter.config.verbosity = 0
+ linter.print_violations(violations)
+ self.assertEqual("", stderr.getvalue())
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ linter.config.verbosity = 1
+ linter.print_violations(violations)
+ expected = "-: RULE_ID_1\n2: RULE_ID_2\n"
+ self.assertEqual(expected, stderr.getvalue())
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ linter.config.verbosity = 2
+ linter.print_violations(violations)
+ expected = "-: RULE_ID_1 Error Messåge 1\n2: RULE_ID_2 Error Message 2\n"
+ self.assertEqual(expected, stderr.getvalue())
+
+ with patch("gitlint.display.stderr", new=StringIO()) as stderr:
+ linter.config.verbosity = 3
+ linter.print_violations(violations)
+ expected = (
+ '-: 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"""
+
+ lint_config = LintConfig()
+ for rule_name in ["my-ïd", "another-rule-ïd"]:
+ rule_id = TitleMustNotContainWord.id + ":" + rule_name
+ lint_config.rules.add_rule(TitleMustNotContainWord, rule_id)
+ 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),
+ ]
+ 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"""
+
+ # Add named rule to lint config
+ config_builder = LintConfigBuilder()
+ rule_id = TitleMustNotContainWord.id + ":my-ïd"
+ config_builder.set_option(rule_id, "words", ["Föo"])
+ lint_config = config_builder.build()
+ linter = GitLinter(lint_config)
+ 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),
+ ]
+ self.assertListEqual(violations, linter.lint(commit))
+
+ # ignore regular rule: only named rule violations show up
+ lint_config.ignore = ["T5"]
+ self.assertListEqual(violations[1:], linter.lint(commit))
+
+ # ignore named rule by id: only regular rule violations show up
+ lint_config.ignore = [rule_id]
+ self.assertListEqual(violations[:-1], linter.lint(commit))
+
+ # ignore named rule by name: only regular rule violations show up
+ lint_config.ignore = [TitleMustNotContainWord.name + ":my-ïd"]
+ self.assertListEqual(violations[:-1], linter.lint(commit))
diff --git a/gitlint-core/gitlint/tests/test_options.py b/gitlint-core/gitlint/tests/test_options.py
new file mode 100644
index 0000000..deff723
--- /dev/null
+++ b/gitlint-core/gitlint/tests/test_options.py
@@ -0,0 +1,240 @@
+import os
+import re
+
+from gitlint.options import (
+ BoolOption,
+ IntOption,
+ ListOption,
+ PathOption,
+ RegexOption,
+ RuleOptionError,
+ StrOption,
+)
+from gitlint.tests.base import BaseTestCase
+
+
+class RuleOptionTests(BaseTestCase):
+ def test_option__str__(self):
+ option = StrOption("tëst-option", "åbc", "Test Dëscription")
+ self.assertEqual(str(option), "(tëst-option: åbc (Test Dëscription))")
+
+ def test_option_equality(self):
+ 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")
+ option2 = clazz("test-öption", val, "Test Dëscription")
+ self.assertEqual(option1, option2)
+
+ # Not equal: class, name, description, value are different
+ self.assertNotEqual(option1, IntOption("tëst-option1", 123, "Test Dëscription"))
+ self.assertNotEqual(option1, StrOption("tëst-option1", "åbc", "Test Dëscription"))
+ self.assertNotEqual(option1, StrOption("tëst-option", "åbcd", "Test Dëscription"))
+ self.assertNotEqual(option1, StrOption("tëst-option", "åbc", "Test Dëscription2"))
+
+ def test_int_option(self):
+ # normal behavior
+ option = IntOption("tëst-name", 123, "Tëst Description")
+ self.assertEqual(option.name, "tëst-name")
+ self.assertEqual(option.description, "Tëst Description")
+ self.assertEqual(option.value, 123)
+
+ # re-set value
+ option.set(456)
+ self.assertEqual(option.value, 456)
+
+ # set to None
+ option.set(None)
+ self.assertEqual(option.value, None)
+
+ # error on negative int when not allowed
+ expected_error = "Option 'tëst-name' must be a positive integer (current value: '-123')"
+ with self.assertRaisesMessage(RuleOptionError, expected_error):
+ option.set(-123)
+
+ # error on non-int value
+ expected_error = "Option 'tëst-name' must be a positive integer (current value: 'foo')"
+ with self.assertRaisesMessage(RuleOptionError, expected_error):
+ option.set("foo")
+
+ # no error on negative value when allowed and negative int is passed
+ option = IntOption("test-name", 123, "Test Description", allow_negative=True)
+ option.set(-456)
+ self.assertEqual(option.value, -456)
+
+ # error on non-int value when negative int is allowed
+ expected_error = "Option 'test-name' must be an integer (current value: 'foo')"
+ with self.assertRaisesMessage(RuleOptionError, expected_error):
+ option.set("foo")
+
+ def test_str_option(self):
+ # normal behavior
+ option = StrOption("tëst-name", "föo", "Tëst Description")
+ self.assertEqual(option.name, "tëst-name")
+ self.assertEqual(option.description, "Tëst Description")
+ self.assertEqual(option.value, "föo")
+
+ # re-set value
+ option.set("bår")
+ self.assertEqual(option.value, "bår")
+
+ # conversion to str
+ option.set(123)
+ self.assertEqual(option.value, "123")
+
+ # conversion to str
+ option.set(-123)
+ self.assertEqual(option.value, "-123")
+
+ # None value
+ option.set(None)
+ self.assertEqual(option.value, None)
+
+ def test_boolean_option(self):
+ # normal behavior
+ option = BoolOption("tëst-name", "true", "Tëst Description")
+ self.assertEqual(option.name, "tëst-name")
+ self.assertEqual(option.description, "Tëst Description")
+ self.assertEqual(option.value, True)
+
+ # re-set value
+ option.set("False")
+ self.assertEqual(option.value, False)
+
+ # Re-set using actual boolean
+ option.set(True)
+ self.assertEqual(option.value, True)
+
+ # error on incorrect value
+ incorrect_values = [1, -1, "foo", "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)
+
+ def test_list_option(self):
+ # normal behavior
+ option = ListOption("tëst-name", "å,b,c,d", "Tëst Description")
+ self.assertEqual(option.name, "tëst-name")
+ self.assertEqual(option.description, "Tëst Description")
+ self.assertListEqual(option.value, ["å", "b", "c", "d"])
+
+ # re-set value
+ option.set("1,2,3,4")
+ self.assertListEqual(option.value, ["1", "2", "3", "4"])
+
+ # set list
+ option.set(["foo", "bår", "test"])
+ self.assertListEqual(option.value, ["foo", "bår", "test"])
+
+ # None
+ option.set(None)
+ self.assertIsNone(option.value)
+
+ # empty string
+ option.set("")
+ self.assertListEqual(option.value, [])
+
+ # whitespace string
+ option.set(" \t ")
+ self.assertListEqual(option.value, [])
+
+ # empty list
+ option.set([])
+ self.assertListEqual(option.value, [])
+
+ # trailing comma
+ option.set("ë,f,g,")
+ self.assertListEqual(option.value, ["ë", "f", "g"])
+
+ # leading and trailing whitespace should be trimmed, but only deduped within text
+ option.set(" abc , def , ghi \t , jkl mno ")
+ self.assertListEqual(option.value, ["abc", "def", "ghi", "jkl mno"])
+
+ # Also strip whitespace within a list
+ option.set(["\t foo", "bar \t ", " test 123 "])
+ self.assertListEqual(option.value, ["foo", "bar", "test 123"])
+
+ # conversion to string before split
+ option.set(123)
+ self.assertListEqual(option.value, ["123"])
+
+ def test_path_option(self):
+ option = PathOption("tëst-directory", ".", "Tëst Description", type="dir")
+ self.assertEqual(option.name, "tëst-directory")
+ self.assertEqual(option.description, "Tëst Description")
+ self.assertEqual(option.value, os.path.realpath("."))
+ self.assertEqual(option.type, "dir")
+
+ # re-set value
+ option.set(self.SAMPLES_DIR)
+ self.assertEqual(option.value, self.SAMPLES_DIR)
+
+ # set to None
+ option.set(None)
+ self.assertIsNone(option.value)
+
+ # set to int
+ expected = "Option tëst-directory must be an existing directory (current value: '1234')"
+ with self.assertRaisesMessage(RuleOptionError, expected):
+ option.set(1234)
+
+ # set to non-existing directory
+ non_existing_path = os.path.join("/föo", "bar")
+ expected = f"Option tëst-directory must be an existing directory (current value: '{non_existing_path}')"
+ with self.assertRaisesMessage(RuleOptionError, expected):
+ option.set(non_existing_path)
+
+ # set to a file, should raise exception since option.type = dir
+ sample_path = self.get_sample_path(os.path.join("commit_message", "sample1"))
+ expected = f"Option tëst-directory must be an existing directory (current value: '{sample_path}')"
+ with self.assertRaisesMessage(RuleOptionError, expected):
+ option.set(sample_path)
+
+ # set option.type = file, file should now be accepted, directories not
+ option.type = "file"
+ option.set(sample_path)
+ self.assertEqual(option.value, sample_path)
+ expected = f"Option tëst-directory must be an existing file (current value: '{self.get_sample_path()}')"
+ with self.assertRaisesMessage(RuleOptionError, expected):
+ option.set(self.get_sample_path())
+
+ # set option.type = both, files and directories should now be accepted
+ option.type = "both"
+ option.set(sample_path)
+ self.assertEqual(option.value, sample_path)
+ option.set(self.get_sample_path())
+ self.assertEqual(option.value, self.get_sample_path())
+
+ # Expect exception if path type is invalid
+ option.type = "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")
+
+ def test_regex_option(self):
+ # normal behavior
+ option = RegexOption("tëst-regex", "^myrëgex(.*)foo$", "Tëst Regex Description")
+ self.assertEqual(option.name, "tëst-regex")
+ self.assertEqual(option.description, "Tëst Regex Description")
+ self.assertEqual(option.value, re.compile("^myrëgex(.*)foo$", re.UNICODE))
+
+ # re-set value
+ option.set("[0-9]föbar.*")
+ self.assertEqual(option.value, re.compile("[0-9]föbar.*", re.UNICODE))
+
+ # set None
+ option.set(None)
+ self.assertIsNone(option.value)
+
+ # error on invalid regex
+ incorrect_values = ["foo(", 123, -1]
+ for value in incorrect_values:
+ with self.assertRaisesRegex(RuleOptionError, "Invalid regular expression"):
+ option.set(value)
diff --git a/gitlint/tests/test_utils.py b/gitlint-core/gitlint/tests/test_utils.py
index 6f667c2..d21ec3f 100644
--- a/gitlint/tests/test_utils.py
+++ b/gitlint-core/gitlint/tests/test_utils.py
@@ -1,55 +1,43 @@
-# -*- coding: utf-8 -*-
+from unittest.mock import patch
from gitlint import utils
from gitlint.tests.base import BaseTestCase
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
class UtilsTests(BaseTestCase):
-
def tearDown(self):
# Since we're messing around with `utils.PLATFORM_IS_WINDOWS` during these tests, we need to reset
# its value after we're done this doesn't influence other tests
utils.PLATFORM_IS_WINDOWS = utils.platform_is_windows()
- @patch('os.environ')
+ @patch("os.environ")
def test_use_sh_library(self, patched_env):
patched_env.get.return_value = "1"
self.assertEqual(utils.use_sh_library(), True)
patched_env.get.assert_called_once_with("GITLINT_USE_SH_LIB", None)
- for invalid_val in ["0", u"foöbar"]:
+ for invalid_val in ["0", "foöbar"]:
patched_env.get.reset_mock() # reset mock call count
patched_env.get.return_value = invalid_val
self.assertEqual(utils.use_sh_library(), False, invalid_val)
patched_env.get.assert_called_once_with("GITLINT_USE_SH_LIB", None)
- # Assert that when GITLINT_USE_SH_LIB is not set, we fallback to checking whether we're on Windows
- utils.PLATFORM_IS_WINDOWS = True
+ # 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)
+ @patch("gitlint.utils.locale")
+ def test_terminal_encoding_non_windows(self, mocked_locale):
utils.PLATFORM_IS_WINDOWS = False
- self.assertEqual(utils.use_sh_library(), True)
-
- @patch('gitlint.utils.locale')
- def test_default_encoding_non_windows(self, mocked_locale):
- utils.PLATFORM_IS_WINDOWS = False
- mocked_locale.getpreferredencoding.return_value = u"foöbar"
- self.assertEqual(utils.getpreferredencoding(), u"foöbar")
+ mocked_locale.getpreferredencoding.return_value = "foöbar"
+ self.assertEqual(utils.getpreferredencoding(), "foöbar")
mocked_locale.getpreferredencoding.assert_called_once()
mocked_locale.getpreferredencoding.return_value = False
- self.assertEqual(utils.getpreferredencoding(), u"UTF-8")
+ self.assertEqual(utils.getpreferredencoding(), "UTF-8")
- @patch('os.environ')
- def test_default_encoding_windows(self, patched_env):
+ @patch("os.environ")
+ def test_terminal_encoding_windows(self, patched_env):
utils.PLATFORM_IS_WINDOWS = True
# Mock out os.environ
mock_env = {}
@@ -60,19 +48,23 @@ class UtilsTests(BaseTestCase):
patched_env.get.side_effect = mocked_get
# Assert getpreferredencoding reads env vars in order: LC_ALL, LC_CTYPE, LANG
- mock_env = {"LC_ALL": u"lc_all_välue", "LC_CTYPE": u"foo", "LANG": u"bar"}
- self.assertEqual(utils.getpreferredencoding(), u"lc_all_välue")
- mock_env = {"LC_CTYPE": u"lc_ctype_välue", "LANG": u"hur"}
- self.assertEqual(utils.getpreferredencoding(), u"lc_ctype_välue")
- mock_env = {"LANG": u"lang_välue"}
- self.assertEqual(utils.getpreferredencoding(), u"lang_välue")
+ mock_env = {"LC_ALL": "ASCII", "LC_CTYPE": "UTF-16", "LANG": "CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), "ASCII")
+ mock_env = {"LC_CTYPE": "UTF-16", "LANG": "CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), "UTF-16")
+ mock_env = {"LANG": "CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), "CP1251")
# Assert split on dot
- mock_env = {"LANG": u"foo.bär"}
- self.assertEqual(utils.getpreferredencoding(), u"bär")
+ mock_env = {"LANG": "foo.UTF-16"}
+ self.assertEqual(utils.getpreferredencoding(), "UTF-16")
# assert default encoding is UTF-8
mock_env = {}
self.assertEqual(utils.getpreferredencoding(), "UTF-8")
- mock_env = {"FOO": u"föo"}
+ mock_env = {"FOO": "föo"}
+ self.assertEqual(utils.getpreferredencoding(), "UTF-8")
+
+ # assert fallback encoding is UTF-8 in case we set an unavailable encoding
+ mock_env = {"LC_ALL": "foo"}
self.assertEqual(utils.getpreferredencoding(), "UTF-8")
diff --git a/gitlint/utils.py b/gitlint-core/gitlint/utils.py
index c418347..3ccb78b 100644
--- a/gitlint/utils.py
+++ b/gitlint-core/gitlint/utils.py
@@ -1,9 +1,7 @@
-# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return
-import platform
-import sys
-import os
-
+import codecs
import locale
+import os
+import platform
# Note: While we can easily inline the logic related to the constants set in this module, we deliberately create
# small functions that encapsulate that logic as this enables easy unit testing. In particular, by creating functions
@@ -11,7 +9,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,75 +29,59 @@ 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()
########################################################################################################################
-# DEFAULT_ENCODING
+# TERMINAL_ENCODING
+# Encoding used for terminal encoding/decoding.
def getpreferredencoding():
- """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars
- on windows and falls back to UTF-8. """
- default_encoding = locale.getpreferredencoding() or "UTF-8"
+ """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"
+ preferred_encoding = locale.getpreferredencoding() or fallback_encoding
# On Windows, we mimic git/linux by trying to read the LC_ALL, LC_CTYPE, LANG env vars manually
# (on Linux/MacOS the `getpreferredencoding()` call will take care of this).
# We fallback to UTF-8
if PLATFORM_IS_WINDOWS:
- default_encoding = "UTF-8"
+ preferred_encoding = fallback_encoding
for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]:
encoding = os.environ.get(env_var, False)
if encoding:
# Support dotted (C.UTF-8) and non-dotted (C or UTF-8) charsets:
# If encoding contains a dot: split and use second part, otherwise use everything
dot_index = encoding.find(".")
- if dot_index != -1:
- default_encoding = encoding[dot_index + 1:]
- else:
- default_encoding = encoding
+ preferred_encoding = encoding[dot_index + 1 :] if dot_index != -1 else encoding
break
- return default_encoding
+ # We've determined what encoding the user *wants*, let's now check if it's actually a valid encoding on the
+ # system. If not, fallback to UTF-8.
+ # This scenario is fairly common on Windows where git sets LC_CTYPE=C when invoking the commit-msg hook, which
+ # is not a valid encoding in Python on Windows.
+ try:
+ codecs.lookup(preferred_encoding)
+ except LookupError:
+ preferred_encoding = fallback_encoding
+
+ return preferred_encoding
-DEFAULT_ENCODING = getpreferredencoding()
+TERMINAL_ENCODING = getpreferredencoding()
########################################################################################################################
-# Unicode utility functions
-
-
-def ustr(obj):
- """ Python 2 and 3 utility method that converts an obj to unicode in python 2 and to a str object in python 3"""
- if sys.version_info[0] == 2:
- # If we are getting a string, then do an explicit decode
- # else, just call the unicode method of the object
- if type(obj) in [str, basestring]: # pragma: no cover # noqa
- return unicode(obj, DEFAULT_ENCODING) # pragma: no cover # noqa
- else:
- return unicode(obj) # pragma: no cover # noqa
- else:
- if type(obj) in [bytes]:
- return obj.decode(DEFAULT_ENCODING)
- else:
- return str(obj)
-
-
-def sstr(obj):
- """ Python 2 and 3 utility method that converts an obj to a DEFAULT_ENCODING encoded string in python 2
- and to unicode in python 3.
- Especially useful for implementing __str__ methods in python 2: http://stackoverflow.com/a/1307210/381010"""
- if sys.version_info[0] == 2:
- # For lists in python2, remove unicode string representation characters.
- # i.e. ensure lists are printed as ['a', 'b'] and not [u'a', u'b']
- if type(obj) in [list]:
- return [sstr(item) for item in obj] # pragma: no cover # noqa
-
- return unicode(obj).encode(DEFAULT_ENCODING) # pragma: no cover # noqa
- else:
- return obj # pragma: no cover
+# FILE_ENCODING
+# Gitlint assumes UTF-8 encoding for all file operations:
+# - reading/writing its own hook and config files
+# - reading/writing git commit messages
+# Git does have i18n.commitEncoding and i18n.logOutputEncoding options which we might want to take into account,
+# but that's not supported today.
+
+FILE_ENCODING = "UTF-8"
diff --git a/gitlint-core/pyproject.toml b/gitlint-core/pyproject.toml
new file mode 100644
index 0000000..e65b7b0
--- /dev/null
+++ b/gitlint-core/pyproject.toml
@@ -0,0 +1,71 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "gitlint-core"
+dynamic = ["version", "urls"]
+description = "Git commit message linter written in python, checks your commit messages for style."
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.7"
+authors = [{ name = "Joris Roovers" }]
+keywords = [
+ "git",
+ "gitlint",
+ "lint", #
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.7",
+ "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",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: Software Development :: Testing",
+]
+dependencies = [
+ "arrow>=1",
+ "Click>=8",
+ "importlib-metadata >= 1.0 ; python_version < \"3.8\"",
+ "sh>=1.13.0 ; sys_platform != \"win32\"",
+]
+
+[project.optional-dependencies]
+trusted-deps = [
+ "arrow==1.2.3",
+ "Click==8.1.3",
+ "sh==1.14.3 ; sys_platform != \"win32\"",
+]
+
+[project.scripts]
+gitlint = "gitlint.cli:cli"
+
+[tool.hatch.version]
+source = "vcs"
+raw-options = { root = ".." }
+
+[tool.hatch.build]
+include = [
+ "/gitlint", #
+]
+
+exclude = [
+ "/gitlint/tests", #
+]
+
+[tool.hatch.metadata.hooks.vcs.urls]
+Homepage = "https://jorisroovers.github.io/gitlint"
+Documentation = "https://jorisroovers.github.io/gitlint"
+Source = "https://github.com/jorisroovers/gitlint/tree/main/gitlint-core"
+Changelog = "https://github.com/jorisroovers/gitlint/blob/main/CHANGELOG.md"
+# TODO(jorisroovers): Temporary disable until fixed in hatch-vcs (see #460)
+# 'Source Commit' = "https://github.com/jorisroovers/gitlint/tree/{commit_hash}/gitlint-core" \ No newline at end of file
diff --git a/gitlint/__init__.py b/gitlint/__init__.py
deleted file mode 100644
index 7e0dc0e..0000000
--- a/gitlint/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "0.13.1"
diff --git a/gitlint/cache.py b/gitlint/cache.py
deleted file mode 100644
index b7f9e6c..0000000
--- a/gitlint/cache.py
+++ /dev/null
@@ -1,57 +0,0 @@
-class PropertyCache(object):
- """ Mixin class providing a simple cache. """
-
- def __init__(self):
- self._cache = {}
-
- def _try_cache(self, cache_key, cache_populate_func):
- """ Tries to get a value from the cache identified by `cache_key`.
- If no value is found in the cache, do a function call to `cache_populate_func` to populate the cache
- and then return the value from the cache. """
- if cache_key not in self._cache:
- cache_populate_func()
- return self._cache[cache_key]
-
-
-def cache(original_func=None, cachekey=None):
- """ Cache decorator. Caches function return values.
- Requires the parent class to extend and initialize PropertyCache.
- Usage:
- # Use function name as cache key
- @cache
- def myfunc(args):
- ...
-
- # Specify cache key
- @cache(cachekey="foobar")
- def myfunc(args):
- ...
- """
-
- # Decorators with optional arguments are a bit convoluted in python, especially if you want to support both
- # Python 2 and 3. See some of the links below for details.
-
- def cache_decorator(func):
-
- # If no specific cache key is given, use the function name as cache key
- if not cache_decorator.cachekey:
- cache_decorator.cachekey = func.__name__
-
- def wrapped(*args):
- def cache_func_result():
- # Call decorated function and store its result in the cache
- args[0]._cache[cache_decorator.cachekey] = func(*args)
- return args[0]._try_cache(cache_decorator.cachekey, cache_func_result)
-
- return wrapped
-
- # Passing parent function variables to child functions requires special voodoo in python2:
- # https://stackoverflow.com/a/14678445/381010
- cache_decorator.cachekey = cachekey # attribute on the function
-
- # To support optional kwargs for decorators, we need to check if a function is passed as first argument or not.
- # https://stackoverflow.com/a/24617244/381010
- if original_func:
- return cache_decorator(original_func)
-
- return cache_decorator
diff --git a/gitlint/cli.py b/gitlint/cli.py
deleted file mode 100644
index 4553fda..0000000
--- a/gitlint/cli.py
+++ /dev/null
@@ -1,338 +0,0 @@
-# pylint: disable=bad-option-value,wrong-import-position
-# We need to disable the import position checks because of the windows check that we need to do below
-import copy
-import logging
-import os
-import platform
-import stat
-import sys
-import click
-
-# Error codes
-MAX_VIOLATION_ERROR_CODE = 252 # noqa
-USAGE_ERROR_CODE = 253 # noqa
-GIT_CONTEXT_ERROR_CODE = 254 # noqa
-CONFIG_ERROR_CODE = 255 # noqa
-
-import gitlint
-from gitlint.lint import GitLinter
-from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator
-from gitlint.git import GitContext, GitContextError, git_version
-from gitlint import hooks
-from gitlint.utils import ustr, LOG_FORMAT
-
-DEFAULT_CONFIG_FILE = ".gitlint"
-
-# Since we use the return code to denote the amount of errors, we need to change the default click usage error code
-click.UsageError.exit_code = USAGE_ERROR_CODE
-
-LOG = logging.getLogger(__name__)
-
-
-class GitLintUsageError(Exception):
- """ Exception indicating there is an issue with how gitlint is used. """
- pass
-
-
-def setup_logging():
- """ Setup gitlint logging """
- root_log = logging.getLogger("gitlint")
- root_log.propagate = False # Don't propagate to child loggers, the gitlint root logger handles everything
- handler = logging.StreamHandler()
- formatter = logging.Formatter(LOG_FORMAT)
- handler.setFormatter(formatter)
- root_log.addHandler(handler)
- root_log.setLevel(logging.ERROR)
-
-
-def log_system_info():
- LOG.debug("Platform: %s", platform.platform())
- LOG.debug("Python version: %s", sys.version)
- LOG.debug("Git version: %s", git_version())
- LOG.debug("Gitlint version: %s", gitlint.__version__)
- LOG.debug("GITLINT_USE_SH_LIB: %s", os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]"))
-
-
-def build_config( # pylint: disable=too-many-arguments
- target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, verbose, silent, debug
-):
- """ Creates a LintConfig object based on a set of commandline parameters. """
- config_builder = LintConfigBuilder()
- # Config precedence:
- # First, load default config or config from configfile
- if config_path:
- config_builder.set_from_config_file(config_path)
- elif os.path.exists(DEFAULT_CONFIG_FILE):
- config_builder.set_from_config_file(DEFAULT_CONFIG_FILE)
-
- # Then process any commandline configuration flags
- config_builder.set_config_from_string_list(c)
-
- # Finally, overwrite with any convenience commandline flags
- if ignore:
- config_builder.set_option('general', 'ignore', ignore)
-
- if contrib:
- config_builder.set_option('general', 'contrib', contrib)
-
- if ignore_stdin:
- config_builder.set_option('general', 'ignore-stdin', ignore_stdin)
-
- if silent:
- config_builder.set_option('general', 'verbosity', 0)
- elif verbose > 0:
- config_builder.set_option('general', 'verbosity', verbose)
-
- if extra_path:
- config_builder.set_option('general', 'extra-path', extra_path)
-
- if target:
- config_builder.set_option('general', 'target', target)
-
- if debug:
- config_builder.set_option('general', 'debug', debug)
-
- if staged:
- config_builder.set_option('general', 'staged', staged)
-
- config = config_builder.build()
-
- return config, config_builder
-
-
-def get_stdin_data():
- """ Helper function that returns data send to stdin or False if nothing is send """
- # STDIN can only be 3 different types of things ("modes")
- # 1. An interactive terminal device (i.e. a TTY -> sys.stdin.isatty() or stat.S_ISCHR)
- # 2. A (named) pipe (stat.S_ISFIFO)
- # 3. A regular file (stat.S_ISREG)
- # Technically, STDIN can also be other device type like a named unix socket (stat.S_ISSOCK), but we don't
- # support that in gitlint (at least not today).
- #
- # Now, the behavior that we want is the following:
- # If someone sends something directly to gitlint via a pipe or a regular file, read it. If not, read from the
- # local repository.
- # Note that we don't care about whether STDIN is a TTY or not, we only care whether data is via a pipe or regular
- # file.
- # However, in case STDIN is not a TTY, it HAS to be one of the 2 other things (pipe or regular file), even if
- # no-one is actually sending anything to gitlint over them. In this case, we still want to read from the local
- # repository.
- # To support this use-case (which is common in CI runners such as Jenkins and Gitlab), we need to actually attempt
- # to read from STDIN in case it's a pipe or regular file. In case that fails, then we'll fall back to reading
- # from the local repo.
-
- mode = os.fstat(sys.stdin.fileno()).st_mode
- stdin_is_pipe_or_file = stat.S_ISFIFO(mode) or stat.S_ISREG(mode)
- if stdin_is_pipe_or_file:
- input_data = sys.stdin.read()
- # Only return the input data if there's actually something passed
- # i.e. don't consider empty piped data
- if input_data:
- return ustr(input_data)
- return False
-
-
-def build_git_context(lint_config, msg_filename, refspec):
- """ Builds a git context based on passed parameters and order of precedence """
-
- # Determine which GitContext method to use if a custom message is passed
- from_commit_msg = GitContext.from_commit_msg
- if lint_config.staged:
- LOG.debug("Fetching additional meta-data from staged commit")
- from_commit_msg = lambda message: GitContext.from_staged_commit(message, lint_config.target) # noqa
-
- # Order of precedence:
- # 1. Any data specified via --msg-filename
- if msg_filename:
- LOG.debug("Using --msg-filename.")
- return from_commit_msg(ustr(msg_filename.read()))
-
- # 2. Any data sent to stdin (unless stdin is being ignored)
- if not lint_config.ignore_stdin:
- stdin_input = get_stdin_data()
- if stdin_input:
- LOG.debug("Stdin data: '%s'", stdin_input)
- LOG.debug("Stdin detected and not ignored. Using as input.")
- return from_commit_msg(stdin_input)
-
- if lint_config.staged:
- raise GitLintUsageError(u"The 'staged' option (--staged) can only be used when using '--msg-filename' or "
- u"when piping data to gitlint via stdin.")
-
- # 3. Fallback to reading from local repository
- LOG.debug("No --msg-filename flag, no or empty data passed to stdin. Using the local repo.")
- return GitContext.from_local_repository(lint_config.target, refspec)
-
-
-@click.group(invoke_without_command=True, context_settings={'max_content_width': 120},
- epilog="When no COMMAND is specified, gitlint defaults to 'gitlint lint'.")
-@click.option('--target', type=click.Path(exists=True, resolve_path=True, file_okay=False, readable=True),
- help="Path of the target git repository. [default: current working directory]")
-@click.option('-C', '--config', type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
- help="Config file location [default: {0}]".format(DEFAULT_CONFIG_FILE))
-@click.option('-c', multiple=True,
- help="Config flags in format <rule>.<option>=<value> (e.g.: -c T1.line-length=80). " +
- "Flag can be used multiple times to set multiple config values.") # pylint: disable=bad-continuation
-@click.option('--commits', default=None, help="The range of commits to lint. [default: HEAD]")
-@click.option('-e', '--extra-path', help="Path to a directory or python module with extra user-defined rules",
- type=click.Path(exists=True, resolve_path=True, readable=True))
-@click.option('--ignore', default="", help="Ignore rules (comma-separated by id or name).")
-@click.option('--contrib', default="", help="Contrib rules to enable (comma-separated by id or name).")
-@click.option('--msg-filename', type=click.File(), help="Path to a file containing a commit-msg.")
-@click.option('--ignore-stdin', is_flag=True, help="Ignore any stdin data. Useful for running in CI server.")
-@click.option('--staged', is_flag=True, help="Read staged commit meta-info from the local repository.")
-@click.option('-v', '--verbose', count=True, default=0,
- help="Verbosity, more v's for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv]", )
-@click.option('-s', '--silent', help="Silent mode (no output). Takes precedence over -v, -vv, -vvv.", is_flag=True)
-@click.option('-d', '--debug', help="Enable debugging output.", is_flag=True)
-@click.version_option(version=gitlint.__version__)
-@click.pass_context
-def cli( # pylint: disable=too-many-arguments
- ctx, target, config, c, commits, extra_path, ignore, contrib,
- msg_filename, ignore_stdin, staged, verbose, silent, debug,
-):
- """ Git lint tool, checks your git commit messages for styling issues
-
- Documentation: http://jorisroovers.github.io/gitlint
- """
-
- try:
- if debug:
- logging.getLogger("gitlint").setLevel(logging.DEBUG)
- LOG.debug("To report issues, please visit https://github.com/jorisroovers/gitlint/issues")
-
- log_system_info()
-
- # Get the lint config from the commandline parameters and
- # store it in the context (click allows storing an arbitrary object in ctx.obj).
- config, config_builder = build_config(target, config, c, extra_path, ignore, contrib,
- ignore_stdin, staged, verbose, silent, debug)
- LOG.debug(u"Configuration\n%s", ustr(config))
-
- ctx.obj = (config, config_builder, commits, msg_filename)
-
- # If no subcommand is specified, then just lint
- if ctx.invoked_subcommand is None:
- ctx.invoke(lint)
-
- except GitContextError as e:
- click.echo(ustr(e))
- ctx.exit(GIT_CONTEXT_ERROR_CODE)
- except GitLintUsageError as e:
- click.echo(u"Error: {0}".format(ustr(e)))
- ctx.exit(USAGE_ERROR_CODE)
- except LintConfigError as e:
- click.echo(u"Config Error: {0}".format(ustr(e)))
- ctx.exit(CONFIG_ERROR_CODE)
-
-
-@cli.command("lint")
-@click.pass_context
-def lint(ctx):
- """ Lints a git repository [default command] """
- lint_config = ctx.obj[0]
- refspec = ctx.obj[2]
- msg_filename = ctx.obj[3]
-
- gitcontext = build_git_context(lint_config, msg_filename, refspec)
-
- number_of_commits = len(gitcontext.commits)
- # Exit if we don't have commits in the specified range. Use a 0 exit code, since a popular use-case is one
- # where users are using --commits in a check job to check the commit messages inside a CI job. By returning 0, we
- # ensure that these jobs don't fail if for whatever reason the specified commit range is empty.
- if number_of_commits == 0:
- LOG.debug(u'No commits in range "%s"', refspec)
- ctx.exit(0)
-
- LOG.debug(u'Linting %d commit(s)', number_of_commits)
- general_config_builder = ctx.obj[1]
- last_commit = gitcontext.commits[-1]
-
- # Let's get linting!
- first_violation = True
- exit_code = 0
- for commit in gitcontext.commits:
- # Build a config_builder taking into account the commit specific config (if any)
- config_builder = general_config_builder.clone()
- config_builder.set_config_from_commit(commit)
-
- # Create a deepcopy from the original config, so we have a unique config object per commit
- # This is important for configuration rules to be able to modifying the config on a per commit basis
- commit_config = config_builder.build(copy.deepcopy(lint_config))
-
- # Actually do the linting
- linter = GitLinter(commit_config)
- violations = linter.lint(commit)
- # exit code equals the total number of violations in all commits
- exit_code += len(violations)
- if violations:
- # Display the commit hash & new lines intelligently
- if number_of_commits > 1 and commit.sha:
- linter.display.e(u"{0}Commit {1}:".format(
- "\n" if not first_violation or commit is last_commit else "",
- commit.sha[:10]
- ))
- linter.print_violations(violations)
- first_violation = False
-
- # cap actual max exit code because bash doesn't like exit codes larger than 255:
- # http://tldp.org/LDP/abs/html/exitcodes.html
- exit_code = min(MAX_VIOLATION_ERROR_CODE, exit_code)
- LOG.debug("Exit Code = %s", exit_code)
- ctx.exit(exit_code)
-
-
-@cli.command("install-hook")
-@click.pass_context
-def install_hook(ctx):
- """ Install gitlint as a git commit-msg hook. """
- try:
- lint_config = ctx.obj[0]
- hooks.GitHookInstaller.install_commit_msg_hook(lint_config)
- hook_path = hooks.GitHookInstaller.commit_msg_hook_path(lint_config)
- click.echo(u"Successfully installed gitlint commit-msg hook in {0}".format(hook_path))
- ctx.exit(0)
- except hooks.GitHookInstallerError as e:
- click.echo(ustr(e), err=True)
- ctx.exit(GIT_CONTEXT_ERROR_CODE)
-
-
-@cli.command("uninstall-hook")
-@click.pass_context
-def uninstall_hook(ctx):
- """ Uninstall gitlint commit-msg hook. """
- try:
- lint_config = ctx.obj[0]
- hooks.GitHookInstaller.uninstall_commit_msg_hook(lint_config)
- hook_path = hooks.GitHookInstaller.commit_msg_hook_path(lint_config)
- click.echo(u"Successfully uninstalled gitlint commit-msg hook from {0}".format(hook_path))
- ctx.exit(0)
- except hooks.GitHookInstallerError as e:
- click.echo(ustr(e), err=True)
- ctx.exit(GIT_CONTEXT_ERROR_CODE)
-
-
-@cli.command("generate-config")
-@click.pass_context
-def generate_config(ctx):
- """ Generates a sample gitlint config file. """
- path = click.prompt('Please specify a location for the sample gitlint config file', default=DEFAULT_CONFIG_FILE)
- path = os.path.realpath(path)
- dir_name = os.path.dirname(path)
- if not os.path.exists(dir_name):
- click.echo(u"Error: Directory '{0}' does not exist.".format(dir_name), err=True)
- ctx.exit(USAGE_ERROR_CODE)
- elif os.path.exists(path):
- click.echo(u"Error: File \"{0}\" already exists.".format(path), err=True)
- ctx.exit(USAGE_ERROR_CODE)
-
- LintConfigGenerator.generate_config(path)
- click.echo(u"Successfully generated {0}".format(path))
- ctx.exit(0)
-
-
-# Let's Party!
-setup_logging()
-if __name__ == "__main__":
- # pylint: disable=no-value-for-parameter
- cli() # pragma: no cover
diff --git a/gitlint/config.py b/gitlint/config.py
deleted file mode 100644
index 914357e..0000000
--- a/gitlint/config.py
+++ /dev/null
@@ -1,482 +0,0 @@
-try:
- # python 2.x
- from ConfigParser import ConfigParser, Error as ConfigParserError
-except ImportError: # pragma: no cover
- # python 3.x
- from configparser import ConfigParser, Error as ConfigParserError # pragma: no cover, pylint: disable=import-error
-
-import copy
-import io
-import re
-import os
-import shutil
-
-from collections import OrderedDict
-from gitlint.utils import ustr, DEFAULT_ENCODING
-from gitlint import rules # For some weird reason pylint complains about this, pylint: disable=unused-import
-from gitlint import options
-from gitlint import rule_finder
-from gitlint.contrib import rules as contrib_rules
-
-
-def handle_option_error(func):
- """ Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a
- LintConfigError. """
-
- def wrapped(*args):
- try:
- return func(*args)
- except options.RuleOptionError as e:
- raise LintConfigError(ustr(e))
-
- return wrapped
-
-
-class LintConfigError(Exception):
- pass
-
-
-class LintConfig(object):
- """ Class representing gitlint configuration.
- Contains active config as well as number of methods to easily get/set the config.
- """
-
- # Default tuple of rule classes (tuple because immutable).
- default_rule_classes = (rules.IgnoreByTitle,
- rules.IgnoreByBody,
- rules.TitleMaxLength,
- rules.TitleTrailingWhitespace,
- rules.TitleLeadingWhitespace,
- rules.TitleTrailingPunctuation,
- rules.TitleHardTab,
- rules.TitleMustNotContainWord,
- rules.TitleRegexMatches,
- rules.BodyMaxLineLength,
- rules.BodyMinLength,
- rules.BodyMissing,
- rules.BodyTrailingWhitespace,
- rules.BodyHardTab,
- rules.BodyFirstLineEmpty,
- rules.BodyChangedFileMention,
- rules.AuthorValidEmail)
-
- def __init__(self):
- self.rules = RuleCollection(self.default_rule_classes)
- self._verbosity = options.IntOption('verbosity', 3, "Verbosity")
- self._ignore_merge_commits = options.BoolOption('ignore-merge-commits', True, "Ignore merge commits")
- self._ignore_fixup_commits = options.BoolOption('ignore-fixup-commits', True, "Ignore fixup commits")
- self._ignore_squash_commits = options.BoolOption('ignore-squash-commits', True, "Ignore squash commits")
- self._ignore_revert_commits = options.BoolOption('ignore-revert-commits', True, "Ignore revert commits")
- self._debug = options.BoolOption('debug', False, "Enable debug mode")
- self._extra_path = None
- target_description = "Path of the target git repository (default=current working directory)"
- self._target = options.PathOption('target', os.path.realpath(os.getcwd()), target_description)
- self._ignore = options.ListOption('ignore', [], 'List of rule-ids to ignore')
- self._contrib = options.ListOption('contrib', [], 'List of contrib-rules to enable')
- self._config_path = None
- ignore_stdin_description = "Ignore any stdin data. Useful for running in CI server."
- self._ignore_stdin = options.BoolOption('ignore-stdin', False, ignore_stdin_description)
- self._staged = options.BoolOption('staged', False, "Read staged commit meta-info from the local repository.")
-
- @property
- def target(self):
- return self._target.value if self._target else None
-
- @target.setter
- @handle_option_error
- def target(self, value):
- return self._target.set(value)
-
- @property
- def verbosity(self):
- return self._verbosity.value
-
- @verbosity.setter
- @handle_option_error
- def verbosity(self, value):
- self._verbosity.set(value)
- if self.verbosity < 0 or self.verbosity > 3:
- raise LintConfigError("Option 'verbosity' must be set between 0 and 3")
-
- @property
- def ignore_merge_commits(self):
- return self._ignore_merge_commits.value
-
- @ignore_merge_commits.setter
- @handle_option_error
- def ignore_merge_commits(self, value):
- return self._ignore_merge_commits.set(value)
-
- @property
- def ignore_fixup_commits(self):
- return self._ignore_fixup_commits.value
-
- @ignore_fixup_commits.setter
- @handle_option_error
- def ignore_fixup_commits(self, value):
- return self._ignore_fixup_commits.set(value)
-
- @property
- def ignore_squash_commits(self):
- return self._ignore_squash_commits.value
-
- @ignore_squash_commits.setter
- @handle_option_error
- def ignore_squash_commits(self, value):
- return self._ignore_squash_commits.set(value)
-
- @property
- def ignore_revert_commits(self):
- return self._ignore_revert_commits.value
-
- @ignore_revert_commits.setter
- @handle_option_error
- def ignore_revert_commits(self, value):
- return self._ignore_revert_commits.set(value)
-
- @property
- def debug(self):
- return self._debug.value
-
- @debug.setter
- @handle_option_error
- def debug(self, value):
- return self._debug.set(value)
-
- @property
- def ignore(self):
- return self._ignore.value
-
- @ignore.setter
- def ignore(self, value):
- if value == "all":
- value = [rule.id for rule in self.rules]
- return self._ignore.set(value)
-
- @property
- def ignore_stdin(self):
- return self._ignore_stdin.value
-
- @ignore_stdin.setter
- @handle_option_error
- def ignore_stdin(self, value):
- return self._ignore_stdin.set(value)
-
- @property
- def staged(self):
- return self._staged.value
-
- @staged.setter
- @handle_option_error
- def staged(self, value):
- return self._staged.set(value)
-
- @property
- def extra_path(self):
- return self._extra_path.value if self._extra_path else None
-
- @extra_path.setter
- def extra_path(self, value):
- try:
- if self.extra_path:
- self._extra_path.set(value)
- else:
- self._extra_path = options.PathOption(
- 'extra-path', value,
- "Path to a directory or module with extra user-defined rules",
- type='both'
- )
-
- # Make sure we unload any previously loaded extra-path rules
- self.rules.delete_rules_by_attr("is_user_defined", True)
-
- # Find rules in the new extra-path and add them to the existing rules
- rule_classes = rule_finder.find_rule_classes(self.extra_path)
- self.rules.add_rules(rule_classes, {'is_user_defined': True})
-
- except (options.RuleOptionError, rules.UserRuleError) as e:
- raise LintConfigError(ustr(e))
-
- @property
- def contrib(self):
- return self._contrib.value
-
- @contrib.setter
- def contrib(self, value):
- try:
- self._contrib.set(value)
-
- # Make sure we unload any previously loaded contrib rules when re-setting the value
- self.rules.delete_rules_by_attr("is_contrib", True)
-
- # Load all classes from the contrib directory
- contrib_dir_path = os.path.dirname(os.path.realpath(contrib_rules.__file__))
- rule_classes = rule_finder.find_rule_classes(contrib_dir_path)
-
- # For each specified contrib rule, check whether it exists among the contrib classes
- for rule_id_or_name in self.contrib:
- rule_class = next((rc for rc in rule_classes if
- rc.id == ustr(rule_id_or_name) or rc.name == ustr(rule_id_or_name)), False)
-
- # If contrib rule exists, instantiate it and add it to the rules list
- if rule_class:
- self.rules.add_rule(rule_class, rule_class.id, {'is_contrib': True})
- else:
- raise LintConfigError(u"No contrib rule with id or name '{0}' found.".format(ustr(rule_id_or_name)))
-
- except (options.RuleOptionError, rules.UserRuleError) as e:
- raise LintConfigError(ustr(e))
-
- def _get_option(self, rule_name_or_id, option_name):
- rule_name_or_id = ustr(rule_name_or_id) # convert to unicode first
- option_name = ustr(option_name)
- rule = self.rules.find_rule(rule_name_or_id)
- if not rule:
- raise LintConfigError(u"No such rule '{0}'".format(rule_name_or_id))
-
- option = rule.options.get(option_name)
- if not option:
- raise LintConfigError(u"Rule '{0}' has no option '{1}'".format(rule_name_or_id, option_name))
-
- return option
-
- def get_rule_option(self, rule_name_or_id, option_name):
- """ Returns the value of a given option for a given rule. LintConfigErrors will be raised if the
- rule or option don't exist. """
- option = self._get_option(rule_name_or_id, option_name)
- return option.value
-
- def set_rule_option(self, rule_name_or_id, option_name, option_value):
- """ Attempts to set a given value for a given option for a given rule.
- LintConfigErrors will be raised if the rule or option don't exist or if the value is invalid. """
- option = self._get_option(rule_name_or_id, option_name)
- try:
- option.set(option_value)
- except options.RuleOptionError as e:
- msg = u"'{0}' is not a valid value for option '{1}.{2}'. {3}."
- raise LintConfigError(msg.format(option_value, rule_name_or_id, option_name, ustr(e)))
-
- def set_general_option(self, option_name, option_value):
- attr_name = option_name.replace("-", "_")
- # only allow setting general options that exist and don't start with an underscore
- if not hasattr(self, attr_name) or attr_name[0] == "_":
- raise LintConfigError(u"'{0}' is not a valid gitlint option".format(option_name))
-
- # else:
- setattr(self, attr_name, option_value)
-
- def __eq__(self, other):
- return isinstance(other, LintConfig) and \
- self.rules == other.rules and \
- self.verbosity == other.verbosity and \
- self.target == other.target and \
- self.extra_path == other.extra_path and \
- self.contrib == other.contrib and \
- self.ignore_merge_commits == other.ignore_merge_commits and \
- self.ignore_fixup_commits == other.ignore_fixup_commits and \
- self.ignore_squash_commits == other.ignore_squash_commits and \
- self.ignore_revert_commits == other.ignore_revert_commits and \
- self.ignore_stdin == other.ignore_stdin and \
- self.staged == other.staged and \
- self.debug == other.debug and \
- self.ignore == other.ignore and \
- self._config_path == other._config_path # noqa
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
- def __str__(self):
- # config-path is not a user exposed variable, so don't print it under the general section
- return_str = u"config-path: {0}\n".format(self._config_path)
- return_str += u"[GENERAL]\n"
- return_str += u"extra-path: {0}\n".format(self.extra_path)
- return_str += u"contrib: {0}\n".format(self.contrib)
- return_str += u"ignore: {0}\n".format(",".join(self.ignore))
- return_str += u"ignore-merge-commits: {0}\n".format(self.ignore_merge_commits)
- return_str += u"ignore-fixup-commits: {0}\n".format(self.ignore_fixup_commits)
- return_str += u"ignore-squash-commits: {0}\n".format(self.ignore_squash_commits)
- return_str += u"ignore-revert-commits: {0}\n".format(self.ignore_revert_commits)
- return_str += u"ignore-stdin: {0}\n".format(self.ignore_stdin)
- return_str += u"staged: {0}\n".format(self.staged)
- return_str += u"verbosity: {0}\n".format(self.verbosity)
- return_str += u"debug: {0}\n".format(self.debug)
- return_str += u"target: {0}\n".format(self.target)
- return_str += u"[RULES]\n{0}".format(self.rules)
- return return_str
-
-
-class RuleCollection(object):
- """ Class representing an ordered list of rules. Methods are provided to easily retrieve, add or delete rules. """
-
- def __init__(self, rule_classes=None, rule_attrs=None):
- # Use an ordered dict so that the order in which rules are applied is always the same
- self._rules = OrderedDict()
- if rule_classes:
- self.add_rules(rule_classes, rule_attrs)
-
- def find_rule(self, rule_id_or_name):
- # try finding rule by id
- rule_id_or_name = ustr(rule_id_or_name) # convert to unicode first
- rule = self._rules.get(rule_id_or_name)
- # if not found, try finding rule by name
- if not rule:
- rule = next((rule for rule in self._rules.values() if rule.name == rule_id_or_name), None)
- return rule
-
- def add_rule(self, rule_class, rule_id, rule_attrs=None):
- """ Instantiates and adds a rule to RuleCollection.
- Note: There can be multiple instantiations of the same rule_class in the RuleCollection, as long as the
- rule_id is unique.
- :param rule_class python class representing the rule
- :param rule_id unique identifier for the rule. If not unique, it will
- overwrite the existing rule with that id
- :param rule_attrs dictionary of attributes to set on the instantiated rule obj
- """
- rule_obj = rule_class()
- rule_obj.id = rule_id
- if rule_attrs:
- for key, val in rule_attrs.items():
- setattr(rule_obj, key, val)
- self._rules[rule_obj.id] = rule_obj
-
- def add_rules(self, rule_classes, rule_attrs=None):
- """ Convenience method to add multiple rules at once based on a list of rule classes. """
- for rule_class in rule_classes:
- self.add_rule(rule_class, rule_class.id, rule_attrs)
-
- def delete_rules_by_attr(self, attr_name, attr_val):
- """ Deletes all rules from the collection that match a given attribute name and value """
- # Create a new list based on _rules.values() because in python 3, values() is a ValuesView as opposed to a list
- # This means you can't modify the ValueView while iterating over it.
- for rule in [r for r in self._rules.values()]:
- if hasattr(rule, attr_name) and (getattr(rule, attr_name) == attr_val):
- del self._rules[rule.id]
-
- def __iter__(self):
- for rule in self._rules.values():
- yield rule
-
- def __eq__(self, other):
- return isinstance(other, RuleCollection) and self._rules == other._rules
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
- def __len__(self):
- return len(self._rules)
-
- def __str__(self):
- return_str = ""
- for rule in self._rules.values():
- return_str += u" {0}: {1}\n".format(rule.id, rule.name)
- for option_name, option_value in sorted(rule.options.items()):
- if isinstance(option_value.value, list):
- option_val_repr = ",".join(option_value.value)
- else:
- option_val_repr = option_value.value
- return_str += u" {0}={1}\n".format(option_name, option_val_repr)
- return return_str
-
-
-class LintConfigBuilder(object):
- """ Factory class that can build gitlint config.
- This is primarily useful to deal with complex configuration scenarios where configuration can be set and overridden
- from various sources (typically according to certain precedence rules) before the actual config should be
- normalized, validated and build. Example usage can be found in gitlint.cli.
- """
-
- def __init__(self):
- self._config_blueprint = {}
- self._config_path = None
-
- def set_option(self, section, option_name, option_value):
- if section not in self._config_blueprint:
- self._config_blueprint[section] = {}
- self._config_blueprint[section][option_name] = option_value
-
- def set_config_from_commit(self, commit):
- """ Given a git commit, applies config specified in the commit message.
- Supported:
- - gitlint-ignore: all
- """
- for line in commit.message.body:
- pattern = re.compile(r"^gitlint-ignore:\s*(.*)")
- matches = pattern.match(line)
- if matches and len(matches.groups()) == 1:
- self.set_option('general', 'ignore', matches.group(1))
-
- def set_config_from_string_list(self, config_options):
- """ Given a list of config options of the form "<rule>.<option>=<value>", parses out the correct rule and option
- and sets the value accordingly in this factory object. """
- for config_option in config_options:
- try:
- config_name, option_value = config_option.split("=", 1)
- if not option_value:
- raise ValueError()
- rule_name, option_name = config_name.split(".", 1)
- self.set_option(rule_name, option_name, option_value)
- except ValueError: # raised if the config string is invalid
- raise LintConfigError(
- u"'{0}' is an invalid configuration option. Use '<rule>.<option>=<value>'".format(config_option))
-
- def set_from_config_file(self, filename):
- """ Loads lint config from a ini-style config file """
- if not os.path.exists(filename):
- raise LintConfigError(u"Invalid file path: {0}".format(filename))
- self._config_path = os.path.realpath(filename)
- try:
- parser = ConfigParser()
-
- with io.open(filename, encoding=DEFAULT_ENCODING) as config_file:
- # readfp() is deprecated in python 3.2+, but compatible with 2.7
- parser.readfp(config_file, filename) # pylint: disable=deprecated-method
-
- for section_name in parser.sections():
- for option_name, option_value in parser.items(section_name):
- self.set_option(section_name, option_name, ustr(option_value))
-
- except ConfigParserError as e:
- raise LintConfigError(ustr(e))
-
- def build(self, config=None):
- """ Build a real LintConfig object by normalizing and validating the options that were previously set on this
- factory. """
-
- # If we are passed a config object, then rebuild that object instead of building a new lintconfig object from
- # scratch
- if not config:
- config = LintConfig()
-
- config._config_path = self._config_path
-
- # Set general options first as this might change the behavior or validity of the other options
- general_section = self._config_blueprint.get('general')
- if general_section:
- for option_name, option_value in general_section.items():
- config.set_general_option(option_name, option_value)
-
- for section_name, section_dict in self._config_blueprint.items():
- for option_name, option_value in section_dict.items():
- # Skip over the general section, as we've already done that above
- if section_name != "general":
- config.set_rule_option(section_name, option_name, option_value)
-
- return config
-
- def clone(self):
- """ Creates an exact copy of a LintConfigBuilder. """
- builder = LintConfigBuilder()
- builder._config_blueprint = copy.deepcopy(self._config_blueprint)
- builder._config_path = self._config_path
- return builder
-
-
-GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/gitlint")
-
-
-class LintConfigGenerator(object):
- @staticmethod
- def generate_config(dest):
- """ Generates a gitlint config file at the given destination location.
- Expects that the given ```dest``` points to a valid destination. """
- shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest)
diff --git a/gitlint/contrib/rules/conventional_commit.py b/gitlint/contrib/rules/conventional_commit.py
deleted file mode 100644
index 3bbbd0f..0000000
--- a/gitlint/contrib/rules/conventional_commit.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import re
-
-from gitlint.options import ListOption
-from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
-from gitlint.utils import ustr
-
-RULE_REGEX = re.compile(r"[^(]+?(\([^)]+?\))?: .+")
-
-
-class ConventionalCommit(LineRule):
- """ This rule enforces the spec at https://www.conventionalcommits.org/. """
-
- name = "contrib-title-conventional-commits"
- id = "CT1"
- target = CommitMessageTitle
-
- options_spec = [
- ListOption(
- "types",
- ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"],
- "Comma separated list of allowed commit types.",
- )
- ]
-
- def validate(self, line, _commit):
- violations = []
-
- for commit_type in self.options["types"].value:
- if line.startswith(ustr(commit_type)):
- break
- else:
- msg = u"Title does not start with one of {0}".format(', '.join(self.options['types'].value))
- violations.append(RuleViolation(self.id, msg, line))
-
- if not RULE_REGEX.match(line):
- msg = u"Title does not follow ConventionalCommits.org format 'type(optional-scope): description'"
- violations.append(RuleViolation(self.id, msg, line))
-
- return violations
diff --git a/gitlint/display.py b/gitlint/display.py
deleted file mode 100644
index dd17ac0..0000000
--- a/gitlint/display.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import codecs
-import locale
-from sys import stdout, stderr, version_info
-
-# For some reason, python 2.x sometimes messes up with printing unicode chars to stdout/stderr
-# This is mostly when there is a mismatch between the terminal encoding and the python encoding.
-# This use-case is primarily triggered when piping input between commands, in particular our integration tests
-# tend to trip over this.
-if version_info[0] == 2:
- stdout = codecs.getwriter(locale.getpreferredencoding())(stdout) # pylint: disable=invalid-name
- stderr = codecs.getwriter(locale.getpreferredencoding())(stderr) # pylint: disable=invalid-name
-
-
-class Display(object):
- """ Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity """
-
- def __init__(self, lint_config):
- self.config = lint_config
-
- def _output(self, message, verbosity, exact, stream):
- """ Output a message if the config's verbosity is >= to the given verbosity. If exact == True, the message
- will only be outputted if the given verbosity exactly matches the config's verbosity. """
- if exact:
- if self.config.verbosity == verbosity:
- stream.write(message + "\n")
- else:
- if self.config.verbosity >= verbosity:
- stream.write(message + "\n")
-
- def v(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 1, exact, stdout)
-
- def vv(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 2, exact, stdout)
-
- def vvv(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 3, exact, stdout)
-
- def e(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 1, exact, stderr)
-
- def ee(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 2, exact, stderr)
-
- def eee(self, message, exact=False): # pylint: disable=invalid-name
- self._output(message, 3, exact, stderr)
diff --git a/gitlint/files/commit-msg b/gitlint/files/commit-msg
deleted file mode 100644
index e468290..0000000
--- a/gitlint/files/commit-msg
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/bin/sh
-### gitlint commit-msg hook start ###
-
-# Determine whether we have a tty available by trying to access it.
-# This allows us to deal with UI based gitclient's like Atlassian SourceTree.
-# NOTE: "exec < /dev/tty" sets stdin to the keyboard
-stdin_available=1
-(exec < /dev/tty) 2> /dev/null || stdin_available=0
-
-if [ $stdin_available -eq 1 ]; then
- # Set bash color codes in case we have a tty
- RED="\033[31m"
- YELLOW="\033[33m"
- GREEN="\033[32m"
- END_COLOR="\033[0m"
-
- # Now that we know we have a functional tty, set stdin to it so we can ask the user questions :-)
- exec < /dev/tty
-else
- # Unset bash colors if we don't have a tty
- RED=""
- YELLOW=""
- GREEN=""
- END_COLOR=""
-fi
-
-run_gitlint(){
- echo "gitlint: checking commit message..."
- python -m gitlint.cli --staged --msg-filename "$1"
- gitlint_exit_code=$?
-}
-
-# Prompts a given yes/no question.
-# Returns 0 if user answers yes, 1 if no
-# Reprompts if different answer
-ask_yes_no_edit(){
- ask_yes_no_edit_result="no"
- # If we don't have a stdin available, then just return "No".
- if [ $stdin_available -eq 0 ]; then
- ask_yes_no_edit_result="no"
- return;
- fi
- # Otherwise, ask the question until the user answers yes or no
- question="$1"
- while true; do
- read -p "$question" yn
- case $yn in
- [Yy]* ) ask_yes_no_edit_result="yes"; return;;
- [Nn]* ) ask_yes_no_edit_result="no"; return;;
- [Ee]* ) ask_yes_no_edit_result="edit"; return;;
- esac
- done
-}
-
-run_gitlint "$1"
-
-while [ $gitlint_exit_code -gt 0 ]; do
- echo "-----------------------------------------------"
- echo "gitlint: ${RED}Your commit message contains the above violations.${END_COLOR}"
- ask_yes_no_edit "Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] "
- if [ $ask_yes_no_edit_result = "yes" ]; then
- exit 0
- elif [ $ask_yes_no_edit_result = "edit" ]; then
- EDITOR=${EDITOR:-vim}
- $EDITOR "$1"
- run_gitlint "$1"
- else
- echo "Commit aborted."
- echo "Your commit message: "
- echo "-----------------------------------------------"
- cat "$1"
- echo "-----------------------------------------------"
-
- exit $gitlint_exit_code
- fi
-done
-
-echo "gitlint: ${GREEN}OK${END_COLOR} (no violations in commit message)"
-exit 0
-
-### gitlint commit-msg hook end ###
diff --git a/gitlint/git.py b/gitlint/git.py
deleted file mode 100644
index ca7ad92..0000000
--- a/gitlint/git.py
+++ /dev/null
@@ -1,395 +0,0 @@
-import os
-import arrow
-
-from gitlint import shell as sh
-# import exceptions separately, this makes it a little easier to mock them out in the unit tests
-from gitlint.shell import CommandNotFound, ErrorReturnCode
-
-from gitlint.cache import PropertyCache, cache
-from gitlint.utils import ustr, sstr
-
-# For now, the git date format we use is fixed, but technically this format is determined by `git config log.date`
-# We should fix this at some point :-)
-GIT_TIMEFORMAT = "YYYY-MM-DD HH:mm:ss Z"
-
-
-class GitContextError(Exception):
- """ Exception indicating there is an issue with the git context """
- pass
-
-
-class GitNotInstalledError(GitContextError):
- def __init__(self):
- super(GitNotInstalledError, self).__init__(
- u"'git' command not found. You need to install git to use gitlint on a local repository. " +
- u"See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git.")
-
-
-def _git(*command_parts, **kwargs):
- """ Convenience function for running git commands. Automatically deals with exceptions and unicode. """
- git_kwargs = {'_tty_out': False}
- git_kwargs.update(kwargs)
- try:
- result = sh.git(*command_parts, **git_kwargs) # pylint: disable=unexpected-keyword-arg
- # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
- # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
- # a non-zero exit code -> just return the entire result
- if hasattr(result, 'exit_code') and result.exit_code > 0:
- return result
- return ustr(result)
- except CommandNotFound:
- raise GitNotInstalledError()
- except ErrorReturnCode as e: # Something went wrong while executing the git command
- error_msg = e.stderr.strip()
- error_msg_lower = error_msg.lower()
- if '_cwd' in git_kwargs and b"not a git repository" in error_msg_lower:
- error_msg = u"{0} is not a git repository.".format(git_kwargs['_cwd'])
- elif (b"does not have any commits yet" in error_msg_lower or
- b"ambiguous argument 'head': unknown revision" in error_msg_lower):
- raise GitContextError(u"Current branch has no commits. Gitlint requires at least one commit to function.")
- else:
- error_msg = u"An error occurred while executing '{0}': {1}".format(e.full_cmd, error_msg)
- raise GitContextError(error_msg)
-
-
-def git_version():
- """ Determine the git version installed on this host by calling git --version"""
- return _git("--version").replace(u"\n", u"")
-
-
-def git_commentchar(repository_path=None):
- """ Shortcut for retrieving comment char from git config """
- commentchar = _git("config", "--get", "core.commentchar", _cwd=repository_path, _ok_code=[0, 1])
- # git will return an exit code of 1 if it can't find a config value, in this case we fall-back to # as commentchar
- if hasattr(commentchar, 'exit_code') and commentchar.exit_code == 1: # pylint: disable=no-member
- commentchar = "#"
- return ustr(commentchar).replace(u"\n", u"")
-
-
-def git_hooks_dir(repository_path):
- """ Determine hooks directory for a given target dir """
- hooks_dir = _git("rev-parse", "--git-path", "hooks", _cwd=repository_path)
- hooks_dir = ustr(hooks_dir).replace(u"\n", u"")
- return os.path.realpath(os.path.join(repository_path, hooks_dir))
-
-
-class GitCommitMessage(object):
- """ Class representing a git commit message. A commit message consists of the following:
- - context: The `GitContext` this commit message is part of
- - original: The actual commit message as returned by `git log`
- - full: original, but stripped of any comments
- - title: the first line of full
- - body: all lines following the title
- """
- def __init__(self, context, original=None, full=None, title=None, body=None):
- self.context = context
- self.original = original
- self.full = full
- self.title = title
- self.body = body
-
- @staticmethod
- def from_full_message(context, commit_msg_str):
- """ Parses a full git commit message by parsing a given string into the different parts of a commit message """
- all_lines = commit_msg_str.splitlines()
- cutline = u"{0} ------------------------ >8 ------------------------".format(context.commentchar)
- try:
- cutline_index = all_lines.index(cutline)
- except ValueError:
- cutline_index = None
- lines = [ustr(line) for line in all_lines[:cutline_index] if not line.startswith(context.commentchar)]
- full = "\n".join(lines)
- title = lines[0] if lines else ""
- body = lines[1:] if len(lines) > 1 else []
- return GitCommitMessage(context=context, original=commit_msg_str, full=full, title=title, body=body)
-
- def __unicode__(self):
- return self.full # pragma: no cover
-
- def __str__(self):
- return sstr(self.__unicode__()) # pragma: no cover
-
- def __repr__(self):
- return self.__str__() # pragma: no cover
-
- def __eq__(self, other):
- return (isinstance(other, GitCommitMessage) and self.original == other.original
- and self.full == other.full and self.title == other.title and self.body == other.body) # noqa
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
-
-class GitCommit(object):
- """ Class representing a git commit.
- A commit consists of: context, message, author name, author email, date, list of parent commit shas,
- list of changed files, list of branch names.
- In the context of gitlint, only the git context and commit message are required.
- """
-
- def __init__(self, context, message, sha=None, date=None, author_name=None, # pylint: disable=too-many-arguments
- author_email=None, parents=None, changed_files=None, branches=None):
- self.context = context
- self.message = message
- self.sha = sha
- self.date = date
- self.author_name = author_name
- self.author_email = author_email
- self.parents = parents or [] # parent commit hashes
- self.changed_files = changed_files or []
- self.branches = branches or []
-
- @property
- def is_merge_commit(self):
- return self.message.title.startswith(u"Merge")
-
- @property
- def is_fixup_commit(self):
- return self.message.title.startswith(u"fixup!")
-
- @property
- def is_squash_commit(self):
- return self.message.title.startswith(u"squash!")
-
- @property
- def is_revert_commit(self):
- return self.message.title.startswith(u"Revert")
-
- def __unicode__(self):
- format_str = (u"--- Commit Message ----\n%s\n"
- u"--- Meta info ---------\n"
- u"Author: %s <%s>\nDate: %s\n"
- u"is-merge-commit: %s\nis-fixup-commit: %s\n"
- u"is-squash-commit: %s\nis-revert-commit: %s\n"
- u"Branches: %s\n"
- u"Changed Files: %s\n"
- u"-----------------------") # pragma: no cover
- date_str = arrow.get(self.date).format(GIT_TIMEFORMAT) if self.date else None
- return format_str % (ustr(self.message), self.author_name, self.author_email, date_str,
- self.is_merge_commit, self.is_fixup_commit, self.is_squash_commit,
- self.is_revert_commit, sstr(self.branches), sstr(self.changed_files)) # pragma: no cover
-
- def __str__(self):
- return sstr(self.__unicode__()) # pragma: no cover
-
- def __repr__(self):
- return self.__str__() # pragma: no cover
-
- def __eq__(self, other):
- # skip checking the context as context refers back to this obj, this will trigger a cyclic dependency
- return (isinstance(other, GitCommit) and self.message == other.message
- and self.sha == other.sha and self.author_name == other.author_name
- and self.author_email == other.author_email
- and self.date == other.date and self.parents == other.parents
- and self.is_merge_commit == other.is_merge_commit and self.is_fixup_commit == other.is_fixup_commit
- and self.is_squash_commit == other.is_squash_commit and self.is_revert_commit == other.is_revert_commit
- and self.changed_files == other.changed_files and self.branches == other.branches) # noqa
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
-
-class LocalGitCommit(GitCommit, PropertyCache):
- """ Class representing a git commit that exists in the local git repository.
- This class uses lazy loading: it defers reading information from the local git repository until the associated
- property is accessed for the first time. Properties are then cached for subsequent access.
-
- This approach ensures that we don't do 'expensive' git calls when certain properties are not actually used.
- In addition, reading the required info when it's needed rather than up front avoids adding delay during gitlint
- startup time and reduces gitlint's memory footprint.
- """
- def __init__(self, context, sha): # pylint: disable=super-init-not-called
- PropertyCache.__init__(self)
- self.context = context
- self.sha = sha
-
- def _log(self):
- """ Does a call to `git log` to determine a bunch of information about the commit. """
- long_format = "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B"
- raw_commit = _git("log", self.sha, "-1", long_format, _cwd=self.context.repository_path).split("\n")
-
- (name, email, date, parents), commit_msg = raw_commit[0].split('\x00'), "\n".join(raw_commit[1:])
-
- commit_parents = parents.split(" ")
- commit_is_merge_commit = len(commit_parents) > 1
-
- # "YYYY-MM-DD HH:mm:ss Z" -> ISO 8601-like format
- # Use arrow for datetime parsing, because apparently python is quirky around ISO-8601 dates:
- # http://stackoverflow.com/a/30696682/381010
- commit_date = arrow.get(ustr(date), GIT_TIMEFORMAT).datetime
-
- # Create Git commit object with the retrieved info
- commit_msg_obj = GitCommitMessage.from_full_message(self.context, commit_msg)
-
- self._cache.update({'message': commit_msg_obj, 'author_name': name, 'author_email': email, 'date': commit_date,
- 'parents': commit_parents, 'is_merge_commit': commit_is_merge_commit})
-
- @property
- def message(self):
- return self._try_cache("message", self._log)
-
- @property
- def author_name(self):
- return self._try_cache("author_name", self._log)
-
- @property
- def author_email(self):
- return self._try_cache("author_email", self._log)
-
- @property
- def date(self):
- return self._try_cache("date", self._log)
-
- @property
- def parents(self):
- return self._try_cache("parents", self._log)
-
- @property
- def branches(self):
- def cache_branches():
- # We have to parse 'git branch --contains <sha>' instead of 'git for-each-ref' to be compatible with
- # git versions < 2.7.0
- # https://stackoverflow.com/questions/45173979/can-i-force-git-branch-contains-tag-to-not-print-the-asterisk
- branches = _git("branch", "--contains", self.sha, _cwd=self.context.repository_path).split("\n")
-
- # This means that we need to remove any leading * that indicates the current branch. Note that we can
- # safely do this since git branches cannot contain '*' anywhere, so if we find an '*' we know it's output
- # from the git CLI and not part of the branch name. See https://git-scm.com/docs/git-check-ref-format
- # We also drop the last empty line from the output.
- self._cache['branches'] = [ustr(branch.replace("*", "").strip()) for branch in branches[:-1]]
-
- return self._try_cache("branches", cache_branches)
-
- @property
- def is_merge_commit(self):
- return self._try_cache("is_merge_commit", self._log)
-
- @property
- def changed_files(self):
- def cache_changed_files():
- self._cache['changed_files'] = _git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root",
- self.sha, _cwd=self.context.repository_path).split()
-
- return self._try_cache("changed_files", cache_changed_files)
-
-
-class StagedLocalGitCommit(GitCommit, PropertyCache):
- """ Class representing a git commit that has been staged, but not committed.
-
- Other than the commit message itself (and changed files), a lot of information is actually not known at staging
- time, since the commit hasn't happened yet. However, we can make educated guesses based on existing repository
- information.
- """
-
- def __init__(self, context, commit_message): # pylint: disable=super-init-not-called
- PropertyCache.__init__(self)
- self.context = context
- self.message = commit_message
- self.sha = None
- self.parents = [] # Not really possible to determine before a commit
-
- @property
- @cache
- def author_name(self):
- return ustr(_git("config", "--get", "user.name", _cwd=self.context.repository_path)).strip()
-
- @property
- @cache
- def author_email(self):
- return ustr(_git("config", "--get", "user.email", _cwd=self.context.repository_path)).strip()
-
- @property
- @cache
- def date(self):
- # We don't know the actual commit date yet, but we make a pragmatic trade-off here by providing the current date
- # We get current date from arrow, reformat in git date format, then re-interpret it as a date.
- # This ensure we capture the same precision and timezone information that git does.
- return arrow.get(arrow.now().format(GIT_TIMEFORMAT), GIT_TIMEFORMAT).datetime
-
- @property
- @cache
- def branches(self):
- # We don't know the branch this commit will be part of yet, but we're pragmatic here and just return the
- # current branch, as for all intents and purposes, this will be what the user is looking for.
- return [self.context.current_branch]
-
- @property
- def changed_files(self):
- return _git("diff", "--staged", "--name-only", "-r", _cwd=self.context.repository_path).split()
-
-
-class GitContext(PropertyCache):
- """ Class representing the git context in which gitlint is operating: a data object storing information about
- the git repository that gitlint is linting.
- """
-
- def __init__(self, repository_path=None):
- PropertyCache.__init__(self)
- self.commits = []
- self.repository_path = repository_path
-
- @property
- @cache
- def commentchar(self):
- return git_commentchar(self.repository_path)
-
- @property
- @cache
- def current_branch(self):
- current_branch = ustr(_git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path)).strip()
- return current_branch
-
- @staticmethod
- def from_commit_msg(commit_msg_str):
- """ Determines git context based on a commit message.
- :param commit_msg_str: Full git commit message.
- """
- context = GitContext()
- commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
- commit = GitCommit(context, commit_msg_obj)
- context.commits.append(commit)
- return context
-
- @staticmethod
- def from_staged_commit(commit_msg_str, repository_path):
- """ Determines git context based on a commit message that is a staged commit for a local git repository.
- :param commit_msg_str: Full git commit message.
- :param repository_path: Path to the git repository to retrieve the context from
- """
- context = GitContext(repository_path=repository_path)
- commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
- commit = StagedLocalGitCommit(context, commit_msg_obj)
- context.commits.append(commit)
- return context
-
- @staticmethod
- def from_local_repository(repository_path, refspec=None):
- """ Retrieves the git context from a local git repository.
- :param repository_path: Path to the git repository to retrieve the context from
- :param refspec: The commit(s) to retrieve
- """
-
- context = GitContext(repository_path=repository_path)
-
- # If no refspec is defined, fallback to the last commit on the current branch
- if refspec is None:
- # We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with
- # repos that only have a single commit - HEAD^... doesn't work there), but then we still get into
- # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`.
- sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace(u"\n", u"")]
- else:
- sha_list = _git("rev-list", refspec, _cwd=repository_path).split()
-
- for sha in sha_list:
- commit = LocalGitCommit(context, sha)
- context.commits.append(commit)
-
- return context
-
- def __eq__(self, other):
- return (isinstance(other, GitContext) and self.commits == other.commits
- and self.repository_path == other.repository_path
- and self.commentchar == other.commentchar and self.current_branch == other.current_branch) # noqa
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
diff --git a/gitlint/options.py b/gitlint/options.py
deleted file mode 100644
index a1ae59c..0000000
--- a/gitlint/options.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from abc import abstractmethod
-import os
-
-from gitlint.utils import ustr, sstr
-
-
-class RuleOptionError(Exception):
- pass
-
-
-class RuleOption(object):
- """ Base class representing a configurable part (i.e. option) of a rule (e.g. the max-length of the title-max-line
- rule).
- This class should not be used directly. Instead, use on the derived classes like StrOption, IntOption to set
- options of a particular type like int, str, etc.
- """
-
- def __init__(self, name, value, description):
- self.name = ustr(name)
- self.description = ustr(description)
- self.value = None
- self.set(value)
-
- @abstractmethod
- def set(self, value):
- """ Validates and sets the option's value """
- pass # pragma: no cover
-
- def __str__(self):
- return sstr(self) # pragma: no cover
-
- def __unicode__(self):
- return u"({0}: {1} ({2}))".format(self.name, self.value, self.description) # pragma: no cover
-
- def __repr__(self):
- return self.__str__() # pragma: no cover
-
- def __eq__(self, other):
- return self.name == other.name and self.description == other.description and self.value == other.value
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
-
-class StrOption(RuleOption):
- def set(self, value):
- self.value = ustr(value)
-
-
-class IntOption(RuleOption):
- def __init__(self, name, value, description, allow_negative=False):
- self.allow_negative = allow_negative
- super(IntOption, self).__init__(name, value, description)
-
- def _raise_exception(self, value):
- if self.allow_negative:
- error_msg = u"Option '{0}' must be an integer (current value: '{1}')".format(self.name, value)
- else:
- error_msg = u"Option '{0}' must be a positive integer (current value: '{1}')".format(self.name, value)
- raise RuleOptionError(error_msg)
-
- def set(self, value):
- try:
- self.value = int(value)
- except ValueError:
- self._raise_exception(value)
-
- if not self.allow_negative and self.value < 0:
- self._raise_exception(value)
-
-
-class BoolOption(RuleOption):
- def set(self, value):
- value = ustr(value).strip().lower()
- if value not in ['true', 'false']:
- raise RuleOptionError(u"Option '{0}' must be either 'true' or 'false'".format(self.name))
- self.value = value == 'true'
-
-
-class ListOption(RuleOption):
- """ Option that is either a given list or a comma-separated string that can be splitted into a list when being set.
- """
-
- def set(self, value):
- if isinstance(value, list):
- the_list = value
- else:
- the_list = ustr(value).split(",")
-
- self.value = [ustr(item.strip()) for item in the_list if item.strip() != ""]
-
-
-class PathOption(RuleOption):
- """ Option that accepts either a directory or both a directory and a file. """
-
- def __init__(self, name, value, description, type=u"dir"):
- self.type = type
- super(PathOption, self).__init__(name, value, description)
-
- def set(self, value):
- value = ustr(value)
-
- error_msg = u""
-
- if self.type == 'dir':
- if not os.path.isdir(value):
- error_msg = u"Option {0} must be an existing directory (current value: '{1}')".format(self.name, value)
- elif self.type == 'file':
- if not os.path.isfile(value):
- error_msg = u"Option {0} must be an existing file (current value: '{1}')".format(self.name, value)
- elif self.type == 'both':
- if not os.path.isdir(value) and not os.path.isfile(value):
- error_msg = (u"Option {0} must be either an existing directory or file "
- u"(current value: '{1}')").format(self.name, value)
- else:
- error_msg = u"Option {0} type must be one of: 'file', 'dir', 'both' (current: '{1}')".format(self.name,
- self.type)
-
- if error_msg:
- raise RuleOptionError(error_msg)
-
- self.value = os.path.realpath(value)
diff --git a/gitlint/rule_finder.py b/gitlint/rule_finder.py
deleted file mode 100644
index 2b8b293..0000000
--- a/gitlint/rule_finder.py
+++ /dev/null
@@ -1,137 +0,0 @@
-import fnmatch
-import inspect
-import os
-import sys
-import importlib
-
-from gitlint import rules, options
-from gitlint.utils import ustr
-
-
-def find_rule_classes(extra_path):
- """
- Searches a given directory or python module for rule classes. This is done by
- adding the directory path to the python path, importing the modules and then finding
- any Rule class in those modules.
-
- :param extra_path: absolute directory or file path to search for rule classes
- :return: The list of rule classes that are found in the given directory or module
- """
-
- files = []
- modules = []
-
- if os.path.isfile(extra_path):
- files = [os.path.basename(extra_path)]
- directory = os.path.dirname(extra_path)
- elif os.path.isdir(extra_path):
- files = os.listdir(extra_path)
- directory = extra_path
- else:
- raise rules.UserRuleError(u"Invalid extra-path: {0}".format(extra_path))
-
- # Filter out files that are not python modules
- for filename in files:
- if fnmatch.fnmatch(filename, '*.py'):
- # We have to treat __init__ files a bit special: add the parent dir instead of the filename, and also
- # add their parent dir to the sys.path (this fixes import issues with pypy2).
- if filename == "__init__.py":
- modules.append(os.path.basename(directory))
- sys.path.append(os.path.dirname(directory))
- else:
- modules.append(os.path.splitext(filename)[0])
-
- # No need to continue if there are no modules specified
- if not modules:
- return []
-
- # Append the extra rules path to python path so that we can import them
- sys.path.append(directory)
-
- # Find all the rule classes in the found python files
- rule_classes = []
- for module in modules:
- # Import the module
- try:
- importlib.import_module(module)
-
- except Exception as e:
- raise rules.UserRuleError(u"Error while importing extra-path module '{0}': {1}".format(module, ustr(e)))
-
- # Find all rule classes in the module. We do this my inspecting all members of the module and checking
- # 1) is it a class, if not, skip
- # 2) is the parent path the current module. If not, we are dealing with an imported class, skip
- # 3) is it a subclass of rule
- rule_classes.extend([clazz for _, clazz in inspect.getmembers(sys.modules[module])
- if
- inspect.isclass(clazz) and # check isclass to ensure clazz.__module__ exists
- clazz.__module__ == module and # ignore imported classes
- (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule))])
-
- # validate that the rule classes are valid user-defined rules
- for rule_class in rule_classes:
- assert_valid_rule_class(rule_class)
-
- return rule_classes
-
-
-def assert_valid_rule_class(clazz, rule_type="User-defined"):
- """
- Asserts that a given rule clazz is valid by checking a number of its properties:
- - Rules must extend from LineRule or CommitRule
- - Rule classes must have id and name string attributes.
- The options_spec is optional, but if set, it must be a list of gitlint Options.
- - Rule classes must have a validate method. In case of a CommitRule, validate must take a single commit parameter.
- In case of LineRule, validate must take line and commit as first and second parameters.
- - LineRule classes must have a target class attributes that is set to either
- CommitMessageTitle or CommitMessageBody.
- - Rule id's cannot start with R, T, B or M as these rule ids are reserved for gitlint itself.
- """
-
- # Rules must extend from LineRule or CommitRule
- if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)):
- msg = u"{0} rule class '{1}' must extend from {2}.{3} or {2}.{4}"
- raise rules.UserRuleError(msg.format(rule_type, clazz.__name__, rules.CommitRule.__module__,
- rules.LineRule.__name__, rules.CommitRule.__name__))
-
- # Rules must have an id attribute
- if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id:
- msg = u"{0} rule class '{1}' must have an 'id' attribute"
- raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
-
- # Rule id's cannot start with gitlint reserved letters
- if clazz.id[0].upper() in ['R', 'T', 'B', 'M']:
- msg = u"The id '{1}' of '{0}' is invalid. Gitlint reserves ids starting with R,T,B,M"
- raise rules.UserRuleError(msg.format(clazz.__name__, clazz.id[0]))
-
- # Rules must have a name attribute
- if not hasattr(clazz, 'name') or clazz.name is None or not clazz.name:
- msg = u"{0} rule class '{1}' must have a 'name' attribute"
- raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
-
- # if set, options_spec must be a list of RuleOption
- if not isinstance(clazz.options_spec, list):
- msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
- raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
- options.RuleOption.__module__, options.RuleOption.__name__))
-
- # check that all items in options_spec are actual gitlint options
- for option in clazz.options_spec:
- if not isinstance(option, options.RuleOption):
- msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
- raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
- options.RuleOption.__module__, options.RuleOption.__name__))
-
- # Rules must have a validate method. We use isroutine() as it's both python 2 and 3 compatible.
- # For more info see http://stackoverflow.com/a/17019998/381010
- if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate):
- msg = u"{0} rule class '{1}' must have a 'validate' method"
- raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
-
- # LineRules must have a valid target: rules.CommitMessageTitle or rules.CommitMessageBody
- if issubclass(clazz, rules.LineRule):
- if clazz.target not in [rules.CommitMessageTitle, rules.CommitMessageBody]:
- msg = u"The target attribute of the {0} LineRule class '{1}' must be either {2}.{3} or {2}.{4}"
- msg = msg.format(rule_type.lower(), clazz.__name__, rules.CommitMessageTitle.__module__,
- rules.CommitMessageTitle.__name__, rules.CommitMessageBody.__name__)
- raise rules.UserRuleError(msg)
diff --git a/gitlint/rules.py b/gitlint/rules.py
deleted file mode 100644
index ad83204..0000000
--- a/gitlint/rules.py
+++ /dev/null
@@ -1,363 +0,0 @@
-# pylint: disable=inconsistent-return-statements
-import copy
-import logging
-import re
-
-from gitlint.options import IntOption, BoolOption, StrOption, ListOption
-from gitlint.utils import sstr
-
-LOG = logging.getLogger(__name__)
-logging.basicConfig()
-
-
-class Rule(object):
- """ Class representing gitlint rules. """
- options_spec = []
- id = None
- name = None
- target = None
-
- def __init__(self, opts=None):
- if not opts:
- opts = {}
- self.options = {}
- for op_spec in self.options_spec:
- self.options[op_spec.name] = copy.deepcopy(op_spec)
- actual_option = opts.get(op_spec.name)
- if actual_option is not None:
- self.options[op_spec.name].set(actual_option)
-
- def __eq__(self, other):
- return self.id == other.id and self.name == other.name and \
- self.options == other.options and self.target == other.target # noqa
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
- def __str__(self):
- return sstr(self) # pragma: no cover
-
- def __unicode__(self):
- return u"{0} {1}".format(self.id, self.name) # pragma: no cover
-
- def __repr__(self):
- return self.__str__() # pragma: no cover
-
-
-class ConfigurationRule(Rule):
- """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """
- pass
-
-
-class CommitRule(Rule):
- """ Class representing rules that act on an entire commit at once """
- pass
-
-
-class LineRule(Rule):
- """ Class representing rules that act on a line by line basis """
- pass
-
-
-class LineRuleTarget(object):
- """ Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied
- (e.g. commit message title, commit message body).
- Each LineRule MUST have a target specified. """
- pass
-
-
-class CommitMessageTitle(LineRuleTarget):
- """ Target class used for rules that apply to a commit message title """
- pass
-
-
-class CommitMessageBody(LineRuleTarget):
- """ Target class used for rules that apply to a commit message body """
- pass
-
-
-class RuleViolation(object):
- """ Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class
- to indicate how and where the rule was broken. """
-
- def __init__(self, rule_id, message, content=None, line_nr=None):
- self.rule_id = rule_id
- self.line_nr = line_nr
- self.message = message
- self.content = content
-
- def __eq__(self, other):
- equal = self.rule_id == other.rule_id and self.message == other.message
- equal = equal and self.content == other.content and self.line_nr == other.line_nr
- return equal
-
- def __ne__(self, other):
- return not self.__eq__(other) # required for py2
-
- def __str__(self):
- return sstr(self) # pragma: no cover
-
- def __unicode__(self):
- return u"{0}: {1} {2}: \"{3}\"".format(self.line_nr, self.rule_id, self.message,
- self.content) # pragma: no cover
-
- def __repr__(self):
- return self.__str__() # pragma: no cover
-
-
-class UserRuleError(Exception):
- """ Error used to indicate that an error occurred while trying to load a user rule """
- pass
-
-
-class MaxLineLength(LineRule):
- name = "max-line-length"
- id = "R1"
- options_spec = [IntOption('line-length', 80, "Max line length")]
- violation_message = "Line exceeds max length ({0}>{1})"
-
- def validate(self, line, _commit):
- max_length = self.options['line-length'].value
- if len(line) > max_length:
- return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
-
-
-class TrailingWhiteSpace(LineRule):
- name = "trailing-whitespace"
- id = "R2"
- violation_message = "Line has trailing whitespace"
-
- def validate(self, line, _commit):
- pattern = re.compile(r"\s$", re.UNICODE)
- if pattern.search(line):
- return [RuleViolation(self.id, self.violation_message, line)]
-
-
-class HardTab(LineRule):
- name = "hard-tab"
- id = "R3"
- violation_message = "Line contains hard tab characters (\\t)"
-
- def validate(self, line, _commit):
- if "\t" in line:
- return [RuleViolation(self.id, self.violation_message, line)]
-
-
-class LineMustNotContainWord(LineRule):
- """ Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
- a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.) """
- name = "line-must-not-contain"
- id = "R5"
- options_spec = [ListOption('words', [], "Comma separated list of words that should not be found")]
- violation_message = u"Line contains {0}"
-
- def validate(self, line, _commit):
- strings = self.options['words'].value
- violations = []
- for string in strings:
- regex = re.compile(r"\b%s\b" % string.lower(), re.IGNORECASE | re.UNICODE)
- match = regex.search(line.lower())
- if match:
- violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
- return violations if violations else None
-
-
-class LeadingWhiteSpace(LineRule):
- name = "leading-whitespace"
- id = "R6"
- violation_message = "Line has leading whitespace"
-
- def validate(self, line, _commit):
- pattern = re.compile(r"^\s", re.UNICODE)
- if pattern.search(line):
- return [RuleViolation(self.id, self.violation_message, line)]
-
-
-class TitleMaxLength(MaxLineLength):
- name = "title-max-length"
- id = "T1"
- target = CommitMessageTitle
- options_spec = [IntOption('line-length', 72, "Max line length")]
- violation_message = "Title exceeds max length ({0}>{1})"
-
-
-class TitleTrailingWhitespace(TrailingWhiteSpace):
- name = "title-trailing-whitespace"
- id = "T2"
- target = CommitMessageTitle
- violation_message = "Title has trailing whitespace"
-
-
-class TitleTrailingPunctuation(LineRule):
- name = "title-trailing-punctuation"
- id = "T3"
- target = CommitMessageTitle
-
- def validate(self, title, _commit):
- punctuation_marks = '?:!.,;'
- for punctuation_mark in punctuation_marks:
- if title.endswith(punctuation_mark):
- return [RuleViolation(self.id, u"Title has trailing punctuation ({0})".format(punctuation_mark), title)]
-
-
-class TitleHardTab(HardTab):
- name = "title-hard-tab"
- id = "T4"
- target = CommitMessageTitle
- violation_message = "Title contains hard tab characters (\\t)"
-
-
-class TitleMustNotContainWord(LineMustNotContainWord):
- name = "title-must-not-contain-word"
- id = "T5"
- target = CommitMessageTitle
- options_spec = [ListOption('words', ["WIP"], "Must not contain word")]
- violation_message = u"Title contains the word '{0}' (case-insensitive)"
-
-
-class TitleLeadingWhitespace(LeadingWhiteSpace):
- name = "title-leading-whitespace"
- id = "T6"
- target = CommitMessageTitle
- violation_message = "Title has leading whitespace"
-
-
-class TitleRegexMatches(LineRule):
- name = "title-match-regex"
- id = "T7"
- target = CommitMessageTitle
- options_spec = [StrOption('regex', ".*", "Regex the title should match")]
-
- def validate(self, title, _commit):
- regex = self.options['regex'].value
- pattern = re.compile(regex, re.UNICODE)
- if not pattern.search(title):
- violation_msg = u"Title does not match regex ({0})".format(regex)
- return [RuleViolation(self.id, violation_msg, title)]
-
-
-class BodyMaxLineLength(MaxLineLength):
- name = "body-max-line-length"
- id = "B1"
- target = CommitMessageBody
-
-
-class BodyTrailingWhitespace(TrailingWhiteSpace):
- name = "body-trailing-whitespace"
- id = "B2"
- target = CommitMessageBody
-
-
-class BodyHardTab(HardTab):
- name = "body-hard-tab"
- id = "B3"
- target = CommitMessageBody
-
-
-class BodyFirstLineEmpty(CommitRule):
- name = "body-first-line-empty"
- id = "B4"
-
- def validate(self, commit):
- if len(commit.message.body) >= 1:
- first_line = commit.message.body[0]
- if first_line != "":
- return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]
-
-
-class BodyMinLength(CommitRule):
- name = "body-min-length"
- id = "B5"
- options_spec = [IntOption('min-length', 20, "Minimum body length")]
-
- def validate(self, commit):
- min_length = self.options['min-length'].value
- body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
- actual_length = len(body_message_no_newline)
- if 0 < actual_length < min_length:
- violation_message = "Body message is too short ({0}<{1})".format(actual_length, min_length)
- return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]
-
-
-class BodyMissing(CommitRule):
- name = "body-is-missing"
- id = "B6"
- options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")]
-
- def validate(self, commit):
- # ignore merges when option tells us to, which may have no body
- if self.options['ignore-merge-commits'].value and commit.is_merge_commit:
- return
- if len(commit.message.body) < 2:
- return [RuleViolation(self.id, "Body message is missing", None, 3)]
-
-
-class BodyChangedFileMention(CommitRule):
- name = "body-changed-file-mention"
- id = "B7"
- options_spec = [ListOption('files', [], "Files that need to be mentioned")]
-
- def validate(self, commit):
- violations = []
- for needs_mentioned_file in self.options['files'].value:
- # if a file that we need to look out for is actually changed, then check whether it occurs
- # in the commit msg body
- if needs_mentioned_file in commit.changed_files:
- if needs_mentioned_file not in " ".join(commit.message.body):
- violation_message = u"Body does not mention changed file '{0}'".format(needs_mentioned_file)
- violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
- return violations if violations else None
-
-
-class AuthorValidEmail(CommitRule):
- name = "author-valid-email"
- id = "M1"
- options_spec = [StrOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]
-
- def validate(self, commit):
- # Note that unicode is allowed in email addresses
- # See http://stackoverflow.com/questions/3844431
- # /are-email-addresses-allowed-to-contain-non-alphanumeric-characters
- email_regex = re.compile(self.options['regex'].value, re.UNICODE)
-
- if commit.author_email and not email_regex.match(commit.author_email):
- return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)]
-
-
-class IgnoreByTitle(ConfigurationRule):
- name = "ignore-by-title"
- id = "I1"
- options_spec = [StrOption('regex', None, "Regex matching the titles of commits this rule should apply to"),
- StrOption('ignore', "all", "Comma-separated list of rules to ignore")]
-
- def apply(self, config, commit):
- title_regex = re.compile(self.options['regex'].value, re.UNICODE)
-
- if title_regex.match(commit.message.title):
- config.ignore = self.options['ignore'].value
-
- message = u"Commit title '{0}' matches the regex '{1}', ignoring rules: {2}"
- message = message.format(commit.message.title, self.options['regex'].value, self.options['ignore'].value)
-
- LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
-
-
-class IgnoreByBody(ConfigurationRule):
- name = "ignore-by-body"
- id = "I2"
- options_spec = [StrOption('regex', None, "Regex matching lines of the body of commits this rule should apply to"),
- StrOption('ignore', "all", "Comma-separated list of rules to ignore")]
-
- def apply(self, config, commit):
- body_line_regex = re.compile(self.options['regex'].value, re.UNICODE)
-
- for line in commit.message.body:
- if body_line_regex.match(line):
- config.ignore = self.options['ignore'].value
-
- message = u"Commit message line '{0}' matches the regex '{1}', ignoring rules: {2}"
- message = message.format(line, self.options['regex'].value, self.options['ignore'].value)
-
- LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
- # No need to check other lines if we found a match
- return
diff --git a/gitlint/shell.py b/gitlint/shell.py
deleted file mode 100644
index 965f492..0000000
--- a/gitlint/shell.py
+++ /dev/null
@@ -1,76 +0,0 @@
-
-"""
-This module implements a shim for the 'sh' library, mainly for use on Windows (sh is not supported on Windows).
-We might consider removing the 'sh' dependency alltogether in the future, but 'sh' does provide a few
-capabilities wrt dealing with more edge-case environments on *nix systems that might be useful.
-"""
-
-import subprocess
-import sys
-from gitlint.utils import ustr, USE_SH_LIB
-
-if USE_SH_LIB:
- from sh import git # pylint: disable=unused-import,import-error
- # import exceptions separately, this makes it a little easier to mock them out in the unit tests
- from sh import CommandNotFound, ErrorReturnCode # pylint: disable=import-error
-else:
-
- class CommandNotFound(Exception):
- """ Exception indicating a command was not found during execution """
- pass
-
- class ShResult(object):
- """ Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using
- the builtin subprocess. module """
-
- def __init__(self, full_cmd, stdout, stderr='', exitcode=0):
- self.full_cmd = full_cmd
- self.stdout = stdout
- self.stderr = stderr
- self.exit_code = exitcode
-
- def __str__(self):
- return self.stdout
-
- class ErrorReturnCode(ShResult, Exception):
- """ ShResult subclass for unexpected results (acts as an exception). """
- pass
-
- def git(*command_parts, **kwargs):
- """ Git shell wrapper.
- Implemented as separate function here, so we can do a 'sh' style imports:
- `from shell import git`
- """
- args = ['git'] + list(command_parts)
- return _exec(*args, **kwargs)
-
- def _exec(*args, **kwargs):
- if sys.version_info[0] == 2:
- no_command_error = OSError # noqa pylint: disable=undefined-variable,invalid-name
- else:
- no_command_error = FileNotFoundError # noqa pylint: disable=undefined-variable
-
- pipe = subprocess.PIPE
- popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs['_tty_out']}
- if '_cwd' in kwargs:
- popen_kwargs['cwd'] = kwargs['_cwd']
-
- try:
- p = subprocess.Popen(args, **popen_kwargs)
- result = p.communicate()
- except no_command_error:
- raise CommandNotFound
-
- exit_code = p.returncode
- stdout = ustr(result[0])
- stderr = result[1] # 'sh' does not decode the stderr bytes to unicode
- full_cmd = '' if args is None else ' '.join(args)
-
- # If not _ok_code is specified, then only a 0 exit code is allowed
- ok_exit_codes = kwargs.get('_ok_code', [0])
-
- if exit_code in ok_exit_codes:
- return ShResult(full_cmd, stdout, stderr, exit_code)
-
- # Unexpected error code => raise ErrorReturnCode
- raise ErrorReturnCode(full_cmd, stdout, stderr, p.returncode)
diff --git a/gitlint/tests/base.py b/gitlint/tests/base.py
deleted file mode 100644
index add4d71..0000000
--- a/gitlint/tests/base.py
+++ /dev/null
@@ -1,169 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import copy
-import io
-import logging
-import os
-import re
-
-try:
- # python 2.x
- import unittest2 as unittest
-except ImportError:
- # python 3.x
- import unittest
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.git import GitContext
-from gitlint.utils import ustr, LOG_FORMAT, DEFAULT_ENCODING
-
-
-# unittest2's assertRaisesRegex doesn't do unicode comparison.
-# Let's monkeypatch the str() function to point to unicode() so that it does :)
-# For reference, this is where this patch is required:
-# https://hg.python.org/unittest2/file/tip/unittest2/case.py#l227
-try:
- # python 2.x
- unittest.case.str = unicode
-except (AttributeError, NameError):
- pass # python 3.x
-
-
-class BaseTestCase(unittest.TestCase):
- """ Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods. """
-
- # In case of assert failures, print the full error message
- maxDiff = None
-
- SAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
- EXPECTED_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
- GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
-
- def setUp(self):
- self.logcapture = LogCapture()
- self.logcapture.setFormatter(logging.Formatter(LOG_FORMAT))
- logging.getLogger('gitlint').setLevel(logging.DEBUG)
- logging.getLogger('gitlint').handlers = [self.logcapture]
-
- # Make sure we don't propagate anything to child loggers, we need to do this explicitely here
- # because if you run a specific test file like test_lint.py, we won't be calling the setupLogging() method
- # in gitlint.cli that normally takes care of this
- logging.getLogger('gitlint').propagate = False
-
- @staticmethod
- def get_sample_path(filename=""):
- # Don't join up empty files names because this will add a trailing slash
- if filename == "":
- return ustr(BaseTestCase.SAMPLES_DIR)
-
- return ustr(os.path.join(BaseTestCase.SAMPLES_DIR, filename))
-
- @staticmethod
- def get_sample(filename=""):
- """ Read and return the contents of a file in gitlint/tests/samples """
- sample_path = BaseTestCase.get_sample_path(filename)
- with io.open(sample_path, encoding=DEFAULT_ENCODING) as content:
- sample = ustr(content.read())
- return sample
-
- @staticmethod
- def get_expected(filename="", variable_dict=None):
- """ Utility method to read an expected file from gitlint/tests/expected and return it as a string.
- Optionally replace template variables specified by variable_dict. """
- expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename)
- with io.open(expected_path, encoding=DEFAULT_ENCODING) as content:
- expected = ustr(content.read())
-
- if variable_dict:
- expected = expected.format(**variable_dict)
- return expected
-
- @staticmethod
- def get_user_rules_path():
- return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules")
-
- @staticmethod
- def gitcontext(commit_msg_str, changed_files=None, ):
- """ Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of
- changed files"""
- with patch("gitlint.git.git_commentchar") as comment_char:
- comment_char.return_value = u"#"
- gitcontext = GitContext.from_commit_msg(commit_msg_str)
- commit = gitcontext.commits[-1]
- if changed_files:
- commit.changed_files = changed_files
- return gitcontext
-
- @staticmethod
- def gitcommit(commit_msg_str, changed_files=None, **kwargs):
- """ Utility method to easily create git commit given a commit msg string and an optional set of changed files"""
- gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files)
- commit = gitcontext.commits[-1]
- for attr, value in kwargs.items():
- setattr(commit, attr, value)
- return commit
-
- def assert_logged(self, expected):
- """ Asserts that the logs match an expected string or list.
- This method knows how to compare a passed list of log lines as well as a newline concatenated string
- of all loglines. """
- if isinstance(expected, list):
- self.assertListEqual(self.logcapture.messages, expected)
- else:
- self.assertEqual("\n".join(self.logcapture.messages), expected)
-
- def assert_log_contains(self, line):
- """ Asserts that a certain line is in the logs """
- self.assertIn(line, self.logcapture.messages)
-
- def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs):
- """ Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed
- `expected_regex`. This is useful to automatically escape all file paths that might be present in the regex.
- """
- return super(BaseTestCase, self).assertRaisesRegex(expected_exception, re.escape(expected_regex),
- *args, **kwargs)
-
- def object_equality_test(self, obj, attr_list, ctor_kwargs=None):
- """ Helper function to easily implement object equality tests.
- Creates an object clone for every passed attribute and checks for (in)equality
- of the original object with the clone based on those attributes' values.
- This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`.
- """
- if not ctor_kwargs:
- ctor_kwargs = {}
-
- attr_kwargs = {}
- for attr in attr_list:
- attr_kwargs[attr] = getattr(obj, attr)
-
- # For every attr, clone the object and assert the clone and the original object are equal
- # Then, change the current attr and assert objects are unequal
- for attr in attr_list:
- attr_kwargs_copy = copy.deepcopy(attr_kwargs)
- attr_kwargs_copy.update(ctor_kwargs)
- clone = obj.__class__(**attr_kwargs_copy)
- self.assertEqual(obj, clone)
-
- # Change attribute and assert objects are different (via both attribute set and ctor)
- setattr(clone, attr, u"föo")
- self.assertNotEqual(obj, clone)
- attr_kwargs_copy[attr] = u"föo"
-
- self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy))
-
-
-class LogCapture(logging.Handler):
- """ Mock logging handler used to capture any log messages during tests."""
-
- def __init__(self, *args, **kwargs):
- logging.Handler.__init__(self, *args, **kwargs)
- self.messages = []
-
- def emit(self, record):
- self.messages.append(ustr(self.format(record)))
diff --git a/gitlint/tests/cli/test_cli.py b/gitlint/tests/cli/test_cli.py
deleted file mode 100644
index 4d47f35..0000000
--- a/gitlint/tests/cli/test_cli.py
+++ /dev/null
@@ -1,541 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import contextlib
-import io
-import os
-import sys
-import platform
-import shutil
-import tempfile
-
-import arrow
-
-try:
- # python 2.x
- from StringIO import StringIO
-except ImportError:
- # python 3.x
- from io import StringIO # pylint: disable=ungrouped-imports
-
-from click.testing import CliRunner
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.shell import CommandNotFound
-
-from gitlint.tests.base import BaseTestCase
-from gitlint import cli
-from gitlint import __version__
-from gitlint.utils import DEFAULT_ENCODING
-
-
-@contextlib.contextmanager
-def tempdir():
- tmpdir = tempfile.mkdtemp()
- try:
- yield tmpdir
- finally:
- shutil.rmtree(tmpdir)
-
-
-class CLITests(BaseTestCase):
- USAGE_ERROR_CODE = 253
- GIT_CONTEXT_ERROR_CODE = 254
- CONFIG_ERROR_CODE = 255
-
- def setUp(self):
- super(CLITests, self).setUp()
- self.cli = CliRunner()
-
- # Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
- self.git_version_path = patch('gitlint.cli.git_version')
- cli.git_version = self.git_version_path.start()
- cli.git_version.return_value = "git version 1.2.3"
-
- def tearDown(self):
- self.git_version_path.stop()
-
- @staticmethod
- def get_system_info_dict():
- """ Returns a dict with items related to system values logged by `gitlint --debug` """
- return {'platform': platform.platform(), "python_version": sys.version, 'gitlint_version': __version__,
- 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB, 'target': os.path.realpath(os.getcwd())}
-
- def test_version(self):
- """ Test for --version option """
- result = self.cli.invoke(cli.cli, ["--version"])
- self.assertEqual(result.output.split("\n")[0], "cli, version {0}".format(__version__))
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_lint(self, sh, _):
- """ Test for basic simple linting functionality """
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360",
- u"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"commït-title\n\ncommït-body",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n",
- u"file1.txt\npåth/to/file2.txt\n"
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli)
- self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
- self.assertEqual(result.exit_code, 1)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_lint_multiple_commits(self, sh, _):
- """ Test for --commits option """
-
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
- "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
- "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"commït-title1\n\ncommït-body1",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
- u"commït-title2\n\ncommït-body2",
- u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
- u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
- u"commït-title3\n\ncommït-body3",
- u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
- u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_multiple_commits_1"))
- self.assertEqual(result.exit_code, 3)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_lint_multiple_commits_config(self, sh, _):
- """ Test for --commits option where some of the commits have gitlint config in the commit message """
-
- # Note that the second commit title has a trailing period that is being ignored by gitlint-ignore: T3
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
- "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
- "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"commït-title1\n\ncommït-body1",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
- u"commït-title2.\n\ncommït-body2\ngitlint-ignore: T3\n",
- u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
- u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
- u"commït-title3.\n\ncommït-body3",
- u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
- u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
- # We expect that the second commit has no failures because of 'gitlint-ignore: T3' in its commit msg body
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_multiple_commits_config_1"))
- self.assertEqual(result.exit_code, 3)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_lint_multiple_commits_configuration_rules(self, sh, _):
- """ Test for --commits option where where we have configured gitlint to ignore certain rules for certain commits
- """
-
- # Note that the second commit
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
- "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
- "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"commït-title1\n\ncommït-body1",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
- # Normally T3 violation (trailing punctuation), but this commit is ignored because of
- # config below
- u"commït-title2.\n\ncommït-body2\n",
- u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
- u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
- # Normally T1 and B5 violations, now only T1 because we're ignoring B5 in config below
- u"commït-title3.\n\ncommït-body3 foo",
- u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
- u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--commits", "foo...bar", "-c", "I1.regex=^commït-title2(.*)",
- "-c", "I2.regex=^commït-body3(.*)", "-c", "I2.ignore=B5"])
- # We expect that the second commit has no failures because of it matching against I1.regex
- # Because we do test for the 3th commit to return violations, this test also ensures that a unique
- # config object is passed to each commit lint call
- expected = (u"Commit 6f29bf81a8:\n"
- u'3: B5 Body message is too short (12<20): "commït-body1"\n\n'
- u"Commit 4da2656b0d:\n"
- u'1: T3 Title has trailing punctuation (.): "commït-title3."\n')
- self.assertEqual(stderr.getvalue(), expected)
- self.assertEqual(result.exit_code, 2)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
- def test_input_stream(self, _):
- """ Test for linting when a message is passed via stdin """
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli)
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_input_stream_1"))
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
-
- @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
- def test_input_stream_debug(self, _):
- """ Test for linting when a message is passed via stdin, and debug is enabled.
- This tests specifically that git commit meta is not fetched when not passing --staged """
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--debug"])
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_input_stream_debug_1"))
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
- expected_kwargs = self.get_system_info_dict()
- expected_logs = self.get_expected('test_cli/test_input_stream_debug_2', expected_kwargs)
- self.assert_logged(expected_logs)
-
- @patch('gitlint.cli.get_stdin_data', return_value="Should be ignored\n")
- @patch('gitlint.git.sh')
- def test_lint_ignore_stdin(self, sh, stdin_data):
- """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360",
- u"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"commït-title\n\ncommït-body",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
- u"file1.txt\npåth/to/file2.txt\n" # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--ignore-stdin"])
- self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
- self.assertEqual(result.exit_code, 1)
-
- # Assert that we didn't even try to get the stdin data
- self.assertEqual(stdin_data.call_count, 0)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
- @patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
- @patch('gitlint.git.sh')
- def test_lint_staged_stdin(self, sh, _, __):
- """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
-
- sh.git.side_effect = [
- u"#", # git config --get core.commentchar
- u"föo user\n", # git config --get user.name
- u"föo@bar.com\n", # git config --get user.email
- u"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--debug", "--staged"])
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_staged_stdin_1"))
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
-
- expected_kwargs = self.get_system_info_dict()
- expected_logs = self.get_expected('test_cli/test_lint_staged_stdin_2', expected_kwargs)
- self.assert_logged(expected_logs)
-
- @patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
- @patch('gitlint.git.sh')
- def test_lint_staged_msg_filename(self, sh, _):
- """ Test for ignoring stdin when --ignore-stdin flag is enabled"""
-
- sh.git.side_effect = [
- u"#", # git config --get core.commentchar
- u"föo user\n", # git config --get user.name
- u"föo@bar.com\n", # git config --get user.email
- u"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- ]
-
- with tempdir() as tmpdir:
- msg_filename = os.path.join(tmpdir, "msg")
- with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
- f.write(u"WIP: msg-filename tïtle\n")
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--debug", "--staged", "--msg-filename", msg_filename])
- self.assertEqual(stderr.getvalue(), self.get_expected("test_cli/test_lint_staged_msg_filename_1"))
- self.assertEqual(result.exit_code, 2)
- self.assertEqual(result.output, "")
-
- expected_kwargs = self.get_system_info_dict()
- expected_logs = self.get_expected('test_cli/test_lint_staged_msg_filename_2', expected_kwargs)
- self.assert_logged(expected_logs)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- def test_lint_staged_negative(self, _):
- result = self.cli.invoke(cli.cli, ["--staged"])
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
- self.assertEqual(result.output, (u"Error: The 'staged' option (--staged) can only be used when using "
- u"'--msg-filename' or when piping data to gitlint via stdin.\n"))
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- def test_msg_filename(self, _):
- expected_output = u"3: B6 Body message is missing\n"
-
- with tempdir() as tmpdir:
- msg_filename = os.path.join(tmpdir, "msg")
- with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
- f.write(u"Commït title\n")
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename])
- self.assertEqual(stderr.getvalue(), expected_output)
- self.assertEqual(result.exit_code, 1)
- self.assertEqual(result.output, "")
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tïtle \n")
- def test_silent_mode(self, _):
- """ Test for --silent option """
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--silent"])
- self.assertEqual(stderr.getvalue(), "")
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tïtle \n")
- def test_verbosity(self, _):
- """ Test for --verbosity option """
- # We only test -v and -vv, more testing is really not required here
- # -v
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["-v"])
- self.assertEqual(stderr.getvalue(), "1: T2\n1: T5\n3: B6\n")
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
-
- # -vv
- expected_output = "1: T2 Title has trailing whitespace\n" + \
- "1: T5 Title contains the word 'WIP' (case-insensitive)\n" + \
- "3: B6 Body message is missing\n"
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["-vv"], input=u"WIP: tïtle \n")
- self.assertEqual(stderr.getvalue(), expected_output)
- self.assertEqual(result.exit_code, 3)
- self.assertEqual(result.output, "")
-
- # -vvvv: not supported -> should print a config error
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["-vvvv"], input=u'WIP: tïtle \n')
- self.assertEqual(stderr.getvalue(), "")
- self.assertEqual(result.exit_code, CLITests.CONFIG_ERROR_CODE)
- self.assertEqual(result.output, "Config Error: Option 'verbosity' must be set between 0 and 3\n")
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_debug(self, sh, _):
- """ Test for --debug option """
-
- sh.git.side_effect = [
- "6f29bf81a8322a04071bb794666e48c443a90360\n" # git rev-list <SHA>
- "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n"
- "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
- # git log --pretty <FORMAT> <SHA>
- u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00abc\n"
- u"commït-title1\n\ncommït-body1",
- u"#", # git config --get core.commentchar
- u"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
- u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
- u"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00abc\n"
- u"commït-title2.\n\ncommït-body2",
- u"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
- u"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
- u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00abc\n"
- u"föo\nbar",
- u"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
- u"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
- ]
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
- result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug", "--commits",
- "foo...bar"])
-
- expected = "Commit 6f29bf81a8:\n3: B5\n\n" + \
- "Commit 25053ccec5:\n1: T3\n3: B5\n\n" + \
- "Commit 4da2656b0d:\n2: B4\n3: B5\n3: B6\n"
-
- self.assertEqual(stderr.getvalue(), expected)
- self.assertEqual(result.exit_code, 6)
-
- expected_kwargs = self.get_system_info_dict()
- expected_kwargs.update({'config_path': config_path})
- expected_logs = self.get_expected('test_cli/test_debug_1', expected_kwargs)
- self.assert_logged(expected_logs)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n")
- def test_extra_path(self, _):
- """ Test for --extra-path flag """
- # Test extra-path pointing to a directory
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- extra_path = self.get_sample_path("user_rules")
- result = self.cli.invoke(cli.cli, ["--extra-path", extra_path, "--debug"])
- expected_output = u"1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
- "3: B6 Body message is missing\n"
- self.assertEqual(stderr.getvalue(), expected_output)
- self.assertEqual(result.exit_code, 2)
-
- # Test extra-path pointing to a file
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- extra_path = self.get_sample_path(os.path.join("user_rules", "my_commit_rules.py"))
- result = self.cli.invoke(cli.cli, ["--extra-path", extra_path, "--debug"])
- expected_output = u"1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
- "3: B6 Body message is missing\n"
- self.assertEqual(stderr.getvalue(), expected_output)
- self.assertEqual(result.exit_code, 2)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n\nMy body that is long enough")
- def test_contrib(self, _):
- # Test enabled contrib rules
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- result = self.cli.invoke(cli.cli, ["--contrib", "contrib-title-conventional-commits,CC1"])
- expected_output = self.get_expected('test_cli/test_contrib_1')
- self.assertEqual(stderr.getvalue(), expected_output)
- self.assertEqual(result.exit_code, 3)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"Test tïtle\n")
- def test_contrib_negative(self, _):
- result = self.cli.invoke(cli.cli, ["--contrib", u"föobar,CC1"])
- self.assertEqual(result.output, u"Config Error: No contrib rule with id or name 'föobar' found.\n")
- self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
-
- @patch('gitlint.cli.get_stdin_data', return_value=u"WIP: tëst")
- def test_config_file(self, _):
- """ Test for --config option """
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
- result = self.cli.invoke(cli.cli, ["--config", config_path])
- self.assertEqual(result.output, "")
- self.assertEqual(stderr.getvalue(), "1: T5\n3: B6\n")
- self.assertEqual(result.exit_code, 2)
-
- def test_config_file_negative(self):
- """ Negative test for --config option """
- # Directory as config file
- config_path = self.get_sample_path("config")
- result = self.cli.invoke(cli.cli, ["--config", config_path])
- expected_string = u"Error: Invalid value for \"-C\" / \"--config\": File \"{0}\" is a directory.".format(
- config_path)
- self.assertEqual(result.output.split("\n")[3], expected_string)
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
-
- # Non existing file
- config_path = self.get_sample_path(u"föo")
- result = self.cli.invoke(cli.cli, ["--config", config_path])
- expected_string = u"Error: Invalid value for \"-C\" / \"--config\": File \"{0}\" does not exist.".format(
- config_path)
- self.assertEqual(result.output.split("\n")[3], expected_string)
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
-
- # Invalid config file
- config_path = self.get_sample_path(os.path.join("config", "invalid-option-value"))
- result = self.cli.invoke(cli.cli, ["--config", config_path])
- self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- def test_target(self, _):
- """ Test for the --target option """
- os.environ["LANGUAGE"] = "C" # Force language to english so we can check for error message
- result = self.cli.invoke(cli.cli, ["--target", "/tmp"])
- # We expect gitlint to tell us that /tmp is not a git repo (this proves that it takes the target parameter
- # into account).
- expected_path = os.path.realpath("/tmp")
- self.assertEqual(result.output, "%s is not a git repository.\n" % expected_path)
- self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
-
- def test_target_negative(self):
- """ Negative test for the --target option """
- # try setting a non-existing target
- result = self.cli.invoke(cli.cli, ["--target", u"/föo/bar"])
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
- expected_msg = u"Error: Invalid value for \"--target\": Directory \"/föo/bar\" does not exist."
- self.assertEqual(result.output.split("\n")[3], expected_msg)
-
- # try setting a file as target
- target_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
- result = self.cli.invoke(cli.cli, ["--target", target_path])
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
- expected_msg = u"Error: Invalid value for \"--target\": Directory \"{0}\" is a file.".format(target_path)
- self.assertEqual(result.output.split("\n")[3], expected_msg)
-
- @patch('gitlint.config.LintConfigGenerator.generate_config')
- def test_generate_config(self, generate_config):
- """ Test for the generate-config subcommand """
- result = self.cli.invoke(cli.cli, ["generate-config"], input=u"tëstfile\n")
- self.assertEqual(result.exit_code, 0)
- expected_msg = u"Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + \
- u"Successfully generated {0}\n".format(os.path.realpath(u"tëstfile"))
- self.assertEqual(result.output, expected_msg)
- generate_config.assert_called_once_with(os.path.realpath(u"tëstfile"))
-
- def test_generate_config_negative(self):
- """ Negative test for the generate-config subcommand """
- # Non-existing directory
- fake_dir = os.path.abspath(u"/föo")
- fake_path = os.path.join(fake_dir, u"bar")
- result = self.cli.invoke(cli.cli, ["generate-config"], input=fake_path)
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
- expected_msg = (u"Please specify a location for the sample gitlint config file [.gitlint]: {0}\n"
- + u"Error: Directory '{1}' does not exist.\n").format(fake_path, fake_dir)
- self.assertEqual(result.output, expected_msg)
-
- # Existing file
- sample_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
- result = self.cli.invoke(cli.cli, ["generate-config"], input=sample_path)
- self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
- expected_msg = "Please specify a location for the sample gitlint " + \
- "config file [.gitlint]: {0}\n".format(sample_path) + \
- "Error: File \"{0}\" already exists.\n".format(sample_path)
- self.assertEqual(result.output, expected_msg)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_git_error(self, sh, _):
- """ Tests that the cli handles git errors properly """
- sh.git.side_effect = CommandNotFound("git")
- result = self.cli.invoke(cli.cli)
- self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
-
- @patch('gitlint.cli.get_stdin_data', return_value=False)
- @patch('gitlint.git.sh')
- def test_no_commits_in_range(self, sh, _):
- """ Test for --commits with the specified range being empty. """
- sh.git.side_effect = lambda *_args, **_kwargs: ""
- result = self.cli.invoke(cli.cli, ["--commits", "master...HEAD"])
-
- self.assert_log_contains(u"DEBUG: gitlint.cli No commits in range \"master...HEAD\"")
- self.assertEqual(result.exit_code, 0)
diff --git a/gitlint/tests/cli/test_cli_hooks.py b/gitlint/tests/cli/test_cli_hooks.py
deleted file mode 100644
index 0564808..0000000
--- a/gitlint/tests/cli/test_cli_hooks.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import os
-
-from click.testing import CliRunner
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
-from gitlint import cli
-from gitlint import hooks
-from gitlint import config
-
-
-class CLIHookTests(BaseTestCase):
- USAGE_ERROR_CODE = 253
- GIT_CONTEXT_ERROR_CODE = 254
- CONFIG_ERROR_CODE = 255
-
- def setUp(self):
- super(CLIHookTests, self).setUp()
- self.cli = CliRunner()
-
- # Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
- self.git_version_path = patch('gitlint.cli.git_version')
- cli.git_version = self.git_version_path.start()
- cli.git_version.return_value = "git version 1.2.3"
-
- def tearDown(self):
- self.git_version_path.stop()
-
- @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
- @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
- def test_install_hook(self, _, install_hook):
- """ Test for install-hook subcommand """
- result = self.cli.invoke(cli.cli, ["install-hook"])
- expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
- expected = u"Successfully installed gitlint commit-msg hook in {0}\n".format(expected_path)
- self.assertEqual(result.output, expected)
- self.assertEqual(result.exit_code, 0)
- expected_config = config.LintConfig()
- expected_config.target = os.path.realpath(os.getcwd())
- install_hook.assert_called_once_with(expected_config)
-
- @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
- @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
- def test_install_hook_target(self, _, install_hook):
- """ Test for install-hook subcommand with a specific --target option specified """
- # Specified target
- result = self.cli.invoke(cli.cli, ["--target", self.SAMPLES_DIR, "install-hook"])
- expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
- expected = "Successfully installed gitlint commit-msg hook in %s\n" % expected_path
- self.assertEqual(result.exit_code, 0)
- self.assertEqual(result.output, expected)
-
- expected_config = config.LintConfig()
- expected_config.target = self.SAMPLES_DIR
- install_hook.assert_called_once_with(expected_config)
-
- @patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook', side_effect=hooks.GitHookInstallerError(u"tëst"))
- def test_install_hook_negative(self, install_hook):
- """ Negative test for install-hook subcommand """
- result = self.cli.invoke(cli.cli, ["install-hook"])
- self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
- self.assertEqual(result.output, u"tëst\n")
- expected_config = config.LintConfig()
- expected_config.target = os.path.realpath(os.getcwd())
- install_hook.assert_called_once_with(expected_config)
-
- @patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook')
- @patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join(u"/hür", u"dur"))
- def test_uninstall_hook(self, _, uninstall_hook):
- """ Test for uninstall-hook subcommand """
- result = self.cli.invoke(cli.cli, ["uninstall-hook"])
- expected_path = os.path.join(u"/hür", u"dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
- expected = u"Successfully uninstalled gitlint commit-msg hook from {0}\n".format(expected_path)
- self.assertEqual(result.exit_code, 0)
- self.assertEqual(result.output, expected)
- expected_config = config.LintConfig()
- expected_config.target = os.path.realpath(os.getcwd())
- uninstall_hook.assert_called_once_with(expected_config)
-
- @patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook', side_effect=hooks.GitHookInstallerError(u"tëst"))
- def test_uninstall_hook_negative(self, uninstall_hook):
- """ Negative test for uninstall-hook subcommand """
- result = self.cli.invoke(cli.cli, ["uninstall-hook"])
- self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
- self.assertEqual(result.output, u"tëst\n")
- expected_config = config.LintConfig()
- expected_config.target = os.path.realpath(os.getcwd())
- uninstall_hook.assert_called_once_with(expected_config)
diff --git a/gitlint/tests/config/test_config_builder.py b/gitlint/tests/config/test_config_builder.py
deleted file mode 100644
index 051a52f..0000000
--- a/gitlint/tests/config/test_config_builder.py
+++ /dev/null
@@ -1,203 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from gitlint.tests.base import BaseTestCase
-
-from gitlint.config import LintConfig, LintConfigBuilder, LintConfigError
-
-
-class LintConfigBuilderTests(BaseTestCase):
- def test_set_option(self):
- config_builder = LintConfigBuilder()
- config = config_builder.build()
-
- # assert some defaults
- self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 72)
- self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80)
- self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["WIP"])
- self.assertEqual(config.verbosity, 3)
-
- # Make some changes and check blueprint
- config_builder.set_option('title-max-length', 'line-length', 100)
- config_builder.set_option('general', 'verbosity', 2)
- config_builder.set_option('title-must-not-contain-word', 'words', ["foo", "bar"])
- expected_blueprint = {'title-must-not-contain-word': {'words': ['foo', 'bar']},
- 'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
- self.assertDictEqual(config_builder._config_blueprint, expected_blueprint)
-
- # Build config and verify that the changes have occurred and no other changes
- config = config_builder.build()
- self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 100)
- self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80) # should be unchanged
- self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["foo", "bar"])
- self.assertEqual(config.verbosity, 2)
-
- def test_set_from_commit_ignore_all(self):
- config = LintConfig()
- original_rules = config.rules
- original_rule_ids = [rule.id for rule in original_rules]
-
- config_builder = LintConfigBuilder()
-
- # nothing gitlint
- config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint\nfoo"))
- config = config_builder.build()
- self.assertSequenceEqual(config.rules, original_rules)
- self.assertListEqual(config.ignore, [])
-
- # ignore all rules
- config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: all\nfoo"))
- config = config_builder.build()
- self.assertEqual(config.ignore, original_rule_ids)
-
- # ignore all rules, no space
- config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore:all\nfoo"))
- config = config_builder.build()
- self.assertEqual(config.ignore, original_rule_ids)
-
- # ignore all rules, more spacing
- config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: \t all\nfoo"))
- config = config_builder.build()
- self.assertEqual(config.ignore, original_rule_ids)
-
- def test_set_from_commit_ignore_specific(self):
- # ignore specific rules
- config_builder = LintConfigBuilder()
- config_builder.set_config_from_commit(self.gitcommit(u"tëst\ngitlint-ignore: T1, body-hard-tab"))
- config = config_builder.build()
- self.assertEqual(config.ignore, ["T1", "body-hard-tab"])
-
- def test_set_from_config_file(self):
- # regular config file load, no problems
- config_builder = LintConfigBuilder()
- config_builder.set_from_config_file(self.get_sample_path("config/gitlintconfig"))
- config = config_builder.build()
-
- # Do some assertions on the config
- self.assertEqual(config.verbosity, 1)
- self.assertFalse(config.debug)
- self.assertFalse(config.ignore_merge_commits)
- self.assertIsNone(config.extra_path)
- self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
-
- self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 20)
- self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 30)
-
- def test_set_from_config_file_negative(self):
- config_builder = LintConfigBuilder()
-
- # bad config file load
- foo_path = self.get_sample_path(u"föo")
- expected_error_msg = u"Invalid file path: {0}".format(foo_path)
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.set_from_config_file(foo_path)
-
- # error during file parsing
- path = self.get_sample_path("config/no-sections")
- expected_error_msg = u"File contains no section headers."
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.set_from_config_file(path)
-
- # non-existing rule
- path = self.get_sample_path("config/nonexisting-rule")
- config_builder = LintConfigBuilder()
- config_builder.set_from_config_file(path)
- expected_error_msg = u"No such rule 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.build()
-
- # non-existing general option
- path = self.get_sample_path("config/nonexisting-general-option")
- config_builder = LintConfigBuilder()
- config_builder.set_from_config_file(path)
- expected_error_msg = u"'foo' is not a valid gitlint option"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.build()
-
- # non-existing option
- path = self.get_sample_path("config/nonexisting-option")
- config_builder = LintConfigBuilder()
- config_builder.set_from_config_file(path)
- expected_error_msg = u"Rule 'title-max-length' has no option 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.build()
-
- # invalid option value
- path = self.get_sample_path("config/invalid-option-value")
- config_builder = LintConfigBuilder()
- config_builder.set_from_config_file(path)
- expected_error_msg = u"'föo' is not a valid value for option 'title-max-length.line-length'. " + \
- u"Option 'line-length' must be a positive integer (current value: 'föo')."
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
- config_builder.build()
-
- def test_set_config_from_string_list(self):
- config = LintConfig()
-
- # change and assert changes
- config_builder = LintConfigBuilder()
- config_builder.set_config_from_string_list(['general.verbosity=1', 'title-max-length.line-length=60',
- 'body-max-line-length.line-length=120',
- u"title-must-not-contain-word.words=håha"])
-
- config = config_builder.build()
- self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 60)
- self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 120)
- self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), [u"håha"])
- self.assertEqual(config.verbosity, 1)
-
- def test_set_config_from_string_list_negative(self):
- config_builder = LintConfigBuilder()
-
- # assert error on incorrect rule - this happens at build time
- config_builder.set_config_from_string_list([u"föo.bar=1"])
- with self.assertRaisesRegex(LintConfigError, u"No such rule 'föo'"):
- config_builder.build()
-
- # no equal sign
- expected_msg = u"'föo.bar' is an invalid configuration option. Use '<rule>.<option>=<value>'"
- with self.assertRaisesRegex(LintConfigError, expected_msg):
- config_builder.set_config_from_string_list([u"föo.bar"])
-
- # missing value
- expected_msg = u"'föo.bar=' is an invalid configuration option. Use '<rule>.<option>=<value>'"
- with self.assertRaisesRegex(LintConfigError, expected_msg):
- config_builder.set_config_from_string_list([u"föo.bar="])
-
- # space instead of equal sign
- expected_msg = u"'föo.bar 1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
- with self.assertRaisesRegex(LintConfigError, expected_msg):
- config_builder.set_config_from_string_list([u"föo.bar 1"])
-
- # no period between rule and option names
- expected_msg = u"'föobar=1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
- with self.assertRaisesRegex(LintConfigError, expected_msg):
- config_builder.set_config_from_string_list([u'föobar=1'])
-
- def test_rebuild_config(self):
- # normal config build
- config_builder = LintConfigBuilder()
- config_builder.set_option('general', 'verbosity', 3)
- lint_config = config_builder.build()
- self.assertEqual(lint_config.verbosity, 3)
-
- # check that existing config gets overwritten when we pass it to a configbuilder with different options
- existing_lintconfig = LintConfig()
- existing_lintconfig.verbosity = 2
- lint_config = config_builder.build(existing_lintconfig)
- self.assertEqual(lint_config.verbosity, 3)
- self.assertEqual(existing_lintconfig.verbosity, 3)
-
- def test_clone(self):
- config_builder = LintConfigBuilder()
- config_builder.set_option('general', 'verbosity', 2)
- config_builder.set_option('title-max-length', 'line-length', 100)
- expected = {'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
- self.assertDictEqual(config_builder._config_blueprint, expected)
-
- # Clone and verify that the blueprint is the same as the original
- cloned_builder = config_builder.clone()
- self.assertDictEqual(cloned_builder._config_blueprint, expected)
-
- # Modify the original and make sure we're not modifying the clone (i.e. check that the copy is a deep copy)
- config_builder.set_option('title-max-length', 'line-length', 120)
- self.assertDictEqual(cloned_builder._config_blueprint, expected)
diff --git a/gitlint/tests/contrib/test_conventional_commit.py b/gitlint/tests/contrib/test_conventional_commit.py
deleted file mode 100644
index ea808fd..0000000
--- a/gitlint/tests/contrib/test_conventional_commit.py
+++ /dev/null
@@ -1,47 +0,0 @@
-
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rules import RuleViolation
-from gitlint.contrib.rules.conventional_commit import ConventionalCommit
-from gitlint.config import LintConfig
-
-
-class ContribConventionalCommitTests(BaseTestCase):
-
- def test_enable(self):
- # Test that rule can be enabled in config
- for rule_ref in ['CT1', 'contrib-title-conventional-commits']:
- config = LintConfig()
- config.contrib = [rule_ref]
- self.assertIn(ConventionalCommit(), config.rules)
-
- def test_conventional_commits(self):
- rule = ConventionalCommit()
-
- # No violations when using a correct type and format
- for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"]:
- violations = rule.validate(type + u": föo", None)
- self.assertListEqual([], violations)
-
- # assert violation on wrong type
- expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs,"
- " style, refactor, perf, test, revert", u"bår: foo")
- violations = rule.validate(u"bår: foo", None)
- self.assertListEqual([expected_violation], violations)
-
- # assert violation on wrong format
- expected_violation = RuleViolation("CT1", "Title does not follow ConventionalCommits.org format "
- "'type(optional-scope): description'", u"fix föo")
- violations = rule.validate(u"fix föo", None)
- self.assertListEqual([expected_violation], violations)
-
- # assert no violation when adding new type
- rule = ConventionalCommit({'types': [u"föo", u"bär"]})
- for typ in [u"föo", u"bär"]:
- violations = rule.validate(typ + u": hür dur", None)
- self.assertListEqual([], violations)
-
- # assert violation when using incorrect type when types have been reconfigured
- violations = rule.validate(u"fix: hür dur", None)
- expected_violation = RuleViolation("CT1", u"Title does not start with one of föo, bär", u"fix: hür dur")
- self.assertListEqual([expected_violation], violations)
diff --git a/gitlint/tests/expected/test_cli/test_contrib_1 b/gitlint/tests/expected/test_cli/test_contrib_1
deleted file mode 100644
index ea5d353..0000000
--- a/gitlint/tests/expected/test_cli/test_contrib_1
+++ /dev/null
@@ -1,3 +0,0 @@
-1: CC1 Body does not contain a 'Signed-Off-By' line
-1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert: "Test tïtle"
-1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"
diff --git a/gitlint/tests/git/test_git.py b/gitlint/tests/git/test_git.py
deleted file mode 100644
index 297b10c..0000000
--- a/gitlint/tests/git/test_git.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.shell import ErrorReturnCode, CommandNotFound
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.git import GitContext, GitContextError, GitNotInstalledError, git_commentchar, git_hooks_dir
-
-
-class GitTests(BaseTestCase):
-
- # Expected special_args passed to 'sh'
- expected_sh_special_args = {
- '_tty_out': False,
- '_cwd': u"fåke/path"
- }
-
- @patch('gitlint.git.sh')
- def test_get_latest_commit_command_not_found(self, sh):
- sh.git.side_effect = CommandNotFound("git")
- expected_msg = "'git' command not found. You need to install git to use gitlint on a local repository. " + \
- "See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git."
- with self.assertRaisesRegex(GitNotInstalledError, expected_msg):
- GitContext.from_local_repository(u"fåke/path")
-
- # assert that commit message was read using git command
- sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
-
- @patch('gitlint.git.sh')
- def test_get_latest_commit_git_error(self, sh):
- # Current directory not a git repo
- err = b"fatal: Not a git repository (or any of the parent directories): .git"
- sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
-
- with self.assertRaisesRegex(GitContextError, u"fåke/path is not a git repository."):
- GitContext.from_local_repository(u"fåke/path")
-
- # assert that commit message was read using git command
- sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
- sh.git.reset_mock()
-
- err = b"fatal: Random git error"
- sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
-
- expected_msg = u"An error occurred while executing 'git log -1 --pretty=%H': {0}".format(err)
- with self.assertRaisesRegex(GitContextError, expected_msg):
- GitContext.from_local_repository(u"fåke/path")
-
- # assert that commit message was read using git command
- sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
-
- @patch('gitlint.git.sh')
- def test_git_no_commits_error(self, sh):
- # No commits: returned by 'git log'
- err = b"fatal: your current branch 'master' does not have any commits yet"
-
- sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
-
- expected_msg = u"Current branch has no commits. Gitlint requires at least one commit to function."
- with self.assertRaisesRegex(GitContextError, expected_msg):
- GitContext.from_local_repository(u"fåke/path")
-
- # assert that commit message was read using git command
- sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
- sh.git.reset_mock()
-
- # Unknown reference 'HEAD' commits: returned by 'git rev-parse'
- err = (b"HEAD"
- b"fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree."
- b"Use '--' to separate paths from revisions, like this:"
- b"'git <command> [<revision>...] -- [<file>...]'")
-
- sh.git.side_effect = [
- u"#\n", # git config --get core.commentchar
- ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err)
- ]
-
- with self.assertRaisesRegex(GitContextError, expected_msg):
- context = GitContext.from_commit_msg(u"test")
- context.current_branch
-
- # assert that commit message was read using git command
- sh.git.assert_called_with("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None)
-
- @patch("gitlint.git._git")
- def test_git_commentchar(self, git):
- git.return_value.exit_code = 1
- self.assertEqual(git_commentchar(), "#")
-
- git.return_value.exit_code = 0
- git.return_value.__str__ = lambda _: u"ä"
- git.return_value.__unicode__ = lambda _: u"ä"
- self.assertEqual(git_commentchar(), u"ä")
-
- git.return_value = ';\n'
- self.assertEqual(git_commentchar(os.path.join(u"/föo", u"bar")), ';')
-
- git.assert_called_with("config", "--get", "core.commentchar", _ok_code=[0, 1],
- _cwd=os.path.join(u"/föo", u"bar"))
-
- @patch("gitlint.git._git")
- def test_git_hooks_dir(self, git):
- hooks_dir = os.path.join(u"föo", ".git", "hooks")
- git.return_value.__str__ = lambda _: hooks_dir + "\n"
- git.return_value.__unicode__ = lambda _: hooks_dir + "\n"
- self.assertEqual(git_hooks_dir(u"/blä"), os.path.abspath(os.path.join(u"/blä", hooks_dir)))
-
- git.assert_called_once_with("rev-parse", "--git-path", "hooks", _cwd=u"/blä")
diff --git a/gitlint/tests/git/test_git_commit.py b/gitlint/tests/git/test_git_commit.py
deleted file mode 100644
index dc83ccb..0000000
--- a/gitlint/tests/git/test_git_commit.py
+++ /dev/null
@@ -1,535 +0,0 @@
-# -*- coding: utf-8 -*-
-import copy
-import datetime
-
-import dateutil
-
-import arrow
-
-try:
- # python 2.x
- from mock import patch, call
-except ImportError:
- # python 3.x
- from unittest.mock import patch, call # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.git import GitContext, GitCommit, LocalGitCommit, StagedLocalGitCommit, GitCommitMessage
-
-
-class GitCommitTests(BaseTestCase):
-
- # Expected special_args passed to 'sh'
- expected_sh_special_args = {
- '_tty_out': False,
- '_cwd': u"fåke/path"
- }
-
- @patch('gitlint.git.sh')
- def test_get_latest_commit(self, sh):
- sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
-
- sh.git.side_effect = [
- sample_sha,
- u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"cömmit-title\n\ncömmit-body",
- u"#", # git config --get core.commentchar
- u"file1.txt\npåth/to/file2.txt\n",
- u"foöbar\n* hürdur\n"
- ]
-
- context = GitContext.from_local_repository(u"fåke/path")
- # assert that commit info was read using git command
- expected_calls = [
- call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
- call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
- call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
- call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
- **self.expected_sh_special_args),
- call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
- ]
-
- # Only first 'git log' call should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:1])
-
- last_commit = context.commits[-1]
- self.assertIsInstance(last_commit, LocalGitCommit)
- self.assertEqual(last_commit.sha, sample_sha)
- self.assertEqual(last_commit.message.title, u"cömmit-title")
- self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
- self.assertEqual(last_commit.author_name, u"test åuthor")
- self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
- self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
- tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
- self.assertListEqual(last_commit.parents, [u"åbc"])
- self.assertFalse(last_commit.is_merge_commit)
- self.assertFalse(last_commit.is_fixup_commit)
- self.assertFalse(last_commit.is_squash_commit)
- self.assertFalse(last_commit.is_revert_commit)
-
- # First 2 'git log' calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
-
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
- # 'git diff-tree' should have happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
-
- self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
- # All expected calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls)
-
- @patch('gitlint.git.sh')
- def test_from_local_repository_specific_ref(self, sh):
- sample_sha = "myspecialref"
-
- sh.git.side_effect = [
- sample_sha,
- u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"cömmit-title\n\ncömmit-body",
- u"#", # git config --get core.commentchar
- u"file1.txt\npåth/to/file2.txt\n",
- u"foöbar\n* hürdur\n"
- ]
-
- context = GitContext.from_local_repository(u"fåke/path", sample_sha)
- # assert that commit info was read using git command
- expected_calls = [
- call("rev-list", sample_sha, **self.expected_sh_special_args),
- call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
- call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
- call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
- **self.expected_sh_special_args),
- call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
- ]
-
- # Only first 'git log' call should've happened at this point
- self.assertEqual(sh.git.mock_calls, expected_calls[:1])
-
- last_commit = context.commits[-1]
- self.assertIsInstance(last_commit, LocalGitCommit)
- self.assertEqual(last_commit.sha, sample_sha)
- self.assertEqual(last_commit.message.title, u"cömmit-title")
- self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
- self.assertEqual(last_commit.author_name, u"test åuthor")
- self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
- self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
- tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
- self.assertListEqual(last_commit.parents, [u"åbc"])
- self.assertFalse(last_commit.is_merge_commit)
- self.assertFalse(last_commit.is_fixup_commit)
- self.assertFalse(last_commit.is_squash_commit)
- self.assertFalse(last_commit.is_revert_commit)
-
- # First 2 'git log' calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
-
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
- # 'git diff-tree' should have happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
-
- self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
- # All expected calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls)
-
- @patch('gitlint.git.sh')
- def test_get_latest_commit_merge_commit(self, sh):
- sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
-
- sh.git.side_effect = [
- sample_sha,
- u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\n"
- u"Merge \"foo bår commit\"",
- u"#", # git config --get core.commentchar
- u"file1.txt\npåth/to/file2.txt\n",
- u"foöbar\n* hürdur\n"
- ]
-
- context = GitContext.from_local_repository(u"fåke/path")
- # assert that commit info was read using git command
- expected_calls = [
- call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
- call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
- call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
- call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
- **self.expected_sh_special_args),
- call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
- ]
-
- # Only first 'git log' call should've happened at this point
- self.assertEqual(sh.git.mock_calls, expected_calls[:1])
-
- last_commit = context.commits[-1]
- self.assertIsInstance(last_commit, LocalGitCommit)
- self.assertEqual(last_commit.sha, sample_sha)
- self.assertEqual(last_commit.message.title, u"Merge \"foo bår commit\"")
- self.assertEqual(last_commit.message.body, [])
- self.assertEqual(last_commit.author_name, u"test åuthor")
- self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
- self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
- tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
- self.assertListEqual(last_commit.parents, [u"åbc", "def"])
- self.assertTrue(last_commit.is_merge_commit)
- self.assertFalse(last_commit.is_fixup_commit)
- self.assertFalse(last_commit.is_squash_commit)
- self.assertFalse(last_commit.is_revert_commit)
-
- # First 2 'git log' calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
-
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
- # 'git diff-tree' should have happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
-
- self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
- # All expected calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls)
-
- @patch('gitlint.git.sh')
- def test_get_latest_commit_fixup_squash_commit(self, sh):
- commit_types = ["fixup", "squash"]
- for commit_type in commit_types:
- sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
-
- sh.git.side_effect = [
- sample_sha,
- u"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
- u"{0}! \"foo bår commit\"".format(commit_type),
- u"#", # git config --get core.commentchar
- u"file1.txt\npåth/to/file2.txt\n",
- u"foöbar\n* hürdur\n"
- ]
-
- context = GitContext.from_local_repository(u"fåke/path")
- # assert that commit info was read using git command
- expected_calls = [
- call("log", "-1", "--pretty=%H", **self.expected_sh_special_args),
- call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
- call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
- call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
- **self.expected_sh_special_args),
- call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
- ]
-
- # Only first 'git log' call should've happened at this point
- self.assertEqual(sh.git.mock_calls, expected_calls[:-4])
-
- last_commit = context.commits[-1]
- self.assertIsInstance(last_commit, LocalGitCommit)
- self.assertEqual(last_commit.sha, sample_sha)
- self.assertEqual(last_commit.message.title, u"{0}! \"foo bår commit\"".format(commit_type))
- self.assertEqual(last_commit.message.body, [])
- self.assertEqual(last_commit.author_name, u"test åuthor")
- self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
- self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
- tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
- self.assertListEqual(last_commit.parents, [u"åbc"])
-
- # First 2 'git log' calls should've happened at this point
- self.assertEqual(sh.git.mock_calls, expected_calls[:3])
-
- # Asserting that squash and fixup are correct
- for type in commit_types:
- attr = "is_" + type + "_commit"
- self.assertEqual(getattr(last_commit, attr), commit_type == type)
-
- self.assertFalse(last_commit.is_merge_commit)
- self.assertFalse(last_commit.is_revert_commit)
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
-
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
- # 'git diff-tree' should have happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
-
- self.assertListEqual(last_commit.branches, [u"foöbar", u"hürdur"])
- # All expected calls should've happened at this point
- self.assertListEqual(sh.git.mock_calls, expected_calls)
-
- sh.git.reset_mock()
-
- @patch("gitlint.git.git_commentchar")
- def test_from_commit_msg_full(self, commentchar):
- commentchar.return_value = u"#"
- gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample1"))
-
- expected_title = u"Commit title contåining 'WIP', as well as trailing punctuation."
- expected_body = ["This line should be empty",
- "This is the first line of the commit message body and it is meant to test a " +
- "line that exceeds the maximum line length of 80 characters.",
- u"This line has a tråiling space. ",
- "This line has a trailing tab.\t"]
- expected_full = expected_title + "\n" + "\n".join(expected_body)
- expected_original = expected_full + (
- u"\n# This is a cömmented line\n"
- u"# ------------------------ >8 ------------------------\n"
- u"# Anything after this line should be cleaned up\n"
- u"# this line appears on `git commit -v` command\n"
- u"diff --git a/gitlint/tests/samples/commit_message/sample1 "
- u"b/gitlint/tests/samples/commit_message/sample1\n"
- u"index 82dbe7f..ae71a14 100644\n"
- u"--- a/gitlint/tests/samples/commit_message/sample1\n"
- u"+++ b/gitlint/tests/samples/commit_message/sample1\n"
- u"@@ -1 +1 @@\n"
- )
-
- commit = gitcontext.commits[-1]
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, expected_title)
- self.assertEqual(commit.message.body, expected_body)
- self.assertEqual(commit.message.full, expected_full)
- self.assertEqual(commit.message.original, expected_original)
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertFalse(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- def test_from_commit_msg_just_title(self):
- gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample2"))
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, u"Just a title contåining WIP")
- self.assertEqual(commit.message.body, [])
- self.assertEqual(commit.message.full, u"Just a title contåining WIP")
- self.assertEqual(commit.message.original, u"Just a title contåining WIP")
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertFalse(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- def test_from_commit_msg_empty(self):
- gitcontext = GitContext.from_commit_msg("")
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, "")
- self.assertEqual(commit.message.body, [])
- self.assertEqual(commit.message.full, "")
- self.assertEqual(commit.message.original, "")
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertFalse(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- @patch("gitlint.git.git_commentchar")
- def test_from_commit_msg_comment(self, commentchar):
- commentchar.return_value = u"#"
- gitcontext = GitContext.from_commit_msg(u"Tïtle\n\nBödy 1\n#Cömment\nBody 2")
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, u"Tïtle")
- self.assertEqual(commit.message.body, ["", u"Bödy 1", "Body 2"])
- self.assertEqual(commit.message.full, u"Tïtle\n\nBödy 1\nBody 2")
- self.assertEqual(commit.message.original, u"Tïtle\n\nBödy 1\n#Cömment\nBody 2")
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertFalse(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- def test_from_commit_msg_merge_commit(self):
- commit_msg = "Merge f919b8f34898d9b48048bcd703bc47139f4ff621 into 8b0409a26da6ba8a47c1fd2e746872a8dab15401"
- gitcontext = GitContext.from_commit_msg(commit_msg)
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, commit_msg)
- self.assertEqual(commit.message.body, [])
- self.assertEqual(commit.message.full, commit_msg)
- self.assertEqual(commit.message.original, commit_msg)
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertTrue(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertFalse(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- def test_from_commit_msg_revert_commit(self):
- commit_msg = "Revert \"Prev commit message\"\n\nThis reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."
- gitcontext = GitContext.from_commit_msg(commit_msg)
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, "Revert \"Prev commit message\"")
- self.assertEqual(commit.message.body, ["", "This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."])
- self.assertEqual(commit.message.full, commit_msg)
- self.assertEqual(commit.message.original, commit_msg)
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_fixup_commit)
- self.assertFalse(commit.is_squash_commit)
- self.assertTrue(commit.is_revert_commit)
- self.assertEqual(len(gitcontext.commits), 1)
-
- def test_from_commit_msg_fixup_squash_commit(self):
- commit_types = ["fixup", "squash"]
- for commit_type in commit_types:
- commit_msg = "{0}! Test message".format(commit_type)
- gitcontext = GitContext.from_commit_msg(commit_msg)
- commit = gitcontext.commits[-1]
-
- self.assertIsInstance(commit, GitCommit)
- self.assertFalse(isinstance(commit, LocalGitCommit))
- self.assertEqual(commit.message.title, commit_msg)
- self.assertEqual(commit.message.body, [])
- self.assertEqual(commit.message.full, commit_msg)
- self.assertEqual(commit.message.original, commit_msg)
- self.assertEqual(commit.author_name, None)
- self.assertEqual(commit.author_email, None)
- self.assertEqual(commit.date, None)
- self.assertListEqual(commit.parents, [])
- self.assertListEqual(commit.branches, [])
- self.assertEqual(len(gitcontext.commits), 1)
- self.assertFalse(commit.is_merge_commit)
- self.assertFalse(commit.is_revert_commit)
- # Asserting that squash and fixup are correct
- for type in commit_types:
- attr = "is_" + type + "_commit"
- self.assertEqual(getattr(commit, attr), commit_type == type)
-
- @patch('gitlint.git.sh')
- @patch('arrow.now')
- def test_staged_commit(self, now, sh):
- # StagedLocalGitCommit()
-
- sh.git.side_effect = [
- u"#", # git config --get core.commentchar
- u"test åuthor\n", # git config --get user.name
- u"test-emåil@foo.com\n", # git config --get user.email
- u"my-brånch\n", # git rev-parse --abbrev-ref HEAD
- u"file1.txt\npåth/to/file2.txt\n",
- ]
- now.side_effect = [arrow.get("2020-02-19T12:18:46.675182+01:00")]
-
- # We use a fixup commit, just to test a non-default path
- context = GitContext.from_staged_commit(u"fixup! Foōbar 123\n\ncömmit-body\n", u"fåke/path")
-
- # git calls we're expexting
- expected_calls = [
- call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
- call('config', '--get', 'user.name', **self.expected_sh_special_args),
- call('config', '--get', 'user.email', **self.expected_sh_special_args),
- call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args),
- call("diff", "--staged", "--name-only", "-r", **self.expected_sh_special_args)
- ]
-
- last_commit = context.commits[-1]
- self.assertIsInstance(last_commit, StagedLocalGitCommit)
- self.assertIsNone(last_commit.sha, None)
- self.assertEqual(last_commit.message.title, u"fixup! Foōbar 123")
- self.assertEqual(last_commit.message.body, ["", u"cömmit-body"])
- # Only `git config --get core.commentchar` should've happened up until this point
- self.assertListEqual(sh.git.mock_calls, expected_calls[0:1])
-
- self.assertEqual(last_commit.author_name, u"test åuthor")
- self.assertListEqual(sh.git.mock_calls, expected_calls[0:2])
-
- self.assertEqual(last_commit.author_email, u"test-emåil@foo.com")
- self.assertListEqual(sh.git.mock_calls, expected_calls[0:3])
-
- self.assertEqual(last_commit.date, datetime.datetime(2020, 2, 19, 12, 18, 46,
- tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
- now.assert_called_once()
-
- self.assertListEqual(last_commit.parents, [])
- self.assertFalse(last_commit.is_merge_commit)
- self.assertTrue(last_commit.is_fixup_commit)
- self.assertFalse(last_commit.is_squash_commit)
- self.assertFalse(last_commit.is_revert_commit)
-
- self.assertListEqual(last_commit.branches, [u"my-brånch"])
- self.assertListEqual(sh.git.mock_calls, expected_calls[0:4])
-
- self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
- self.assertListEqual(sh.git.mock_calls, expected_calls[0:5])
-
- def test_gitcommitmessage_equality(self):
- commit_message1 = GitCommitMessage(GitContext(), u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
- attrs = ['original', 'full', 'title', 'body']
- self.object_equality_test(commit_message1, attrs, {"context": commit_message1.context})
-
- def test_gitcommit_equality(self):
- # Test simple equality case
- now = datetime.datetime.utcnow()
- context1 = GitContext()
- commit_message1 = GitCommitMessage(context1, u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
- commit1 = GitCommit(context1, commit_message1, u"shä", now, u"Jöhn Smith", u"jöhn.smith@test.com", None,
- [u"föo/bar"], [u"brånch1", u"brånch2"])
- context1.commits = [commit1]
-
- context2 = GitContext()
- commit_message2 = GitCommitMessage(context2, u"tëst\n\nfoo", u"tëst\n\nfoo", u"tēst", ["", u"föo"])
- commit2 = GitCommit(context2, commit_message1, u"shä", now, u"Jöhn Smith", u"jöhn.smith@test.com", None,
- [u"föo/bar"], [u"brånch1", u"brånch2"])
- context2.commits = [commit2]
-
- self.assertEqual(context1, context2)
- self.assertEqual(commit_message1, commit_message2)
- self.assertEqual(commit1, commit2)
-
- # Check that objects are unequal when changing a single attribute
- kwargs = {'message': commit1.message, 'sha': commit1.sha, 'date': commit1.date,
- 'author_name': commit1.author_name, 'author_email': commit1.author_email, 'parents': commit1.parents,
- 'changed_files': commit1.changed_files, 'branches': commit1.branches}
-
- self.object_equality_test(commit1, kwargs.keys(), {"context": commit1.context})
-
- # Check that the is_* attributes that are affected by the commit message affect equality
- special_messages = {'is_merge_commit': u"Merge: foöbar", 'is_fixup_commit': u"fixup! foöbar",
- 'is_squash_commit': u"squash! foöbar", 'is_revert_commit': u"Revert: foöbar"}
- for key in special_messages:
- kwargs_copy = copy.deepcopy(kwargs)
- clone1 = GitCommit(context=commit1.context, **kwargs_copy)
- clone1.message = GitCommitMessage.from_full_message(context1, special_messages[key])
- self.assertTrue(getattr(clone1, key))
-
- clone2 = GitCommit(context=commit1.context, **kwargs_copy)
- clone2.message = GitCommitMessage.from_full_message(context1, u"foöbar")
- self.assertNotEqual(clone1, clone2)
-
- @patch("gitlint.git.git_commentchar")
- def test_commit_msg_custom_commentchar(self, patched):
- patched.return_value = u"ä"
- context = GitContext()
- message = GitCommitMessage.from_full_message(context, u"Tïtle\n\nBödy 1\näCömment\nBody 2")
-
- self.assertEqual(message.title, u"Tïtle")
- self.assertEqual(message.body, ["", u"Bödy 1", "Body 2"])
- self.assertEqual(message.full, u"Tïtle\n\nBödy 1\nBody 2")
- self.assertEqual(message.original, u"Tïtle\n\nBödy 1\näCömment\nBody 2")
diff --git a/gitlint/tests/git/test_git_context.py b/gitlint/tests/git/test_git_context.py
deleted file mode 100644
index b243d5e..0000000
--- a/gitlint/tests/git/test_git_context.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# -*- coding: utf-8 -*-
-
-try:
- # python 2.x
- from mock import patch, call
-except ImportError:
- # python 3.x
- from unittest.mock import patch, call # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.git import GitContext
-
-
-class GitContextTests(BaseTestCase):
-
- # Expected special_args passed to 'sh'
- expected_sh_special_args = {
- '_tty_out': False,
- '_cwd': u"fåke/path"
- }
-
- @patch('gitlint.git.sh')
- def test_gitcontext(self, sh):
-
- sh.git.side_effect = [
- u"#", # git config --get core.commentchar
- u"\nfoöbar\n"
- ]
-
- expected_calls = [
- call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
- call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args)
- ]
-
- context = GitContext(u"fåke/path")
- self.assertEqual(sh.git.mock_calls, [])
-
- # gitcontext.comment_branch
- self.assertEqual(context.commentchar, u"#")
- self.assertEqual(sh.git.mock_calls, expected_calls[0:1])
-
- # gitcontext.current_branch
- self.assertEqual(context.current_branch, u"foöbar")
- self.assertEqual(sh.git.mock_calls, expected_calls)
-
- @patch('gitlint.git.sh')
- def test_gitcontext_equality(self, sh):
-
- sh.git.side_effect = [
- u"û\n", # context1: git config --get core.commentchar
- u"û\n", # context2: git config --get core.commentchar
- u"my-brånch\n", # context1: git rev-parse --abbrev-ref HEAD
- u"my-brånch\n", # context2: git rev-parse --abbrev-ref HEAD
- ]
-
- context1 = GitContext(u"fåke/path")
- context1.commits = [u"fōo", u"bår"] # we don't need real commits to check for equality
-
- context2 = GitContext(u"fåke/path")
- context2.commits = [u"fōo", u"bår"]
- self.assertEqual(context1, context2)
-
- # INEQUALITY
- # Different commits
- context2.commits = [u"hür", u"dür"]
- self.assertNotEqual(context1, context2)
-
- # Different repository_path
- context2.commits = context1.commits
- context2.repository_path = u"ōther/path"
- self.assertNotEqual(context1, context2)
-
- # Different comment_char
- context3 = GitContext(u"fåke/path")
- context3.commits = [u"fōo", u"bår"]
- sh.git.side_effect = ([
- u"ç\n", # context3: git config --get core.commentchar
- u"my-brånch\n" # context3: git rev-parse --abbrev-ref HEAD
- ])
- self.assertNotEqual(context1, context3)
-
- # Different current_branch
- context4 = GitContext(u"fåke/path")
- context4.commits = [u"fōo", u"bår"]
- sh.git.side_effect = ([
- u"û\n", # context4: git config --get core.commentchar
- u"different-brånch\n" # context4: git rev-parse --abbrev-ref HEAD
- ])
- self.assertNotEqual(context1, context4)
diff --git a/gitlint/tests/rules/test_body_rules.py b/gitlint/tests/rules/test_body_rules.py
deleted file mode 100644
index fcb1b30..0000000
--- a/gitlint/tests/rules/test_body_rules.py
+++ /dev/null
@@ -1,180 +0,0 @@
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint import rules
-
-
-class BodyRuleTests(BaseTestCase):
- def test_max_line_length(self):
- rule = rules.BodyMaxLineLength()
-
- # assert no error
- violation = rule.validate(u"å" * 80, None)
- self.assertIsNone(violation)
-
- # assert error on line length > 80
- expected_violation = rules.RuleViolation("B1", "Line exceeds max length (81>80)", u"å" * 81)
- violations = rule.validate(u"å" * 81, None)
- self.assertListEqual(violations, [expected_violation])
-
- # set line length to 120, and check no violation on length 73
- rule = rules.BodyMaxLineLength({'line-length': 120})
- violations = rule.validate(u"å" * 73, None)
- self.assertIsNone(violations)
-
- # assert raise on 121
- expected_violation = rules.RuleViolation("B1", "Line exceeds max length (121>120)", u"å" * 121)
- violations = rule.validate(u"å" * 121, None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_trailing_whitespace(self):
- rule = rules.BodyTrailingWhitespace()
-
- # assert no error
- violations = rule.validate(u"å", None)
- self.assertIsNone(violations)
-
- # trailing space
- expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", u"å ")
- violations = rule.validate(u"å ", None)
- self.assertListEqual(violations, [expected_violation])
-
- # trailing tab
- expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", u"å\t")
- violations = rule.validate(u"å\t", None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_hard_tabs(self):
- rule = rules.BodyHardTab()
-
- # assert no error
- violations = rule.validate(u"This is ã test", None)
- self.assertIsNone(violations)
-
- # contains hard tab
- expected_violation = rules.RuleViolation("B3", "Line contains hard tab characters (\\t)", u"This is å\ttest")
- violations = rule.validate(u"This is å\ttest", None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_body_first_line_empty(self):
- rule = rules.BodyFirstLineEmpty()
-
- # assert no error
- commit = self.gitcommit(u"Tïtle\n\nThis is the secōnd body line")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # second line not empty
- expected_violation = rules.RuleViolation("B4", "Second line is not empty", u"nöt empty", 2)
-
- commit = self.gitcommit(u"Tïtle\nnöt empty\nThis is the secönd body line")
- violations = rule.validate(commit)
- self.assertListEqual(violations, [expected_violation])
-
- def test_body_min_length(self):
- rule = rules.BodyMinLength()
-
- # assert no error - body is long enough
- commit = self.gitcommit("Title\n\nThis is the second body line\n")
-
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert no error - no body
- commit = self.gitcommit(u"Tïtle\n")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # body is too short
- expected_violation = rules.RuleViolation("B5", "Body message is too short (8<20)", u"töoshort", 3)
-
- commit = self.gitcommit(u"Tïtle\n\ntöoshort\n")
- violations = rule.validate(commit)
- self.assertListEqual(violations, [expected_violation])
-
- # assert error - short across multiple lines
- expected_violation = rules.RuleViolation("B5", "Body message is too short (11<20)", u"secöndthïrd", 3)
- commit = self.gitcommit(u"Tïtle\n\nsecönd\nthïrd\n")
- violations = rule.validate(commit)
- self.assertListEqual(violations, [expected_violation])
-
- # set line length to 120, and check violation on length 21
- expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", u"å" * 21, 3)
-
- rule = rules.BodyMinLength({'min-length': 120})
- commit = self.gitcommit(u"Title\n\n%s\n" % (u"å" * 21))
- violations = rule.validate(commit)
- self.assertListEqual(violations, [expected_violation])
-
- # Make sure we don't get the error if the body-length is exactly the min-length
- rule = rules.BodyMinLength({'min-length': 8})
- commit = self.gitcommit(u"Tïtle\n\n%s\n" % (u"å" * 8))
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- def test_body_missing(self):
- rule = rules.BodyMissing()
-
- # assert no error - body is present
- commit = self.gitcommit(u"Tïtle\n\nThis ïs the first body line\n")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # body is too short
- expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
-
- commit = self.gitcommit(u"Tïtle\n")
- violations = rule.validate(commit)
- self.assertListEqual(violations, [expected_violation])
-
- def test_body_missing_merge_commit(self):
- rule = rules.BodyMissing()
-
- # assert no error - merge commit
- commit = self.gitcommit(u"Merge: Tïtle\n")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert error for merge commits if ignore-merge-commits is disabled
- rule = rules.BodyMissing({'ignore-merge-commits': False})
- violations = rule.validate(commit)
- expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
- self.assertListEqual(violations, [expected_violation])
-
- def test_body_changed_file_mention(self):
- rule = rules.BodyChangedFileMention()
-
- # assert no error when no files have changed and no files need to be mentioned
- commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert no error when no files have changed but certain files need to be mentioned on change
- rule = rules.BodyChangedFileMention({'files': u"bar.txt,föo/test.py"})
- commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert no error if a file has changed and is mentioned
- commit = self.gitcommit(u"This is a test\n\nHere is a mention of föo/test.py", [u"föo/test.py"])
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert no error if multiple files have changed and are mentioned
- commit_msg = u"This is a test\n\nHere is a mention of föo/test.py\nAnd here is a mention of bar.txt"
- commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # assert error if file has changed and is not mentioned
- commit_msg = u"This is a test\n\nHere is å mention of\nAnd here is a mention of bar.txt"
- commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
- violations = rule.validate(commit)
- expected_violation = rules.RuleViolation("B7", u"Body does not mention changed file 'föo/test.py'", None, 4)
- self.assertEqual([expected_violation], violations)
-
- # assert multiple errors if multiple files habe changed and are not mentioned
- commit_msg = u"This is å test\n\nHere is a mention of\nAnd here is a mention of"
- commit = self.gitcommit(commit_msg, [u"föo/test.py", "bar.txt"])
- violations = rule.validate(commit)
- expected_violation_2 = rules.RuleViolation("B7", "Body does not mention changed file 'bar.txt'", None, 4)
- self.assertEqual([expected_violation_2, expected_violation], violations)
diff --git a/gitlint/tests/rules/test_configuration_rules.py b/gitlint/tests/rules/test_configuration_rules.py
deleted file mode 100644
index 73d42f3..0000000
--- a/gitlint/tests/rules/test_configuration_rules.py
+++ /dev/null
@@ -1,71 +0,0 @@
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint import rules
-from gitlint.config import LintConfig
-
-
-class ConfigurationRuleTests(BaseTestCase):
- def test_ignore_by_title(self):
- commit = self.gitcommit(u"Releäse\n\nThis is the secōnd body line")
-
- # No regex specified -> Config shouldn't be changed
- rule = rules.IgnoreByTitle()
- config = LintConfig()
- rule.apply(config, commit)
- self.assertEqual(config, LintConfig())
- self.assert_logged([]) # nothing logged -> nothing ignored
-
- # Matching regex -> expect config to ignore all rules
- rule = rules.IgnoreByTitle({"regex": u"^Releäse(.*)"})
- expected_config = LintConfig()
- expected_config.ignore = "all"
- rule.apply(config, commit)
- self.assertEqual(config, expected_config)
-
- expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
- u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: all"
- self.assert_log_contains(expected_log_message)
-
- # Matching regex with specific ignore
- rule = rules.IgnoreByTitle({"regex": u"^Releäse(.*)",
- "ignore": "T1,B2"})
- expected_config = LintConfig()
- expected_config.ignore = "T1,B2"
- rule.apply(config, commit)
- self.assertEqual(config, expected_config)
-
- expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
- u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: T1,B2"
-
- def test_ignore_by_body(self):
- commit = self.gitcommit(u"Tïtle\n\nThis is\n a relëase body\n line")
-
- # No regex specified -> Config shouldn't be changed
- rule = rules.IgnoreByBody()
- config = LintConfig()
- rule.apply(config, commit)
- self.assertEqual(config, LintConfig())
- self.assert_logged([]) # nothing logged -> nothing ignored
-
- # Matching regex -> expect config to ignore all rules
- rule = rules.IgnoreByBody({"regex": u"(.*)relëase(.*)"})
- expected_config = LintConfig()
- expected_config.ignore = "all"
- rule.apply(config, commit)
- self.assertEqual(config, expected_config)
-
- expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I2': " + \
- u"Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)'," + \
- u" ignoring rules: all"
- self.assert_log_contains(expected_log_message)
-
- # Matching regex with specific ignore
- rule = rules.IgnoreByBody({"regex": u"(.*)relëase(.*)",
- "ignore": "T1,B2"})
- expected_config = LintConfig()
- expected_config.ignore = "T1,B2"
- rule.apply(config, commit)
- self.assertEqual(config, expected_config)
-
- expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
- u"Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)', ignoring rules: T1,B2"
diff --git a/gitlint/tests/rules/test_meta_rules.py b/gitlint/tests/rules/test_meta_rules.py
deleted file mode 100644
index c94b8b3..0000000
--- a/gitlint/tests/rules/test_meta_rules.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rules import AuthorValidEmail, RuleViolation
-
-
-class MetaRuleTests(BaseTestCase):
- def test_author_valid_email_rule(self):
- rule = AuthorValidEmail()
-
- # valid email addresses
- valid_email_addresses = [u"föo@bar.com", u"Jöhn.Doe@bar.com", u"jöhn+doe@bar.com", u"jöhn/doe@bar.com",
- u"jöhn.doe@subdomain.bar.com"]
- for email in valid_email_addresses:
- commit = self.gitcommit(u"", author_email=email)
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # No email address (=allowed for now, as gitlint also lints messages passed via stdin that don't have an
- # email address)
- commit = self.gitcommit(u"")
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # Invalid email addresses: no TLD, no domain, no @, space anywhere (=valid but not allowed by gitlint)
- invalid_email_addresses = [u"föo@bar", u"JöhnDoe", u"Jöhn Doe", u"Jöhn Doe@foo.com", u" JöhnDoe@foo.com",
- u"JöhnDoe@ foo.com", u"JöhnDoe@foo. com", u"JöhnDoe@foo. com", u"@bår.com",
- u"föo@.com"]
- for email in invalid_email_addresses:
- commit = self.gitcommit(u"", author_email=email)
- violations = rule.validate(commit)
- self.assertListEqual(violations,
- [RuleViolation("M1", "Author email for commit is invalid", email)])
-
- def test_author_valid_email_rule_custom_regex(self):
- # Custom domain
- rule = AuthorValidEmail({'regex': u"[^@]+@bår.com"})
- valid_email_addresses = [
- u"föo@bår.com", u"Jöhn.Doe@bår.com", u"jöhn+doe@bår.com", u"jöhn/doe@bår.com"]
- for email in valid_email_addresses:
- commit = self.gitcommit(u"", author_email=email)
- violations = rule.validate(commit)
- self.assertIsNone(violations)
-
- # Invalid email addresses
- invalid_email_addresses = [u"föo@hur.com"]
- for email in invalid_email_addresses:
- commit = self.gitcommit(u"", author_email=email)
- violations = rule.validate(commit)
- self.assertListEqual(violations,
- [RuleViolation("M1", "Author email for commit is invalid", email)])
diff --git a/gitlint/tests/rules/test_rules.py b/gitlint/tests/rules/test_rules.py
deleted file mode 100644
index 89caa27..0000000
--- a/gitlint/tests/rules/test_rules.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rules import Rule, RuleViolation
-
-
-class RuleTests(BaseTestCase):
-
- def test_rule_equality(self):
- self.assertEqual(Rule(), Rule())
- # Ensure rules are not equal if they differ on their attributes
- for attr in ["id", "name", "target", "options"]:
- rule = Rule()
- setattr(rule, attr, u"åbc")
- self.assertNotEqual(Rule(), rule)
-
- def test_rule_violation_equality(self):
- violation1 = RuleViolation(u"ïd1", u"My messåge", u"My cöntent", 1)
- self.object_equality_test(violation1, ["rule_id", "message", "content", "line_nr"])
diff --git a/gitlint/tests/rules/test_title_rules.py b/gitlint/tests/rules/test_title_rules.py
deleted file mode 100644
index 07d2323..0000000
--- a/gitlint/tests/rules/test_title_rules.py
+++ /dev/null
@@ -1,154 +0,0 @@
-# -*- coding: utf-8 -*-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rules import TitleMaxLength, TitleTrailingWhitespace, TitleHardTab, TitleMustNotContainWord, \
- TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation
-
-
-class TitleRuleTests(BaseTestCase):
- def test_max_line_length(self):
- rule = TitleMaxLength()
-
- # assert no error
- violation = rule.validate(u"å" * 72, None)
- self.assertIsNone(violation)
-
- # assert error on line length > 72
- expected_violation = RuleViolation("T1", "Title exceeds max length (73>72)", u"å" * 73)
- violations = rule.validate(u"å" * 73, None)
- self.assertListEqual(violations, [expected_violation])
-
- # set line length to 120, and check no violation on length 73
- rule = TitleMaxLength({'line-length': 120})
- violations = rule.validate(u"å" * 73, None)
- self.assertIsNone(violations)
-
- # assert raise on 121
- expected_violation = RuleViolation("T1", "Title exceeds max length (121>120)", u"å" * 121)
- violations = rule.validate(u"å" * 121, None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_trailing_whitespace(self):
- rule = TitleTrailingWhitespace()
-
- # assert no error
- violations = rule.validate(u"å", None)
- self.assertIsNone(violations)
-
- # trailing space
- expected_violation = RuleViolation("T2", "Title has trailing whitespace", u"å ")
- violations = rule.validate(u"å ", None)
- self.assertListEqual(violations, [expected_violation])
-
- # trailing tab
- expected_violation = RuleViolation("T2", "Title has trailing whitespace", u"å\t")
- violations = rule.validate(u"å\t", None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_hard_tabs(self):
- rule = TitleHardTab()
-
- # assert no error
- violations = rule.validate(u"This is å test", None)
- self.assertIsNone(violations)
-
- # contains hard tab
- expected_violation = RuleViolation("T4", "Title contains hard tab characters (\\t)", u"This is å\ttest")
- violations = rule.validate(u"This is å\ttest", None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_trailing_punctuation(self):
- rule = TitleTrailingPunctuation()
-
- # assert no error
- violations = rule.validate(u"This is å test", None)
- self.assertIsNone(violations)
-
- # assert errors for different punctuations
- punctuation = u"?:!.,;"
- for char in punctuation:
- line = u"This is å test" + char # note that make sure to include some unicode!
- gitcontext = self.gitcontext(line)
- expected_violation = RuleViolation("T3", u"Title has trailing punctuation ({0})".format(char), line)
- violations = rule.validate(line, gitcontext)
- self.assertListEqual(violations, [expected_violation])
-
- def test_title_must_not_contain_word(self):
- rule = TitleMustNotContainWord()
-
- # no violations
- violations = rule.validate(u"This is å test", None)
- self.assertIsNone(violations)
-
- # no violation if WIP occurs inside a wor
- violations = rule.validate(u"This is å wiping test", None)
- self.assertIsNone(violations)
-
- # match literally
- violations = rule.validate(u"WIP This is å test", None)
- expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"WIP This is å test")
- self.assertListEqual(violations, [expected_violation])
-
- # match case insensitive
- violations = rule.validate(u"wip This is å test", None)
- expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"wip This is å test")
- self.assertListEqual(violations, [expected_violation])
-
- # match if there is a colon after the word
- violations = rule.validate(u"WIP:This is å test", None)
- expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"WIP:This is å test")
- self.assertListEqual(violations, [expected_violation])
-
- # match multiple words
- rule = TitleMustNotContainWord({'words': u"wip,test,å"})
- violations = rule.validate(u"WIP:This is å test", None)
- expected_violation = RuleViolation("T5", "Title contains the word 'wip' (case-insensitive)",
- u"WIP:This is å test")
- expected_violation2 = RuleViolation("T5", "Title contains the word 'test' (case-insensitive)",
- u"WIP:This is å test")
- expected_violation3 = RuleViolation("T5", u"Title contains the word 'å' (case-insensitive)",
- u"WIP:This is å test")
- self.assertListEqual(violations, [expected_violation, expected_violation2, expected_violation3])
-
- def test_leading_whitespace(self):
- rule = TitleLeadingWhitespace()
-
- # assert no error
- violations = rule.validate("a", None)
- self.assertIsNone(violations)
-
- # leading space
- expected_violation = RuleViolation("T6", "Title has leading whitespace", " a")
- violations = rule.validate(" a", None)
- self.assertListEqual(violations, [expected_violation])
-
- # leading tab
- expected_violation = RuleViolation("T6", "Title has leading whitespace", "\ta")
- violations = rule.validate("\ta", None)
- self.assertListEqual(violations, [expected_violation])
-
- # unicode test
- expected_violation = RuleViolation("T6", "Title has leading whitespace", u" ☺")
- violations = rule.validate(u" ☺", None)
- self.assertListEqual(violations, [expected_violation])
-
- def test_regex_matches(self):
- commit = self.gitcommit(u"US1234: åbc\n")
-
- # assert no violation on default regex (=everything allowed)
- rule = TitleRegexMatches()
- violations = rule.validate(commit.message.title, commit)
- self.assertIsNone(violations)
-
- # assert no violation on matching regex
- rule = TitleRegexMatches({'regex': u"^US[0-9]*: å"})
- violations = rule.validate(commit.message.title, commit)
- self.assertIsNone(violations)
-
- # assert violation when no matching regex
- rule = TitleRegexMatches({'regex': u"^UÅ[0-9]*"})
- violations = rule.validate(commit.message.title, commit)
- expected_violation = RuleViolation("T7", u"Title does not match regex (^UÅ[0-9]*)", u"US1234: åbc")
- self.assertListEqual(violations, [expected_violation])
diff --git a/gitlint/tests/rules/test_user_rules.py b/gitlint/tests/rules/test_user_rules.py
deleted file mode 100644
index 57c03a0..0000000
--- a/gitlint/tests/rules/test_user_rules.py
+++ /dev/null
@@ -1,223 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import os
-import sys
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.rule_finder import find_rule_classes, assert_valid_rule_class
-from gitlint.rules import UserRuleError
-from gitlint.utils import ustr
-
-from gitlint import options, rules
-
-
-class UserRuleTests(BaseTestCase):
- def test_find_rule_classes(self):
- # Let's find some user classes!
- user_rule_path = self.get_sample_path("user_rules")
- classes = find_rule_classes(user_rule_path)
-
- # Compare string representations because we can't import MyUserCommitRule here since samples/user_rules is not
- # a proper python package
- # Note that the following check effectively asserts that:
- # - There is only 1 rule recognized and it is MyUserCommitRule
- # - Other non-python files in the directory are ignored
- # - Other members of the my_commit_rules module are ignored
- # (such as func_should_be_ignored, global_variable_should_be_ignored)
- # - Rules are loaded non-recursively (user_rules/import_exception directory is ignored)
- self.assertEqual("[<class 'my_commit_rules.MyUserCommitRule'>]", ustr(classes))
-
- # Assert that we added the new user_rules directory to the system path and modules
- self.assertIn(user_rule_path, sys.path)
- self.assertIn("my_commit_rules", sys.modules)
-
- # Do some basic asserts on our user rule
- self.assertEqual(classes[0].id, "UC1")
- self.assertEqual(classes[0].name, u"my-üser-commit-rule")
- expected_option = options.IntOption('violation-count', 1, u"Number of violåtions to return")
- self.assertListEqual(classes[0].options_spec, [expected_option])
- self.assertTrue(hasattr(classes[0], "validate"))
-
- # Test that we can instantiate the class and can execute run the validate method and that it returns the
- # expected result
- rule_class = classes[0]()
- violations = rule_class.validate("false-commit-object (ignored)")
- self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1)])
-
- # Have it return more violations
- rule_class.options['violation-count'].value = 2
- violations = rule_class.validate("false-commit-object (ignored)")
- self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1),
- rules.RuleViolation("UC1", u"Commit violåtion 2", u"Contënt 2", 2)])
-
- def test_extra_path_specified_by_file(self):
- # Test that find_rule_classes can handle an extra path given as a file name instead of a directory
- user_rule_path = self.get_sample_path("user_rules")
- user_rule_module = os.path.join(user_rule_path, "my_commit_rules.py")
- classes = find_rule_classes(user_rule_module)
-
- rule_class = classes[0]()
- violations = rule_class.validate("false-commit-object (ignored)")
- self.assertListEqual(violations, [rules.RuleViolation("UC1", u"Commit violåtion 1", u"Contënt 1", 1)])
-
- def test_rules_from_init_file(self):
- # Test that we can import rules that are defined in __init__.py files
- # This also tests that we can import rules from python packages. This use to cause issues with pypy
- # So this is also a regression test for that.
- user_rule_path = self.get_sample_path(os.path.join("user_rules", "parent_package"))
- classes = find_rule_classes(user_rule_path)
-
- # convert classes to strings and sort them so we can compare them
- class_strings = sorted([ustr(clazz) for clazz in classes])
- expected = [u"<class 'my_commit_rules.MyUserCommitRule'>", u"<class 'parent_package.InitFileRule'>"]
- self.assertListEqual(class_strings, expected)
-
- def test_empty_user_classes(self):
- # Test that we don't find rules if we scan a different directory
- user_rule_path = self.get_sample_path("config")
- classes = find_rule_classes(user_rule_path)
- self.assertListEqual(classes, [])
-
- # Importantly, ensure that the directory is not added to the syspath as this happens only when we actually
- # find modules
- self.assertNotIn(user_rule_path, sys.path)
-
- def test_failed_module_import(self):
- # test importing a bogus module
- user_rule_path = self.get_sample_path("user_rules/import_exception")
- # We don't check the entire error message because that is different based on the python version and underlying
- # operating system
- expected_msg = "Error while importing extra-path module 'invalid_python'"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- find_rule_classes(user_rule_path)
-
- def test_find_rule_classes_nonexisting_path(self):
- with self.assertRaisesRegex(UserRuleError, u"Invalid extra-path: föo/bar"):
- find_rule_classes(u"föo/bar")
-
- def test_assert_valid_rule_class(self):
- class MyLineRuleClass(rules.LineRule):
- id = 'UC1'
- name = u'my-lïne-rule'
- target = rules.CommitMessageTitle
-
- def validate(self):
- pass
-
- class MyCommitRuleClass(rules.CommitRule):
- id = 'UC2'
- name = u'my-cömmit-rule'
-
- def validate(self):
- pass
-
- # Just assert that no error is raised
- self.assertIsNone(assert_valid_rule_class(MyLineRuleClass))
- self.assertIsNone(assert_valid_rule_class(MyCommitRuleClass))
-
- def test_assert_valid_rule_class_negative(self):
- # general test to make sure that incorrect rules will raise an exception
- user_rule_path = self.get_sample_path("user_rules/incorrect_linerule")
- with self.assertRaisesRegex(UserRuleError,
- "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
- find_rule_classes(user_rule_path)
-
- def test_assert_valid_rule_class_negative_parent(self):
- # rule class must extend from LineRule or CommitRule
- class MyRuleClass(object):
- pass
-
- expected_msg = "User-defined rule class 'MyRuleClass' must extend from gitlint.rules.LineRule " + \
- "or gitlint.rules.CommitRule"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- def test_assert_valid_rule_class_negative_id(self):
- class MyRuleClass(rules.LineRule):
- pass
-
- # Rule class must have an id
- expected_msg = "User-defined rule class 'MyRuleClass' must have an 'id' attribute"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # Rule ids must be non-empty
- MyRuleClass.id = ""
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # Rule ids must not start with one of the reserved id letters
- for letter in ["T", "R", "B", "M"]:
- MyRuleClass.id = letter + "1"
- expected_msg = "The id '{0}' of 'MyRuleClass' is invalid. Gitlint reserves ids starting with R,T,B,M"
- with self.assertRaisesRegex(UserRuleError, expected_msg.format(letter)):
- assert_valid_rule_class(MyRuleClass)
-
- def test_assert_valid_rule_class_negative_name(self):
- class MyRuleClass(rules.LineRule):
- id = "UC1"
-
- # Rule class must have an name
- expected_msg = "User-defined rule class 'MyRuleClass' must have a 'name' attribute"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # Rule names must be non-empty
- MyRuleClass.name = ""
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- def test_assert_valid_rule_class_negative_option_spec(self):
- class MyRuleClass(rules.LineRule):
- id = "UC1"
- name = u"my-rüle-class"
-
- # if set, option_spec must be a list of gitlint options
- MyRuleClass.options_spec = u"föo"
- expected_msg = "The options_spec attribute of user-defined rule class 'MyRuleClass' must be a list " + \
- "of gitlint.options.RuleOption"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # option_spec is a list, but not of gitlint options
- MyRuleClass.options_spec = [u"föo", 123] # pylint: disable=bad-option-value,redefined-variable-type
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- def test_assert_valid_rule_class_negative_validate(self):
- class MyRuleClass(rules.LineRule):
- id = "UC1"
- name = u"my-rüle-class"
-
- with self.assertRaisesRegex(UserRuleError,
- "User-defined rule class 'MyRuleClass' must have a 'validate' method"):
- assert_valid_rule_class(MyRuleClass)
-
- # validate attribute - not a method
- MyRuleClass.validate = u"föo"
- with self.assertRaisesRegex(UserRuleError,
- "User-defined rule class 'MyRuleClass' must have a 'validate' method"):
- assert_valid_rule_class(MyRuleClass)
-
- def test_assert_valid_rule_class_negative_target(self):
- class MyRuleClass(rules.LineRule):
- id = "UC1"
- name = u"my-rüle-class"
-
- def validate(self):
- pass
-
- # no target
- expected_msg = "The target attribute of the user-defined LineRule class 'MyRuleClass' must be either " + \
- "gitlint.rules.CommitMessageTitle or gitlint.rules.CommitMessageBody"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # invalid target
- MyRuleClass.target = u"föo"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
-
- # valid target, no exception should be raised
- MyRuleClass.target = rules.CommitMessageTitle # pylint: disable=bad-option-value,redefined-variable-type
- self.assertIsNone(assert_valid_rule_class(MyRuleClass))
diff --git a/gitlint/tests/test_display.py b/gitlint/tests/test_display.py
deleted file mode 100644
index 1c64b34..0000000
--- a/gitlint/tests/test_display.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# -*- coding: utf-8 -*-
-
-try:
- # python 2.x
- from StringIO import StringIO
-except ImportError:
- # python 3.x
- from io import StringIO
-
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.display import Display
-from gitlint.config import LintConfig
-from gitlint.tests.base import BaseTestCase
-
-
-class DisplayTests(BaseTestCase):
- def test_v(self):
- display = Display(LintConfig())
- display.config.verbosity = 2
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- # Non exact outputting, should output both v and vv output
- with patch('gitlint.display.stdout', new=StringIO()) as stdout:
- display.v(u"tëst")
- display.vv(u"tëst2")
- # vvvv should be ignored regardless
- display.vvv(u"tëst3.1")
- display.vvv(u"tëst3.2", exact=True)
- self.assertEqual(u"tëst\ntëst2\n", stdout.getvalue())
-
- # exact outputting, should only output v
- with patch('gitlint.display.stdout', new=StringIO()) as stdout:
- display.v(u"tëst", exact=True)
- display.vv(u"tëst2", exact=True)
- # vvvv should be ignored regardless
- display.vvv(u"tëst3.1")
- display.vvv(u"tëst3.2", exact=True)
- self.assertEqual(u"tëst2\n", stdout.getvalue())
-
- # standard error should be empty throughtout all of this
- self.assertEqual('', stderr.getvalue())
-
- def test_e(self):
- display = Display(LintConfig())
- display.config.verbosity = 2
-
- with patch('gitlint.display.stdout', new=StringIO()) as stdout:
- # Non exact outputting, should output both v and vv output
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- display.e(u"tëst")
- display.ee(u"tëst2")
- # vvvv should be ignored regardless
- display.eee(u"tëst3.1")
- display.eee(u"tëst3.2", exact=True)
- self.assertEqual(u"tëst\ntëst2\n", stderr.getvalue())
-
- # exact outputting, should only output v
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- display.e(u"tëst", exact=True)
- display.ee(u"tëst2", exact=True)
- # vvvv should be ignored regardless
- display.eee(u"tëst3.1")
- display.eee(u"tëst3.2", exact=True)
- self.assertEqual(u"tëst2\n", stderr.getvalue())
-
- # standard output should be empty throughtout all of this
- self.assertEqual('', stdout.getvalue())
diff --git a/gitlint/tests/test_hooks.py b/gitlint/tests/test_hooks.py
deleted file mode 100644
index 08bd730..0000000
--- a/gitlint/tests/test_hooks.py
+++ /dev/null
@@ -1,136 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import os
-
-try:
- # python 2.x
- from mock import patch, ANY, mock_open
-except ImportError:
- # python 3.x
- from unittest.mock import patch, ANY, mock_open # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.config import LintConfig
-from gitlint.hooks import GitHookInstaller, GitHookInstallerError, COMMIT_MSG_HOOK_SRC_PATH, COMMIT_MSG_HOOK_DST_PATH, \
- GITLINT_HOOK_IDENTIFIER
-
-
-class HookTests(BaseTestCase):
-
- @patch('gitlint.hooks.git_hooks_dir')
- def test_commit_msg_hook_path(self, git_hooks_dir):
- git_hooks_dir.return_value = os.path.join(u"/föo", u"bar")
- lint_config = LintConfig()
- lint_config.target = self.SAMPLES_DIR
- expected_path = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- path = GitHookInstaller.commit_msg_hook_path(lint_config)
-
- git_hooks_dir.assert_called_once_with(self.SAMPLES_DIR)
- self.assertEqual(path, expected_path)
-
- @staticmethod
- @patch('os.chmod')
- @patch('os.stat')
- @patch('gitlint.hooks.shutil.copy')
- @patch('os.path.exists', return_value=False)
- @patch('os.path.isdir', return_value=True)
- @patch('gitlint.hooks.git_hooks_dir')
- def test_install_commit_msg_hook(git_hooks_dir, isdir, path_exists, copy, stat, chmod):
- lint_config = LintConfig()
- lint_config.target = os.path.join(u"/hür", u"dur")
- git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
- expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- GitHookInstaller.install_commit_msg_hook(lint_config)
- isdir.assert_called_with(git_hooks_dir.return_value)
- path_exists.assert_called_once_with(expected_dst)
- copy.assert_called_once_with(COMMIT_MSG_HOOK_SRC_PATH, expected_dst)
- stat.assert_called_once_with(expected_dst)
- chmod.assert_called_once_with(expected_dst, ANY)
- git_hooks_dir.assert_called_with(lint_config.target)
-
- @patch('gitlint.hooks.shutil.copy')
- @patch('os.path.exists', return_value=False)
- @patch('os.path.isdir', return_value=True)
- @patch('gitlint.hooks.git_hooks_dir')
- def test_install_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, copy):
- lint_config = LintConfig()
- lint_config.target = os.path.join(u"/hür", u"dur")
- git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
- # mock that current dir is not a git repo
- isdir.return_value = False
- expected_msg = u"{0} is not a git repository".format(lint_config.target)
- with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
- GitHookInstaller.install_commit_msg_hook(lint_config)
- isdir.assert_called_with(git_hooks_dir.return_value)
- path_exists.assert_not_called()
- copy.assert_not_called()
-
- # mock that there is already a commit hook present
- isdir.return_value = True
- path_exists.return_value = True
- expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- expected_msg = u"There is already a commit-msg hook file present in {0}.\n".format(expected_dst) + \
- "gitlint currently does not support appending to an existing commit-msg file."
- with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
- GitHookInstaller.install_commit_msg_hook(lint_config)
-
- @staticmethod
- @patch('os.remove')
- @patch('os.path.exists', return_value=True)
- @patch('os.path.isdir', return_value=True)
- @patch('gitlint.hooks.git_hooks_dir')
- def test_uninstall_commit_msg_hook(git_hooks_dir, isdir, path_exists, remove):
- lint_config = LintConfig()
- git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
- lint_config.target = os.path.join(u"/hür", u"dur")
- read_data = "#!/bin/sh\n" + GITLINT_HOOK_IDENTIFIER
- with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True):
- GitHookInstaller.uninstall_commit_msg_hook(lint_config)
-
- expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- isdir.assert_called_with(git_hooks_dir.return_value)
- path_exists.assert_called_once_with(expected_dst)
- remove.assert_called_with(expected_dst)
- git_hooks_dir.assert_called_with(lint_config.target)
-
- @patch('os.remove')
- @patch('os.path.exists', return_value=True)
- @patch('os.path.isdir', return_value=True)
- @patch('gitlint.hooks.git_hooks_dir')
- def test_uninstall_commit_msg_hook_negative(self, git_hooks_dir, isdir, path_exists, remove):
- lint_config = LintConfig()
- lint_config.target = os.path.join(u"/hür", u"dur")
- git_hooks_dir.return_value = os.path.join(u"/föo", u"bar", ".git", "hooks")
-
- # mock that the current directory is not a git repo
- isdir.return_value = False
- expected_msg = u"{0} is not a git repository".format(lint_config.target)
- with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
- GitHookInstaller.uninstall_commit_msg_hook(lint_config)
- isdir.assert_called_with(git_hooks_dir.return_value)
- path_exists.assert_not_called()
- remove.assert_not_called()
-
- # mock that there is no commit hook present
- isdir.return_value = True
- path_exists.return_value = False
- expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- expected_msg = u"There is no commit-msg hook present in {0}.".format(expected_dst)
- with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
- GitHookInstaller.uninstall_commit_msg_hook(lint_config)
- isdir.assert_called_with(git_hooks_dir.return_value)
- path_exists.assert_called_once_with(expected_dst)
- remove.assert_not_called()
-
- # mock that there is a different (=not gitlint) commit hook
- isdir.return_value = True
- path_exists.return_value = True
- read_data = "#!/bin/sh\nfoo"
- expected_dst = os.path.join(git_hooks_dir.return_value, COMMIT_MSG_HOOK_DST_PATH)
- expected_msg = u"The commit-msg hook in {0} was not installed by gitlint ".format(expected_dst) + \
- "(or it was modified).\nUninstallation of 3th party or modified gitlint hooks " + \
- "is not supported."
- with patch('gitlint.hooks.io.open', mock_open(read_data=read_data), create=True):
- with self.assertRaisesRegex(GitHookInstallerError, expected_msg):
- GitHookInstaller.uninstall_commit_msg_hook(lint_config)
- remove.assert_not_called()
diff --git a/gitlint/tests/test_lint.py b/gitlint/tests/test_lint.py
deleted file mode 100644
index bcdd984..0000000
--- a/gitlint/tests/test_lint.py
+++ /dev/null
@@ -1,197 +0,0 @@
-# -*- coding: utf-8 -*-
-
-try:
- # python 2.x
- from StringIO import StringIO
-except ImportError:
- # python 3.x
- from io import StringIO
-
-try:
- # python 2.x
- from mock import patch
-except ImportError:
- # python 3.x
- from unittest.mock import patch # pylint: disable=no-name-in-module, import-error
-
-from gitlint.tests.base import BaseTestCase
-from gitlint.lint import GitLinter
-from gitlint.rules import RuleViolation
-from gitlint.config import LintConfig, LintConfigBuilder
-
-
-class LintTests(BaseTestCase):
-
- def test_lint_sample1(self):
- linter = GitLinter(LintConfig())
- gitcontext = self.gitcontext(self.get_sample("commit_message/sample1"))
- violations = linter.lint(gitcontext.commits[-1])
- expected_errors = [RuleViolation("T3", "Title has trailing punctuation (.)",
- u"Commit title contåining 'WIP', as well as trailing punctuation.", 1),
- RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"Commit title contåining 'WIP', as well as trailing punctuation.", 1),
- RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
- RuleViolation("B1", "Line exceeds max length (135>80)",
- "This is the first line of the commit message body and it is meant to test " +
- "a line that exceeds the maximum line length of 80 characters.", 3),
- RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling space. ", 4),
- RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5),
- RuleViolation("B3", "Line contains hard tab characters (\\t)",
- "This line has a trailing tab.\t", 5)]
-
- self.assertListEqual(violations, expected_errors)
-
- def test_lint_sample2(self):
- linter = GitLinter(LintConfig())
- gitcontext = self.gitcontext(self.get_sample("commit_message/sample2"))
- violations = linter.lint(gitcontext.commits[-1])
- expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"Just a title contåining WIP", 1),
- RuleViolation("B6", "Body message is missing", None, 3)]
-
- self.assertListEqual(violations, expected)
-
- def test_lint_sample3(self):
- linter = GitLinter(LintConfig())
- gitcontext = self.gitcontext(self.get_sample("commit_message/sample3"))
- violations = linter.lint(gitcontext.commits[-1])
-
- title = u" Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters."
- expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1),
- RuleViolation("T3", "Title has trailing punctuation (.)", title, 1),
- RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1),
- RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1),
- RuleViolation("T6", "Title has leading whitespace", title, 1),
- RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
- RuleViolation("B1", "Line exceeds max length (101>80)",
- u"This is the first line is meånt to test a line that exceeds the maximum line " +
- "length of 80 characters.", 3),
- RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing space. ", 4),
- RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling tab.\t", 5),
- RuleViolation("B3", "Line contains hard tab characters (\\t)",
- u"This line has a tråiling tab.\t", 5)]
-
- self.assertListEqual(violations, expected)
-
- def test_lint_sample4(self):
- commit = self.gitcommit(self.get_sample("commit_message/sample4"))
- config_builder = LintConfigBuilder()
- config_builder.set_config_from_commit(commit)
- linter = GitLinter(config_builder.build())
- violations = linter.lint(commit)
- # expect no violations because sample4 has a 'gitlint: disable line'
- expected = []
- self.assertListEqual(violations, expected)
-
- def test_lint_sample5(self):
- commit = self.gitcommit(self.get_sample("commit_message/sample5"))
- config_builder = LintConfigBuilder()
- config_builder.set_config_from_commit(commit)
- linter = GitLinter(config_builder.build())
- violations = linter.lint(commit)
-
- title = u" Commit title containing 'WIP', \tleading and tråiling whitespace and longer than 72 characters."
- # expect only certain violations because sample5 has a 'gitlint-ignore: T3, T6, body-max-line-length'
- expected = [RuleViolation("T1", "Title exceeds max length (95>72)", title, 1),
- RuleViolation("T4", "Title contains hard tab characters (\\t)", title, 1),
- RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", title, 1),
- RuleViolation("B4", "Second line is not empty", u"This line should be ëmpty", 2),
- RuleViolation("B2", "Line has trailing whitespace", u"This line has a tråiling space. ", 4),
- RuleViolation("B2", "Line has trailing whitespace", "This line has a trailing tab.\t", 5),
- RuleViolation("B3", "Line contains hard tab characters (\\t)",
- "This line has a trailing tab.\t", 5)]
- self.assertListEqual(violations, expected)
-
- def test_lint_meta(self):
- """ Lint sample2 but also add some metadata to the commit so we that get's linted as well """
- linter = GitLinter(LintConfig())
- gitcontext = self.gitcontext(self.get_sample("commit_message/sample2"))
- gitcontext.commits[0].author_email = u"foo bår"
- violations = linter.lint(gitcontext.commits[-1])
- expected = [RuleViolation("M1", "Author email for commit is invalid", u"foo bår", None),
- RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"Just a title contåining WIP", 1),
- RuleViolation("B6", "Body message is missing", None, 3)]
-
- self.assertListEqual(violations, expected)
-
- def test_lint_ignore(self):
- lint_config = LintConfig()
- lint_config.ignore = ["T1", "T3", "T4", "T5", "T6", "B1", "B2"]
- linter = GitLinter(lint_config)
- violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample3")))
-
- expected = [RuleViolation("B4", "Second line is not empty", "This line should be empty", 2),
- RuleViolation("B3", "Line contains hard tab characters (\\t)",
- u"This line has a tråiling tab.\t", 5)]
-
- self.assertListEqual(violations, expected)
-
- def test_lint_configuration_rule(self):
- # Test that all rules are ignored because of matching regex
- lint_config = LintConfig()
- lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
-
- linter = GitLinter(lint_config)
- violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
- self.assertListEqual(violations, [])
-
- # Test ignoring only certain rules
- lint_config = LintConfig()
- lint_config.set_rule_option("I1", "regex", "^Just a title(.*)")
- lint_config.set_rule_option("I1", "ignore", "B6")
-
- linter = GitLinter(lint_config)
- violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2")))
-
- # Normally we'd expect a B6 violation, but that one is skipped because of the specific ignore set above
- expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
- u"Just a title contåining WIP", 1)]
-
- self.assertListEqual(violations, expected)
-
- def test_lint_special_commit(self):
- for commit_type in ["merge", "revert", "squash", "fixup"]:
- commit = self.gitcommit(self.get_sample("commit_message/{0}".format(commit_type)))
- lintconfig = LintConfig()
- linter = GitLinter(lintconfig)
- violations = linter.lint(commit)
- # Even though there are a number of violations in the commit message, they are ignored because
- # we are dealing with a merge commit
- self.assertListEqual(violations, [])
-
- # Check that we do see violations if we disable 'ignore-merge-commits'
- setattr(lintconfig, "ignore_{0}_commits".format(commit_type), False)
- linter = GitLinter(lintconfig)
- violations = linter.lint(commit)
- self.assertTrue(len(violations) > 0)
-
- def test_print_violations(self):
- violations = [RuleViolation("RULE_ID_1", u"Error Messåge 1", "Violating Content 1", None),
- RuleViolation("RULE_ID_2", "Error Message 2", u"Violåting Content 2", 2)]
- linter = GitLinter(LintConfig())
-
- # test output with increasing verbosity
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- linter.config.verbosity = 0
- linter.print_violations(violations)
- self.assertEqual("", stderr.getvalue())
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- linter.config.verbosity = 1
- linter.print_violations(violations)
- expected = u"-: RULE_ID_1\n2: RULE_ID_2\n"
- self.assertEqual(expected, stderr.getvalue())
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- linter.config.verbosity = 2
- linter.print_violations(violations)
- expected = u"-: RULE_ID_1 Error Messåge 1\n2: RULE_ID_2 Error Message 2\n"
- self.assertEqual(expected, stderr.getvalue())
-
- with patch('gitlint.display.stderr', new=StringIO()) as stderr:
- linter.config.verbosity = 3
- linter.print_violations(violations)
- expected = u"-: RULE_ID_1 Error Messåge 1: \"Violating Content 1\"\n" + \
- u"2: RULE_ID_2 Error Message 2: \"Violåting Content 2\"\n"
- self.assertEqual(expected, stderr.getvalue())
diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py
deleted file mode 100644
index 2c17226..0000000
--- a/gitlint/tests/test_options.py
+++ /dev/null
@@ -1,179 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-
-from gitlint.tests.base import BaseTestCase
-
-from gitlint.options import IntOption, BoolOption, StrOption, ListOption, PathOption, RuleOptionError
-
-
-class RuleOptionTests(BaseTestCase):
- def test_option_equality(self):
- # 2 options are equal if their name, value and description match
- option1 = IntOption("test-option", 123, u"Test Dëscription")
- option2 = IntOption("test-option", 123, u"Test Dëscription")
- self.assertEqual(option1, option2)
-
- # Not equal: name, description, value are different
- self.assertNotEqual(option1, IntOption("test-option1", 123, u"Test Dëscription"))
- self.assertNotEqual(option1, IntOption("test-option", 1234, u"Test Dëscription"))
- self.assertNotEqual(option1, IntOption("test-option", 123, u"Test Dëscription2"))
-
- def test_int_option(self):
- # normal behavior
- option = IntOption("test-name", 123, "Test Description")
- self.assertEqual(option.value, 123)
- self.assertEqual(option.name, "test-name")
- self.assertEqual(option.description, "Test Description")
-
- # re-set value
- option.set(456)
- self.assertEqual(option.value, 456)
-
- # error on negative int when not allowed
- expected_error = u"Option 'test-name' must be a positive integer (current value: '-123')"
- with self.assertRaisesRegex(RuleOptionError, expected_error):
- option.set(-123)
-
- # error on non-int value
- expected_error = u"Option 'test-name' must be a positive integer (current value: 'foo')"
- with self.assertRaisesRegex(RuleOptionError, expected_error):
- option.set("foo")
-
- # no error on negative value when allowed and negative int is passed
- option = IntOption("test-name", 123, "Test Description", allow_negative=True)
- option.set(-456)
- self.assertEqual(option.value, -456)
-
- # error on non-int value when negative int is allowed
- expected_error = u"Option 'test-name' must be an integer (current value: 'foo')"
- with self.assertRaisesRegex(RuleOptionError, expected_error):
- option.set("foo")
-
- def test_str_option(self):
- # normal behavior
- option = StrOption("test-name", u"föo", "Test Description")
- self.assertEqual(option.value, u"föo")
- self.assertEqual(option.name, "test-name")
- self.assertEqual(option.description, "Test Description")
-
- # re-set value
- option.set(u"bår")
- self.assertEqual(option.value, u"bår")
-
- # conversion to str
- option.set(123)
- self.assertEqual(option.value, "123")
-
- # conversion to str
- option.set(-123)
- self.assertEqual(option.value, "-123")
-
- def test_boolean_option(self):
- # normal behavior
- option = BoolOption("test-name", "true", "Test Description")
- self.assertEqual(option.value, True)
-
- # re-set value
- option.set("False")
- self.assertEqual(option.value, False)
-
- # Re-set using actual boolean
- option.set(True)
- self.assertEqual(option.value, True)
-
- # error on incorrect value
- incorrect_values = [1, -1, "foo", u"bår", ["foo"], {'foo': "bar"}]
- for value in incorrect_values:
- with self.assertRaisesRegex(RuleOptionError, "Option 'test-name' must be either 'true' or 'false'"):
- option.set(value)
-
- def test_list_option(self):
- # normal behavior
- option = ListOption("test-name", u"å,b,c,d", "Test Description")
- self.assertListEqual(option.value, [u"å", u"b", u"c", u"d"])
-
- # re-set value
- option.set(u"1,2,3,4")
- self.assertListEqual(option.value, [u"1", u"2", u"3", u"4"])
-
- # set list
- option.set([u"foo", u"bår", u"test"])
- self.assertListEqual(option.value, [u"foo", u"bår", u"test"])
-
- # empty string
- option.set("")
- self.assertListEqual(option.value, [])
-
- # whitespace string
- option.set(" \t ")
- self.assertListEqual(option.value, [])
-
- # empty list
- option.set([])
- self.assertListEqual(option.value, [])
-
- # trailing comma
- option.set(u"ë,f,g,")
- self.assertListEqual(option.value, [u"ë", u"f", u"g"])
-
- # leading and trailing whitespace should be trimmed, but only deduped within text
- option.set(" abc , def , ghi \t , jkl mno ")
- self.assertListEqual(option.value, ["abc", "def", "ghi", "jkl mno"])
-
- # Also strip whitespace within a list
- option.set(["\t foo", "bar \t ", " test 123 "])
- self.assertListEqual(option.value, ["foo", "bar", "test 123"])
-
- # conversion to string before split
- option.set(123)
- self.assertListEqual(option.value, ["123"])
-
- def test_path_option(self):
- option = PathOption("test-directory", ".", u"Test Description", type=u"dir")
- self.assertEqual(option.value, os.getcwd())
- self.assertEqual(option.name, "test-directory")
- self.assertEqual(option.description, u"Test Description")
- self.assertEqual(option.type, u"dir")
-
- # re-set value
- option.set(self.SAMPLES_DIR)
- self.assertEqual(option.value, self.SAMPLES_DIR)
-
- # set to int
- expected = u"Option test-directory must be an existing directory (current value: '1234')"
- with self.assertRaisesRegex(RuleOptionError, expected):
- option.set(1234)
-
- # set to non-existing directory
- non_existing_path = os.path.join(u"/föo", u"bar")
- expected = u"Option test-directory must be an existing directory (current value: '{0}')"
- with self.assertRaisesRegex(RuleOptionError, expected.format(non_existing_path)):
- option.set(non_existing_path)
-
- # set to a file, should raise exception since option.type = dir
- sample_path = self.get_sample_path(os.path.join("commit_message", "sample1"))
- expected = u"Option test-directory must be an existing directory (current value: '{0}')".format(sample_path)
- with self.assertRaisesRegex(RuleOptionError, expected):
- option.set(sample_path)
-
- # set option.type = file, file should now be accepted, directories not
- option.type = u"file"
- option.set(sample_path)
- self.assertEqual(option.value, sample_path)
- expected = u"Option test-directory must be an existing file (current value: '{0}')".format(
- self.get_sample_path())
- with self.assertRaisesRegex(RuleOptionError, expected):
- option.set(self.get_sample_path())
-
- # set option.type = both, files and directories should now be accepted
- option.type = u"both"
- option.set(sample_path)
- self.assertEqual(option.value, sample_path)
- option.set(self.get_sample_path())
- self.assertEqual(option.value, self.get_sample_path())
-
- # Expect exception if path type is invalid
- option.type = u'föo'
- expected = u"Option test-directory type must be one of: 'file', 'dir', 'both' (current: 'föo')"
- with self.assertRaisesRegex(RuleOptionError, expected):
- option.set("haha")
diff --git a/hatch_build.py b/hatch_build.py
new file mode 100644
index 0000000..4be7a45
--- /dev/null
+++ b/hatch_build.py
@@ -0,0 +1,13 @@
+# hatch_build.py is executed by hatch at build-time and can contain custom build logic hooks
+import os
+from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+
+class CustomMetadataHook(MetadataHookInterface):
+ """Custom metadata hook for hatch that ensures that gitlint and gitlint-core[trusted-deps] versions always match"""
+
+ def update(self, metadata: dict) -> None:
+ # Only enforce versioning matching outside of the 'dev' environment, this allows for re-use of the 'dev'
+ # environment between different git branches.
+ if os.environ.get("HATCH_ENV_ACTIVE", "not-dev") != "dev":
+ metadata["dependencies"] = [f"gitlint-core[trusted-deps]=={metadata['version']}"]
diff --git a/mkdocs.yml b/mkdocs.yml
index e373b71..23ceb23 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -2,6 +2,7 @@ site_name: Gitlint
site_description: Linting for your git commit messages
site_url: http://jorisroovers.github.io/gitlint/
repo_url: https://github.com/jorisroovers/gitlint
+edit_uri: edit/main/docs
nav:
- Home: index.md
- Configuration: configuration.md
@@ -9,9 +10,14 @@ nav:
- Contrib Rules: contrib_rules.md
- User Defined Rules: user_defined_rules.md
- Contributing: contributing.md
- - Changelog: https://github.com/jorisroovers/gitlint/blob/master/CHANGELOG.md
+ - Changelog: https://github.com/jorisroovers/gitlint/blob/main/CHANGELOG.md
markdown_extensions: [admonition]
-theme: readthedocs
+theme:
+ name: readthedocs
+ navigation_depth: 2
strict: true
-extra_css: [extra.css] \ No newline at end of file
+extra_css:
+ - extra.css
+extra_javascript:
+ - extra.js \ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7435b66
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,203 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "gitlint"
+dynamic = ["version", "dependencies", "urls"]
+description = "Git commit message linter written in python, checks your commit messages for style."
+readme = "README.md"
+
+license = "MIT"
+requires-python = ">=3.7"
+authors = [{ name = "Joris Roovers" }]
+keywords = [
+ "git",
+ "gitlint",
+ "lint", #
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.7",
+ "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",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: Software Development :: Testing",
+]
+
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.build]
+exclude = ["*"]
+
+[tool.hatch.metadata.hooks.vcs.urls]
+Homepage = "https://jorisroovers.github.io/gitlint"
+Documentation = "https://jorisroovers.github.io/gitlint"
+Source = "https://github.com/jorisroovers/gitlint"
+Changelog = "https://github.com/jorisroovers/gitlint/blob/main/CHANGELOG.md"
+# TODO(jorisroovers): Temporary disable until fixed in hatch-vcs (see #460)
+# 'Source Archive' = "https://github.com/jorisroovers/gitlint/archive/{commit_hash}.zip"
+# 'Source Commit' = "https://github.com/jorisroovers/gitlint/tree/{commit_hash}"
+
+# Use metadata hooks specified in 'hatch_build.py'
+# (this line is critical when building wheels, when building sdist it seems optional)
+[tool.hatch.metadata.hooks.custom]
+
+# Environments #########################################################################################################
+# NOTE: By default all environments inherit from the 'default' environment
+
+# DEV
+# Workaround for editable install:
+# https://github.com/pypa/hatch/issues/588
+[tool.hatch.envs.dev]
+description = """
+Dev environment (running gitlint itself from source)
+"""
+pre-install-commands = [
+ "pip install -e ./gitlint-core", #
+]
+
+[tool.hatch.envs.dev.scripts]
+fullclean = [
+ "rm .coverage .coverage.lcov",
+ "rm -rf site dist .pytest_cache",
+ "rm -rf gitlint-core/dist gitlint-core/build gitlint-core/.pytest_cache",
+ "rm -rf qa/__pycache__ qa/.pytest_cache",
+]
+
+# TEST
+[tool.hatch.envs.test]
+description = """
+Test environment (unit tests, formatting, lint)
+"""
+skip-install = true
+dependencies = [
+ "gitlint-core[trusted-deps] @ {root:uri}/gitlint-core",
+ "black==23.1.0",
+ "pytest==7.2.1",
+ "pytest-cov==4.0.0",
+ "python-coveralls==2.9.3",
+ "ruff==0.0.252",
+ "radon==5.1.0",
+ "pdbr==0.8.2; sys_platform != \"win32\"",
+]
+
+[tool.hatch.envs.test.scripts]
+unit-tests = [
+ "pytest --cov=gitlint-core --cov-report=term --cov-report=lcov:.coverage.lcov -rw -s {args:gitlint-core}",
+]
+u = "unit-tests"
+unit-tests-no-cov = "pytest -rw -s {args:gitlint-core}"
+format = "black --check --diff {args:.}"
+lint = "ruff {args:gitlint-core/gitlint qa}"
+autoformat = "black {args:.}"
+autofix = [
+ "ruff --fix {args:gitlint-core/gitlint qa}",
+ "autoformat", #
+]
+
+all = [
+ "unit-tests",
+ "format",
+ "lint", #
+]
+stats = ["./tools/stats.sh"]
+
+# QA
+[tool.hatch.envs.qa]
+description = """
+Integration test environment.
+Run a set of integration tests against any gitlint binary (not just the one from local source).
+"""
+detached = true
+dependencies = [
+ "pytest==7.2.1",
+ "arrow==1.2.3",
+ "sh==1.14.3; sys_platform != \"win32\"",
+ "pdbr==0.8.2; sys_platform != \"win32\"",
+]
+
+[tool.hatch.envs.qa.scripts]
+# The integration tests can be ran against any gitlint binary, e.g. one installed from pypi (for post-release testing)
+# This is why by default we don't install the local dev version of gitlint in the qa environment
+# To run integration tests against the dev version of gitlint, use install-local first
+install-local = "pip install -e ./gitlint-core[trusted-deps]"
+integration-tests = "pytest -rw -s {args:qa}"
+i = "integration-tests"
+
+
+# DOCS
+[tool.hatch.envs.docs]
+description = """
+Documentation environment. Run docs build and serve commands.
+"""
+detached = true
+dependencies = [
+ "mkdocs==1.4.2", #
+]
+
+[tool.hatch.envs.docs.scripts]
+build = "mkdocs build --clean --strict"
+serve = "mkdocs serve"
+
+# Tool config ##########################################################################################################
+
+[tool.black]
+target_version = ['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"
+
+[tool.ruff]
+target-version = "py37"
+extend-exclude = [
+ "gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py",
+]
+
+ignore = [
+ "E501", # Never enforce `E501` (line length violations) - taken care of by black
+ "SIM108", # Use ternary operator instead of if-else-block
+ "PLR0913", # Too many arguments to function call
+]
+
+select = [
+ "F", # PyFlakes
+ "E", # Pycodestyle
+ "W", # Pycodestyle
+ "I", # isort (import order)
+ "YTT", # flake8-2020 (misuse of sys.version)
+ "S", # flake8-bandit (security)
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions (correct use of comprehensions)
+ "T10", # flake8-debugger (no debug statements)
+ "T20", # flake8-print (no print statements)
+ "SIM", # flake8-simplify (use simple code)
+ "TID", # flake8-tidy-imports (correct import syntax)
+ "ARG", # flake8-unused-arguments (no unused function arguments)
+ "DTZ", # flake8-datetimez (correct datetime usage)
+ "ERA", # eradicate (no commented out code)
+ "UP", # pyupgrade (modern python syntax)
+ "PLC", # pylint
+ "PLE", # pylint
+ "PLR", # pylint
+ "PLW", # pylint
+ "PIE", # flake8-pie
+ "RUF", # ruff specific
+]
+
+[tool.coverage.run]
+branch = true # measure branch coverage in addition to statement coverage
+
+[tool.coverage.report]
+fail_under = 97
+show_missing = true
diff --git a/qa/base.py b/qa/base.py
index 05d85e5..2b83778 100644
--- a/qa/base.py
+++ b/qa/base.py
@@ -1,32 +1,21 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return,
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
-
-import io
import os
import platform
import shutil
import sys
import tempfile
-from datetime import datetime
+from datetime import datetime, timezone
+from unittest import TestCase
from uuid import uuid4
import arrow
-try:
- # python 2.x
- from unittest2 import TestCase
-except ImportError:
- # python 3.x
- from unittest import TestCase
-
-from qa.shell import git, gitlint, RunningCommand
-from qa.utils import DEFAULT_ENCODING, ustr
+from qa.shell import RunningCommand, git, gitlint
+from qa.utils import FILE_ENCODING, PLATFORM_IS_WINDOWS, TERMINAL_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
@@ -34,43 +23,39 @@ class BaseTestCase(TestCase):
GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
GIT_CONTEXT_ERROR_CODE = 254
-
- @classmethod
- def setUpClass(cls):
- """ Sets up the integration tests by creating a new temporary git repository """
- cls.tmp_git_repos = []
- cls.tmp_git_repo = cls.create_tmp_git_repo()
-
- @classmethod
- def tearDownClass(cls):
- """ Cleans up the temporary git repositories """
- for repo in cls.tmp_git_repos:
- shutil.rmtree(repo)
+ GITLINT_USAGE_ERROR = 253
def setUp(self):
+ """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()
def tearDown(self):
+ # Clean up temporary files and repos
for tmpfile in self.tmpfiles:
os.remove(tmpfile)
+ for repo in self.tmp_git_repos:
+ # On windows we need to ignore errors because git might still be holding on to some files
+ shutil.rmtree(repo, ignore_errors=PLATFORM_IS_WINDOWS)
- def assertEqualStdout(self, output, expected): # pylint: disable=invalid-name
+ def assertEqualStdout(self, output, expected):
self.assertIsInstance(output, RunningCommand)
- output = ustr(output.stdout)
- output = output.replace('\r', '')
+ output = output.stdout.decode(TERMINAL_ENCODING)
+ output = output.replace("\r", "")
self.assertMultiLineEqual(output, expected)
- @classmethod
- def generate_temp_path(cls):
- return os.path.realpath("/tmp/gitlint-test-{0}".format(datetime.now().strftime("%Y%m%d-%H%M%S-%f")))
+ @staticmethod
+ def generate_temp_path():
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
+ return os.path.realpath(f"/tmp/gitlint-test-{timestamp}") # noqa
- @classmethod
- def create_tmp_git_repo(cls):
- """ Creates a temporary git repository and returns its directory path """
- tmp_git_repo = cls.generate_temp_path()
- cls.tmp_git_repos.append(tmp_git_repo)
+ def create_tmp_git_repo(self):
+ """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)
@@ -84,18 +69,48 @@ class BaseTestCase(TestCase):
# http://stackoverflow.com/questions/5581857/git-and-the-umlaut-problem-on-mac-os-x
git("config", "core.precomposeunicode", "true", _cwd=tmp_git_repo)
+ # Git now does commit message cleanup by default (e.g. removing trailing whitespace), disable that for testing
+ git("config", "commit.cleanup", "verbatim", _cwd=tmp_git_repo)
+
return tmp_git_repo
@staticmethod
- def create_file(parent_dir):
- """ Creates a file inside a passed directory. Returns filename."""
- test_filename = u"test-fïle-" + str(uuid4())
- io.open(os.path.join(parent_dir, test_filename), 'a', encoding=DEFAULT_ENCODING).close()
+ def create_file(parent_dir, content=None):
+ """Creates a file inside a passed directory. Returns filename."""
+ test_filename = "test-fïle-" + str(uuid4())
+ 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": FILE_ENCODING}
+
+ with open(full_path, **open_kwargs) as f:
+ f.write(content)
+ else:
+ open(full_path, "a", encoding=FILE_ENCODING).close() # noqa: SIM115 (Use context handler for opening files)
+
return test_filename
- def create_simple_commit(self, message, out=None, ok_code=None, env=None, git_repo=None, tty_in=False):
- """ Creates a simple commit with an empty test file.
- :param message: Commit message for the commit. """
+ @staticmethod
+ def create_environment(envvars=None):
+ """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."""
+ tmp_config = self.create_tmpfile(contents)
+ return self.create_environment({"GIT_CONFIG": tmp_config})
+
+ 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
@@ -103,28 +118,42 @@ class BaseTestCase(TestCase):
# variables can influence how git runs.
# This was needed to fix https://github.com/jorisroovers/gitlint/issues/15 as we need to make sure to use
# the PATH variable that contains the virtualenv's python binary.
- environment = os.environ
- if env:
- environment.update(env)
+ 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": FILE_ENCODING}
+
+ with open(tmpfile, **open_kwargs) as f:
f.write(content)
+
return tmpfilepath
@staticmethod
@@ -147,32 +176,40 @@ 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)
- expected = io.open(expected_path, encoding=DEFAULT_ENCODING).read()
+ # Expected files are UTF-8 encoded (not dependent on the system's default encoding)
+ with open(expected_path, encoding=FILE_ENCODING) as file:
+ expected = file.read()
- if variable_dict:
- expected = expected.format(**variable_dict)
- return expected
+ if variable_dict:
+ expected = expected.format(**variable_dict)
+ return expected
@staticmethod
def get_system_info_dict():
- """ Returns a dict with items related to system values logged by `gitlint --debug` """
- expected_gitlint_version = gitlint("--version").replace("gitlint, version ", "").replace("\n", "")
- expected_git_version = git("--version").replace("\n", "")
- return {'platform': platform.platform(), 'python_version': sys.version,
- 'git_version': expected_git_version, 'gitlint_version': expected_gitlint_version,
- 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB}
+ """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,
+ "TERMINAL_ENCODING": TERMINAL_ENCODING,
+ "FILE_ENCODING": FILE_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 878bc4c..03a558c 100644
--- a/qa/expected/test_commits/test_lint_staged_msg_filename_1
+++ b/qa/expected/test_commits/test_lint_staged_msg_filename_1
@@ -1,9 +1,12 @@
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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -12,10 +15,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}
@@ -26,6 +32,11 @@ target: {target}
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
@@ -35,7 +46,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=80
B5: body-min-length
@@ -47,13 +60,20 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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.lint Commit Object
--- Commit Message ----
WIP: from fïle test.
@@ -62,10 +82,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 3f178f8..7892865 100644
--- a/qa/expected/test_commits/test_lint_staged_stdin_1
+++ b/qa/expected/test_commits/test_lint_staged_stdin_1
@@ -1,9 +1,12 @@
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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -12,10 +15,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}
@@ -26,6 +32,11 @@ target: {target}
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
@@ -35,7 +46,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=80
B5: body-min-length
@@ -47,15 +60,22 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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.
'
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.lint Commit Object
--- Commit Message ----
WIP: Pïpe test.
@@ -64,10 +84,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
new file mode 100644
index 0000000..91eee40
--- /dev/null
+++ b/qa/expected/test_config/test_config_from_env_1
@@ -0,0 +1,104 @@
+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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
+DEBUG: gitlint.cli Configuration
+config-path: None
+[GENERAL]
+extra-path: None
+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}
+[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=^[^@ ]+@[^@ ]+\.[^@ ]+
+ CC1: contrib-body-requires-signed-off-by
+ CT1: contrib-title-conventional-commits
+ types=fix,feat,chore,docs,style,refactor,perf,test,revert,ci,build
+
+DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo.
+DEBUG: gitlint.git ('rev-list', '{commit_sha}')
+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 ----
+WIP: Thïs is a title thåt is a bit longer.
+Content on the second line
+This line of the body is here because we need it
+
+--- Meta info ---------
+Author: gitlint-test-user <gitlint@test.com>
+Date: {commit_date}
+is-merge-commit: False
+is-fixup-commit: False
+is-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}
+-----------------------
+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
+1: T3 Title has trailing punctuation (.)
+1: T5 Title contains the word 'WIP' (case-insensitive)
+2: B4 Second line is not empty
+DEBUG: gitlint.cli Exit Code = 5
diff --git a/qa/expected/test_config/test_config_from_env_2 b/qa/expected/test_config/test_config_from_env_2
new file mode 100644
index 0000000..06b0c1b
--- /dev/null
+++ b/qa/expected/test_config/test_config_from_env_2
@@ -0,0 +1,93 @@
+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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_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: True
+fail-without-commits: False
+regex-style-search: False
+verbosity: 0
+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 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.lint Commit Object
+--- Commit Message ----
+WIP: msg-fïlename test.
+--- Meta info ---------
+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
+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 443ee26..279fb32 100644
--- a/qa/expected/test_config/test_config_from_file_debug_1
+++ b/qa/expected/test_config/test_config_from_file_debug_1
@@ -1,9 +1,12 @@
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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: {config_path}
[GENERAL]
@@ -12,10 +15,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}
@@ -26,6 +32,11 @@ target: {target}
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=20
T2: title-trailing-whitespace
@@ -35,7 +46,9 @@ target: {target}
T5: title-must-not-contain-word
words=WIP,thåt
T7: title-match-regex
- regex=.*
+ regex=None
+ T8: title-min-length
+ min-length=5
B1: body-max-line-length
line-length=30
B5: body-min-length
@@ -47,12 +60,19 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ 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')
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 ----
WIP: Thïs is a title thåt is a bit longer.
@@ -64,10 +84,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_contrib/test_contrib_rules_1 b/qa/expected/test_contrib/test_contrib_rules_1
index 99b33b7..6ab7512 100644
--- a/qa/expected/test_contrib/test_contrib_rules_1
+++ b/qa/expected/test_contrib/test_contrib_rules_1
@@ -1,4 +1,3 @@
-1: CC1 Body does not contain a 'Signed-Off-By' line
-1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert: "WIP Thi$ is å title"
+1: CC1 Body does not contain a 'Signed-off-by' line
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"
diff --git a/qa/expected/test_contrib/test_contrib_rules_with_config_1 b/qa/expected/test_contrib/test_contrib_rules_with_config_1
index 21d467a..6ab7512 100644
--- a/qa/expected/test_contrib/test_contrib_rules_with_config_1
+++ b/qa/expected/test_contrib/test_contrib_rules_with_config_1
@@ -1,4 +1,3 @@
-1: CC1 Body does not contain a 'Signed-Off-By' line
-1: CT1 Title does not start with one of föo, bår: "WIP Thi$ is å title"
+1: CC1 Body does not contain a 'Signed-off-by' line
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"
diff --git a/qa/expected/test_gitlint/test_commit_binary_file_1 b/qa/expected/test_gitlint/test_commit_binary_file_1
new file mode 100644
index 0000000..83faf1b
--- /dev/null
+++ b/qa/expected/test_gitlint/test_commit_binary_file_1
@@ -0,0 +1,95 @@
+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 TERMINAL_ENCODING: {TERMINAL_ENCODING}
+DEBUG: gitlint.cli FILE_ENCODING: {FILE_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_named_rules/test_named_rule_1 b/qa/expected/test_named_rules/test_named_rule_1
new file mode 100644
index 0000000..e5a380c
--- /dev/null
+++ b/qa/expected/test_named_rules/test_named_rule_1
@@ -0,0 +1,5 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: thåt dûr bår"
+1: T5 Title contains the word 'thåt' (case-insensitive): "WIP: thåt dûr bår"
+1: T5:even$more%wôrds Title contains the word 'bår' (case-insensitive): "WIP: thåt dûr bår"
+1: T5:extra-wôrds Title contains the word 'dûr' (case-insensitive): "WIP: thåt dûr bår"
+3: B5 Body message is too short (18<20): "Sïmple commit body"
diff --git a/qa/expected/test_named_rules/test_named_user_rule_1 b/qa/expected/test_named_rules/test_named_user_rule_1
new file mode 100644
index 0000000..3cd18b4
--- /dev/null
+++ b/qa/expected/test_named_rules/test_named_user_rule_1
@@ -0,0 +1,9 @@
+1: UC4 int-öption: 2
+1: UC4 str-öption: föo
+1: UC4 list-öption: ['foo', 'bar']
+1: UC4:bår int-öption: 2
+1: UC4:bår str-öption: bår
+1: UC4:bår list-öption: ['bar', 'list']
+1: UC4:föo int-öption: 3
+1: UC4:föo str-öption: föo
+1: UC4:föo list-öption: ['foo', 'bar']
diff --git a/qa/expected/test_rules/test_ignore_rules_1 b/qa/expected/test_rules/test_ignore_rules_1
new file mode 100644
index 0000000..f87f303
--- /dev/null
+++ b/qa/expected/test_rules/test_ignore_rules_1
@@ -0,0 +1,3 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Commït Tïtle"
+3: B3 Line contains hard tab characters (\t): "Sïmple commit body"
+4: B2 Line has trailing whitespace: "Anōther Line "
diff --git a/qa/expected/test_rules/test_ignore_rules_2 b/qa/expected/test_rules/test_ignore_rules_2
new file mode 100644
index 0000000..dc6428c
--- /dev/null
+++ b/qa/expected/test_rules/test_ignore_rules_2
@@ -0,0 +1,2 @@
+1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Commït Tïtle"
+3: B3 Line contains hard tab characters (\t): "Sïmple commit body"
diff --git a/qa/expected/test_rules/test_match_regex_rules_1 b/qa/expected/test_rules/test_match_regex_rules_1
new file mode 100644
index 0000000..3bfaa58
--- /dev/null
+++ b/qa/expected/test_rules/test_match_regex_rules_1
@@ -0,0 +1,2 @@
+1: T7 Title does not match regex (foo): "Thåt dûr bår"
+4: B8 Body does not match regex (bar)
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 9d00445..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: UC2 Body does not contain a 'Signed-off-by' line
+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
new file mode 100644
index 0000000..d706b12
--- /dev/null
+++ b/qa/expected/test_user_defined/test_user_defined_rules_examples_2
@@ -0,0 +1,5 @@
+1: UC2 Body does not contain a 'Signed-off-by' line
+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
+3: B3 Line contains hard tab characters (\t)
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 a143715..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: UC2 Body does not contain a 'Signed-off-by' line
+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 65f3507..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,5 +1,9 @@
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']
-2: B4 Second line is not empty: "Content on the second line"
+1: UC2 GitCommit.branches: ['main']
+1: UC2 GitCommit.custom_prop: foöbar
+1: UC4 int-öption: 2
+1: UC4 str-öption: föo
+1: UC4 list-öption: ['foo', 'bar']
+4: B2 Line has trailing whitespace: "{repo-path} "
diff --git a/qa/requirements.txt b/qa/requirements.txt
deleted file mode 100644
index f042dad..0000000
--- a/qa/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-sh==1.12.14
-pytest==4.6.3;
-arrow==0.15.5;
-gitlint # no version as you want to test the currently installed version
diff --git a/qa/samples/config/named-rules b/qa/samples/config/named-rules
new file mode 100644
index 0000000..f9bbdf5
--- /dev/null
+++ b/qa/samples/config/named-rules
@@ -0,0 +1,8 @@
+[title-must-not-contain-word]
+words=WIP,thåt
+
+[title-must-not-contain-word:extra-wôrds]
+words=hûr,dûr
+
+[title-must-not-contain-word: even$more%wôrds ]
+words=fôo,bår \ No newline at end of file
diff --git a/qa/samples/config/named-user-rules b/qa/samples/config/named-user-rules
new file mode 100644
index 0000000..ed811fb
--- /dev/null
+++ b/qa/samples/config/named-user-rules
@@ -0,0 +1,15 @@
+# Ignore other user-defined rules
+[general]
+ignore=UC1,UC2,UC3,configürable:ignöred
+
+[UC4:föo]
+int-öption=3
+str-öption=föo
+
+[configürable:bår]
+str-öption=bår
+list-öption=bar,list
+
+# The following rule will be ignored
+[configürable:ignöred]
+str-öption=foöbar \ No newline at end of file
diff --git a/qa/samples/user_rules/extra/extra_rules.py b/qa/samples/user_rules/extra/extra_rules.py
index 8109299..7996590 100644
--- a/qa/samples/user_rules/extra/extra_rules.py
+++ b/qa/samples/user_rules/extra/extra_rules.py
@@ -1,29 +1,72 @@
-from gitlint.rules import CommitRule, RuleViolation
-from gitlint.utils import sstr
+from gitlint.options import IntOption, ListOption, StrOption
+from gitlint.rules import CommitRule, ConfigurationRule, RuleViolation
class GitContextRule(CommitRule):
- """ Rule that tests whether we can correctly access certain gitcontext properties """
- name = "gitcontext"
+ """Rule that tests whether we can correctly access certain gitcontext properties"""
+
+ name = "gïtcontext"
id = "UC1"
def validate(self, commit):
violations = [
- RuleViolation(self.id, "GitContext.current_branch: {0}".format(commit.context.current_branch), line_nr=1),
- RuleViolation(self.id, "GitContext.commentchar: {0}".format(commit.context.commentchar), line_nr=1)
+ 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),
]
return violations
class GitCommitRule(CommitRule):
- """ Rule that tests whether we can correctly access certain commit properties """
- name = "gitcommit"
+ """Rule that tests whether we can correctly access certain commit properties"""
+
+ name = "gïtcommit"
id = "UC2"
def validate(self, commit):
violations = [
- RuleViolation(self.id, "GitCommit.branches: {0}".format(sstr(commit.branches)), line_nr=1),
+ RuleViolation(self.id, f"GitCommit.branches: {commit.branches}", line_nr=1),
+ RuleViolation(self.id, f"GitCommit.custom_prop: {commit.custom_prop}", line_nr=1),
+ ]
+
+ return violations
+
+
+class GitlintConfigurationRule(ConfigurationRule):
+ """Rule that tests whether we can correctly access the config as well as modify the commit message"""
+
+ name = "cönfigrule"
+ id = "UC3"
+
+ def apply(self, config, commit):
+ # We add a line to the commit message body that pulls a value from config, this proves we can modify the body
+ # and read the config contents
+ commit.message.body.append(f"{config.target} ") # trailing whitespace deliberate to trigger violation
+
+ # We set a custom property that we access in CommitRule, to prove we can add extra properties to the commit
+ commit.custom_prop = "foöbar"
+
+ # We also ignore some extra rules, proving that we can modify the config
+ config.ignore.append("B4")
+
+
+class ConfigurableCommitRule(CommitRule):
+ """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"),
+ ]
+
+ def validate(self, _):
+ violations = [
+ RuleViolation(self.id, f"int-öption: {self.options['int-öption'].value}", line_nr=1),
+ RuleViolation(self.id, f"str-öption: {self.options['str-öption'].value}", line_nr=1),
+ RuleViolation(self.id, f"list-öption: {self.options['list-öption'].value}", line_nr=1),
]
return violations
diff --git a/qa/shell.py b/qa/shell.py
index 8ba6dc1..3ef874d 100644
--- a/qa/shell.py
+++ b/qa/shell.py
@@ -1,43 +1,69 @@
-
-# This code is mostly duplicated from the `gitlint.shell` module. We conciously duplicate this code as to not depend
+# 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.
import subprocess
-import sys
-from qa.utils import ustr, USE_SH_LIB
+
+from qa.utils import TERMINAL_ENCODING, USE_SH_LIB
if USE_SH_LIB:
- from sh import git, echo, gitlint # pylint: disable=unused-import,no-name-in-module,import-error
+ from sh import (
+ echo,
+ git,
+ gitlint,
+ )
+
+ gitlint = gitlint.bake(_unify_ttys=True, _tty_in=True)
# import exceptions separately, this makes it a little easier to mock them out in the unit tests
- from sh import CommandNotFound, ErrorReturnCode, RunningCommand # pylint: disable=import-error
+ from sh import (
+ CommandNotFound,
+ ErrorReturnCode,
+ RunningCommand,
+ )
else:
class CommandNotFound(Exception):
- """ Exception indicating a command was not found during execution """
- pass
+ """Exception indicating a command was not found during execution"""
- class RunningCommand(object):
- pass
+ class RunningCommand:
+ ...
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.
- self.stdout = stdout + ustr(stderr)
- self.stderr = stderr
+ self._stdout = stdout + stderr
+ self._stderr = stderr
self.exit_code = exitcode
def __str__(self):
+ return self.stdout.decode(TERMINAL_ENCODING)
+
+ def __unicode__(self):
return self.stdout
+ @property
+ def stdout(self):
+ return self._stdout
+
+ @property
+ def stderr(self):
+ return self._stderr
+
+ def __getattr__(self, p):
+ # https://github.com/amoffat/sh/blob/e0ed8e244e9d973ef4e0749b2b3c2695e7b5255b/sh.py#L952=
+ _unicode_methods = set(dir(str())) # noqa
+ if p in _unicode_methods:
+ return getattr(str(self), p)
+
+ raise AttributeError
+
class ErrorReturnCode(ShResult, Exception):
- """ ShResult subclass for unexpected results (acts as an exception). """
- pass
+ """ShResult subclass for unexpected results (acts as an exception)."""
def git(*command_parts, **kwargs):
return run_command("git", *command_parts, **kwargs)
@@ -49,39 +75,41 @@ else:
return run_command("gitlint", *command_parts, **kwargs)
def run_command(command, *args, **kwargs):
- args = [command] + list(args)
- result = _exec(*args, **kwargs)
- # If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
- # get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
- # a non-zero exit code -> just return the entire result
- if hasattr(result, 'exit_code') and result.exit_code > 0:
- return result
- return ustr(result)
+ args = [command, *list(args)]
+ return _exec(*args, **kwargs)
def _exec(*args, **kwargs):
- if sys.version_info[0] == 2:
- no_command_error = OSError # noqa pylint: disable=undefined-variable,invalid-name
- else:
- no_command_error = FileNotFoundError # noqa pylint: disable=undefined-variable
-
- pipe = subprocess.PIPE
- popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)}
- if '_cwd' in kwargs:
- popen_kwargs['cwd'] = kwargs['_cwd']
+ popen_kwargs = {
+ "stdout": subprocess.PIPE,
+ "stderr": subprocess.PIPE,
+ "stdin": subprocess.PIPE,
+ "shell": kwargs.get("_tty_out", False),
+ "cwd": kwargs.get("_cwd", None),
+ "env": kwargs.get("_env", None),
+ }
+
+ stdin_input = None
+ if len(args) > 1 and isinstance(args[1], ShResult):
+ stdin_input = args[1].stdout
+ # pop args[1] from the array and use it as stdin
+ args = list(args)
+ args.pop(1)
+ popen_kwargs["stdin"] = subprocess.PIPE
try:
- p = subprocess.Popen(args, **popen_kwargs)
- result = p.communicate()
- except no_command_error:
- raise CommandNotFound
+ with subprocess.Popen(args, **popen_kwargs) as p:
+ result = p.communicate(stdin_input)
+
+ except FileNotFoundError as exc:
+ raise CommandNotFound from exc
exit_code = p.returncode
- stdout = ustr(result[0])
+ stdout = result[0]
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 f485856..11d1851 100644
--- a/qa/test_commits.py
+++ b/qa/test_commits.py
@@ -1,67 +1,145 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
import re
import arrow
-from qa.shell import echo, git, gitlint
from qa.base import BaseTestCase
-from qa.utils import sstr
+from qa.shell import echo, git, gitlint
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(u"Sïmple title\n\nSimple bödy describing the commit")
+ 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(u"Sïmple title2\n\nSimple bödy describing the commit2")
- self.create_simple_commit(u"Sïmple title3\n\nSimple bödy describing the commit3")
- output = gitlint("--commits", "test-branch-commits-base...test-branch-commits",
- _cwd=self.tmp_git_repo, _tty_in=True)
+ self.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
+ )
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(u"Sïmple title.\n")
+ self.create_simple_commit("Sïmple title.\n")
git("checkout", "-b", "test-branch-commits-violations", _cwd=self.tmp_git_repo)
- self.create_simple_commit(u"Sïmple title2.\n")
+ self.create_simple_commit("Sïmple title2.\n")
commit_sha1 = self.get_last_commit_hash()[:10]
- self.create_simple_commit(u"Sïmple title3.\n")
+ 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."""
+ self.create_simple_commit("Sïmple title.\n")
+ self.create_simple_commit("Sïmple title2.\n")
+ commit_sha = self.get_last_commit_hash()
+ # git revspec -> 2 dots: <exclusive sha>..<inclusive sha> -> empty range when using same start and end sha
+ refspec = f"{commit_sha}..{commit_sha}"
+
+ # Regular gitlint invocation should run without issues
+ output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True)
+ self.assertEqual(output.exit_code, 0)
+ 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],
+ )
+ self.assertEqual(output.exit_code, self.GITLINT_USAGE_ERROR)
+ self.assertEqualStdout(output, f'Error: No commits in range "{refspec}"\n')
+
def test_lint_single_commit(self):
- """ Tests `gitlint --commits <sha>` """
- self.create_simple_commit(u"Sïmple title.\n")
- self.create_simple_commit(u"Sïmple title2.\n")
+ """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")
commit_sha = self.get_last_commit_hash()
- refspec = "{0}^...{0}".format(commit_sha)
- self.create_simple_commit(u"Sïmple title3.\n")
+ 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"
+
+ # Lint using --commit <commit sha>
+ output = gitlint("--commit", commit_sha, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ self.assertEqual(output.exit_code, 2)
+ self.assertEqualStdout(output, expected)
+
+ # Lint using --commits <commit sha>,
+ output = gitlint("--commits", f"{commit_sha},", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ self.assertEqual(output.exit_code, 2)
+ self.assertEqualStdout(output, expected)
+
+ # Lint a single commit using --commits <refspec> pointing to the single commit
output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
- expected = (u"1: T3 Title has trailing punctuation (.): \"Sïmple title2.\"\n" +
- u"3: B6 Body message is missing\n")
self.assertEqual(output.exit_code, 2)
self.assertEqualStdout(output, expected)
+ # 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"
+ 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)
+
+ # Assert that indeed --commits <refspec> is not supported when <refspec> points the the first commit
+ refspec = f"{first_commit_sha}^...{first_commit_sha}"
+ output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[254])
+ 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 and fetch additional meta-data
+ from the underlying repository. The easiest way to test this is by inspecting `--debug` output.
+ This is the equivalent of doing:
+ echo "WIP: Pïpe test." | gitlint --staged --debug
"""
# Create a commit first, before we stage changes. This ensures the repo is properly initialized.
- self.create_simple_commit(u"Sïmple title.\n")
+ self.create_simple_commit("Sïmple title.\n")
# Add some files, stage them: they should show up in the debug output as changed file
filename1 = self.create_file(self.tmp_git_repo)
@@ -69,33 +147,48 @@ class CommitsTests(BaseTestCase):
filename2 = self.create_file(self.tmp_git_repo)
git("add", filename2, _cwd=self.tmp_git_repo)
- output = gitlint(echo(u"WIP: Pïpe test."), "--staged", "--debug",
- _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
+ 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': sstr(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(u"Sïmple title.\n")
+ self.create_simple_commit("Sïmple title.\n")
# Add some files, stage them: they should show up in the debug output as changed file
filename1 = self.create_file(self.tmp_git_repo)
@@ -103,59 +196,81 @@ class CommitsTests(BaseTestCase):
filename2 = self.create_file(self.tmp_git_repo)
git("add", filename2, _cwd=self.tmp_git_repo)
- tmp_commit_msg_file = self.create_tmpfile(u"WIP: from fïle test.")
+ 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': sstr(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(u"Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
- self.create_simple_commit(u"Sïmple title", git_repo=tmp_git_repo)
- self.create_simple_commit(u"WIP: Sïmple title\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
+ 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)
+ self.create_simple_commit("WIP: Sïmple title\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
output = gitlint("--commits", "HEAD", _cwd=tmp_git_repo, _tty_in=True, _ok_code=[3])
revlist = git("rev-list", "HEAD", _tty_in=True, _cwd=tmp_git_repo).split()
- expected_kwargs = {"commit_sha0": revlist[0][:10], "commit_sha1": revlist[1][:10],
- "commit_sha2": revlist[2][:10]}
+ expected_kwargs = {
+ "commit_sha0": revlist[0][:10],
+ "commit_sha1": revlist[1][:10],
+ "commit_sha2": revlist[2][:10],
+ }
self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_head_1", expected_kwargs))
def test_ignore_commits(self):
- """ Tests multiple commits of which some rules get igonored because of ignore-* rules """
+ """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(u"Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
+ self.create_simple_commit("Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)
# Normally, this commit will give T3 (trailing-punctuation), T5 (WIP) and B5 (bod-too-short) violations
# But in this case only B5 because T3 and T5 are being ignored because of config
- self.create_simple_commit(u"Release: WIP tïtle.\n\nShort", git_repo=tmp_git_repo)
+ 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(
- u"Sïmple WIP title3.\n\nThis is \ta relëase commit\nMore info", git_repo=tmp_git_repo)
- self.create_simple_commit(u"Sïmple title4.\n\nSimple bödy describing the commit4", git_repo=tmp_git_repo)
+ 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 b893b1d..d051686 100644
--- a/qa/test_config.py
+++ b/qa/test_config.py
@@ -1,31 +1,36 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
-from qa.shell import gitlint
+import os
+import re
+
from qa.base import BaseTestCase
-from qa.utils import sstr
+from qa.shell import gitlint
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(u"WIP: Thïs is a title.\nContënt on the second line")
+ 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 = u"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(u"WIP: Thïs is a title.\nContënt on the second line")
- output = gitlint("--ignore", "title-must-not-contain-word,body-first-line-empty",
- _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
- expected = u"1: T3 Title has trailing punctuation (.): \"WIP: Thïs is a title.\"\n"
+ self.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'
self.assertEqualStdout(output, expected)
def test_verbosity(self):
- self.create_simple_commit(u"WIP: Thïs is a title.\nContënt on the second line")
+ self.create_simple_commit("WIP: Thïs is a title.\nContënt on the second line")
output = gitlint("-v", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
- expected = u"1: T3\n1: T5\n2: B4\n"
+ expected = "1: T3\n1: T5\n2: B4\n"
self.assertEqualStdout(output, expected)
output = gitlint("-vv", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
@@ -39,29 +44,90 @@ class ConfigTests(BaseTestCase):
self.assertEqualStdout(output, "")
def test_set_rule_option(self):
- self.create_simple_commit(u"This ïs a title.")
+ self.create_simple_commit("This ïs a title.")
output = gitlint("-c", "title-max-length.line-length=5", _tty_in=True, _cwd=self.tmp_git_repo, _ok_code=[3])
self.assertEqualStdout(output, self.get_expected("test_config/test_set_rule_option_1"))
def test_config_from_file(self):
- commit_msg = u"WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \
- "This line of the body is here because we need it"
+ 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])
self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_file_1"))
def test_config_from_file_debug(self):
- # Test bot on existing and new repo (we've had a bug in the past that was unique to empty repos)
+ # 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 = u"WIP: Thïs is a title thåt is a bit longer.\nContent on the second line\n" + \
- "This line of the body is here because we need it"
+ 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")
+ config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
output = gitlint("--config", config_path, "--debug", _cwd=target_repo, _tty_in=True, _ok_code=[5])
expected_kwargs = self.get_debug_vars_last_commit(git_repo=target_repo)
- expected_kwargs.update({'config_path': config_path, 'changed_files': sstr([filename])})
- self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_file_debug_1",
- expected_kwargs))
+ 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"""
+
+ # 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"
+ )
+ 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),
+ }
+ )
+ 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], "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"}
+ )
+
+ 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
+ p = re.compile("Date: (.*)\n", re.UNICODE | re.MULTILINE)
+ result = p.search(str(output))
+ date = result.group(1).strip()
+ expected_kwargs.update({"date": date})
+
+ self.assertEqualStdout(output, self.get_expected("test_config/test_config_from_env_2", expected_kwargs))
diff --git a/qa/test_contrib.py b/qa/test_contrib.py
index e2b4bc5..d3a45ba 100644
--- a/qa/test_contrib.py
+++ b/qa/test_contrib.py
@@ -1,26 +1,31 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=
-from qa.shell import gitlint
from qa.base import BaseTestCase
+from qa.shell import gitlint
class ContribRuleTests(BaseTestCase):
- """ Integration tests for contrib rules."""
+ """Integration tests for contrib rules."""
def test_contrib_rules(self):
- self.create_simple_commit(u"WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
- output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
- _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
+ self.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]
+ )
self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_1"))
def test_contrib_rules_with_config(self):
- self.create_simple_commit(u"WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
- output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
- "-c", u"contrib-title-conventional-commits.types=föo,bår",
- _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
+ self.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],
+ )
self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_with_config_1"))
def test_invalid_contrib_rules(self):
self.create_simple_commit("WIP: test")
- output = gitlint("--contrib", u"föobar,CC1", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[255])
- self.assertEqualStdout(output, u"Config Error: No contrib rule with id or name 'föobar' found.\n")
+ output = gitlint("--contrib", "föobar,CC1", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[255])
+ self.assertEqualStdout(output, "Config Error: No contrib rule with id or name 'föobar' found.\n")
diff --git a/qa/test_gitlint.py b/qa/test_gitlint.py
index 4762721..7a04a39 100644
--- a/qa/test_gitlint.py
+++ b/qa/test_gitlint.py
@@ -1,47 +1,45 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
-import io
import os
-from qa.shell import echo, git, gitlint
+
from qa.base import BaseTestCase
-from qa.utils import DEFAULT_ENCODING
+from qa.shell import echo, git, gitlint
+from qa.utils import FILE_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
- self.create_simple_commit(u"Sïmple title\n\nSimple bödy describing the commit")
+ self.create_simple_commit("Sïmple title\n\nSimple bödy describing the commit")
output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _err_to_out=True)
self.assertEqualStdout(output, "")
def test_successful_gitconfig(self):
- """ Test gitlint when the underlying repo has specific git config set.
- In the past, we've had issues with gitlint failing on some of these, so this acts as a regression test. """
+ """Test gitlint when the underlying repo has specific git config set.
+ In the past, we've had issues with gitlint failing on some of these, so this acts as a regression test."""
# Different commentchar (Note: tried setting this to a special unicode char, but git doesn't like that)
git("config", "--add", "core.commentchar", "$", _cwd=self.tmp_git_repo)
- self.create_simple_commit(u"Sïmple title\n\nSimple bödy describing the commit\n$after commentchar\t ignored")
+ self.create_simple_commit("Sïmple title\n\nSimple bödy describing the commit\n$after commentchar\t ignored")
output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _err_to_out=True)
self.assertEqualStdout(output, "")
def test_successful_merge_commit(self):
- # Create branch on master
- self.create_simple_commit(u"Cömmit on master\n\nSimple bödy")
+ # Create 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)
git("checkout", "test-branch", _cwd=self.tmp_git_repo)
- commit_title = u"Commit on test-brånch with a pretty long title that will cause issues when merging"
- self.create_simple_commit(u"{0}\n\nSïmple body".format(commit_title))
+ commit_title = "Commit on test-brånch with a pretty long title that will cause issues when merging"
+ 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("merge", "--no-ff", "-m", u"Merge '{0}'".format(commit_title), hash, _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
output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
@@ -50,23 +48,18 @@ class IntegrationTests(BaseTestCase):
# Assert that we do see the error if we disable the ignore-merge-commits option
output = gitlint("-c", "general.ignore-merge-commits=false", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[1])
self.assertEqual(output.exit_code, 1)
- self.assertEqualStdout(output,
- u"1: T1 Title exceeds max length (90>72): \"Merge '{0}'\"\n".format(commit_title))
+ self.assertEqualStdout(output, f"1: T1 Title exceeds max length (90>72): \"Merge '{commit_title}'\"\n")
def test_fixup_commit(self):
# Create a normal commit and assert that it has a violation
- test_filename = self.create_simple_commit(u"Cömmit on WIP master\n\nSimple bödy that is long enough")
+ 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 = u"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(u"Appending some stuff\n")
+ with open(os.path.join(self.tmp_git_repo, test_filename), "a", encoding=FILE_ENCODING) as fh:
+ fh.write("Appending söme stuff\n")
git("add", test_filename, _cwd=self.tmp_git_repo)
@@ -79,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 = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"fixup! Cömmit on WIP master\"\n" + \
- u"3: B6 Body message is missing\n"
+ 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=FILE_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(u"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)
@@ -94,30 +118,31 @@ 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 = u"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(u"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 = u"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=FILE_ENCODING) as fh:
# Wanted to write a unicode string, but that's obnoxious if you want to do it across Python 2 and 3.
# https://stackoverflow.com/questions/22392377/
# error-writing-a-file-with-file-write-in-python-unicodeencodeerror
# So just keeping it simple - ASCII will here
- fh.write(u"Appending some stuff\n")
+ fh.write("Appending some stuff\n")
git("add", test_filename, _cwd=self.tmp_git_repo)
- git("commit", "--squash", self.get_last_commit_hash(), "-m", u"Töo short body", _cwd=self.tmp_git_repo)
+ git("commit", "--squash", self.get_last_commit_hash(), "-m", "Töo short body", _cwd=self.tmp_git_repo)
# Assert that gitlint does not show an error for the fixup commit
output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True)
@@ -125,47 +150,118 @@ 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 = u"1: T5 Title contains the word 'WIP' (case-insensitive): \"squash! Cömmit on WIP master\"\n" + \
- u"3: B5 Body message is too short (14<20): \"Töo short body\"\n"
+ 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)
def test_violations(self):
- commit_msg = u"WIP: This ïs a title.\nContent on the sëcond line"
+ commit_msg = "WIP: This ïs a title.\nContent on the sëcond line"
self.create_simple_commit(commit_msg)
output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
self.assertEqualStdout(output, self.get_expected("test_gitlint/test_violations_1"))
def test_msg_filename(self):
- tmp_commit_msg_file = self.create_tmpfile(u"WIP: msg-fïlename test.")
- output = gitlint("--msg-filename", tmp_commit_msg_file, _tty_in=True, _ok_code=[3])
+ tmp_commit_msg_file = self.create_tmpfile("WIP: msg-fïlename test.")
+ 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 """
- tmp_commit_msg_file = self.create_tmpfile(u"WIP: msg-fïlename NO TTY test.")
+ """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
# no TTY attached to STDIN
# http://amoffat.github.io/sh/sections/special_arguments.html?highlight=_tty_in#err-to-out
# We need to pass some whitespace to _in as sh will otherwise hang, see
# https://github.com/amoffat/sh/issues/427
- output = gitlint("--msg-filename", tmp_commit_msg_file, _in=" ",
- _tty_in=False, _err_to_out=True, _ok_code=[3])
+ 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_git_errors(self):
+ def test_no_git_name_set(self):
+ """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,
+ _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"""
+ 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,
+ _cwd=self.tmp_git_repo,
+ )
+ expected = "Missing git configuration: please set user.email\n"
+ self.assertEqualStdout(output, expected)
+
+ 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])
- expected = u"Current branch has no commits. Gitlint requires at least one commit to function.\n"
+ expected = "Current branch has no commits. Gitlint requires at least one commit to function.\n"
+ self.assertEqualStdout(output, expected)
+
+ 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)
- # Repo has no commits: caused by `git rev-parse`
- output = gitlint(echo(u"WIP: Pïpe test."), "--staged", _cwd=empty_git_repo, _tty_in=False,
- _err_to_out=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE])
+ 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 a41580b..99e76dd 100644
--- a/qa/test_hooks.py
+++ b/qa/test_hooks.py
@@ -1,22 +1,24 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
import os
-from qa.shell import git, gitlint
+
from qa.base import BaseTestCase
+from qa.shell import git, gitlint
class HookTests(BaseTestCase):
- """ Integration tests for gitlint commitmsg hooks"""
-
- VIOLATIONS = ['gitlint: checking commit message...\n',
- u'1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n',
- u'1: T5 Title contains the word \'WIP\' (case-insensitive): "WIP: This ïs a title."\n',
- u'2: B4 Second line is not empty: "Contënt on the second line"\n',
- '3: B6 Body message is missing\n',
- '-----------------------------------------------\n',
- 'gitlint: \x1b[31mYour commit message contains the above violations.\x1b[0m\n']
+ """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()
self.responses = []
self.response_index = 0
self.githook_output = []
@@ -24,20 +26,23 @@ class HookTests(BaseTestCase):
# The '--staged' flag used in the commit-msg hook fetches additional information from the underlying
# git repo which means there already needs to be a commit in the repo
# (as gitlint --staged doesn't work against empty repos)
- self.create_simple_commit(u"Commït Title\n\nCommit Body explaining commit.")
+ self.create_simple_commit("Commït Title\n\nCommit Body explaining commit.")
# install git commit-msg hook and assert output
output_installed = gitlint("install-hook", _cwd=self.tmp_git_repo)
- expected_installed = u"Successfully installed gitlint commit-msg hook in %s/.git/hooks/commit-msg\n" % \
- self.tmp_git_repo
+ commit_msg_hook_path = os.path.join(self.tmp_git_repo, ".git", "hooks", "commit-msg")
+ expected_installed = f"Successfully installed gitlint commit-msg hook in {commit_msg_hook_path}\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 = u"Successfully uninstalled gitlint commit-msg hook from %s/.git/hooks/commit-msg\n" % \
- self.tmp_git_repo
+ commit_msg_hook_path = os.path.join(self.tmp_git_repo, ".git", "hooks", "commit-msg")
+ expected_uninstalled = f"Successfully uninstalled gitlint commit-msg hook from {commit_msg_hook_path}\n"
+
self.assertEqualStdout(output_uninstalled, expected_uninstalled)
+ super().tearDown()
def _violations(self):
# Make a copy of the violations array so that we don't inadvertently edit it in the test (like I did :D)
@@ -48,58 +53,78 @@ class HookTests(BaseTestCase):
def _interact(self, line, stdin):
self.githook_output.append(line)
# Answer 'yes' to question to keep violating commit-msg
- if "Your commit message contains the above violations" in line:
+ if "Your commit message contains violations" in line:
response = self.responses[self.response_index]
- stdin.put("{0}\n".format(response))
+ stdin.put(f"{response}\n")
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
+ )
+
+ 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"[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(u"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)] " +
- u"[master %s] WIP: This ïs a title. Contënt on the second line\n"
- % short_hash,
- " 1 file changed, 0 insertions(+), 0 deletions(-)\n",
- u" create mode 100644 %s\n" % test_filename]
-
- assert len(self.githook_output) == len(expected_output)
+ 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",
+ ]
+
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(u"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",
- u"WIP: This ïs a title.\n",
- u"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(u"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", "")
@@ -107,23 +132,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)] " +
- u"[master %s] WIP: This ïs a title. Contënt on the second line\n" % short_hash,
- " 1 file changed, 0 insertions(+), 0 deletions(-)\n",
- u" create mode 100644 %s\n" % test_filename]
+ 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>
@@ -135,7 +160,7 @@ class HookTests(BaseTestCase):
```
"""
tmp_git_repo = self.create_tmp_git_repo()
- self.create_simple_commit(u"Simple title\n\nContënt in the body", git_repo=tmp_git_repo)
+ self.create_simple_commit("Simple title\n\nContënt in the body", git_repo=tmp_git_repo)
worktree_dir = self.generate_temp_path()
self.tmp_git_repos.append(worktree_dir) # make sure we clean up the worktree afterwards
@@ -144,10 +169,10 @@ class HookTests(BaseTestCase):
output_installed = gitlint("install-hook", _cwd=worktree_dir)
expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg")
- expected_msg = "Successfully installed gitlint commit-msg hook in {0}\n".format(expected_hook_path)
- self.assertEqual(output_installed, expected_msg)
+ expected_msg = f"Successfully installed gitlint commit-msg hook in {expected_hook_path}\n"
+ self.assertEqualStdout(output_installed, expected_msg)
output_uninstalled = gitlint("uninstall-hook", _cwd=worktree_dir)
expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg")
- expected_msg = "Successfully uninstalled gitlint commit-msg hook from {0}\n".format(expected_hook_path)
- self.assertEqual(output_uninstalled, expected_msg)
+ expected_msg = f"Successfully uninstalled gitlint commit-msg hook from {expected_hook_path}\n"
+ self.assertEqualStdout(output_uninstalled, expected_msg)
diff --git a/qa/test_named_rules.py b/qa/test_named_rules.py
new file mode 100644
index 0000000..e3c6908
--- /dev/null
+++ b/qa/test_named_rules.py
@@ -0,0 +1,23 @@
+from qa.base import BaseTestCase
+from qa.shell import gitlint
+
+
+class NamedRuleTests(BaseTestCase):
+ """Integration tests for named rules."""
+
+ def test_named_rule(self):
+ commit_msg = "WIP: thåt dûr bår\n\nSïmple commit body"
+ self.create_simple_commit(commit_msg)
+ config_path = self.get_sample_path("config/named-rules")
+ output = gitlint("--config", config_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
+ self.assertEqualStdout(output, self.get_expected("test_named_rules/test_named_rule_1"))
+
+ def test_named_user_rule(self):
+ commit_msg = "Normal cömmit title\n\nSïmple commit message body"
+ 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]
+ )
+ self.assertEqualStdout(output, self.get_expected("test_named_rules/test_named_user_rule_1"))
diff --git a/qa/test_rules.py b/qa/test_rules.py
new file mode 100644
index 0000000..218a13a
--- /dev/null
+++ b/qa/test_rules.py
@@ -0,0 +1,61 @@
+from qa.base import BaseTestCase
+from qa.shell import gitlint
+
+
+class RuleTests(BaseTestCase):
+ """
+ Tests for specific rules that are worth testing as integration tests.
+ It's not a goal to test every edge case of each rule, that's what the unit tests do.
+ """
+
+ def test_match_regex_rules(self):
+ """
+ Test that T7 (title-match-regex) and B8 (body-match-regex) work as expected.
+ By default, these rules don't do anything, only when setting a custom regex will they run.
+ """
+
+ commit_msg = "Thåt dûr bår\n\nSïmple commit message body"
+ self.create_simple_commit(commit_msg)
+
+ # Assert violations when T7 and B8 regexes don't match
+ output = gitlint("-c", "T7.regex=foo", "-c", "B8.regex=bar", _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
+ self.assertEqualStdout(output, self.get_expected("test_rules/test_match_regex_rules_1"))
+
+ # Assert no violations when T7 and B8 regexes do match
+ output = gitlint("-c", "T7.regex=^Thåt", "-c", "B8.regex=commit message", _cwd=self.tmp_git_repo, _tty_in=True)
+ self.assertEqualStdout(output, "")
+
+ def test_ignore_rules(self):
+ """
+ Test that ignore rules work as expected:
+ ignore-by-title, ignore-by-body, ignore-by-author-name, ignore-body-lines
+ By default, these rules don't do anything, only when setting a custom regex will they run.
+ """
+ commit_msg = "WIP: Commït Tïtle\n\nSïmple commit\tbody\nAnōther Line \nLåst Line"
+ self.create_simple_commit(commit_msg)
+
+ # Assert violations when not ignoring anything
+ output = gitlint(_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
+ self.assertEqualStdout(output, self.get_expected("test_rules/test_ignore_rules_1"))
+
+ # Simple convenience function that passes in common arguments for this test
+ def invoke_gitlint(*args, **kwargs):
+ return gitlint(
+ *args, "-c", "general.regex-style-search=True", **kwargs, _cwd=self.tmp_git_repo, _tty_in=True
+ )
+
+ # ignore-by-title
+ output = invoke_gitlint("-c", "ignore-by-title.regex=Commït")
+ self.assertEqualStdout(output, "")
+
+ # ignore-by-body
+ output = invoke_gitlint("-c", "ignore-by-body.regex=Anōther Line")
+ self.assertEqualStdout(output, "")
+
+ # ignore-by-author-name
+ output = invoke_gitlint("-c", "ignore-by-author-name.regex=gitlint-test-user")
+ self.assertEqualStdout(output, "")
+
+ # ignore-body-lines
+ output = invoke_gitlint("-c", "ignore-body-lines.regex=^Anōther", _ok_code=[2])
+ self.assertEqualStdout(output, self.get_expected("test_rules/test_ignore_rules_2"))
diff --git a/qa/test_stdin.py b/qa/test_stdin.py
index fff636f..04a3de9 100644
--- a/qa/test_stdin.py
+++ b/qa/test_stdin.py
@@ -1,34 +1,31 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
-import io
import subprocess
-from qa.shell import echo, gitlint
+
from qa.base import BaseTestCase
-from qa.utils import ustr, DEFAULT_ENCODING
+from qa.shell import echo, gitlint
+from qa.utils import FILE_ENCODING, TERMINAL_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(u"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 an nothing is piped into gitlint. This occurs in
- CI runners like Jenkins and Gitlab, see https://github.com/jorisroovers/gitlint/issues/42 for details.
- This is the equivalent of doing:
- $ echo -n "" | gitlint
+ """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 = u"WIP: This ïs a title.\nContent on the sëcond line"
+ commit_msg = "WIP: This ïs a title.\nContent on the sëcond line"
self.create_simple_commit(commit_msg)
# We need to set _err_to_out explicitly for sh to merge stdout and stderr output in case there's
@@ -36,21 +33,21 @@ class StdInTests(BaseTestCase):
# http://amoffat.github.io/sh/sections/special_arguments.html?highlight=_tty_in#err-to-out
output = gitlint(echo("-n", ""), _cwd=self.tmp_git_repo, _tty_in=False, _err_to_out=True, _ok_code=[3])
- self.assertEqual(ustr(output), self.get_expected("test_stdin/test_stdin_pipe_empty_1"))
+ self.assertEqualStdout(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(u"WIP: STDIN ïs a file test.")
-
- with io.open(tmp_commit_msg_file, encoding=DEFAULT_ENCODING) as file_handle:
+ tmp_commit_msg_file = self.create_tmpfile("WIP: STDIN ïs a file test.")
+ with open(tmp_commit_msg_file, encoding=FILE_ENCODING) as file_handle: # noqa: SIM117
# We need to use subprocess.Popen() here instead of sh because when passing a file_handle to sh, it will
# deal with reading the file itself instead of passing it on to gitlint as a STDIN. Since we're trying to
# test for the condition where stat.S_ISREG == True that won't work for us here.
- p = subprocess.Popen(u"gitlint", stdin=file_handle, cwd=self.tmp_git_repo,
- stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
- output, _ = p.communicate()
- self.assertEqual(ustr(output), self.get_expected("test_stdin/test_stdin_file_1"))
+ 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(TERMINAL_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 cf7effd..718766c 100644
--- a/qa/test_user_defined.py
+++ b/qa/test_user_defined.py
@@ -1,38 +1,57 @@
-# -*- coding: utf-8 -*-
-# pylint: disable=too-many-function-args,unexpected-keyword-arg
-from qa.shell import gitlint
from qa.base import BaseTestCase
+from qa.shell import gitlint
class UserDefinedRuleTests(BaseTestCase):
- """ Integration tests for user-defined rules."""
+ """Integration tests for user-defined rules."""
- def test_user_defined_rules_examples(self):
+ def test_user_defined_rules_examples1(self):
+ """Test the user defined rules in the top-level `examples/` directory"""
extra_path = self.get_example_path()
- commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ 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=[5])
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"""
+ 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)
+ output = gitlint("--extra-path", extra_path, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
+ self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_examples_2"))
+
def test_user_defined_rules_examples_with_config(self):
+ """Test the user defined rules in the top-level `examples/` directory"""
extra_path = self.get_example_path()
- commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ 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))
def test_user_defined_rules_extra(self):
extra_path = self.get_sample_path("user_rules/extra")
- commit_msg = u"WIP: Thi$ is å title\nContent on the second line"
+ 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=[5])
- self.assertEqualStdout(output, self.get_expected("test_user_defined/test_user_defined_rules_extra_1"))
+ 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}),
+ )
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 eb9869a..d560d86 100644
--- a/qa/utils.py
+++ b/qa/utils.py
@@ -1,9 +1,6 @@
-# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return
-import platform
-import sys
-import os
-
import locale
+import os
+import platform
########################################################################################################################
# PLATFORM_IS_WINDOWS
@@ -23,7 +20,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
@@ -32,68 +29,20 @@ def use_sh_library():
USE_SH_LIB = use_sh_library()
########################################################################################################################
-# DEFAULT_ENCODING
+# TERMINAL_ENCODING
+# Encoding for reading gitlint command output
def getpreferredencoding():
- """ Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars
- on windows and falls back to UTF-8. """
- default_encoding = locale.getpreferredencoding() or "UTF-8"
-
- # On Windows, we mimic git/linux by trying to read the LC_ALL, LC_CTYPE, LANG env vars manually
- # (on Linux/MacOS the `getpreferredencoding()` call will take care of this).
- # We fallback to UTF-8
- if PLATFORM_IS_WINDOWS:
- default_encoding = "UTF-8"
- for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]:
- encoding = os.environ.get(env_var, False)
- if encoding:
- # Support dotted (C.UTF-8) and non-dotted (C or UTF-8) charsets:
- # If encoding contains a dot: split and use second part, otherwise use everything
- dot_index = encoding.find(".")
- if dot_index != -1:
- default_encoding = encoding[dot_index + 1:]
- else:
- default_encoding = encoding
- break
-
- return default_encoding
-
-
-DEFAULT_ENCODING = getpreferredencoding()
+ """Use local.getpreferredencoding() or fallback to UTF-8."""
+ return locale.getpreferredencoding() or "UTF-8"
+
+
+TERMINAL_ENCODING = getpreferredencoding()
-########################################################################################################################
-# Unicode utility functions
-
-
-def ustr(obj):
- """ Python 2 and 3 utility method that converts an obj to unicode in python 2 and to a str object in python 3"""
- if sys.version_info[0] == 2:
- # If we are getting a string, then do an explicit decode
- # else, just call the unicode method of the object
- if type(obj) in [str, basestring]: # pragma: no cover # noqa
- return unicode(obj, DEFAULT_ENCODING) # pragma: no cover # noqa
- else:
- return unicode(obj) # pragma: no cover # noqa
- else:
- if type(obj) in [bytes]:
- return obj.decode(DEFAULT_ENCODING)
- else:
- return str(obj)
-
-
-def sstr(obj):
- """ Python 2 and 3 utility method that converts an obj to a DEFAULT_ENCODING encoded string in python 2
- and to unicode in python 3.
- Especially useful for implementing __str__ methods in python 2: http://stackoverflow.com/a/1307210/381010"""
- if sys.version_info[0] == 2:
- # For lists in python2, remove unicode string representation characters.
- # i.e. ensure lists are printed as ['a', 'b'] and not [u'a', u'b']
- if type(obj) in [list]:
- return [sstr(item) for item in obj] # pragma: no cover # noqa
-
- return unicode(obj).encode(DEFAULT_ENCODING) # pragma: no cover # noqa
- else:
- return obj # pragma: no cover
########################################################################################################################
+# FILE_ENCODING
+
+# Encoding for reading/writing files within the tests, this is always UTF-8
+FILE_ENCODING = "UTF-8"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index e8d531b..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-setuptools
-wheel==0.33.4
-Click==7.0
-sh==1.12.14; sys_platform != 'win32' # sh is not supported on windows
-arrow==0.15.5;
diff --git a/run_tests.sh b/run_tests.sh
deleted file mode 100755
index 23ccb37..0000000
--- a/run_tests.sh
+++ /dev/null
@@ -1,539 +0,0 @@
-#!/bin/bash
-
-
-help(){
- echo "Usage: $0 [OPTION]..."
- echo "Run gitlint's test suite(s) or some convience commands"
- echo " -h, --help Show this help output"
- echo " -c, --clean Clean the project of temporary files"
- echo " -p, --pep8 Run pep8 checks"
- echo " -l, --lint Run pylint checks"
- echo " -g, --git Run gitlint checks"
- echo " -i, --integration Run integration tests"
- echo " -b, --build Run build tests"
- echo " -a, --all Run all tests and checks (unit, integration, pep8, git)"
- echo " -e, --envs [ENV1],[ENV2] Run tests against specified python environments"
- echo " (envs: 27,35,36,37,pypy2,pypy35)."
- echo " Also works for integration, pep8 and lint tests."
- echo " -C, --container Run the specified command in the container for the --envs specified"
- echo " --all-env Run all tests against all python environments"
- echo " --install Install virtualenvs for the --envs specified"
- echo " --uninstall Remove virtualenvs for the --envs specified"
- echo " --install-container Build and run Docker container for the --envs specified"
- echo " --uninstall-container Kill Docker container for the --envs specified"
- echo " --exec [CMD] Execute [CMD] in the --envs specified"
- echo " -s, --stats Show some project stats"
- echo " --no-coverage Don't make a unit test coverage report"
- echo ""
- exit 0
-}
-
-RED="\033[31m"
-YELLOW="\033[33m"
-BLUE="\033[94m"
-GREEN="\033[32m"
-NO_COLOR="\033[0m"
-
-title(){
- MSG="$BLUE$1$NO_COLOR"
- echo -e $MSG
-}
-
-subtitle(){
- MSG="$YELLOW$1$NO_COLOR"
- echo -e $MSG
-}
-
-fatal(){
- MSG="$RED$1$NO_COLOR"
- echo -e $MSG
- exit 1
-}
-
-assert_root(){
- if [ "$(id -u)" != "0" ]; then
- fatal "$1"
- fi
-}
-
-# Utility method that prints SUCCESS if a test was succesful, or FAIL together with the test output
-handle_test_result(){
- EXIT_CODE=$1
- RESULT="$2"
- # Change color to red or green depending on SUCCESS
- if [ $EXIT_CODE -eq 0 ]; then
- echo -e "${GREEN}SUCCESS"
- else
- echo -e "${RED}FAIL"
- fi
- # Print RESULT if not empty
- if [ -n "$RESULT" ] ; then
- echo -e "\n$RESULT"
- fi
- # Reset color
- echo -e "${NO_COLOR}"
-}
-
-run_pep8_check(){
- # FLAKE 8
- target=${testargs:-"gitlint qa examples"}
- echo -ne "Running flake8..."
- RESULT=$(flake8 $target)
- local exit_code=$?
- handle_test_result $exit_code "$RESULT"
- return $exit_code
-}
-
-run_unit_tests(){
- clean
- # py.test -s => print standard output (i.e. show print statement output)
- # -rw => print warnings
- OMIT="*pypy*,*venv*,*virtualenv*,*gitlint/tests/*"
- target=${testargs:-"gitlint"}
- coverage run --omit=$OMIT -m pytest -rw -s $target
- TEST_RESULT=$?
- if [ $include_coverage -eq 1 ]; then
- COVERAGE_REPORT=$(coverage report -m)
- echo "$COVERAGE_REPORT"
- fi
-
- return $TEST_RESULT;
-}
-
-run_integration_tests(){
- clean
- # Make sure the version of python used by the git hooks in our integration tests
- # is the same one as the one that is currently active. In order to achieve this, we need to set
- # GIT_EXEC_PATH (https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables) to the current PATH, otherwise
- # the git hooks will use the default PATH variable as defined by .bashrc which doesn't contain the current
- # virtualenv's python binary path.
- export GIT_EXEC_PATH="$PATH"
-
- echo ""
- gitlint --version
- echo -e "Using $(which gitlint)\n"
-
- # py.test -s => print standard output (i.e. show print statement output)
- # -rw => print warnings
- target=${testargs:-"qa/"}
- py.test -s $target
-}
-
-run_git_check(){
- echo -ne "Running gitlint...${RED}"
- RESULT=$(gitlint 2>&1)
- local exit_code=$?
- handle_test_result $exit_code "$RESULT"
- # FUTURE: check if we use str() function: egrep -nriI "( |\(|\[)+str\(" gitlint | egrep -v "\w*#(.*)"
- return $exit_code
-}
-
-run_lint_check(){
- echo -ne "Running pylint...${RED}"
- target=${testargs:-"gitlint qa"}
- RESULT=$(pylint $target --rcfile=".pylintrc" -r n)
- local exit_code=$?
- handle_test_result $exit_code "$RESULT"
- return $exit_code
-}
-
-run_build_test(){
- clean
- datestr=$(date +"%Y-%m-%d-%H-%M-%S")
- temp_dir="/tmp/gitlint-build-test-$datestr"
-
- # Copy gitlint to a new temp dir
- echo -n "Copying gitlint to $temp_dir..."
- mkdir "$temp_dir"
- rsync -az --exclude ".vagrant" --exclude ".git" --exclude ".venv*" . "$temp_dir"
- echo -e "${GREEN}DONE${NO_COLOR}"
-
- # Update the version to include a timestamp
- echo -n "Writing new version to file..."
- version_file="$temp_dir/gitlint/__init__.py"
- version_str="$(cat $version_file)"
- version_str="${version_str:0:${#version_str}-1}-$datestr\""
- echo "$version_str" > $version_file
- echo -e "${GREEN}DONE${NO_COLOR}"
- # Attempt to build the package
- echo "Building package ..."
- pushd "$temp_dir"
- # Copy stdout file descriptor so we can both print output to stdout as well as capture it in a variable
- # https://stackoverflow.com/questions/12451278/bash-capture-stdout-to-a-variable-but-still-display-it-in-the-console
- exec 5>&1
- output=$(python setup.py sdist bdist_wheel | tee /dev/fd/5)
- local exit_code=$?
- popd
- # Cleanup :-)
- rm -rf "$temp_dir"
-
- # Print success/no success
- if [ $exit_code -gt 0 ]; then
- echo -e "Building package...${RED}FAIL${NO_COLOR}"
- else
- echo -e "Building package...${GREEN}SUCCESS${NO_COLOR}"
- fi
-
- return $exit_code
-}
-
-run_stats(){
- clean # required for py.test to count properly
- echo "*** Code ***"
- radon raw -s gitlint | tail -n 11
- echo "*** Docs ***"
- echo " Markdown: $(cat docs/*.md | wc -l | tr -d " ") lines"
- echo "*** Tests ***"
- nr_unit_tests=$(py.test gitlint/ --collect-only | grep TestCaseFunction | wc -l)
- nr_integration_tests=$(py.test qa/ --collect-only | grep TestCaseFunction | wc -l)
- echo " Unit Tests: ${nr_unit_tests//[[:space:]]/}"
- echo " Integration Tests: ${nr_integration_tests//[[:space:]]/}"
- echo "*** Git ***"
- echo " Commits: $(git rev-list --all --count)"
- echo " Commits (master): $(git rev-list master --count)"
- echo " First commit: $(git log --pretty="%aD" $(git rev-list --max-parents=0 HEAD))"
- echo " Contributors: $(git log --format='%aN' | sort -u | wc -l | tr -d ' ')"
- echo " Releases (tags): $(git tag --list | wc -l | tr -d ' ')"
- latest_tag=$(git tag --sort=creatordate | tail -n 1)
- echo " Latest Release (tag): $latest_tag"
- echo " Commits since $latest_tag: $(git log --format=oneline HEAD...$latest_tag | wc -l | tr -d ' ')"
- echo " Line changes since $latest_tag: $(git diff --shortstat $latest_tag)"
- # PyPi API: https://pypistats.org/api/
- echo "*** PyPi ***"
- info=$(curl -Ls https://pypi.python.org/pypi/gitlint/json)
- echo " Current version: $(echo $info | jq -r .info.version)"
- echo "*** PyPi (Downloads) ***"
- overall_stats=$(curl -s https://pypistats.org/api/packages/gitlint/overall)
- recent_stats=$(curl -s https://pypistats.org/api/packages/gitlint/recent)
- echo " Last 6 Months: $(echo $overall_stats | jq -r '.data[].downloads' | awk '{sum+=$1} END {print sum}')"
- echo " Last Month: $(echo $recent_stats | jq .data.last_month)"
- echo " Last Week: $(echo $recent_stats | jq .data.last_week)"
- echo " Last Day: $(echo $recent_stats | jq .data.last_day)"
-}
-
-clean(){
- echo -n "Cleaning the *.pyc, site/, build/, dist/ and all __pycache__ directories..."
- find gitlint -type d -name "__pycache__" -exec rm -rf {} \; 2> /dev/null
- find qa -type d -name "__pycache__" -exec rm -rf {} \; 2> /dev/null
- find gitlint -iname *.pyc -exec rm -rf {} \; 2> /dev/null
- find qa -iname *.pyc -exec rm -rf {} \; 2> /dev/null
- rm -rf "site" "dist" "build"
- echo -e "${GREEN}DONE${NO_COLOR}"
-}
-
-run_all(){
- local exit_code=0
- subtitle "# UNIT TESTS ($(python --version 2>&1), $(which python)) #"
- run_unit_tests
- exit_code=$((exit_code + $?))
- subtitle "# INTEGRATION TESTS ($(python --version 2>&1), $(which python)) #"
- run_integration_tests
- exit_code=$((exit_code + $?))
- subtitle "# BUILD TEST ($(python --version 2>&1), $(which python)) #"
- run_build_test
- exit_code=$((exit_code + $?))
- subtitle "# STYLE CHECKS ($(python --version 2>&1), $(which python)) #"
- run_pep8_check
- exit_code=$((exit_code + $?))
- run_lint_check
- exit_code=$((exit_code + $?))
- run_git_check
- exit_code=$((exit_code + $?))
- return $exit_code
-}
-
-uninstall_virtualenv(){
- version="$1"
- venv_name=".venv$version"
- echo -n "Uninstalling $venv_name..."
- deactivate 2> /dev/null # deactivate any active environment
- rm -rf "$venv_name"
- echo -e "${GREEN}DONE${NO_COLOR}"
-}
-
-install_virtualenv(){
- version="$1"
- venv_name=".venv$version"
-
- # For regular python: the binary has a dot between the first and second char of the version string
- python_binary="/usr/bin/python${version:0:1}.${version:1:1}"
-
- # For pypy: custom path + fetch from the web if not installed (=distro agnostic)
- if [[ $version == *"pypy2"* ]]; then
- python_binary="/opt/pypy2.7-v7.3.0-linux64/bin/pypy"
- if [ ! -f $python_binary ]; then
- assert_root "Must be root to install pypy2.7, use sudo"
- title "### DOWNLOADING PYPY2 ($pypy_archive) ###"
- pushd "/opt"
- pypy_archive="pypy2.7-v7.3.0-linux64.tar.bz2"
- wget "https://bitbucket.org/pypy/pypy/downloads/$pypy_archive"
- title "### EXTRACTING PYPY TARBALL ($pypy_archive) ###"
- tar xvf $pypy_archive
- popd
- fi
- fi
-
- if [[ $version == *"pypy35"* ]]; then
- python_binary="/opt/pypy3.5-v7.0.0-linux64/bin/pypy3"
- if [ ! -f $python_binary ]; then
- assert_root "Must be root to install pypy3.5, use sudo"
- title "### DOWNLOADING PYPY3 ($pypy_archive) ###"
- pushd "/opt"
- pypy_archive="pypy3.5-v7.0.0-linux64.tar.bz2"
- wget "https://bitbucket.org/pypy/pypy/downloads/$pypy_archive"
- title "### EXTRACTING PYPY TARBALL ($pypy_archive) ###"
- tar xvf $pypy_archive
- popd
- fi
- fi
-
- title "### INSTALLING $venv_name ($python_binary) ###"
- deactivate 2> /dev/null # deactivate any active environment
- virtualenv -p "$python_binary" "$venv_name"
- source "${venv_name}/bin/activate"
- pip install --ignore-requires-python -r requirements.txt
- pip install --ignore-requires-python -r test-requirements.txt
- deactivate 2> /dev/null
-}
-
-container_name(){
- echo "jorisroovers/gitlint:dev-python-$1"
-}
-
-start_container(){
- container_name="$1"
- echo -n "Starting container $1..."
- container_details=$(docker container inspect $container_name 2>&1 > /dev/null)
- local exit_code=$?
- if [ $exit_code -gt 0 ]; then
- docker run -t -d -v $(pwd):/gitlint --name $container_name $container_name
- exit_code=$?
- echo -e "${GREEN}DONE${NO_COLOR}"
- else
- echo -e "${YELLOW}SKIP (ALREADY RUNNING)${NO_COLOR}"
- exit_code=0
- fi
- return $exit_code
-}
-
-stop_container(){
- container_name="$1"
- echo -n "Stopping container $container_name..."
- result=$(docker kill $container_name 2> /dev/null)
- local exit_code=$?
- if [ $exit_code -gt 0 ]; then
- echo -e "${YELLOW}SKIP (DOES NOT EXIST)${NO_COLOR}"
- exit_code=0
- else
- echo -e "${GREEN}DONE${NO_COLOR}"
- fi
- return $exit_code
-}
-
-install_container(){
- local exit_code=0
- python_version="$1"
- python_version_dotted="${python_version:0:1}.${python_version:1:1}"
- container_name="$(container_name $python_version)"
-
- title "Installing container $container_name"
- image_details=$(docker image inspect $container_name 2> /dev/null)
- tmp_exit_code=$?
- if [ $tmp_exit_code -gt 0 ]; then
- subtitle "Building container image from python:${python_version_dotted}-stretch..."
- docker build -f Dockerfile.dev --build-arg python_version_dotted="$python_version_dotted" -t $container_name .
- exit_code=$?
- else
- subtitle "Building container image from python:${python_version_dotted}-stretch...SKIP (ALREADY-EXISTS)"
- echo " Use '$0 --uninstall-container; $0 --install-container' to rebuild"
- exit_code=0
- fi
- return $exit_code
-}
-
-uninstall_container(){
- python_version="$1"
- container_name="$(container_name $python_version)"
-
- echo -n "Removing container image $container_name..."
- image_details=$(docker image inspect $container_name 2> /dev/null)
- tmp_exit_code=$?
- if [ $tmp_exit_code -gt 0 ]; then
- echo -e "${YELLOW}SKIP (DOES NOT EXIST)${NO_COLOR}"
- exit_code=0
- else
- result=$(docker image rm -f $container_name 2> /dev/null)
- exit_code=$?
- fi
- return $exit_code
-}
-
-assert_specific_env(){
- if [ -z "$1" ] || [ "$1" == "default" ]; then
- fatal "ERROR: Please specify one or more valid python environments using --envs: 27,35,36,37,pypy2,pypy35"
- exit 1
- fi
-}
-
-switch_env(){
- if [ "$1" != "default" ]; then
- # If we activated a virtualenv within this script, deactivate it
- deactivate 2> /dev/null # deactivate any active environment
-
- # If this script was run from within an existing virtualenv, manually remove the current VIRTUAL_ENV from the
- # current path. This ensures that our PATH is clean of that virtualenv.
- # Note that the 'deactivate' function from the virtualenv is not available here unless the script was invoked
- # as 'source ./run_tests.sh').
- # Thanks internet stranger! https://unix.stackexchange.com/a/496050/38465
- if [ ! -z "$VIRTUAL_ENV" ]; then
- export PATH=$(echo $PATH | tr ":" "\n" | grep -v "$VIRTUAL_ENV" | tr "\n" ":");
- fi
- set -e # Let's error out if you try executing against a non-existing env
- source "/vagrant/.venv${1}/bin/activate"
- set +e
- fi
- title "### PYTHON ($(python --version 2>&1), $(which python)) ###"
-}
-
-run_in_container(){
- python_version="$1"
- envs="$2"
- args="$3"
- container_name="$(container_name $python_version)"
- container_command=$(echo "$0 $args" | sed -E "s/( -e | --envs )$envs//" | sed -E "s/( --container| -C)//")
-
- title "### CONTAINER $container_name"
- start_container "$container_name"
- docker exec "$container_name" $container_command
-}
-##############################################################################
-# The magic starts here: argument parsing and determining what to do
-
-
-# default behavior
-just_pep8=0
-just_lint=0
-just_git=0
-just_integration_tests=0
-just_build_tests=0
-just_stats=0
-just_all=0
-just_clean=0
-just_install=0
-just_uninstall=0
-just_install_container=0
-just_uninstall_container=0
-just_exec=0
-container_enabled=0
-include_coverage=1
-envs="default"
-cmd=""
-testargs=""
-original_args="$@"
-while [ "$#" -gt 0 ]; do
- case "$1" in
- -h|--help) shift; help;;
- -c|--clean) shift; just_clean=1;;
- -p|--pep8) shift; just_pep8=1;;
- -l|--lint) shift; just_lint=1;;
- -g|--git) shift; just_git=1;;
- -b|--build) shift; just_build_tests=1;;
- -s|--stats) shift; just_stats=1;;
- -i|--integration) shift; just_integration_tests=1;;
- -a|--all) shift; just_all=1;;
- -e|--envs) shift; envs="$1"; shift;;
- --exec) shift; just_exec=1; cmd="$1"; shift;;
- --install) shift; just_install=1;;
- --uninstall) shift; just_uninstall=1;;
- --install-container) shift; just_install_container=1;;
- --uninstall-container) shift; just_uninstall_container=1;;
- --all-env) shift; envs="all";;
- -C|--container) shift; container_enabled=1;;
- --no-coverage)shift; include_coverage=0;;
- *) testargs="$1"; shift;
- esac
-done
-
-old_virtualenv="$VIRTUAL_ENV" # Store the current virtualenv so we can restore it at the end
-
-trap exit_script INT # Exit on interrupt (i.e. ^C)
-exit_script(){
- echo -e -n $NO_COLOR # make sure we don't have color left on the terminal
- exit
-}
-
-exit_code=0
-
-# If the users specified 'all', then just replace $envs with the list of all envs
-if [ "$envs" == "all" ]; then
- envs="27,35,36,37,38,pypy2,pypy35"
-fi
-original_envs="$envs"
-envs=$(echo "$envs" | tr ',' '\n') # Split the env list on comma so we can loop through it
-
-for environment in $envs; do
-
- if [ $container_enabled -eq 1 ]; then
- run_in_container "$environment" "$original_envs" "$original_args"
- elif [ $just_pep8 -eq 1 ]; then
- switch_env "$environment"
- run_pep8_check
- elif [ $just_stats -eq 1 ]; then
- switch_env "$environment"
- run_stats
- elif [ $just_integration_tests -eq 1 ]; then
- switch_env "$environment"
- run_integration_tests
- elif [ $just_build_tests -eq 1 ]; then
- switch_env "$environment"
- run_build_test
- elif [ $just_git -eq 1 ]; then
- switch_env "$environment"
- run_git_check
- elif [ $just_lint -eq 1 ]; then
- switch_env "$environment"
- run_lint_check
- elif [ $just_all -eq 1 ]; then
- switch_env "$environment"
- run_all
- elif [ $just_clean -eq 1 ]; then
- switch_env "$environment"
- clean
- elif [ $just_exec -eq 1 ]; then
- switch_env "$environment"
- eval "$cmd"
- elif [ $just_uninstall -eq 1 ]; then
- assert_specific_env "$environment"
- uninstall_virtualenv "$environment"
- elif [ $just_install -eq 1 ]; then
- assert_specific_env "$environment"
- install_virtualenv "$environment"
- elif [ $just_install_container -eq 1 ]; then
- assert_specific_env "$environment"
- install_container "$environment"
- elif [ $just_uninstall_container -eq 1 ]; then
- assert_specific_env "$environment"
- uninstall_container "$environment"
- else
- switch_env "$environment"
- run_unit_tests
- fi
- # We add up all the exit codes and use that as our final exit code
- # While we lose the meaning of the exit code per individual environment by doing this, we do ensure that the end
- # exit code reflects success (=0) or failure (>0).
- exit_code=$((exit_code + $?))
-done
-
-# reactivate the virtualenv if we had one before
-if [ ! -z "$old_virtualenv" ]; then
- source "$old_virtualenv/bin/activate"
-fi
-
-# Report some overall status
-if [ $exit_code -eq 0 ]; then
- echo -e "\n${GREEN}### OVERALL STATUS: SUCCESS ###${NO_COLOR}"
-else
- echo -e "\n${RED}### OVERALL STATUS: FAILURE ###${NO_COLOR}"
-fi
-
-exit $exit_code
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 7c2b287..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal = 1 \ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 278e065..0000000
--- a/setup.py
+++ /dev/null
@@ -1,105 +0,0 @@
-#!/usr/bin/env python
-from __future__ import print_function
-from setuptools import setup, find_packages
-import io
-import re
-import os
-import platform
-import sys
-
-# There is an issue with building python packages in a shared vagrant directory because of how setuptools works
-# in python < 2.7.9. We solve this by deleting the filesystem hardlinking capability during build.
-# See: http://stackoverflow.com/a/22147112/381010
-try:
- del os.link
-except:
- pass # Not all OSes (e.g. windows) support os.link
-
-description = "Git commit message linter written in python, checks your commit messages for style."
-long_description = """
-Great for use as a commit-msg git hook or as part of your gating script in a CI pipeline (e.g. jenkins, gitlab).
-Many of the gitlint validations are based on `well-known`_ community_ `standards`_, others are based on checks that
-we've found useful throughout the years. Gitlint has sane defaults, but you can also easily customize it to your
-own liking.
-
-Demo and full documentation on `jorisroovers.github.io/gitlint`_.
-To see what's new in the latest release, visit the CHANGELOG_.
-
-Source code on `github.com/jorisroovers/gitlint`_.
-
-.. _well-known: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
-.. _community: http://addamhardy.com/blog/2013/06/05/good-commit-messages-and-enforcing-them-with-git-hooks/
-.. _standards: http://chris.beams.io/posts/git-commit/
-.. _jorisroovers.github.io/gitlint: https://jorisroovers.github.io/gitlint
-.. _CHANGELOG: https://github.com/jorisroovers/gitlint/blob/master/CHANGELOG.md
-.. _github.com/jorisroovers/gitlint: https://github.com/jorisroovers/gitlint
-"""
-
-
-# shamelessly stolen from mkdocs' setup.py: https://github.com/mkdocs/mkdocs/blob/master/setup.py
-def get_version(package):
- """Return package version as listed in `__version__` in `init.py`."""
- init_py = io.open(os.path.join(package, '__init__.py'), encoding="UTF-8").read()
- return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1)
-
-
-setup(
- name="gitlint",
- version=get_version("gitlint"),
- description=description,
- long_description=long_description,
- classifiers=[
- "Development Status :: 5 - Production/Stable",
- "Operating System :: OS Independent",
- "Programming Language :: Python",
- "Programming Language :: Python :: 2.7",
- "Programming Language :: Python :: 3.5",
- "Programming Language :: Python :: 3.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: Implementation :: CPython",
- "Programming Language :: Python :: Implementation :: PyPy",
- "Environment :: Console",
- "Intended Audience :: Developers",
- "Topic :: Software Development :: Quality Assurance",
- "Topic :: Software Development :: Testing",
- "License :: OSI Approved :: MIT License"
- ],
- install_requires=[
- 'Click==7.0',
- 'arrow==0.15.5',
- ],
- extras_require={
- ':sys_platform != "win32"': [
- 'sh==1.12.14',
- ],
- },
- keywords='gitlint git lint',
- author='Joris Roovers',
- url='https://github.com/jorisroovers/gitlint',
- license='MIT',
- package_data={
- 'gitlint': ['files/*']
- },
- packages=find_packages(exclude=["examples"]),
- entry_points={
- "console_scripts": [
- "gitlint = gitlint.cli:cli",
- ],
- },
-)
-
-# Print a red deprecation warning for python 2.6 users
-if sys.version_info[0] == 2 and sys.version_info[1] <= 6:
- msg = "\033[31mDEPRECATION: Python 2.6 or below are no longer supported by gitlint or the Python core team." + \
- "Please upgrade your Python to a later version.\033[0m"
- print(msg)
-
-# Print a red deprecation warning for python 2.6 users
-PLATFORM_IS_WINDOWS = "windows" in platform.system().lower()
-if PLATFORM_IS_WINDOWS:
- msg = "\n\n\n\n\n****************\n" + \
- "WARNING: Gitlint support for Windows is still experimental and there are some known issues: " + \
- "https://github.com/jorisroovers/gitlint/issues?q=is%3Aissue+is%3Aopen+label%3Awindows " + \
- "\n*******************"
- print(msg)
diff --git a/test-requirements.txt b/test-requirements.txt
deleted file mode 100644
index 3afab45..0000000
--- a/test-requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-unittest2==1.1.0; python_version <= '2.7'
-flake8==3.7.9
-coverage==4.5.3
-python-coveralls==2.9.2
-radon==4.1.0
-mock==3.0.5 # mock 4.x no longer supports Python 2.7
-pytest==4.6.3; # pytest 5.x no longer supports Python 2.7
-pylint==1.9.4; python_version == '2.7'
-pylint==2.3.1; python_version >= '3.4'
--e .
diff --git a/tools/changelog.py b/tools/changelog.py
new file mode 100755
index 0000000..24b74a8
--- /dev/null
+++ b/tools/changelog.py
@@ -0,0 +1,51 @@
+# ruff: noqa: T201 (Allow print statements)
+# Simple script to generate a rough changelog from git log.
+# This changelog is manually edited before it goes into CHANGELOG.md
+
+import re
+import subprocess
+import sys
+from collections import defaultdict
+
+if len(sys.argv) != 2:
+ print("Usage: python changelog.py <tag>")
+ sys.exit(1)
+
+tag = sys.argv[1]
+# Get all commits since the last release
+cmd = ["git", "log", "--pretty=%s|%aN", f"{tag}..HEAD"]
+log_lines = subprocess.Popen(cmd, stdout=subprocess.PIPE).stdout.read().decode("UTF-8")
+log_lines = log_lines.split("\n")[:-1]
+
+# Group commits by type
+commit_groups = defaultdict(list)
+for log_line in log_lines:
+ message, author = log_line.split("|")
+ # skip dependabot commits
+ if author == "dependabot[bot]":
+ group = "dependabot"
+ else:
+ type_parts = message.split(":")
+ if len(type_parts) == 1:
+ group = "other"
+ else:
+ group = type_parts[0]
+
+ commit_groups[group].append((message, author))
+
+# Print the changelog
+for group, commits in commit_groups.items():
+ print(group)
+ for message, author in commits:
+ # Thank authors other than maintainer
+ author_thanks = ""
+ if author != "Joris Roovers":
+ author_thanks = f" - Thanks {author}"
+
+ # Find the issue number in message using regex, format: (#1234)
+ issue_number = re.search(r"\(#(\d+)\)", message)
+ if issue_number:
+ issue_url = f"https://github.com/jorisroovers/gitlint/issues/{issue_number.group(1)}"
+ message = message.replace(issue_number.group(0), f"([#{issue_number.group(1)}]({issue_url}))")
+
+ print(f" - {message}{author_thanks}")
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/stats.sh b/tools/stats.sh
new file mode 100755
index 0000000..ada2658
--- /dev/null
+++ b/tools/stats.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Script that displays some interesting stats about the gitlint project (LOC, # commits, downloads, etc)
+
+BLUE="\033[94m"
+NO_COLOR="\033[0m"
+
+title(){
+ echo -e "$BLUE=== $1 ===$NO_COLOR"
+}
+
+title Code
+radon raw -s gitlint-core | tail -n 11 | sed 's/^ //'
+
+title Docs
+echo "Markdown: $(cat docs/*.md | wc -l | tr -d " ") lines"
+
+title Tests
+nr_unit_tests=$(py.test gitlint-core/ --collect-only | grep TestCaseFunction | wc -l)
+nr_integration_tests=$(py.test qa/ --collect-only | grep TestCaseFunction | wc -l)
+echo "Unit Tests: ${nr_unit_tests//[[:space:]]/}"
+echo "Integration Tests: ${nr_integration_tests//[[:space:]]/}"
+
+title Git
+echo "Commits: $(git rev-list --all --count)"
+echo "Commits (main): $(git rev-list main --count)"
+echo "First commit: $(git log --pretty="%aD" $(git rev-list --max-parents=0 HEAD))"
+echo "Contributors: $(git log --format='%aN' | sort -u | wc -l | tr -d ' ')"
+echo "Releases (tags): $(git tag --list | wc -l | tr -d ' ')"
+latest_tag=$(git tag --sort=creatordate | tail -n 1)
+echo "Latest Release (tag): $latest_tag"
+echo "Commits since $latest_tag: $(git log --format=oneline HEAD...$latest_tag | wc -l | tr -d ' ')"
+echo "Line changes since $latest_tag: $(git diff --shortstat $latest_tag)"
+
+# PyPi API: https://pypistats.org/api/
+title PyPi
+info=$(curl -Ls https://pypi.python.org/pypi/gitlint/json)
+echo "Current version: $(echo $info | jq -r .info.version)"
+
+title "PyPI (Downloads)"
+overall_stats=$(curl -s https://pypistats.org/api/packages/gitlint/overall)
+recent_stats=$(curl -s https://pypistats.org/api/packages/gitlint/recent)
+echo "Last 6 Months: $(echo $overall_stats | jq -r '.data[].downloads' | awk '{sum+=$1} END {print sum}')"
+echo "Last Month: $(echo $recent_stats | jq .data.last_month)"
+echo "Last Week: $(echo $recent_stats | jq .data.last_week)"
+echo "Last Day: $(echo $recent_stats | jq .data.last_day)" \ No newline at end of file
diff --git a/tools/windows/create-test-repo.bat b/tools/windows/create-test-repo.bat
index 4220ad1..54cf146 100644
--- a/tools/windows/create-test-repo.bat
+++ b/tools/windows/create-test-repo.bat
@@ -2,19 +2,20 @@
:: Use pushd, so we can popd back at the end (directory changes are not contained inside batch file)
PUSHD C:\Windows\Temp
-:: Determine unique git repo name
-:: Note that date/time parsing on windows is locale dependent, so this might not work on every windows machine
-:: (see https://stackoverflow.com/questions/203090/how-do-i-get-current-date-time-on-the-windows-command-line-in-a-suitable-format)
@echo off
-For /f "tokens=2-4 delims=/ " %%a in ('date /t') do (set mydate=%%c-%%a-%%b)
-For /f "tokens=1-2 delims=/:" %%a in ("%TIME%") do (set mytime=%%a-%%b)
-echo %mydate%_%mytime%
-set Reponame=gitlint-test-%mydate%_%mytime%
+:: Determine unique git repo name
+:: We use Python to determine to get a datetime stamp since other workarounds in BATCH are locale dependent
+:: Note that we double escape the % in the format string to %%
+FOR /F "tokens=* USEBACKQ" %%F IN (`python -c "import datetime; print(datetime.datetime.now().strftime('%%Y-%%m-%%d_%%H-%%M-%%S'))"`) DO (
+SET datetime=%%F
+)
+echo %datetime%
+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
@@ -28,8 +29,9 @@ echo "tëst 123" > test.txt
git add test.txt
git commit -m "test cömmit title" -m "test cömmit body that has a bit more text"
-:: echo. -> the dot allows us to print and empty line
+:: echo. -> the dot allows us to print an empty line
echo.
echo Created C:\Windows\Temp\%Reponame%
+
:: Move back to original dir
-POPD
+POPD \ No newline at end of file