chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""Local git client: vendor-neutral branch/commit/push/status helpers.
|
||||
|
||||
Public surface for callers that need safe local git operations (e.g. shipping a
|
||||
code change as a branch + commit + push). Operations raise :class:`GitCommandError`
|
||||
with a stable ``kind`` that callers map onto their own error model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from integrations.git.errors import (
|
||||
BRANCH_FAILED,
|
||||
COMMIT_FAILED,
|
||||
GIT_UNAVAILABLE,
|
||||
NOT_A_GIT_REPO,
|
||||
PROTECTED_BRANCH,
|
||||
PUSH_FAILED,
|
||||
GitCommandError,
|
||||
)
|
||||
from integrations.git.local import (
|
||||
assert_not_protected,
|
||||
changed_paths,
|
||||
checkout_branch,
|
||||
commit_paths,
|
||||
create_branch,
|
||||
current_branch,
|
||||
default_branch,
|
||||
ensure_git_repo,
|
||||
file_fingerprints,
|
||||
is_git_repo,
|
||||
push_branch,
|
||||
short_head,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BRANCH_FAILED",
|
||||
"COMMIT_FAILED",
|
||||
"GIT_UNAVAILABLE",
|
||||
"NOT_A_GIT_REPO",
|
||||
"PROTECTED_BRANCH",
|
||||
"PUSH_FAILED",
|
||||
"GitCommandError",
|
||||
"assert_not_protected",
|
||||
"changed_paths",
|
||||
"checkout_branch",
|
||||
"commit_paths",
|
||||
"create_branch",
|
||||
"current_branch",
|
||||
"default_branch",
|
||||
"ensure_git_repo",
|
||||
"file_fingerprints",
|
||||
"is_git_repo",
|
||||
"push_branch",
|
||||
"short_head",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Neutral error type for local git operations.
|
||||
|
||||
Kept vendor- and tool-agnostic so this package can be reused by any caller
|
||||
(``fix_sentry_issue``, future GitLab flows, etc.) without depending on a tool's
|
||||
error model. Callers map ``GitCommandError.kind`` onto their own error surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Stable failure categories a git operation can raise.
|
||||
GIT_UNAVAILABLE = "git_unavailable"
|
||||
NOT_A_GIT_REPO = "not_a_git_repo"
|
||||
PROTECTED_BRANCH = "protected_branch"
|
||||
BRANCH_FAILED = "branch_failed"
|
||||
COMMIT_FAILED = "commit_failed"
|
||||
PUSH_FAILED = "push_failed"
|
||||
|
||||
|
||||
class GitCommandError(Exception):
|
||||
"""A local git operation failed, with a stable ``kind`` for the caller to map."""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Thin, safe local-git client (branch / commit / push / status / hashing).
|
||||
|
||||
Every call shells out to the ``git`` binary in the target *workspace* with an
|
||||
explicit argument list (never ``shell=True``) and a bounded timeout, and raises a
|
||||
neutral :class:`GitCommandError` on failure. The push path is deliberately narrow:
|
||||
it refuses to create or push a *protected* branch (``main``/``master``/the repo
|
||||
default) and never uses ``--force`` — the structural half of a "never push to the
|
||||
base branch" guarantee.
|
||||
|
||||
Vendor-neutral: callers pass a token for HTTPS auth, but nothing here is
|
||||
GitHub-specific.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from integrations.git.errors import (
|
||||
BRANCH_FAILED,
|
||||
COMMIT_FAILED,
|
||||
GIT_UNAVAILABLE,
|
||||
NOT_A_GIT_REPO,
|
||||
PROTECTED_BRANCH,
|
||||
PUSH_FAILED,
|
||||
GitCommandError,
|
||||
)
|
||||
|
||||
_GIT_TIMEOUT_SEC = 60
|
||||
# Networked lookups get a tighter bound so a slow/unreachable remote can't stall
|
||||
# the whole flow (they always have a safe local fallback).
|
||||
_REMOTE_TIMEOUT_SEC = 15
|
||||
# Branch names we refuse to create or push to, on top of the resolved default.
|
||||
_PROTECTED_BRANCHES = frozenset({"main", "master", "develop", "trunk"})
|
||||
|
||||
|
||||
def _run_git(
|
||||
workspace: str,
|
||||
*args: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = _GIT_TIMEOUT_SEC,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run ``git <args>`` in *workspace*; raise GitCommandError if git is missing."""
|
||||
try:
|
||||
return subprocess.run( # nosemgrep: dangerous-subprocess-use-audit
|
||||
["git", *args],
|
||||
cwd=workspace,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise GitCommandError(GIT_UNAVAILABLE, "git is not installed or not on PATH.") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise GitCommandError(
|
||||
GIT_UNAVAILABLE, f"git command timed out after {timeout:.0f}s."
|
||||
) from exc
|
||||
|
||||
|
||||
def _remote_https_base(workspace: str, remote: str = "origin") -> str:
|
||||
"""``https://host/`` of *remote* when it uses HTTPS, else "" (http/SSH/file/etc.).
|
||||
|
||||
Only HTTPS qualifies: injecting the token for a plaintext ``http://`` remote
|
||||
would send the credential in cleartext on the wire.
|
||||
"""
|
||||
result = _run_git(workspace, "remote", "get-url", remote)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
parsed = urlsplit(result.stdout.strip())
|
||||
if parsed.scheme == "https" and parsed.hostname:
|
||||
return f"https://{parsed.hostname}/"
|
||||
return ""
|
||||
|
||||
|
||||
def _token_auth_env(token: str, base_url: str) -> dict[str, str]:
|
||||
"""Env that injects an HTTP Authorization header scoped to *base_url* for this call.
|
||||
|
||||
Uses git's ``GIT_CONFIG_*`` env-config so the token never appears in argv, the
|
||||
remote URL, .git/config, or git's output. The header is scoped via
|
||||
``http.<base_url>.extraheader`` so the token is only sent to that host and never
|
||||
forwarded to other HTTPS remotes or redirects. This makes the request use the
|
||||
*provided* token instead of whatever stale credential the local git credential
|
||||
helper might have cached (the usual cause of a 403 on push).
|
||||
"""
|
||||
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
env = dict(os.environ)
|
||||
# Append at the next free index rather than clobbering an existing
|
||||
# GIT_CONFIG_COUNT / GIT_CONFIG_KEY_* the caller may already rely on.
|
||||
try:
|
||||
count = int(env.get("GIT_CONFIG_COUNT", "0") or "0")
|
||||
except ValueError:
|
||||
count = 0
|
||||
env[f"GIT_CONFIG_KEY_{count}"] = f"http.{base_url}.extraheader"
|
||||
env[f"GIT_CONFIG_VALUE_{count}"] = f"Authorization: Basic {basic}"
|
||||
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
||||
return env
|
||||
|
||||
|
||||
def is_git_repo(workspace: str) -> bool:
|
||||
"""True when *workspace* is inside a git work tree."""
|
||||
result = _run_git(workspace, "rev-parse", "--is-inside-work-tree")
|
||||
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||
|
||||
|
||||
def ensure_git_repo(workspace: str) -> None:
|
||||
if not is_git_repo(workspace):
|
||||
raise GitCommandError(NOT_A_GIT_REPO, f"{workspace} is not a git repository.")
|
||||
|
||||
|
||||
def current_branch(workspace: str) -> str:
|
||||
"""Name of the currently checked-out branch (empty on detached HEAD)."""
|
||||
result = _run_git(workspace, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
branch = result.stdout.strip()
|
||||
return "" if branch in ("", "HEAD") else branch
|
||||
|
||||
|
||||
def _remote_default_branch(workspace: str, token: str | None) -> str:
|
||||
"""The remote's default branch via ``ls-remote --symref`` (authoritative).
|
||||
|
||||
Bounded by a short timeout and returns "" on any failure/timeout, so a slow or
|
||||
unreachable remote never stalls or aborts the caller — they fall back locally.
|
||||
"""
|
||||
base = _remote_https_base(workspace, "origin")
|
||||
env = _token_auth_env(token, base) if (token and base) else None
|
||||
try:
|
||||
result = _run_git(
|
||||
workspace,
|
||||
"ls-remote",
|
||||
"--symref",
|
||||
"origin",
|
||||
"HEAD",
|
||||
env=env,
|
||||
timeout=_REMOTE_TIMEOUT_SEC,
|
||||
)
|
||||
except GitCommandError:
|
||||
return ""
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
for line in result.stdout.splitlines():
|
||||
# "ref: refs/heads/main\tHEAD"
|
||||
if line.startswith("ref:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return parts[1].removeprefix("refs/heads/")
|
||||
return ""
|
||||
|
||||
|
||||
def default_branch(workspace: str, *, token: str | None = None) -> str:
|
||||
"""Resolve the repo's default branch (the usual PR base), or "" if unknown.
|
||||
|
||||
Prefers the local ``origin/HEAD`` pointer; if it isn't configured (common on
|
||||
fresh clones), asks the remote directly. Returns "" when neither is available
|
||||
(e.g. offline) rather than guessing the current branch — callers must decide
|
||||
what to do so a PR never silently targets the wrong base.
|
||||
"""
|
||||
result = _run_git(workspace, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().removeprefix("origin/")
|
||||
return _remote_default_branch(workspace, token)
|
||||
|
||||
|
||||
def short_head(workspace: str) -> str:
|
||||
"""Short SHA of HEAD, or "" if it can't be resolved (e.g. an unborn HEAD)."""
|
||||
result = _run_git(workspace, "rev-parse", "--short", "HEAD")
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def changed_paths(workspace: str) -> list[str]:
|
||||
"""Paths with staged/unstaged/untracked changes (individual files, not dirs).
|
||||
|
||||
Uses ``-z`` (NUL-separated) so paths are returned verbatim: git's default
|
||||
porcelain C-quotes filenames with spaces, quotes, or non-ASCII bytes, which
|
||||
would then not match on ``git add``/``hash-object``.
|
||||
"""
|
||||
result = _run_git(workspace, "status", "--porcelain", "-z", "--untracked-files=all")
|
||||
tokens = result.stdout.split("\0")
|
||||
paths: list[str] = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
record = tokens[i]
|
||||
i += 1
|
||||
if len(record) < 3:
|
||||
continue
|
||||
# Porcelain: "XY <path>". Rename/copy (R/C) records carry the original path
|
||||
# in the next NUL-terminated token.
|
||||
path = record[3:]
|
||||
if path:
|
||||
paths.append(path)
|
||||
if record[0] in ("R", "C"):
|
||||
orig = tokens[i] if i < len(tokens) else ""
|
||||
i += 1
|
||||
# A rename deletes the original, so it must be committed too; a copy
|
||||
# leaves the original untouched, so it is excluded.
|
||||
if record[0] == "R" and orig:
|
||||
paths.append(orig)
|
||||
return paths
|
||||
|
||||
|
||||
def file_fingerprints(workspace: str, paths: Sequence[str]) -> dict[str, str]:
|
||||
"""Map each path to a git hash of its current worktree content ("" if unreadable).
|
||||
|
||||
Lets a caller tell whether a file that was already dirty before a run was
|
||||
actually *changed* (hash differs) versus left untouched (same hash).
|
||||
"""
|
||||
fingerprints: dict[str, str] = dict.fromkeys(paths, "")
|
||||
# Hash all files in a single git invocation (one process, not one per file).
|
||||
# Deleted/unreadable paths are filtered out first so they don't fail the batch;
|
||||
# they keep the "" fingerprint.
|
||||
existing = [p for p in paths if os.path.isfile(os.path.join(workspace, p))]
|
||||
if not existing:
|
||||
return fingerprints
|
||||
result = _run_git(workspace, "hash-object", "--", *existing)
|
||||
hashes = result.stdout.splitlines()
|
||||
if result.returncode == 0 and len(hashes) == len(existing):
|
||||
for path, digest in zip(existing, hashes):
|
||||
fingerprints[path] = digest.strip()
|
||||
return fingerprints
|
||||
|
||||
|
||||
def assert_not_protected(branch: str, *, protected_extra: str = "") -> None:
|
||||
"""Raise unless *branch* is a safe, non-base feature branch to push to."""
|
||||
name = branch.strip()
|
||||
protected = set(_PROTECTED_BRANCHES)
|
||||
if protected_extra.strip():
|
||||
protected.add(protected_extra.strip())
|
||||
if not name or name in protected:
|
||||
raise GitCommandError(
|
||||
PROTECTED_BRANCH,
|
||||
f"Refusing to create or push protected branch '{name or '(empty)'}'. "
|
||||
"Work is always shipped on a fresh namespaced branch, never the base branch.",
|
||||
)
|
||||
|
||||
|
||||
def create_branch(workspace: str, branch: str, *, base_default: str = "") -> None:
|
||||
"""Create and switch to *branch* off the current HEAD (protected-name guarded)."""
|
||||
assert_not_protected(branch, protected_extra=base_default)
|
||||
result = _run_git(workspace, "checkout", "-b", branch)
|
||||
if result.returncode != 0:
|
||||
raise GitCommandError(
|
||||
BRANCH_FAILED, f"Could not create branch '{branch}': {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def checkout_branch(workspace: str, branch: str) -> None:
|
||||
"""Switch to an already-existing local *branch* (does not create one).
|
||||
|
||||
Used to put the workspace on a known branch (typically the resolved base)
|
||||
before creating a new branch off it, so the new branch's parent is never
|
||||
whatever unrelated branch the workspace happened to have checked out.
|
||||
"""
|
||||
result = _run_git(workspace, "checkout", branch)
|
||||
if result.returncode != 0:
|
||||
raise GitCommandError(
|
||||
BRANCH_FAILED, f"Could not check out branch '{branch}': {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def commit_paths(workspace: str, paths: Sequence[str], message: str) -> None:
|
||||
"""Stage and commit *only* the given paths, excluding any other WIP in the tree.
|
||||
|
||||
``git add`` registers the paths (so newly created files are tracked), and
|
||||
``git commit --only`` commits exactly those paths — disregarding any other
|
||||
staged or unstaged changes the developer may have in the working tree.
|
||||
"""
|
||||
if not paths:
|
||||
raise GitCommandError(COMMIT_FAILED, "no files to commit.")
|
||||
|
||||
# Register the paths that still exist (new/modified files) so ``--only`` can
|
||||
# commit them; deleted paths (e.g. a rename's original) are skipped here and
|
||||
# handled by ``git commit --only``, which records their removal.
|
||||
existing = [p for p in paths if os.path.isfile(os.path.join(workspace, p))]
|
||||
if existing:
|
||||
add = _run_git(workspace, "add", "--", *existing)
|
||||
if add.returncode != 0:
|
||||
raise GitCommandError(COMMIT_FAILED, f"git add failed: {add.stderr.strip()}")
|
||||
|
||||
commit = _run_git(workspace, "commit", "--only", "-m", message, "--", *paths)
|
||||
if commit.returncode != 0:
|
||||
raise GitCommandError(COMMIT_FAILED, f"git commit failed: {commit.stderr.strip()}")
|
||||
|
||||
|
||||
def push_branch(
|
||||
workspace: str,
|
||||
branch: str,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
base_default: str = "",
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
"""Push *branch* to *remote* with upstream tracking. Never force, never base branch.
|
||||
|
||||
When *token* is given and *remote* is an HTTPS URL, the push authenticates with
|
||||
that token (via an ephemeral, host-scoped HTTP header) instead of the machine's
|
||||
cached git credentials. For SSH/other remotes the token is not injected (the
|
||||
transport authenticates itself).
|
||||
"""
|
||||
assert_not_protected(branch, protected_extra=base_default)
|
||||
env = None
|
||||
if token:
|
||||
base = _remote_https_base(workspace, remote)
|
||||
if base:
|
||||
env = _token_auth_env(token, base)
|
||||
result = _run_git(workspace, "push", "--set-upstream", remote, branch, env=env)
|
||||
if result.returncode != 0:
|
||||
raise GitCommandError(
|
||||
PUSH_FAILED, f"git push to {remote}/{branch} failed: {result.stderr.strip()}"
|
||||
)
|
||||
Reference in New Issue
Block a user