feat: add git history backend implementation

- Add git_history.py utility for extracting commit history and details
- Add API endpoints for repository history and commit details
- Integrate with existing git_repositories router

This completes the backend for git history visualization.
This commit is contained in:
Fusion
2026-05-19 12:50:57 +02:00
parent 0926e4de83
commit 0ae0e3fec1
2 changed files with 412 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
"""Git history extraction utilities for bare/mirror repositories."""
import subprocess
from dataclasses import dataclass
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
log_args = [
"log",
"--all",
"--graph",
f"--format=%H|%P|%an|%ae|%at|%s",
f"--max-count={limit}",
f"--skip={offset}",
]
if branch:
log_args.append(branch)
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 = []
graph_lines = log_output.strip().split("\n") if log_output.strip() else []
for line in graph_lines:
if "|" not in line:
continue
parts = line.split("|")
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
graph_data = _build_graph_data(commits)
return {
"commits": [_commit_to_dict(c) for c in commits],
"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": author,
"email": email,
"date": str(timestamp),
"timestamp": timestamp,
"message": message,
"body": body,
"branches": branch_map.get(commit_hash, []),
"tags": tag_map.get(commit_hash, []),
"stats": stats,
"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) -> dict[str, Any]:
"""Convert Commit dataclass to dictionary."""
return {
"hash": commit.hash,
"short_hash": commit.short_hash,
"parents": commit.parents,
"author": commit.author,
"email": commit.email,
"date": commit.date,
"timestamp": commit.timestamp,
"message": commit.message,
"branches": commit.branches,
"tags": commit.tags,
}
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,
}