"""Bounded patching and single-commit Git workspaces for Gitea changesets.""" from __future__ import annotations import io import os import re import tempfile from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager from pathlib import Path from threading import Lock from typing import Any, Protocol from dulwich import porcelain from dulwich.repo import Repo from packages.connector_kit import GiteaError, RegisteredRepository from packages.connector_kit.gitea import TokenProvider _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$") _SHA = re.compile(r"^[0-9a-fA-F]{40,64}$") _MAX_CHANGESET_BYTES = 1_000_000 _MAX_OPERATIONS = 50 _MAX_WORKSPACE_BYTES = 256 * 1024 * 1024 _DULWICH_IDENTITY_LOCK = Lock() _DULWICH_IDENTITY = {"USER": "mcp-agent", "EMAIL": "mcp-agent@example.com"} @contextmanager def _dulwich_clone_identity() -> Iterator[None]: """Give Dulwich a bounded reflog identity without mutating process state concurrently.""" with _DULWICH_IDENTITY_LOCK: previous = {name: os.environ.get(name) for name in _DULWICH_IDENTITY} os.environ.update(_DULWICH_IDENTITY) try: yield finally: for name, value in previous.items(): if value is None: os.environ.pop(name, None) else: os.environ[name] = value def _hex_object_id(value: bytes) -> str: try: decoded = value.decode("ascii") except UnicodeDecodeError: return value.hex() return decoded.casefold() if _SHA.fullmatch(decoded) else value.hex() class ChangesetWorkspace(Protocol): def apply( self, repository: RegisteredRepository, branch: str, message: str, operations: Sequence[Mapping[str, Any]], ) -> dict[str, Any]: ... def apply_unified_patch(original: str, patch: str) -> str: """Apply a single-file unified diff with exact context matching.""" if not patch or len(patch.encode("utf-8")) > 300_000 or "\x00" in patch: raise GiteaError("GITEA_PATCH_INVALID", "patch is outside the connector budget") lines = patch.splitlines(keepends=True) index = 0 while index < len(lines) and not lines[index].startswith("@@ "): if lines[index].startswith(("GIT binary patch", "Binary files ")): raise GiteaError("GITEA_PATCH_INVALID", "binary patches are not supported") index += 1 if index == len(lines): raise GiteaError("GITEA_PATCH_INVALID", "patch contains no unified diff hunk") source = original.splitlines(keepends=True) output: list[str] = [] source_index = 0 seen_hunk = False while index < len(lines): header = lines[index].rstrip("\r\n") match = _HUNK.fullmatch(header) if match is None: raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk header") seen_hunk = True old_start = int(match.group(1)) old_count = int(match.group(2) or "1") new_count = int(match.group(4) or "1") target_index = max(old_start - 1, 0) if target_index < source_index or target_index > len(source): raise GiteaError("GITEA_PATCH_CONFLICT", "patch hunks overlap or exceed the file") output.extend(source[source_index:target_index]) source_index = target_index index += 1 consumed = 0 produced = 0 while index < len(lines) and not lines[index].startswith("@@ "): line = lines[index] if line.startswith("\\ No newline at end of file"): index += 1 continue if not line or line[0] not in {" ", "+", "-"}: raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk line") marker, value = line[0], line[1:] if marker in {" ", "-"}: if source_index >= len(source) or source[source_index] != value: raise GiteaError("GITEA_PATCH_CONFLICT", "patch context does not match") source_index += 1 consumed += 1 if marker in {" ", "+"}: output.append(value) produced += 1 index += 1 if consumed != old_count or produced != new_count: raise GiteaError("GITEA_PATCH_INVALID", "patch hunk counts do not match") if not seen_hunk: raise GiteaError("GITEA_PATCH_INVALID", "patch contains no hunks") output.extend(source[source_index:]) result = "".join(output) if len(result.encode("utf-8")) > 200_000: raise GiteaError("GITEA_INPUT_INVALID", "patched file exceeds the connector budget") return result def validate_operations(operations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]: if not 1 <= len(operations) <= _MAX_OPERATIONS: raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation count is invalid") total = 0 normalized: list[dict[str, Any]] = [] touched: set[str] = set() for raw in operations: kind = str(raw.get("kind", "")) if kind not in {"create", "update", "delete", "move", "patch"}: raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation kind is invalid") path = str(raw.get("path", "")) if not path or path in touched: raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique") touched.add(path) operation: dict[str, Any] = {"kind": kind, "path": path} for field in ("content", "patch", "expectedSha", "destinationPath"): if field in raw and raw[field] is not None: operation[field] = str(raw[field]) if kind in {"update", "delete", "move", "patch"} and not _SHA.fullmatch( operation.get("expectedSha", "") ): raise GiteaError("GITEA_CHANGESET_INVALID", "expectedSha is required and invalid") if kind in {"create", "update"} and "content" not in operation: raise GiteaError("GITEA_CHANGESET_INVALID", "content is required") if kind == "patch" and "patch" not in operation: raise GiteaError("GITEA_CHANGESET_INVALID", "patch text is required") if kind == "move" and not operation.get("destinationPath"): raise GiteaError("GITEA_CHANGESET_INVALID", "destinationPath is required") destination = operation.get("destinationPath") if destination is not None: if destination in touched: raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique") touched.add(destination) total += sum(len(str(value).encode("utf-8")) for value in operation.values()) normalized.append(operation) if total > _MAX_CHANGESET_BYTES: raise GiteaError("GITEA_CHANGESET_INVALID", "changeset exceeds the connector budget") return tuple(normalized) class DulwichChangesetWorkspace: """Apply an already-validated changeset through a fixed, isolated Git workflow.""" def __init__(self, base_url: str, token_provider: TokenProvider, token_reference: str) -> None: self._base_url = base_url.rstrip("/") self._token_provider = token_provider self._token_reference = token_reference @staticmethod def _relative(root: Path, value: str) -> Path: if not value or value.startswith(("/", "\\")) or "\\" in value: raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") parts = value.split("/") if any(not part or part in {".", ".."} or "\x00" in part for part in parts): raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") target = root.joinpath(*parts) try: target.resolve(strict=False).relative_to(root.resolve()) except ValueError as exc: raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") from exc return target @staticmethod def _blob_sha(repo: Repo, relative: str) -> str | None: try: entry = repo.open_index()[relative.encode("utf-8")] except KeyError: return None sha = getattr(entry, "sha", None) return _hex_object_id(sha) if sha is not None else None def apply( self, repository: RegisteredRepository, branch: str, message: str, operations: Sequence[Mapping[str, Any]], ) -> dict[str, Any]: normalized = validate_operations(operations) token = self._token_provider.get_token(self._token_reference) remote = f"{self._base_url}/{repository.owner}/{repository.name}.git" authenticated = remote.startswith(("https://", "http://")) stderr = io.BytesIO() with tempfile.TemporaryDirectory(prefix="itworx-gitea-") as temporary: root = Path(temporary) / "repository" repo: Repo | None = None try: with _dulwich_clone_identity(): if authenticated: repo = porcelain.clone( remote, root, branch=branch, depth=1, errstream=stderr, username="oauth2", password=token, ) else: repo = porcelain.clone( remote, root, branch=branch, depth=1, errstream=stderr ) workspace_bytes = sum( entry.stat(follow_symlinks=False).st_size for entry in root.rglob("*") if entry.is_file() and not entry.is_symlink() ) if workspace_bytes > _MAX_WORKSPACE_BYTES: raise GiteaError( "GITEA_CHANGESET_TOO_LARGE", "repository exceeds the changeset workspace budget", ) staged: list[str] = [] removed: list[str] = [] for operation in normalized: kind = operation["kind"] relative = operation["path"] target = self._relative(root, relative) actual_sha = self._blob_sha(repo, relative) expected_sha = operation.get("expectedSha") if expected_sha is not None and actual_sha != expected_sha.casefold(): raise GiteaError( "GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {relative}" ) if kind == "create": if actual_sha is not None or target.exists(): raise GiteaError( "GITEA_CHANGESET_CONFLICT", f"file already exists: {relative}" ) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(operation["content"], encoding="utf-8") staged.append(relative) elif kind == "update": if actual_sha is None: raise GiteaError( "GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}" ) target.write_text(operation["content"], encoding="utf-8") staged.append(relative) elif kind == "patch": if actual_sha is None: raise GiteaError( "GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}" ) original = target.read_text(encoding="utf-8") target.write_text( apply_unified_patch(original, operation["patch"]), encoding="utf-8" ) staged.append(relative) elif kind == "delete": if actual_sha is None: raise GiteaError( "GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}" ) target.unlink() removed.append(relative) else: destination = str(operation["destinationPath"]) destination_target = self._relative(root, destination) if actual_sha is None or destination_target.exists(): raise GiteaError( "GITEA_CHANGESET_CONFLICT", "move source/destination is invalid" ) destination_target.parent.mkdir(parents=True, exist_ok=True) os.replace(target, destination_target) removed.append(relative) staged.append(destination) if removed: porcelain.rm(repo, removed) if staged: porcelain.add(repo, staged) status = porcelain.status(repo) if not status.staged and not status.unstaged and not status.untracked: raise GiteaError("GITEA_CHANGESET_EMPTY", "changeset produces no change") commit_sha = _hex_object_id( porcelain.commit( repo, message=message.encode("utf-8"), author=b"ChatGPT MCP ", committer=b"ChatGPT MCP ", ) ) if authenticated: porcelain.push( repo, remote, refspecs=f"refs/heads/{branch}:refs/heads/{branch}", force=False, errstream=stderr, username="oauth2", password=token, ) else: porcelain.push( repo, remote, refspecs=f"refs/heads/{branch}:refs/heads/{branch}", force=False, errstream=stderr, ) except GiteaError: raise except Exception as exc: raise GiteaError( "GITEA_CHANGESET_FAILED", "bounded Git changeset could not be committed" ) from exc finally: if repo is not None: repo.close() return { "commitSha": commit_sha, "branch": branch, "operationCount": len(normalized), "atomic": True, }