1
0
Fork 0
gitlint/docs/user_defined_rules.md
Daniel Baumann 129d2ce1fc
Merging upstream version 0.18.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-13 06:06:24 +01:00

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 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 LineRules, they can also be used by CommitRules and are further explained in the Options section below.

The commit object

Both CommitRules and LineRules 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 shas (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 RuleViolations.

!!! 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 LineRules 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:

How Rule violations map to gitlint output

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 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.