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
86 changes: 72 additions & 14 deletions commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@
no_banner: bool = False
compact: bool = False
push_upstream_fallback: bool = False
# A git revision naming the commit under test. When set, message and
# author checks read that commit -- the author is the commit's author,
# never the local git config, because an existing commit's identity is
# a fact about the commit rather than about whoever is running the
# check. The CLI verifies the revision resolves before it gets here.
# Last on purpose: positional construction predates it.
rev: str | None = None


@dataclass
Expand Down Expand Up @@ -152,11 +159,13 @@
"""
Determine if validation should be skipped.

Skip only when there is no stdin_text, no commit_file, and no commits.
Skip only when there is no stdin_text, no commit_file, no rev, and
no commits.
"""
return (
context.stdin_text is None
and context.commit_file is None
and context.rev is None
and not has_commits()
)

Expand All @@ -176,6 +185,10 @@
(``get_commit_info("an")``), not the local git config which may
belong to a different person.
"""
if context.rev is not None:
# An explicit revision names an existing commit; its author is a
# fact about that commit, so the config never enters into it.
return get_commit_info("an", context.rev)
if context.stdin_text is not None or context.commit_file is not None:
return get_git_config_value("user.name") or get_commit_info("an")
return get_commit_info("an") or get_git_config_value("user.name")
Expand All @@ -194,7 +207,11 @@
genuinely empty, and rejecting that under allow_empty_commits = false
is the verdict the rule exists to give.
"""
return context.stdin_text is not None or context.commit_file is not None
return (
context.stdin_text is not None
or context.commit_file is not None
or context.rev is not None
)

@staticmethod
def _get_commit_message(context: ValidationContext) -> str:
Expand All @@ -210,8 +227,12 @@
pass

# Fallback to git log
subject = get_commit_info("s")
body = get_commit_info("b")
if context.rev is not None:
subject = get_commit_info("s", context.rev)
body = get_commit_info("b", context.rev)
else:
subject = get_commit_info("s")
body = get_commit_info("b")
return f"{subject}\n\n{body}".strip()

def _author_in_ignore_list(self, context: ValidationContext) -> bool:
Expand Down Expand Up @@ -254,6 +275,8 @@
return f.read()
except (OSError, IOError):
pass
if context.rev is not None:
return get_commit_info("b", context.rev)
return get_commit_info("b")

def _should_skip_commit_validation(self, context: ValidationContext) -> bool:
Expand All @@ -269,6 +292,7 @@
return (
context.stdin_text is None
and context.commit_file is None
and context.rev is None
and not has_commits()
)

Expand Down Expand Up @@ -359,6 +383,8 @@
except FileNotFoundError:
pass

if context.rev is not None:
return get_commit_info("s", context.rev)
return get_commit_info("s")

def _validate_subject(self, _subject: str) -> ValidationResult:
Expand All @@ -370,9 +396,10 @@
"""Validates that subject starts with capital letter."""

def _validate_subject(self, subject: str) -> ValidationResult:
# Skip merge commits
if subject.lower().startswith("merge"):
return ValidationResult.PASS
# A merge subject is machine-written; the rule declines to judge it.
# Git writes "Merge " exactly, so anything else is author prose.
if subject.startswith("Merge "):

Check failure on line 401 in commit_check/engine.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Merge " 4 times.

See more on https://sonarcloud.io/project/issues?id=commit-check_commit-check&issues=AZ_1Fb-OTMuUxbWglnYz&open=AZ_1Fb-OTMuUxbWglnYz&pullRequest=544
return ValidationResult.SKIP
Comment thread
shenxianpeng marked this conversation as resolved.

# For conventional commits, check the description part after the colon
import re
Expand Down Expand Up @@ -408,9 +435,11 @@
_INFLECTED = ("ed", "ing")

def _validate_subject(self, subject: str) -> ValidationResult:
# Skip merge commits and fixup commits
if subject.lower().startswith(("merge", "fixup!")):
return ValidationResult.PASS
# Merge and fixup subjects are machine-written; decline to judge them.
# Git writes "Merge " and "fixup! " exactly, so anything else is
# author prose.
if subject.startswith(("Merge ", "fixup! ")):
return ValidationResult.SKIP

# Extract first word (ignore conventional commit prefixes)
import re
Expand Down Expand Up @@ -461,9 +490,9 @@
"""Validates subject line length constraints."""

def _validate_subject(self, subject: str) -> ValidationResult:
# Skip merge commits for length checks
if subject.lower().startswith("merge"):
return ValidationResult.PASS
# A merge subject's length is git's doing, not the author's.
if subject.startswith("Merge "):
return ValidationResult.SKIP

length = len(subject)
constraint_value = self.rule.value
Expand Down Expand Up @@ -513,6 +542,13 @@
"author_email": "ae",
}

# An explicit revision names an existing commit, whose identity is a
# fact about the commit: read it from the commit and never from the
# config, which describes whoever happens to be running the check.
if context.rev is not None:
format_str = git_log_map.get(self.rule.check, "")
return get_commit_info(format_str, context.rev) if format_str else ""

# Try git config first (validates configured identity for new commits)
config_key = git_config_map.get(self.rule.check, "")
if config_key:
Expand Down Expand Up @@ -863,7 +899,14 @@
# never run. A message the caller supplied goes to the rule even when
# it is empty; an empty one from git is still nothing to check.
if not message and not self._message_was_supplied(context):
return ValidationResult.PASS
# ignore_authors delivered its verdict above -- it judges the
# author, so an absent message is no reason to disown it, and a
# SKIP here would wrongly read as "author was bypassed".
return (
ValidationResult.PASS
if self.rule.check == "ignore_authors"
else ValidationResult.SKIP
)

