From 3b4e8073b098e22ae07fe14db540796fe39c95f0 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Wed, 14 Oct 2026 04:18:17 +0200 Subject: [PATCH] fix(hook): handle amend 3-arg CLI, verbose scissors, quoted paths, sequential gitignore, and API compatibility --- .github/workflows/ci.yml | 12 +- prepare_commit_msg_hooks/ai_commit_gen.py | 60 ++--- prepare_commit_msg_hooks/util/api.py | 42 +++- prepare_commit_msg_hooks/util/config.py | 39 ++- .../util/diff_processor.py | 163 ++++++++++--- prepare_commit_msg_hooks/util/git_util.py | 59 +++-- prepare_commit_msg_hooks/util/gitignore.py | 123 +++++----- pyproject.toml | 8 + setup.py | 1 - tests/test_ai_commit_gen.py | 228 +++++++++++++++++- 10 files changed, 580 insertions(+), 155 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 390aab4..11706b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/prepare_commit_msg_hooks/ai_commit_gen.py b/prepare_commit_msg_hooks/ai_commit_gen.py index 4ea4afe..16f609f 100644 --- a/prepare_commit_msg_hooks/ai_commit_gen.py +++ b/prepare_commit_msg_hooks/ai_commit_gen.py @@ -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 @@ -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-source]") + config._error("Usage: ai-commit-gen [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}") @@ -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 @@ -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 diff --git a/prepare_commit_msg_hooks/util/api.py b/prepare_commit_msg_hooks/util/api.py index 58a4ba6..9d62462 100644 --- a/prepare_commit_msg_hooks/util/api.py +++ b/prepare_commit_msg_hooks/util/api.py @@ -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".*?", "", raw_text, flags=re.DOTALL | re.IGNORECASE) + if "" in cleaned.lower(): + cleaned = re.split(r"", 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() @@ -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}, @@ -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) @@ -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", ) @@ -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 diff --git a/prepare_commit_msg_hooks/util/config.py b/prepare_commit_msg_hooks/util/config.py index 88d42b7..5290ec2 100644 --- a/prepare_commit_msg_hooks/util/config.py +++ b/prepare_commit_msg_hooks/util/config.py @@ -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", @@ -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", @@ -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)) @@ -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) @@ -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"), @@ -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}") @@ -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) diff --git a/prepare_commit_msg_hooks/util/diff_processor.py b/prepare_commit_msg_hooks/util/diff_processor.py index a04d80d..72abd5f 100644 --- a/prepare_commit_msg_hooks/util/diff_processor.py +++ b/prepare_commit_msg_hooks/util/diff_processor.py @@ -10,49 +10,148 @@ from . import config, gitignore - -def abbreviate_diff(diff: str, max_lines: int) -> str: - result_parts: list[str] = [] +MAX_LINE_LENGTH = 1000 +MAX_TOTAL_CHARS = 40000 + +_DIFF_HEADER_RE = re.compile( + r'^diff --git (?:a/(.*?)|"a/((?:[^"\\]|\\.)*)") (?:b/(.*)|"b/((?:[^"\\]|\\.)*)")$' +) + +_C_ESCAPES: dict[str, int] = { + "a": 0x07, + "b": 0x08, + "f": 0x0C, + "n": 0x0A, + "r": 0x0D, + "t": 0x09, + "v": 0x0B, + "\\": ord("\\"), + '"': ord('"'), +} + + +def _unquote_git_path(raw: str) -> str: + """Unescape C-style quoted Git paths (including octal UTF-8 byte sequences).""" + if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2: + raw = raw[1:-1] + buf = bytearray() + i = 0 + n = len(raw) + while i < n: + ch = raw[i] + if ch == "\\" and i + 1 < n: + octal = raw[i + 1 : i + 4] + if len(octal) == 3 and all(c in "01234567" for c in octal): + buf.append(int(octal, 8) & 0xFF) + i += 4 + continue + nxt = raw[i + 1] + if nxt in _C_ESCAPES: + buf.append(_C_ESCAPES[nxt]) + else: + buf.extend(nxt.encode("utf-8")) + i += 2 + else: + buf.extend(ch.encode("utf-8")) + i += 1 + return buf.decode("utf-8", errors="replace") + + +def _parse_diff_git_header(line: str) -> tuple[str, str] | None: + m = _DIFF_HEADER_RE.match(line) + if not m: + return None + a_unquoted, a_quoted, b_unquoted, b_quoted = m.groups() + a_path = _unquote_git_path(a_quoted) if a_quoted is not None else (a_unquoted or "") + b_path = _unquote_git_path(b_quoted) if b_quoted is not None else (b_unquoted or "") + return a_path, b_path + + +def _format_abbreviated_file( + current_file: str, + current_lines: list[str], + line_count: int, + reason: str, +) -> str: + added = sum(1 for item in current_lines if item.startswith("+") and not item.startswith("+++")) + removed = sum( + 1 for item in current_lines if item.startswith("-") and not item.startswith("---") + ) + return "\n".join( + [ + f"--- a/{current_file}", + f"+++ b/{current_file}", + f"@@ ... @@ (abbreviated: +{added}/-{removed} lines, {reason})", + ] + ) + + +def abbreviate_diff( + diff: str, + max_lines: int, + max_total_chars: int = MAX_TOTAL_CHARS, + max_line_length: int = MAX_LINE_LENGTH, +) -> str: + file_entries: list[tuple[str, list[str], int]] = [] current_file = "" current_lines: list[str] = [] line_count = 0 for line in diff.splitlines(): - m = re.match(r"^diff --git a/(.*) b/(.*)", line) - if m: + parsed = _parse_diff_git_header(line) + if parsed is not None: if current_file and current_lines: - if line_count > max_lines: - added = sum(1 for item in current_lines if item.startswith("+") and not item.startswith("+++")) - removed = sum(1 for item in current_lines if item.startswith("-") and not item.startswith("---")) - result_parts.append(f"--- a/{current_file}") - result_parts.append(f"+++ b/{current_file}") - result_parts.append( - f"@@ ... @@ (abbreviated: +{added}/-{removed} lines, " - f"total {line_count} lines exceed threshold {max_lines})" - ) - else: - result_parts.extend(current_lines) - - current_file = m.group(2) + file_entries.append((current_file, current_lines, line_count)) + current_file = parsed[1] current_lines = [line] line_count = 0 else: - current_lines.append(line) if line.startswith(("+", "-")) and not line.startswith(("+++", "---")): line_count += 1 + if len(line) > max_line_length: + orig_len = len(line) + line = f"{line[:max_line_length]}... (line truncated, {orig_len} chars)" + current_lines.append(line) if current_file and current_lines: - if line_count > max_lines: - added = sum(1 for item in current_lines if item.startswith("+") and not item.startswith("+++")) - removed = sum(1 for item in current_lines if item.startswith("-") and not item.startswith("---")) - result_parts.append(f"--- a/{current_file}") - result_parts.append(f"+++ b/{current_file}") - result_parts.append( - f"@@ ... @@ (abbreviated: +{added}/-{removed} lines, " - f"total {line_count} lines exceed threshold {max_lines})" + file_entries.append((current_file, current_lines, line_count)) + + result_parts: list[str] = [] + total_chars = 0 + + for filepath, lines, count in file_entries: + if count > max_lines: + block = _format_abbreviated_file( + filepath, + lines, + count, + f"total {count} lines exceed threshold {max_lines}", ) else: - result_parts.extend(current_lines) + block = "\n".join(lines) + + sep_len = 1 if result_parts else 0 + if total_chars + sep_len + len(block) > max_total_chars: + abbrev_block = _format_abbreviated_file( + filepath, + lines, + count, + f"total diff budget {max_total_chars} chars exceeded", + ) + if total_chars + sep_len + len(abbrev_block) <= max_total_chars: + result_parts.append(abbrev_block) + total_chars += sep_len + len(abbrev_block) + else: + remaining = max_total_chars - total_chars - sep_len + notice = "... (diff truncated due to max_total_chars budget)" + if remaining >= len(notice): + result_parts.append(notice) + elif not result_parts and max_total_chars > 0: + result_parts.append(block[:max_total_chars]) + break + else: + result_parts.append(block) + total_chars += sep_len + len(block) return "\n".join(result_parts) @@ -64,12 +163,12 @@ def filter_diff_by_ignore(diff: str, spec: gitignore.GitIgnoreSpec) -> str: current_lines: list[str] = [] for line in diff.splitlines(): - m = re.match(r"^diff --git a/(.*) b/(.*)", line) - if m: + parsed = _parse_diff_git_header(line) + if parsed is not None: if current_file and not skip and current_lines: result_parts.extend(current_lines) - current_file = m.group(2) + current_file = parsed[1] skip = spec.is_ignored(current_file) if skip: config._debug(f"Ignoring file: {current_file}") @@ -96,6 +195,8 @@ def build_file_summary(stats: str, spec: gitignore.GitIgnoreSpec) -> str: if len(parts) != 3: continue added_s, removed_s, filepath = parts + if filepath.startswith('"') and filepath.endswith('"'): + filepath = _unquote_git_path(filepath) if spec.is_ignored(filepath): continue diff --git a/prepare_commit_msg_hooks/util/git_util.py b/prepare_commit_msg_hooks/util/git_util.py index e5cd94b..eaaabcc 100644 --- a/prepare_commit_msg_hooks/util/git_util.py +++ b/prepare_commit_msg_hooks/util/git_util.py @@ -9,6 +9,8 @@ import os import subprocess +SCISSORS_LINE = "# ------------------------ >8 ------------------------" + class GitError(RuntimeError): pass @@ -19,11 +21,13 @@ def cmd_output(*cmd: str, retcode: int | None = 0) -> str: cmd, capture_output=True, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", ) if retcode is not None and proc.returncode != retcode: - raise GitError(f"Command '{' '.join(cmd)}' returned {proc.returncode}: {proc.stderr.strip()}") + raise GitError( + f"Command '{' '.join(cmd)}' returned {proc.returncode}: {proc.stderr.strip()}" + ) return proc.stdout @@ -33,8 +37,8 @@ def git_root() -> str: ["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", ).strip() except (subprocess.CalledProcessError, FileNotFoundError): return "." @@ -46,8 +50,8 @@ def get_staged_diff() -> str: ["git", "diff", "--cached", "--diff-filter=ACMR", "--unified=3"], stderr=subprocess.DEVNULL, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", ).strip() except (subprocess.CalledProcessError, FileNotFoundError): return "" @@ -59,8 +63,8 @@ def get_staged_file_stats() -> str: ["git", "diff", "--cached", "--numstat"], stderr=subprocess.DEVNULL, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", ).strip() except (subprocess.CalledProcessError, FileNotFoundError): return "" @@ -72,15 +76,15 @@ def get_branch_name() -> str: ["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", ).strip() except (subprocess.CalledProcessError, FileNotFoundError): return "unknown" def added_files() -> set[str]: - return set(cmd_output('git', 'diff', '--staged', '--name-only', '--diff-filter=A').splitlines()) + return set(cmd_output("git", "diff", "--staged", "--name-only", "--diff-filter=A").splitlines()) def find_commit_msg_file() -> str: @@ -88,7 +92,10 @@ def find_commit_msg_file() -> str: try: git_dir = subprocess.check_output( ["git", "rev-parse", "--git-dir"], - stderr=subprocess.DEVNULL, text=True, encoding='utf-8', errors='replace', + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", ).strip() if git_dir: candidates.insert(0, os.path.join(git_dir, "COMMIT_EDITMSG")) @@ -100,11 +107,35 @@ def find_commit_msg_file() -> str: return "" +def parse_commit_editmsg(content: str) -> tuple[str, str]: + """ + Split COMMIT_EDITMSG content into (clean_user_message, comment_and_scissors_block). + + Any line starting with '#' and everything at or below the Git scissors line + ('# ------------------------ >8 ------------------------') are treated as the + trailing Git comment/verbose-diff block rather than user message content. + """ + user_lines: list[str] = [] + comment_lines: list[str] = [] + in_scissors = False + + for line in content.splitlines(): + if not in_scissors and line.strip() == SCISSORS_LINE: + in_scissors = True + if in_scissors or line.startswith("#"): + comment_lines.append(line) + else: + user_lines.append(line) + + return "\n".join(user_lines).strip(), "\n".join(comment_lines) + + def detect_commit_source(commit_msg_file: str) -> str: try: with open(commit_msg_file, "r", encoding="utf-8") as f: content = f.read() - if content.strip(): + user_msg, _ = parse_commit_editmsg(content) + if user_msg: return "message" except OSError: pass diff --git a/prepare_commit_msg_hooks/util/gitignore.py b/prepare_commit_msg_hooks/util/gitignore.py index a873d43..9bba685 100644 --- a/prepare_commit_msg_hooks/util/gitignore.py +++ b/prepare_commit_msg_hooks/util/gitignore.py @@ -21,35 +21,43 @@ def _glob_segment_to_regex(segment: str) -> str: while i < n: ch = segment[i] - if ch == '*': - if i + 1 < n and segment[i + 1] == '*': + if ch == "*": + if i + 1 < n and segment[i + 1] == "*": j = i + 2 - while j < n and segment[j] == '*': + while j < n and segment[j] == "*": j += 1 i = j - result.append('.*') + result.append(".*") else: - result.append('[^/]*') + result.append("[^/]*") i += 1 - elif ch == '?': - result.append('[^/]') + elif ch == "?": + result.append("[^/]") i += 1 - elif ch == '[': + elif ch == "\\": + if i + 1 < n: + result.append(re.escape(segment[i + 1])) + i += 2 + else: + result.append(re.escape("\\")) + i += 1 + + elif ch == "[": j = i + 1 - if j < n and segment[j] == ']': + if j < n and segment[j] == "]": j += 1 - while j < n and segment[j] != ']': + while j < n and segment[j] != "]": j += 1 if j >= n: - result.append(re.escape('[')) + result.append(re.escape("[")) i += 1 else: - bracket_expr = segment[i:j + 1] - negated = len(bracket_expr) >= 3 and bracket_expr[1] == '!' + bracket_expr = segment[i : j + 1] + negated = len(bracket_expr) >= 3 and bracket_expr[1] == "!" inner = bracket_expr[1:-1] - if inner == '!': + if inner == "!": result.append(re.escape(bracket_expr)) else: if negated: @@ -57,27 +65,42 @@ def _glob_segment_to_regex(segment: str) -> str: prefix = "^/" else: prefix = "" - if '/' in inner: - inner_expr = '' + if "/" in inner: + inner_expr = "" k = 0 while k < len(inner): - if inner[k] == '/': - inner_expr += '/' - elif k + 2 < len(inner) and inner[k] == '-' and inner[k + 1] != ']': + if inner[k] == "/": + inner_expr += "/" + elif k + 2 < len(inner) and inner[k] == "-" and inner[k + 1] != "]": inner_expr += inner[k] else: inner_expr += re.escape(inner[k]) k += 1 - result.append(f'[{prefix}{"".join(inner_expr)}]') + result.append(f"[{prefix}{''.join(inner_expr)}]") else: - result.append(f'[{prefix}{inner}]') + result.append(f"[{prefix}{inner}]") i = j + 1 else: result.append(re.escape(ch)) i += 1 - return ''.join(result) + return "".join(result) + + +def _strip_unescaped_trailing_spaces(pattern: str) -> str: + pattern = pattern.rstrip("\r\n") + end = len(pattern) + while end > 0 and pattern[end - 1] in (" ", "\t"): + backslashes = 0 + k = end - 1 + while k > 0 and pattern[k - 1] == "\\": + backslashes += 1 + k -= 1 + if backslashes % 2 == 1: + break + end -= 1 + return pattern[:end].lstrip() def _pattern_to_regex(pattern: str) -> tuple[str, bool, bool] | None: @@ -86,62 +109,59 @@ def _pattern_to_regex(pattern: str) -> tuple[str, bool, bool] | None: Returns None if the pattern should be skipped (blank, comment, single '/'). """ - if pattern.endswith('\\ '): - pattern = pattern.lstrip() - else: - pattern = pattern.strip() + pattern = _strip_unescaped_trailing_spaces(pattern) - if not pattern or pattern.startswith('#'): + if not pattern or pattern.startswith("#"): return None - if pattern == '/': + if pattern == "/": return None - is_negated = pattern.startswith('!') + is_negated = pattern.startswith("!") if is_negated: pattern = pattern[1:] - is_dir_only = pattern.endswith('/') + is_dir_only = pattern.endswith("/") if is_dir_only: pattern = pattern[:-1] - is_rooted = pattern.startswith('/') + is_rooted = pattern.startswith("/") if is_rooted: pattern = pattern[1:] - has_slash = '/' in pattern + has_slash = "/" in pattern if has_slash or is_rooted: - segments = pattern.split('/') + segments = pattern.split("/") else: - segments = ['**', pattern] + segments = ["**", pattern] - regex_parts: list[str] = ['^'] + regex_parts: list[str] = ["^"] skip_next_slash = False for idx, seg in enumerate(segments): if not skip_next_slash and idx > 0: - regex_parts.append('/') + regex_parts.append("/") skip_next_slash = False - if seg == '**': + if seg == "**": if idx == 0: - regex_parts.append('(?:.*/)*') + regex_parts.append("(?:.*/)*") skip_next_slash = True elif idx == len(segments) - 1: - regex_parts.append('.*') + regex_parts.append(".*") else: - regex_parts.append('(?:.*/)*') + regex_parts.append("(?:.*/)*") skip_next_slash = True else: regex_parts.append(_glob_segment_to_regex(seg)) if is_dir_only: - regex_parts.append('(?:/.*)?$') + regex_parts.append("(?:/.*)?$") else: - regex_parts.append('$') + regex_parts.append("$") - return ''.join(regex_parts), is_negated, is_dir_only + return "".join(regex_parts), is_negated, is_dir_only class GitIgnoreSpec: @@ -150,8 +170,7 @@ class GitIgnoreSpec: """ def __init__(self, patterns: Iterable[str]) -> None: - self._include: list[re.Pattern] = [] - self._exclude: list[re.Pattern] = [] + self._rules: list[tuple[re.Pattern[str], bool]] = [] for pat in patterns: result = _pattern_to_regex(pat) @@ -159,21 +178,13 @@ def __init__(self, patterns: Iterable[str]) -> None: continue regex_str, is_negated, _ = result compiled = re.compile(regex_str) - if is_negated: - self._include.append(compiled) - else: - self._exclude.append(compiled) + self._rules.append((compiled, is_negated)) def is_ignored(self, filepath: str) -> bool: ignored = False - for pattern in self._exclude: - if pattern.match(filepath): - ignored = True - break - for pattern in self._include: + for pattern, is_negated in self._rules: if pattern.match(filepath): - ignored = False - break + ignored = not is_negated return ignored diff --git a/pyproject.toml b/pyproject.toml index 350325a..ff4cd6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,14 @@ ai-commit-gen = "prepare_commit_msg_hooks.ai_commit_gen:main" dev = [ "pytest>=8.0.0", "ruff>=0.9.0", + "mypy>=1.10.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.9", + "mypy>=1.10", ] [tool.setuptools.dynamic] diff --git a/setup.py b/setup.py index d29800d..a03590f 100644 --- a/setup.py +++ b/setup.py @@ -3,4 +3,3 @@ from setuptools import setup setup() - diff --git a/tests/test_ai_commit_gen.py b/tests/test_ai_commit_gen.py index bbce422..9cbf2e0 100644 --- a/tests/test_ai_commit_gen.py +++ b/tests/test_ai_commit_gen.py @@ -2,14 +2,18 @@ from __future__ import annotations +import io +import json import os import sys import tempfile -from unittest.mock import patch +from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from prepare_commit_msg_hooks import ai_commit_gen from prepare_commit_msg_hooks.util import ( + api, config, diff_processor, git_util, @@ -61,6 +65,23 @@ def test_negation(self): assert spec.is_ignored("error.log") is True assert spec.is_ignored("important.log") is False + def test_sequential_negation_reignore(self): + spec = gitignore.compile_patterns(["*.log", "!important.log", "important.log"]) + assert spec.is_ignored("important.log") is True + + spec2 = gitignore.compile_patterns(["!important.log", "*.log"]) + assert spec2.is_ignored("important.log") is True + + spec3 = gitignore.compile_patterns(["logs/*", "!logs/keep.log", "logs/secret.log"]) + assert spec3.is_ignored("logs/other.log") is True + assert spec3.is_ignored("logs/keep.log") is False + assert spec3.is_ignored("logs/secret.log") is True + + def test_escaped_trailing_space(self): + spec = gitignore.compile_patterns([r"trailing\ "]) + assert spec.is_ignored("trailing ") is True + assert spec.is_ignored("trailing") is False + def test_anchored_pattern(self): spec = gitignore.compile_patterns(["/src/test.py"]) assert spec.is_ignored("src/test.py") is True @@ -99,6 +120,24 @@ def test_large_diff_abbreviated(self): assert "abbreviated" in result assert "+2000" in result + def test_long_single_line_truncated(self): + long_line = "+" + ("x" * 2500) + diff = f"diff --git a/bundle.js b/bundle.js\n--- a/bundle.js\n+++ b/bundle.js\n@@ -1 +1 @@\n{long_line}" + result = diff_processor.abbreviate_diff(diff, 1000) + assert "line truncated" in result + assert len(result) < 1500 + + def test_global_total_chars_budget(self): + parts = [] + for idx in range(10): + parts.append(f"diff --git a/f{idx}.py b/f{idx}.py") + for line_no in range(50): + parts.append(f"+file_{idx}_line_{line_no}_{'a' * 60}") + diff = "\n".join(parts) + result = diff_processor.abbreviate_diff(diff, 1000, max_total_chars=5000) + assert len(result) <= 5000 + assert "abbreviated" in result + class TestFilterDiffByIgnore: def test_filters_ignored_file(self): @@ -118,6 +157,28 @@ def test_no_patterns_returns_all(self): spec = gitignore.compile_patterns([]) assert diff_processor.filter_diff_by_ignore(diff, spec) == diff + def test_quoted_space_and_octal_non_ascii_paths(self): + # "\344\270\255\346\226\207.md" is Git's octal C-escaped representation of "中文.md" + diff = ( + 'diff --git "a/docs/my notes.md" "b/docs/my notes.md"\n' + '--- "a/docs/my notes.md"\n' + '+++ "b/docs/my notes.md"\n' + "@@ -1 +1 @@\n-old\n+new\n" + 'diff --git "a/docs/\\344\\270\\255\\346\\226\\207.md" "b/docs/\\344\\270\\255\\346\\226\\207.md"\n' + '--- "a/docs/\\344\\270\\255\\346\\226\\207.md"\n' + '+++ "b/docs/\\344\\270\\255\\346\\226\\207.md"\n' + "@@ -1 +1 @@\n-old\n+new\n" + 'diff --git "a/src/keep file.py" "b/src/keep file.py"\n' + '--- "a/src/keep file.py"\n' + '+++ "b/src/keep file.py"\n' + "@@ -1 +1 @@\n-old\n+new" + ) + spec = gitignore.compile_patterns(["*.md"]) + result = diff_processor.filter_diff_by_ignore(diff, spec) + assert "my notes.md" not in result + assert "344" not in result + assert "keep file.py" in result + class TestBuildFileSummary: def test_basic_summary(self, monkeypatch): @@ -173,3 +234,168 @@ def test_empty_file(self): result = git_util.detect_commit_source(f.name) os.unlink(f.name) assert result == "" + + def test_comments_and_scissors_only_not_detected_as_message(self, tmp_path): + msg_file = tmp_path / "COMMIT_EDITMSG" + msg_file.write_text( + "\n# Please enter the commit message\n" + "# ------------------------ >8 ------------------------\n" + "diff --git a/foo.py b/foo.py\n" + "+added line\n", + encoding="utf-8", + ) + assert git_util.detect_commit_source(str(msg_file)) == "" + + +class TestMainHookBehavior: + def test_amend_three_args_skips_without_crash_or_overwrite(self, tmp_path): + msg_file = tmp_path / "COMMIT_EDITMSG" + original_content = "feat: existing commit message to amend\n" + msg_file.write_text(original_content, encoding="utf-8") + + with patch("prepare_commit_msg_hooks.ai_commit_gen.generate_commit_message") as mock_gen: + ret = ai_commit_gen.main([str(msg_file), "commit", "HEAD"]) + assert ret == 0 + mock_gen.assert_not_called() + + assert msg_file.read_text(encoding="utf-8") == original_content + + def test_verbose_commit_scissors_preserved_without_diff_leak(self, tmp_path): + msg_file = tmp_path / "COMMIT_EDITMSG" + verbose_template = ( + "\n" + "# Please enter the commit message for your changes.\n" + "# On branch main\n" + "# ------------------------ >8 ------------------------\n" + "# Do not modify or remove the line above.\n" + "diff --git a/app.py b/app.py\n" + "--- a/app.py\n" + "+++ b/app.py\n" + "@@ -1 +1,2 @@\n" + " print('hello')\n" + "+print('world')\n" + ) + msg_file.write_text(verbose_template, encoding="utf-8") + + with patch( + "prepare_commit_msg_hooks.ai_commit_gen.generate_commit_message", + return_value="feat(app): add world greeting", + ): + ret = ai_commit_gen.main([str(msg_file)]) + assert ret == 0 + + written = msg_file.read_text(encoding="utf-8") + user_msg, trailer = git_util.parse_commit_editmsg(written) + assert user_msg == "feat(app): add world greeting" + assert "diff --git" not in user_msg + assert "# ------------------------ >8 ------------------------" in trailer + assert "+print('world')" in trailer + + +class TestApiThinkStrippingAndHeaders: + def test_strips_think_tags_and_sends_user_agent(self, monkeypatch): + monkeypatch.setitem(config._cfg, "api_key", "test-key") + monkeypatch.setitem(config._cfg, "api_base_url", "https://api.openai.com/v1") + monkeypatch.setitem(config._cfg, "model", "deepseek-r1") + monkeypatch.setitem(config._cfg, "max_output_tokens", 512) + monkeypatch.setitem(config._cfg, "thinking", False) + + fake_resp_body = json.dumps( + { + "choices": [ + { + "message": { + "content": "\nAnalyzing staged changes...\n\n```text\nfix(parser): handle quoted paths\n```" + } + } + ] + } + ).encode("utf-8") + + captured_req = {} + + def fake_urlopen(req, timeout=30): + captured_req["req"] = req + mock_resp = MagicMock() + mock_resp.read.return_value = fake_resp_body + mock_resp.status = 200 + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + return mock_resp + + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + msg = api.call_ai_api("diff --git a/a.py b/a.py\n+pass", " (+1/-0) a.py") + + assert msg == "fix(parser): handle quoted paths" + assert "" not in msg + req = captured_req["req"] + assert req.get_header("User-agent") == "ai-commit/1.0" + sent_payload = json.loads(req.data.decode("utf-8")) + # Non-deepseek.com endpoint with thinking=False must not receive proprietary "thinking" field + assert "thinking" not in sent_payload + + def test_falls_back_to_reasoning_content(self, monkeypatch): + monkeypatch.setitem(config._cfg, "api_key", "test-key") + monkeypatch.setitem(config._cfg, "api_base_url", "https://api.deepseek.com") + monkeypatch.setitem(config._cfg, "model", "deepseek-reasoner") + monkeypatch.setitem(config._cfg, "max_output_tokens", 512) + monkeypatch.setitem(config._cfg, "thinking", True) + + fake_resp_body = json.dumps( + { + "choices": [ + { + "message": { + "content": "only thoughts", + "reasoning_content": "step 1\nchore: update lockfile ignore list", + } + } + ] + } + ).encode("utf-8") + + mock_resp = MagicMock() + mock_resp.read.return_value = fake_resp_body + mock_resp.status = 200 + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + + with patch("urllib.request.urlopen", return_value=mock_resp): + msg = api.call_ai_api("diff --git a/a.py b/a.py\n+pass", "") + + assert msg == "chore: update lockfile ignore list" + + +class TestConfigAndIgnoreLoading: + def test_read_ignore_patterns_preserves_escaped_trailing_space_and_defaults( + self, tmp_path, monkeypatch + ): + ignore_file = tmp_path / ".opencommitignore" + ignore_file.write_text("# comment\nspace_file\\ \ncustom.lock \n", encoding="utf-8") + monkeypatch.setitem(config._cfg, "ignore_file", ".opencommitignore") + with patch("prepare_commit_msg_hooks.util.config._git_root", return_value=str(tmp_path)): + patterns = config.read_ignore_patterns() + + assert "uv.lock" in patterns + assert "package-lock.json" in patterns + assert "space_file\\ " in patterns + spec = gitignore.compile_patterns(patterns) + assert spec.is_ignored("uv.lock") is True + assert spec.is_ignored("space_file ") is True + assert spec.is_ignored("space_file") is False + assert spec.is_ignored("custom.lock") is True + + def test_load_config_handles_non_dict_json_and_early_debug(self, tmp_path, monkeypatch): + bad_cfg = tmp_path / ".ai-commit.json" + bad_cfg.write_text('["not", "a", "dict"]', encoding="utf-8") + monkeypatch.setenv("AI_COMMIT_DEBUG", "true") + stderr_buf = io.StringIO() + with ( + patch("prepare_commit_msg_hooks.util.config._git_root", return_value=str(tmp_path)), + patch("os.path.expanduser", return_value=str(tmp_path / "nonexistent_home")), + patch("sys.stderr", stderr_buf), + ): + config.load_config() + + assert config.cfg_get("debug") is True + assert "root JSON value must be an object" in stderr_buf.getvalue()