364 lines
9.5 KiB
Python
364 lines
9.5 KiB
Python
from typing import List, Optional
|
|
|
|
from e2b.exceptions import InvalidArgumentException
|
|
from e2b.sandbox._git.auth import strip_credentials, with_credentials
|
|
from e2b.sandbox._git.parse import derive_repo_dir_from_url
|
|
from e2b.sandbox._git.types import ClonePlan
|
|
|
|
|
|
def shell_escape(value: str) -> str:
|
|
"""
|
|
Escape a string for safe use in a shell command.
|
|
|
|
:param value: Value to escape
|
|
:return: Shell-escaped string
|
|
"""
|
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
|
|
|
|
def build_git_command(args: List[str], repo_path: Optional[str] = None) -> str:
|
|
"""
|
|
Build a shell-safe git command string.
|
|
|
|
:param args: Git command arguments
|
|
:param repo_path: Repository path for `git -C`, if provided
|
|
:return: Shell-safe git command
|
|
"""
|
|
parts = ["git"]
|
|
if repo_path:
|
|
parts.extend(["-C", repo_path])
|
|
parts.extend(args)
|
|
return " ".join(shell_escape(part) for part in parts)
|
|
|
|
|
|
def build_push_args(
|
|
remote_name: Optional[str],
|
|
*,
|
|
remote: Optional[str],
|
|
branch: Optional[str],
|
|
set_upstream: bool,
|
|
) -> List[str]:
|
|
"""
|
|
Build arguments for a git push command.
|
|
|
|
:param remote_name: Resolved remote name, if any
|
|
:param remote: Remote name override
|
|
:param branch: Branch name to push
|
|
:param set_upstream: Whether to set upstream tracking
|
|
:return: List of git push arguments
|
|
"""
|
|
args = ["push"]
|
|
target_remote = remote_name or remote
|
|
if set_upstream and target_remote:
|
|
args.append("--set-upstream")
|
|
if target_remote:
|
|
args.append(target_remote)
|
|
if branch:
|
|
args.append(branch)
|
|
return args
|
|
|
|
|
|
def build_pull_args(
|
|
remote: Optional[str],
|
|
branch: Optional[str],
|
|
remote_name: Optional[str] = None,
|
|
) -> List[str]:
|
|
"""
|
|
Build arguments for a git pull command.
|
|
|
|
:param remote: Remote name override
|
|
:param branch: Branch name to pull
|
|
:param remote_name: Resolved remote name, if any
|
|
:return: List of git pull arguments
|
|
"""
|
|
args = ["pull"]
|
|
target_remote = remote_name or remote
|
|
if target_remote:
|
|
args.append(target_remote)
|
|
if branch:
|
|
args.append(branch)
|
|
return args
|
|
|
|
|
|
def build_remote_add_args(name: str, url: str, fetch: bool) -> List[str]:
|
|
"""
|
|
Build arguments for a git remote add command.
|
|
|
|
:param name: Remote name
|
|
:param url: Remote URL
|
|
:param fetch: Whether to fetch after adding the remote
|
|
:return: List of git remote add arguments
|
|
"""
|
|
if not name or not url:
|
|
raise InvalidArgumentException(
|
|
"Both remote name and URL are required to add a git remote."
|
|
)
|
|
|
|
args = ["remote", "add"]
|
|
if fetch:
|
|
args.append("-f")
|
|
args.extend([name, url])
|
|
return args
|
|
|
|
|
|
def build_remote_add_shell_command(
|
|
args: List[str],
|
|
path: str,
|
|
name: str,
|
|
url: str,
|
|
fetch: bool,
|
|
) -> str:
|
|
"""
|
|
Build a shell command that adds or updates a remote and optionally fetches.
|
|
|
|
:param args: Base git remote add args
|
|
:param path: Repository path
|
|
:param name: Remote name
|
|
:param url: Remote URL
|
|
:param fetch: Whether to fetch after adding the remote
|
|
:return: Shell command string
|
|
"""
|
|
add_cmd = build_git_command(args, path)
|
|
set_url_cmd = build_git_command(build_remote_set_url_args(name, url), path)
|
|
cmd = f"{add_cmd} || {set_url_cmd}"
|
|
if fetch:
|
|
fetch_cmd = build_git_command(["fetch", name], path)
|
|
cmd = f"({cmd}) && {fetch_cmd}"
|
|
return cmd
|
|
|
|
|
|
def build_remote_get_url_args(name: str) -> List[str]:
|
|
"""
|
|
Build arguments for a git remote get-url command.
|
|
"""
|
|
return ["remote", "get-url", name]
|
|
|
|
|
|
def build_remote_set_url_args(name: str, url: str) -> List[str]:
|
|
"""
|
|
Build arguments for a git remote set-url command.
|
|
"""
|
|
return ["remote", "set-url", name, url]
|
|
|
|
|
|
def build_remote_get_command(path: str, name: str) -> str:
|
|
"""
|
|
Build a shell command that returns the remote URL or empty output.
|
|
|
|
:param path: Repository path
|
|
:param name: Remote name
|
|
:return: Shell command string
|
|
"""
|
|
if not name:
|
|
raise InvalidArgumentException("Remote name is required.")
|
|
|
|
return f"{build_git_command(build_remote_get_url_args(name), path)} || true"
|
|
|
|
|
|
def build_credential_approve_command(
|
|
username: str,
|
|
password: str,
|
|
host: str,
|
|
protocol: str,
|
|
) -> str:
|
|
"""
|
|
Build a git credential approve command for the given credentials.
|
|
"""
|
|
target_host = host.strip() or "github.com"
|
|
target_protocol = protocol.strip() or "https"
|
|
credential_input = "\n".join(
|
|
[
|
|
f"protocol={target_protocol}",
|
|
f"host={target_host}",
|
|
f"username={username}",
|
|
f"password={password}",
|
|
"",
|
|
"",
|
|
]
|
|
)
|
|
return (
|
|
f"printf %s {shell_escape(credential_input)} | "
|
|
f"{build_git_command(['credential', 'approve'])}"
|
|
)
|
|
|
|
|
|
def build_has_upstream_args() -> List[str]:
|
|
"""
|
|
Build arguments for a git upstream check command.
|
|
"""
|
|
return ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
|
|
|
|
|
|
def build_status_args() -> List[str]:
|
|
"""
|
|
Build arguments for a git status command.
|
|
"""
|
|
return ["status", "--porcelain=1", "-b"]
|
|
|
|
|
|
def build_branches_args() -> List[str]:
|
|
"""
|
|
Build arguments for a git branch listing command.
|
|
"""
|
|
return ["branch", "--format=%(refname:short)\t%(HEAD)"]
|
|
|
|
|
|
def build_create_branch_args(branch: str) -> List[str]:
|
|
"""
|
|
Build arguments for a git checkout -b command.
|
|
"""
|
|
return ["checkout", "-b", branch]
|
|
|
|
|
|
def build_checkout_branch_args(branch: str) -> List[str]:
|
|
"""
|
|
Build arguments for a git checkout command.
|
|
"""
|
|
return ["checkout", branch]
|
|
|
|
|
|
def build_delete_branch_args(branch: str, force: bool) -> List[str]:
|
|
"""
|
|
Build arguments for a git branch delete command.
|
|
"""
|
|
return ["branch", "-D" if force else "-d", branch]
|
|
|
|
|
|
def build_add_args(files: Optional[List[str]], all: bool) -> List[str]:
|
|
"""
|
|
Build arguments for a git add command.
|
|
"""
|
|
args = ["add"]
|
|
if not files:
|
|
args.append("-A" if all else ".")
|
|
else:
|
|
args.append("--")
|
|
args.extend(files)
|
|
return args
|
|
|
|
|
|
def build_commit_args(
|
|
message: str,
|
|
author_name: Optional[str],
|
|
author_email: Optional[str],
|
|
allow_empty: bool,
|
|
) -> List[str]:
|
|
"""
|
|
Build arguments for a git commit command.
|
|
"""
|
|
args = ["commit", "-m", message]
|
|
if allow_empty:
|
|
args.append("--allow-empty")
|
|
author_args: List[str] = []
|
|
if author_name:
|
|
author_args.extend(["-c", f"user.name={author_name}"])
|
|
if author_email:
|
|
author_args.extend(["-c", f"user.email={author_email}"])
|
|
if author_args:
|
|
args = author_args + args
|
|
return args
|
|
|
|
|
|
def build_reset_args(
|
|
mode: Optional[str],
|
|
target: Optional[str],
|
|
paths: Optional[List[str]],
|
|
) -> List[str]:
|
|
"""
|
|
Build arguments for a git reset command.
|
|
"""
|
|
allowed_modes = ["soft", "mixed", "hard", "merge", "keep"]
|
|
if mode and mode not in allowed_modes:
|
|
raise InvalidArgumentException(
|
|
f"Reset mode must be one of {', '.join(allowed_modes)}."
|
|
)
|
|
|
|
args = ["reset"]
|
|
if mode:
|
|
args.append(f"--{mode}")
|
|
if target:
|
|
args.append(target)
|
|
if paths:
|
|
args.append("--")
|
|
args.extend(paths)
|
|
return args
|
|
|
|
|
|
def build_restore_args(
|
|
paths: List[str],
|
|
staged: Optional[bool],
|
|
worktree: Optional[bool],
|
|
source: Optional[str],
|
|
) -> List[str]:
|
|
"""
|
|
Build arguments for a git restore command.
|
|
"""
|
|
if not paths:
|
|
raise InvalidArgumentException("At least one path is required.")
|
|
|
|
resolved_staged = staged
|
|
resolved_worktree = worktree
|
|
if staged is None and worktree is None:
|
|
resolved_worktree = True
|
|
elif staged is True and worktree is None:
|
|
resolved_worktree = False
|
|
elif staged is None and worktree is not None:
|
|
resolved_staged = False
|
|
|
|
if resolved_staged is False and resolved_worktree is False:
|
|
raise InvalidArgumentException(
|
|
"At least one of staged or worktree must be true."
|
|
)
|
|
|
|
args = ["restore"]
|
|
if resolved_worktree:
|
|
args.append("--worktree")
|
|
if resolved_staged:
|
|
args.append("--staged")
|
|
if source:
|
|
args.extend(["--source", source])
|
|
args.append("--")
|
|
args.extend(paths)
|
|
return args
|
|
|
|
|
|
def build_clone_plan(
|
|
url: str,
|
|
path: Optional[str],
|
|
branch: Optional[str],
|
|
depth: Optional[int],
|
|
auth_username: Optional[str],
|
|
auth_password: Optional[str],
|
|
dangerously_store_credentials: bool,
|
|
) -> ClonePlan:
|
|
"""
|
|
Build clone arguments and metadata for post-clone credential stripping.
|
|
"""
|
|
clone_url = (
|
|
with_credentials(url, auth_username, auth_password)
|
|
if auth_username and auth_password
|
|
else url
|
|
)
|
|
sanitized_url = strip_credentials(clone_url)
|
|
should_strip = not dangerously_store_credentials and sanitized_url != clone_url
|
|
repo_path = path if not should_strip else path or derive_repo_dir_from_url(url)
|
|
if should_strip and not repo_path:
|
|
raise InvalidArgumentException(
|
|
"A destination path is required when using credentials without storing them."
|
|
)
|
|
|
|
args = ["clone", clone_url]
|
|
if branch:
|
|
args.extend(["--branch", branch, "--single-branch"])
|
|
if depth:
|
|
args.extend(["--depth", str(depth)])
|
|
if path:
|
|
args.append(path)
|
|
|
|
return ClonePlan(
|
|
args=args,
|
|
repo_path=repo_path,
|
|
sanitized_url=sanitized_url if should_strip else None,
|
|
should_strip=should_strip,
|
|
)
|