802d8f1e8c
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.
458 lines
13 KiB
Python
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)
|