415 lines
24 KiB
Markdown
415 lines
24 KiB
Markdown
# User Defined Rules
|
|
_Introduced in gitlint v0.8.0_
|
|
|
|
Gitlint supports the concept of **user-defined** rules: the ability for users to write their own custom rules in python.
|
|
|
|
In a nutshell, use `--extra-path /home/joe/myextensions` to point gitlint to a `myextensions` directory where it will search
|
|
for python files containing gitlint rule classes. You can also specify a single python module, ie
|
|
`--extra-path /home/joe/my_rules.py`.
|
|
|
|
```sh
|
|
cat examples/commit-message-1 | gitlint --extra-path examples/
|
|
# Example output of a user-defined Signed-off-by rule
|
|
1: UC2 Body does not contain a 'Signed-off-by Line'
|
|
# other violations were removed for brevity
|
|
```
|
|
|
|
The `SignedOffBy` user-defined `CommitRule` was discovered by gitlint when it scanned
|
|
[examples/gitlint/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py),
|
|
which is part of the examples directory that was passed via `--extra-path`:
|
|
|
|
```python
|
|
# -*- coding: utf-8 -*-
|
|
from gitlint.rules import CommitRule, RuleViolation
|
|
|
|
class SignedOffBy(CommitRule):
|
|
""" This rule will enforce that each commit contains a "Signed-off-by" line.
|
|
We keep things simple here and just check whether the commit body contains a
|
|
line that starts with "Signed-off-by".
|
|
"""
|
|
|
|
# A rule MUST have a human friendly name
|
|
name = "body-requires-signed-off-by"
|
|
|
|
# A rule MUST have a *unique* id, we recommend starting with UC
|
|
# (for User-defined Commit-rule).
|
|
id = "UC2"
|
|
|
|
def validate(self, commit):
|
|
self.log.debug("SignedOffBy: This will be visible when running `gitlint --debug`")
|
|
|
|
for line in commit.message.body:
|
|
if line.startswith("Signed-off-by"):
|
|
return
|
|
|
|
msg = "Body does not contain a 'Signed-off-by' line"
|
|
return [RuleViolation(self.id, msg, line_nr=1)]
|
|
```
|
|
|
|
As always, `--extra-path` can also be set by adding it under the `[general]` section in your `.gitlint` file or using
|
|
[one of the other ways to configure gitlint](configuration.md).
|
|
|
|
If you want to check whether your rules are properly discovered by gitlint, you can use the `--debug` flag:
|
|
|
|
```sh
|
|
$ gitlint --debug --extra-path examples/
|
|
# [output cut for brevity]
|
|
UC1: body-max-line-count
|
|
body-max-line-count=3
|
|
UC2: body-requires-signed-off-by
|
|
UL1: title-no-special-chars
|
|
special-chars=['$', '^', '%', '@', '!', '*', '(', ')']
|
|
```
|
|
|
|
!!! Note
|
|
In most cases it's really the easiest to just copy an example from the
|
|
[examples](https://github.com/jorisroovers/gitlint/tree/main/examples) directory and modify it to your needs.
|
|
The remainder of this page contains the technical details, mostly for reference.
|
|
|
|
## Line and Commit Rules
|
|
The `SignedOffBy` class above was an example of a user-defined `CommitRule`. Commit rules are gitlint rules that
|
|
act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
|
|
to the entire commit. This happens exactly once per commit.
|
|
|
|
A `CommitRule` contrasts with a `LineRule`
|
|
(see e.g.: [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_line_rules.py))
|
|
in that a `CommitRule` is only applied once on an entire commit while a `LineRule` is applied for every line in the commit
|
|
(you can also apply it once to the title using a `target` - see the examples section below).
|
|
|
|
The benefit of a commit rule is that it allows commit rules to implement more complex checks that span multiple lines and/or checks
|
|
that should only be done once per commit.
|
|
|
|
While every `LineRule` can be implemented as a `CommitRule`, it's usually easier and more concise to go with a `LineRule` if
|
|
that fits your needs.
|
|
|
|
### Examples
|
|
|
|
In terms of code, writing your own `CommitRule` or `LineRule` is very similar.
|
|
The only 2 differences between a `CommitRule` and a `LineRule` are the parameters of the `validate(...)` method and the extra
|
|
`target` attribute that `LineRule` requires.
|
|
|
|
Consider the following `CommitRule` that can be found in [examples/my_commit_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py):
|
|
|
|
```python
|
|
# -*- coding: utf-8 -*-
|
|
from gitlint.rules import CommitRule, RuleViolation
|
|
|
|
class SignedOffBy(CommitRule):
|
|
""" This rule will enforce that each commit contains a "Signed-off-by" line.
|
|
We keep things simple here and just check whether the commit body contains a
|
|
line that starts with "Signed-off-by".
|
|
"""
|
|
|
|
# A rule MUST have a human friendly name
|
|
name = "body-requires-signed-off-by"
|
|
|
|
# A rule MUST have a *unique* id, we recommend starting with UC
|
|
# (for User-defined Commit-rule).
|
|
id = "UC2"
|
|
|
|
def validate(self, commit):
|
|
self.log.debug("SignedOffBy: This will be visible when running `gitlint --debug`")
|
|
|
|
for line in commit.message.body:
|
|
if line.startswith("Signed-off-by"):
|
|
return
|
|
|
|
msg = "Body does not contain a 'Signed-off-by' line"
|
|
return [RuleViolation(self.id, msg, line_nr=1)]
|
|
```
|
|
Note the use of the `name` and `id` class attributes and the `validate(...)` method taking a single `commit` parameter.
|
|
|
|
Contrast this with the following `LineRule` that can be found in [examples/my_line_rules.py](https://github.com/jorisroovers/gitlint/blob/main/examples/my_line_rules.py):
|
|
|
|
```python
|
|
# -*- coding: utf-8 -*-
|
|
from gitlint.rules import LineRule, RuleViolation, CommitMessageTitle
|
|
from gitlint.options import ListOption
|
|
|
|
class SpecialChars(LineRule):
|
|
""" This rule will enforce that the commit message title does not contai
|
|
any of the following characters:
|
|
$^%@!*() """
|
|
|
|
# A rule MUST have a human friendly name
|
|
name = "title-no-special-chars"
|
|
|
|
# A rule MUST have a *unique* id, we recommend starting with UL
|
|
# for User-defined Line-rule), but this can really be anything.
|
|
id = "UL1"
|
|
|
|
# A line-rule MUST have a target (not required for CommitRules).
|
|
target = CommitMessageTitle
|
|
|
|
# A rule MAY have an option_spec if its behavior should be configurable.
|
|
options_spec = [ListOption('special-chars', ['$', '^', '%', '@', '!', '*', '(', ')'],
|
|
"Comma separated list of characters that should not occur in the title")]
|
|
|
|
def validate(self, line, _commit):
|
|
self.log.debug("SpecialChars: This will be visible when running `gitlint --debug`")
|
|
|
|
violations = []
|
|
# options can be accessed by looking them up by their name in self.options
|
|
for char in self.options['special-chars'].value:
|
|
if char in line:
|
|
msg = f"Title contains the special character '{char}'"
|
|
violation = RuleViolation(self.id, msg, line)
|
|
violations.append(violation)
|
|
|
|
return violations
|
|
|
|
```
|
|
|
|
Note the following 2 differences:
|
|
|
|
- **extra `target` class attribute**: in this example set to `CommitMessageTitle` indicating that this `LineRule`
|
|
should only be applied once to the commit message title. The alternative value for `target` is `CommitMessageBody`,
|
|
in which case gitlint will apply
|
|
your rule to **every** line in the commit message body.
|
|
- **`validate(...)` takes 2 parameters**: Line rules get the `line` against which they are applied as the first parameter and
|
|
the `commit` object of which the line is part of as second.
|
|
|
|
In addition, you probably also noticed the extra `options_spec` class attribute which allows you to make your rules configurable.
|
|
Options are not unique to `LineRule`s, they can also be used by `CommitRule`s and are further explained in the
|
|
[Options](user_defined_rules.md#options) section below.
|
|
|
|
|
|
## The commit object
|
|
Both `CommitRule`s and `LineRule`s take a `commit` object in their `validate(...)` methods.
|
|
The table below outlines the various attributes of that commit object that can be used during validation.
|
|
|
|
|
|
| Property | Type | Description |
|
|
| -------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
|
|
| commit | `GitCommit` | Python object representing the commit |
|
|
| commit.message | `GitCommitMessage` | Python object representing the commit message |
|
|
| commit.message.original | `str` | Original commit message as returned by git |
|
|
| commit.message.full | `str` | Full commit message, with comments (lines starting with #) removed. |
|
|
| commit.message.title | `str` | Title/subject of the commit message: the first line |
|
|
| commit.message.body | `str[]` | List of lines in the body of the commit message (i.e. starting from the second line) |
|
|
| commit.author_name | `str` | Name of the author, result of `git log --pretty=%aN` |
|
|
| commit.author_email | `str` | Email of the author, result of `git log --pretty=%aE` |
|
|
| commit.date | `datetime.datetime` | Python `datetime` object representing the time of commit |
|
|
| commit.is_merge_commit | `bool` | Boolean indicating whether the commit is a merge commit or not. |
|
|
| commit.is_revert_commit | `bool` | Boolean indicating whether the commit is a revert commit or not. |
|
|
| commit.is_fixup_commit | `bool` | Boolean indicating whether the commit is a fixup commit or not. |
|
|
| commit.is_fixup_amend_commit | `bool` | Boolean indicating whether the commit is a (fixup) amend commit or not. |
|
|
| commit.is_squash_commit | `bool` | Boolean indicating whether the commit is a squash commit or not. |
|
|
| commit.parents | `str[]` | List of parent commit `sha`s (only for merge commits). |
|
|
| commit.changed_files | `str[]` | List of files changed in the commit (relative paths). |
|
|
| commit.changed_files_stats | `dict[str, GitChangedFilesStats]` | Dictionary mapping the changed files to a `GitChangedFilesStats` objects |
|
|
| commit.changed_files_stats["path"].filepath | `pathlib.Path` | Relative path (compared to repo root) of the file that was changed. |
|
|
| commit.changed_files_stats["path"].additions | `int` | Number of additions in the file. |
|
|
| commit.changed_files_stats["path"].deletions | `int` | Number of deletions in the file. |
|
|
| commit.branches | `str[]` | List of branch names the commit is part of |
|
|
| commit.context | `GitContext` | Object pointing to the bigger git context that the commit is part of |
|
|
| commit.context.current_branch | `str` | Name of the currently active branch (of local repo) |
|
|
| commit.context.repository_path | `str` | Absolute path pointing to the git repository being linted |
|
|
| commit.context.commits | `GitCommit[]` | List of commits gitlint is acting on, NOT all commits in the repo. |
|
|
|
|
## Violations
|
|
In order to let gitlint know that there is a violation in the commit being linted, users should have the `validate(...)`
|
|
method in their rules return a list of `RuleViolation`s.
|
|
|
|
!!! important
|
|
The `validate(...)` method doesn't always need to return a list, you can just skip the return statement in case there are no violations.
|
|
However, in case of a single violation, validate should return a **list** with a single item.
|
|
|
|
The `RuleViolation` class has the following generic signature:
|
|
|
|
```python
|
|
RuleViolation(rule_id, message, content=None, line_nr=None):
|
|
```
|
|
With the parameters meaning the following:
|
|
|
|
| Parameter | Type | Description |
|
|
| --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| rule_id | `str` | Rule's unique string id |
|
|
| message | `str` | Short description of the violation |
|
|
| content | `str` | (optional) the violating part of commit or line |
|
|
| line_nr | `int` | (optional) line number in the commit message where the violation occurs. **Automatically set to the correct line number for `LineRule`s if not set explicitly.** |
|
|
|
|
A typical `validate(...)` implementation for a `CommitRule` would then be as follows:
|
|
```python
|
|
def validate(self, commit)
|
|
for line_nr, line in commit.message.body:
|
|
if "Jon Snow" in line:
|
|
# we add 1 to the line_nr because we offset the title which is on the first line
|
|
return [RuleViolation(self.id, "Commit message has the words 'Jon Snow' in it", line, line_nr + 1)]
|
|
return []
|
|
```
|
|
|
|
The parameters of this `RuleViolation` can be directly mapped onto gitlint's output as follows:
|
|
|
|
![How Rule violations map to gitlint output](images/RuleViolation.png)
|
|
|
|
## Options
|
|
|
|
In order to make your own rules configurable, you can add an optional `options_spec` attribute to your rule class
|
|
(supported for both `LineRule` and `CommitRule`).
|
|
|
|
```python
|
|
# -*- coding: utf-8 -*-
|
|
from gitlint.rules import CommitRule, RuleViolation
|
|
from gitlint.options import IntOption
|
|
|
|
class BodyMaxLineCount(CommitRule):
|
|
# A rule MUST have a human friendly name
|
|
name = "body-max-line-count"
|
|
|
|
# A rule MUST have a *unique* id, we recommend starting with UC (for
|
|
# User-defined Commit-rule).
|
|
id = "UC1"
|
|
|
|
# A rule MAY have an option_spec if its behavior should be configurable.
|
|
options_spec = [IntOption('max-line-count', 3, "Maximum body line count")]
|
|
|
|
def validate(self, commit):
|
|
line_count = len(commit.message.body)
|
|
max_line_count = self.options['max-line-count'].value
|
|
if line_count > max_line_count:
|
|
message = f"Body contains too many lines ({line_count} > {max_line_count})"
|
|
return [RuleViolation(self.id, message, line_nr=1)]
|
|
```
|
|
|
|
|
|
By using `options_spec`, you make your option available to be configured through a `.gitlint` file
|
|
or one of the [other ways to configure gitlint](configuration.md). Gitlint automatically takes care of the parsing and input validation.
|
|
|
|
For example, to change the value of the `max-line-count` option, add the following to your `.gitlint` file:
|
|
```ini
|
|
[body-max-line-count]
|
|
body-max-line-count=1
|
|
```
|
|
|
|
As `options_spec` is a list, you can obviously have multiple options per rule. The general signature of an option is:
|
|
`Option(name, default_value, description)`.
|
|
|
|
Gitlint supports a variety of different option types, all can be imported from `gitlint.options`:
|
|
|
|
| Option Class | Use for |
|
|
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `StrOption ` | Strings |
|
|
| `IntOption` | Integers. `IntOption` takes an optional `allow_negative` parameter if you want to allow negative integers. |
|
|
| `BoolOption` | Booleans. Valid values: `true`, `false`. Case-insensitive. |
|
|
| `ListOption` | List of strings. Comma separated. |
|
|
| `PathOption` | Directory or file path. Takes an optional `type` parameter for specifying path type (`file`, `dir` (=default) or `both`). |
|
|
| `RegexOption` | String representing a [Python-style regex](https://docs.python.org/library/re.html) - compiled and validated before rules are applied. |
|
|
|
|
!!! note
|
|
Gitlint currently does not support options for all possible types (e.g. float, list of int, etc).
|
|
[We could use a hand getting those implemented](contributing.md)!
|
|
|
|
|
|
## Configuration Rules
|
|
|
|
_Introduced in gitlint v0.14.0_
|
|
|
|
Configuration rules are special rules that are applied once per commit and *BEFORE* any other rules are run.
|
|
Configuration rules are meant to dynamically change gitlint's configuration and/or the commit that is about to be
|
|
linted.
|
|
A typically use-case for this is when you want to modifying gitlint's behavior for all rules against a commit matching
|
|
specific circumstances.
|
|
|
|
!!! warning
|
|
Configuration rules can drastically change the way gitlint behaves and are typically only needed for more advanced
|
|
use-cases. We recommend you double check:
|
|
|
|
1. Whether gitlint already supports your use-case out-of-the-box (special call-out for [ignore rules](rules.md#i1-ignore-by-title) which allow you to ignore (parts) of your commit message).
|
|
2. Whether there's a [Contrib Rule](contrib_rules.md) that implements your use-case.
|
|
3. Whether you can implement your use-case using a regular Commit or Line user-defined rule (see above).
|
|
|
|
|
|
As with other user-defined rules, the easiest way to get started is by copying [`my_configuration.py` from the examples directory](https://github.com/jorisroovers/gitlint/tree/main/examples/my_configuration_rules.py) and modifying it to fit your need.
|
|
|
|
```python
|
|
# -*- coding: utf-8 -*-
|
|
from gitlint.rules import ConfigurationRule
|
|
from gitlint.options import IntOption
|
|
|
|
class ReleaseConfigurationRule(ConfigurationRule):
|
|
"""
|
|
This rule will modify gitlint's behavior for Release Commits.
|
|
|
|
This example might not be the most realistic for a real-world scenario,
|
|
but is meant to give an overview of what's possible.
|
|
"""
|
|
|
|
# A rule MUST have a human friendly name
|
|
name = "release-configuration-rule"
|
|
|
|
# A rule MUST have a *unique* id, we recommend starting with UCR
|
|
# (for User-defined Configuration-Rule), but this can really be anything.
|
|
id = "UCR1"
|
|
|
|
# A rule MAY have an option_spec if its behavior should be configurable.
|
|
options_spec = [IntOption('custom-verbosity', 2, "Gitlint verbosity for release commits")]
|
|
|
|
def apply(self, config, commit):
|
|
self.log.debug("ReleaseConfigurationRule: This will be visible when running `gitlint --debug`")
|
|
|
|
# If the commit title starts with 'Release', we want to modify
|
|
# how all subsequent rules interpret that commit
|
|
if commit.message.title.startswith("Release"):
|
|
|
|
# If your Release commit messages are auto-generated, the
|
|
# body might contain trailing whitespace. Let's ignore that
|
|
config.ignore.append("body-trailing-whitespace")
|
|
|
|
# Similarly, the body lines might exceed 80 chars,
|
|
# let's set gitlint's limit to 200
|
|
# To set rule options use:
|
|
# config.set_rule_option(<rule-name>, <rule-option>, <value>)
|
|
config.set_rule_option("body-max-line-length", "line-length", 200)
|
|
|
|
# For kicks, let's set gitlint's verbosity to 2
|
|
# To set general options use
|
|
# config.set_general_option(<general-option>, <value>)
|
|
config.set_general_option("verbosity", 2)
|
|
# We can also use custom options to make this configurable
|
|
config.set_general_option("verbosity", self.options['custom-verbosity'].value)
|
|
|
|
# Strip any lines starting with $ from the commit message
|
|
# (this only affects how gitlint sees your commit message, it does
|
|
# NOT modify your actual commit in git)
|
|
commit.message.body = [line for line in commit.message.body if not line.startswith("$")]
|
|
|
|
# You can add any extra properties you want to the commit object,
|
|
# these will be available later on in all rules.
|
|
commit.my_property = "This is my property"
|
|
```
|
|
|
|
For all available properties and methods on the `config` object, have a look at the
|
|
[LintConfig class](https://github.com/jorisroovers/gitlint/blob/main/gitlint-core/gitlint/config.py). Please do not use any
|
|
properties or methods starting with an underscore, as those are subject to change.
|
|
|
|
|
|
## Rule requirements
|
|
|
|
As long as you stick with simple rules that are similar to the sample user-defined rules (see the
|
|
[examples](https://github.com/jorisroovers/gitlint/blob/main/examples/my_commit_rules.py) directory), gitlint
|
|
should be able to discover and execute them. While clearly you can run any python code you want in your rules,
|
|
you might run into some issues if you don't follow the conventions that gitlint requires.
|
|
|
|
While the [rule finding source-code](https://github.com/jorisroovers/gitlint/blob/main/gitlint-core/gitlint/rule_finder.py) is the
|
|
ultimate source of truth, here are some of the requirements that gitlint enforces.
|
|
|
|
### Rule class requirements
|
|
|
|
- Rules **must** extend from `LineRule`, `CommitRule` or `ConfigurationRule`
|
|
- Rule classes **must** have `id` and `name` string attributes. The `options_spec` is optional,
|
|
but if set, it **must** be a list of gitlint Options.
|
|
- `CommitRule` and `LineRule` classes **must** have a `validate` method.
|
|
- In case of a `CommitRule`, `validate` **must** take a single `commit` parameter.
|
|
- In case of `LineRule`, `validate` **must** take `line` and `commit` as first and second parameters.
|
|
- `ConfigurationRule` classes **must** have an `apply` method that take `config` and `commit` as first and second parameters.
|
|
- LineRule classes **must** have a `target` class attributes that is set to either `CommitMessageTitle` or `CommitMessageBody`.
|
|
- User Rule id's **cannot** start with `R`, `T`, `B`, `M` or `I` as these rule ids are reserved for gitlint itself.
|
|
- Rules **should** have a case-insensitive unique id as only one rule can exist with a given id. While gitlint does not
|
|
enforce this, having multiple rules with the same id might lead to unexpected or undeterministic behavior.
|
|
|
|
### extra-path requirements
|
|
- If `extra-path` is a directory, it does **not** need to be a proper python package, i.e. it doesn't require an `__init__.py` file.
|
|
- Python files containing user-defined rules must have a `.py` extension. Files with a different extension will be ignored.
|
|
- The `extra-path` will be searched non-recursively, i.e. all rule classes must be present at the top level `extra-path` directory.
|
|
- User rule classes must be defined in the modules that are part of `extra-path`, rules that are imported from outside the `extra-path` will be ignored.
|