diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-10-13 05:34:57 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-10-13 05:34:57 +0000 |
commit | e2d38cd54491535f409372393baeed787c77388d (patch) | |
tree | e9f823c384ee487d30de5ae84c8d3755f6974baa | |
parent | Releasing debian version 0.15.1-3. (diff) | |
download | gitlint-e2d38cd54491535f409372393baeed787c77388d.tar.xz gitlint-e2d38cd54491535f409372393baeed787c77388d.zip |
Merging upstream version 0.16.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
56 files changed, 614 insertions, 160 deletions
diff --git a/.coveragerc b/.coveragerc index a2e4c8f..a120715 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,6 @@ +[report] +fail_under = 97 + [run] -omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv*
\ No newline at end of file +branch = true +omit=*dist-packages*,*site-packages*,gitlint/tests/*,.venv/*,*virtualenv* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7c33438 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 15eb7be..8fbda21 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bb72596..dd224e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: @@ -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 @@ -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! diff --git a/doc-requirements.txt b/doc-requirements.txt index 53dbf05..becd4f4 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -1 +1 @@ -mkdocs==1.1.2
\ No newline at end of file +mkdocs==1.2.2
\ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 50c4e63..226ba8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/contributing.md b/docs/contributing.md index e58378c..d39f9e1 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 ``` diff --git a/docs/images/readme-gitlint.png b/docs/images/readme-gitlint.png Binary files differnew file mode 100644 index 0000000..516c915 --- /dev/null +++ b/docs/images/readme-gitlint.png diff --git a/docs/index.md b/docs/index.md index 5b371bf..98b72de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/rules.md b/docs/rules.md index 9779c54..eb4b65e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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 +```
\ No newline at end of file diff --git a/gitlint/__init__.py b/gitlint/__init__.py index 903e77c..5a313cc 100644 --- a/gitlint/__init__.py +++ b/gitlint/__init__.py @@ -1 +1 @@ -__version__ = "0.15.1" +__version__ = "0.16.0" diff --git a/gitlint/cli.py b/gitlint/cli.py index 9b16d47..19676b3 100644 --- a/gitlint/cli.py +++ b/gitlint/cli.py @@ -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! diff --git a/gitlint/config.py b/gitlint/config.py index 1eeb35d..6d2ead2 100644 --- a/gitlint/config.py +++ b/gitlint/config.py @@ -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): @@ -171,6 +174,15 @@ class LintConfig: 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" diff --git a/gitlint/contrib/rules/conventional_commit.py b/gitlint/contrib/rules/conventional_commit.py index 71f6adf..9c9d5cb 100644 --- a/gitlint/contrib/rules/conventional_commit.py +++ b/gitlint/contrib/rules/conventional_commit.py @@ -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 diff --git a/gitlint/files/gitlint b/gitlint/files/gitlint index e95bf9e..cbbae70 100644 --- a/gitlint/files/gitlint +++ b/gitlint/files/gitlint @@ -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. diff --git a/gitlint/git.py b/gitlint/git.py index a9609d0..773c7b2 100644 --- a/gitlint/git.py +++ b/gitlint/git.py @@ -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) diff --git a/gitlint/rules.py b/gitlint/rules.py index db21e56..1c5a618 100644 --- a/gitlint/rules.py +++ b/gitlint/rules.py @@ -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 diff --git a/gitlint/shell.py b/gitlint/shell.py index 7f598ae..e05204a 100644 --- a/gitlint/shell.py +++ b/gitlint/shell.py @@ -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 diff --git a/gitlint/tests/base.py b/gitlint/tests/base.py index 9406240..017122b 100644 --- a/gitlint/tests/base.py +++ b/gitlint/tests/base.py @@ -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 = [] diff --git a/gitlint/tests/cli/test_cli.py b/gitlint/tests/cli/test_cli.py index bf35e96..59ec7af 100644 --- a/gitlint/tests/cli/test_cli.py +++ b/gitlint/tests/cli/test_cli.py @@ -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 """ @@ -283,6 +317,30 @@ class CLITests(BaseTestCase): "'--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, _): diff --git a/gitlint/tests/config/test_config.py b/gitlint/tests/config/test_config.py index 93e35de..c3fd78a 100644 --- a/gitlint/tests/config/test_config.py +++ b/gitlint/tests/config/test_config.py @@ -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'"): diff --git a/gitlint/tests/contrib/rules/test_conventional_commit.py b/gitlint/tests/contrib/rules/test_conventional_commit.py index fb492df..5da5cd5 100644 --- a/gitlint/tests/contrib/rules/test_conventional_commit.py +++ b/gitlint/tests/contrib/rules/test_conventional_commit.py @@ -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) diff --git a/gitlint/tests/expected/cli/test_cli/test_contrib_1 b/gitlint/tests/expected/cli/test_cli/test_contrib_1 index ed21eca..b95433b 100644 --- a/gitlint/tests/expected/cli/test_cli/test_contrib_1 +++ b/gitlint/tests/expected/cli/test_cli/test_contrib_1 @@ -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" diff --git a/gitlint/tests/expected/cli/test_cli/test_debug_1 b/gitlint/tests/expected/cli/test_cli/test_debug_1 index a95a58d..fcd5d7e 100644 --- a/gitlint/tests/expected/cli/test_cli/test_debug_1 +++ b/gitlint/tests/expected/cli/test_cli/test_debug_1 @@ -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 diff --git a/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 b/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 index c05d147..7c94b45 100644 --- a/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 +++ b/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 @@ -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 diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 b/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 new file mode 100644 index 0000000..b9f0742 --- /dev/null +++ b/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 @@ -0,0 +1,2 @@ +1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title1" +3: B5 Body message is too short (12<20): "commït-body1" diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 b/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 index e8e9f33..f37ffa0 100644 --- a/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 +++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 @@ -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 diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 b/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 index b822edc..1d1020a 100644 --- a/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 +++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 @@ -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 diff --git a/gitlint/tests/expected/cli/test_cli/test_named_rules_2 b/gitlint/tests/expected/cli/test_cli/test_named_rules_2 index 828e296..83c4bf2 100644 --- a/gitlint/tests/expected/cli/test_cli/test_named_rules_2 +++ b/gitlint/tests/expected/cli/test_cli/test_named_rules_2 @@ -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 diff --git a/gitlint/tests/git/test_git_commit.py b/gitlint/tests/git/test_git_commit.py index 6bb545a..02c5795 100644 --- a/gitlint/tests/git/test_git_commit.py +++ b/gitlint/tests/git/test_git_commit.py @@ -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, @@ -128,6 +129,59 @@ class GitCommitTests(BaseTestCase): 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" diff --git a/gitlint/tests/rules/test_body_rules.py b/gitlint/tests/rules/test_body_rules.py index a268585..812c74a 100644 --- a/gitlint/tests/rules/test_body_rules.py +++ b/gitlint/tests/rules/test_body_rules.py @@ -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) diff --git a/gitlint/tests/rules/test_configuration_rules.py b/gitlint/tests/rules/test_configuration_rules.py index 479d9c2..9302da5 100644 --- a/gitlint/tests/rules/test_configuration_rules.py +++ b/gitlint/tests/rules/test_configuration_rules.py @@ -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") diff --git a/gitlint/tests/rules/test_title_rules.py b/gitlint/tests/rules/test_title_rules.py index e1be857..10b4aab 100644 --- a/gitlint/tests/rules/test_title_rules.py +++ b/gitlint/tests/rules/test_title_rules.py @@ -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) diff --git a/gitlint/tests/rules/test_user_rules.py b/gitlint/tests/rules/test_user_rules.py index 510a829..5bf9b77 100644 --- a/gitlint/tests/rules/test_user_rules.py +++ b/gitlint/tests/rules/test_user_rules.py @@ -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 diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py index fc3ccc1..eabcfe1 100644 --- a/gitlint/tests/test_options.py +++ b/gitlint/tests/test_options.py @@ -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") @@ -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(): diff --git a/qa/expected/test_commits/test_lint_staged_msg_filename_1 b/qa/expected/test_commits/test_lint_staged_msg_filename_1 index eb2682f..901ea27 100644 --- a/qa/expected/test_commits/test_lint_staged_msg_filename_1 +++ b/qa/expected/test_commits/test_lint_staged_msg_filename_1 @@ -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 diff --git a/qa/expected/test_commits/test_lint_staged_stdin_1 b/qa/expected/test_commits/test_lint_staged_stdin_1 index 76b5048..e4677c3 100644 --- a/qa/expected/test_commits/test_lint_staged_stdin_1 +++ b/qa/expected/test_commits/test_lint_staged_stdin_1 @@ -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 diff --git a/qa/expected/test_config/test_config_from_env_1 b/qa/expected/test_config/test_config_from_env_1 index f3947bb..60f6690 100644 --- a/qa/expected/test_config/test_config_from_env_1 +++ b/qa/expected/test_config/test_config_from_env_1 @@ -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 diff --git a/qa/expected/test_config/test_config_from_env_2 b/qa/expected/test_config/test_config_from_env_2 index 8d36672..e9ebd67 100644 --- a/qa/expected/test_config/test_config_from_env_2 +++ b/qa/expected/test_config/test_config_from_env_2 @@ -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 diff --git a/qa/expected/test_config/test_config_from_file_debug_1 b/qa/expected/test_config/test_config_from_file_debug_1 index 540c3a0..6ad5ec4 100644 --- a/qa/expected/test_config/test_config_from_file_debug_1 +++ b/qa/expected/test_config/test_config_from_file_debug_1 @@ -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 diff --git a/qa/expected/test_contrib/test_contrib_rules_1 b/qa/expected/test_contrib/test_contrib_rules_1 index 6876c80..6ab7512 100644 --- a/qa/expected/test_contrib/test_contrib_rules_1 +++ b/qa/expected/test_contrib/test_contrib_rules_1 @@ -1,4 +1,3 @@ 1: CC1 Body does not contain a 'Signed-off-by' line -1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, 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" diff --git a/qa/expected/test_contrib/test_contrib_rules_with_config_1 b/qa/expected/test_contrib/test_contrib_rules_with_config_1 index d5b5cf8..6ab7512 100644 --- a/qa/expected/test_contrib/test_contrib_rules_with_config_1 +++ b/qa/expected/test_contrib/test_contrib_rules_with_config_1 @@ -1,4 +1,3 @@ 1: CC1 Body does not contain a 'Signed-off-by' line -1: CT1 Title does not start with one of föo, bår: "WIP Thi$ is å title" 1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "WIP Thi$ is å title" 1: T5 Title contains the word 'WIP' (case-insensitive): "WIP Thi$ is å title" diff --git a/qa/requirements.txt b/qa/requirements.txt index 95d49d4..5a4f660 100644 --- a/qa/requirements.txt +++ b/qa/requirements.txt @@ -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 diff --git a/qa/shell.py b/qa/shell.py index 97dcd2c..630843f 100644 --- a/qa/shell.py +++ b/qa/shell.py @@ -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 diff --git a/qa/test_commits.py b/qa/test_commits.py index 389ad66..92e1087 100644 --- a/qa/test_commits.py +++ b/qa/test_commits.py @@ -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_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>` """ + """ 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) diff --git a/qa/test_config.py b/qa/test_config.py index 9c00b95..432a2c5 100644 --- a/qa/test_config.py +++ b/qa/test_config.py @@ -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) diff --git a/qa/test_contrib.py b/qa/test_contrib.py index e599d50..d71229a 100644 --- a/qa/test_contrib.py +++ b/qa/test_contrib.py @@ -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): diff --git a/qa/test_hooks.py b/qa/test_hooks.py index 32abcb0..b78100e 100644 --- a/qa/test_hooks.py +++ b/qa/test_hooks.py @@ -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): diff --git a/qa/test_stdin.py b/qa/test_stdin.py index 18d6e7e..c98580e 100644 --- a/qa/test_stdin.py +++ b/qa/test_stdin.py @@ -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")) diff --git a/requirements.txt b/requirements.txt index 578c38a..f4f3a1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run_tests.sh b/run_tests.sh index 2f8ebe9..da937ea 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -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) @@ -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', diff --git a/test-requirements.txt b/test-requirements.txt index b766a0b..30b3073 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -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 . |