Files
headquarter/apps/api/src/utils/git_files.py
T
alex 802d8f1e8c fix: strip remote prefix from branch names in list_branches
Git branch -a --format=%(refname:short) returns remote branches as
'origin/branch-name', not 'remotes/origin/branch-name'. The code was
only filtering 'remotes/' prefix, causing clone to fail with branch
names like 'origin/feat/foo'.

Now properly detects remote names using 'git remote' and strips the
remote prefix (e.g., 'origin/') from branch names.
2026-05-24 10:25:01 +00:00

458 lines
13 KiB
Python

"""Git file utilities for browsing repository contents."""
import logging
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:
stderr = result.stderr
# Handle "dubious ownership" security error
if "dubious ownership" in stderr.lower():
logger.warning("Git ownership mismatch for %s, adding to safe.directory", repo_path)
# Add this directory to git's safe.directory list
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", repo_path],
capture_output=True,
)
# Retry the command
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
stderr = result.stderr
raise RuntimeError(f"Git command failed: {stderr}")
return result.stdout
logger = logging.getLogger(__name__)
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 as e:
logger.warning("git ls-tree failed for %s with branch '%s': %s", repo_path, tree_path, str(e))
# Try with HEAD if branch doesn't exist
tree_path = f"HEAD:{path}" if path else "HEAD"
try:
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
except RuntimeError as e:
logger.error("git ls-tree failed for %s with HEAD: %s", repo_path, str(e))
# Check if this is an empty repository (no commits yet)
error_msg = str(e).lower()
if "not a valid object name" in error_msg or "does not exist" in error_msg:
# Empty repository - return empty list
return []
raise
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 using raw bytes to avoid encoding issues."""
try:
result = subprocess.run(
["git", "show", f"{branch}:{path}"],
cwd=repo_path,
capture_output=True,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr.decode()}")
# A file is binary if it contains null bytes
return b"\x00" in result.stdout
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
try:
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
except RuntimeError as e:
logger.error("Failed to list branches for %s: %s", repo_path, str(e))
raise
branches: list[BranchInfo] = []
default_branch = "main"
# Get list of remote names to properly filter remote tracking branches
try:
remote_output = _run_git_command(repo_path, "remote")
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
except RuntimeError:
remote_names = set()
for line in output.strip().split("\n"):
if not line:
continue
branch_name = line.strip()
# Skip detached HEAD pointer
if branch_name == "HEAD":
continue
# Skip remote tracking branches - they appear as "origin/branch-name"
# Check if first part is a remote name
if "/" in branch_name:
first_part = branch_name.split("/", 1)[0]
if first_part in remote_names:
# Extract just the branch name part (after "origin/")
branch_name = branch_name.split("/", 1)[1]
elif branch_name.startswith("remotes/"):
# Handle "remotes/origin/branch-name" format
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:
try:
output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD")
branch_name = output.strip()
if branch_name:
branches.append(
BranchInfo(
name=branch_name,
is_default=True,
last_commit=None,
)
)
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)