summaryrefslogtreecommitdiffstats
path: root/docs/user_defined_rules.md
blob: a8a51d52927ae02e57ce1e823bde0246e5a3f2b2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# 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.