Repository: jorisroovers/gitlint (language: python)

Issue:
Add a rule to disallow commit messages starting with "fixup!"
They are created with the `git commit --fixup=<commit-sha>` command and are supposed to be squashed.

If they are present, they usually indicate that someone forgot to do `git rebase -i` and squash them.

A similar thing could be achieved with the `title-must-not-contain-word` rule, but unfortunately, the currently used regex:
https://github.com/jorisroovers/gitlint/blob/7f7c52fc2ee54ef8d8f1cfa8033b457c1cbac6b2/gitlint/rules.py#L158

prevents using the `fixup!` word since then the regex doesn't match at all:

```
In [11]: re.search(r"\b%s\b" % "fixup", "fixup! Some title")                    
Out[11]: <re.Match object; span=(0, 5), match='fixup'>

In [12]: re.search(r"\b%s\b" % "fixup!", "fixup! Some title")
```

Simply using `fixup` as a forbidden word is an incomplete solution since the term "fixup" is valid in other parts of the title, e.g.: "Add another check to fixup tests"


Discussion / hints:
So given that [gitlint actually ignores fixup commits by default](https://jorisroovers.com/gitlint/#merge-fixup-squash-and-revert-commits), I think this is probably a good candidate for a [Contrib Rule](https://jorisroovers.com/gitlint/contrib_rules/) (PRs appreciated!)

Good news is that gitlint already knows whether commits are fixup commits or not, so implementing this as a [User Defined](https://jorisroovers.com/gitlint/user_defined_rules/) or Contrib Rule is easy:

```python
from gitlint.rules import CommitRule, RuleViolation

class DisallowFixup(CommitRule):
    name = "fixup-disallowed"
    # A rule MUST have a *unique* id, we recommend starting with UC (for User-defined Commit-rule) 
    id = "UC1"

    def validate(self, commit):
        if commit.is_fixup_commit:
            return [RuleViolation(self.id, "Don't use fixup! commits (use squash)", line_nr=1)]
```

To invoke:

```bash
gitlint -c general.ignore-fixup-commits=false --extra-path ~/joe/myrules/disallow-fixup.py

# You can also use a .gitlint file instead of commandline flags:
$ cat .gitlint
[general]
ignore-fixup-commits=false
extra-path=~/joe/myrules/disallow-fixup.py
```

Hope this helps!
@jorisroovers, thanks for your helpful reply!

I've decided to implement this as a User Defined Rule first.
For anyone curious, this is how I've done it for the [Oasis Rosetta Gateway](https://github.com/oasisprotocol/oasis-core-rosetta-gateway/commit/c2ce3395f7775410c2f3bf2ffaaa5c518f1e0044) project.

Agreed that this would be a good candidate for a Contrib Rule.

Fix patch:
diff --git a/docs/contrib_rules.md b/docs/contrib_rules.md
index 336e42ac..370d39b6 100644
--- a/docs/contrib_rules.md
+++ b/docs/contrib_rules.md
@@ -42,6 +42,7 @@ ID    | Name                                | gitlint version   | Description
 ------|-------------------------------------|------------------ |-------------------------------------------
 CT1   | contrib-title-conventional-commits  | >= 0.12.0         | Enforces [Conventional Commits](https://www.conventionalcommits.org/) commit message style on the title.
 CC1   | contrib-body-requires-signed-off-by | >= 0.12.0         | Commit body must contain a `Signed-off-by` line.
+CC2   | contrib-disallow-cleanup-commits    | >= 0.18.0         | Commit title must not contain `fixup!`, `squash!`, `amend!`.
 
 ## CT1: contrib-title-conventional-commits ##
 
@@ -63,5 +64,11 @@ ID    | Name                                  | gitlint version    | Description
 CC1   | contrib-body-requires-signed-off-by   | >= 0.12.0          | Commit body must contain a `Signed-off-by` line. This means, a line that starts with the `Signed-off-by` keyword.
 
 
+## CC2: contrib-disallow-cleanup-commits ##
+
+ID    | Name                             | gitlint version    | Description
+------|----------------------------------|--------------------|-------------------------------------------
+CC2   | contrib-disallow-cleanup-commits | >= 0.18.0          | Commit title must not contain `fixup!`, `squash!` or `amend!`. This means `git commit --fixup` and `git commit --squash` commits are not allowed.
+
 ## Contributing Contrib rules
 We'd love for you to contribute new Contrib rules to gitlint or improve existing ones! Please visit the [Contributing](contributing) page on how to get started.
diff --git a/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py b/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py
new file mode 100644
index 00000000..815e31f6
--- /dev/null
+++ b/gitlint-core/gitlint/contrib/rules/disallow_cleanup_commits.py
@@ -0,0 +1,22 @@
+from gitlint.rules import CommitRule, RuleViolation
+
+
+class DisallowCleanupCommits(CommitRule):
+    """ This rule checks the commits for "fixup!"/"squash!"/"amend!" commits
+    and rejects them.
+    """
+
+    name = "contrib-disallow-cleanup-commits"
+    id = "CC2"
+
+    def validate(self, commit):
+        if commit.is_fixup_commit:
+            return [RuleViolation(self.id, "Fixup commits are not allowed", line_nr=1)]
+
+        if commit.is_squash_commit:
+            return [RuleViolation(self.id, "Squash commits are not allowed", line_nr=1)]
+
+        if commit.is_fixup_amend_commit:
+            return [RuleViolation(self.id, "Amend commits are not allowed", line_nr=1)]
+
+        return []


Test patch:
diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py b/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py
new file mode 100644
index 00000000..0d916c9c
--- /dev/null
+++ b/gitlint-core/gitlint/tests/contrib/rules/test_disallow_cleanup_commits.py
@@ -0,0 +1,38 @@
+
+# -*- coding: utf-8 -*-
+from gitlint.tests.base import BaseTestCase
+from gitlint.rules import RuleViolation
+from gitlint.contrib.rules.disallow_cleanup_commits import DisallowCleanupCommits
+
+from gitlint.config import LintConfig
+
+
+class ContribDisallowCleanupCommitsTest(BaseTestCase):
+
+    def test_enable(self):
+        # Test that rule can be enabled in config
+        for rule_ref in ['CC2', 'contrib-disallow-cleanup-commits']:
+            config = LintConfig()
+            config.contrib = [rule_ref]
+            self.assertIn(DisallowCleanupCommits(), config.rules)
+
+    def test_disallow_fixup_squash_commit(self):
+        # No violations when no 'fixup!' line and no 'squash!' line is present
+        rule = DisallowCleanupCommits()
+        violations = rule.validate(self.gitcommit("Föobar\n\nMy Body"))
+        self.assertListEqual(violations, [])
+
+        # Assert violation when 'fixup!' in title
+        violations = rule.validate(self.gitcommit("fixup! Föobar\n\nMy Body"))
+        expected_violation = RuleViolation("CC2", "Fixup commits are not allowed", line_nr=1)
+        self.assertListEqual(violations, [expected_violation])
+
+        # Assert violation when 'squash!' in title
+        violations = rule.validate(self.gitcommit("squash! Föobar\n\nMy Body"))
+        expected_violation = RuleViolation("CC2", "Squash commits are not allowed", line_nr=1)
+        self.assertListEqual(violations, [expected_violation])
+
+        # Assert violation when 'amend!' in title
+        violations = rule.validate(self.gitcommit("amend! Föobar\n\nMy Body"))
+        expected_violation = RuleViolation("CC2", "Amend commits are not allowed", line_nr=1)
+        self.assertListEqual(violations, [expected_violation])