Repository: jorisroovers/gitlint

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"

Comments:

Patch:
diff --git a/docs/contrib_rules.md b/docs/contrib_rules.md
index 336e42a..370d39b 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 0000000..815e31f
--- /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 0000000..0d916c9
--- /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])