Files
cleveragents-core/tools/mcp_git_server.py
T
drew 615a05b982 feat(controller): rebase-default conflict resolution with merge fallback
PR branches 177-180 commits ahead of base cannot be rebased
commit-by-commit by a single-shot resolver agent (too many conflict
stops for one session). Conflict-prep now defaults to rebase (linear
history) and falls back to a single 3-way merge when the branch is too
divergent (commit count over CONTROLLER_CONFLICT_REBASE_MAX_COMMITS,
default 60). The merge pipeline derives the track from branch shape via
a Do:rebase -> Do:merge ladder in _make_merge_pr — no stored flag.

Adds a git_rebase_continue MCP tool plus status rebase/merge-in-progress
fields so the conflict-resolver agent is fully MCP-driven and dual-mode
(mid-rebase or mid-merge). Also routes a green-CI implementer noop
straight to REVIEWING instead of a deadlock-prone AWAITING_CI round
trip. No state-machine change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:16 -04:00

1145 lines
42 KiB
Python

#!/usr/bin/env python3
"""MCP server wrapping git plumbing for the auto-agents pipeline.
Replaces the fleet of single-op subagents in ``.opencode/agents/`` —
``git-isolator-util``, ``git-clone-util``, ``git-fetch-util``,
``git-checkout-util``, ``git-stage-util``, ``git-commit-util``,
``git-create-commit-util``, ``git-commit-and-push-util``,
``git-push-util``, ``git-rebase-util``, ``git-rebase-and-push-util``,
``git-force-push-with-lease-util``, ``git-cleanup-util``. That layer
existed only as a permission-scoping pattern (each agent's narrow
bash allowlist covered exactly one git operation). With this MCP,
agents call typed tools — no subagent overhead, no per-op prompt
boilerplate, and the worktree-path allowlist lives in one file
instead of being scattered across 13.
Path safety
-----------
Every tool that takes a ``worktree`` argument validates the path
against an allowlist of acceptable bases — currently
``/tmp/cleveragents-implementer-worktrees/`` and
``/tmp/cleveragents-review-worktrees/``. A request to operate on
``/etc``, ``/`` or the host repo is rejected with a clear error
before any git invocation runs. The allowlist can be extended via
``MCP_GIT_WORKTREE_BASES`` (colon-separated paths) for edge cases
like local development or future dispatchers.
Identity
--------
Pushes authenticate as HAL9000 (``FORGEJO_PAT``) — the worker
identity for code changes. The reviewer never commits or pushes
under normal operation; if a future workflow needs it, add an
explicit ``as_reviewer=True`` parameter to ``push`` rather than
flipping a runtime toggle. Author/committer identity for
:func:`commit` comes from ``GIT_USER_NAME`` / ``GIT_USER_EMAIL``
which ``tools/launch_fork.sh`` sets to ``CleverThis`` /
``hal9000@cleverthis.com``.
Tools
-----
``isolate(pr, head_sha, head_ref=None, kind="implementer")``
Pre-clone a PR's head SHA into a fresh worktree under the
kind-specific base. Wraps ``_pr_clone.prepare_pr_worktree``.
Returns ``{worktree, branch, kind, run_tag}`` or
``{error: str}``.
``status(worktree)`` / ``stage(worktree, paths)`` /
``commit(worktree, message, author_name?, author_email?)`` /
``push(worktree, remote="origin", force_with_lease=False)`` /
``fetch(worktree, remote="origin")`` /
``rebase(worktree, onto)`` / ``rebase_continue(worktree)`` /
``cleanup(worktree)``
Standard git plumbing — see each tool's docstring for shape.
``rebase_continue`` resumes a conflict-stopped rebase after the
agent has resolved + staged the current step (mid-rebase mode);
``status`` also reports ``rebase_in_progress`` /
``merge_in_progress`` / a ``conflicts`` list so the conflict-
resolver can drive either a mid-rebase or a mid-merge worktree.
Each write tool returns a small structured dict with the next-step
fact the agent needs (e.g. ``commit`` returns ``{sha}``,
``push`` returns ``{remote_sha}``, ``rebase`` returns
``{success, conflicts}``). On failure the tool returns
``{error: str, stderr: str}`` so the agent can surface the real
git error without re-reading transcript output.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
sys.path.insert(0, str(Path(__file__).parent))
from _mcp_common import ( # noqa: E402
ForgejoCfg,
bootstrap_loader,
error_envelope as _err,
make_main,
require_token,
)
load_sibling = bootstrap_loader()
_pr_clone = load_sibling("_pr_clone", "_pr_clone.py")
server = FastMCP("git")
# ─── Worktree allowlist ────────────────────────────────────────────
def _allowed_bases() -> tuple[Path, ...]:
"""Resolve the set of base directories any ``worktree`` argument
is allowed to live under. The defaults match the dispatcher's
pre-clone targets; the env var lets operators extend (e.g. for
a one-off debug worktree elsewhere)."""
defaults = (
"/tmp/cleveragents-implementer-worktrees",
"/tmp/cleveragents-review-worktrees",
# The state-machine controller pre-clones every role's worktree
# under this root (workspace.py DEFAULT_WORKSPACE_ROOT). Without
# it the conflict-resolver's git_* MCP calls all error and the
# agent silently falls back to bash.
"/tmp/cleveragents-controller",
)
extra = os.environ.get("MCP_GIT_WORKTREE_BASES", "").strip()
extras = tuple(p for p in extra.split(":") if p) if extra else ()
return tuple(Path(p).resolve() for p in (defaults + extras))
def _validate_worktree(worktree: str) -> tuple[Path | None, str | None]:
"""Return ``(resolved_path, None)`` if ``worktree`` is allowed,
or ``(None, error_message)`` if not. Resolves symlinks so an
agent cannot escape the allowlist by linking through /tmp."""
if not worktree or not worktree.strip():
return None, "worktree path must be non-empty"
try:
resolved = Path(worktree).expanduser().resolve()
except (OSError, RuntimeError) as exc:
return None, f"could not resolve {worktree!r}: {exc}"
if not resolved.exists():
return None, f"worktree does not exist at {resolved}"
if not resolved.is_dir():
return None, f"worktree is not a directory: {resolved}"
for base in _allowed_bases():
try:
resolved.relative_to(base)
return resolved, None
except ValueError:
continue
bases = ", ".join(str(b) for b in _allowed_bases())
return None, (
f"worktree {resolved} is outside the allowed bases ({bases}). "
"Set MCP_GIT_WORKTREE_BASES to extend if this is intentional."
)
# ─── Subprocess wrapping ───────────────────────────────────────────
_DEFAULT_GIT_TIMEOUT_S = int(os.environ.get("MCP_GIT_TIMEOUT_S", "300"))
def _ensure_askpass_for_hal9000() -> Path:
"""Create a 0700 askpass shim that returns the HAL9000 PAT.
Mirrors the pattern in ``_pr_clone_creds._ensure_askpass_script``
but pins to ``FORGEJO_PAT`` (HAL9000) instead of
``FORGEJO_REVIEWER_PAT`` (HAL9001). The MCP server is per-process
long-lived, so the script is created lazily on first push/fetch
and reused thereafter.
"""
global _ASKPASS_PATH
if _ASKPASS_PATH is not None and _ASKPASS_PATH.exists():
return _ASKPASS_PATH
fd, path = tempfile.mkstemp(prefix="mcp-git-askpass-", suffix=".sh")
with os.fdopen(fd, "w") as f:
f.write(
"#!/bin/sh\n"
"# Generated by tools/mcp_git_server.py — HAL9000 (FORGEJO_PAT).\n"
'prompt="${1:-}"\n'
'lower=$(printf "%s" "$prompt" | tr "[:upper:]" "[:lower:]")\n'
'case "$lower" in\n'
" *username*)\n"
' printf "%s\\n" "${FORGEJO_USERNAME:-x-token-auth}"\n'
" ;;\n"
" *)\n"
' printf "%s\\n" "${FORGEJO_PAT:-${GITEA_TOKEN:-}}"\n'
" ;;\n"
"esac\n"
)
os.chmod(path, 0o700)
_ASKPASS_PATH = Path(path)
return _ASKPASS_PATH
_ASKPASS_PATH: Path | None = None
def _git_env_for_push() -> dict[str, str]:
"""Build a clean env for a git subprocess that pushes as HAL9000.
Matches the ``_pr_clone_creds._GIT_ENV_PASSTHROUGH`` allowlist —
only proxy / SSL / locale / TMPDIR vars carry through; everything
else is dropped to keep unrelated secrets out of any git hook the
push triggers.
"""
askpass = _ensure_askpass_for_hal9000()
# The worktree credential helper (worker/workspace.py) sources the
# token from $FORGEJO_TOKEN. Resolve it with the same precedence
# the askpass shim uses (FORGEJO_PAT → GITEA_TOKEN) so a push
# authenticates whether it goes through the shim OR the worktree
# helper. Without FORGEJO_TOKEN the helper sends an empty password
# and Forgejo rejects it as "Credentials are incorrect or expired".
forgejo_token = os.environ.get("FORGEJO_PAT") or os.environ.get("GITEA_TOKEN") or os.environ.get("HAL_9000_API_KEY") or ""
env: dict[str, str] = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/tmp"),
"GIT_ASKPASS": str(askpass),
"GIT_TERMINAL_PROMPT": "0",
"FORGEJO_PAT": os.environ.get("FORGEJO_PAT", ""),
"FORGEJO_TOKEN": forgejo_token,
"FORGEJO_USERNAME": os.environ.get("FORGEJO_USERNAME", "x-token-auth"),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""),
}
for name in (
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"GIT_SSL_NO_VERIFY",
"http_proxy",
"https_proxy",
"no_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TMPDIR",
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
):
v = os.environ.get(name)
if v is not None:
env[name] = v
return env
def _run_git(
args: list[str],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
timeout: int = _DEFAULT_GIT_TIMEOUT_S,
) -> tuple[int, str, str]:
"""Run a git subprocess and return ``(returncode, stdout, stderr)``.
Catches timeouts and FileNotFoundError; returns ``(-1, "",
error_message)`` in those cases so callers can branch on
``returncode`` uniformly.
"""
cmd = ["git", *args]
try:
result = subprocess.run(
cmd,
cwd=str(cwd) if cwd is not None else None,
env=env,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return -1, "", f"git timed out after {timeout}s: {' '.join(cmd)}"
except FileNotFoundError:
return -1, "", "git binary not found on PATH"
return result.returncode, result.stdout or "", result.stderr or ""
# ``_err`` is the shared :func:`_mcp_common.error_envelope` imported above.
def _rel_path_in_worktree(wt: Path, path: str) -> tuple[str, str | None]:
"""Validate ``path`` is a worktree-relative path that stays inside
``wt``.
Returns ``(normalised_relative_path, None)`` on success or
``("", error_message)`` on rejection. Rejects empty / non-string
paths, absolute paths, and any path that resolves outside the
worktree — ``..`` traversal OR a symlink escape (``resolve()``
follows links, then ``relative_to`` confirms containment).
"""
if not path or not isinstance(path, str):
return "", "path must be a non-empty string"
if path.startswith("/"):
return "", f"path must be worktree-relative, got absolute: {path!r}"
if ".." in path.split("/"):
return "", f"path must not contain '..': {path!r}"
try:
resolved = (wt / path).resolve()
except (OSError, RuntimeError) as exc:
return "", f"could not resolve path {path!r}: {exc}"
try:
rel = resolved.relative_to(wt)
except ValueError:
return "", f"path {path!r} resolves outside the worktree"
return str(rel), None
# ─── Tools ─────────────────────────────────────────────────────────
@server.tool()
def isolate(
pr: int,
head_sha: str,
head_ref: str | None = None,
kind: str = "implementer",
) -> dict[str, Any]:
"""Pre-clone a PR's HEAD SHA into a fresh worktree.
Wraps ``_pr_clone.prepare_pr_worktree`` which uses a bare mirror
at ``/tmp/.cleveragents-mirror.git`` plus ``git worktree add``
for speed. Requires ``IMPLEMENTER_DISPATCHER_PRECLONE=1`` (or
the review-side equivalent) — same gating as the dispatcher's
own pre-clone path.
Parameters:
pr: PR number.
head_sha: the SHA to materialise (caller is responsible for
fetching this from Forgejo first — typically via the
forgejo MCP's ``fetch_pr`` and reading ``head.sha``).
head_ref: optional branch ref (saves a ``git for-each-ref``
call inside the helper). Passes through verbatim.
kind: ``"implementer"`` or ``"review"``. Selects the
worktree base and gating flag.
Returns ``{worktree, branch, kind, run_tag}`` on success or
``{error: str}`` if the pre-clone is gated off / mirror fetch
fails / SHA invalid. On gate-off the caller should fall back to
a plain `clone` operation (not yet implemented as a tool).
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err(err, pr=pr)
try:
handle = _pr_clone.prepare_pr_worktree(
cfg,
int(pr),
head_sha,
head_ref=head_ref or "",
kind=kind,
)
except Exception as exc:
return _err(f"prepare_pr_worktree raised: {exc!r}", pr=pr)
if handle is None:
return _err(
"pre-clone returned None (gated off, mirror fetch failed, or "
"SHA invalid — check dispatcher logs)",
pr=pr,
)
return {
"worktree": str(handle.path),
"branch": handle.head_ref or "",
"kind": handle.kind,
"run_tag": handle.path.name.rsplit("-", 1)[-1],
}
# Porcelain XY codes that mark an unmerged (conflicted) path. Both
# ``status`` and the conflict-resolver use this set to enumerate the
# files git considers still in conflict.
_UNMERGED_CODES = frozenset(("UU", "AA", "DD", "AU", "UA", "DU", "UD"))
def _unmerged_paths(cwd: Path) -> list[str]:
"""Worktree paths git reports as unmerged (conflicted), keyed on the
porcelain XY codes in ``_UNMERGED_CODES``.
Shared by ``rebase`` / ``rebase_continue`` so their conflict
detection is identical to ``status`` — the prior ad-hoc
``startswith(("U", "AA", "DD"))`` parse missed the ``AU`` / ``UA`` /
``DU`` / ``UD`` unmerged codes.
"""
rc, out, _ = _run_git(["status", "--porcelain=v1", "-z"], cwd=cwd)
if rc != 0:
return []
paths: list[str] = []
for entry in out.split("\x00"):
if len(entry) < 4:
continue
if entry[:2] in _UNMERGED_CODES:
paths.append(entry[3:])
return paths
@server.tool()
def status(worktree: str) -> dict[str, Any]:
"""Compact working-tree status: branch, HEAD SHA, file lists.
Returns:
``{worktree, branch, head_sha, staged, unstaged, untracked,
rebase_in_progress, merge_in_progress, conflicts}``
where ``staged`` / ``unstaged`` / ``untracked`` / ``conflicts``
are lists of file paths relative to the worktree root. Easier for
the agent than parsing ``git status --porcelain`` itself.
``rebase_in_progress`` is True when ``.git/rebase-merge`` or
``.git/rebase-apply`` exists (a rebase has stopped, typically on a
conflict). ``merge_in_progress`` is True when ``.git/MERGE_HEAD``
exists (a ``git merge`` stopped on a conflict). ``conflicts`` lists
every path with an unmerged porcelain code (``UU``/``AA``/``DD``/
``AU``/``UA``/``DU``/``UD``) — the conflict-resolver uses it to
enumerate what it still has to resolve in either mode.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
branch_rc, branch_out, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt)
sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt)
porcelain_rc, porcelain_out, porcelain_err = _run_git(
["status", "--porcelain=v1", "-z"], cwd=wt
)
if porcelain_rc != 0:
return _err(f"git status failed: {porcelain_err.strip()}", worktree=str(wt))
staged: list[str] = []
unstaged: list[str] = []
untracked: list[str] = []
conflicts: list[str] = []
# -z: NUL-separated entries; "XY path" where X/Y are status codes
for entry in porcelain_out.split("\x00"):
if not entry or len(entry) < 3:
continue
x, y, path = entry[0], entry[1], entry[3:]
if x == "?" and y == "?":
untracked.append(path)
continue
if (x + y) in _UNMERGED_CODES:
conflicts.append(path)
if x != " ":
staged.append(path)
if y != " ":
unstaged.append(path)
git_dir = wt / ".git"
rebase_in_progress = (git_dir / "rebase-merge").exists() or (
git_dir / "rebase-apply"
).exists()
merge_in_progress = (git_dir / "MERGE_HEAD").exists()
return {
"worktree": str(wt),
"branch": branch_out.strip() if branch_rc == 0 else "",
"head_sha": sha_out.strip() if sha_rc == 0 else "",
"staged": staged,
"unstaged": unstaged,
"untracked": untracked,
"rebase_in_progress": rebase_in_progress,
"merge_in_progress": merge_in_progress,
"conflicts": conflicts,
}
@server.tool()
def stage(worktree: str, paths: list[str]) -> dict[str, Any]:
"""``git add`` the given paths (relative to worktree).
Returns ``{staged: [...], skipped: [{path, reason}]}``. Paths
are passed individually so a single bad path doesn't fail the
whole batch — each one's outcome is reported.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not paths:
return _err("paths must be a non-empty list", worktree=str(wt))
staged: list[str] = []
skipped: list[dict[str, str]] = []
for p in paths:
if not p or not isinstance(p, str):
skipped.append({"path": str(p), "reason": "empty or non-string"})
continue
# Reject absolute paths or path traversal that could escape
# the worktree. ``git add`` itself would reject most of these
# but a clear error here is friendlier than a cryptic git one.
if p.startswith("/") or ".." in p.split("/"):
skipped.append({"path": p, "reason": "absolute or contains '..'"})
continue
rc, _, err_out = _run_git(["add", "--", p], cwd=wt)
if rc != 0:
skipped.append({"path": p, "reason": err_out.strip()[:200]})
else:
staged.append(p)
return {"staged": staged, "skipped": skipped}
@server.tool()
def commit(
worktree: str,
message: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict[str, Any]:
"""``git commit -m`` the staged changes.
Returns ``{sha}`` on success or ``{error, stderr}`` on failure.
``author_name`` / ``author_email`` default to
``GIT_USER_NAME`` / ``GIT_USER_EMAIL`` from env which
``tools/launch_fork.sh`` sets to ``CleverThis`` /
``hal9000@cleverthis.com``. A commit with nothing staged returns
``{error: "nothing to commit"}`` rather than the git-default
error message.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not message or not message.strip():
return _err("commit message must be non-empty", worktree=str(wt))
name = author_name or os.environ.get("GIT_USER_NAME") or "CleverThis"
email = author_email or os.environ.get("GIT_USER_EMAIL") or "hal9000@cleverthis.com"
env = dict(os.environ)
env["GIT_AUTHOR_NAME"] = name
env["GIT_AUTHOR_EMAIL"] = email
env["GIT_COMMITTER_NAME"] = name
env["GIT_COMMITTER_EMAIL"] = email
rc, _, err_out = _run_git(["commit", "-m", message], cwd=wt, env=env)
if rc != 0:
lowered = err_out.lower()
if "nothing to commit" in lowered or "no changes added" in lowered:
return _err("nothing to commit", worktree=str(wt))
return _err(f"git commit failed", stderr=err_out.strip(), worktree=str(wt))
sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt)
return {"sha": sha_out.strip() if sha_rc == 0 else ""}
@server.tool()
def checkout_conflict(worktree: str, path: str, side: str) -> dict[str, Any]:
"""Resolve ONE conflicted file by taking an entire side, then stage it.
Runs ``git checkout --ours|--theirs -- <path>`` followed by
``git add -- <path>`` so the file ends up fully resolved (working
tree + index). This is the conflict-resolver's deterministic
side-selection primitive.
Use this — an MCP tool, permission-matched by tool NAME (``git*``)
— rather than the built-in ``edit``/``write`` tools, which are
matched by a file-path glob that does not reach deep worktree
paths (the PR-40 incident, 2026-05-21).
``side`` is ``"ours"`` or ``"theirs"``. NOTE the rebase sense:
during ``git rebase`` ``ours`` is the branch being rebased ONTO
(the base) and ``theirs`` is the PR commit being replayed. The
caller owns that semantic choice; this tool only passes it on.
Returns ``{path, side, resolved: true}`` or ``{error, stderr}``.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if side not in ("ours", "theirs"):
return _err(
f"side must be 'ours' or 'theirs', got {side!r}", worktree=str(wt)
)
rel, perr = _rel_path_in_worktree(wt, path)
if perr:
return _err(perr, worktree=str(wt))
rc, _, err_out = _run_git(["checkout", f"--{side}", "--", rel], cwd=wt)
if rc != 0:
return _err(
"git checkout failed", stderr=err_out.strip(), worktree=str(wt), path=rel
)
add_rc, _, add_err = _run_git(["add", "--", rel], cwd=wt)
if add_rc != 0:
return _err(
"git add failed after checkout",
stderr=add_err.strip(),
worktree=str(wt),
path=rel,
)
return {"path": rel, "side": side, "resolved": True}
@server.tool()
def write_file(worktree: str, path: str, content: str) -> dict[str, Any]:
"""Write ``content`` to ``path`` (relative to ``worktree``),
overwriting any existing file and creating parent dirs as needed.
The conflict-resolver's tool for three-way / manual merges — when
no single side wins and it must compose the resolved file by hand.
Use this — an MCP tool, permission-matched by tool NAME (``git*``)
— rather than the built-in ``write``/``edit`` tools, which are
matched by a file-path glob that does not reach deep worktree
paths (the PR-40 incident, 2026-05-21).
Does NOT stage — call ``git_stage`` afterwards (mirrors the
write-then-stage protocol). ``path`` must stay inside the worktree;
absolute paths and ``..`` traversal are rejected.
Returns ``{path, bytes_written}`` or ``{error}``.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not isinstance(content, str):
return _err("content must be a string", worktree=str(wt))
rel, perr = _rel_path_in_worktree(wt, path)
if perr:
return _err(perr, worktree=str(wt))
target = wt / rel
try:
target.parent.mkdir(parents=True, exist_ok=True)
data = content.encode("utf-8")
target.write_bytes(data)
except OSError as exc:
return _err(f"write failed: {exc}", worktree=str(wt), path=rel)
return {"path": rel, "bytes_written": len(data)}
@server.tool()
def push(
worktree: str,
remote: str = "origin",
force_with_lease: bool = False,
) -> dict[str, Any]:
"""``git push`` the current branch.
Authenticates as HAL9000 via the MCP's own askpass shim (does
NOT use the reviewer PAT). Returns ``{remote_sha, branch}`` on
success or ``{error, stderr}`` on failure.
``force_with_lease=True`` uses ``--force-with-lease`` which is
safe against concurrent pushes (refuses to overwrite if the
remote moved since the worker last fetched). Plain ``--force``
is intentionally NOT supported — it's the kind of thing whose
blast radius justifies a human in the loop.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not os.environ.get("FORGEJO_PAT") and not os.environ.get("GITEA_TOKEN"):
return _err(
"FORGEJO_PAT / GITEA_TOKEN not set in MCP environment — push "
"would prompt for credentials (which terminal_prompt=0 refuses)."
)
branch_rc, branch_out, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt)
if branch_rc != 0 or not branch_out.strip():
return _err("could not resolve current branch", worktree=str(wt))
branch = branch_out.strip()
if branch == "HEAD":
return _err(
"worktree is in detached HEAD state — checkout a branch first",
worktree=str(wt),
)
env = _git_env_for_push()
# Refresh the remote-tracking ref BEFORE pushing. Without this,
# ``--force-with-lease`` (no value form) uses whatever
# ``refs/remotes/{remote}/{branch}`` was at the start of the
# cycle, which is usually stale by the time the worker reaches
# the push step — leading to spurious
# ``! [rejected] (stale info)`` failures even though there is no
# actual concurrent push to race against. The fetch + explicit
# ``--force-with-lease=<ref>:<sha>`` form pin the lease to the
# tip we just observed, eliminating the false-rejection class.
# Live-confirmed on 2026-05-16 (PR #30): five bash-path pushes
# all failed with "stale remote state info" while the worker
# made no other progress; this MCP path is engineered to avoid
# that mode.
# Explicit refspec form (``+<branch>:refs/remotes/origin/<branch>``)
# is critical here: the bare ``git fetch origin <branch>`` shape
# would update the local ``<branch>`` if it exists, and git
# REFUSES that when the local ``<branch>`` is currently
# checked out ("fatal: refusing to fetch into branch ... checked
# out at ..."). The explicit-refspec form only touches the
# remote-tracking ref, never the local branch, so it works
# regardless of checkout state. The leading ``+`` is the
# force-update marker — without it, a force-push on the remote
# since our last sync would fail the fetch with non-fast-forward.
# Live-confirmed root cause on 2026-05-16 (run-16 PR #29): the
# bare-shape fetch failed silently, MCP fell through to bare
# ``--force-with-lease`` with a stale tracking ref, push
# rejected stale-info. Worker burned ~10 min troubleshooting.
fetch_rc, _, fetch_err = _run_git(
["fetch", remote, f"+{branch}:refs/remotes/{remote}/{branch}"],
cwd=wt,
env=env,
timeout=60,
)
if fetch_rc != 0:
# Non-fatal: pre-fetch failed (network / auth blip). Push
# with the bare ``--force-with-lease`` form as a fallback;
# the lease is stale but the agent at least sees a real
# rejection if there's a genuine race.
lease_sha = ""
else:
rt_rc, rt_out, _ = _run_git(
["rev-parse", f"{remote}/{branch}"], cwd=wt, env=env, timeout=15
)
lease_sha = rt_out.strip() if rt_rc == 0 else ""
cmd = ["push"]
if force_with_lease:
if lease_sha:
cmd.append(f"--force-with-lease=refs/heads/{branch}:{lease_sha}")
else:
cmd.append("--force-with-lease")
cmd += [remote, branch]
rc, _, err_out = _run_git(cmd, cwd=wt, env=env)
if rc != 0:
# One-shot retry for the specific "stale info" class —
# another fetch + a fresh lease often clears it (the remote
# may have advanced between our pre-fetch and the push).
lower = (err_out or "").lower()
if force_with_lease and "stale info" in lower:
_run_git(["fetch", remote, branch], cwd=wt, env=env, timeout=60)
rt_rc, rt_out, _ = _run_git(
["rev-parse", f"{remote}/{branch}"],
cwd=wt,
env=env,
timeout=15,
)
retry_lease = rt_out.strip() if rt_rc == 0 else ""
retry_cmd = ["push"]
if retry_lease:
retry_cmd.append(
f"--force-with-lease=refs/heads/{branch}:{retry_lease}"
)
else:
retry_cmd.append("--force-with-lease")
retry_cmd += [remote, branch]
rc, _, err_out = _run_git(retry_cmd, cwd=wt, env=env)
if rc != 0:
return _err(
"git push failed",
stderr=(err_out or "").strip()[:1000],
worktree=str(wt),
branch=branch,
)
# Read the remote-tracking ref after the push to confirm what landed.
rt_rc, rt_out, _ = _run_git(["rev-parse", f"{remote}/{branch}"], cwd=wt, env=env)
return {
"remote_sha": rt_out.strip() if rt_rc == 0 else "",
"branch": branch,
"remote": remote,
}
@server.tool()
def fetch(
worktree: str,
remote: str = "origin",
branch: str | None = None,
) -> dict[str, Any]:
"""``git fetch`` from ``remote``. Authenticates as HAL9000.
Parameters:
remote: defaults to ``origin``.
branch: optional. When provided, fetches ONLY this branch using
the explicit refspec ``+<branch>:refs/remotes/<remote>/<branch>``
so the operation works regardless of whether the local branch
by that name is currently checked out. (The bare
``git fetch origin <branch>`` shape would refuse with "fatal:
refusing to fetch into branch ... checked out at ..." in the
checked-out case — same bug that bit the ``push`` MCP's
internal pre-fetch on 2026-05-16, run-16 PR #29.) When
``branch`` is omitted the fetch updates every remote-tracking
ref the default refspec covers.
Returns ``{ok: bool}`` on success or ``{error, stderr}`` on
failure. Output (which is usually noise like ``From https://...``)
is not returned; if you need it, run ``status`` afterward.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
env = _git_env_for_push()
if branch:
cmd = [
"fetch",
remote,
f"+{branch}:refs/remotes/{remote}/{branch}",
]
else:
cmd = ["fetch", remote]
rc, _, err_out = _run_git(cmd, cwd=wt, env=env)
if rc != 0:
return _err(
"git fetch failed",
stderr=err_out.strip(),
worktree=str(wt),
)
return {"ok": True}
@server.tool()
def checkout(
worktree: str,
branch: str,
create: bool = False,
force: bool = False,
) -> dict[str, Any]:
"""``git checkout`` — switch to (or create) a branch.
Parameters:
branch: target branch name.
create: when True, uses ``-B`` to create-or-reset the branch
at the current HEAD. This is the common pattern for converting
a detached HEAD (left by the dispatcher's pre-clone
``worktree add --detach`` flow) back into a named branch
before pushing. Without this, ``git push`` rejects with
"src refspec HEAD does not match any" / "you are not
currently on a branch."
force: when True, uses ``-f`` to discard local changes that
would otherwise block the checkout. Use sparingly — silent
data loss risk if the worker had uncommitted work.
Returns ``{branch, head_sha}`` on success or ``{error, stderr}``
on failure.
The 2026-05-16 (run-16 PR #29) trace showed task-implementor
repeatedly dropping to bash for ``git checkout -B <branch>``
because no MCP tool existed for it. This tool closes that gap
so the MCP path covers the full git workflow.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not branch or not branch.strip():
return _err("branch must be non-empty", worktree=str(wt))
cmd = ["checkout"]
if create:
cmd.append("-B")
elif force:
cmd.append("-f")
cmd.append(str(branch))
rc, _, err_out = _run_git(cmd, cwd=wt)
if rc != 0:
return _err(
"git checkout failed",
stderr=err_out.strip(),
worktree=str(wt),
branch=branch,
)
# Report the post-checkout state so the caller can verify they
# ended up on the expected branch + SHA.
sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt)
return {
"branch": branch,
"head_sha": sha_out.strip() if sha_rc == 0 else "",
}
@server.tool()
def rebase(worktree: str, onto: str) -> dict[str, Any]:
"""``git rebase`` the current branch onto ``onto`` (e.g.
``origin/master``).
Returns ``{success: True}`` on a clean rebase or ``{success:
False, conflicts: [path, …]}`` when the rebase stops on a
conflict — the worktree is left in the rebase-in-progress state
so the agent can resolve conflicts and then call the
``rebase_continue`` tool to resume.
On non-conflict errors returns ``{error, stderr}``.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not onto or not onto.strip():
return _err("`onto` ref must be non-empty", worktree=str(wt))
rc, _, err_out = _run_git(["rebase", onto], cwd=wt)
if rc == 0:
return {"success": True}
# Conflict detection: a stopped rebase exits non-zero and leaves
# unmerged paths in the index (``_unmerged_paths`` / ``_UNMERGED_CODES``
# — the same set ``status`` reports).
conflicts = _unmerged_paths(wt)
if conflicts:
return {
"success": False,
"conflicts": conflicts,
"stderr": err_out.strip()[:500],
}
return _err(f"git rebase failed", stderr=err_out.strip(), worktree=str(wt))
@server.tool()
def rebase_continue(worktree: str) -> dict[str, Any]:
"""``git rebase --continue`` after the conflicts for the current
rebase step have been resolved + staged.
Runs with ``GIT_EDITOR=true`` so git never opens an interactive
editor (a stopped rebase that resumes would otherwise block on a
commit-message editor, hanging the MCP call).
Returns ``{success: True}`` when the rebase ran to completion, or
``{success: False, conflicts: [path, …]}`` when it advanced and
stopped on the NEXT step's conflicts — same shape + conflict
detection as the ``rebase`` tool, so the agent loops
resolve → ``rebase_continue`` until ``success`` is True.
On a non-conflict failure (e.g. nothing staged so git refuses to
continue, or no rebase in progress) returns ``{error, stderr}``.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
env = dict(os.environ)
env["GIT_EDITOR"] = "true"
rc, _, err_out = _run_git(["rebase", "--continue"], cwd=wt, env=env)
if rc == 0:
return {"success": True}
# The rebase advanced and stopped on the NEXT step's conflicts:
# same detection as the ``rebase`` tool — git exits non-zero and
# leaves unmerged paths in the index.
conflicts = _unmerged_paths(wt)
if conflicts:
return {
"success": False,
"conflicts": conflicts,
"stderr": err_out.strip()[:500],
}
return _err(
"git rebase --continue failed", stderr=err_out.strip(), worktree=str(wt)
)
@server.tool()
def cleanup(worktree: str) -> dict[str, Any]:
"""Remove a worktree.
Runs ``git worktree remove --force`` against the mirror so git's
metadata stays consistent, then deletes the directory if any
files remain. Returns ``{removed: True}`` on success or
``{error}`` on failure. Safe to call on an already-removed
worktree.
"""
wt, err = _validate_worktree(worktree)
if err:
# If the directory doesn't exist that's actually success for
# cleanup — the post-condition (path is gone) already holds.
if "does not exist" in (err or ""):
return {"removed": True, "note": "already absent"}
return _err(err)
# Best-effort: ask the mirror to deregister the worktree. The
# mirror path follows the dispatcher's convention.
mirror = Path("/tmp/.cleveragents-mirror.git")
if mirror.is_dir():
_run_git(
["--git-dir", str(mirror), "worktree", "remove", "--force", str(wt)],
)
# Whatever the worktree-remove result, scrub any remaining files
# so the agent sees a clean post-condition.
try:
shutil.rmtree(wt, ignore_errors=True)
except OSError as exc:
return _err(f"rmtree failed: {exc}", worktree=str(wt))
return {"removed": True}
# ─── Read-only inspection tools (2026-05-16, Step 4) ───────────────
# Added so the git MCP covers the full surface that the soon-to-be-
# retired ``git-*-util`` subagent fleet supported. Without these,
# callers migrating off ``git-commit-util`` / ``git-rebase-util`` /
# etc. would still need bash perms for ``git log`` / ``git diff`` /
# ``git show`` / ``git rev-parse`` / ``git merge-base`` to verify
# the state of their work — keeping the bash dependency the MCP
# migration was supposed to eliminate. All five are read-only with
# no side effects beyond stdout/stderr.
@server.tool()
def log(
worktree: str,
range: str | None = None,
max_count: int = 20,
oneline: bool = True,
paths: list[str] | None = None,
) -> dict[str, Any]:
"""``git log`` — list commits. Defaults to oneline format capped
at 20 commits.
Parameters:
range: e.g. ``"master..HEAD"`` (commits in HEAD not in master),
``"HEAD~5..HEAD"``, or omit for full history.
max_count: ``-n`` flag; capped at 200 to bound output.
oneline: ``--oneline`` (default). Set False for the full
commit-message format.
paths: limit log to commits touching these paths (e.g.
``["src/foo.py", "tests/test_foo.py"]``).
Returns ``{output: str}`` on success or ``{error, stderr}`` on
failure.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
cmd = ["log"]
if oneline:
cmd.append("--oneline")
n = max(1, min(int(max_count), 200))
cmd += ["-n", str(n)]
if range:
cmd.append(str(range))
if paths:
cmd.append("--")
cmd += [str(p) for p in paths if p]
rc, out, err_out = _run_git(cmd, cwd=wt)
if rc != 0:
return _err("git log failed", stderr=err_out.strip(), worktree=str(wt))
return {"output": out}
@server.tool()
def diff(
worktree: str,
ref1: str | None = None,
ref2: str | None = None,
paths: list[str] | None = None,
name_only: bool = False,
stat: bool = False,
) -> dict[str, Any]:
"""``git diff`` between refs or against the working tree.
Parameters:
ref1 / ref2: omit both → diff working tree vs HEAD. Specify
ref1 only → diff working tree vs ref1. Specify both →
``git diff ref1 ref2`` (use ``"master...HEAD"`` syntax in
ref1 for triple-dot semantics).
paths: limit diff to these paths.
name_only: ``--name-only`` (list of changed files, no hunks).
stat: ``--stat`` (insertions/deletions summary, no hunks).
Returns ``{output: str}`` on success or ``{error, stderr}`` on
failure. ``name_only`` and ``stat`` are mutually exclusive; if
both are True ``stat`` wins.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
cmd = ["diff"]
if stat:
cmd.append("--stat")
elif name_only:
cmd.append("--name-only")
if ref1:
cmd.append(str(ref1))
if ref2:
cmd.append(str(ref2))
if paths:
cmd.append("--")
cmd += [str(p) for p in paths if p]
rc, out, err_out = _run_git(cmd, cwd=wt)
if rc != 0:
return _err("git diff failed", stderr=err_out.strip(), worktree=str(wt))
return {"output": out}
@server.tool()
def show(
worktree: str,
ref: str,
path: str | None = None,
) -> dict[str, Any]:
"""``git show`` a commit or a file's content at a specific ref.
Without ``path``: shows the commit (message + diff) at ``ref``.
With ``path``: shows the file content at ``ref:path`` (e.g.
``ref="HEAD"`` ``path="README.md"`` returns README at HEAD).
Returns ``{output: str}`` on success or ``{error, stderr}`` on
failure. Useful for reading the file as it existed in a previous
commit without checking out.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not ref or not ref.strip():
return _err("ref must be non-empty", worktree=str(wt))
target = f"{ref}:{path}" if path else ref
rc, out, err_out = _run_git(["show", target], cwd=wt)
if rc != 0:
return _err("git show failed", stderr=err_out.strip(), worktree=str(wt))
return {"output": out}
@server.tool()
def rev_parse(
worktree: str,
ref: str,
abbrev_ref: bool = False,
) -> dict[str, Any]:
"""``git rev-parse`` — resolve a ref to a SHA (or to its
abbreviated-ref / branch name).
Parameters:
ref: anything git can parse — ``"HEAD"``, ``"master"``,
``"origin/main"``, ``"HEAD~3"``, a tag, a partial SHA.
abbrev_ref: ``--abbrev-ref`` — return the branch name a ref
points to (e.g. ``rev_parse("HEAD", abbrev_ref=True)`` →
``"feature/x"``) instead of the SHA. ``git_status`` already
returns the branch name; this flag exists for the rarer
case of resolving an arbitrary ref's branch.
Returns ``{sha: str}`` (or ``{branch: str}`` when abbrev_ref) on
success, or ``{error, stderr}`` on failure.
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not ref or not ref.strip():
return _err("ref must be non-empty", worktree=str(wt))
cmd = ["rev-parse"]
if abbrev_ref:
cmd.append("--abbrev-ref")
cmd.append(str(ref))
rc, out, err_out = _run_git(cmd, cwd=wt)
if rc != 0:
return _err("git rev-parse failed", stderr=err_out.strip(), worktree=str(wt))
value = out.strip()
return {"branch": value} if abbrev_ref else {"sha": value}
@server.tool()
def merge_base(
worktree: str,
ref1: str,
ref2: str,
) -> dict[str, Any]:
"""``git merge-base`` — find the common ancestor SHA of two refs.
Used to compute the "PR fork point" (``merge_base("HEAD",
"origin/master")``) for diff-base resolution and for verifying a
rebase landed on the expected base.
Returns ``{sha: str}`` on success or ``{error, stderr}`` on
failure (the most common failure is unrelated histories — e.g.
you compared two refs that share no common commit).
"""
wt, err = _validate_worktree(worktree)
if err:
return _err(err)
if not ref1 or not ref2:
return _err("both ref1 and ref2 must be non-empty", worktree=str(wt))
rc, out, err_out = _run_git(["merge-base", str(ref1), str(ref2)], cwd=wt)
if rc != 0:
return _err(
"git merge-base failed",
stderr=err_out.strip(),
worktree=str(wt),
)
return {"sha": out.strip()}
main = make_main(server, "mcp_git_server")
if __name__ == "__main__":
sys.exit(main())