a103a31bbf
The implementer agent no longer pushes to git — the controller worker now owns the push: it gates the agent's commits on lint+typecheck and pushes via a single leased primitive. Closes two production defects: - Clobber: the pre-fix MCP --force-with-lease leased against a freshly-fetched tip, so the lease always passed — an in-flight implementer destroyed a commit pushed to the PR branch during its run (lost a hand-pushed skip_coverage fix on PR #46). - Gate-skip: the agent verified only the CI-flagged gate, so a fix for one gate shipped fresh violations in another (lint flapped pass->fail across CI runs 198->199). Step 1 — worker_push primitive: - New git_push.py: one leased push, pinned to the SHA the worker started from; classifies pushed / stale_input / diverged / infra_error; bounded infra-retry. - mcp_git_server.push gains expected_sha for a correctly-pinned lease. - finalize_conflict_resolution migrated onto worker_push. Step 2 — deterministic gate: - New gate.py: per-slot nox env-dirs (no venv races), manifest-hash staleness keying, lazy warm-up. - local_ci_gate.sh gains --envdir. Step 3 — worker-owned gated push: - New implementer_finalize.py: divergence pre-check -> lint+typecheck gate -> ruff auto-fix -> leased push. finalize's outcome is authoritative over the agent's emitted outcome. - agent_runner integrates finalize; salvage no longer pushes. - outcomes/tick: gate-failed + push-time stale-input caps, epoch-scoped; WorkerError carries an output_payload so the gate report reaches the next attempt's prompt; prefetch surfaces gate-failed attempts. - The 5 task-implementor prompts drop the agent push step. Reviewed across 4 adversarial rounds; full controller+MCP suite green (1379 passed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1150 lines
43 KiB
Python
1150 lines
43 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,
|
|
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 (``+<branch>:refs/remotes/origin/<branch>``)
|
|
# only touches the remote-tracking ref, so it works even when the
|
|
# local ``<branch>`` 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 ``+<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())
|