1
0
Fork 0

Merging upstream version 0.16.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-13 06:05:35 +01:00
parent 40df5416c1
commit 72676ec535
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
56 changed files with 615 additions and 161 deletions

View file

@ -1,2 +1,6 @@
[report]
fail_under = 97
[run]
omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv*
branch = true
omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv*

14
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: daily
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: pip
directory: /
schedule:
interval: daily

View file

@ -7,7 +7,7 @@ jobs:
runs-on: "ubuntu-latest"
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9, pypy3]
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", pypy3]
os: ["macos-latest", "ubuntu-latest"]
steps:
- uses: actions/checkout@v2
@ -69,8 +69,11 @@ jobs:
- name: Re-add git version control to code
run: mv ._git .git
# Run gitlint. Skip during PR runs, since PR commit messages are transient and usually full of gitlint violations.
# PRs get squashed and get a proper commit message during merge.
- name: Gitlint check
run: ./run_tests.sh -g --debug
if: ${{ github.event_name != 'pull_request' }}
windows-checks:
runs-on: windows-latest
@ -133,5 +136,8 @@ jobs:
- name: Re-add git version control to code
run: Rename-Item ._git .git
# Run gitlint. Skip during PR runs, since PR commit messages are transient and usually full of gitlint violations.
# PRs get squashed and get a proper commit message during merge.
- name: Gitlint check
run: gitlint --debug
if: ${{ github.event_name != 'pull_request' }}

View file

