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
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.11
rev: v0.12.12
hooks:
# Run the linter.
- id: ruff
Expand Down
14 changes: 9 additions & 5 deletions commit_check/author.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Check git author name and email"""
import re
from typing import Optional
from commit_check import YELLOW, RESET_COLOR, PASS, FAIL
from commit_check.util import (
get_commit_info,
Expand All @@ -21,10 +22,10 @@ def _get_author_value(check_type: str) -> str:
return str(get_commit_info(format_str))


def check_author(checks: list, check_type: str) -> int:
"""Validate author name or email according to configured regex."""
if has_commits() is False:
return PASS # pragma: no cover
def check_author(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int:
# If an explicit value is provided (stdin), validate it even if there are no commits
if stdin_text is None and has_commits() is False:
return PASS # pragma: no cover

check = _find_check(checks, check_type)
if not check:
Expand All @@ -36,7 +37,10 @@ def check_author(checks: list, check_type: str) -> int:
print(f"{YELLOW}Not found regex for {check_type}. skip checking.{RESET_COLOR}")
return PASS

value = _get_author_value(check_type)
if stdin_text is not None:
value = stdin_text
else:
value = _get_author_value(check_type)

if re.match(regex, value):
return PASS
Expand Down
5 changes: 3 additions & 2 deletions commit_check/branch.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Check git branch naming convention."""
import re
from typing import Optional
from commit_check import YELLOW, RESET_COLOR, PASS, FAIL
from commit_check.util import _find_check, _print_failure, get_branch_name, git_merge_base, has_commits


def check_branch(checks: list) -> int:
def check_branch(checks: list, stdin_text: Optional[str] = None) -> int:
check = _find_check(checks, 'branch')
if not check:
return PASS
Expand All @@ -16,7 +17,7 @@ def check_branch(checks: list) -> int:
)
return PASS

branch_name = get_branch_name()
branch_name = stdin_text.strip() if stdin_text is not None else get_branch_name()
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line will raise an AttributeError if stdin_text is an empty string, since empty strings are not None but calling .strip() on None would fail. Should check for both None and empty string: stdin_text.strip() if stdin_text else get_branch_name()

Suggested change
branch_name = stdin_text.strip() if stdin_text is not None else get_branch_name()
branch_name = stdin_text.strip() if stdin_text is not None and stdin_text.strip() != "" else get_branch_name()

Copilot uses AI. Check for mistakes.
if re.match(regex, branch_name):
return PASS

Expand Down
47 changes: 31 additions & 16 deletions commit_check/commit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Check git commit message formatting"""
from typing import Optional
import re
from pathlib import PurePath
from commit_check import YELLOW, RESET_COLOR, PASS, FAIL
Expand Down Expand Up @@ -32,10 +33,15 @@ def read_commit_msg(commit_msg_file) -> str:
# Commit message is composed by subject and body
return str(get_commit_info("s") + "\n\n" + get_commit_info("b"))

def check_commit_msg(checks: list, commit_msg_file: str = "") -> int:
"""Check commit message against the provided checks."""
if has_commits() is False:
return PASS # pragma: no cover

def check_commit_msg(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int:
"""Check commit message against the provided checks.

If stdin_text is provided, use it directly (stdin override) and do not
require a git repository state. Otherwise, fall back to reading from file/Git.
"""
if stdin_text is None and has_commits() is False:
return PASS # pragma: no cover

check = _find_check(checks, 'message')
if not check:
Expand All @@ -46,8 +52,11 @@ def check_commit_msg(checks: list, commit_msg_file: str = "") -> int:
print(f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}")
return PASS

path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)
if stdin_text is not None:
commit_msg = stdin_text
else:
path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)

if re.match(regex, commit_msg):
return PASS
Expand All @@ -56,9 +65,9 @@ def check_commit_msg(checks: list, commit_msg_file: str = "") -> int:
return FAIL


def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int:
if has_commits() is False:
return PASS # pragma: no cover
def check_commit_signoff(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int:
if stdin_text is None and has_commits() is False:
return PASS # pragma: no cover

check = _find_check(checks, 'commit_signoff')
if not check:
Expand All @@ -69,8 +78,11 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int:
print(f"{YELLOW}Not found regex for commit signoff. skip checking.{RESET_COLOR}")
return PASS

path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)
if stdin_text is not None:
commit_msg = stdin_text
else:
path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)

# Extract the subject line (first line of commit message)
subject = commit_msg.split('\n')[0].strip()
Expand All @@ -87,17 +99,20 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int:
return FAIL


def check_imperative(checks: list, commit_msg_file: str = "") -> int:
def check_imperative(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int:
"""Check if commit message uses imperative mood."""
if has_commits() is False:
return PASS # pragma: no cover
if stdin_text is None and has_commits() is False:
return PASS # pragma: no cover

check = _find_check(checks, 'imperative')
if not check:
return PASS

path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)
if stdin_text is not None:
commit_msg = stdin_text
else:
path = _ensure_msg_file(commit_msg_file)
commit_msg = read_commit_msg(path)

# Extract the subject line (first line of commit message)
subject = commit_msg.split('\n')[0].strip()
Expand Down
22 changes: 16 additions & 6 deletions commit_check/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
The module containing main entrypoint function.
"""
import argparse
import sys
from commit_check import branch
from commit_check import commit
from commit_check import author
Expand Down Expand Up @@ -111,6 +112,15 @@ def main() -> int:
if args.dry_run:
return PASS

