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
54 changes: 53 additions & 1 deletion commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class CheckOutcome:

check: str
status: str # "pass" or "fail"
# The concrete value that was checked (subject, branch, author, ...),
# populated on both pass and fail so consumers can report what was
# validated even when the check succeeded.
value: str = ""
error: str = ""
suggest: str = ""
Expand Down Expand Up @@ -87,6 +90,15 @@ def __init__(self, rule: ValidationRule):
self._compact: bool = False
# Populated by _print_failure() on every failure, regardless of mode.
self._last_failure: dict[str, str] | None = None
# Populated by subclasses on every validation (pass or fail) with the
# concrete value that was checked (subject, branch, author, ...), so
# structured consumers (--format json, validate_all_detailed) can
# report what was checked even when the check passed.
self._checked_value: str = ""
# Set by ValidationEngine.validate_all_detailed() to opt into value
# collection. Text-mode validation skips the extra lookups (e.g. a
# git subprocess for the branch name) and keeps values empty.
self._collect_value: bool = False

@abstractmethod
def validate(self, context: ValidationContext) -> ValidationResult:
Expand Down Expand Up @@ -244,6 +256,8 @@ def validate(self, context: ValidationContext) -> ValidationResult:
if not message:
return ValidationResult.PASS

self._checked_value = message

import re

if self.rule.regex and re.match(self.rule.regex, message):
Expand All @@ -264,6 +278,8 @@ def validate(self, context: ValidationContext) -> ValidationResult:
if not subject:
return ValidationResult.PASS

self._checked_value = subject

return self._validate_subject(subject)

def _get_subject(self, context: ValidationContext) -> str:
Expand Down Expand Up @@ -369,6 +385,8 @@ def validate(self, context: ValidationContext) -> ValidationResult:
if not author_value:
return ValidationResult.PASS

self._checked_value = author_value

return self._validate_author(author_value)

def _get_author_value(self, context: ValidationContext) -> str:
Expand Down Expand Up @@ -429,6 +447,7 @@ def validate(self, context: ValidationContext) -> ValidationResult:
branch_name = (
context.stdin_text.strip() if context.stdin_text else get_branch_name()
)
self._checked_value = branch_name

if not self.rule.regex:
return ValidationResult.PASS
Expand All @@ -451,6 +470,7 @@ def validate(self, context: ValidationContext) -> ValidationResult:

current_branch = get_branch_name()
target_pattern = self.rule.regex
self._checked_value = current_branch

if not target_pattern:
return ValidationResult.PASS
Expand Down Expand Up @@ -525,6 +545,8 @@ def validate(self, context: ValidationContext) -> ValidationResult:
if not message:
return ValidationResult.PASS

self._checked_value = message

import re

if self.rule.regex and re.search(self.rule.regex, message):
Expand All @@ -545,6 +567,8 @@ def validate(self, context: ValidationContext) -> ValidationResult:
if not message:
return ValidationResult.PASS

self._checked_value = message

# Split message into lines and check if there's content after the subject
lines = message.strip().split("\n")

Expand Down Expand Up @@ -598,6 +622,10 @@ def _check_current_branch_against_upstream(self) -> ValidationResult:
if not upstream_ref:
return ValidationResult.PASS

if self._collect_value:
branch = get_branch_name()
self._checked_value = f"{branch} -> {upstream_ref}"

target_ref = get_upstream_remote_sha(upstream_ref) or upstream_ref
returncode = git_merge_base(target_ref, "HEAD")
if (
Expand Down Expand Up @@ -627,6 +655,12 @@ def _check_push_line(self, line: str) -> ValidationResult:
parts[2],
parts[3],
)
pair = f"{local_ref} -> {remote_ref}"
# Accumulate every checked ref pair: a pre-push stdin may carry
# several refs, and each one is validated individually.
self._checked_value = (
f"{self._checked_value}\n{pair}" if self._checked_value else pair
)

# Zero SHA for remote means a new branch push (not a force push)
if remote_sha == self.ZERO_SHA:
Expand Down Expand Up @@ -670,13 +704,26 @@ class CommitTypeValidator(BaseValidator):
"""Base validator for special commit types (merge, revert, fixup, WIP, empty)."""

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
if self.rule.check == "ignore_authors":
# The ignore_authors rule is about the commit author, not the
# message; record it before the skip check so non-ignored
# authors still carry the checked identity. An ignored author
# means nothing was checked, so the value stays empty. The
# author lookup only runs when structured consumers opt in.
if self._collect_value:
self._checked_value = self._resolve_current_author(context)
if self._should_skip_commit_validation(context):
self._checked_value = ""
return ValidationResult.PASS
elif self._should_skip_commit_validation(context):
return ValidationResult.PASS

message = self._get_commit_message(context)
if not message:
return ValidationResult.PASS

self._checked_value = message

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Check if this commit type is allowed based on rule configuration
is_allowed = self._is_commit_type_allowed(message)

