summaryrefslogtreecommitdiffstats
path: root/docs/user_defined_rules.md
blob: db2180940d2aeeceb3b7d5f92ef0ab03a82b2fba (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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
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.