diff options
Diffstat (limited to 'docs/user_defined_rules.md')
-rw-r--r-- | docs/user_defined_rules.md | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/docs/user_defined_rules.md b/docs/user_defined_rules.md new file mode 100644 index 0000000..db21809 --- /dev/null +++ b/docs/user_defined_rules.md @@ -0,0 +1,415 @@ +# 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. |