Expand Down Expand Up @@ -754,10 +801,13 @@ def validate(self, context: ValidationContext) -> ValidationResult:

policy = self.rule.value # "ignore" | "forbid"
if policy != "forbid":
# No-op policy: nothing is checked, so no value is recorded.
return ValidationResult.PASS

signatures = detect_ai_signatures(message)
if not signatures:
# The message was scanned and no AI signature found.
self._checked_value = message
return ValidationResult.PASS

tools = {s["tool"] for s in signatures}
Expand Down Expand Up @@ -866,6 +916,7 @@ def validate_all_detailed(self, context: ValidationContext) -> list[CheckOutcome

validator: BaseValidator = validator_class(rule)
validator._suppress_output = True # collect, don't print
validator._collect_value = True # report checked values on pass
result = validator.validate(context)

if result == ValidationResult.FAIL:
Expand All @@ -886,6 +937,7 @@ def validate_all_detailed(self, context: ValidationContext) -> list[CheckOutcome
CheckOutcome(
check=rule.check,
status="pass",
value=validator._checked_value or "",
Comment thread
shenxianpeng marked this conversation as resolved.
rule_id=rule.rule_id or "",
docs_url=rule.docs_url or "",
)
Expand Down
161 changes: 161 additions & 0 deletions tests/engine_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,37 @@ def test_commit_type_validator_merge_commits(self):
result = validator.validate(context)
assert result == ValidationResult.PASS

@pytest.mark.benchmark
def test_ignore_authors_records_resolved_author(self):
"""ignore_authors records the checked author identity, not the message."""
rule = ValidationRule(check="ignore_authors", value=["ignored"])
validator = CommitTypeValidator(rule)
validator._collect_value = True
context = ValidationContext(config={"commit": {"ignore_authors": ["ignored"]}})

with patch("commit_check.engine.get_commit_info", return_value=""):
with patch(GIT_CONFIG_VALUE, return_value="Jane Doe"):
with patch("commit_check.engine.has_commits", return_value=True):
result = validator.validate(context)

assert result == ValidationResult.PASS
assert validator._checked_value == "Jane Doe"

@pytest.mark.benchmark
def test_ignore_authors_skipped_keeps_value_empty(self):
"""An ignored author skips the rule and leaves the value empty."""
rule = ValidationRule(check="ignore_authors", value=["Jane Doe"])
validator = CommitTypeValidator(rule)
validator._collect_value = True
context = ValidationContext(config={"commit": {"ignore_authors": ["Jane Doe"]}})

with patch("commit_check.engine.get_commit_info", return_value="Jane Doe"):
with patch("commit_check.engine.has_commits", return_value=True):
result = validator.validate(context)

assert result == ValidationResult.PASS
assert validator._checked_value == ""

@pytest.mark.benchmark
def test_commit_type_validator_revert_commits(self):
"""Test CommitTypeValidator with revert commits."""
Expand Down Expand Up @@ -1158,6 +1189,46 @@ def test_validation_engine_validate_all_pass(self):
result = engine.validate_all(context)
assert result == ValidationResult.PASS

@pytest.mark.benchmark
def test_validate_all_detailed_reports_value_on_pass(self):
"""Passed checks still report the concrete value that was checked."""
rules = [
ValidationRule(check="message", regex=r"^feat:"),
ValidationRule(check="subject_imperative", regex=r""),
]
engine = ValidationEngine(rules)
context = ValidationContext(stdin_text="feat: add feature")

outcomes = engine.validate_all_detailed(context)
assert len(outcomes) == 2
assert all(o.status == "pass" for o in outcomes)
by_check = {o.check: o for o in outcomes}
assert by_check["message"].value == "feat: add feature"
assert by_check["subject_imperative"].value == "feat: add feature"

@pytest.mark.benchmark
def test_validate_all_detailed_author_reports_author_name(self):
"""Author check reports the checked identity even when it passes."""
rules = [ValidationRule(check="author_name", regex=r"^Jane")]
engine = ValidationEngine(rules)

with patch(GIT_CONFIG_VALUE, return_value="Jane Doe"):
outcomes = engine.validate_all_detailed(ValidationContext())

assert outcomes[0].status == "pass"
assert outcomes[0].value == "Jane Doe"

@pytest.mark.benchmark
def test_validate_all_detailed_branch_reports_branch_name(self):
"""Branch check reports the branch name even when it passes."""
rules = [ValidationRule(check="branch", regex=r"^feature/")]
engine = ValidationEngine(rules)
context = ValidationContext(stdin_text="feature/add-login")

outcomes = engine.validate_all_detailed(context)
assert outcomes[0].status == "pass"
assert outcomes[0].value == "feature/add-login"

@pytest.mark.benchmark
def test_validation_engine_validate_all_fail(self):
"""Test ValidationEngine with some validations failing."""
Expand Down Expand Up @@ -1673,6 +1744,27 @@ def test_no_stdin_skips_validation(self):
result = validator.validate(context)
assert result == ValidationResult.PASS

@pytest.mark.benchmark
def test_multiple_push_refs_accumulate_checked_value(self):
"""Every validated ref pair is preserved, not overwritten."""
rule = self._make_rule()
validator = ForcePushValidator(rule)
stdin = (
"refs/heads/main deadbeef refs/heads/main abc123\n"
"refs/heads/feature/x deadbeef refs/heads/feature/x "
f"{self.ZERO_SHA}\n"
)
context = ValidationContext(stdin_text=stdin)

with patch("commit_check.engine.git_merge_base", return_value=0):
result = validator.validate(context)

assert result == ValidationResult.PASS
assert validator._checked_value == (
"refs/heads/main -> refs/heads/main\n"
"refs/heads/feature/x -> refs/heads/feature/x"
)

@pytest.mark.benchmark
def test_no_stdin_with_upstream_fallback_passes_without_upstream(self):
"""Standalone mode passes when the current branch has no upstream."""
Expand Down Expand Up @@ -1703,6 +1795,49 @@ def test_no_stdin_with_upstream_fallback_passes_fast_forward(self):

assert result == ValidationResult.PASS

@pytest.mark.benchmark
def test_upstream_fallback_text_mode_skips_branch_lookup(self):
"""Text mode does not pay for the extra branch-name lookup."""
rule = self._make_rule()
validator = ForcePushValidator(rule)
context = ValidationContext(push_upstream_fallback=True)

with patch(
"commit_check.engine.get_upstream_branch", return_value="origin/main"
):
with patch(
"commit_check.engine.get_upstream_remote_sha", return_value="abc123"
):
with patch("commit_check.engine.git_merge_base", return_value=0):
with patch("commit_check.engine.get_branch_name") as mock_branch:
result = validator.validate(context)

assert result == ValidationResult.PASS
mock_branch.assert_not_called()

@pytest.mark.benchmark
def test_upstream_fallback_structured_mode_records_value(self):
"""Structured mode records branch -> upstream as the checked value."""
rule = self._make_rule()
validator = ForcePushValidator(rule)
validator._collect_value = True
context = ValidationContext(push_upstream_fallback=True)

with patch(
"commit_check.engine.get_upstream_branch", return_value="origin/main"
):
with patch(
"commit_check.engine.get_upstream_remote_sha", return_value="abc123"
):
with patch("commit_check.engine.git_merge_base", return_value=0):
with patch(
"commit_check.engine.get_branch_name", return_value="main"
):
result = validator.validate(context)

assert result == ValidationResult.PASS
assert validator._checked_value == "main -> origin/main"

@pytest.mark.benchmark
def test_no_stdin_with_upstream_fallback_uses_tracking_ref_when_remote_sha_missing(
self,
Expand Down Expand Up @@ -2006,6 +2141,32 @@ def test_forbid_policy_allows_clean_commit(self):
result = validator.validate(context)
assert result == ValidationResult.PASS

@pytest.mark.benchmark
def test_forbid_policy_clean_commit_records_message(self):
"""forbid policy records the scanned message when no signature is found."""
rule = ValidationRule(
check="ai_attribution",
value="forbid",
)
validator = AiAttributionValidator(rule)
context = ValidationContext(stdin_text="feat: add feature by hand")
result = validator.validate(context)
assert result == ValidationResult.PASS
assert validator._checked_value == "feat: add feature by hand"

@pytest.mark.benchmark
def test_ignore_policy_records_no_value(self):
"""ignore policy is a no-op and records no checked value."""
rule = ValidationRule(
check="ai_attribution",
value="ignore",
)
validator = AiAttributionValidator(rule)
context = ValidationContext(stdin_text="feat: add feature")
result = validator.validate(context)
assert result == ValidationResult.PASS
assert validator._checked_value == ""

@pytest.mark.benchmark
def test_forbid_policy_multiple_tools(self):
"""forbid rejects commits with multiple AI tools."""
Expand Down
17 changes: 17 additions & 0 deletions tests/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,23 @@ def test_json_format_valid_message_returns_pass(self, mocker, capsys, monkeypatc
assert isinstance(data["checks"], list)
assert all("check" in c and "status" in c for c in data["checks"])

@pytest.mark.benchmark
def test_json_format_pass_reports_checked_value(self, mocker, capsys, monkeypatch):
"""JSON output reports the checked value even when the check passed."""
mocker.patch("sys.stdin.isatty", return_value=False)
mocker.patch("sys.stdin.read", return_value="feat: add new feature\n")

monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"])
main()

out, _ = capsys.readouterr()
data = json.loads(out)
passed_with_value = [
c for c in data["checks"] if c["status"] == "pass" and c["value"]
]
assert passed_with_value
assert all(c["value"] == "feat: add new feature" for c in passed_with_value)

@pytest.mark.benchmark
def test_json_format_invalid_message_returns_fail(
self, mocker, capsys, monkeypatch
Expand Down