"""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 BASE_BRANCHES = frozenset({"master", "main", "dev"}) 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 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." )