Namespace-scoped workspace resolution prevents GITEA_AUTHOR_WORKTREE from poisoning reviewer, merger, and reconciler purity checks. Each role uses its own worktree env var with actionable binding-source errors and safe reconnect guidance. Preserves #274/#475 author and control-checkout guards.
234 lines
8.2 KiB
Python
234 lines
8.2 KiB
Python
"""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 ``<project_root>/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/<task> 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."
|
|
) |