Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ repos:
rev: v2.4.2
hooks:
- id: codespell
# iTerm is a terminal emulator, named in the hyperlink support probe.
args: [--ignore-words-list=iterm]
- repo: https://github.com/commit-check/commit-check
rev: v2.11.0
hooks:
Expand Down
10 changes: 9 additions & 1 deletion commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,24 @@ def _build_length_rule(
if not isinstance(length, int):
return None

# The suggestion is templated on the same values as the error: naming
# the configured length is the whole point of the advice, and "the
# configured minimum" told the reader less than the error above it.
error = (
catalog_entry.error.format(max_len=length, min_len=length)
if catalog_entry.error
else None
)
suggest = (
catalog_entry.suggest.format(max_len=length, min_len=length)
if catalog_entry.suggest
else None
)

return ValidationRule(
check=catalog_entry.check,
error=error,
suggest=catalog_entry.suggest,
suggest=suggest,
value=length,
)

Expand Down
4 changes: 2 additions & 2 deletions commit_check/rules_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,14 @@ def docs_url(self) -> str | None:
check="subject_max_length",
regex=None,
error="Subject must be at most {max_len} characters",
suggest="Keep the subject concise (<= configured max)",
suggest="Shorten the subject to {max_len} characters or fewer",
),
RuleCatalogEntry(
rule_id="CC005",
check="subject_min_length",
regex=None,
error="Subject must be at least {min_len} characters",
suggest="Provide a meaningful subject (>= configured min)",
suggest="Write a subject of at least {min_len} characters",
),
RuleCatalogEntry(
rule_id="CC006",
Expand Down
84 changes: 72 additions & 12 deletions commit_check/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations
import os
import subprocess
import sys
from subprocess import CalledProcessError
from commit_check import RED, GREEN, YELLOW, RESET_COLOR

Expand All @@ -27,17 +28,24 @@ def _print_failure(
return
if not no_banner and not print_error_header.has_been_called:
print_error_header()
docs_url = check.get("docs_url", "") or ""
print_error_message(
check["check"],
check.get("error", ""),
actual,
rule_id=rule_id,
docs_url=docs_url,
)
if check.get("suggest"):
print_suggestion(check["suggest"])
docs_url = check.get("docs_url", "")
if docs_url:
# When the ID above is already a link, repeating the URL only adds a line
# to read. Without hyperlink support — a pipe, a CI log — it is the only
# way the reader gets the address at all, so it stays.
if docs_url and not (rule_id and supports_hyperlinks()):
print(f"Docs: {docs_url}")
# Blank line closes the whole block, rather than splitting it before the
# documentation link.
print()


def get_branch_name() -> str:
Expand Down Expand Up @@ -294,22 +302,78 @@ def print_error_header():
print(" ")


def print_error_message(check_type: str, error: str, reason: str, rule_id: str = ""):
#: Terminals known to render OSC 8 hyperlinks, by ``TERM_PROGRAM``.
_HYPERLINK_TERM_PROGRAMS = frozenset(
{"iTerm.app", "WezTerm", "vscode", "Hyper", "ghostty", "rio"}
)


def supports_hyperlinks() -> bool:
"""Whether the terminal renders OSC 8 hyperlinks.

A terminal that does not understand the escape sequence may print its
payload as visible junk, so this errs towards saying no. The signals are
the ones the wider tooling ecosystem settled on, which is why a link that
works in ``ruff`` works here too.

Piped or redirected output always says no: the sequence would end up in
the file, and a CI log is read as plain text.

``FORCE_HYPERLINK`` overrides the detection in both directions, following
the convention ``FORCE_COLOR`` established: ``0`` turns links off even on a
terminal that renders them, any other value turns them on.
"""
forced = os.environ.get("FORCE_HYPERLINK")
if forced:
return forced != "0"
if not sys.stdout.isatty():
return False
if os.environ.get("TERM") == "dumb":
return False
if os.environ.get("TERM_PROGRAM") in _HYPERLINK_TERM_PROGRAMS:
return True
if "kitty" in os.environ.get("TERM", ""):
return True
# GNOME Terminal and the other VTE-based terminals, from 0.50 onwards.
try:
return int(os.environ.get("VTE_VERSION", "0")) >= 5000
except ValueError:
return False


def hyperlink(text: str, url: str) -> str:
"""Wrap ``text`` in an OSC 8 hyperlink pointing at ``url``."""
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"


def print_error_message(
check_type: str,
error: str,
reason: str,
rule_id: str = "",
docs_url: str = "",
) -> None:
"""Print error message.

:param check_type: the check that failed, e.g. ``subject_imperative``
:param error: the human-readable explanation of the failure
:param reason: the offending value
:param rule_id: stable rule ID, e.g. ``CC003`` (omitted when empty)
:param docs_url: the rule's documentation, linked from the ID when the
terminal supports it

:returns: Give error messages to user
"""
prefix = f"{YELLOW}{rule_id}{RESET_COLOR} " if rule_id else ""
# The kebab-case form is what the rules reference uses as its headings, so
# the name printed here can be searched for there verbatim.
name = check_type.replace("_", "-")
label = rule_id
if rule_id and docs_url and supports_hyperlinks():
label = hyperlink(rule_id, docs_url)
prefix = f"{YELLOW}{label}{RESET_COLOR} " if rule_id else ""
print(
f"{prefix}{YELLOW}{check_type}{RESET_COLOR} check failed ==> {RED}{reason}{RESET_COLOR} ",
end="",
f"{prefix}{YELLOW}{name}{RESET_COLOR} check failed ==> {RED}{reason}{RESET_COLOR}"
)
print("")
if error:
print(error)

Expand All @@ -319,8 +383,4 @@ def print_suggestion(suggest: str) -> None:
:param suggest: what message to print out
"""
if suggest:
print(
f"Suggest: {GREEN}{suggest}{RESET_COLOR} ",
end="",
)
print("\n")
print(f"Suggest: {GREEN}{suggest}{RESET_COLOR}")
34 changes: 34 additions & 0 deletions tests/rule_builder_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,37 @@ def test_build_all_rules_no_ai_by_default(self):
rules = builder.build_all_rules()
ai_rules = [r for r in rules if r.check.startswith("ai_")]
assert len(ai_rules) == 0


class TestLengthRuleMessages:
"""The configured limit reaches both the error and the advice.

The suggestion used to name no length at all — "Provide a meaningful
subject (>= configured min)" sat directly under an error that had already
said "at least 5 characters", so the advice was vaguer than the complaint
above it.
"""

@pytest.mark.benchmark
def test_min_length_names_the_limit(self):
rules = RuleBuilder({"commit": {"subject_min_length": 12}}).build_all_rules()
rule = next(r for r in rules if r.check == "subject_min_length")
assert rule.error == "Subject must be at least 12 characters"
assert rule.suggest == "Write a subject of at least 12 characters"

@pytest.mark.benchmark
def test_max_length_names_the_limit(self):
rules = RuleBuilder({"commit": {"subject_max_length": 72}}).build_all_rules()
rule = next(r for r in rules if r.check == "subject_max_length")
assert rule.error == "Subject must be at most 72 characters"
assert rule.suggest == "Shorten the subject to 72 characters or fewer"

@pytest.mark.benchmark
@pytest.mark.parametrize("check", ["subject_min_length", "subject_max_length"])
def test_no_placeholder_survives_into_output(self, check):
"""A missed substitution would print a literal brace to the user."""
rules = RuleBuilder({"commit": {check: 40}}).build_all_rules()
rule = next(r for r in rules if r.check == check)
assert "{" not in (rule.error or "")
assert "{" not in (rule.suggest or "")
assert "40" in (rule.suggest or "")
Loading