Files
e2b-dev--e2b/packages/python-sdk/e2b/sandbox/_git/parse.py
T
2026-07-13 12:47:58 +08:00

223 lines
6.5 KiB
Python

from typing import List, Optional
from urllib.parse import urlparse
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox._git.types import GitBranches, GitFileStatus, GitStatus
def derive_repo_dir_from_url(url: str) -> Optional[str]:
"""
Derive the default repository directory name from a Git URL.
:param url: Git repository URL
:return: Repository directory name, if it can be determined
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None
trimmed_path = parsed.path.rstrip("/")
if not trimmed_path:
return None
last_segment = trimmed_path.split("/")[-1]
if not last_segment:
return None
return last_segment[:-4] if last_segment.endswith(".git") else last_segment
def _parse_ahead_behind(segment: Optional[str]) -> tuple[int, int]:
"""
Parse the ahead/behind segment from porcelain branch info.
:param segment: Segment text like "ahead 2, behind 1"
:return: Tuple of (ahead, behind)
"""
if not segment:
return 0, 0
ahead = 0
behind = 0
if "ahead" in segment:
try:
ahead = int(segment.split("ahead")[1].split(",")[0].strip())
except Exception:
ahead = 0
if "behind" in segment:
try:
behind = int(segment.split("behind")[1].split(",")[0].strip())
except Exception:
behind = 0
return ahead, behind
def _normalize_branch_name(name: str) -> str:
"""
Normalize branch names from porcelain branch output.
:param name: Raw branch name section
:return: Normalized branch name
"""
if name.startswith("HEAD (detached at "):
return name.replace("HEAD (detached at ", "").rstrip(")")
return (
name.replace("HEAD (no branch)", "HEAD")
.replace("No commits yet on ", "")
.replace("Initial commit on ", "")
)
def _derive_status(index_status: str, working_status: str) -> str:
"""
Derive a normalized status label from porcelain status characters.
:param index_status: Index status character
:param working_status: Working tree status character
:return: Normalized status label
"""
statuses = {index_status, working_status}
if "U" in statuses:
return "conflict"
if "R" in statuses:
return "renamed"
if "C" in statuses:
return "copied"
if "D" in statuses:
return "deleted"
if "A" in statuses:
return "added"
if "M" in statuses:
return "modified"
if "T" in statuses:
return "typechange"
if "?" in statuses:
return "untracked"
return "unknown"
def parse_git_status(output: str) -> GitStatus:
"""
Parse `git status --porcelain=1 -b` output into a structured object.
:param output: Git status output
:return: Parsed `GitStatus`
"""
lines = [line.rstrip() for line in output.split("\n") if line.strip()]
current_branch: Optional[str] = None
upstream: Optional[str] = None
ahead = 0
behind = 0
detached = False
file_status: List[GitFileStatus] = []
if not lines:
return GitStatus(
current_branch=current_branch,
upstream=upstream,
ahead=ahead,
behind=behind,
detached=detached,
file_status=file_status,
)
branch_line = lines[0]
if branch_line.startswith("## "):
branch_info = branch_line[3:]
ahead_start = branch_info.find(" [")
branch_part = branch_info if ahead_start == -1 else branch_info[:ahead_start]
ahead_part = None if ahead_start == -1 else branch_info[ahead_start + 2 : -1]
normalized_branch = _normalize_branch_name(branch_part)
raw_branch = branch_part
is_detached = raw_branch.startswith("HEAD (detached at ") or (
"detached" in raw_branch
)
if is_detached or normalized_branch.startswith("HEAD"):
detached = True
elif "..." in normalized_branch:
branch, upstream_branch = normalized_branch.split("...")
current_branch = branch or None
upstream = upstream_branch or None
else:
current_branch = normalized_branch or None
ahead, behind = _parse_ahead_behind(ahead_part)
for line in lines[1:]:
if line.startswith("?? "):
name = line[3:]
file_status.append(
GitFileStatus(
name=name,
status="untracked",
index_status="?",
working_tree_status="?",
staged=False,
)
)
continue
if len(line) < 3:
continue
index_status = line[0]
working_status = line[1]
path = line[3:]
renamed_from: Optional[str] = None
name = path
if " -> " in path:
renamed_from, name = path.split(" -> ", 1)
file_status.append(
GitFileStatus(
name=name,
status=_derive_status(index_status, working_status),
index_status=index_status,
working_tree_status=working_status,
staged=index_status not in (" ", "?"),
renamed_from=renamed_from,
)
)
return GitStatus(
current_branch=current_branch,
upstream=upstream,
ahead=ahead,
behind=behind,
detached=detached,
file_status=file_status,
)
def parse_git_branches(output: str) -> GitBranches:
"""
Parse `git branch --format=%(refname:short)\t%(HEAD)` output.
:param output: Git branch output
:return: Parsed `GitBranches`
"""
branches: List[str] = []
current_branch: Optional[str] = None
lines = [line.strip() for line in output.split("\n") if line.strip()]
for line in lines:
parts = line.split("\t")
name = parts[0]
branches.append(name)
if len(parts) > 1 and parts[1] == "*":
current_branch = name
return GitBranches(branches=branches, current_branch=current_branch)
def parse_remote_url(output: str, remote: str) -> str:
"""
Parse a git remote URL output and validate it's present.
:param output: Git remote get-url output
:param remote: Remote name for the error message
:return: Remote URL
"""
url = output.strip()
if not url:
raise InvalidArgumentException(
f'Remote "{remote}" URL not found in repository.'
)
return url