6807f449b7
- Create git file utilities (list_tree, get_file_content, list_branches, commit_file) - Add file browsing API endpoints (list, content, branches, update) - Create RepoWorkspace page with sidebar + main content layout - Add FileTree component with directory navigation - Add FileViewer component for viewing file contents - Update project list to link to workspace - Add workspace CSS styles - Update router with workspace route Quality gates: ruff ✓, mypy ✓, typecheck ✓, build ✓
392 lines
10 KiB
Python
392 lines
10 KiB
Python
"""Git file utilities for browsing repository contents."""
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class FileTreeEntry:
|
|
"""Represents a file or directory in the repository."""
|
|
|
|
name: str
|
|
type: str # "file" or "directory"
|
|
path: str
|
|
size: int | None = None
|
|
mode: str | None = None
|
|
last_commit: dict[str, Any] | None = None
|
|
|
|
|
|
@dataclass
|
|
class BranchInfo:
|
|
"""Represents a git branch."""
|
|
|
|
name: str
|
|
is_default: bool
|
|
last_commit: dict[str, Any] | None = None
|
|
|
|
|
|
@dataclass
|
|
class FileContent:
|
|
"""Represents file content and metadata."""
|
|
|
|
path: str
|
|
branch: str
|
|
content: str
|
|
size: int
|
|
encoding: str
|
|
language: str | None
|
|
is_binary: bool
|
|
last_commit: dict[str, Any] | None = None
|
|
|
|
|
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
|
"""Run a git command in the repository directory."""
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Git command failed: {result.stderr}")
|
|
return result.stdout
|
|
|
|
|
|
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
|
|
"""List files and directories in a repository path.
|
|
|
|
Args:
|
|
repo_path: Path to the git repository
|
|
branch: Branch name to list from
|
|
path: Directory path within the repository (empty for root)
|
|
|
|
Returns:
|
|
List of FileTreeEntry objects
|
|
"""
|
|
tree_path = f"{branch}:{path}" if path else branch
|
|
|
|
try:
|
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
|
except RuntimeError:
|
|
# Try with HEAD if branch doesn't exist
|
|
tree_path = f"HEAD:{path}" if path else "HEAD"
|
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
|
|
|
entries = []
|
|
for line in output.strip().split("\n"):
|
|
if not line:
|
|
continue
|
|
|
|
# Format: <mode> <type> <hash> <size>\t<name>
|
|
parts = line.split("\t", 1)
|
|
if len(parts) != 2:
|
|
continue
|
|
|
|
meta, name = parts
|
|
meta_parts = meta.split()
|
|
if len(meta_parts) < 4:
|
|
continue
|
|
|
|
mode = meta_parts[0]
|
|
obj_type = meta_parts[1]
|
|
_ = meta_parts[2] # object hash, not used
|
|
size = int(meta_parts[3]) if obj_type == "blob" else None
|
|
|
|
entry_path = f"{path}/{name}" if path else name
|
|
|
|
# Get last commit info for this entry
|
|
last_commit = _get_last_commit_for_path(repo_path, branch, entry_path)
|
|
|
|
entries.append(
|
|
FileTreeEntry(
|
|
name=name,
|
|
type="directory" if obj_type == "tree" else "file",
|
|
path=entry_path,
|
|
size=size,
|
|
mode=mode,
|
|
last_commit=last_commit,
|
|
)
|
|
)
|
|
|
|
return entries
|
|
|
|
|
|
def _get_last_commit_for_path(repo_path: str, branch: str, path: str) -> dict[str, Any] | None:
|
|
"""Get the last commit that modified a path."""
|
|
try:
|
|
output = _run_git_command(
|
|
repo_path,
|
|
"log",
|
|
"-1",
|
|
"--format=%H|%s|%an|%aI",
|
|
branch,
|
|
"--",
|
|
path,
|
|
)
|
|
if not output.strip():
|
|
return None
|
|
|
|
parts = output.strip().split("|", 3)
|
|
if len(parts) != 4:
|
|
return None
|
|
|
|
return {
|
|
"hash": parts[0],
|
|
"message": parts[1],
|
|
"author": parts[2],
|
|
"date": parts[3],
|
|
}
|
|
except RuntimeError:
|
|
return None
|
|
|
|
|
|
def get_file_content(repo_path: str, branch: str, path: str) -> FileContent:
|
|
"""Get the content of a file.
|
|
|
|
Args:
|
|
repo_path: Path to the git repository
|
|
branch: Branch name
|
|
path: File path within the repository
|
|
|
|
Returns:
|
|
FileContent with content and metadata
|
|
"""
|
|
# Check if file exists
|
|
try:
|
|
_run_git_command(repo_path, "cat-file", "-e", f"{branch}:{path}")
|
|
except RuntimeError:
|
|
raise FileNotFoundError(f"File '{path}' not found in branch '{branch}'")
|
|
|
|
# Get file size
|
|
size_output = _run_git_command(repo_path, "cat-file", "-s", f"{branch}:{path}")
|
|
size = int(size_output.strip())
|
|
|
|
# Check if binary
|
|
is_binary = _is_binary_file(repo_path, branch, path)
|
|
|
|
# Get content (only for text files)
|
|
content = ""
|
|
if not is_binary:
|
|
content = _run_git_command(repo_path, "show", f"{branch}:{path}")
|
|
|
|
# Detect language from extension
|
|
language = _detect_language(path)
|
|
|
|
# Get last commit
|
|
last_commit = _get_last_commit_for_path(repo_path, branch, path)
|
|
|
|
return FileContent(
|
|
path=path,
|
|
branch=branch,
|
|
content=content,
|
|
size=size,
|
|
encoding="utf-8",
|
|
language=language,
|
|
is_binary=is_binary,
|
|
last_commit=last_commit,
|
|
)
|
|
|
|
|
|
def _is_binary_file(repo_path: str, branch: str, path: str) -> bool:
|
|
"""Check if a file is binary."""
|
|
try:
|
|
_run_git_command(
|
|
repo_path,
|
|
"diff",
|
|
"--numstat",
|
|
"--",
|
|
path,
|
|
)
|
|
# Alternative: use git show and check for null bytes
|
|
content = _run_git_command(repo_path, "show", f"{branch}:{path}")
|
|
return b"\x00" in content.encode("utf-8", errors="replace")
|
|
except RuntimeError:
|
|
return True
|
|
|
|
|
|
def _detect_language(path: str) -> str | None:
|
|
"""Detect programming language from file extension."""
|
|
ext = Path(path).suffix.lower()
|
|
language_map = {
|
|
".py": "python",
|
|
".js": "javascript",
|
|
".ts": "typescript",
|
|
".jsx": "jsx",
|
|
".tsx": "tsx",
|
|
".html": "html",
|
|
".css": "css",
|
|
".scss": "scss",
|
|
".json": "json",
|
|
".md": "markdown",
|
|
".yaml": "yaml",
|
|
".yml": "yaml",
|
|
".sh": "bash",
|
|
".rs": "rust",
|
|
".go": "go",
|
|
".java": "java",
|
|
".c": "c",
|
|
".cpp": "cpp",
|
|
".h": "c",
|
|
".php": "php",
|
|
".rb": "ruby",
|
|
".sql": "sql",
|
|
".dockerfile": "dockerfile",
|
|
".vue": "vue",
|
|
".svelte": "svelte",
|
|
}
|
|
return language_map.get(ext)
|
|
|
|
|
|
def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|
"""List all branches and identify the default branch.
|
|
|
|
Args:
|
|
repo_path: Path to the git repository
|
|
|
|
Returns:
|
|
Tuple of (list of BranchInfo, default branch name)
|
|
"""
|
|
# Get all branches
|
|
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
|
|
|
|
branches: list[BranchInfo] = []
|
|
default_branch = "main"
|
|
|
|
for line in output.strip().split("\n"):
|
|
if not line:
|
|
continue
|
|
|
|
branch_name = line.strip()
|
|
# Skip remote tracking branches (they start with remotes/)
|
|
if branch_name.startswith("remotes/"):
|
|
# Extract just the branch name part
|
|
parts = branch_name.split("/", 2)
|
|
if len(parts) >= 3:
|
|
branch_name = parts[2]
|
|
else:
|
|
continue
|
|
|
|
# Skip duplicates
|
|
if any(b.name == branch_name for b in branches):
|
|
continue
|
|
|
|
# Check if this is the default branch (HEAD points to it)
|
|
try:
|
|
head_output = _run_git_command(
|
|
repo_path,
|
|
"symbolic-ref",
|
|
"HEAD",
|
|
)
|
|
if head_output.strip() == f"refs/heads/{branch_name}":
|
|
default_branch = branch_name
|
|
except RuntimeError:
|
|
pass
|
|
|
|
# Get last commit for branch
|
|
last_commit = _get_last_commit_for_path(repo_path, branch_name, ".")
|
|
|
|
branches.append(
|
|
BranchInfo(
|
|
name=branch_name,
|
|
is_default=(branch_name == default_branch),
|
|
last_commit=last_commit,
|
|
)
|
|
)
|
|
|
|
# If no branches found, try to get HEAD
|
|
if not branches:
|
|
try:
|
|
output = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
branch_name = output.strip()
|
|
if branch_name and branch_name != "HEAD":
|
|
last_commit = _get_last_commit_for_path(repo_path, branch_name, ".")
|
|
branches.append(
|
|
BranchInfo(
|
|
name=branch_name,
|
|
is_default=True,
|
|
last_commit=last_commit,
|
|
)
|
|
)
|
|
default_branch = branch_name
|
|
except RuntimeError:
|
|
pass
|
|
|
|
return branches, default_branch
|
|
|
|
|
|
def commit_file(
|
|
repo_path: str,
|
|
branch: str,
|
|
path: str,
|
|
content: str,
|
|
commit_message: str,
|
|
author_name: str,
|
|
author_email: str,
|
|
) -> str:
|
|
"""Commit a file change.
|
|
|
|
Args:
|
|
repo_path: Path to the git repository
|
|
branch: Branch to commit to
|
|
path: File path within the repository
|
|
content: New file content
|
|
commit_message: Commit message
|
|
author_name: Author name
|
|
author_email: Author email
|
|
|
|
Returns:
|
|
Commit hash
|
|
"""
|
|
# For bare repositories, we need to use git commands differently
|
|
# We'll create a temporary worktree, make changes, and commit
|
|
|
|
import tempfile
|
|
import os
|
|
|
|
# Create a temporary worktree
|
|
with tempfile.TemporaryDirectory() as worktree_path:
|
|
# Add worktree
|
|
_run_git_command(
|
|
repo_path,
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
worktree_path,
|
|
branch,
|
|
)
|
|
|
|
try:
|
|
# Write file content
|
|
file_path = os.path.join(worktree_path, path)
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
# Configure git author
|
|
_run_git_command(worktree_path, "config", "user.name", author_name)
|
|
_run_git_command(worktree_path, "config", "user.email", author_email)
|
|
|
|
# Stage and commit
|
|
_run_git_command(worktree_path, "add", path)
|
|
_run_git_command(
|
|
worktree_path,
|
|
"commit",
|
|
"-m",
|
|
commit_message,
|
|
)
|
|
|
|
# Get commit hash
|
|
commit_hash = _run_git_command(
|
|
worktree_path,
|
|
"rev-parse",
|
|
"HEAD",
|
|
).strip()
|
|
|
|
return commit_hash
|
|
|
|
finally:
|
|
# Remove worktree
|
|
_run_git_command(repo_path, "worktree", "remove", worktree_path)
|