summaryrefslogtreecommitdiffstats
path: root/gitlint
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2020-11-03 06:07:45 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2020-11-03 06:07:45 +0000
commit5f208e04c159791e668031a7fa83f98724ec8d24 (patch)
tree4b58b42fd2a91a14871010e2dd39369a839ae383 /gitlint
parentAdding upstream version 0.13.1. (diff)
downloadgitlint-5f208e04c159791e668031a7fa83f98724ec8d24.tar.xz
gitlint-5f208e04c159791e668031a7fa83f98724ec8d24.zip
Adding upstream version 0.14.0.upstream/0.14.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'gitlint')
-rw-r--r--gitlint/__init__.py2
-rw-r--r--gitlint/cli.py144
-rw-r--r--gitlint/config.py65
-rw-r--r--gitlint/contrib/rules/conventional_commit.py2
-rw-r--r--gitlint/display.py5
-rw-r--r--gitlint/files/commit-msg78
-rw-r--r--gitlint/files/gitlint25
-rw-r--r--gitlint/git.py34
-rw-r--r--gitlint/options.py37
-rw-r--r--gitlint/rule_finder.py41
-rw-r--r--gitlint/rules.py131
-rw-r--r--gitlint/shell.py18
-rw-r--r--gitlint/tests/base.py41
-rw-r--r--gitlint/tests/cli/test_cli.py78
-rw-r--r--gitlint/tests/cli/test_cli_hooks.py172
-rw-r--r--gitlint/tests/config/test_config.py62
-rw-r--r--gitlint/tests/config/test_config_builder.py81
-rw-r--r--gitlint/tests/config/test_config_precedence.py26
-rw-r--r--gitlint/tests/contrib/rules/__init__.py0
-rw-r--r--gitlint/tests/contrib/rules/test_conventional_commit.py (renamed from gitlint/tests/contrib/test_conventional_commit.py)4
-rw-r--r--gitlint/tests/contrib/rules/test_signedoff_by.py (renamed from gitlint/tests/contrib/test_signedoff_by.py)0
-rw-r--r--gitlint/tests/contrib/test_contrib_rules.py2
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_contrib_1 (renamed from gitlint/tests/expected/test_cli/test_contrib_1)2
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_debug_1 (renamed from gitlint/tests/expected/test_cli/test_debug_1)22
-rw-r--r--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/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/tests/expected/cli/test_cli/test_input_stream_debug_2 (renamed from gitlint/tests/expected/test_cli/test_input_stream_debug_2)10
-rw-r--r--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/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/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/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2)14
-rw-r--r--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/tests/expected/cli/test_cli/test_lint_staged_stdin_2 (renamed from gitlint/tests/expected/test_cli/test_lint_staged_stdin_2)14
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_named_rules_14
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_named_rules_282
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout5
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr6
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout14
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout4
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout8
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout5
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout5
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr2
-rw-r--r--gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout4
-rw-r--r--gitlint/tests/git/test_git.py10
-rw-r--r--gitlint/tests/git/test_git_commit.py40
-rw-r--r--gitlint/tests/rules/test_body_rules.py46
-rw-r--r--gitlint/tests/rules/test_configuration_rules.py38
-rw-r--r--gitlint/tests/rules/test_meta_rules.py9
-rw-r--r--gitlint/tests/rules/test_rules.py5
-rw-r--r--gitlint/tests/rules/test_title_rules.py34
-rw-r--r--gitlint/tests/rules/test_user_rules.py137
-rw-r--r--gitlint/tests/samples/commit_message/no-violations6
-rw-r--r--gitlint/tests/samples/config/named-rules8
-rw-r--r--gitlint/tests/test_hooks.py14
-rw-r--r--gitlint/tests/test_lint.py94
-rw-r--r--gitlint/tests/test_options.py122
-rw-r--r--gitlint/tests/test_utils.py20
-rw-r--r--gitlint/utils.py33
65 files changed, 1514 insertions, 363 deletions
diff --git a/gitlint/__init__.py b/gitlint/__init__.py
index 7e0dc0e..9e78220 100644
--- a/gitlint/__init__.py
+++ b/gitlint/__init__.py
@@ -1 +1 @@
-__version__ = "0.13.1"
+__version__ = "0.14.0"
diff --git a/gitlint/cli.py b/gitlint/cli.py
index 4553fda..f284792 100644
--- a/gitlint/cli.py
+++ b/gitlint/cli.py
@@ -19,14 +19,19 @@ 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
+from gitlint.shell import shell
+from gitlint.utils import ustr, LOG_FORMAT, IS_PY2
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
-LOG = logging.getLogger(__name__)
+# 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(Exception):
@@ -51,6 +56,7 @@ def log_system_info():
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("DEFAULT_ENCODING: %s", gitlint.utils.DEFAULT_ENCODING)
def build_config( # pylint: disable=too-many-arguments
@@ -164,27 +170,44 @@ def build_git_context(lint_config, msg_filename, refspec):
return GitContext.from_local_repository(lint_config.target, refspec)
+class ContextObj(object):
+ """ Simple class to hold data that is passed between Click commands via the Click context. """
+
+ def __init__(self, config, config_builder, refspec, msg_filename, gitcontext=None):
+ self.config = config
+ self.config_builder = config_builder
+ self.refspec = refspec
+ self.msg_filename = msg_filename
+ self.gitcontext = gitcontext
+
+
@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),
+@click.option('--target', envvar='GITLINT_TARGET',
+ type=click.Path(exists=True, resolve_path=True, file_okay=False, readable=True),
help="Path of the target git repository. [default: current working directory]")
@click.option('-C', '--config', type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
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",
+@click.option('--commits', envvar='GITLINT_COMMITS', default=None, help="The range of commits 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', 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('--ignore', envvar='GITLINT_IGNORE', default="", help="Ignore rules (comma-separated by id or name).")
+@click.option('--contrib', envvar='GITLINT_CONTRIB', default="",
+ help="Contrib rules to enable (comma-separated by id or name).")
@click.option('--msg-filename', type=click.File(), help="Path to a file containing a commit-msg.")
-@click.option('--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,
+@click.option('--ignore-stdin', envvar='GITLINT_IGNORE_STDIN', is_flag=True,
+ help="Ignore any stdin data. Useful for running in CI server.")
+@click.option('--staged', envvar='GITLINT_STAGED', is_flag=True,
+ help="Read staged commit meta-info from the local repository.")
+@click.option('-v', '--verbose', envvar='GITLINT_VERBOSITY', 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.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( # pylint: disable=too-many-arguments
@@ -209,7 +232,7 @@ def cli( # pylint: disable=too-many-arguments
ignore_stdin, staged, verbose, silent, debug)
LOG.debug(u"Configuration\n%s", ustr(config))
- ctx.obj = (config, config_builder, commits, msg_filename)
+ ctx.obj = ContextObj(config, config_builder, commits, msg_filename)
# If no subcommand is specified, then just lint
if ctx.invoked_subcommand is None:
@@ -230,11 +253,14 @@ def cli( # pylint: disable=too-many-arguments
@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]
+ lint_config = ctx.obj.config
+ refspec = ctx.obj.refspec
+ msg_filename = ctx.obj.msg_filename
gitcontext = build_git_context(lint_config, msg_filename, 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
@@ -245,7 +271,7 @@ def lint(ctx):
ctx.exit(0)
LOG.debug(u'Linting %d commit(s)', number_of_commits)
- general_config_builder = ctx.obj[1]
+ general_config_builder = ctx.obj.config_builder
last_commit = gitcontext.commits[-1]
# Let's get linting!
@@ -287,9 +313,8 @@ def lint(ctx):
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)
+ hooks.GitHookInstaller.install_commit_msg_hook(ctx.obj.config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
click.echo(u"Successfully installed gitlint commit-msg hook in {0}".format(hook_path))
ctx.exit(0)
except hooks.GitHookInstallerError as e:
@@ -302,9 +327,8 @@ def install_hook(ctx):
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)
+ hooks.GitHookInstaller.uninstall_commit_msg_hook(ctx.obj.config)
+ hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
click.echo(u"Successfully uninstalled gitlint commit-msg hook from {0}".format(hook_path))
ctx.exit(0)
except hooks.GitHookInstallerError as e:
@@ -312,6 +336,80 @@ def uninstall_hook(ctx):
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(u"gitlint: checking commit message...")
+ ctx.invoke(lint)
+ 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 == 0:
+ click.echo(u"gitlint: " + click.style("OK", fg='green') + u" (no violations in commit message)")
+ continue
+
+ click.echo(u"-----------------------------------------------")
+ click.echo(u"gitlint: " + click.style("Your commit message contains the above 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.
+ #
+ # We also need a to use raw_input() in Python2 as input() is unsafe (and raw_input() doesn't exist in
+ # Python3). See https://stackoverflow.com/a/4960216/381010
+ input_func = input
+ if IS_PY2:
+ input_func = raw_input # noqa pylint: disable=undefined-variable
+
+ value = input_func()
+
+ if value == "y":
+ LOG.debug("run-hook: commit message accepted")
+ exit_code = 0
+ 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(u"Editing only possible when --msg-filename is specified.")
+ ctx.exit(exit_code)
+ elif value == "n":
+ LOG.debug("run-hook: commit message declined")
+ click.echo(u"Commit aborted.")
+ click.echo(u"Your commit message: ")
+ click.echo(u"-----------------------------------------------")
+ click.echo(ctx.obj.gitcontext.commits[0].message.full)
+ click.echo(u"-----------------------------------------------")
+ ctx.exit(exit_code)
+
+ ctx.exit(exit_code)
+
+
@cli.command("generate-config")
@click.pass_context
def generate_config(ctx):
diff --git a/gitlint/config.py b/gitlint/config.py
index 914357e..4dad707 100644
--- a/gitlint/config.py
+++ b/gitlint/config.py
@@ -12,7 +12,7 @@ import os
import shutil
from collections import OrderedDict
-from gitlint.utils import ustr, DEFAULT_ENCODING
+from gitlint.utils import ustr, sstr, 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
@@ -44,6 +44,7 @@ class LintConfig(object):
# Default tuple of rule classes (tuple because immutable).
default_rule_classes = (rules.IgnoreByTitle,
rules.IgnoreByBody,
+ rules.IgnoreBodyLines,
rules.TitleMaxLength,
rules.TitleTrailingWhitespace,
rules.TitleLeadingWhitespace,
@@ -51,6 +52,7 @@ class LintConfig(object):
rules.TitleHardTab,
rules.TitleMustNotContainWord,
rules.TitleRegexMatches,
+ rules.TitleMinLength,
rules.BodyMaxLineLength,
rules.BodyMinLength,
rules.BodyMissing,
@@ -58,6 +60,7 @@ class LintConfig(object):
rules.BodyHardTab,
rules.BodyFirstLineEmpty,
rules.BodyChangedFileMention,
+ rules.BodyRegexMatches,
rules.AuthorValidEmail)
def __init__(self):
@@ -290,7 +293,7 @@ class LintConfig(object):
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"contrib: {0}\n".format(sstr(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)
@@ -365,13 +368,20 @@ class RuleCollection(object):
def __len__(self):
return len(self._rules)
- def __str__(self):
+ def __repr__(self):
+ return self.__unicode__() # pragma: no cover
+
+ def __unicode__(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):
+ 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 += u" {0}={1}\n".format(option_name, option_val_repr)
@@ -385,13 +395,15 @@ class LintConfigBuilder(object):
normalized, validated and build. Example usage can be found in gitlint.cli.
"""
+ RULE_QUALIFIER_SYMBOL = ":"
+
def __init__(self):
- self._config_blueprint = {}
+ 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] = {}
+ self._config_blueprint[section] = OrderedDict()
self._config_blueprint[section][option_name] = option_value
def set_config_from_commit(self, commit):
@@ -438,10 +450,43 @@ class LintConfigBuilder(object):
except ConfigParserError as e:
raise LintConfigError(ustr(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 = u"The rule-name part in '{0}' cannot contain whitespace, colons or be empty"
+ raise LintConfigError(msg.format(qualified_rule_name))
+
+ # find parent rule
+ parent_rule = config.rules.find_rule(parent_rule_specifier)
+ if not parent_rule:
+ msg = u"No such rule '{0}' (named rule: '{1}')"
+ raise LintConfigError(msg.format(parent_rule_specifier, qualified_rule_name))
+
+ # 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:
@@ -459,6 +504,12 @@ class LintConfigBuilder(object):
for option_name, option_value in section_dict.items():
# Skip over the general section, as we've already done that above
if section_name != "general":
+
+ # If the section name contains a colon (:), then this section is defining a Named Rule
+ # Which means we need to instantiate that Named Rule in the config.
+ if self.RULE_QUALIFIER_SYMBOL in section_name:
+ section_name = self._add_named_rule(config, section_name)
+
config.set_rule_option(section_name, option_name, option_value)
return config
diff --git a/gitlint/contrib/rules/conventional_commit.py b/gitlint/contrib/rules/conventional_commit.py
index 3bbbd0f..8530343 100644
--- a/gitlint/contrib/rules/conventional_commit.py
+++ b/gitlint/contrib/rules/conventional_commit.py
@@ -17,7 +17,7 @@ class ConventionalCommit(LineRule):
options_spec = [
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.",
)
]
diff --git a/gitlint/display.py b/gitlint/display.py
index dd17ac0..c66a256 100644
--- a/gitlint/display.py
+++ b/gitlint/display.py
@@ -1,12 +1,13 @@
import codecs
import locale
-from sys import stdout, stderr, version_info
+from sys import stdout, stderr
+from gitlint.utils import IS_PY2
# 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:
+if IS_PY2:
stdout = codecs.getwriter(locale.getpreferredencoding())(stdout) # pylint: disable=invalid-name
stderr = codecs.getwriter(locale.getpreferredencoding())(stderr) # pylint: disable=invalid-name
diff --git a/gitlint/files/commit-msg b/gitlint/files/commit-msg
index e468290..6a25d34 100644
--- a/gitlint/files/commit-msg
+++ b/gitlint/files/commit-msg
@@ -8,74 +8,28 @@ 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;
+ # On Windows, we need to explicitely 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
- # 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"
+fi
-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 "-----------------------------------------------"
+gitlint --staged --msg-filename "$1" run-hook
+exit_code=$?
- exit $gitlint_exit_code
- fi
-done
+# 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
-echo "gitlint: ${GREEN}OK${END_COLOR} (no violations in commit message)"
-exit 0
+exit $exit_code
### gitlint commit-msg hook end ###
diff --git a/gitlint/files/gitlint b/gitlint/files/gitlint
index 15a6626..e95bf9e 100644
--- a/gitlint/files/gitlint
+++ b/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]
@@ -43,6 +43,11 @@
# [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 +55,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]*
@@ -74,9 +78,13 @@
# it in the commit message.
# files=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 +106,14 @@
# 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
+
# 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/git.py b/gitlint/git.py
index ca7ad92..8e00f89 100644
--- a/gitlint/git.py
+++ b/gitlint/git.py
@@ -1,4 +1,6 @@
+import logging
import os
+
import arrow
from gitlint import shell as sh
@@ -12,6 +14,8 @@ from gitlint.utils import ustr, sstr
# We should fix this at some point :-)
GIT_TIMEFORMAT = "YYYY-MM-DD HH:mm:ss Z"
+LOG = logging.getLogger(__name__)
+
class GitContextError(Exception):
""" Exception indicating there is an issue with the git context """
@@ -25,11 +29,20 @@ class GitNotInstalledError(GitContextError):
u"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(GitExitCodeError, self).__init__(
+ u"An error occurred while executing '{0}': {1}".format(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(sstr(command_parts))
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
@@ -44,12 +57,13 @@ def _git(*command_parts, **kwargs):
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(error_msg)
+
+ if (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)
+
+ raise GitExitCodeError(e.full_cmd, error_msg)
def git_version():
@@ -291,12 +305,18 @@ class StagedLocalGitCommit(GitCommit, PropertyCache):
@property
@cache
def author_name(self):
- return ustr(_git("config", "--get", "user.name", _cwd=self.context.repository_path)).strip()
+ try:
+ return ustr(_git("config", "--get", "user.name", _cwd=self.context.repository_path)).strip()
+ except GitExitCodeError:
+ raise GitContextError("Missing git configuration: please set user.name")
@property
@cache
def author_email(self):
- return ustr(_git("config", "--get", "user.email", _cwd=self.context.repository_path)).strip()
+ try:
+ return ustr(_git("config", "--get", "user.email", _cwd=self.context.repository_path)).strip()
+ except GitExitCodeError:
+ raise GitContextError("Missing git configuration: please set user.email")
@property
@cache
diff --git a/gitlint/options.py b/gitlint/options.py
index a1ae59c..3ea8310 100644
--- a/gitlint/options.py
+++ b/gitlint/options.py
@@ -1,9 +1,22 @@
from abc import abstractmethod
import os
+import re
from gitlint.utils import ustr, sstr
+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(Exception):
pass
@@ -43,6 +56,7 @@ class RuleOption(object):
class StrOption(RuleOption):
+ @allow_none
def set(self, value):
self.value = ustr(value)
@@ -59,6 +73,7 @@ class IntOption(RuleOption):
error_msg = u"Option '{0}' must be a positive integer (current value: '{1}')".format(self.name, value)
raise RuleOptionError(error_msg)
+ @allow_none
def set(self, value):
try:
self.value = int(value)
@@ -70,6 +85,8 @@ class IntOption(RuleOption):
class BoolOption(RuleOption):
+
+ # explicit choice to not annotate with @allow_none: Booleans must be False or True, they cannot be unset.
def set(self, value):
value = ustr(value).strip().lower()
if value not in ['true', 'false']:
@@ -81,6 +98,7 @@ 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.
"""
+ @allow_none
def set(self, value):
if isinstance(value, list):
the_list = value
@@ -97,6 +115,7 @@ class PathOption(RuleOption):
self.type = type
super(PathOption, self).__init__(name, value, description)
+ @allow_none
def set(self, value):
value = ustr(value)
@@ -120,3 +139,21 @@ class PathOption(RuleOption):
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("Invalid regular expression: '{0}'".format(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/rule_finder.py b/gitlint/rule_finder.py
index 2b8b293..d7d700b 100644
--- a/gitlint/rule_finder.py
+++ b/gitlint/rule_finder.py
@@ -66,7 +66,9 @@ def find_rule_classes(extra_path):
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))])
+ (issubclass(clazz, rules.LineRule) or
+ issubclass(clazz, rules.CommitRule) or
+ issubclass(clazz, rules.ConfigurationRule))])
# validate that the rule classes are valid user-defined rules
for rule_class in rule_classes:
@@ -75,24 +77,27 @@ def find_rule_classes(extra_path):
return rule_classes
-def assert_valid_rule_class(clazz, rule_type="User-defined"):
+def assert_valid_rule_class(clazz, rule_type="User-defined"): # pylint: disable=too-many-branches
"""
Asserts that a given rule clazz is valid by checking a number of its properties:
- - Rules must extend from LineRule or CommitRule
+ - 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 or M as these rule ids are reserved for gitlint itself.
+ - 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 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}"
+ # Rules must extend from LineRule, CommitRule or ConfigurationRule
+ if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)
+ or issubclass(clazz, rules.ConfigurationRule)):
+ msg = u"{0} rule class '{1}' must extend from {2}.{3}, {2}.{4} or {2}.{5}"
raise rules.UserRuleError(msg.format(rule_type, clazz.__name__, rules.CommitRule.__module__,
- rules.LineRule.__name__, rules.CommitRule.__name__))
+ rules.LineRule.__name__, rules.CommitRule.__name__,
+ rules.ConfigurationRule.__name__))
# Rules must have an id attribute
if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id:
@@ -100,8 +105,8 @@ def assert_valid_rule_class(clazz, rule_type="User-defined"):
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"
+ if clazz.id[0].upper() in ['R', 'T', 'B', 'M', 'I']:
+ msg = u"The id '{1}' of '{0}' is invalid. Gitlint reserves ids starting with R,T,B,M,I"
raise rules.UserRuleError(msg.format(clazz.__name__, clazz.id[0]))
# Rules must have a name attribute
@@ -122,11 +127,17 @@ def assert_valid_rule_class(clazz, rule_type="User-defined"):
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__))
+ # Line/Commit rules must have a `validate` method
+ # We use isroutine() as it's both python 2 and 3 compatible. Details: http://stackoverflow.com/a/17019998/381010
+ if (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)):
+ if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate):
+ msg = u"{0} rule class '{1}' must have a 'validate' method"
+ raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))
+ # Configuration rules must have an `apply` method
+ elif issubclass(clazz, rules.ConfigurationRule):
+ if not hasattr(clazz, 'apply') or not inspect.isroutine(clazz.apply):
+ msg = u"{0} Configuration rule class '{1}' must have an 'apply' 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):
diff --git a/gitlint/rules.py b/gitlint/rules.py
index ad83204..1cb50da 100644
--- a/gitlint/rules.py
+++ b/gitlint/rules.py
@@ -3,12 +3,9 @@ import copy
import logging
import re
-from gitlint.options import IntOption, BoolOption, StrOption, ListOption
+from gitlint.options import IntOption, BoolOption, StrOption, ListOption, RegexOption
from gitlint.utils import sstr
-LOG = logging.getLogger(__name__)
-logging.basicConfig()
-
class Rule(object):
""" Class representing gitlint rules. """
@@ -16,6 +13,7 @@ class Rule(object):
id = None
name = None
target = None
+ _log = None
def __init__(self, opts=None):
if not opts:
@@ -27,6 +25,13 @@ class Rule(object):
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 # noqa
@@ -102,7 +107,7 @@ class RuleViolation(object):
self.content) # pragma: no cover
def __repr__(self):
- return self.__str__() # pragma: no cover
+ return self.__unicode__() # pragma: no cover
class UserRuleError(Exception):
@@ -126,10 +131,10 @@ 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):
- pattern = re.compile(r"\s$", re.UNICODE)
- if pattern.search(line):
+ if self.pattern.search(line):
return [RuleViolation(self.id, self.violation_message, line)]
@@ -226,16 +231,32 @@ class TitleRegexMatches(LineRule):
name = "title-match-regex"
id = "T7"
target = CommitMessageTitle
- options_spec = [StrOption('regex', ".*", "Regex the title should match")]
+ options_spec = [RegexOption('regex', None, "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)
+ # If no regex is specified, immediately return
+ if not self.options['regex'].value:
+ return
+
+ if not self.options['regex'].value.search(title):
+ violation_msg = u"Title does not match regex ({0})".format(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 = "Title is too short ({0}<{1})".format(actual_length, min_length)
+ return [RuleViolation(self.id, violation_message, title, 1)]
+
+
class BodyMaxLineLength(MaxLineLength):
name = "body-max-line-length"
id = "B1"
@@ -309,55 +330,109 @@ class BodyChangedFileMention(CommitRule):
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 = u"Body does not match regex ({0})".format(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"
- options_spec = [StrOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]
+ options_spec = [RegexOption('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 no regex is specified, immediately return
+ if not self.options['regex'].value:
+ return
- if commit.author_email and not email_regex.match(commit.author_email):
+ if commit.author_email and not self.options['regex'].value.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"),
+ 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):
- title_regex = re.compile(self.options['regex'].value, re.UNICODE)
+ # If no regex is specified, immediately return
+ if not self.options['regex'].value:
+ return
- if title_regex.match(commit.message.title):
+ if self.options['regex'].value.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)
+ message = message.format(commit.message.title, self.options['regex'].value.pattern,
+ self.options['ignore'].value)
- LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ self.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"),
+ 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):
- body_line_regex = re.compile(self.options['regex'].value, re.UNICODE)
+ # If no regex is specified, immediately return
+ if not self.options['regex'].value:
+ return
for line in commit.message.body:
- if body_line_regex.match(line):
+ if self.options['regex'].value.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)
+ message = message.format(line, self.options['regex'].value.pattern, self.options['ignore'].value)
- LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ 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
+
+ new_body = []
+ for line in commit.message.body:
+ if self.options['regex'].value.match(line):
+ debug_msg = u"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 = u"\n".join([commit.message.title] + new_body)
diff --git a/gitlint/shell.py b/gitlint/shell.py
index 965f492..2601b04 100644
--- a/gitlint/shell.py
+++ b/gitlint/shell.py
@@ -2,12 +2,18 @@
"""
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.
+capabilities wrt dealing with more edge-case environments on *nix systems that are useful.
"""
import subprocess
-import sys
-from gitlint.utils import ustr, USE_SH_LIB
+from gitlint.utils import ustr, IS_PY2, USE_SH_LIB
+
+
+def shell(cmd):
+ """ Convenience function that opens a given command in a shell. Does not use 'sh' library. """
+ p = subprocess.Popen(cmd, shell=True)
+ p.communicate()
+
if USE_SH_LIB:
from sh import git # pylint: disable=unused-import,import-error
@@ -21,7 +27,7 @@ else:
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 """
+ the builtin subprocess module """
def __init__(self, full_cmd, stdout, stderr='', exitcode=0):
self.full_cmd = full_cmd
@@ -45,13 +51,13 @@ else:
return _exec(*args, **kwargs)
def _exec(*args, **kwargs):
- if sys.version_info[0] == 2:
+ if IS_PY2:
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']}
+ popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)}
if '_cwd' in kwargs:
popen_kwargs['cwd'] = kwargs['_cwd']
diff --git a/gitlint/tests/base.py b/gitlint/tests/base.py
index add4d71..c8f68c4 100644
--- a/gitlint/tests/base.py
+++ b/gitlint/tests/base.py
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
+import contextlib
import copy
import io
import logging
import os
import re
+import shutil
+import tempfile
try:
# python 2.x
@@ -21,7 +24,7 @@ except ImportError:
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
+from gitlint.utils import ustr, IS_PY2, LOG_FORMAT, DEFAULT_ENCODING
# unittest2's assertRaisesRegex doesn't do unicode comparison.
@@ -57,6 +60,15 @@ class BaseTestCase(unittest.TestCase):
logging.getLogger('gitlint').propagate = False
@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 == "":
@@ -73,6 +85,15 @@ class BaseTestCase(unittest.TestCase):
return sample
@staticmethod
+ def patch_input(side_effect):
+ """ Patches the built-in input() with a provided side-effect """
+ module_path = "builtins.input"
+ if IS_PY2:
+ module_path = "__builtin__.raw_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. """
@@ -129,6 +150,24 @@ class BaseTestCase(unittest.TestCase):
return super(BaseTestCase, self).assertRaisesRegex(expected_exception, re.escape(expected_regex),
*args, **kwargs)
+ @contextlib.contextmanager
+ def assertRaisesMessage(self, expected_exception, expected_msg): # pylint: disable=invalid-name
+ """ Asserts an exception has occurred with a given error message """
+ try:
+ yield
+ except expected_exception as exc:
+ exception_msg = ustr(exc)
+ if exception_msg != expected_msg:
+ error = u"Right exception, wrong message:\n got: {0}\n expected: {1}"
+ raise self.fail(error.format(exception_msg, expected_msg))
+ # else: everything is fine, just return
+ return
+ except Exception as exc:
+ raise self.fail(u"Expected '{0}' got '{1}'".format(expected_exception.__name__, exc.__class__.__name__))
+
+ # No exception raised while we expected one
+ raise self.fail("Expected to raise {0}, didn't get an exception at all".format(expected_exception.__name__))
+
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
diff --git a/gitlint/tests/cli/test_cli.py b/gitlint/tests/cli/test_cli.py
index 4d47f35..88bcfb7 100644
--- a/gitlint/tests/cli/test_cli.py
+++ b/gitlint/tests/cli/test_cli.py
@@ -1,12 +1,10 @@
# -*- coding: utf-8 -*-
-import contextlib
+
import io
import os
import sys
import platform
-import shutil
-import tempfile
import arrow
@@ -34,15 +32,6 @@ 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
@@ -64,7 +53,8 @@ class CLITests(BaseTestCase):
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())}
+ 'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB, 'target': os.path.realpath(os.getcwd()),
+ 'DEFAULT_ENCODING': DEFAULT_ENCODING}
def test_version(self):
""" Test for --version option """
@@ -118,7 +108,7 @@ class CLITests(BaseTestCase):
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(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)
@@ -152,7 +142,7 @@ class CLITests(BaseTestCase):
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(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)
@@ -205,7 +195,7 @@ class CLITests(BaseTestCase):
""" 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(stderr.getvalue(), self.get_expected("cli/test_cli/test_input_stream_1"))
self.assertEqual(result.exit_code, 3)
self.assertEqual(result.output, "")
@@ -215,11 +205,11 @@ class CLITests(BaseTestCase):
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(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('test_cli/test_input_stream_debug_2', expected_kwargs)
+ expected_logs = self.get_expected('cli/test_cli/test_input_stream_debug_2', expected_kwargs)
self.assert_logged(expected_logs)
@patch('gitlint.cli.get_stdin_data', return_value="Should be ignored\n")
@@ -259,12 +249,12 @@ class CLITests(BaseTestCase):
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(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_staged_stdin_1"))
self.assertEqual(result.exit_code, 3)
self.assertEqual(result.output, "")
expected_kwargs = self.get_system_info_dict()
- expected_logs = self.get_expected('test_cli/test_lint_staged_stdin_2', expected_kwargs)
+ expected_logs = self.get_expected('cli/test_cli/test_lint_staged_stdin_2', expected_kwargs)
self.assert_logged(expected_logs)
@patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
@@ -280,19 +270,19 @@ class CLITests(BaseTestCase):
u"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
]
- with tempdir() as tmpdir:
+ with self.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(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_staged_msg_filename_1"))
self.assertEqual(result.exit_code, 2)
self.assertEqual(result.output, "")
expected_kwargs = self.get_system_info_dict()
- expected_logs = self.get_expected('test_cli/test_lint_staged_msg_filename_2', expected_kwargs)
+ expected_logs = self.get_expected('cli/test_cli/test_lint_staged_msg_filename_2', expected_kwargs)
self.assert_logged(expected_logs)
@patch('gitlint.cli.get_stdin_data', return_value=False)
@@ -306,7 +296,7 @@ class CLITests(BaseTestCase):
def test_msg_filename(self, _):
expected_output = u"3: B6 Body message is missing\n"
- with tempdir() as tmpdir:
+ with self.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")
@@ -375,7 +365,7 @@ class CLITests(BaseTestCase):
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"föobar\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
]
@@ -394,7 +384,7 @@ class CLITests(BaseTestCase):
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)
+ 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=u"Test tïtle\n")
@@ -403,7 +393,7 @@ class CLITests(BaseTestCase):
# 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"])
+ result = self.cli.invoke(cli.cli, ["--extra-path", extra_path])
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)
@@ -412,7 +402,7 @@ class CLITests(BaseTestCase):
# 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"])
+ result = self.cli.invoke(cli.cli, ["--extra-path", extra_path])
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)
@@ -423,7 +413,7 @@ class CLITests(BaseTestCase):
# 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')
+ expected_output = self.get_expected('cli/test_cli/test_contrib_1')
self.assertEqual(stderr.getvalue(), expected_output)
self.assertEqual(result.exit_code, 3)
@@ -469,13 +459,14 @@ class CLITests(BaseTestCase):
@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)
+ 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 """
@@ -539,3 +530,18 @@ class CLITests(BaseTestCase):
self.assert_log_contains(u"DEBUG: gitlint.cli No commits in range \"master...HEAD\"")
self.assertEqual(result.exit_code, 0)
+
+ @patch('gitlint.cli.get_stdin_data', return_value=u"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/tests/cli/test_cli_hooks.py b/gitlint/tests/cli/test_cli_hooks.py
index 0564808..b5e7fc4 100644
--- a/gitlint/tests/cli/test_cli_hooks.py
+++ b/gitlint/tests/cli/test_cli_hooks.py
@@ -1,11 +1,19 @@
# -*- coding: utf-8 -*-
+import io
import os
from click.testing import CliRunner
try:
# python 2.x
+ from StringIO import StringIO
+except ImportError:
+ # python 3.x
+ from io import StringIO # pylint: disable=ungrouped-imports
+
+try:
+ # python 2.x
from mock import patch
except ImportError:
# python 3.x
@@ -16,6 +24,8 @@ from gitlint import cli
from gitlint import hooks
from gitlint import config
+from gitlint.utils import DEFAULT_ENCODING
+
class CLIHookTests(BaseTestCase):
USAGE_ERROR_CODE = 253
@@ -94,3 +104,165 @@ class CLIHookTests(BaseTestCase):
expected_config = config.LintConfig()
expected_config.target = os.path.realpath(os.getcwd())
uninstall_hook.assert_called_once_with(expected_config)
+
+ def test_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, u"hür")
+ with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
+ f.write(u"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_hook_edit(self, shell):
+ """ Test for run-hook subcommand, answering 'e(dit)' after commit-hook """
+
+ set_editors = [None, u"myeditor"]
+ expected_editors = [u"vim -n", u"myeditor"]
+ commit_messages = [u"WIP: höok edit 1", u"WIP: höok edit 2"]
+
+ for i in range(0, len(set_editors)):
+ if set_editors[i]:
+ os.environ['EDITOR'] = set_editors[i]
+
+ with self.patch_input(['e', 'e', 'n']):
+ with self.tempdir() as tmpdir:
+ msg_filename = os.path.realpath(os.path.join(tmpdir, u"hür"))
+ with io.open(msg_filename, 'w', encoding=DEFAULT_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(u"DEBUG: gitlint.cli run-hook: editing commit message")
+ self.assert_log_contains(u"DEBUG: gitlint.cli run-hook: {0} {1}".format(expected_editors[i],
+ msg_filename))
+
+ def test_hook_no(self):
+ """ Test for run-hook subcommand, answering 'n(o)' after commit-hook """
+
+ with self.patch_input(['n']):
+ with self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, u"hür")
+ with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
+ f.write(u"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_hook_yes(self):
+ """ Test for run-hook subcommand, answering 'y(es)' after commit-hook """
+ with self.patch_input(['y']):
+ with self.tempdir() as tmpdir:
+ msg_filename = os.path.join(tmpdir, u"hür")
+ with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
+ f.write(u"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=u"WIP: Test hook stdin tïtle\n")
+ def test_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=u"Test tïtle\n\nTest bödy that is long enough")
+ def test_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=u"WIP: Test hook config tïtle\n")
+ def test_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_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",
+ u"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
+ u"WIP: 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 self.patch_input(['e']):
+ with patch('gitlint.display.stderr', new=StringIO()) as stderr:
+ result = self.cli.invoke(cli.cli, ["run-hook"])
+ expected = self.get_expected('cli/test_cli_hooks/test_hook_local_commit_1_stderr')
+ 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/tests/config/test_config.py
index d3fdc2c..b981a86 100644
--- a/gitlint/tests/config/test_config.py
+++ b/gitlint/tests/config/test_config.py
@@ -30,18 +30,18 @@ class LintConfigTests(BaseTestCase):
# non-existing rule
expected_error_msg = u"No such rule 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config.set_rule_option(u'föobar', u'lïne-length', 60)
# non-existing option
expected_error_msg = u"Rule 'title-max-length' has no option 'föobar'"
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config.set_rule_option('title-max-length', u'föobar', 60)
# invalid option value
expected_error_msg = u"'föo' is not a valid value for option 'title-max-length.line-length'. " + \
u"Option 'line-length' must be a positive integer (current value: 'föo')."
- with self.assertRaisesRegex(LintConfigError, expected_error_msg):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config.set_rule_option('title-max-length', 'line-length', u"föo")
def test_set_general_option(self):
@@ -124,7 +124,7 @@ class LintConfigTests(BaseTestCase):
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.",
)
@@ -151,14 +151,14 @@ 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."):
+ with self.assertRaisesMessage(LintConfigError, u"No contrib rule with id or name 'föo' found."):
config.contrib = u"contrib-title-conventional-commits,föo"
# UserRuleError, RuleOptionError should be re-raised as LintConfigErrors
side_effects = [rules.UserRuleError(u"üser-rule"), options.RuleOptionError(u"rüle-option")]
for side_effect in side_effects:
with patch('gitlint.config.rule_finder.find_rule_classes', side_effect=side_effect):
- with self.assertRaisesRegex(LintConfigError, ustr(side_effect)):
+ with self.assertRaisesMessage(LintConfigError, ustr(side_effect)):
config.contrib = u"contrib-title-conventional-commits"
def test_extra_path(self):
@@ -185,36 +185,36 @@ class LintConfigTests(BaseTestCase):
config = LintConfig()
regex = u"Option extra-path must be either an existing directory or file (current value: 'föo/bar')"
# incorrect extra_path
- with self.assertRaisesRegex(LintConfigError, regex):
+ with self.assertRaisesMessage(LintConfigError, regex):
config.extra_path = u"föo/bar"
# extra path contains classes with errors
- with self.assertRaisesRegex(LintConfigError,
- "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
+ 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"):
+ with self.assertRaisesMessage(LintConfigError, "'foo' is not a valid gitlint option"):
config.set_general_option("foo", u"bår")
# try setting _config_path, this is a real attribute of LintConfig, but the code should prevent it from
# being set
- with self.assertRaisesRegex(LintConfigError, "'_config_path' is not a valid gitlint option"):
+ with self.assertRaisesMessage(LintConfigError, "'_config_path' is not a valid gitlint option"):
config.set_general_option("_config_path", u"bår")
# invalid verbosity
incorrect_values = [-1, u"föo"]
for value in incorrect_values:
expected_msg = u"Option 'verbosity' must be a positive integer (current value: '{0}')".format(value)
- with self.assertRaisesRegex(LintConfigError, expected_msg):
+ 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
@@ -224,8 +224,8 @@ class LintConfigTests(BaseTestCase):
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,
+ "Option '{0}' must be either 'true' or 'false'".format(option_name)):
setattr(config, attribute, value)
# invalid ignore -> not here because ignore is a ListOption which converts everything to a string before
@@ -234,15 +234,15 @@ class LintConfigTests(BaseTestCase):
# invalid boolean options
for attribute in ['debug', 'staged', 'ignore_stdin']:
option_name = attribute.replace("_", "-")
- with self.assertRaisesRegex(LintConfigError,
- "Option '{0}' must be either 'true' or 'false'".format(option_name)):
+ with self.assertRaisesMessage(LintConfigError,
+ "Option '{0}' must be either 'true' or 'false'".format(option_name)):
setattr(config, attribute, u"föobar")
# extra-path has its own negative test
# invalid target
- with self.assertRaisesRegex(LintConfigError,
- u"Option target must be an existing directory (current value: 'föo/bar')"):
+ with self.assertRaisesMessage(LintConfigError,
+ u"Option target must be an existing directory (current value: 'föo/bar')"):
config.target = u"föo/bar"
def test_ignore_independent_from_rules(self):
@@ -254,6 +254,30 @@ 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), ("debug", True),
+ ("ignore", ["T1"]), ("staged", True), ("_config_path", self.get_sample_path()),
+ ("ignore_merge_commits", False), ("ignore_fixup_commits", False),
+ ("ignore_squash_commits", False), ("ignore_revert_commits", False),
+ ("extra_path", self.get_sample_path("user_rules")), ("target", self.get_sample_path()),
+ ("contrib", ["CC1"])]
+ 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 = u"bår"
+ self.assertEqual(config1, config2)
+ config2.foo = u"dūr"
+ self.assertEqual(config1, config2)
+
class LintConfigGeneratorTests(BaseTestCase):
@staticmethod
diff --git a/gitlint/tests/config/test_config_builder.py b/gitlint/tests/config/test_config_builder.py
index 051a52f..5a28c9f 100644
--- a/gitlint/tests/config/test_config_builder.py
+++ b/gitlint/tests/config/test_config_builder.py
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
+import copy
from gitlint.tests.base import BaseTestCase
from gitlint.config import LintConfig, LintConfigBuilder, LintConfigError
+from gitlint import rules
+
class LintConfigBuilderTests(BaseTestCase):
def test_set_option(self):
@@ -88,12 +91,13 @@ class LintConfigBuilderTests(BaseTestCase):
# 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):
+ 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 = u"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)
@@ -102,7 +106,7 @@ class LintConfigBuilderTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config_builder.build()
# non-existing general option
@@ -110,7 +114,7 @@ class LintConfigBuilderTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config_builder.build()
# non-existing option
@@ -118,7 +122,7 @@ class LintConfigBuilderTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config_builder.build()
# invalid option value
@@ -127,7 +131,7 @@ class LintConfigBuilderTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(LintConfigError, expected_error_msg):
config_builder.build()
def test_set_config_from_string_list(self):
@@ -150,27 +154,27 @@ class LintConfigBuilderTests(BaseTestCase):
# 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'"):
+ with self.assertRaisesMessage(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):
+ with self.assertRaisesMessage(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):
+ with self.assertRaisesMessage(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):
+ with self.assertRaisesMessage(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):
+ with self.assertRaisesMessage(LintConfigError, expected_msg):
config_builder.set_config_from_string_list([u'föobar=1'])
def test_rebuild_config(self):
@@ -201,3 +205,60 @@ class LintConfigBuilderTests(BaseTestCase):
# 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 = [u'T7:my-extra-rüle', u' T7 : my-extra-rüle ', u'\tT7:\tmy-extra-rüle\t',
+ u'T7:\t\n \tmy-extra-rüle\t\n\n', u"title-match-regex:my-extra-rüle"]
+ for rule_qualifier in rule_qualifiers:
+ config_builder = LintConfigBuilder()
+ config_builder.set_option(rule_qualifier, 'regex', u"föo")
+
+ expected_rules = copy.deepcopy(default_rules)
+ my_rule = rules.TitleRegexMatches({'regex': u"föo"})
+ my_rule.id = rules.TitleRegexMatches.id + u":my-extra-rüle"
+ my_rule.name = rules.TitleRegexMatches.name + u":my-extra-rüle"
+ expected_rules._rules[u'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 + u"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 + u"bōr")
+ self.assertEqual(cb.build().rules, expected_rules)
+ my_rule.options['regex'].set(u"wrong")
+
+ def test_named_rules_negative(self):
+ # T7 = title-match-regex
+ # Invalid rule name
+ for invalid_name in ["", " ", " ", "\t", "\n", u"å b", u"å:b", u"åb:", u":åb"]:
+ config_builder = LintConfigBuilder()
+ config_builder.set_option(u"T7:{0}".format(invalid_name), 'regex', u"tëst")
+ expected_msg = u"The rule-name part in 'T7:{0}' cannot contain whitespace, colons or be empty"
+ with self.assertRaisesMessage(LintConfigError, expected_msg.format(invalid_name)):
+ config_builder.build()
+
+ # Invalid parent rule name
+ config_builder = LintConfigBuilder()
+ config_builder.set_option(u"Ž123:foöbar", u"fåke-option", u"fåke-value")
+ with self.assertRaisesMessage(LintConfigError, u"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(u"T7:foöbar", u"blå", u"my-rëgex")
+ with self.assertRaisesMessage(LintConfigError, u"Rule 'T7:foöbar' has no option 'blå'"):
+ config_builder.build()
diff --git a/gitlint/tests/config/test_config_precedence.py b/gitlint/tests/config/test_config_precedence.py
index 9689e55..a0eeccd 100644
--- a/gitlint/tests/config/test_config_precedence.py
+++ b/gitlint/tests/config/test_config_precedence.py
@@ -25,40 +25,48 @@ class LintConfigPrecedenceTests(BaseTestCase):
def setUp(self):
self.cli = CliRunner()
- @patch('gitlint.cli.get_stdin_data', return_value=u"WIP\n\nThis is å test message\n")
+ @patch('gitlint.cli.get_stdin_data', return_value=u"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:
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(), u"1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
- # 2. commandline -c flags
+ # 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(), u"1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
+
+ # 3. commandline -c flags
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
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
+ # 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
+ # 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(), u"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")
def test_ignore_precedence(self, get_stdin_data):
diff --git a/gitlint/tests/contrib/rules/__init__.py b/gitlint/tests/contrib/rules/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gitlint/tests/contrib/rules/__init__.py
diff --git a/gitlint/tests/contrib/test_conventional_commit.py b/gitlint/tests/contrib/rules/test_conventional_commit.py
index ea808fd..001af32 100644
--- a/gitlint/tests/contrib/test_conventional_commit.py
+++ b/gitlint/tests/contrib/rules/test_conventional_commit.py
@@ -19,13 +19,13 @@ class ContribConventionalCommitTests(BaseTestCase):
rule = ConventionalCommit()
# No violations when using a correct type and format
- for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert"]:
+ for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"]:
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")
+ " style, refactor, perf, test, revert, ci, build", u"bår: foo")
violations = rule.validate(u"bår: foo", None)
self.assertListEqual([expected_violation], violations)
diff --git a/gitlint/tests/contrib/test_signedoff_by.py b/gitlint/tests/contrib/rules/test_signedoff_by.py
index 934aec5..934aec5 100644
--- a/gitlint/tests/contrib/test_signedoff_by.py
+++ b/gitlint/tests/contrib/rules/test_signedoff_by.py
diff --git a/gitlint/tests/contrib/test_contrib_rules.py b/gitlint/tests/contrib/test_contrib_rules.py
index 3fa4048..84db2d5 100644
--- a/gitlint/tests/contrib/test_contrib_rules.py
+++ b/gitlint/tests/contrib/test_contrib_rules.py
@@ -3,7 +3,7 @@ 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.tests.contrib import rules as contrib_tests
from gitlint import rule_finder, rules
from gitlint.utils import ustr
diff --git a/gitlint/tests/expected/test_cli/test_contrib_1 b/gitlint/tests/expected/cli/test_cli/test_contrib_1
index ea5d353..cdfb821 100644
--- a/gitlint/tests/expected/test_cli/test_contrib_1
+++ b/gitlint/tests/expected/cli/test_cli/test_contrib_1
@@ -1,3 +1,3 @@
1: CC1 Body does not contain a 'Signed-Off-By' line
-1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert: "Test tïtle"
+1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build: "Test tïtle"
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"
diff --git a/gitlint/tests/expected/test_cli/test_debug_1 b/gitlint/tests/expected/cli/test_cli/test_debug_1
index 612f78e..a95a58d 100644
--- a/gitlint/tests/expected/test_cli/test_debug_1
+++ b/gitlint/tests/expected/cli/test_cli/test_debug_1
@@ -4,6 +4,7 @@ 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 DEFAULT_ENCODING: {DEFAULT_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: {config_path}
[GENERAL]
@@ -26,6 +27,8 @@ target: {target}
I2: ignore-by-body
ignore=all
regex=None
+ I3: ignore-body-lines
+ regex=None
T1: title-max-length
line-length=20
T2: title-trailing-whitespace
@@ -35,7 +38,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 +52,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=[^@ ]+@[^@ ]+\.[^@ ]+
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 ('branch', '--contains', '6f29bf81a8322a04071bb794666e48c443a90360')
+DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '6f29bf81a8322a04071bb794666e48c443a90360')
DEBUG: gitlint.lint Commit Object
--- Commit Message ----
commït-title1
@@ -68,7 +80,10 @@ is-revert-commit: False
Branches: ['commit-1-branch-1', 'commit-1-branch-2']
Changed Files: ['commit-1/file-1', 'commit-1/file-2']
-----------------------
+DEBUG: gitlint.git ('log', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B')
DEBUG: gitlint.lint Linting commit 25053ccec5e28e1bb8f7551fdbb5ab213ada2401
+DEBUG: gitlint.git ('branch', '--contains', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401')
+DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401')
DEBUG: gitlint.lint Commit Object
--- Commit Message ----
commït-title2.
@@ -84,10 +99,13 @@ is-revert-commit: False
Branches: ['commit-2-branch-1', 'commit-2-branch-2']
Changed Files: ['commit-2/file-1', 'commit-2/file-2']
-----------------------
+DEBUG: gitlint.git ('log', '4da2656b0dadc76c7ee3fd0243a96cb64007f125', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B')
DEBUG: gitlint.lint Linting commit 4da2656b0dadc76c7ee3fd0243a96cb64007f125
+DEBUG: gitlint.git ('branch', '--contains', '4da2656b0dadc76c7ee3fd0243a96cb64007f125')
+DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '4da2656b0dadc76c7ee3fd0243a96cb64007f125')
DEBUG: gitlint.lint Commit Object
--- Commit Message ----
-föo
+föobar
bar
--- Meta info ---------
Author: test åuthor3 <test-email3@föo.com>
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_1 b/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/tests/expected/cli/test_cli/test_input_stream_1
diff --git a/gitlint/tests/expected/test_cli/test_input_stream_debug_1 b/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/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/tests/expected/cli/test_cli/test_input_stream_debug_2
index a9028e1..c05d147 100644
--- a/gitlint/tests/expected/test_cli/test_input_stream_debug_2
+++ b/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2
@@ -4,6 +4,7 @@ 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 DEFAULT_ENCODING: {DEFAULT_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -26,6 +27,8 @@ target: {target}
I2: ignore-by-body
ignore=all
regex=None
+ I3: ignore-body-lines
+ regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace
@@ -35,7 +38,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 +52,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=[^@ ]+@[^@ ]+\.[^@ ]+
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
diff --git a/gitlint/tests/expected/test_cli/test_lint_multiple_commits_1 b/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/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/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/tests/expected/cli/test_cli/test_lint_multiple_commits_config_1
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_1 b/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/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/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2
index 3e5dcb6..e8e9f33 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_msg_filename_2
+++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2
@@ -4,6 +4,7 @@ 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 DEFAULT_ENCODING: {DEFAULT_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -26,6 +27,8 @@ target: {target}
I2: ignore-by-body
ignore=all
regex=None
+ I3: ignore-body-lines
+ regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace
@@ -35,7 +38,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 +52,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=[^@ ]+@[^@ ]+\.[^@ ]+
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 ('config', '--get', 'user.name')
+DEBUG: gitlint.git ('config', '--get', 'user.email')
+DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD')
+DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r')
DEBUG: gitlint.lint Commit Object
--- Commit Message ----
WIP: msg-filename tïtle
diff --git a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_1 b/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/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/tests/expected/cli/test_cli/test_lint_staged_stdin_2
index 03fd8c3..b822edc 100644
--- a/gitlint/tests/expected/test_cli/test_lint_staged_stdin_2
+++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2
@@ -4,6 +4,7 @@ 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 DEFAULT_ENCODING: {DEFAULT_ENCODING}
DEBUG: gitlint.cli Configuration
config-path: None
[GENERAL]
@@ -26,6 +27,8 @@ target: {target}
I2: ignore-by-body
ignore=all
regex=None
+ I3: ignore-body-lines
+ regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace
@@ -35,7 +38,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,6 +52,8 @@ target: {target}
B4: body-first-line-empty
B7: body-changed-file-mention
files=
+ B8: body-match-regex
+ regex=None
M1: author-valid-email
regex=[^@ ]+@[^@ ]+\.[^@ ]+
@@ -54,8 +61,13 @@ 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 ('config', '--get', 'user.name')
+DEBUG: gitlint.git ('config', '--get', 'user.email')
+DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD')
+DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r')
DEBUG: gitlint.lint Commit Object
--- Commit Message ----
WIP: tïtle
diff --git a/gitlint/tests/expected/cli/test_cli/test_named_rules_1 b/gitlint/tests/expected/cli/test_cli/test_named_rules_1
new file mode 100644
index 0000000..a581d05
--- /dev/null
+++ b/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/tests/expected/cli/test_cli/test_named_rules_2 b/gitlint/tests/expected/cli/test_cli/test_named_rules_2
new file mode 100644
index 0000000..828e296
--- /dev/null
+++ b/gitlint/tests/expected/cli/test_cli/test_named_rules_2
@@ -0,0 +1,82 @@
+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 DEFAULT_ENCODING: {DEFAULT_ENCODING}
+DEBUG: gitlint.cli Configuration
+config-path: {config_path}
+[GENERAL]
+extra-path: None
+contrib: []
+ignore:
+ignore-merge-commits: True
+ignore-fixup-commits: True
+ignore-squash-commits: True
+ignore-revert-commits: True
+ignore-stdin: False
+staged: False
+verbosity: 3
+debug: True
+target: {target}
+[RULES]
+ I1: ignore-by-title
+ ignore=all
+ regex=None
+ I2: ignore-by-body
+ ignore=all
+ regex=None
+ I3: ignore-body-lines
+ 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-squash-commit: False
+is-revert-commit: False
+Branches: []
+Changed Files: []
+-----------------------
+DEBUG: gitlint.cli Exit Code = 4 \ No newline at end of file
diff --git a/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stderr
new file mode 100644
index 0000000..cfacd42
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_config_1_stdout
new file mode 100644
index 0000000..5d3f1fc
--- /dev/null
+++ b/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 the above violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stderr
new file mode 100644
index 0000000..3eb8fca
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_edit_1_stdout
new file mode 100644
index 0000000..fa6b3bc
--- /dev/null
+++ b/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 the above 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 the above 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 the above 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/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stderr
new file mode 100644
index 0000000..11c3cd8
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_local_commit_1_stdout
new file mode 100644
index 0000000..a95bfea
--- /dev/null
+++ b/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 the above 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/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stderr
new file mode 100644
index 0000000..6d0c9cf
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_1_stdout
new file mode 100644
index 0000000..9cc53c1
--- /dev/null
+++ b/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 the above 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/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stderr
new file mode 100644
index 0000000..a8d8760
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_no_tty_1_stdout
new file mode 100644
index 0000000..5d3f1fc
--- /dev/null
+++ b/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 the above violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout b/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/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/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stderr
new file mode 100644
index 0000000..1404f4a
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_stdin_violations_1_stdout
new file mode 100644
index 0000000..5d3f1fc
--- /dev/null
+++ b/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 the above violations.
+Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
+Aborted!
diff --git a/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stderr
new file mode 100644
index 0000000..da6f874
--- /dev/null
+++ b/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/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout b/gitlint/tests/expected/cli/test_cli_hooks/test_hook_yes_1_stdout
new file mode 100644
index 0000000..bb753b0
--- /dev/null
+++ b/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 the above 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/tests/git/test_git.py b/gitlint/tests/git/test_git.py
index 297b10c..1830119 100644
--- a/gitlint/tests/git/test_git.py
+++ b/gitlint/tests/git/test_git.py
@@ -27,7 +27,7 @@ class GitTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(GitNotInstalledError, expected_msg):
GitContext.from_local_repository(u"fåke/path")
# assert that commit message was read using git command
@@ -39,7 +39,7 @@ class GitTests(BaseTestCase):
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."):
+ with self.assertRaisesMessage(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
@@ -50,7 +50,7 @@ class GitTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(GitContextError, expected_msg):
GitContext.from_local_repository(u"fåke/path")
# assert that commit message was read using git command
@@ -64,7 +64,7 @@ class GitTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(GitContextError, expected_msg):
GitContext.from_local_repository(u"fåke/path")
# assert that commit message was read using git command
@@ -82,7 +82,7 @@ class GitTests(BaseTestCase):
ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err)
]
- with self.assertRaisesRegex(GitContextError, expected_msg):
+ with self.assertRaisesMessage(GitContextError, expected_msg):
context = GitContext.from_commit_msg(u"test")
context.current_branch
diff --git a/gitlint/tests/git/test_git_commit.py b/gitlint/tests/git/test_git_commit.py
index dc83ccb..5f87a8e 100644
--- a/gitlint/tests/git/test_git_commit.py
+++ b/gitlint/tests/git/test_git_commit.py
@@ -14,7 +14,9 @@ except ImportError:
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
+from gitlint.git import GitContext, GitCommit, GitContextError, LocalGitCommit, StagedLocalGitCommit, GitCommitMessage
+from gitlint.shell import ErrorReturnCode
+from gitlint.utils import ustr
class GitCommitTests(BaseTestCase):
@@ -479,12 +481,46 @@ class GitCommitTests(BaseTestCase):
self.assertListEqual(last_commit.changed_files, ["file1.txt", u"påth/to/file2.txt"])
self.assertListEqual(sh.git.mock_calls, expected_calls[0:5])
+ @patch('gitlint.git.sh')
+ def test_staged_commit_with_missing_username(self, sh):
+ # StagedLocalGitCommit()
+
+ sh.git.side_effect = [
+ u"#", # 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(u"Foōbar 123\n\ncömmit-body\n", u"fåke/path")
+ [ustr(commit) for commit in ctx.commits]
+
+ @patch('gitlint.git.sh')
+ def test_staged_commit_with_missing_email(self, sh):
+ # StagedLocalGitCommit()
+
+ sh.git.side_effect = [
+ u"#", # git config --get core.commentchar
+ u"test åuthor\n", # git config --get user.name
+ ErrorReturnCode('git config --get user.name', b"", b""),
+ ]
+
+ expected_msg = "Missing git configuration: please set user.email"
+ with self.assertRaisesMessage(GitContextError, expected_msg):
+ ctx = GitContext.from_staged_commit(u"Foōbar 123\n\ncömmit-body\n", u"fåke/path")
+ [ustr(commit) for commit in ctx.commits]
+
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):
+ @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 = u"foöbar"
+
# Test simple equality case
now = datetime.datetime.utcnow()
context1 = GitContext()
diff --git a/gitlint/tests/rules/test_body_rules.py b/gitlint/tests/rules/test_body_rules.py
index fcb1b30..f46760b 100644
--- a/gitlint/tests/rules/test_body_rules.py
+++ b/gitlint/tests/rules/test_body_rules.py
@@ -178,3 +178,49 @@ class BodyRuleTests(BaseTestCase):
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(u"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': u"^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': u"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': u"(.*)Föo(.*)"})
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
+ # assert violation on non-matching body
+ rule = rules.BodyRegexMatches({'regex': u"^Tëst(.*)Foo"})
+ violations = rule.validate(commit)
+ expected_violation = rules.RuleViolation("B8", u"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 = [u"åbc", u"åbc\n", u"åbc\nföo\n", u"åbc\n\n", u"åbc\nföo\nblå", u"å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/tests/rules/test_configuration_rules.py b/gitlint/tests/rules/test_configuration_rules.py
index 73d42f3..121cb3a 100644
--- a/gitlint/tests/rules/test_configuration_rules.py
+++ b/gitlint/tests/rules/test_configuration_rules.py
@@ -67,5 +67,41 @@ class ConfigurationRuleTests(BaseTestCase):
rule.apply(config, commit)
self.assertEqual(config, expected_config)
- expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
+ 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(.*)', ignoring rules: T1,B2"
+ self.assert_log_contains(expected_log_message)
+
+ def test_ignore_body_lines(self):
+ commit1 = self.gitcommit(u"Tïtle\n\nThis is\n a relëase body\n line")
+ commit2 = self.gitcommit(u"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": u"(.*)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(u"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
+ self.assert_log_contains(u"DEBUG: gitlint.rules Ignoring line ' a relëase body' because it " +
+ u"matches '(.*)relëase(.*)'")
+
+ # Non-Matching regex: no changes expected
+ commit1 = self.gitcommit(u"Tïtle\n\nThis is\n a relëase body\n line")
+ rule = rules.IgnoreBodyLines({"regex": u"(.*)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/tests/rules/test_meta_rules.py b/gitlint/tests/rules/test_meta_rules.py
index c94b8b3..987aa88 100644
--- a/gitlint/tests/rules/test_meta_rules.py
+++ b/gitlint/tests/rules/test_meta_rules.py
@@ -32,6 +32,15 @@ class MetaRuleTests(BaseTestCase):
[RuleViolation("M1", "Author email for commit is invalid", email)])
def test_author_valid_email_rule_custom_regex(self):
+ # regex=None -> the rule isn't applied
+ rule = AuthorValidEmail()
+ rule.options['regex'].set(None)
+ emailadresses = [u"föo", None, u"hür dür"]
+ for email in emailadresses:
+ commit = self.gitcommit(u"", author_email=email)
+ violations = rule.validate(commit)
+ self.assertIsNone(violations)
+
# Custom domain
rule = AuthorValidEmail({'regex': u"[^@]+@bår.com"})
valid_email_addresses = [
diff --git a/gitlint/tests/rules/test_rules.py b/gitlint/tests/rules/test_rules.py
index 89caa27..58ee1c3 100644
--- a/gitlint/tests/rules/test_rules.py
+++ b/gitlint/tests/rules/test_rules.py
@@ -13,6 +13,11 @@ class RuleTests(BaseTestCase):
setattr(rule, attr, u"åbc")
self.assertNotEqual(Rule(), rule)
+ def test_rule_log(self):
+ rule = Rule()
+ rule.log.debug(u"Tēst message")
+ self.assert_log_contains(u"DEBUG: gitlint.rules Tēst message")
+
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
index 07d2323..049735e 100644
--- a/gitlint/tests/rules/test_title_rules.py
+++ b/gitlint/tests/rules/test_title_rules.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from gitlint.tests.base import BaseTestCase
from gitlint.rules import TitleMaxLength, TitleTrailingWhitespace, TitleHardTab, TitleMustNotContainWord, \
- TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation
+ TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation, TitleMinLength
class TitleRuleTests(BaseTestCase):
@@ -152,3 +152,35 @@ class TitleRuleTests(BaseTestCase):
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])
+
+ def test_min_line_length(self):
+ rule = TitleMinLength()
+
+ # assert no error
+ violation = rule.validate(u"å" * 72, None)
+ self.assertIsNone(violation)
+
+ # assert error on line length < 5
+ expected_violation = RuleViolation("T8", "Title is too short (4<5)", u"å" * 4, 1)
+ violations = rule.validate(u"å" * 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(u"å" * 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(u"å" * 3, None)
+ self.assertIsNone(violations)
+
+ # assert raise on 2
+ expected_violation = RuleViolation("T8", "Title is too short (2<3)", u"å" * 2, 1)
+ violations = rule.validate(u"å" * 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/tests/rules/test_user_rules.py b/gitlint/tests/rules/test_user_rules.py
index 57c03a0..52d0283 100644
--- a/gitlint/tests/rules/test_user_rules.py
+++ b/gitlint/tests/rules/test_user_rules.py
@@ -92,7 +92,7 @@ class UserRuleTests(BaseTestCase):
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"):
+ with self.assertRaisesMessage(UserRuleError, u"Invalid extra-path: föo/bar"):
find_rule_classes(u"föo/bar")
def test_assert_valid_rule_class(self):
@@ -111,15 +111,23 @@ class UserRuleTests(BaseTestCase):
def validate(self):
pass
+ class MyConfigurationRuleClass(rules.ConfigurationRule):
+ id = 'UC3'
+ name = u'my-cönfiguration-rule'
+
+ def apply(self):
+ pass
+
# 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.assertRaisesRegex(UserRuleError,
- "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
+ with self.assertRaisesMessage(UserRuleError,
+ "User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
find_rule_classes(user_rule_path)
def test_assert_valid_rule_class_negative_parent(self):
@@ -127,76 +135,101 @@ class UserRuleTests(BaseTestCase):
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):
+ 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):
- 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)
+ for parent_class in [rules.LineRule, rules.CommitRule]:
- # Rule ids must be non-empty
- MyRuleClass.id = ""
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
+ class MyRuleClass(parent_class):
+ pass
- # 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)):
+ # 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 = "The id '{0}' of 'MyRuleClass' is invalid. Gitlint reserves ids starting with R,T,B,M,I"
+ with self.assertRaisesMessage(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"
+ for parent_class in [rules.LineRule, rules.CommitRule]:
- # 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)
+ class MyRuleClass(parent_class):
+ id = "UC1"
- # Rule names must be non-empty
- MyRuleClass.name = ""
- with self.assertRaisesRegex(UserRuleError, expected_msg):
- assert_valid_rule_class(MyRuleClass)
+ # Rule class must have an 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):
- 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)
+ for parent_class in [rules.LineRule, rules.CommitRule]:
- # 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)
+ class MyRuleClass(parent_class):
+ 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.assertRaisesMessage(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.assertRaisesMessage(UserRuleError, expected_msg):
+ assert_valid_rule_class(MyRuleClass)
def test_assert_valid_rule_class_negative_validate(self):
- class MyRuleClass(rules.LineRule):
- id = "UC1"
+
+ baseclasses = [rules.LineRule, rules.CommitRule]
+ for clazz in baseclasses:
+ class MyRuleClass(clazz):
+ id = "UC1"
+ name = u"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 = u"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 = u"my-rüle-class"
- with self.assertRaisesRegex(UserRuleError,
- "User-defined rule class 'MyRuleClass' must have a 'validate' method"):
+ expected_msg = "User-defined Configuration rule class 'MyRuleClass' must have an 'apply' method"
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
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"):
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
assert_valid_rule_class(MyRuleClass)
def test_assert_valid_rule_class_negative_target(self):
@@ -210,12 +243,12 @@ class UserRuleTests(BaseTestCase):
# 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):
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
assert_valid_rule_class(MyRuleClass)
# invalid target
MyRuleClass.target = u"föo"
- with self.assertRaisesRegex(UserRuleError, expected_msg):
+ with self.assertRaisesMessage(UserRuleError, expected_msg):
assert_valid_rule_class(MyRuleClass)
# valid target, no exception should be raised
diff --git a/gitlint/tests/samples/commit_message/no-violations b/gitlint/tests/samples/commit_message/no-violations
new file mode 100644
index 0000000..33c73b9
--- /dev/null
+++ b/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/config/named-rules b/gitlint/tests/samples/config/named-rules
new file mode 100644
index 0000000..73ab0d2
--- /dev/null
+++ b/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/test_hooks.py b/gitlint/tests/test_hooks.py
index 08bd730..62f55e5 100644
--- a/gitlint/tests/test_hooks.py
+++ b/gitlint/tests/test_hooks.py
@@ -58,8 +58,8 @@ class HookTests(BaseTestCase):
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):
+ expected_msg = u"{0} is not a git repository.".format(lint_config.target)
+ 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()
@@ -71,7 +71,7 @@ class HookTests(BaseTestCase):
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):
+ with self.assertRaisesMessage(GitHookInstallerError, expected_msg):
GitHookInstaller.install_commit_msg_hook(lint_config)
@staticmethod
@@ -104,8 +104,8 @@ class HookTests(BaseTestCase):
# 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):
+ expected_msg = u"{0} is not a git repository.".format(lint_config.target)
+ 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()
@@ -116,7 +116,7 @@ class HookTests(BaseTestCase):
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):
+ 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)
@@ -131,6 +131,6 @@ class HookTests(BaseTestCase):
"(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):
+ with self.assertRaisesMessage(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
index bcdd984..3bf9a94 100644
--- a/gitlint/tests/test_lint.py
+++ b/gitlint/tests/test_lint.py
@@ -16,7 +16,7 @@ except ImportError:
from gitlint.tests.base import BaseTestCase
from gitlint.lint import GitLinter
-from gitlint.rules import RuleViolation
+from gitlint.rules import RuleViolation, TitleMustNotContainWord
from gitlint.config import LintConfig, LintConfigBuilder
@@ -103,7 +103,7 @@ class LintTests(BaseTestCase):
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 """
+ """ 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 = u"foo bår"
@@ -150,6 +150,25 @@ class LintTests(BaseTestCase):
self.assertListEqual(violations, expected)
+ # Test ignoring body lines
+ lint_config = LintConfig()
+ linter = GitLinter(lint_config)
+ lint_config.set_rule_option("I3", "regex", u"(.*)tråiling(.*)")
+ violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample1")))
+ 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", "This line has a trailing tab.\t", 4),
+ RuleViolation("B3", "Line contains hard tab characters (\\t)",
+ "This line has a trailing tab.\t", 4)]
+
+ self.assertListEqual(violations, expected_errors)
+
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)))
@@ -166,6 +185,31 @@ class LintTests(BaseTestCase):
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", u"Tïtle$"), ("body-match-regex", u"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", u"^Tïtle")
+ lintconfig.set_rule_option("body-match-regex", "regex", u"Sügned-Off-By: (.*)$")
+ expected_violations = [RuleViolation("T7", u"Title does not match regex (^Tïtle)", u"Normal Commit Tïtle", 1),
+ RuleViolation("B8", u"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", u"Error Messåge 1", "Violating Content 1", None),
RuleViolation("RULE_ID_2", "Error Message 2", u"Violåting Content 2", 2)]
@@ -195,3 +239,49 @@ class LintTests(BaseTestCase):
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())
+
+ 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 [u"my-ïd", u"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", [u"Föo"])
+ linter = GitLinter(lint_config)
+
+ violations = [RuleViolation("T5", u"Title contains the word 'WIP' (case-insensitive)", u"WIP: Föo bar", 1),
+ RuleViolation(u"T5:another-rule-ïd", u"Title contains the word 'Föo' (case-insensitive)",
+ u"WIP: Föo bar", 1),
+ RuleViolation(u"T5:my-ïd", u"Title contains the word 'Föo' (case-insensitive)",
+ u"WIP: Föo bar", 1)]
+ self.assertListEqual(violations, linter.lint(self.gitcommit(u"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 + u":my-ïd"
+ config_builder.set_option(rule_id, "words", [u"Föo"])
+ lint_config = config_builder.build()
+ linter = GitLinter(lint_config)
+ commit = self.gitcommit(u"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", u"Title contains the word 'WIP' (case-insensitive)", u"WIP: Föo bar", 1),
+ RuleViolation(u"T5:my-ïd", u"Title contains the word 'Föo' (case-insensitive)",
+ u"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 + u":my-ïd"]
+ self.assertListEqual(violations[:-1], linter.lint(commit))
diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py
index 2c17226..68f0f8c 100644
--- a/gitlint/tests/test_options.py
+++ b/gitlint/tests/test_options.py
@@ -1,42 +1,51 @@
# -*- coding: utf-8 -*-
import os
+import re
from gitlint.tests.base import BaseTestCase
-from gitlint.options import IntOption, BoolOption, StrOption, ListOption, PathOption, RuleOptionError
+from gitlint.options import IntOption, BoolOption, StrOption, ListOption, PathOption, RegexOption, 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"))
+ options = {IntOption: 123, StrOption: u"foöbar", BoolOption: False, ListOption: ["a", "b"],
+ PathOption: ".", RegexOption: u"^foöbar(.*)"}
+ for clazz, val in options.items():
+ # 2 options are equal if their name, value and description match
+ option1 = clazz(u"test-öption", val, u"Test Dëscription")
+ option2 = clazz(u"test-öption", val, u"Test Dëscription")
+ self.assertEqual(option1, option2)
+
+ # Not equal: class, name, description, value are different
+ self.assertNotEqual(option1, IntOption(u"tëst-option1", 123, u"Test Dëscription"))
+ self.assertNotEqual(option1, StrOption(u"tëst-option1", u"åbc", u"Test Dëscription"))
+ self.assertNotEqual(option1, StrOption(u"tëst-option", u"åbcd", u"Test Dëscription"))
+ self.assertNotEqual(option1, StrOption(u"tëst-option", u"åbc", u"Test Dëscription2"))
def test_int_option(self):
# normal behavior
- option = IntOption("test-name", 123, "Test Description")
+ option = IntOption(u"tëst-name", 123, u"Tëst Description")
+ self.assertEqual(option.name, u"tëst-name")
+ self.assertEqual(option.description, u"Tëst 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)
+ # set to None
+ option.set(None)
+ self.assertEqual(option.value, None)
+
# 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):
+ expected_error = u"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 = u"Option 'test-name' must be a positive integer (current value: 'foo')"
- with self.assertRaisesRegex(RuleOptionError, expected_error):
+ expected_error = u"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
@@ -46,15 +55,15 @@ class RuleOptionTests(BaseTestCase):
# 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):
+ with self.assertRaisesMessage(RuleOptionError, expected_error):
option.set("foo")
def test_str_option(self):
# normal behavior
- option = StrOption("test-name", u"föo", "Test Description")
+ option = StrOption(u"tëst-name", u"föo", u"Tëst Description")
+ self.assertEqual(option.name, u"tëst-name")
+ self.assertEqual(option.description, u"Tëst 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")
@@ -68,9 +77,15 @@ class RuleOptionTests(BaseTestCase):
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("test-name", "true", "Test Description")
+ option = BoolOption(u"tëst-name", "true", u"Tëst Description")
+ self.assertEqual(option.name, u"tëst-name")
+ self.assertEqual(option.description, u"Tëst Description")
self.assertEqual(option.value, True)
# re-set value
@@ -82,14 +97,16 @@ class RuleOptionTests(BaseTestCase):
self.assertEqual(option.value, True)
# error on incorrect value
- incorrect_values = [1, -1, "foo", u"bår", ["foo"], {'foo': "bar"}]
+ incorrect_values = [1, -1, "foo", u"bår", ["foo"], {'foo': "bar"}, None]
for value in incorrect_values:
- with self.assertRaisesRegex(RuleOptionError, "Option 'test-name' must be either 'true' or 'false'"):
+ with self.assertRaisesMessage(RuleOptionError, u"Option 'tëst-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")
+ option = ListOption(u"tëst-name", u"å,b,c,d", u"Tëst Description")
+ self.assertEqual(option.name, u"tëst-name")
+ self.assertEqual(option.description, u"Tëst Description")
self.assertListEqual(option.value, [u"å", u"b", u"c", u"d"])
# re-set value
@@ -100,6 +117,10 @@ class RuleOptionTests(BaseTestCase):
option.set([u"foo", u"bår", u"test"])
self.assertListEqual(option.value, [u"foo", u"bår", u"test"])
+ # None
+ option.set(None)
+ self.assertIsNone(option.value)
+
# empty string
option.set("")
self.assertListEqual(option.value, [])
@@ -129,40 +150,44 @@ class RuleOptionTests(BaseTestCase):
self.assertListEqual(option.value, ["123"])
def test_path_option(self):
- option = PathOption("test-directory", ".", u"Test Description", type=u"dir")
+ option = PathOption(u"tëst-directory", ".", u"Tëst Description", type=u"dir")
+ self.assertEqual(option.name, u"tëst-directory")
+ self.assertEqual(option.description, u"Tëst Description")
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 None
+ option.set(None)
+ self.assertIsNone(option.value)
+
# set to int
- expected = u"Option test-directory must be an existing directory (current value: '1234')"
- with self.assertRaisesRegex(RuleOptionError, expected):
+ expected = u"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(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)):
+ expected = u"Option tëst-directory must be an existing directory (current value: '{0}')"
+ with self.assertRaisesMessage(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):
+ expected = u"Option tëst-directory must be an existing directory (current value: '{0}')".format(sample_path)
+ with self.assertRaisesMessage(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(
+ expected = u"Option tëst-directory must be an existing file (current value: '{0}')".format(
self.get_sample_path())
- with self.assertRaisesRegex(RuleOptionError, expected):
+ with self.assertRaisesMessage(RuleOptionError, expected):
option.set(self.get_sample_path())
# set option.type = both, files and directories should now be accepted
@@ -174,6 +199,27 @@ class RuleOptionTests(BaseTestCase):
# 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):
+ expected = u"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(u"tëst-regex", u"^myrëgex(.*)foo$", u"Tëst Regex Description")
+ self.assertEqual(option.name, u"tëst-regex")
+ self.assertEqual(option.description, u"Tëst Regex Description")
+ self.assertEqual(option.value, re.compile(u"^myrëgex(.*)foo$", re.UNICODE))
+
+ # re-set value
+ option.set(u"[0-9]föbar.*")
+ self.assertEqual(option.value, re.compile(u"[0-9]föbar.*", re.UNICODE))
+
+ # set None
+ option.set(None)
+ self.assertIsNone(option.value)
+
+ # error on invalid regex
+ incorrect_values = [u"foo(", 123, -1]
+ for value in incorrect_values:
+ with self.assertRaisesRegex(RuleOptionError, u"Invalid regular expression"):
+ option.set(value)
diff --git a/gitlint/tests/test_utils.py b/gitlint/tests/test_utils.py
index 6f667c2..5841b63 100644
--- a/gitlint/tests/test_utils.py
+++ b/gitlint/tests/test_utils.py
@@ -60,19 +60,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": u"ASCII", "LC_CTYPE": u"UTF-16", "LANG": u"CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), u"ASCII")
+ mock_env = {"LC_CTYPE": u"UTF-16", "LANG": u"CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), u"UTF-16")
+ mock_env = {"LANG": u"CP1251"}
+ self.assertEqual(utils.getpreferredencoding(), u"CP1251")
# Assert split on dot
- mock_env = {"LANG": u"foo.bär"}
- self.assertEqual(utils.getpreferredencoding(), u"bär")
+ mock_env = {"LANG": u"foo.UTF-16"}
+ self.assertEqual(utils.getpreferredencoding(), u"UTF-16")
# assert default encoding is UTF-8
mock_env = {}
self.assertEqual(utils.getpreferredencoding(), "UTF-8")
mock_env = {"FOO": u"föo"}
self.assertEqual(utils.getpreferredencoding(), "UTF-8")
+
+ # assert fallback encoding is UTF-8 in case we set an unavailable encoding
+ mock_env = {"LC_ALL": u"foo"}
+ self.assertEqual(utils.getpreferredencoding(), u"UTF-8")
diff --git a/gitlint/utils.py b/gitlint/utils.py
index c418347..89015e7 100644
--- a/gitlint/utils.py
+++ b/gitlint/utils.py
@@ -1,4 +1,5 @@
# pylint: disable=bad-option-value,unidiomatic-typecheck,undefined-variable,no-else-return
+import codecs
import platform
import sys
import os
@@ -24,6 +25,16 @@ def platform_is_windows():
PLATFORM_IS_WINDOWS = platform_is_windows()
########################################################################################################################
+# IS_PY2
+
+
+def is_py2():
+ return sys.version_info[0] == 2
+
+
+IS_PY2 = is_py2()
+
+########################################################################################################################
# USE_SH_LIB
# Determine whether to use the `sh` library
# On windows we won't want to use the sh library since it's not supported - instead we'll use our own shell module.
@@ -46,13 +57,14 @@ USE_SH_LIB = use_sh_library()
def getpreferredencoding():
""" Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars
on windows and falls back to UTF-8. """
- default_encoding = locale.getpreferredencoding() or "UTF-8"
+ fallback_encoding = "UTF-8"
+ default_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"
+ default_encoding = fallback_encoding
for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]:
encoding = os.environ.get(env_var, False)
if encoding:
@@ -65,6 +77,15 @@ def getpreferredencoding():
default_encoding = encoding
break
+ # 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(default_encoding)
+ except LookupError:
+ default_encoding = fallback_encoding
+
return default_encoding
@@ -76,7 +97,7 @@ DEFAULT_ENCODING = getpreferredencoding()
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 IS_PY2:
# 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
@@ -94,11 +115,13 @@ 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.
+ if IS_PY2:
+ # For lists and tuples 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
+ elif type(obj) in [tuple]:
+ return tuple(sstr(item) for item in obj) # pragma: no cover # noqa
return unicode(obj).encode(DEFAULT_ENCODING) # pragma: no cover # noqa
else: