#!/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 -- `` followed by ``git add -- `` 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, expected_sha: str | None = None, ) -> 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. ``expected_sha`` pins ``--force-with-lease`` to a caller-supplied SHA (the tip the caller last validated) instead of a freshly fetched one. This is the ONLY genuinely safe lease form: a lease derived from a just-fetched tip always passes and would silently clobber a concurrent push. When ``expected_sha`` is given the pre-fetch and the stale-info auto-retry are skipped — a rejection is then a real concurrent push and is returned to the caller. """ 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() if expected_sha is not None: # Caller-pinned lease — the ONLY correct force-with-lease form. # The lease value is the SHA the caller last validated, NOT a # freshly fetched tip: a fetch-derived lease always passes and # would silently clobber a concurrent push. No pre-fetch and no # stale-info auto-retry — a rejection here is a genuine # concurrent push and must surface to the caller unmodified. cmd = ["push"] if force_with_lease: cmd.append(f"--force-with-lease=refs/heads/{branch}:{expected_sha}") cmd += [remote, branch] rc, _, err_out = _run_git(cmd, cwd=wt, env=env) if rc != 0: return _err( "git push failed", stderr=(err_out or "").strip()[:1000], worktree=str(wt), branch=branch, ) else: # Legacy path (no caller-pinned SHA). Refresh the remote-tracking # ref and lease against it. Kept for back-compat; the lease is # weaker than the ``expected_sha`` form (it cannot distinguish a # genuine concurrent push from a stale local tracking ref). # Explicit refspec form (``+:refs/remotes/origin/``) # only touches the remote-tracking ref, so it works even when the # local ```` is currently checked out; the leading ``+`` # force-updates past a remote rewrite. fetch_rc, _, fetch_err = _run_git( ["fetch", remote, f"+{branch}:refs/remotes/{remote}/{branch}"], cwd=wt, env=env, timeout=60, ) if fetch_rc != 0: 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 "stale info" class — another fetch # + a fresh lease often clears a false rejection. 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 ``+:refs/remotes//`` so the operation works regardless of whether the local branch by that name is currently checked out. (The bare ``git fetch origin `` 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 `` 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())