@ -1,5 +1,19 @@
# Changelog #
## v0.16.0 (2021-10-08) ##
Contributors:
Special thanks to all contributors for this release, in particular [sigmavirus24](https://github.com/sigmavirus24), [l0b0](https://github.com/l0b0) and [rafaelbubach](https://github.com/rafaelbubach).
- Python 3.10 support
- **New Rule**: [ignore-by-author-name](http://jorisroovers.github.io/gitlint/rules/#i4-ignore-by-author-name) allows users to skip linting commit messages made by specific authors
- `--commit <SHA>` flag to more easily lint a single commit message ([#141](https://github.com/jorisroovers/gitlint/issues/141))
- `--fail-without-commits` flag will force gitlint to fail ([exit code 253](https://jorisroovers.com/gitlint/#exit-codes)) when the target commit range is empty (typically when using `--commits`) ([#193](https://github.com/jorisroovers/gitlint/issues/193))
- Bugfixes:
- [contrib-title-conventional-commits (CT1)](https://jorisroovers.com/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits) now properly enforces the commit type ([#185](https://github.com/jorisroovers/gitlint/issues/185))
- [contrib-title-conventional-commits (CT1)](https://jorisroovers.com/gitlint/contrib_rules/#ct1-contrib-title-conventional-commits) now supports the BREAKING CHANGE symbol "!" ([#186](https://github.com/jorisroovers/gitlint/issues/186))
- Heads-up: [Python 3.6 will become EOL at the end of 2021](https://endoflife.date/python). It's likely that future gitlint releases will stop supporting Python 3.6 as a result. We will continue to support Python 3.6 as long as its easily doable, which in practice usually means as long as our dependencies support it.
- Under-the-hood: dependencies updated, test and github action improvements.
## v0.15.1 (2021-04-16) ##
Contributors:

View file

@ -9,7 +9,7 @@
# NOTE: --ulimit is required to work around a limitation in Docker
# Details: https://github.com/jorisroovers/gitlint/issues/129
FROM python:3.9-alpine
FROM python:3.10-alpine
ARG GITLINT_VERSION
RUN apk add git

View file

@ -8,12 +8,14 @@ Git commit message linter written in python (for Linux and Mac, experimental on
**See [jorisroovers.github.io/gitlint](http://jorisroovers.github.io/gitlint/) for full documentation.**
<a href="http://jorisroovers.github.io/gitlint/" target="_blank"><img src="https://asciinema.org/a/30477.png" width="640"/></a>
<a href="http://jorisroovers.github.io/gitlint/" target="_blank">
<img src="docs/images/readme-gitlint.png" />
</a>
## Contributing ##
All contributions are welcome and very much appreciated!
**I'm [looking for contributors](https://github.com/jorisroovers/gitlint/issues/134) that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please open a PR if you're interested - Thanks!**
**I'm [looking for contributors](https://github.com/jorisroovers/gitlint/issues/134) that are interested in taking a more active co-maintainer role as it's becoming increasingly difficult for me to find time to maintain gitlint. Please leave a comment in [#134](https://github.com/jorisroovers/gitlint/issues/134) if you're interested!**
See [jorisroovers.github.io/gitlint/contributing](http://jorisroovers.github.io/gitlint/contributing) for details on
how to get started - it's easy!

View file

@ -1 +1 @@
mkdocs==1.1.2
mkdocs==1.2.2

View file

@ -52,6 +52,12 @@ ignore-stdin=true
# commit message to gitlint via stdin or --commit-msg. Disabled by default.
staged=true
# Hard fail when the target commit range is empty. Note that gitlint will
# already fail by default on invalid commit ranges. This option is specifically
# to tell gitlint to fail on *valid but empty* commit ranges.
# Disabled by default.
fail-without-commits=true
# Enable debug mode (prints more output). Disabled by default.
debug=true
@ -128,7 +134,7 @@ ignore=T1,body-min-length
[ignore-by-body]
# Ignore certain rules for commits of which the body has a line that matches a regex
# E.g. Match bodies that have a line that that contain "release"
# regex=(.*)release(.*)
regex=(.*)release(.*)
#
# Ignore certain rules, you can reference them by their id or by their full name
# Use 'all' to ignore all rules
@ -139,6 +145,15 @@ ignore=T1,body-min-length
# E.g. Ignore all lines that start with 'Co-Authored-By'
regex=^Co-Authored-By
[ignore-by-author-name]
# Ignore certain rules for commits of which the author name matches a regex
# E.g. Match commits made by dependabot
regex=(.*)dependabot(.*)
# Ignore certain rules, you can reference them by their id or by their full name
# Use 'all' to ignore all rules
ignore=T1,body-min-length
# This is a contrib rule - a community contributed rule. These are disabled by default.
# You need to explicitly enable them one-by-one by adding them to the "contrib" option
# under [general] section above.
@ -363,6 +378,30 @@ GITLINT_STAGED=1 gitlint # using env variable
staged=true
```
### fail-without-commits
Hard fail when the target commit range is empty. Note that gitlint will
already fail by default on invalid commit ranges. This option is specifically
to tell gitlint to fail on **valid but empty** commit ranges.
Default value | gitlint version | commandline flag | environment variable
---------------|------------------|---------------------------|-----------------------
false | >= 0.15.2 | `--fail-without-commits` | `GITLINT_FAIL_WITHOUT_COMMITS`
#### Examples
```sh
# CLI
# The following will cause gitlint to hard fail (i.e. exit code > 0)
# since HEAD..HEAD is a valid but empty commit range.
gitlint --fail-without-commits --commits HEAD..HEAD
GITLINT_FAIL_WITHOUT_COMMITS=1 gitlint # using env variable
```
```ini
#.gitlint
[general]
fail-without-commits=true
```
### ignore-stdin
Ignore any stdin data. Sometimes useful when running gitlint in a CI server.

View file

@ -44,7 +44,9 @@ vagrant ssh
Or you can choose to use your local environment:
```sh
virtualenv .venv
python -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt -r test-requirements.txt -r doc-requirements.txt
python setup.py develop
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View file

@ -38,12 +38,11 @@ useful throughout the years.
# Pip is recommended to install the latest version
pip install gitlint
# macOS
brew install gitlint
sudo port install gitlint # alternative using macports
# Ubuntu
apt-get install gitlint
# Community maintained packages:
brew install gitlint # Homebrew (macOS)
sudo port install gitlint # Macports (macOS)
apt-get install gitlint # Ubuntu
# Other package managers, see https://repology.org/project/gitlint/versions
# Docker: https://hub.docker.com/r/jorisroovers/gitlint
docker run --ulimit nofile=1024 -v $(pwd):/repo jorisroovers/gitlint
@ -134,8 +133,9 @@ Options:
current working directory]
-C, --config FILE Config file location [default: .gitlint]
-c TEXT Config flags in format <rule>.<option>=<value>
(e.g.: -c T1.line-length=80). Flag can be used
multiple times to set multiple config values.
(e.g.: -c T1.line-length=80). Flag can be
used multiple times to set multiple config values.
--commit TEXT Hash (SHA) of specific commit to lint.
--commits TEXT The range of commits to lint. [default: HEAD]
-e, --extra-path PATH Path to a directory or python module with extra
user-defined rules
@ -147,10 +147,11 @@ Options:
server.
--staged Read staged commit meta-info from the local
repository.
-v, --verbose Verbosity, more v's for more verbose output (e.g.:
-v, -vv, -vvv). [default: -vvv]
-s, --silent Silent mode (no output). Takes precedence over -v,
-vv, -vvv.
--fail-without-commits Hard fail when the target commit range is empty.
-v, --verbose Verbosity, more v's for more verbose output
(e.g.: -v, -vv, -vvv). [default: -vvv]
-s, --silent Silent mode (no output).
Takes precedence over -v, -vv, -vvv.
-d, --debug Enable debugging output.
--version Show the version and exit.
--help Show this message and exit.
@ -159,6 +160,7 @@ Commands:
generate-config Generates a sample gitlint config file.
install-hook Install gitlint as a git commit-msg hook.
lint Lints a git repository [default command]
run-hook Runs the gitlint commit-msg hook.
uninstall-hook Uninstall gitlint commit-msg hook.
When no COMMAND is specified, gitlint defaults to 'gitlint lint'.
@ -246,19 +248,21 @@ git log -1 --pretty=%B 62c0519 | gitlint
Note that gitlint requires that you specify `--pretty=%B` (=only print the log message, not the metadata),
future versions of gitlint might fix this and not require the `--pretty` argument.
## Linting a range of commits
## Linting specific commits
_Introduced in gitlint v0.9.0 (experimental in v0.8.0)_
Gitlint allows users to lint a specific commit:
```sh
gitlint --commit 019cf40580a471a3958d3c346aa8bfd265fe5e16
gitlint --commit 019cf40 # short SHAs work too
```
Gitlint allows users to lint a number of commits at once like so:
You can also lint multiple commits at once like so:
```sh
# Lint a specific commit range:
gitlint --commits "019cf40...d6bc75a"
# You can also use git's special references:
gitlint --commits "origin..HEAD"
# Or specify a single specific commit in refspec format, like so:
gitlint --commits "019cf40^...019cf40"
```
The `--commits` flag takes a **single** refspec argument or commit range. Basically, any range that is understood
@ -271,9 +275,8 @@ script to lint an arbitrary set of commits, like shown in the example below.
#!/bin/sh
for commit in $(git rev-list master); do
commit_msg=$(git log -1 --pretty=%B $commit)
echo "$commit"
echo "$commit_msg" | gitlint
echo "Commit $commit"
gitlint --commit $commit
echo "--------"
done
```
@ -309,7 +312,6 @@ general `ignore-merge-commits`, `ignore-revert-commits`, `ignore-fixup-commits`
[using one of the various ways to configure gitlint](configuration.md).
## Ignoring commits
_Introduced in gitlint v0.10.0_
You can configure gitlint to ignore specific commits or parts of a commit.
@ -317,8 +319,7 @@ One way to do this, is to by [adding a gitline-ignore line to your commit messag
If you have a case where you want to ignore a certain type of commits all-together, you can
use gitlint's *ignore* rules.
Here's an example gitlint file that configures gitlint to ignore rules `title-max-length` and `body-min-length`
for all commits with a title starting with *"Release"*.
Here's a few examples snippets from a `.gitlint` file:
```ini
[ignore-by-title]
@ -332,6 +333,11 @@ ignore=title-max-length,body-min-length
# Match commits message bodies that have a line that contains 'release'
regex=(.*)release(.*)
ignore=all
[ignore-by-author-name]
# Match commits by author name (e.g. ignore all rules when a commit is made by dependabot)
regex=dependabot
ignore=all
```
If you just want to ignore certain lines in a commit, you can do that using the

View file

@ -30,6 +30,8 @@ M1 | author-valid-email | >= 0.9.0 | Author email address m
I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title
I2 | ignore-by-body | >= 0.10.0 | Ignore a commit based on matching its body
I3 | ignore-body-lines | >= 0.14.0 | Ignore certain lines in a commit body that match a regex
I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name
## T1: title-max-length
@ -405,3 +407,32 @@ regex=(^Co-Authored-By)|(^Signed-off-by)
[ignore-body-lines]
regex=(.*)foobar(.*)
```
## I4: ignore-by-author-name
ID | Name | gitlint version | Description
------|---------------------------|-----------------|-------------------------------------------
I4 | ignore-by-author-name | >= 0.16.0 | Ignore a commit based on matching its author name.
### Options
Name | gitlint version | Default | Description
----------------------|-------------------|------------------------------|----------------------------------
regex | >= 0.16.0 | None | [Python regex](https://docs.python.org/library/re.html) to match against the commit author name. On match, the commit will be ignored.
ignore | >= 0.16.0 | all | Comma-separated list of rule names or ids to ignore when this rule is matched.
### Examples
#### .gitlint
```ini
# Ignore all commits authored by dependabot
[ignore-by-author-name]
regex=dependabot
# For commits made by anyone with "[bot]" in their name, ignore
# rules T1, body-min-length and B6
[ignore-by-author-name]
regex=(.*)\[bot\](.*)
ignore=T1,body-min-length,B6
```

View file

@ -1 +1 @@
__version__ = "0.15.1"
__version__ = "0.16.0"

View file

@ -18,6 +18,7 @@ from gitlint.utils import LOG_FORMAT
from gitlint.exception import GitlintError
# Error codes
GITLINT_SUCCESS = 0
MAX_VIOLATION_ERROR_CODE = 252
USAGE_ERROR_CODE = 253
GIT_CONTEXT_ERROR_CODE = 254
@ -61,7 +62,8 @@ def log_system_info():
def build_config( # pylint: disable=too-many-arguments
target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, verbose, silent, debug
target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, fail_without_commits, verbose,
silent, debug
):
""" Creates a LintConfig object based on a set of commandline parameters. """
config_builder = LintConfigBuilder()
@ -102,6 +104,9 @@ def build_config( # pylint: disable=too-many-arguments
if staged:
config_builder.set_option('general', 'staged', staged)
if fail_without_commits:
config_builder.set_option('general', 'fail-without-commits', fail_without_commits)
config = config_builder.build()
return config, config_builder
@ -139,7 +144,7 @@ def get_stdin_data():
return False
def build_git_context(lint_config, msg_filename, refspec):
def build_git_context(lint_config, msg_filename, commit_hash, refspec):
""" Builds a git context based on passed parameters and order of precedence """
# Determine which GitContext method to use if a custom message is passed
@ -168,7 +173,11 @@ def build_git_context(lint_config, msg_filename, refspec):
# 3. Fallback to reading from local repository
LOG.debug("No --msg-filename flag, no or empty data passed to stdin. Using the local repo.")
return GitContext.from_local_repository(lint_config.target, refspec)
if commit_hash and refspec:
raise GitLintUsageError("--commit and --commits are mutually exclusive, use one or the other.")
return GitContext.from_local_repository(lint_config.target, refspec=refspec, commit_hash=commit_hash)
def handle_gitlint_error(ctx, exc):
@ -187,9 +196,10 @@ def handle_gitlint_error(ctx, exc):
class ContextObj:
""" 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):
def __init__(self, config, config_builder, commit_hash, refspec, msg_filename, gitcontext=None):
self.config = config
self.config_builder = config_builder
self.commit_hash = commit_hash
self.refspec = refspec
self.msg_filename = msg_filename
self.gitcontext = gitcontext
@ -205,6 +215,7 @@ class ContextObj:
@click.option('-c', multiple=True,
help="Config flags in format <rule>.<option>=<value> (e.g.: -c T1.line-length=80). " +
"Flag can be used multiple times to set multiple config values.") # pylint: disable=bad-continuation
@click.option('--commit', envvar='GITLINT_COMMIT', default=None, help="Hash (SHA) of specific commit to lint.")
@click.option('--commits', envvar='GITLINT_COMMITS', default=None, help="The range of commits to lint. [default: HEAD]")
@click.option('-e', '--extra-path', envvar='GITLINT_EXTRA_PATH',
help="Path to a directory or python module with extra user-defined rules",
@ -217,6 +228,8 @@ class ContextObj:
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('--fail-without-commits', envvar='GITLINT_FAIL_WITHOUT_COMMITS', is_flag=True,
help="Hard fail when the target commit range is empty.")
@click.option('-v', '--verbose', envvar='GITLINT_VERBOSITY', count=True, default=0,
help="Verbosity, more v's for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv]", )
@click.option('-s', '--silent', envvar='GITLINT_SILENT', is_flag=True,
@ -225,8 +238,9 @@ class ContextObj:
@click.version_option(version=gitlint.__version__)
@click.pass_context
def cli( # pylint: disable=too-many-arguments
ctx, target, config, c, commits, extra_path, ignore, contrib,
msg_filename, ignore_stdin, staged, verbose, silent, debug,
ctx, target, config, c, commit, commits, extra_path, ignore, contrib,
msg_filename, ignore_stdin, staged, fail_without_commits, verbose,
silent, debug,
):
""" Git lint tool, checks your git commit messages for styling issues
@ -242,11 +256,11 @@ def cli( # pylint: disable=too-many-arguments
# Get the lint config from the commandline parameters and
# store it in the context (click allows storing an arbitrary object in ctx.obj).
config, config_builder = build_config(target, config, c, extra_path, ignore, contrib,
ignore_stdin, staged, verbose, silent, debug)
config, config_builder = build_config(target, config, c, extra_path, ignore, contrib, ignore_stdin, staged,
fail_without_commits, verbose, silent, debug)
LOG.debug("Configuration\n%s", config)
ctx.obj = ContextObj(config, config_builder, commits, msg_filename)
ctx.obj = ContextObj(config, config_builder, commit, commits, msg_filename)
# If no subcommand is specified, then just lint
if ctx.invoked_subcommand is None:
@ -262,9 +276,10 @@ def lint(ctx):
""" Lints a git repository [default command] """
lint_config = ctx.obj.config
refspec = ctx.obj.refspec
commit_hash = ctx.obj.commit_hash
msg_filename = ctx.obj.msg_filename
gitcontext = build_git_context(lint_config, msg_filename, refspec)
gitcontext = build_git_context(lint_config, msg_filename, commit_hash, refspec)
# Set gitcontext in the click context, so we can use it in command that are ran after this
# in particular, this is used by run-hook
ctx.obj.gitcontext = gitcontext
@ -273,17 +288,20 @@ def lint(ctx):
# Exit if we don't have commits in the specified range. Use a 0 exit code, since a popular use-case is one
# where users are using --commits in a check job to check the commit messages inside a CI job. By returning 0, we
# ensure that these jobs don't fail if for whatever reason the specified commit range is empty.
# This behavior can be overridden by using the --fail-without-commits flag.
if number_of_commits == 0:
LOG.debug(u'No commits in range "%s"', refspec)
ctx.exit(0)
LOG.debug('No commits in range "%s"', refspec)
if lint_config.fail_without_commits:
raise GitLintUsageError(f'No commits in range "{refspec}"')
ctx.exit(GITLINT_SUCCESS)
LOG.debug(u'Linting %d commit(s)', number_of_commits)
LOG.debug('Linting %d commit(s)', number_of_commits)
general_config_builder = ctx.obj.config_builder
last_commit = gitcontext.commits[-1]
# Let's get linting!
first_violation = True
exit_code = 0
exit_code = GITLINT_SUCCESS
for commit in gitcontext.commits:
# Build a config_builder taking into account the commit specific config (if any)
config_builder = general_config_builder.clone()
@ -301,10 +319,8 @@ def lint(ctx):
if violations:
# Display the commit hash & new lines intelligently
if number_of_commits > 1 and commit.sha:
linter.display.e("{0}Commit {1}:".format(
"\n" if not first_violation or commit is last_commit else "",
commit.sha[:10]
))
commit_separator = "\n" if not first_violation or commit is last_commit else ""
linter.display.e(f"{commit_separator}Commit {commit.sha[:10]}:")
linter.print_violations(violations)
first_violation = False
@ -323,7 +339,7 @@ def install_hook(ctx):
hooks.GitHookInstaller.install_commit_msg_hook(ctx.obj.config)
hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
click.echo(f"Successfully installed gitlint commit-msg hook in {hook_path}")
ctx.exit(0)
ctx.exit(GITLINT_SUCCESS)
except hooks.GitHookInstallerError as e:
click.echo(e, err=True)
ctx.exit(GIT_CONTEXT_ERROR_CODE)
@ -337,7 +353,7 @@ def uninstall_hook(ctx):
hooks.GitHookInstaller.uninstall_commit_msg_hook(ctx.obj.config)
hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config)
click.echo(f"Successfully uninstalled gitlint commit-msg hook from {hook_path}")
ctx.exit(0)
ctx.exit(GITLINT_SUCCESS)
except hooks.GitHookInstallerError as e:
click.echo(e, err=True)
ctx.exit(GIT_CONTEXT_ERROR_CODE)
@ -361,7 +377,7 @@ def run_hook(ctx):
sys.stdout.flush()
exit_code = e.exit_code
if exit_code == 0:
if exit_code == GITLINT_SUCCESS:
click.echo("gitlint: " + click.style("OK", fg='green') + " (no violations in commit message)")
continue
@ -387,7 +403,7 @@ def run_hook(ctx):
if value == "y":
LOG.debug("run-hook: commit message accepted")
exit_code = 0
exit_code = GITLINT_SUCCESS
elif value == "e":
LOG.debug("run-hook: editing commit message")
msg_filename = ctx.obj.msg_filename
@ -428,7 +444,7 @@ def generate_config(ctx):
LintConfigGenerator.generate_config(path)
click.echo(f"Successfully generated {path}")
ctx.exit(0)
ctx.exit(GITLINT_SUCCESS)
# Let's Party!

View file

@ -41,6 +41,7 @@ class LintConfig:
default_rule_classes = (rules.IgnoreByTitle,
rules.IgnoreByBody,
rules.IgnoreBodyLines,
rules.IgnoreByAuthorName,
rules.TitleMaxLength,
rules.TitleTrailingWhitespace,
rules.TitleLeadingWhitespace,
@ -76,6 +77,8 @@ class LintConfig:
ignore_stdin_description = "Ignore any stdin data. Useful for running in CI server."
self._ignore_stdin = options.BoolOption('ignore-stdin', False, ignore_stdin_description)
self._staged = options.BoolOption('staged', False, "Read staged commit meta-info from the local repository.")
self._fail_without_commits = options.BoolOption('fail-without-commits', False,
"Hard fail when the target commit range is empty")
@property
def target(self):
@ -170,6 +173,15 @@ class LintConfig:
def staged(self, value):
return self._staged.set(value)
@property
def fail_without_commits(self):
return self._fail_without_commits.value
@fail_without_commits.setter
@handle_option_error
def fail_without_commits(self, value):
return self._fail_without_commits.set(value)
@property
def extra_path(self):
return self._extra_path.value if self._extra_path else None
@ -275,6 +287,7 @@ class LintConfig:
self.ignore_revert_commits == other.ignore_revert_commits and \
self.ignore_stdin == other.ignore_stdin and \
self.staged == other.staged and \
self.fail_without_commits == other.fail_without_commits and \
self.debug == other.debug and \
self.ignore == other.ignore and \
self._config_path == other._config_path # noqa
@ -292,6 +305,7 @@ class LintConfig:
f"ignore-revert-commits: {self.ignore_revert_commits}\n"
f"ignore-stdin: {self.ignore_stdin}\n"
f"staged: {self.staged}\n"
f"fail-without-commits: {self.fail_without_commits}\n"
f"verbosity: {self.verbosity}\n"
f"debug: {self.debug}\n"
f"target: {self.target}\n"

View file

@ -3,7 +3,7 @@ import re
from gitlint.options import ListOption
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
RULE_REGEX = re.compile(r"[^(]+?(\([^)]+?\))?: .+")
RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+")
class ConventionalCommit(LineRule):
@ -23,16 +23,15 @@ class ConventionalCommit(LineRule):
def validate(self, line, _commit):
violations = []
match = RULE_REGEX.match(line)
for commit_type in self.options["types"].value:
if line.startswith(commit_type):
break
else:
msg = "Title does not start with one of {0}".format(', '.join(self.options['types'].value))
violations.append(RuleViolation(self.id, msg, line))
if not RULE_REGEX.match(line):
if not match:
msg = "Title does not follow ConventionalCommits.org format 'type(optional-scope): description'"
violations.append(RuleViolation(self.id, msg, line))
else:
line_commit_type = match.group(1)
if line_commit_type not in self.options["types"].value:
opt_str = ', '.join(self.options['types'].value)
violations.append(RuleViolation(self.id, f"Title does not start with one of {opt_str}", line))
return violations

View file

@ -27,6 +27,12 @@
# commit message to gitlint via stdin or --commit-msg. Disabled by default.
# staged=true
# Hard fail when the target commit range is empty. Note that gitlint will
# already fail by default on invalid commit ranges. This option is specifically
# to tell gitlint to fail on *valid but empty* commit ranges.
# Disabled by default.
# fail-without-commits=true
# Enable debug mode (prints more output). Disabled by default.
# debug=true
@ -111,6 +117,15 @@
# E.g. Ignore all lines that start with 'Co-Authored-By'
# regex=^Co-Authored-By
# [ignore-by-author-name]
# Ignore certain rules for commits of which the author name matches a regex
# E.g. Match commits made by dependabot
# regex=(.*)dependabot(.*)
#
# Ignore certain rules, you can reference them by their id or by their full name
# Use 'all' to ignore all rules
# ignore=T1,body-min-length
# This is a contrib rule - a community contributed rule. These are disabled by default.
# You need to explicitly enable them one-by-one by adding them to the "contrib" option
# under [general] section above.

View file

@ -364,22 +364,27 @@ class GitContext(PropertyCache):
return context
@staticmethod
def from_local_repository(repository_path, refspec=None):
def from_local_repository(repository_path, refspec=None, commit_hash=None):
""" Retrieves the git context from a local git repository.
:param repository_path: Path to the git repository to retrieve the context from
:param refspec: The commit(s) to retrieve
:param refspec: The commit(s) to retrieve (mutually exclusive with `commit_sha`)
:param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`)
"""
context = GitContext(repository_path=repository_path)
# If no refspec is defined, fallback to the last commit on the current branch
if refspec is None:
if refspec:
sha_list = _git("rev-list", refspec, _cwd=repository_path).split()
elif commit_hash: # Single commit, just pass it to `git log -1`
# Even though we have already been passed the commit hash, we ask git to retrieve this hash and
# return it to us. This way we verify that the passed hash is a valid hash for the target repo and we
# also convert it to the full hash format (we might have been passed a short hash).
sha_list = [_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")]
else: # If no refspec is defined, fallback to the last commit on the current branch
# We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with
# repos that only have a single commit - HEAD^... doesn't work there), but then we still get into
# problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`.
sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace("\n", "")]
else:
sha_list = _git("rev-list", refspec, _cwd=repository_path).split()
for sha in sha_list:
commit = LocalGitCommit(context, sha)

View file

@ -141,7 +141,7 @@ class LineMustNotContainWord(LineRule):
strings = self.options['words'].value
violations = []
for string in strings:
regex = re.compile(r"\b%s\b" % string.lower(), re.IGNORECASE | re.UNICODE)
regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE)
match = regex.search(line.lower())
if match:
violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
@ -416,3 +416,25 @@ class IgnoreBodyLines(ConfigurationRule):
commit.message.body = new_body
commit.message.full = "\n".join([commit.message.title] + new_body)
class IgnoreByAuthorName(ConfigurationRule):
name = "ignore-by-author-name"
id = "I4"
options_spec = [RegexOption('regex', None, "Regex matching the author name of commits this rule should apply to"),
StrOption('ignore', "all", "Comma-separated list of rules to ignore")]
def apply(self, config, commit):
# If no regex is specified, immediately return
if not self.options['regex'].value:
return
if self.options['regex'].value.match(commit.author_name):
config.ignore = self.options['ignore'].value
message = (f"Commit Author Name '{commit.author_name}' matches the regex "
f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}")
self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
# No need to check other lines if we found a match
return

View file

@ -11,8 +11,8 @@ from gitlint.utils import USE_SH_LIB, DEFAULT_ENCODING
def shell(cmd):
""" Convenience function that opens a given command in a shell. Does not use 'sh' library. """
p = subprocess.Popen(cmd, shell=True)
p.communicate()
with subprocess.Popen(cmd, shell=True) as p:
p.communicate()
if USE_SH_LIB:
@ -57,8 +57,8 @@ else:
popen_kwargs['cwd'] = kwargs['_cwd']
try:
p = subprocess.Popen(args, **popen_kwargs)
result = p.communicate()
with subprocess.Popen(args, **popen_kwargs) as p:
result = p.communicate()
except FileNotFoundError as e:
raise CommandNotFound from e

View file

@ -126,6 +126,10 @@ class BaseTestCase(unittest.TestCase):
"""
return super().assertRaisesRegex(expected_exception, re.escape(expected_regex), *args, **kwargs)
def clearlog(self):
""" Clears the log capture """
self.logcapture.clear()
@contextlib.contextmanager
def assertRaisesMessage(self, expected_exception, expected_msg): # pylint: disable=invalid-name
""" Asserts an exception has occurred with a given error message """
@ -182,3 +186,6 @@ class LogCapture(logging.Handler):
def emit(self, record):
self.messages.append(self.format(record))
def clear(self):
self.messages = []

View file

@ -26,6 +26,7 @@ class CLITests(BaseTestCase):
USAGE_ERROR_CODE = 253
GIT_CONTEXT_ERROR_CODE = 254
CONFIG_ERROR_CODE = 255
GITLINT_SUCCESS_CODE = 0
def setUp(self):
super(CLITests, self).setUp()
@ -180,6 +181,39 @@ class CLITests(BaseTestCase):
self.assertEqual(stderr.getvalue(), expected)
self.assertEqual(result.exit_code, 2)
@patch('gitlint.cli.get_stdin_data', return_value=False)
@patch('gitlint.git.sh')
def test_lint_commit(self, sh, _):
""" Test for --commit option """
sh.git.side_effect = [
"6f29bf81a8322a04071bb794666e48c443a90360\n", # git log -1 <SHA> --pretty=%H
# git log --pretty <FORMAT> <SHA>
"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
"WIP: commït-title1\n\ncommït-body1",
"#", # git config --get core.commentchar
"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
]
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
result = self.cli.invoke(cli.cli, ["--commit", "foo"])
self.assertEqual(result.output, "")
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_commit_1"))
self.assertEqual(result.exit_code, 2)
@patch('gitlint.cli.get_stdin_data', return_value=False)
@patch('gitlint.git.sh')
def test_lint_commit_negative(self, sh, _):
""" Negative test for --commit option """
# Try using --commit and --commits at the same time (not allowed)
result = self.cli.invoke(cli.cli, ["--commit", "foo", "--commits", "foo...bar"])
expected_output = "Error: --commit and --commits are mutually exclusive, use one or the other.\n"
self.assertEqual(result.output, expected_output)
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
@patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
def test_input_stream(self, _):
""" Test for linting when a message is passed via stdin """
@ -282,6 +316,30 @@ class CLITests(BaseTestCase):
self.assertEqual(result.output, ("Error: The 'staged' option (--staged) can only be used when using "
"'--msg-filename' or when piping data to gitlint via stdin.\n"))
@patch('gitlint.cli.get_stdin_data', return_value=False)
@patch('gitlint.git.sh')
def test_fail_without_commits(self, sh, _):
""" Test for --debug option """
sh.git.side_effect = [
"", # First invocation of git rev-list
"" # Second invocation of git rev-list
]
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
# By default, gitlint should silently exit with code GITLINT_SUCCESS when there are no commits
result = self.cli.invoke(cli.cli, ["--commits", "foo..bar"])
self.assertEqual(stderr.getvalue(), "")
self.assertEqual(result.exit_code, cli.GITLINT_SUCCESS)
self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"")
# When --fail-without-commits is set, gitlint should hard fail with code USAGE_ERROR_CODE
self.clearlog()
result = self.cli.invoke(cli.cli, ["--commits", "foo..bar", "--fail-without-commits"])
self.assertEqual(result.output, 'Error: No commits in range "foo..bar"\n')
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"")
@patch('gitlint.cli.get_stdin_data', return_value=False)
def test_msg_filename(self, _):
expected_output = "3: B6 Body message is missing\n"
@ -405,7 +463,7 @@ class CLITests(BaseTestCase):
result = self.cli.invoke(cli.cli, ["--contrib", "contrib-title-conventional-commits,CC1"])
expected_output = self.get_expected('cli/test_cli/test_contrib_1')
self.assertEqual(stderr.getvalue(), expected_output)
self.assertEqual(result.exit_code, 3)
self.assertEqual(result.exit_code, 2)
@patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n")
def test_contrib_negative(self, _):
@ -475,7 +533,7 @@ class CLITests(BaseTestCase):
def test_generate_config(self, generate_config):
""" Test for the generate-config subcommand """
result = self.cli.invoke(cli.cli, ["generate-config"], input="tëstfile\n")
self.assertEqual(result.exit_code, 0)
self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE)
expected_msg = "Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + \
f"Successfully generated {os.path.realpath('tëstfile')}\n"
self.assertEqual(result.output, expected_msg)
@ -517,7 +575,7 @@ class CLITests(BaseTestCase):
result = self.cli.invoke(cli.cli, ["--commits", "master...HEAD"])
self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"master...HEAD\"")
self.assertEqual(result.exit_code, 0)
self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE)
@patch('gitlint.cli.get_stdin_data', return_value="WIP: tëst tïtle")
def test_named_rules(self, _):

View file

@ -50,6 +50,7 @@ class LintConfigTests(BaseTestCase):
self.assertFalse(config.ignore_stdin)
self.assertFalse(config.staged)
self.assertFalse(config.fail_without_commits)
self.assertFalse(config.debug)
self.assertEqual(config.verbosity, 3)
active_rule_classes = tuple(type(rule) for rule in config.rules)
@ -95,6 +96,10 @@ class LintConfigTests(BaseTestCase):
config.set_general_option("staged", "true")
self.assertTrue(config.staged)
# fail-without-commits
config.set_general_option("fail-without-commits", "true")
self.assertTrue(config.fail_without_commits)
# target
config.set_general_option("target", self.SAMPLES_DIR)
self.assertEqual(config.target, self.SAMPLES_DIR)
@ -227,7 +232,7 @@ class LintConfigTests(BaseTestCase):
# splitting which means it it will accept just about everything
# invalid boolean options
for attribute in ['debug', 'staged', 'ignore_stdin']:
for attribute in ['debug', 'staged', 'ignore_stdin', 'fail_without_commits']:
option_name = attribute.replace("_", "-")
with self.assertRaisesMessage(LintConfigError,
f"Option '{option_name}' must be either 'true' or 'false'"):

View file

@ -29,12 +29,34 @@ class ContribConventionalCommitTests(BaseTestCase):
violations = rule.validate("bår: foo", None)
self.assertListEqual([expected_violation], violations)
# assert violation when use strange chars after correct type
expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs,"
" style, refactor, perf, test, revert, ci, build",
"feat_wrong_chars: föo")
violations = rule.validate("feat_wrong_chars: föo", None)
self.assertListEqual([expected_violation], violations)
# assert violation when use strange chars after correct type
expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs,"
" style, refactor, perf, test, revert, ci, build",
"feat_wrong_chars(scope): föo")
violations = rule.validate("feat_wrong_chars(scope): föo", None)
self.assertListEqual([expected_violation], violations)
# assert violation on wrong format
expected_violation = RuleViolation("CT1", "Title does not follow ConventionalCommits.org format "
"'type(optional-scope): description'", "fix föo")
violations = rule.validate("fix föo", None)
self.assertListEqual([expected_violation], violations)
# assert no violation when use ! for breaking changes without scope
violations = rule.validate("feat!: föo", None)
self.assertListEqual([], violations)
# assert no violation when use ! for breaking changes with scope
violations = rule.validate("fix(scope)!: föo", None)
self.assertListEqual([], violations)
# assert no violation when adding new type
rule = ConventionalCommit({'types': ["föo", "bär"]})
for typ in ["föo", "bär"]:
@ -45,3 +67,9 @@ class ContribConventionalCommitTests(BaseTestCase):
violations = rule.validate("fix: hür dur", None)
expected_violation = RuleViolation("CT1", "Title does not start with one of föo, bär", "fix: hür dur")
self.assertListEqual([expected_violation], violations)
# assert no violation when adding new type named with numbers
rule = ConventionalCommit({'types': ["föo123", "123bär"]})
for typ in ["föo123", "123bär"]:
violations = rule.validate(typ + ": hür dur", None)
self.assertListEqual([], violations)

View file

@ -1,3 +1,2 @@
1: CC1 Body does not contain a 'Signed-off-by' line
1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build: "Test tïtle"
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"

View file

@ -17,6 +17,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: False
fail-without-commits: False
verbosity: 1
debug: True
target: {target}
@ -29,6 +30,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=20
T2: title-trailing-whitespace

View file

@ -17,6 +17,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: False
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -29,6 +30,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -0,0 +1,2 @@
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title1"
3: B5 Body message is too short (12<20): "commït-body1"

View file

@ -17,6 +17,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: True
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -29,6 +30,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -17,6 +17,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: True
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -29,6 +30,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -17,6 +17,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: False
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -29,6 +30,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -75,11 +75,12 @@ class GitCommitTests(BaseTestCase):
self.assertListEqual(sh.git.mock_calls, expected_calls)
@patch('gitlint.git.sh')
def test_from_local_repository_specific_ref(self, sh):
sample_sha = "myspecialref"
def test_from_local_repository_specific_refspec(self, sh):
sample_refspec = "åbc123..def456"
sample_sha = "åbc123"
sh.git.side_effect = [
sample_sha,
sample_sha, # git rev-list <sample_refspec>
"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
"cömmit-title\n\ncömmit-body",
"#", # git config --get core.commentchar
@ -87,10 +88,10 @@ class GitCommitTests(BaseTestCase):
"foöbar\n* hürdur\n"
]
context = GitContext.from_local_repository("fåke/path", sample_sha)
context = GitContext.from_local_repository("fåke/path", refspec=sample_refspec)
# assert that commit info was read using git command
expected_calls = [
call("rev-list", sample_sha, **self.expected_sh_special_args),
call("rev-list", sample_refspec, **self.expected_sh_special_args),
call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha,
@ -127,6 +128,59 @@ class GitCommitTests(BaseTestCase):
# All expected calls should've happened at this point
self.assertListEqual(sh.git.mock_calls, expected_calls)
@patch('gitlint.git.sh')
def test_from_local_repository_specific_commit_hash(self, sh):
sample_hash = "åbc123"
sh.git.side_effect = [
sample_hash, # git log -1 <sample_hash>
"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
"cömmit-title\n\ncömmit-body",
"#", # git config --get core.commentchar
"file1.txt\npåth/to/file2.txt\n",
"foöbar\n* hürdur\n"
]
context = GitContext.from_local_repository("fåke/path", commit_hash=sample_hash)
# assert that commit info was read using git command
expected_calls = [
call("log", "-1", sample_hash, "--pretty=%H", **self.expected_sh_special_args),
call("log", sample_hash, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args),
call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_hash,
**self.expected_sh_special_args),
call('branch', '--contains', sample_hash, **self.expected_sh_special_args)
]
# Only first 'git log' call should've happened at this point
self.assertEqual(sh.git.mock_calls, expected_calls[:1])
last_commit = context.commits[-1]
self.assertIsInstance(last_commit, LocalGitCommit)
self.assertEqual(last_commit.sha, sample_hash)
self.assertEqual(last_commit.message.title, "cömmit-title")
self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
self.assertEqual(last_commit.author_name, "test åuthor")
self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15,
tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
self.assertListEqual(last_commit.parents, ["åbc"])
self.assertFalse(last_commit.is_merge_commit)
self.assertFalse(last_commit.is_fixup_commit)
self.assertFalse(last_commit.is_squash_commit)
self.assertFalse(last_commit.is_revert_commit)
# First 2 'git log' calls should've happened at this point
self.assertListEqual(sh.git.mock_calls, expected_calls[:3])
self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
# 'git diff-tree' should have happened at this point
self.assertListEqual(sh.git.mock_calls, expected_calls[:4])
self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"])
# All expected calls should've happened at this point
self.assertListEqual(sh.git.mock_calls, expected_calls)
@patch('gitlint.git.sh')
def test_get_latest_commit_merge_commit(self, sh):
sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"

View file

@ -101,13 +101,13 @@ class BodyRuleTests(BaseTestCase):
expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", "å" * 21, 3)
rule = rules.BodyMinLength({'min-length': 120})
commit = self.gitcommit("Title\n\n%s\n" % ("å" * 21))
commit = self.gitcommit("Title\n\n{0}\n".format("å" * 21)) # pylint: disable=consider-using-f-string
violations = rule.validate(commit)
self.assertListEqual(violations, [expected_violation])
# Make sure we don't get the error if the body-length is exactly the min-length
rule = rules.BodyMinLength({'min-length': 8})
commit = self.gitcommit("Tïtle\n\n%s\n" % ("å" * 8))
commit = self.gitcommit("Tïtle\n\n{0}\n".format("å" * 8)) # pylint: disable=consider-using-f-string
violations = rule.validate(commit)
self.assertIsNone(violations)
@ -182,7 +182,7 @@ class BodyRuleTests(BaseTestCase):
expected_violation = rules.RuleViolation("B7", "Body does not mention changed file 'föo/test.py'", None, 4)
self.assertEqual([expected_violation], violations)
# assert multiple errors if multiple files habe changed and are not mentioned
# assert multiple errors if multiple files have changed and are not mentioned
commit_msg = "This is å test\n\nHere is a mention of\nAnd here is a mention of"
commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
violations = rule.validate(commit)

View file

@ -71,6 +71,39 @@ class ConfigurationRuleTests(BaseTestCase):
"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_by_author_name(self):
commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line", author_name="Tëst nåme")
# No regex specified -> Config shouldn't be changed
rule = rules.IgnoreByAuthorName()
config = LintConfig()
rule.apply(config, commit)
self.assertEqual(config, LintConfig())
self.assert_logged([]) # nothing logged -> nothing ignored
# Matching regex -> expect config to ignore all rules
rule = rules.IgnoreByAuthorName({"regex": "(.*)ëst(.*)"})
expected_config = LintConfig()
expected_config.ignore = "all"
rule.apply(config, commit)
self.assertEqual(config, expected_config)
expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': "
"Commit Author Name 'Tëst nåme' matches the regex '(.*)ëst(.*)',"
" ignoring rules: all")
self.assert_log_contains(expected_log_message)
# Matching regex with specific ignore
rule = rules.IgnoreByAuthorName({"regex": "(.*)nåme", "ignore": "T1,B2"})
expected_config = LintConfig()
expected_config.ignore = "T1,B2"
rule.apply(config, commit)
self.assertEqual(config, expected_config)
expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': "
"Commit Author Name 'Tëst nåme' matches the regex '(.*)nåme', ignoring rules: T1,B2")
self.assert_log_contains(expected_log_message)
def test_ignore_body_lines(self):
commit1 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")
commit2 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")

View file

@ -79,7 +79,7 @@ class TitleRuleTests(BaseTestCase):
violations = rule.validate("This is å test", None)
self.assertIsNone(violations)
# no violation if WIP occurs inside a wor
# no violation if WIP occurs inside a word
violations = rule.validate("This is å wiping test", None)
self.assertIsNone(violations)

View file

@ -97,7 +97,7 @@ class UserRuleTests(BaseTestCase):
def test_assert_valid_rule_class(self):
class MyLineRuleClass(rules.LineRule):
id = 'UC1'
name = u'my-lïne-rule'
name = 'my-lïne-rule'
target = rules.CommitMessageTitle
def validate(self):
@ -105,14 +105,14 @@ class UserRuleTests(BaseTestCase):
class MyCommitRuleClass(rules.CommitRule):
id = 'UC2'
name = u'my-cömmit-rule'
name = 'my-cömmit-rule'
def validate(self):
pass
class MyConfigurationRuleClass(rules.ConfigurationRule):
id = 'UC3'
name = u'my-cönfiguration-rule'
name = 'my-cönfiguration-rule'
def apply(self):
pass

View file

@ -197,7 +197,7 @@ class RuleOptionTests(BaseTestCase):
self.assertEqual(option.value, self.get_sample_path())
# Expect exception if path type is invalid
option.type = u'föo'
option.type = 'föo'
expected = "Option tëst-directory type must be one of: 'file', 'dir', 'both' (current: 'föo')"
with self.assertRaisesMessage(RuleOptionError, expected):
option.set("haha")

View file

@ -29,25 +29,20 @@ class BaseTestCase(TestCase):
GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
GIT_CONTEXT_ERROR_CODE = 254
@classmethod
def setUpClass(cls):
""" Sets up the integration tests by creating a new temporary git repository """
cls.tmp_git_repos = []
cls.tmp_git_repo = cls.create_tmp_git_repo()
@classmethod
def tearDownClass(cls):
""" Cleans up the temporary git repositories """
for repo in cls.tmp_git_repos:
shutil.rmtree(repo)
GITLINT_USAGE_ERROR = 253
def setUp(self):
""" Sets up the integration tests by creating a new temporary git repository """
self.tmpfiles = []
self.tmp_git_repos = []
self.tmp_git_repo = self.create_tmp_git_repo()
def tearDown(self):
# Clean up temporary files and repos
for tmpfile in self.tmpfiles:
os.remove(tmpfile)
for repo in self.tmp_git_repos:
shutil.rmtree(repo)
def assertEqualStdout(self, output, expected): # pylint: disable=invalid-name
self.assertIsInstance(output, RunningCommand)
@ -55,16 +50,15 @@ class BaseTestCase(TestCase):
output = output.replace('\r', '')
self.assertMultiLineEqual(output, expected)
@classmethod
def generate_temp_path(cls):
@staticmethod
def generate_temp_path():
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
return os.path.realpath(f"/tmp/gitlint-test-{timestamp}")
@classmethod
def create_tmp_git_repo(cls):
def create_tmp_git_repo(self):
""" Creates a temporary git repository and returns its directory path """
tmp_git_repo = cls.generate_temp_path()
cls.tmp_git_repos.append(tmp_git_repo)
tmp_git_repo = self.generate_temp_path()
self.tmp_git_repos.append(tmp_git_repo)
git("init", tmp_git_repo)
# configuring name and email is required in every git repot
@ -86,6 +80,7 @@ class BaseTestCase(TestCase):
def create_file(parent_dir):
""" Creates a file inside a passed directory. Returns filename."""
test_filename = "test-fïle-" + str(uuid4())
# pylint: disable=consider-using-with
io.open(os.path.join(parent_dir, test_filename), 'a', encoding=DEFAULT_ENCODING).close()
return test_filename
@ -158,11 +153,12 @@ class BaseTestCase(TestCase):
specified by variable_dict. """
expected_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
expected_path = os.path.join(expected_dir, filename)
expected = io.open(expected_path, encoding=DEFAULT_ENCODING).read()
with io.open(expected_path, encoding=DEFAULT_ENCODING) as file:
expected = file.read()
if variable_dict:
expected = expected.format(**variable_dict)
return expected
if variable_dict:
expected = expected.format(**variable_dict)
return expected
@staticmethod
def get_system_info_dict():

View file

@ -18,6 +18,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: True
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -30,6 +31,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -18,6 +18,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: True
fail-without-commits: False
verbosity: 3
debug: True
target: {target}
@ -30,6 +31,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -18,6 +18,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: True
staged: False
fail-without-commits: True
verbosity: 2
debug: True
target: {target}
@ -30,6 +31,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -18,6 +18,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: True
fail-without-commits: False
verbosity: 0
debug: True
target: {target}
@ -30,6 +31,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=72
T2: title-trailing-whitespace

View file

@ -18,6 +18,7 @@ ignore-squash-commits: True
ignore-revert-commits: True
ignore-stdin: False
staged: False
fail-without-commits: False
verbosity: 2
debug: True
target: {target}
@ -30,6 +31,9 @@ target: {target}
regex=None
I3: ignore-body-lines
regex=None
I4: ignore-by-author-name
ignore=all
regex=None
T1: title-max-length
line-length=20
T2: title-trailing-whitespace

View file

@ -1,4 +1,3 @@
1: CC1 Body does not contain a 'Signed-off-by' line
1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build: "WIP Thi$ is å title"
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"

View file

@ -1,4 +1,3 @@
1: CC1 Body does not contain a 'Signed-off-by' line
1: CT1 Title does not start with one of föo, bår: "WIP Thi$ is å title"
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title"
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title"

View file

@ -1,4 +1,4 @@
sh==1.14.1
pytest==6.2.3;
arrow==1.0.3;
sh==1.14.2
pytest==6.2.5;
arrow==1.2.0;
gitlint # no version as you want to test the currently installed version

View file

@ -67,8 +67,8 @@ else:
popen_kwargs['env'] = kwargs['_env']
try:
p = subprocess.Popen(args, **popen_kwargs)
result = p.communicate()
with subprocess.Popen(args, **popen_kwargs) as p:
result = p.communicate()
except FileNotFoundError as exc:
raise CommandNotFound from exc

View file

@ -40,19 +40,60 @@ class CommitsTests(BaseTestCase):
expected_kwargs = {'commit_sha1': commit_sha1, 'commit_sha2': commit_sha2}
self.assertEqualStdout(output, self.get_expected("test_commits/test_violations_1", expected_kwargs))
def test_lint_single_commit(self):
""" Tests `gitlint --commits <sha>` """
def test_lint_empty_commit_range(self):
""" Tests `gitlint --commits <sha>^...<sha>` --fail-without-commits where the provided range is empty. """
self.create_simple_commit("Sïmple title.\n")
self.create_simple_commit("Sïmple title2.\n")
commit_sha = self.get_last_commit_hash()
# git revspec -> 2 dots: <exclusive sha>..<inclusive sha> -> empty range when using same start and end sha
refspec = f"{commit_sha}..{commit_sha}"
# Regular gitlint invocation should run without issues
output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True)
self.assertEqual(output.exit_code, 0)
self.assertEqualStdout(output, "")
# Gitlint should fail when --fail-without-commits is used
output = gitlint("--commits", refspec, "--fail-without-commits", _cwd=self.tmp_git_repo, _tty_in=True,
_ok_code=[self.GITLINT_USAGE_ERROR])
self.assertEqual(output.exit_code, self.GITLINT_USAGE_ERROR)
self.assertEqualStdout(output, f"Error: No commits in range \"{refspec}\"\n")
def test_lint_single_commit(self):
""" Tests `gitlint --commits <sha>^...<same sha>` """
self.create_simple_commit("Sïmple title.\n")
first_commit_sha = self.get_last_commit_hash()
self.create_simple_commit("Sïmple title2.\n")
commit_sha = self.get_last_commit_hash()
refspec = f"{commit_sha}^...{commit_sha}"
self.create_simple_commit("Sïmple title3.\n")
output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
expected = ("1: T3 Title has trailing punctuation (.): \"Sïmple title2.\"\n" +
"3: B6 Body message is missing\n")
# Lint using --commit <commit sha>
output = gitlint("--commit", commit_sha, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
self.assertEqual(output.exit_code, 2)
self.assertEqualStdout(output, expected)
# Lint a single commit using --commits <refspec> pointing to the single commit
output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
self.assertEqual(output.exit_code, 2)
self.assertEqualStdout(output, expected)
# Lint the first commit in the repository. This is a use-case that is not supported by --commits
# As <sha>^...<sha> is not correct refspec in case <sha> points to the initial commit (which has no parents)
expected = ("1: T3 Title has trailing punctuation (.): \"Sïmple title.\"\n" +
"3: B6 Body message is missing\n")
output = gitlint("--commit", first_commit_sha, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[2])
self.assertEqual(output.exit_code, 2)
self.assertEqualStdout(output, expected)
# Assert that indeed --commits <refspec> is not supported when <refspec> points the the first commit
refspec = f"{first_commit_sha}^...{first_commit_sha}"
output = gitlint("--commits", refspec, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[254])
self.assertEqual(output.exit_code, 254)
def test_lint_staged_stdin(self):
""" Tests linting a staged commit. Gitint should lint the passed commit message andfetch additional meta-data
from the underlying repository. The easiest way to test this is by inspecting `--debug` output.
@ -139,7 +180,7 @@ class CommitsTests(BaseTestCase):
self.assertEqualStdout(output, self.get_expected("test_commits/test_lint_head_1", expected_kwargs))
def test_ignore_commits(self):
""" Tests multiple commits of which some rules get igonored because of ignore-* rules """
""" Tests multiple commits of which some rules get ignored because of ignore-* rules """
# Create repo and some commits
tmp_git_repo = self.create_tmp_git_repo()
self.create_simple_commit("Sïmple title.\n\nSimple bödy describing the commit", git_repo=tmp_git_repo)

View file

@ -80,7 +80,8 @@ class ConfigTests(BaseTestCase):
filename = self.create_simple_commit(commit_msg, git_repo=target_repo)
env = self.create_environment({"GITLINT_DEBUG": "1", "GITLINT_VERBOSITY": "2",
"GITLINT_IGNORE": "T1,T2", "GITLINT_CONTRIB": "CC1,CT1",
"GITLINT_IGNORE_STDIN": "1", "GITLINT_TARGET": target_repo,
"GITLINT_FAIL_WITHOUT_COMMITS": "1", "GITLINT_IGNORE_STDIN": "1",
"GITLINT_TARGET": target_repo,
"GITLINT_COMMITS": self.get_last_commit_hash(git_repo=target_repo)})
output = gitlint(_env=env, _cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[5])
expected_kwargs = self.get_debug_vars_last_commit(git_repo=target_repo)

View file

@ -10,14 +10,14 @@ class ContribRuleTests(BaseTestCase):
def test_contrib_rules(self):
self.create_simple_commit("WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_1"))
def test_contrib_rules_with_config(self):
self.create_simple_commit("WIP Thi$ is å title\n\nMy bödy that is a bit longer than 20 chars")
output = gitlint("--contrib", "contrib-title-conventional-commits,CC1",
"-c", "contrib-title-conventional-commits.types=föo,bår",
_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[4])
_cwd=self.tmp_git_repo, _tty_in=True, _ok_code=[3])
self.assertEqualStdout(output, self.get_expected("test_contrib/test_contrib_rules_with_config_1"))
def test_invalid_contrib_rules(self):

View file

@ -9,14 +9,15 @@ class HookTests(BaseTestCase):
""" Integration tests for gitlint commitmsg hooks"""
VIOLATIONS = ['gitlint: checking commit message...\n',
u'1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n',
u'1: T5 Title contains the word \'WIP\' (case-insensitive): "WIP: This ïs a title."\n',
u'2: B4 Second line is not empty: "Contënt on the second line"\n',
'1: T3 Title has trailing punctuation (.): "WIP: This ïs a title."\n',
'1: T5 Title contains the word \'WIP\' (case-insensitive): "WIP: This ïs a title."\n',
'2: B4 Second line is not empty: "Contënt on the second line"\n',
'3: B6 Body message is missing\n',
'-----------------------------------------------\n',
'gitlint: \x1b[31mYour commit message contains violations.\x1b[0m\n']
def setUp(self):
super().setUp()
self.responses = []
self.response_index = 0
self.githook_output = []
@ -28,16 +29,19 @@ class HookTests(BaseTestCase):
# install git commit-msg hook and assert output
output_installed = gitlint("install-hook", _cwd=self.tmp_git_repo)
expected_installed = "Successfully installed gitlint commit-msg hook in %s/.git/hooks/commit-msg\n" % \
self.tmp_git_repo
expected_installed = ("Successfully installed gitlint commit-msg hook in "
f"{self.tmp_git_repo}/.git/hooks/commit-msg\n")
self.assertEqualStdout(output_installed, expected_installed)
def tearDown(self):
# uninstall git commit-msg hook and assert output
output_uninstalled = gitlint("uninstall-hook", _cwd=self.tmp_git_repo)
expected_uninstalled = "Successfully uninstalled gitlint commit-msg hook from %s/.git/hooks/commit-msg\n" % \
self.tmp_git_repo
expected_uninstalled = ("Successfully uninstalled gitlint commit-msg hook from "
f"{self.tmp_git_repo}/.git/hooks/commit-msg\n")
self.assertEqualStdout(output_uninstalled, expected_uninstalled)
super().tearDown()
def _violations(self):
# Make a copy of the violations array so that we don't inadvertently edit it in the test (like I did :D)
@ -60,9 +64,9 @@ class HookTests(BaseTestCase):
short_hash = self.get_last_commit_short_hash()
expected_output = ["gitlint: checking commit message...\n",
"gitlint: \x1b[32mOK\x1b[0m (no violations in commit message)\n",
"[master %s] This ïs a title\n" % short_hash,
f"[master {short_hash}] This ïs a title\n",
" 1 file changed, 0 insertions(+), 0 deletions(-)\n",
" create mode 100644 %s\n" % test_filename]
f" create mode 100644 {test_filename}\n"]
self.assertListEqual(expected_output, self.githook_output)
def test_commit_hook_continue(self):
@ -76,10 +80,9 @@ class HookTests(BaseTestCase):
expected_output = self._violations()
expected_output += ["Continue with commit anyways (this keeps the current commit message)? " +
"[y(es)/n(no)/e(dit)] " +
"[master %s] WIP: This ïs a title. Contënt on the second line\n"
% short_hash,
f"[master {short_hash}] WIP: This ïs a title. Contënt on the second line\n",
" 1 file changed, 0 insertions(+), 0 deletions(-)\n",
" create mode 100644 %s\n" % test_filename]
f" create mode 100644 {test_filename}\n"]
assert len(self.githook_output) == len(expected_output)
for output, expected in zip(self.githook_output, expected_output):
@ -124,9 +127,9 @@ class HookTests(BaseTestCase):
expected_output += self._violations()[1:]
expected_output += ['Continue with commit anyways (this keeps the current commit message)? ' +
"[y(es)/n(no)/e(dit)] " +
"[master %s] WIP: This ïs a title. Contënt on the second line\n" % short_hash,
f"[master {short_hash}] WIP: This ïs a title. Contënt on the second line\n",
" 1 file changed, 0 insertions(+), 0 deletions(-)\n",
" create mode 100644 %s\n" % test_filename]
f" create mode 100644 {test_filename}\n"]
assert len(self.githook_output) == len(expected_output)
for output, expected in zip(self.githook_output, expected_output):

View file

@ -50,7 +50,7 @@ class StdInTests(BaseTestCase):
# We need to use subprocess.Popen() here instead of sh because when passing a file_handle to sh, it will
# deal with reading the file itself instead of passing it on to gitlint as a STDIN. Since we're trying to
# test for the condition where stat.S_ISREG == True that won't work for us here.
p = subprocess.Popen("gitlint", stdin=file_handle, cwd=self.tmp_git_repo,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output, _ = p.communicate()
self.assertEqual(output.decode(DEFAULT_ENCODING), self.get_expected("test_stdin/test_stdin_file_1"))
with subprocess.Popen("gitlint", stdin=file_handle, cwd=self.tmp_git_repo,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as p:
output, _ = p.communicate()
self.assertEqual(output.decode(DEFAULT_ENCODING), self.get_expected("test_stdin/test_stdin_file_1"))

View file

@ -1,5 +1,5 @@
setuptools
wheel==0.36.2
Click==7.1.2
sh==1.14.1; sys_platform != 'win32' # sh is not supported on windows
arrow==1.0.3
wheel==0.37.0
Click==8.0.1
sh==1.14.2; sys_platform != 'win32' # sh is not supported on windows
arrow==1.2.0

View file

@ -88,9 +88,8 @@ run_unit_tests(){
clean
# py.test -s => print standard output (i.e. show print statement output)
# -rw => print warnings
OMIT="*pypy*,*venv*,*virtualenv*,*gitlint/tests/*"
target=${testargs:-"gitlint"}
coverage run --omit=$OMIT -m pytest -rw -s $target
coverage run -m pytest -rw -s $target
TEST_RESULT=$?
if [ $include_coverage -eq 1 ]; then
COVERAGE_REPORT=$(coverage report -m)

View file

@ -49,6 +49,7 @@ setup(
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Environment :: Console",
@ -59,12 +60,12 @@ setup(
],
python_requires=">=3.6",
install_requires=[
'Click==7.1.2',
'arrow==1.0.3',
'Click==8.0.1',
'arrow==1.2.0',
],
extras_require={
':sys_platform != "win32"': [
'sh==1.14.1',
'sh==1.14.2',
],
},
keywords='gitlint git lint',

View file

@ -1,8 +1,8 @@
flake8==3.9.1
coverage==5.5
flake8==3.9.2
coverage==6.0
python-coveralls==2.9.3
radon==4.5.0
radon==5.1.0
flake8-polyfill==1.0.2 # Required when installing both flake8 and radon>=4.3.1
pytest==6.2.3;
pylint==2.7.4;
pytest==6.2.5;
pylint==2.11.1;
-e .