Files
Gitea-Tools/branch_cleanup_guard.py
T
sysadmin 137426f7ad fix(cleanup): post-delete readback and active ownership gates (#687)
Require authoritative not-found readback after merged-PR branch DELETE, and
block cleanup when active author/reviewer/merger/controller/reconciler
session, lease, or worktree-binding ownership still uses the target branch.
2026-07-16 00:57:30 -04:00

500 lines
18 KiB
Python

"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
from __future__ import annotations
import re
from typing import Any
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
# Evidence / preservation branches must never be removed by cleanup tools
# (e.g. chore/issue-681-preserve-review-session-wip).
_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence")
def is_preservation_or_evidence_branch(branch: str | None) -> bool:
"""Return True when *branch* is a preservation/evidence ref that must stay."""
if not branch:
return False
name = str(branch).lower()
return any(marker in name for marker in _PRESERVATION_MARKERS)
_RAW_BRANCH_DELETE_PATTERNS = (
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
)
def raw_branch_delete_commands(text: str | None) -> list[str]:
"""Return raw git branch-delete commands cited in *text*."""
if not text:
return []
commands: list[str] = []
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
return list(dict.fromkeys(commands))
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
commands = raw_branch_delete_commands(text)
reasons = [
(
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
f"{command}"
)
for command in commands
]
return {
"proven": not reasons,
"block": bool(reasons),
"commands": commands,
"reasons": reasons,
"safe_next_action": (
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
"helper with explicit branch.delete capability"
if reasons
else "proceed"
),
}
def assess_merged_pr_branch_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
remote_branch_exists: bool,
open_pr_heads: set[str],
head_on_target: bool | None,
delete_capability_allowed: bool,
confirmation: str | None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Assess whether a merged PR source branch can be deleted via MCP."""
protected = protected_branches or PROTECTED_BRANCHES
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
reasons: list[str] = []
if not merged:
reasons.append("PR is not merged")
if not remote_branch_exists:
reasons.append("remote branch already absent")
if not head_branch:
reasons.append("PR head branch is missing")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if is_preservation_or_evidence_branch(head_branch):
reasons.append(
f"branch '{head_branch}' is a preservation/evidence branch and "
"cannot be deleted through merged-PR cleanup"
)
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if head_on_target is False:
reasons.append("PR head is not an ancestor of the target branch")
if head_on_target is None:
reasons.append("PR head ancestry could not be proven")
if not delete_capability_allowed:
reasons.append("gitea.branch.delete capability is not allowed")
if confirmation != expected_confirmation:
reasons.append(
"confirmation must equal "
f"'{expected_confirmation}' for branch cleanup"
)
safe = not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"expected_confirmation": expected_confirmation,
"remote_branch_exists": remote_branch_exists,
"safe_to_delete": safe,
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
# ---------------------------------------------------------------------------
# #687 remediation: post-delete readback + active ownership protection
# ---------------------------------------------------------------------------
READBACK_NOT_FOUND = "not_found"
READBACK_EXISTS = "exists"
READBACK_AUTHENTICATION = "authentication_error"
READBACK_AUTHORIZATION = "authorization_error"
READBACK_TRANSPORT = "transport_error"
READBACK_UNEXPECTED = "unexpected_response"
ERROR_CLASS_AUTHENTICATION = "authentication"
ERROR_CLASS_AUTHORIZATION = "authorization"
ERROR_CLASS_TRANSPORT = "transport"
ERROR_CLASS_UNEXPECTED = "unexpected"
OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session"
OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease"
OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease"
OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
_ROLE_TO_OWNERSHIP_CATEGORY = {
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
"reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE,
"merger": OWNERSHIP_CATEGORY_MERGER_LEASE,
"controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
"reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE,
}
_ACTIVE_OWNERSHIP_STATUSES = frozenset(
{"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"}
)
_TERMINAL_OWNERSHIP_STATUSES = frozenset(
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
)
_EXPIRED_STATUSES = frozenset({"expired"})
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
def _norm_str(value: Any) -> str:
return str(value or "").strip()
def ownership_category_for_role(role: str | None) -> str:
"""Map a role kind to a non-secret ownership category label."""
key = _norm_str(role).lower()
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
def classify_branch_readback_http_status(status_code: int | None) -> dict[str, Any]:
"""Classify a GET-branch HTTP status into a secret-free readback result."""
if status_code == 404:
return {
"status": READBACK_NOT_FOUND,
"error_class": None,
"verified_absent": True,
"branch_present": False,
"reasons": [],
}
if status_code in (401, 407):
return {
"status": READBACK_AUTHENTICATION,
"error_class": ERROR_CLASS_AUTHENTICATION,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback authentication failed"],
}
if status_code == 403:
return {
"status": READBACK_AUTHORIZATION,
"error_class": ERROR_CLASS_AUTHORIZATION,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback authorization failed"],
}
if status_code is not None and 200 <= int(status_code) < 300:
return {
"status": READBACK_EXISTS,
"error_class": None,
"verified_absent": False,
"branch_present": True,
"reasons": ["post-delete readback found branch still present"],
}
if status_code is not None and int(status_code) >= 500:
return {
"status": READBACK_TRANSPORT,
"error_class": ERROR_CLASS_TRANSPORT,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback transport/upstream failure"],
}
return {
"status": READBACK_UNEXPECTED,
"error_class": ERROR_CLASS_UNEXPECTED,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback returned an unexpected response"],
"http_status": status_code,
}
def classify_branch_readback_exception(exc: BaseException) -> dict[str, Any]:
"""Classify a GET-branch exception without leaking credentials or bodies.
Prefer typed HTTP status when present on the exception chain; fall back to
a narrow ``HTTP <code>`` prefix parse. Never includes response bodies,
tokens, or raw exception text in the returned payload.
"""
status_code: int | None = None
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
code = getattr(current, "code", None)
if isinstance(code, int):
status_code = code
break
status = getattr(current, "status", None)
if isinstance(status, int):
status_code = status
break
status_code_attr = getattr(current, "status_code", None)
if isinstance(status_code_attr, int):
status_code = status_code_attr
break
current = current.__cause__ or current.__context__
if status_code is None:
# Narrow, non-secret parse of our own RuntimeError shape: "HTTP 404: ..."
text = str(exc) if exc is not None else ""
match = re.match(r"HTTP\s+(\d{3})\b", text)
if match:
status_code = int(match.group(1))
else:
lower = text.lower()
if "not found" in lower or "404" in lower:
status_code = 404
elif "unauthorized" in lower or "401" in lower:
status_code = 401
elif "forbidden" in lower or "403" in lower:
status_code = 403
elif any(
token in lower
for token in (
"timed out",
"timeout",
"connection",
"network",
"temporarily unavailable",
"name or service not known",
"nodename nor servname",
)
):
return {
"status": READBACK_TRANSPORT,
"error_class": ERROR_CLASS_TRANSPORT,
"verified_absent": False,
"branch_present": None,
"reasons": [
"post-delete branch readback transport/upstream failure"
],
}
if status_code is not None:
return classify_branch_readback_http_status(status_code)
return {
"status": READBACK_UNEXPECTED,
"error_class": ERROR_CLASS_UNEXPECTED,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback returned an unexpected response"],
}
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
"""Decide whether DELETE success may be reported after branch readback.
Success requires an authoritative not-found readback. DELETE HTTP success
alone is never enough.
"""
payload = dict(readback or {})
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
verified = bool(payload.get("verified_absent")) or status == READBACK_NOT_FOUND
if status == READBACK_NOT_FOUND and verified:
return {
"ok": True,
"success": True,
"readback": {
"status": READBACK_NOT_FOUND,
"verified_absent": True,
"branch_present": False,
"error_class": None,
},
"reasons": [],
}
reasons = list(payload.get("reasons") or [])
if not reasons:
if status == READBACK_EXISTS:
reasons = ["post-delete readback found branch still present"]
elif status == READBACK_AUTHENTICATION:
reasons = ["post-delete branch readback authentication failed"]
elif status == READBACK_AUTHORIZATION:
reasons = ["post-delete branch readback authorization failed"]
elif status == READBACK_TRANSPORT:
reasons = ["post-delete branch readback transport/upstream failure"]
else:
reasons = ["post-delete branch readback could not verify deletion"]
return {
"ok": False,
"success": False,
"readback": {
"status": status,
"verified_absent": False,
"branch_present": payload.get("branch_present"),
"error_class": payload.get("error_class"),
},
"reasons": reasons,
"blocker_kind": "post_delete_readback_failed",
}
def _repo_matches(record: dict[str, Any], *, remote: str, org: str, repo: str) -> bool:
return (
_norm_str(record.get("remote")).lower() == _norm_str(remote).lower()
and _norm_str(record.get("org")).lower() == _norm_str(org).lower()
and _norm_str(record.get("repo")).lower() == _norm_str(repo).lower()
)
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
return _norm_str(record.get("branch")) == _norm_str(branch)
def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
"""Classify one ownership record as blocking or non-blocking.
Distinguishes active ownership from expired/released/terminal/stale records.
Stale/expired records block only when reclaim is not allowed (sticky foreign
ownership with live owner + present worktree).
"""
status = _norm_str(record.get("status")).lower()
category = _norm_str(record.get("category")) or "unknown"
reclaim_allowed = record.get("reclaim_allowed")
if status in _TERMINAL_OWNERSHIP_STATUSES:
return {
"blocks": False,
"status": status,
"category": category,
"reason": f"{category} ownership is terminal/released ({status})",
}
if status in _ACTIVE_OWNERSHIP_STATUSES:
return {
"blocks": True,
"status": status,
"category": category,
"reason": f"active {category} ownership still uses the target branch",
}
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
# Canonical recovery policy (#601): reclaimable expired/stale does not
# block; sticky expired/stale (live pid + present worktree) does.
if reclaim_allowed is True:
return {
"blocks": False,
"status": status,
"category": category,
"reason": (
f"{category} ownership is {status} and reclaimable; "
"does not block deletion"
),
}
if reclaim_allowed is False:
return {
"blocks": True,
"status": status,
"category": category,
"reason": (
f"sticky {status} {category} ownership still protects the "
"target branch (recovery review required)"
),
}
# Unknown reclaimability → fail closed
return {
"blocks": True,
"status": status,
"category": category,
"reason": (
f"{status} {category} ownership reclaimability unknown; "
"fail closed"
),
}
# Unknown status → fail closed
return {
"blocks": True,
"status": status or "unknown",
"category": category,
"reason": (
f"unclassified {category} ownership status "
f"'{status or 'unknown'}'; fail closed"
),
}
def assess_active_branch_ownership(
*,
remote: str,
org: str,
repo: str,
branch: str,
records: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Assess whether any active ownership still uses *branch* in *repo*.
Only records matching the exact remote/org/repo/branch quadruple are
considered. Other repositories or branches never produce a false block.
Returned denial identifies ownership category only — never secrets.
"""
target_branch = _norm_str(branch)
considered: list[dict[str, Any]] = []
blocking: list[dict[str, Any]] = []
ignored: list[dict[str, Any]] = []
for raw in records or []:
if not isinstance(raw, dict):
continue
if not _repo_matches(raw, remote=remote, org=org, repo=repo):
ignored.append(
{
"category": _norm_str(raw.get("category")) or "unknown",
"reason": "different repository scope",
}
)
continue
if not _branch_matches(raw, target_branch):
ignored.append(
{
"category": _norm_str(raw.get("category")) or "unknown",
"reason": "different branch",
}
)
continue
activity = assess_ownership_record_activity(raw)
entry = {
"category": activity["category"],
"status": activity["status"],
"blocks": activity["blocks"],
"reason": activity["reason"],
}
considered.append(entry)
if activity["blocks"]:
blocking.append(entry)
block = bool(blocking)
categories = sorted({b["category"] for b in blocking})
reasons = [
(
"active ownership protects branch "
f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking)
)
] if block else []
return {
"block": block,
"safe_to_delete": not block,
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"blocking_categories": categories,
"blocking": blocking,
"considered": considered,
"ignored_out_of_scope": ignored,
"reasons": reasons,
"blocker_kind": "active_branch_ownership" if block else None,
"recommended_action": "keep_remote_branch" if block else "delete_remote_branch",
}