diff --git a/.github/gitlint/body_max_line_length_with_exceptions.py b/.github/gitlint/body_max_line_length_with_exceptions.py new file mode 100644 index 0000000..cfaca40 --- /dev/null +++ b/.github/gitlint/body_max_line_length_with_exceptions.py @@ -0,0 +1,26 @@ +import re + +from gitlint.rules import BodyMaxLineLength + + +class BodyMaxLineLengthWithExceptions(BodyMaxLineLength): + """Extend the existing body-max-line-length rule. + + Allow narrow exceptions, specifically leading block quote-style + indent and footnote-style URLs, to the line length enforcement. + """ + + name = "body-max-line-length-with-exceptions" + id = "UC2" + + def validate(self, line, commit): + # Allow block-quoted lines to exceed the line length limit. + if line.startswith(" " * 4): + return None + + # Allow footnote lines to exceed the line length limit. + ret = re.match(r"^\[\d+\] ", line) + if ret is not None: + return None + + return super().validate(line, commit) diff --git a/.github/gitlint/gitlint.ini b/.github/gitlint/gitlint.ini new file mode 100644 index 0000000..4105a4b --- /dev/null +++ b/.github/gitlint/gitlint.ini @@ -0,0 +1,23 @@ +[general] +ignore=body-is-missing,body-max-line-length + +ignore-merge-commits=false +# If you're reverting a commit, the revert commit should darn well contain enough information about WHY +ignore-revert-commits=false +ignore-fixup-commits=false +ignore-fixup-amend-commits=false +ignore-squash-commits=false + +# This will be the default in the future, so enable now +regex-style-search=true + +[title-must-not-contain-word] +words=wip,fixup,fixups,squash,fixme,dropme,dontmerge,donotmerge + +# 72 should be the target, but don't fail until it's past 80. +[body-max-line-length-with-exceptions] +line-length=80 + +# Not every commit needs a body. Some are simple enough just a title will do +[body-min-length] +min-length=0 diff --git a/.github/gitlint/title_imperative_mood.py b/.github/gitlint/title_imperative_mood.py new file mode 100644 index 0000000..86ab36f --- /dev/null +++ b/.github/gitlint/title_imperative_mood.py @@ -0,0 +1,321 @@ +from typing import List + +from gitlint.git import GitCommit +from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation + +# Word list from https://github.com/m1foley/fit-commit +# Copyright (c) 2015 Mike Foley +# License: MIT +# Ref: fit_commit/validators/tense.rb +WORD_SET = { + "adds", + "adding", + "added", + "allows", + "allowing", + "allowed", + "amends", + "amending", + "amended", + "bumps", + "bumping", + "bumped", + "calculates", + "calculating", + "calculated", + "changes", + "changing", + "changed", + "cleans", + "cleaning", + "cleaned", + "commits", + "committing", + "committed", + "corrects", + "correcting", + "corrected", + "creates", + "creating", + "created", + "darkens", + "darkening", + "darkened", + "disables", + "disabling", + "disabled", + "displays", + "displaying", + "displayed", + "documents", + "documenting", + "documented", + "drys", + "drying", + "dryed", + "ends", + "ending", + "ended", + "enforces", + "enforcing", + "enforced", + "enqueues", + "enqueuing", + "enqueued", + "extracts", + "extracting", + "extracted", + "finishes", + "finishing", + "finished", + "fixes", + "fixing", + "fixed", + "formats", + "formatting", + "formatted", + "guards", + "guarding", + "guarded", + "handles", + "handling", + "handled", + "hides", + "hiding", + "hid", + "increases", + "increasing", + "increased", + "ignores", + "ignoring", + "ignored", + "implements", + "implementing", + "implemented", + "improves", + "improving", + "improved", + "keeps", + "keeping", + "kept", + "kills", + "killing", + "killed", + "makes", + "making", + "made", + "merges", + "merging", + "merged", + "moves", + "moving", + "moved", + "permits", + "permitting", + "permitted", + "prevents", + "preventing", + "prevented", + "pushes", + "pushing", + "pushed", + "rebases", + "rebasing", + "rebased", + "refactors", + "refactoring", + "refactored", + "removes", + "removing", + "removed", + "renames", + "renaming", + "renamed", + "reorders", + "reordering", + "reordered", + "replaces", + "replacing", + "replaced", + "requires", + "requiring", + "required", + "restores", + "restoring", + "restored", + "sends", + "sending", + "sent", + "sets", + "setting", + "separates", + "separating", + "separated", + "shows", + "showing", + "showed", + "simplifies", + "simplifying", + "simplified", + "skips", + "skipping", + "skipped", + "sorts", + "sorting", + "speeds", + "speeding", + "sped", + "starts", + "starting", + "started", + "supports", + "supporting", + "supported", + "takes", + "taking", + "took", + "testing", + "tested", # 'tests' excluded to reduce false negative + "truncates", + "truncating", + "truncated", + "updates", + "updating", + "updated", + "uses", + "using", + "used", +} + +imperative_forms = [ + "add", + "allow", + "amend", + "bump", + "calculate", + "change", + "clean", + "commit", + "correct", + "create", + "darken", + "disable", + "display", + "document", + "dry", + "end", + "enforce", + "enqueue", + "extract", + "finish", + "fix", + "format", + "guard", + "handle", + "hide", + "ignore", + "implement", + "improve", + "increase", + "keep", + "kill", + "make", + "merge", + "move", + "permit", + "prevent", + "push", + "rebase", + "refactor", + "remove", + "rename", + "reorder", + "replace", + "require", + "restore", + "send", + "separate", + "set", + "show", + "simplify", + "skip", + "sort", + "speed", + "start", + "support", + "take", + "test", + "truncate", + "update", + "use", +] +imperative_forms.sort() + + +def head_binary_search(key: str, words: List[str]) -> str: + """Find the imperative mood version of `word` by looking at the first 3 characters.""" + # Edge case: 'disable' and 'display' have the same 3 starting letters. + if key in ["displays", "displaying", "displayed"]: + return "display" + + lower = 0 + upper = len(words) - 1 + + while True: + if lower > upper: + # Should not happen + raise Exception(f"Cannot find imperative mood of {key}") + + mid = (lower + upper) // 2 + imperative_form = words[mid] + + if key[:3] == imperative_form[:3]: + return imperative_form + if key < imperative_form: + upper = mid - 1 + elif key > imperative_form: + lower = mid + 1 + + +class ImperativeMood(LineRule): + """A rule to enforce that the commit message title uses imperative mood. + + This is done by checking if the first word is in `WORD_SET`, if so show the word in the correct + mood. + """ + + name = "title-imperative-mood" + id = "UC1" + target = CommitMessageTitle + + error_msg = ( + "The first word in commit title should be in imperative mood " + '("{word}" -> "{imperative}"): "{title}"' + ) + + def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]: + """Validate the given commit message.""" + violations = [] + + # Ignore the section tag (ie `
: .`) + words = line.split(": ", 1)[-1].split() + + if not words: + return None + + first_word = words[0].lower() + + if first_word in WORD_SET: + imperative = head_binary_search(first_word, imperative_forms) + violation = RuleViolation( + self.id, + self.error_msg.format( + word=first_word, + imperative=imperative, + title=commit.message.title, + ), + ) + + violations.append(violation) + + return violations diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 88d98bc..78d3d8a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,30 @@ env: CARGO_TERM_COLOR: always jobs: + gitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Check out at the last commit (pre-automated merge, we don't care about the + # temporary commit for linting) + ref: ${{ github.event.pull_request.head.sha }} + # Get all history + fetch-depth: 0 + - name: Install gitlint + run: python -m pip install gitlint + - name: Run gitlint + env: + GITLINT_COMMIT_RANGE: "${{ github.event.pull_request.base.sha }}..HEAD" + run: | + echo "GITLINT_COMMIT_RANGE=$GITLINT_COMMIT_RANGE" + git log --color=always --graph --decorate --oneline "$GITLINT_COMMIT_RANGE" + gitlint \ + --ignore-stdin \ + --config .github/gitlint/gitlint.ini \ + --extra-path .github/gitlint/ \ + --commits "$GITLINT_COMMIT_RANGE" + format: runs-on: ubuntu-latest steps: @@ -12,8 +36,8 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: stable - components: rustfmt + toolchain: stable + components: rustfmt - name: Setup Rust cache uses: swatinem/rust-cache@v2 - name: Run rustfmt @@ -26,8 +50,8 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: stable - components: clippy + toolchain: stable + components: clippy - name: Setup Rust cache uses: swatinem/rust-cache@v2 - name: Build @@ -42,10 +66,10 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: stable - name: Setup Rust cache uses: swatinem/rust-cache@v2 - name: Test run: | - git fetch - cargo test --release --all-targets + git fetch + cargo test --release --all-targets