Files
local-commit-message-generator/src/generator.py
7000pctAUTO c732e1cb50
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
fix: Add Gitea Actions CI workflow and fix linting issues
2026-02-04 16:59:23 +00:00

224 lines
6.1 KiB
Python

"""Commit message generation logic."""
from pathlib import Path
from typing import Optional, Tuple
from .analyzer import ChangeAnalyzer, ChangeSet, ChangeType
from .config import get_type_rules, load_config
from .templates import format_message
class GenerationError(Exception):
"""Raised when commit message generation fails."""
pass
def detect_commit_type(
change_set: ChangeSet,
type_rules: Optional[dict] = None
) -> str:
"""Detect the commit type based on staged changes.
Args:
change_set: Collection of staged changes.
type_rules: Optional custom type rules dictionary.
Returns:
Detected commit type string.
"""
if type_rules is None:
type_rules = get_type_rules()
scores = {type_: 0 for type_ in type_rules.keys()}
scores["chore"] = 0
for change in change_set.changes:
path = change.path.lower()
for type_, patterns in type_rules.items():
for pattern in patterns:
if pattern.startswith("."):
if path.endswith(pattern):
scores[type_] += 1
elif "/" in pattern or pattern.startswith("."):
if pattern in path or path.startswith(pattern):
scores[type_] += 1
else:
if pattern in path:
scores[type_] += 1
scores["chore"] = sum(1 for c in change_set.changes if c.change_type in [
ChangeType.DELETED, ChangeType.TYPE_CHANGE
])
if not scores or all(v == 0 for v in scores.values()):
return "chore"
return max(scores, key=scores.get)
def detect_scope(
change_set: ChangeSet,
scope_overrides: Optional[dict] = None
) -> str:
"""Detect the commit scope based on staged changes.
Args:
change_set: Collection of staged changes.
scope_overrides: Optional custom scope mapping.
Returns:
Detected scope string or empty string.
"""
if not change_set.has_changes:
return ""
if scope_overrides is None:
config = load_config()
scope_overrides = config.get("scopes", {})
paths = [c.path for c in change_set.changes]
common_parts = []
for parts in (p.split("/") for p in paths):
if not common_parts:
common_parts = parts
else:
common_parts = [
common_parts[i] if (
i < len(common_parts) and
i < len(parts) and
common_parts[i] == parts[i]
) else None
for i in range(min(len(common_parts), len(parts)))
]
common_parts = [p for p in common_parts if p is not None]
if len(common_parts) <= 1:
candidates = set()
for path in paths:
parts = path.split("/")
if len(parts) > 1:
candidates.add(parts[0])
for path in paths:
for scope, match in scope_overrides.items():
if match in path:
return scope
if len(candidates) == 1:
return candidates.pop()
if len(candidates) > 1:
return ",".join(sorted(candidates)[:2])
return ""
scope = common_parts[0] if common_parts else ""
return scope
def generate_description(change_set: ChangeSet) -> str:
"""Generate a description based on changes.
Args:
change_set: Collection of staged changes.
Returns:
Generated description string.
"""
if not change_set.has_changes:
return "update"
added = change_set.added
deleted = change_set.deleted
modified = change_set.modified
if deleted:
return f"remove {Path(deleted[0].path).name}"
elif added and len(added) == 1:
return f"add {Path(added[0].path).name}"
elif modified and len(modified) == 1:
return f"update {Path(modified[0].path).name}"
elif added:
return f"add {len(added)} files"
elif modified:
return f"update {len(modified)} files"
else:
return "update"
def generate_commit_message(
repo_path: Optional[str] = None,
config_path: Optional[str] = None
) -> str:
"""Generate a conventional commit message.
Args:
repo_path: Optional path to git repository.
config_path: Optional path to configuration file.
Returns:
Generated commit message string.
Raises:
GenerationError: If generation fails.
"""
try:
analyzer = ChangeAnalyzer(repo_path)
change_set = analyzer.get_staged_changes()
if not change_set.has_changes:
raise GenerationError("No staged changes found. Run 'git add' first.")
config = load_config() if config_path is None else load_config(config_path)
type_rules = config.get("type_rules", {})
template = config.get("template")
scope_overrides = config.get("scopes", {})
commit_type = detect_commit_type(change_set, type_rules)
scope = detect_scope(change_set, scope_overrides)
description = generate_description(change_set)
files = change_set.file_paths if config.get("include_file_list", True) else None
max_files = config.get("max_files", 5)
if files and len(files) > max_files:
files = files[:max_files]
message = format_message(
type=commit_type,
scope=scope,
description=description,
template=template,
files=files
)
return message
except ValueError as e:
raise GenerationError(str(e))
def get_commit_message_preview(
repo_path: Optional[str] = None
) -> Tuple[str, bool]:
"""Get a preview of the commit message.
Args:
repo_path: Optional path to git repository.
Returns:
Tuple of (message, has_changes).
"""
try:
analyzer = ChangeAnalyzer(repo_path)
change_set = analyzer.get_staged_changes()
if not change_set.has_changes:
return ("", False)
message = generate_commit_message(repo_path)
return (message, True)
except GenerationError:
return ("", False)