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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,19 @@ jobs:
with:
python-version: "3.12"

- name: Install ruff
- name: Install lint and typecheck tools
run: |
python -m pip install --upgrade pip
pip install ruff
pip install ruff mypy

- name: Run ruff check
run: |
ruff check .

- name: Run ruff format check
run: |
ruff format --check .

- name: Run mypy
run: |
mypy --strict prepare_commit_msg_hooks
60 changes: 33 additions & 27 deletions prepare_commit_msg_hooks/ai_commit_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ def generate_commit_message() -> str | None:

api_key = config.cfg_get("api_key")
if not api_key:
config._error("API key not configured. Set AI_COMMIT_API_KEY env var or add 'api_key' to .ai-commit.json")
config._error(
"API key not configured. Set AI_COMMIT_API_KEY env var or add 'api_key' to .ai-commit.json"
)
config._error("Project config: .ai-commit.json | Global config: ~/.ai-commit.json")
return None

Expand Down Expand Up @@ -67,26 +69,34 @@ def main(argv: list[str] | None = None) -> int:
description="Generate AI-powered Conventional Commits messages for pre-commit prepare-commit-msg hook",
)
parser.add_argument(
'commit_msg_file',
nargs='?',
default='',
help='Path to the commit message file (COMMIT_EDITMSG)',
"commit_msg_file",
nargs="?",
default="",
help="Path to the commit message file (COMMIT_EDITMSG)",
)
parser.add_argument(
'commit_source',
nargs='?',
default='',
help='Commit source: message, merge, squash, amend, or empty',
"commit_source",
nargs="?",
default="",
help="Commit source: message, template, merge, squash, commit, amend, or empty",
)
parser.add_argument(
"commit_sha",
nargs="?",
default="",
help="Commit SHA-1 (passed by git for commit/amend)",
)
args = parser.parse_args(argv)

config.load_config()

commit_msg_file = args.commit_msg_file
commit_source = args.commit_source

if not commit_msg_file:
commit_msg_file = git_util.find_commit_msg_file()
if not commit_msg_file:
config._error("Usage: ai-commit-gen <commit-msg-file> [commit-source]")
config._error("Usage: ai-commit-gen <commit-msg-file> [commit-source] [commit-sha]")
config._error("Auto-detection failed - no COMMIT_EDITMSG found")
return 1
config._debug(f"Auto-detected commit msg file: {commit_msg_file}")
Expand All @@ -97,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
config._error(f"Commit message file not found: {commit_msg_file}")
return 1

if commit_source in ("merge", "squash", "amend"):
if commit_source in ("merge", "squash", "commit", "amend"):
config._debug(f"Skipping for {commit_source} commit")
return 0

Expand All @@ -112,26 +122,22 @@ def main(argv: list[str] | None = None) -> int:
except OSError:
existing_msg = ""

existing_lines = [
line for line in existing_msg.splitlines()
if not line.startswith("#")
]
existing_msg_clean = "\n".join(existing_lines).strip()
existing_msg_clean, comment_block = git_util.parse_commit_editmsg(existing_msg)

if existing_msg_clean and commit_source == "message":
combined = f"{existing_msg_clean}\n\n{ai_msg}"
with open(commit_msg_file, "w", encoding="utf-8") as f:
f.write(combined + "\n")
config._log("AI message appended after your -m message")
final_msg = f"{existing_msg_clean}\n\n{ai_msg}"
log_msg = "AI message appended after your -m message"
elif existing_msg_clean and not commit_source:
combined = f"{existing_msg_clean}\n\n{ai_msg}"
with open(commit_msg_file, "w", encoding="utf-8") as f:
f.write(combined + "\n")
config._log("AI message appended after existing message")
final_msg = f"{existing_msg_clean}\n\n{ai_msg}"
log_msg = "AI message appended after existing message"
else:
with open(commit_msg_file, "w", encoding="utf-8") as f:
f.write(ai_msg + "\n")
config._log("AI message generated (edit in editor to adjust)")
final_msg = ai_msg
log_msg = "AI message generated (edit in editor to adjust)"

output = f"{final_msg}\n\n{comment_block}\n" if comment_block else f"{final_msg}\n"
with open(commit_msg_file, "w", encoding="utf-8") as f:
f.write(output)
config._log(log_msg)

return 0

Expand Down
42 changes: 29 additions & 13 deletions prepare_commit_msg_hooks/util/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,22 @@ def _get_lang_instruction() -> str:
return config.LANG_INSTRUCTIONS.get(lang, f"Write the commit message in {lang}.")


def _clean_commit_message(raw_text: str) -> str:
cleaned = re.sub(r"<think>.*?</think>", "", raw_text, flags=re.DOTALL | re.IGNORECASE)
if "</think>" in cleaned.lower():
cleaned = re.split(r"</think>", cleaned, flags=re.IGNORECASE)[-1]
cleaned = cleaned.strip()
cleaned = re.sub(r"^```\w*\n?", "", cleaned)
cleaned = re.sub(r"\n?```$", "", cleaned)
return cleaned.strip()


