"""Shared utility module for pre-commit hooks analyzing staged files.""" import subprocess from dataclasses import dataclass, field from pathlib import PurePosixPath @dataclass class StagedFileInfo: path: str added: int removed: int is_new: bool category: str @dataclass class CommitAnalysis: source_files: list[StagedFileInfo] = field(default_factory=list) test_files: list[StagedFileInfo] = field(default_factory=list) doc_files: list[StagedFileInfo] = field(default_factory=list) @property def new_source_files(self): return [f for f in self.source_files if f.is_new] @property def total_source_added(self): return sum(f.added for f in self.source_files) @property def has_tests(self): return len(self.test_files) > 0 @property def has_docs(self): return len(self.doc_files) > 0 # Directories/patterns excluded from "source" classification _EXCLUDED_DIRS = { "migrations", "alembic", "config", "settings", "scripts", ".pre-commit-hooks", "examples", "docs", } _EXCLUDED_BASENAMES = {"__init__.py", "conftest.py"} def _run_git(args): """Run a git command and return stdout lines. Returns empty list if git command fails (e.g., not in a git repo). """ result = subprocess.run( ["git"] + args, capture_output=True, text=True, ) if result.returncode != 0: return [] return result.stdout.strip().splitlines() if result.stdout.strip() else [] def _parse_numstat(lines): """Parse git diff --numstat output into {filepath: (added, removed)}.""" stats = {} for line in lines: parts = line.split("\t") if len(parts) != 3: continue added_str, removed_str, filepath = parts # Binary files show '-' for counts added = int(added_str) if added_str != "-" else 0 removed = int(removed_str) if removed_str != "-" else 0 stats[filepath] = (added, removed) return stats def classify_file(path): """Classify a file path into a category string. Returns one of: source, test, doc, init, conftest, migration, config, hook, script, other """ p = PurePosixPath(path) basename = p.name parts_set = set(p.parts) # Doc files if p.suffix == ".md": return "doc" # JavaScript files if p.suffix == ".js": # Vitest test files: tests/js/**/*.test.js if "tests" in parts_set and basename.endswith(".test.js"): return "test" # Source: under src/.../static/js/ if "static" in parts_set and "js" in parts_set and parts_set & {"src"}: # Skip vendored / third-party if "vendor" in parts_set or "lib" in parts_set: return "other" return "source" return "other" # Non-Python files are "other" if p.suffix != ".py": return "other" # Specific basename exclusions if basename == "__init__.py": return "init" if basename == "conftest.py": return "conftest" # Test files: under tests/ or matching test_*.py / *_test.py if ( "tests" in parts_set or basename.startswith("test_") or basename.endswith("_test.py") ): return "test" # Check excluded directory patterns for part in p.parts: if part.lower() in _EXCLUDED_DIRS: return _dir_to_category(part.lower()) # Source files: under src/ if len(p.parts) > 0 and p.parts[0] == "src": return "source" return "other" def _dir_to_category(dirname): """Map excluded directory names to categories.""" mapping = { "migrations": "migration", "alembic": "migration", "config": "config", "settings": "config", "scripts": "script", ".pre-commit-hooks": "hook", "examples": "other", "docs": "doc", } return mapping.get(dirname, "other") def suggest_test_path(source_path): """Suggest a test file path for a given source file. Python: src/local_deep_research/web/api.py -> tests/web/test_api.py JS: src/local_deep_research/web/static/js/components/foo.js -> tests/js/components/foo.test.js """ p = PurePosixPath(source_path) # JavaScript: mirror tests/js/ if p.suffix == ".js": parts = list(p.parts) if "static" in parts and "js" in parts: js_idx = parts.index("js", parts.index("static")) sub_parts = parts[js_idx + 1 :] if sub_parts: stem = PurePosixPath(sub_parts[-1]).stem # Convert snake_case → kebab-case to match existing test naming stem = stem.replace("_", "-") test_name = f"{stem}.test.js" test_parts = ["tests", "js"] + sub_parts[:-1] + [test_name] return str(PurePosixPath(*test_parts)) return f"tests/js/{p.stem.replace('_', '-')}.test.js" parts = list(p.parts) # Strip leading src/ and package name (e.g., src/local_deep_research/) if parts and parts[0] == "src": parts = parts[1:] if parts and not parts[0].startswith("test"): parts = parts[1:] # Remove package name like local_deep_research # Build test path if parts: test_name = f"test_{parts[-1]}" test_parts = ["tests"] + parts[:-1] + [test_name] else: test_parts = ["tests", f"test_{p.name}"] return str(PurePosixPath(*test_parts)) def analyze_commit(): """Analyze staged files and return a CommitAnalysis.""" # Get list of staged files with their status status_lines = _run_git(["diff", "--cached", "--name-status"]) new_files = set() for line in status_lines: parts = line.split("\t") if len(parts) >= 2 and parts[0].startswith("A"): new_files.add(parts[-1]) # Get line counts numstat_lines = _run_git(["diff", "--cached", "--numstat"]) stats = _parse_numstat(numstat_lines) analysis = CommitAnalysis() for filepath, (added, removed) in stats.items(): category = classify_file(filepath) info = StagedFileInfo( path=filepath, added=added, removed=removed, is_new=filepath in new_files, category=category, ) if category == "source": analysis.source_files.append(info) elif category == "test": analysis.test_files.append(info) elif category == "doc": analysis.doc_files.append(info) return analysis