# 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```. ```bash 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/master/examples/my_commit_rules.py), which is part of the examples directory that was passed via ```--extra-path```: ```python 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): 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: ```bash $ 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/master/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/master/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/master/examples/my_commit_rules.py): ```python 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): 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/master/examples/my_line_rules.py): ```python 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): violations = [] # option values can be accessed via self.options for char in self.options['special-chars'].value: if char in line: violation = RuleViolation(self.id, "Title contains the special character '{}'".format(char), 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.message | object | Python object representing the commit message commit.message.original | string | Original commit message as returned by git commit.message.full | string | Full commit message, with comments (lines starting with #) removed. commit.message.title | string | Title/subject of the commit message: the first line commit.message.body | string[] | List of lines in the body of the commit message (i.e. starting from the second line) commit.author_name | string | Name of the author, result of ```git log --pretty=%aN``` commit.author_email | string | Email of the author, result of ```git log --pretty=%aE``` commit.date | datetime | Python ```datetime``` object representing the time of commit commit.is_merge_commit | boolean | Boolean indicating whether the commit is a merge commit or not. commit.is_revert_commit | boolean | Boolean indicating whether the commit is a revert commit or not. commit.is_fixup_commit | boolean | Boolean indicating whether the commit is a fixup commit or not. commit.is_squash_commit | boolean | Boolean indicating whether the commit is a squash commit or not. commit.parents | string[] | List of parent commit ```sha```s (only for merge commits). commit.changed_files | string[] | List of files changed in the commit (relative paths). commit.branches | string[] | List of branch names the commit is part of commit.context | object | Object pointing to the bigger git context that the commit is part of commit.context.current_branch | string | Name of the currently active branch (of local repo) commit.context.repository_path | string | Absolute path pointing to the git repository being linted commit.context.commits | object[] | 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 | string | Rule's unique string id message | string | Short description of the violation content | string | (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 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 = "Body contains too many lines ({0} > {1})".format(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```). !!! 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)! # 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/master/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/master/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``` or ```CommitRule``` - 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. - Rule 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. - 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``` or ```M``` 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.