diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 653a008..37e7881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: cache: "pip" - name: Install Ruff - run: pip install ruff + run: pip install ruff==0.12.3 - name: Ruff check run: ruff check . diff --git a/git_explain/gemini.py b/git_explain/gemini.py index 2eead6b..951f860 100644 --- a/git_explain/gemini.py +++ b/git_explain/gemini.py @@ -11,8 +11,10 @@ from git_explain.commit_infer import refine_type_and_message_from_diff from git_explain.path_topics import ( + alnum_key, area_scope_suffix, basename_fallback_topic, + code_topics, infer_scope, infra_deploy_topics, is_test_path, @@ -184,7 +186,6 @@ def truncate_commit_subject( "misc", } -CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"} _WEAK_TOPIC_WORDS = { "project", "projects", @@ -210,40 +211,6 @@ def truncate_commit_subject( } -def _code_topics(files: list[str]) -> list[str]: - labeled: list[tuple[str, str]] = [] # (folder_label, stem) - for p in files: - p2 = p.replace("\\", "/") - base = os.path.basename(p2) - ext = os.path.splitext(base)[1].lower() - if ext not in CODE_EXTS: - continue - stem = os.path.splitext(base)[0].replace("_", " ") - parts = [x for x in p2.split("/") if x] - folder = parts[-2] if len(parts) >= 2 else stem - labeled.append((folder.replace("_", " "), stem)) - - if not labeled: - return [] - - folder_set = {f.lower() for f, _ in labeled} - prefer_stems = len(folder_set) == 1 and len(labeled) >= 2 - - topics: list[str] = [] - seen: set[str] = set() - for folder, stem in labeled: - label = stem if prefer_stems else folder - key = label.lower() - if key not in seen: - seen.add(key) - topics.append(label) - return topics - - -def _alnum_key(s: str) -> str: - return re.sub(r"[^a-z0-9]+", "", (s or "").lower()) - - def _is_generic_message(message: str) -> bool: msg = (message or "").strip().lower() if not msg: @@ -291,8 +258,8 @@ def _is_generic_message(message: str) -> bool: return True m_for = re.match(r"^(add|update|modify|make)\s+(.+?)\s+for\s+(.+)$", msg) if m_for: - left = _alnum_key(m_for.group(2)) - right = _alnum_key(m_for.group(3)) + left = alnum_key(m_for.group(2)) + right = alnum_key(m_for.group(3)) if left and right and (left == right or left in right or right in left): return True # "update X" is okay, but bare "update" or "update stuff" isn't @@ -379,9 +346,9 @@ def is_packaging(f: str) -> bool: topics.append("tests") if touches_docs and not docs_only: topics.append("docs") - code_topics = _code_topics(files) - if code_topics: - topics.append(", ".join(code_topics[:5])) + file_code_topics = code_topics(files) + if file_code_topics: + topics.append(", ".join(file_code_topics[:5])) if touches_packaging: topics.append("packaging config") @@ -402,8 +369,8 @@ def is_packaging(f: str) -> bool: scope = area_scope_suffix(files) if scope: - scope_key = _alnum_key(scope.replace("for", "", 1)) - msg_key = _alnum_key(msg) + scope_key = alnum_key(scope.replace("for", "", 1)) + msg_key = alnum_key(msg) if scope_key and scope_key not in msg_key: msg += scope diff --git a/git_explain/heuristics.py b/git_explain/heuristics.py index 90f3b63..8086002 100644 --- a/git_explain/heuristics.py +++ b/git_explain/heuristics.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import re from git_explain.commit_infer import refine_type_and_message_from_diff from git_explain.gemini import ( @@ -12,8 +11,11 @@ truncate_commit_subject, ) from git_explain.path_topics import ( + CODE_EXTS, + alnum_key, area_scope_suffix, basename_fallback_topic, + code_topics, infer_scope, infra_deploy_topics, is_build_path, @@ -36,7 +38,6 @@ "license.md", } CONFIG_EXTS = {".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"} -CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"} def _is_doc(path: str) -> bool: @@ -60,40 +61,6 @@ def _is_config(path: str) -> bool: return _is_plain_config(path) or is_infra_deploy_path(path) -def _code_topics(paths: list[str]) -> list[str]: - labeled: list[tuple[str, str]] = [] # (folder_label, stem) - for p in paths: - p2 = p.replace("\\", "/") - base = os.path.basename(p2) - ext = os.path.splitext(base)[1].lower() - if ext not in CODE_EXTS: - continue - stem = os.path.splitext(base)[0].replace("_", " ") - parts = [x for x in p2.split("/") if x] - folder = parts[-2] if len(parts) >= 2 else stem - labeled.append((folder.replace("_", " "), stem)) - - if not labeled: - return [] - - folder_set = {f.lower() for f, _ in labeled} - prefer_stems = len(folder_set) == 1 and len(labeled) >= 2 - - topics: list[str] = [] - seen: set[str] = set() - for folder, stem in labeled: - label = stem if prefer_stems else folder - key = label.lower() - if key not in seen: - seen.add(key) - topics.append(label) - return topics - - -def _alnum_key(s: str) -> str: - return re.sub(r"[^a-z0-9]+", "", (s or "").lower()) - - def _path_first_segment(path: str) -> str: parts = [x for x in path.replace("\\", "/").strip("/").split("/") if x] return parts[0].lower() if parts else "root" @@ -175,9 +142,9 @@ def suggest_from_changes( topics.append("tests") if any(_is_plain_config(p) for p in paths): topics.append("config") - code_topics = _code_topics(paths) - if code_topics: - if len(code_topics) > 4: + file_code_topics = code_topics(paths) + if file_code_topics: + if len(file_code_topics) > 4: roots = {_path_first_segment(p) for p in paths} if len(roots) == 1: root = next(iter(roots)) @@ -189,9 +156,9 @@ def suggest_from_changes( a, b = sorted(roots) topics.append(f"{len(paths)} files across {a} and {b}") else: - topics.append(", ".join(code_topics[:4])) + topics.append(", ".join(file_code_topics[:4])) else: - topics.append(", ".join(code_topics[:5])) + topics.append(", ".join(file_code_topics[:5])) # Dedupe while preserving order seen: set[str] = set() @@ -210,8 +177,8 @@ def suggest_from_changes( scope_suffix = area_scope_suffix(paths) if scope_suffix: - scope_key = _alnum_key(scope_suffix.replace("for", "", 1)) - msg_key = _alnum_key(message) + scope_key = alnum_key(scope_suffix.replace("for", "", 1)) + msg_key = alnum_key(message) if scope_key and scope_key not in msg_key: message += scope_suffix diff --git a/git_explain/path_topics.py b/git_explain/path_topics.py index 1c81acd..7e6a528 100644 --- a/git_explain/path_topics.py +++ b/git_explain/path_topics.py @@ -3,12 +3,52 @@ from __future__ import annotations import os +import re def _norm(p: str) -> str: return p.replace("\\", "/").strip() +CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs"} + + +def alnum_key(s: str) -> str: + """Lowercase alnum-only key, for loose substring comparisons of free text.""" + return re.sub(r"[^a-z0-9]+", "", (s or "").lower()) + + +def code_topics(paths: list[str]) -> list[str]: + """Ordered, deduplicated topic labels for code files: filename stems or parent folders.""" + labeled: list[tuple[str, str]] = [] # (folder_label, stem) + for p in paths: + p2 = p.replace("\\", "/") + base = os.path.basename(p2) + ext = os.path.splitext(base)[1].lower() + if ext not in CODE_EXTS: + continue + stem = os.path.splitext(base)[0].replace("_", " ") + parts = [x for x in p2.split("/") if x] + folder = parts[-2] if len(parts) >= 2 else stem + labeled.append((folder.replace("_", " "), stem)) + + if not labeled: + return [] + + folder_set = {f.lower() for f, _ in labeled} + prefer_stems = len(folder_set) == 1 and len(labeled) >= 2 + + topics: list[str] = [] + seen: set[str] = set() + for folder, stem in labeled: + label = stem if prefer_stems else folder + key = label.lower() + if key not in seen: + seen.add(key) + topics.append(label) + return topics + + _TEST_HINTS = ("pytest", "unittest", "tests/", "/tests/") diff --git a/pyproject.toml b/pyproject.toml index 496a42a..bb37a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0.0", - "ruff>=0.8.0", + "ruff==0.12.3", ] [project.scripts]