def call_ai_api(diff_content: str, file_summary: str) -> str:
api_key = config.cfg_get("api_key")
api_base_url = config.cfg_get("api_base_url")
model = config.cfg_get("model")
api_key = str(config.cfg_get("api_key") or "")
api_base_url = str(config.cfg_get("api_base_url") or "")
model = str(config.cfg_get("model") or "")
max_output_tokens = config.cfg_get("max_output_tokens")
thinking = config.cfg_get("thinking")
thinking = bool(config.cfg_get("thinking"))

branch_name = git_util.get_branch_name()
lang_instruction = _get_lang_instruction()
Expand All @@ -40,7 +50,7 @@ def call_ai_api(diff_content: str, file_summary: str) -> str:
)
user_prompt = "\n".join(user_prompt_parts)

payload = {
payload: dict[str, object] = {
"model": model,
"messages": [
{"role": "system", "content": config.SYSTEM_PROMPT},
Expand All @@ -49,10 +59,12 @@ def call_ai_api(diff_content: str, file_summary: str) -> str:
"max_tokens": max_output_tokens,
"temperature": 0.3,
}
if thinking:
is_deepseek_api = "api.deepseek.com" in api_base_url.lower()
is_deepseek_model = "deepseek" in model.lower()
if is_deepseek_api:
payload["thinking"] = {"type": "enabled" if thinking else "disabled"}
elif thinking and is_deepseek_model:
payload["thinking"] = {"type": "enabled"}
elif "deepseek" in api_base_url.lower() or "deepseek" in model.lower():
payload["thinking"] = {"type": "disabled"}

request_body = json.dumps(payload)

Expand All @@ -68,6 +80,7 @@ def call_ai_api(diff_content: str, file_summary: str) -> str:
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "ai-commit/1.0",
},
method="POST",
)
Expand All @@ -93,14 +106,17 @@ def call_ai_api(diff_content: str, file_summary: str) -> str:
raise

data = json.loads(body)
commit_msg = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
message = (data.get("choices") or [{}])[0].get("message") or {}
raw_content = message.get("content")
commit_msg = _clean_commit_message(raw_content) if isinstance(raw_content, str) else ""

if not commit_msg:
raw_reasoning = message.get("reasoning_content")
if isinstance(raw_reasoning, str):
commit_msg = _clean_commit_message(raw_reasoning)

if not commit_msg:
config._error("Empty response from AI API")
raise ValueError("Empty AI response")

commit_msg = re.sub(r"^```\w*\n?", "", commit_msg)
commit_msg = re.sub(r"\n?```$", "", commit_msg)
commit_msg = commit_msg.strip()

return commit_msg
39 changes: 29 additions & 10 deletions prepare_commit_msg_hooks/util/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
import os
import subprocess
import sys
from typing import Any

from .. import __version__ as __version__

DEFAULTS = {
DEFAULTS: dict[str, Any] = {
"api_key": "",
"api_base_url": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
Expand All @@ -29,6 +30,17 @@
"thinking": False,
}

DEFAULT_IGNORE_PATTERNS: list[str] = [
"uv.lock",
"package-lock.json",
"pnpm-lock.json",
"pnpm-lock.yaml",
"Cargo.lock",
"poetry.lock",
"*.min.js",
"*.min.css",
]

ENV_MAP = {
"api_key": "AI_COMMIT_API_KEY",
"api_base_url": "AI_COMMIT_API_BASE_URL",
Expand Down Expand Up @@ -67,10 +79,10 @@
8. If multiple types of changes, use the most significant one as the main type"""


_cfg: dict = {}
_cfg: dict[str, Any] = {}


def cfg_get(key: str):
def cfg_get(key: str) -> Any:
return _cfg.get(key, DEFAULTS.get(key))


Expand All @@ -87,7 +99,7 @@ def _error(msg: str) -> None:
print(f"[ai-commit ERROR] {msg}", file=sys.stderr)


def _coerce_type(key: str, raw):
def _coerce_type(key: str, raw: Any) -> Any:
if key in ("max_diff_lines", "max_output_tokens"):
try:
return int(raw)
Expand Down Expand Up @@ -115,6 +127,10 @@ def load_config() -> None:
global _cfg
_cfg = dict(DEFAULTS)

debug_env = os.environ.get(ENV_MAP["debug"])
if debug_env is not None:
_cfg["debug"] = _coerce_type("debug", debug_env)

project_root = _git_root()
candidates = [
os.path.join(os.path.expanduser("~"), ".ai-commit.json"),
Expand All @@ -126,9 +142,12 @@ def load_config() -> None:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
for k, v in data.items():
if k in _cfg:
_cfg[k] = _coerce_type(k, v)
if isinstance(data, dict):
for k, v in data.items():
if k in _cfg:
_cfg[k] = _coerce_type(k, v)
else:
_debug(f"Ignoring {path}: root JSON value must be an object")
except (json.JSONDecodeError, OSError) as exc:
_debug(f"Failed to read {path}: {exc}")

Expand All @@ -150,13 +169,13 @@ def load_config() -> None:
def read_ignore_patterns() -> list[str]:
project_root = _git_root()
ignore_path = os.path.join(project_root, cfg_get("ignore_file"))
patterns: list[str] = []
patterns: list[str] = list(DEFAULT_IGNORE_PATTERNS)

if os.path.isfile(ignore_path):
with open(ignore_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
line = line.rstrip("\r\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
patterns.append(line)

Expand Down
Loading
Loading