"""Git history extraction utilities for bare/mirror repositories.""" import subprocess from dataclasses import dataclass from datetime import datetime, timezone from typing import Any @dataclass class Commit: """Represents a single git commit.""" hash: str short_hash: str parents: list[str] author: str email: str date: str timestamp: int message: str branches: list[str] tags: list[str] @dataclass class FileChange: """Represents a changed file in a commit.""" path: str change_type: str insertions: int deletions: int diff: str @dataclass class CommitDetail(Commit): """Extended commit info with diff.""" body: str stats: dict[str, int] files: list[FileChange] def _run_git_command(repo_path: str, args: list[str]) -> str: """Execute a git command in the repository directory.""" result = subprocess.run( ["git", "-C", repo_path, *args], capture_output=True, text=True, check=False, ) if result.returncode != 0: raise RuntimeError(f"Git command failed: {result.stderr}") return result.stdout def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 100, offset: int = 0) -> dict[str, Any]: """Extract commit history from a git repository. Returns structured data including commits, branches, and graph information. """ # Get list of branches branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"]) branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()] # Build git log command - use NULL bytes as separators to avoid parsing issues log_args = [ "log", "--format=%H%x00%P%x00%an%x00%ae%x00%at%x00%s", f"--max-count={limit}", f"--skip={offset}", ] if branch: log_args.append(branch) else: log_args.append("--all") log_output = _run_git_command(repo_path, log_args) # Get branch info for each commit branch_map = _get_branch_map(repo_path) tag_map = _get_tag_map(repo_path) commits = [] log_lines = log_output.strip().split("\n") if log_output.strip() else [] for line in log_lines: line = line.strip() if not line: continue parts = line.split("\x00") if len(parts) < 6: continue commit_hash = parts[0] parents = parts[1].split() if parts[1] else [] commits.append( Commit( hash=commit_hash, short_hash=commit_hash[:7], parents=parents, author=parts[2], email=parts[3], date=parts[4], timestamp=int(parts[4]), message=parts[5], branches=branch_map.get(commit_hash, []), tags=tag_map.get(commit_hash, []), ) ) # Get total commit count count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"]) total_commits = int(count_output.strip()) if count_output.strip() else 0 # Build graph data and generate graph symbols graph_data = _build_graph_data(commits) # Generate simple graph symbols based on parent count commit_dicts = [] for i, commit in enumerate(commits): if len(commit.parents) == 0: graph_symbol = "○" # Initial commit elif len(commit.parents) > 1: graph_symbol = "●" # Merge commit else: graph_symbol = "○" # Regular commit # Simple depth calculation based on merge status graph_depth = min(len(commit.parents), 3) commit_dicts.append(_commit_to_dict(commit, graph_symbol, graph_depth)) return { "commits": commit_dicts, "branches": branches, "total_commits": total_commits, "graph_data": graph_data, } def get_commit_detail(repo_path: str, commit_hash: str) -> dict[str, Any]: """Get detailed information about a specific commit.""" # Get commit metadata format_str = "%H|%P|%an|%ae|%at|%s|%b" log_output = _run_git_command( repo_path, ["log", "-1", f"--format={format_str}", commit_hash] ) parts = log_output.strip().split("|", 6) if len(parts) < 6: raise ValueError(f"Invalid commit: {commit_hash}") commit_hash = parts[0] parents = parts[1].split() if parts[1] else [] author = parts[2] email = parts[3] timestamp = int(parts[4]) message = parts[5] body = parts[6] if len(parts) > 6 else "" # Get stats stat_output = _run_git_command( repo_path, ["show", "--stat", "--format=", commit_hash] ) stats = _parse_stats(stat_output) # Get diff diff_output = _run_git_command( repo_path, ["show", "--format=", commit_hash] ) files = _parse_diff(diff_output) # Get branch/tag info branch_map = _get_branch_map(repo_path) tag_map = _get_tag_map(repo_path) return { "hash": commit_hash, "short_hash": commit_hash[:7], "parents": parents, "author_name": author, "author_email": email, "author_date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(), "committer_name": author, # TODO: extract committer separately "committer_email": email, # TODO: extract committer separately "committer_date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(), "message": message, "body": body, "branches": branch_map.get(commit_hash, []), "tags": tag_map.get(commit_hash, []), "stats": stats, "diff": diff_output, "files": [_file_change_to_dict(f) for f in files], } def _get_branch_map(repo_path: str) -> dict[str, list[str]]: """Build a mapping of commit hash to branch names.""" result = {} branch_output = _run_git_command( repo_path, ["for-each-ref", "--format=%(objectname) %(refname:short)", "refs/heads/"] ) for line in branch_output.strip().split("\n"): if " " in line: commit_hash, branch_name = line.split(" ", 1) if commit_hash not in result: result[commit_hash] = [] result[commit_hash].append(branch_name) return result def _get_tag_map(repo_path: str) -> dict[str, list[str]]: """Build a mapping of commit hash to tag names.""" result = {} tag_output = _run_git_command( repo_path, ["for-each-ref", "--format=%(objectname) %(refname:short)", "refs/tags/"] ) for line in tag_output.strip().split("\n"): if " " in line: commit_hash, tag_name = line.split(" ", 1) if commit_hash not in result: result[commit_hash] = [] result[commit_hash].append(tag_name) return result def _build_graph_data(commits: list[Commit]) -> dict[str, Any]: """Build graph visualization data from commits.""" if not commits: return {"nodes": [], "edges": []} # Create hash to index mapping hash_to_idx = {c.hash: i for i, c in enumerate(commits)} nodes = [] edges = [] for i, commit in enumerate(commits): # Calculate column based on branch column = 0 if commit.branches: # Use first branch as column indicator column = hash(commit.branches[0]) % 5 nodes.append( { "hash": commit.hash, "x": column * 60 + 30, "y": i * 50 + 25, "column": column, } ) # Create edges to parents for parent_hash in commit.parents: if parent_hash in hash_to_idx: edges.append( { "from_hash": commit.hash, "to_hash": parent_hash, "type": "parent", } ) return {"nodes": nodes, "edges": edges} def _parse_stats(stat_output: str) -> dict[str, int]: """Parse git show --stat output.""" lines = stat_output.strip().split("\n") stats = {"files_changed": 0, "insertions": 0, "deletions": 0} for line in lines: line = line.strip() if "files changed" in line or "file changed" in line: # Parse summary line like "3 files changed, 45 insertions(+), 12 deletions(-)" parts = line.split(",") for part in parts: part = part.strip() if "file" in part: try: stats["files_changed"] = int(part.split()[0]) except (ValueError, IndexError): pass elif "insertion" in part: try: stats["insertions"] = int(part.split()[0]) except (ValueError, IndexError): pass elif "deletion" in part: try: stats["deletions"] = int(part.split()[0]) except (ValueError, IndexError): pass return stats def _parse_diff(diff_output: str) -> list[FileChange]: """Parse git diff output into file changes.""" files = [] current_file = None current_diff = [] for line in diff_output.split("\n"): if line.startswith("diff --git"): # Save previous file if current_file: current_file.diff = "\n".join(current_diff) files.append(current_file) # Start new file current_diff = [line] current_file = FileChange( path="", change_type="modified", insertions=0, deletions=0, diff="", ) elif line.startswith("--- ") or line.startswith("+++ "): current_diff.append(line) if line.startswith("+++ ") and not line.startswith("+++ /dev/null"): current_file.path = line[6:] elif line.startswith("@@ "): current_diff.append(line) elif line.startswith("+") and not line.startswith("+++"): current_diff.append(line) current_file.insertions += 1 elif line.startswith("-") and not line.startswith("---"): current_diff.append(line) current_file.deletions += 1 elif current_file: current_diff.append(line) # Save last file if current_file: current_file.diff = "\n".join(current_diff) files.append(current_file) return files def _commit_to_dict(commit: Commit, graph_symbol: str = "", graph_depth: int = 0) -> dict[str, Any]: """Convert Commit dataclass to dictionary.""" refs = [] if commit.branches: refs.extend(commit.branches) if commit.tags: refs.extend(commit.tags) return { "hash": commit.hash, "short_hash": commit.short_hash, "parents": commit.parents, "author_name": commit.author, "author_email": commit.email, "author_date": datetime.fromtimestamp(commit.timestamp, tz=timezone.utc).isoformat(), "message": commit.message, "refs": refs, "graph_symbol": graph_symbol, "graph_depth": graph_depth, } def _file_change_to_dict(file_change: FileChange) -> dict[str, Any]: """Convert FileChange dataclass to dictionary.""" return { "path": file_change.path, "change_type": file_change.change_type, "insertions": file_change.insertions, "deletions": file_change.deletions, "diff": file_change.diff, }