"""Branches-only author mutation worktree guard (#274). Author/coder mutations must run from a session-owned worktree under the project's ``branches/`` directory, never from the stable control checkout. """ from __future__ import annotations import os import subprocess BASE_BRANCHES = frozenset({"master", "main", "dev"}) ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE" # Author-only: reviewer/merger/reconciler namespaces use role-specific env vars # via namespace_workspace_binding (#510). def _normalize_path(path: str) -> str: return (path or "").replace("\\", "/").rstrip("/") def is_path_under_branches(path: str, project_root: str | None = None) -> bool: """True when *path* resolves inside ``/branches/``.""" normalized = _normalize_path(path) if not normalized: return False if "/branches/" in f"{normalized}/": return True if normalized.endswith("/branches"): return True if project_root: root = _normalize_path(os.path.realpath(project_root)) real = _normalize_path(os.path.realpath(path)) if real.startswith(f"{root}/"): rel = real[len(root) + 1 :] return rel == "branches" or rel.startswith("branches/") return False def resolve_mutation_workspace( worktree_path: str | None, project_root: str, *, active_worktree_env: str | None = None, author_worktree_env: str | None = None, ) -> str: """Resolve the workspace path inspected before author mutations.""" for candidate in (worktree_path, active_worktree_env, author_worktree_env): text = (candidate or "").strip() if text: return os.path.realpath(os.path.abspath(text)) return os.path.realpath(project_root) def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str: """Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*.""" raw = (common_dir or "").strip() if not raw: return raw if os.path.isabs(raw): return os.path.realpath(raw) return os.path.realpath(os.path.join(workspace_path, raw)) def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str: """Return the stable repository root for *workspace_path* via git metadata (#460).""" path = (workspace_path or "").strip() fallback = os.path.realpath(fallback_project_root) if not path: return fallback try: res = subprocess.run( ["git", "-C", path, "rev-parse", "--git-common-dir"], capture_output=True, text=True, check=True, ) common = _realpath_git_common_dir(path, res.stdout) except Exception: return fallback if common.endswith(f"{os.sep}.git"): return os.path.dirname(common) if os.path.basename(common) == ".git": return os.path.dirname(common) return fallback def resolve_author_mutation_context( worktree_path: str | None, process_project_root: str, *, active_worktree_env: str | None = None, author_worktree_env: str | None = None, ) -> dict: """Shared workspace resolution for runtime_context and mutation guards (#460).""" workspace = resolve_mutation_workspace( worktree_path, process_project_root, active_worktree_env=active_worktree_env, author_worktree_env=author_worktree_env, ) process_root = os.path.realpath(process_project_root) # Canonical repository identity comes from the MCP process checkout (#460), # not from the declared task workspace being validated. canonical_root = resolve_canonical_repo_root(process_root, process_root) return { "workspace_path": workspace, "process_project_root": process_root, "canonical_repo_root": canonical_root, "roots_aligned": canonical_root == process_root, } def assess_workspace_repo_membership( *, workspace_path: str, canonical_repo_root: str, ) -> dict: """Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*.""" workspace = os.path.realpath(workspace_path) root = os.path.realpath(canonical_repo_root) reasons: list[str] = [] if not os.path.exists(workspace): reasons.append(f"worktree path '{workspace}' does not exist") return _membership_assessment(False, reasons, workspace, root, None) if not os.path.isdir(workspace): reasons.append(f"worktree path '{workspace}' is not a directory") return _membership_assessment(False, reasons, workspace, root, None) try: res = subprocess.run( ["git", "-C", workspace, "rev-parse", "--git-common-dir"], capture_output=True, text=True, check=True, ) common_dir = _realpath_git_common_dir(workspace, res.stdout) except Exception: reasons.append(f"worktree '{workspace}' is not a valid git repository") return _membership_assessment(False, reasons, workspace, root, None) expected_dir = os.path.realpath(os.path.join(root, ".git")) if common_dir != expected_dir: reasons.append( f"worktree '{workspace}' does not belong to the target repository '{root}'" ) return _membership_assessment(not reasons, reasons, workspace, root, common_dir) def _membership_assessment( proven: bool, reasons: list[str], workspace: str, root: str, common_dir: str | None, ) -> dict: return { "proven": proven, "block": not proven, "reasons": reasons, "workspace_path": workspace, "canonical_repo_root": root, "git_common_dir": common_dir, } def format_workspace_repo_membership_error(assessment: dict) -> str: workspace = assessment.get("workspace_path") or "(unknown)" root = assessment.get("canonical_repo_root") or "(unknown)" reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"]) return ( f"Branches-only mutation guard (#274): {reasons} (fail closed). " f"canonical repository root: {root}; workspace: {workspace}." ) def assess_author_mutation_worktree( *, workspace_path: str, project_root: str, current_branch: str | None = None, base_branches: frozenset[str] | None = None, ) -> dict: """Fail closed when author mutations are not rooted under ``branches/``.""" bases = base_branches or BASE_BRANCHES reasons: list[str] = [] root = os.path.realpath(project_root) workspace = os.path.realpath(workspace_path) branch = (current_branch or "").strip() under_branches = is_path_under_branches(workspace, root) if not under_branches: if workspace == root: reasons.append( "author mutation blocked: workspace is the stable control checkout; " "create or switch to a session-owned worktree under branches/" ) else: reasons.append( f"author mutation blocked: workspace '{workspace}' is not under " f"'{root}/branches/'; create a branches/ worktree first" ) if not under_branches and workspace == root and branch and branch not in bases: reasons.append( f"control checkout drift: branch '{branch}' is not a stable base " f"branch ({'/'.join(sorted(bases))})" ) proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "project_root": root, "workspace_path": workspace, "under_branches": is_path_under_branches(workspace, root), "current_branch": branch or None, } def format_author_mutation_worktree_error(assessment: dict) -> str: """Single RuntimeError message for MCP preflight gates.""" workspace = assessment.get("workspace_path") or "(unknown)" root = assessment.get("project_root") or "(unknown)" reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"]) return ( f"Branches-only mutation guard (#274): {reasons}. " f"project root: {root}; workspace: {workspace}. " "Create a session-owned worktree under branches/ before mutating." )