# Capture stdin (if piped) once and pass to checks.
stdin_text = None
try:
if not sys.stdin.isatty():
data = sys.stdin.read()
stdin_text = data or None
except Exception:
stdin_text = None

check_results: list[int] = []

with error_handler():
Expand All @@ -119,19 +129,19 @@ def main() -> int:
) else DEFAULT_CONFIG
checks = config['checks']
if args.message:
check_results.append(commit.check_commit_msg(checks, args.commit_msg_file))
check_results.append(commit.check_commit_msg(checks, args.commit_msg_file, stdin_text=stdin_text))
if args.author_name:
check_results.append(author.check_author(checks, "author_name"))
check_results.append(author.check_author(checks, "author_name", stdin_text=stdin_text))
if args.author_email:
check_results.append(author.check_author(checks, "author_email"))
check_results.append(author.check_author(checks, "author_email", stdin_text=stdin_text))
if args.branch:
check_results.append(branch.check_branch(checks))
check_results.append(branch.check_branch(checks, stdin_text=stdin_text))
if args.commit_signoff:
check_results.append(commit.check_commit_signoff(checks, args.commit_msg_file))
check_results.append(commit.check_commit_signoff(checks, args.commit_msg_file, stdin_text=stdin_text))
if args.merge_base:
check_results.append(branch.check_merge_base(checks))
if args.imperative:
check_results.append(commit.check_imperative(checks, args.commit_msg_file))
check_results.append(commit.check_imperative(checks, args.commit_msg_file, stdin_text=stdin_text))

return PASS if all(val == PASS for val in check_results) else FAIL

Expand Down
6 changes: 3 additions & 3 deletions tests/commit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ def test_check_imperative_skip_merge_commit(mocker):
return_value="Merge branch 'feature/test' into main"
)

retval = check_imperative(checks, MSG_FILE)
retval = check_imperative(checks, MSG_FILE, stdin_text=None)
assert retval == PASS


Expand All @@ -357,7 +357,7 @@ def test_check_imperative_different_check_type(mocker):
return_value="feat: Added new feature"
)

retval = check_imperative(checks, MSG_FILE)
retval = check_imperative(checks, MSG_FILE, stdin_text="feat: Added new feature")
assert retval == PASS
assert m_read_commit_msg.call_count == 0

Expand Down Expand Up @@ -388,7 +388,7 @@ def test_check_imperative_empty_checks(mocker):
return_value="feat: Added new feature"
)

retval = check_imperative(checks, MSG_FILE)
retval = check_imperative(checks, MSG_FILE, stdin_text=None)
assert retval == PASS
assert m_read_commit_msg.call_count == 0

Expand Down
15 changes: 7 additions & 8 deletions tests/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,22 +162,21 @@ def test_main_multiple_checks(
)

mocker.patch(
"commit_check.commit.check_commit_msg", return_value=message_result
"commit_check.commit.check_commit_msg", return_value=message_result, stdin_text=None
)
mocker.patch(
"commit_check.commit.check_commit_signoff",
return_value=commit_signoff_result,
return_value=commit_signoff_result, stdin_text=None
)

mocker.patch("commit_check.branch.check_branch", return_value=branch_result)
mocker.patch("commit_check.branch.check_branch", return_value=branch_result, stdin_text=None)
mocker.patch(
"commit_check.branch.check_merge_base", return_value=merge_base_result
"commit_check.branch.check_merge_base", return_value=merge_base_result, stdin_text=None
)
mocker.patch("commit_check.commit.check_imperative", return_value=PASS)
mocker.patch("commit_check.commit.check_imperative", return_value=PASS, stdin_text=None)

# this is messy. why isn't this a private implementation detail with a
# public check_author_name and check_author email?
def author_side_effect(_, check_type: str) -> int: # type: ignore[return]
# Route author check results based on check_type while tolerating extra kwargs
def author_side_effect(_, check_type: str, **kwargs) -> int: # type: ignore[return]
assert check_type in ("author_name", "author_email")
if check_type == "author_name":
return author_name_result
Expand Down
Loading