self._checked_value = message

Expand Down Expand Up @@ -1016,6 +1059,7 @@
def validate_all(self, context: ValidationContext) -> ValidationResult:
"""Run all validations and return overall result."""
results = []
skipped: list[str] = []

for rule in self.rules:
validator_class = self.VALIDATOR_MAP.get(rule.check)
Expand All @@ -1027,6 +1071,20 @@
validator._compact = context.compact
result = validator.validate(context)
results.append(result)
if result == ValidationResult.SKIP:
skipped.append(rule.check.replace("_", "-"))

if skipped:
# A skipped check validated nothing, and a silent skip is
# indistinguishable from a pass — which is how a merge commit at
# HEAD once let a whole run report success having read nothing.
# One line, stderr, so scripts parsing stdout are unaffected.
import sys

print(
f"⊘ skipped (not validated): {', '.join(skipped)}",
file=sys.stderr,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Return FAIL if any validation failed
return (
Expand Down
78 changes: 70 additions & 8 deletions commit_check/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,38 @@
"""Handles stdin reading with proper error handling."""

@staticmethod
def read_piped_input() -> str | None:
def _has_pending_data(timeout: float) -> bool:
"""Whether reading stdin would return promptly rather than block.

``read()`` on a pipe that is open but has no writer about to close it
blocks forever. That is what stdin looks like under some CI runners
and process managers, so an unconditional read turns "nothing was
piped" into a hang — the step neither fails nor finishes.

``select`` distinguishes the two: piped input is already in the pipe
buffer by the time this process is exec'd (and EOF, as with
``< /dev/null``, counts as readable), while an idle pipe is not
readable and never will be. The timeout is margin, not a wait.
"""
if sys.platform == "win32": # pragma: no cover
# select() only works on sockets on Windows. Keep the historic
# blocking read there; the hang has only been observed on POSIX
# runners, and a wrong guess here would break piping instead.
return True
import select

try:
ready, _, _ = select.select([sys.stdin], [], [], timeout)
except (OSError, ValueError):
# No usable stdin descriptor at all: nothing to read.
return False
return bool(ready)

@classmethod
def read_piped_input(cls) -> str | None:
"""Read commit message content if piped, with proper error handling."""
try:
if not sys.stdin.isatty():
if not sys.stdin.isatty() and cls._has_pending_data(timeout=0.1):
data = sys.stdin.read()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return data.strip() if data else None
except (OSError, IOError):
Expand Down Expand Up @@ -103,6 +131,16 @@
help="path to config file (cchk.toml or commit-check.toml). If not specified, searches for config in: cchk.toml, commit-check.toml, .github/cchk.toml, .github/commit-check.toml",
)

parser.add_argument(
"--rev",
metavar="REVISION",
default=None,
help="check the commit at this git revision (e.g. HEAD^2, a SHA) "
"instead of HEAD or the working state. Message checks read that "
"commit's message; author checks read that commit's author, not "
"the local git config",
)

parser.add_argument(
"commit_msg_file",
nargs="?",
Expand Down Expand Up @@ -462,7 +500,7 @@
return 1 if overall == "fail" else 0


def main() -> int:

Check failure on line 503 in commit_check/main.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=commit-check_commit-check&issues=AZ_1DdZVI0ZGxXigDQCv&open=AZ_1DdZVI0ZGxXigDQCv&pullRequest=544
"""The main entrypoint of commit-check program."""
_reconfigure_io()
parser = _get_parser()
Expand All @@ -478,6 +516,24 @@
if args.commit_msg_file:
args.message = True

if args.rev is not None:
if args.commit_msg_file:
parser.error(
"--rev and a commit message file both name the "
"thing to check; pass one or the other"
)
# Fail here, with the revision named, rather than deep inside a
# validator where the error would surface as a missing message.
from commit_check.util import git_rev_parse_verify

if not git_rev_parse_verify(args.rev):
print(
f"Error: --rev {args.rev!r} does not resolve to a commit "
"in this repository",
file=sys.stderr,
)
return 1

# Load and merge configuration from all sources: CLI > Env > TOML > Defaults
config_data = ConfigMerger.from_all_sources(args, args.config)

Expand All @@ -500,12 +556,17 @@
filtered_rules = [rule for rule in all_rules if rule.check in requested_checks]
engine = ValidationEngine(filtered_rules)

# Resolve validation context inputs
stdin_content, commit_file_path = _resolve_commit_message_source(
args, stdin_reader
)
if not args.message:
stdin_content = _resolve_stdin_for_non_message(args, stdin_reader)
# Resolve validation context inputs. With --rev the commit itself is
# the thing under test, so stdin is never consulted: piping and a
# revision would name two different subjects for the same checks.
if args.rev is not None:
stdin_content, commit_file_path = None, None
else:
stdin_content, commit_file_path = _resolve_commit_message_source(
args, stdin_reader
)
if not args.message:
stdin_content = _resolve_stdin_for_non_message(args, stdin_reader)

# Reset banner state for this run
from commit_check.util import print_error_header as _peh
Expand All @@ -515,6 +576,7 @@
context = ValidationContext(
stdin_text=stdin_content,
commit_file=commit_file_path,
rev=args.rev,
config=config_data,
no_banner=getattr(args, "no_banner", False),
compact=getattr(args, "compact", False),
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,5 @@ omit = [
# Silence PytestUnknownMarkWarning for custom marks used in tests
markers = [
"benchmark: performance-related tests (no-op marker in this project)",
"real_stdin_gate: opt out of the fixture that force-opens the stdin readiness gate",
]
Loading