24 KiB
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
.
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,
which is part of the examples directory that was passed via --extra-path
:
# -*- 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.
If you want to check whether your rules are properly discovered by gitlint, you can use the --debug
flag:
$ 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 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)
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:
# -*- 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:
# -*- 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 toCommitMessageTitle
indicating that thisLineRule
should only be applied once to the commit message title. The alternative value fortarget
isCommitMessageBody
, in which case gitlint will apply your rule to every line in the commit message body. validate(...)
takes 2 parameters: Line rules get theline
against which they are applied as the first parameter and thecommit
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 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:
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:
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:
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
).
# -*- 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. 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:
[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 - 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!
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 and modifying it to fit your need.
# -*- 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. 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 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 is the ultimate source of truth, here are some of the requirements that gitlint enforces.
Rule class requirements
- Rules must extend from
LineRule
,CommitRule
orConfigurationRule
- Rule classes must have
id
andname
string attributes. Theoptions_spec
is optional, but if set, it must be a list of gitlint Options. CommitRule
andLineRule
classes must have avalidate
method.- In case of a
CommitRule
,validate
must take a singlecommit
parameter. - In case of
LineRule
,validate
must takeline
andcommit
as first and second parameters. ConfigurationRule
classes must have anapply
method that takeconfig
andcommit
as first and second parameters.- LineRule classes must have a
target
class attributes that is set to eitherCommitMessageTitle
orCommitMessageBody
. - User Rule id's cannot start with
R
,T
,B
,M
orI
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 levelextra-path
directory. - User rule classes must be defined in the modules that are part of
extra-path
, rules that are imported from outside theextra-path
will be ignored.