Compare commits

..
Author SHA1 Message Date
sysadmin 7cc3460580 Merge branch 'prgs/master' into feat/issue-503-reviewer-active-worktree 2026-07-08 04:43:59 -04:00
sysadmin c54ac0d4de Merge pull request 'feat: isolate MCP workspace binding across role namespaces (Closes #510)' (#512) from feat/issue-510-workspace-binding-isolation into master 2026-07-08 03:37:17 -05:00
sysadmin 4d24c34548 Merge pull request 'feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)' (#415) from feat/issue-402-post-merge-cleanup-proof into master 2026-07-08 03:13:43 -05:00
sysadmin 3f3d6cb35d fix: resolve conflicts for PR #508
Merge prgs/master into feat/issue-503-reviewer-active-worktree.
Preserve reviewer mutation workspace binding (#503) and fold in
master's review_pr task preflight checks via _verify_reviewer_mutation_workspace.
2026-07-08 04:10:57 -04:00
sysadmin 3d11e1f12b feat: isolate MCP workspace binding across role namespaces (Closes #510)
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.
2026-07-08 04:08:08 -04:00
sysadmin ce61e424f6 Merge pull request 'fix: preserve capability preflight across safe reads (Closes #469)' (#502) from fix/issue-469-preflight-read-survival into master 2026-07-08 03:01:48 -05:00
sysadminandClaude Opus 4.8 dad1dc8d51 fix: bind reviewer mutations to active branches worktree (Closes #503)
Reviewer mutation tools now resolve workspace from worktree_path,
GITEA_ACTIVE_WORKTREE, or the session reviewer lease before preflight
and branches-only validation. Preflight rejects metadata-only worktree_path
bindings that would mislead runtime_context while mutations still inspect
the MCP process root.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:42:07 -04:00
sysadminandClaude Opus 4.8 3bce0a55fb test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadminandClaude Opus 4.8 afb4ba563c fix: preserve capability preflight across interleaved read-only whoami (#469)
Read-only gitea_whoami calls no longer clear a valid capability proof when
whoami was already verified clean. Capability is consumed on mutation (one
resolve per mutation), and verify_preflight_purity rejects task mismatches.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadmin d6b1e4be3c fix: resolve conflicts for PR #415 2026-07-08 02:20:04 -04:00
sysadminandClaude Opus 4.8 44a7c62dc3 fix: resolve conflicts for PR #415
Rebase onto current prgs/master and fix cleanup-mutations regex so
"Cleanup mutations: none" does not false-positive the proof checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
sysadminandClaude Opus 4.8 d5dc9f2708 feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)
Adds post_merge_cleanup_proof validation so cleanup claims must carry the
branch deletion and worktree removal checklists, or report CLEANUP_SKIPPED
with an exact blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
15 changed files with 1430 additions and 909 deletions
+9
View File
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
# profile's values. Leave unset for pure env-based configuration.
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
GITEA_MCP_PROFILE=prgs
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
# merger process) are ignored.
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
+2
View File
@@ -12,6 +12,8 @@ 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:
+39
View File
@@ -823,6 +823,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
## Namespace workspace binding (#510)
Each MCP namespace resolves its **own** active task workspace. Foreign role
worktree environment variables must not poison another namespace's purity
checks.
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|-----------|------------------------------------------------------------------|---------------|
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
MCP processes ignore it even when it points at a dirty author WIP tree.
### Safe reconnect / rebind procedure
When a mutation blocks on workspace binding:
1. Read the error — it names the **resolved workspace path**, **role
namespace**, and **binding source** (tool arg, env var, or process root).
2. Reconnect or relaunch the correct namespace MCP server from the intended
workspace (or set the role-specific env var before launch).
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
branches/ worktree differs from the MCP process root.
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
own namespace — that destroys another agent's WIP.
### CTH guidance for workspace binding blockers
When posting a Canonical Thread Handoff after a binding blocker:
- State which namespace was active (author / reviewer / merger / reconciler).
- Quote the resolved workspace path and binding source from the error.
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
`GITEA_*_WORKTREE`, or pass `worktree_path`).
- Explicitly note that foreign worktrees must not be cleaned to unblock.
## Safety notes
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
+16 -22
View File
@@ -12,6 +12,7 @@ import re
from typing import Any, Callable
import issue_lock_provenance
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -492,27 +493,6 @@ def _rule_reviewer_validation_failure_history(
]
def _rule_worktree_cleanup_audit_proof(report_text: str) -> list[dict[str, str]]:
from worktree_cleanup_audit import assess_cleanup_audit_final_report
text = report_text or ""
if "cleanup audit" not in text.lower() and "reconciliation table" not in text.lower():
return []
result = assess_cleanup_audit_final_report(text)
if result.get("proven"):
return []
return _findings_from_reasons(
"author.worktree_cleanup_audit_proof",
result.get("reasons") or [],
field="Worktree cleanup audit",
severity="block",
safe_next_action=(
"include reconciliation table counts, disposition rows, and "
"final git worktree list proof"
),
)
def _rule_reviewer_validation_cwd_proof(
report_text: str,
*,
@@ -1063,6 +1043,20 @@ def _rule_reviewer_review_mutation(
)
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
result = assess_post_merge_cleanup_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.post_merge_cleanup_proof",
result.get("reasons") or [],
field="Cleanup status",
severity="block",
safe_next_action=result.get("safe_next_action")
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
@@ -1092,6 +1086,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_target_branch_freshness,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
_rule_reviewer_post_merge_cleanup_proof,
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
@@ -1117,7 +1112,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
_rule_worktree_cleanup_audit_proof,
_rule_conflict_fix_push_proof,
],
"issue_filing": [
+263 -124
View File
@@ -165,6 +165,7 @@ _preflight_capability_called = False
_preflight_whoami_violation = False
_preflight_capability_violation = False
_preflight_resolved_role = None
_preflight_resolved_task: str | None = None
_process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None
@@ -174,12 +175,76 @@ _preflight_reviewer_violation_files: list[str] = []
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
import namespace_workspace_binding as nwb # noqa: E402
def _preflight_in_test_mode() -> bool:
return "pytest" in sys.modules or "unittest" in sys.modules
def _reviewer_session_worktree() -> str | None:
import reviewer_pr_lease as _reviewer_pr_lease
session = _reviewer_pr_lease.get_session_lease()
if not session:
return None
worktree = (session.get("worktree") or "").strip()
return worktree or None
def _effective_workspace_role() -> str:
"""Resolve the namespace key used for workspace binding (#510)."""
profile = get_profile()
role = _preflight_resolved_role
if not role:
role = _role_kind(
profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [],
)
return nwb.normalize_role_kind(
role,
profile_name=profile.get("profile_name"),
)
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards."""
role = _effective_workspace_role()
workspace, _source = nwb.resolve_namespace_workspace(
role_kind=role,
worktree_path=worktree_path,
process_project_root=PROJECT_ROOT,
session_lease_worktree=(
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
),
profile_name=get_profile().get("profile_name"),
)
return workspace
def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
"""Canonical namespace workspace + repository root for guards (#460/#510)."""
role = _effective_workspace_role()
return nwb.resolve_namespace_mutation_context(
role_kind=role,
worktree_path=worktree_path,
process_project_root=PROJECT_ROOT,
session_lease_worktree=(
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
),
profile_name=get_profile().get("profile_name"),
)
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
"""Backward-compatible alias for namespace workspace context."""
return _resolve_namespace_mutation_context(worktree_path)
def _ensure_process_start_porcelain() -> str:
"""Capture the shared-worktree baseline once per MCP process (#252)."""
global _process_start_porcelain
@@ -188,26 +253,6 @@ def _ensure_process_start_porcelain() -> str:
return _process_start_porcelain
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
"""Resolve the workspace root inspected by pre-flight guards."""
return author_mutation_worktree.resolve_mutation_workspace(
worktree_path,
PROJECT_ROOT,
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
)
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
"""Canonical workspace + repository root for runtime_context and guards (#460)."""
return author_mutation_worktree.resolve_author_mutation_context(
worktree_path,
PROJECT_ROOT,
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
)
def _get_git_root(path: str) -> str | None:
try:
res = subprocess.run(
@@ -275,7 +320,7 @@ def _format_preflight_files(files: list[str]) -> str:
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
ctx = _resolve_author_mutation_context(worktree_path)
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
inspected_root = _get_git_root(workspace)
process_root = ctx["process_project_root"]
@@ -293,6 +338,9 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
"dirty_files": list(dirty_files),
"dirty_scope": dirty_scope,
"workspace_roots_aligned": ctx["roots_aligned"],
"workspace_role_kind": ctx.get("workspace_role_kind"),
"workspace_binding_source": ctx.get("workspace_binding_source"),
"ignored_bindings": list(ctx.get("ignored_bindings") or []),
}
if not ctx["roots_aligned"]:
details["workspace_root_mismatch"] = (
@@ -303,13 +351,19 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
def _format_preflight_workspace_details(details: dict) -> str:
return (
f"MCP server process root: {details.get('mcp_server_process_root')}; "
f"active task workspace root: {details.get('active_task_workspace_root')}; "
f"inspected git root: {details.get('inspected_git_root')}; "
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}; "
f"dirty scope: {details.get('dirty_scope')}"
)
parts = [
f"MCP server process root: {details.get('mcp_server_process_root')}",
f"active task workspace root: {details.get('active_task_workspace_root')}",
f"workspace role: {details.get('workspace_role_kind')}",
f"binding source: {details.get('workspace_binding_source')}",
f"inspected git root: {details.get('inspected_git_root')}",
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}",
f"dirty scope: {details.get('dirty_scope')}",
]
ignored = details.get("ignored_bindings") or []
if ignored:
parts.append(f"ignored foreign bindings: {'; '.join(ignored)}")
return "; ".join(parts)
def assess_preflight_status(worktree_path: str | None = None) -> dict:
@@ -332,6 +386,25 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
"Active task workspace has tracked file edits before mutation "
f"({_format_preflight_workspace_details(workspace_details)})"
)
role = _effective_workspace_role()
if role in nwb.NON_AUTHOR_ROLES:
binding = nwb.assess_metadata_only_worktree_binding(
role_kind=role,
declared_worktree_path=worktree_path,
mutation_workspace=_resolve_preflight_workspace_path(worktree_path),
process_project_root=PROJECT_ROOT,
profile_name=get_profile().get("profile_name"),
)
if binding.get("block"):
reasons.append(binding["reasons"][0])
reasons.append(
nwb.format_namespace_workspace_binding_error(
role_kind=role,
workspace_path=binding["mutation_workspace"],
binding_source="MCP server process root (default)",
reasons=binding.get("reasons"),
)
)
return {
"preflight_ready": not reasons,
"preflight_block_reasons": reasons,
@@ -379,11 +452,30 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
}
def record_preflight_check(type_name: str, resolved_role: str | None = None):
def _clear_preflight_capability_state() -> None:
"""Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
global _preflight_capability_called, _preflight_capability_violation
global _preflight_resolved_task
global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_resolved_task = None
_preflight_reviewer_violation_files = []
def record_preflight_check(
type_name: str,
resolved_role: str | None = None,
resolved_task: str | None = None,
):
"""Record a pre-flight check (whoami or capability) with session-scoped deltas."""
global _preflight_whoami_called, _preflight_capability_called
global _preflight_whoami_violation, _preflight_capability_violation
global _preflight_resolved_role
global _preflight_resolved_role, _preflight_resolved_task
global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
global _preflight_whoami_violation_files, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
@@ -391,13 +483,24 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
current = _get_workspace_porcelain()
if type_name == "whoami":
# Fresh whoami restarts the capability step and re-evaluates violations
# instead of replaying a sticky record (#252).
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_reviewer_violation_files = []
# Re-evaluate whoami violations instead of replaying sticky state (#252).
# Interleaved read-only whoami must not clear a valid capability proof (#469).
saved_capability = None
preserve_capability = (
_preflight_capability_called
and _preflight_whoami_called
and not _preflight_whoami_violation
)
if preserve_capability:
saved_capability = (
_preflight_capability_violation,
list(_preflight_capability_violation_files),
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
)
else:
_clear_preflight_capability_state()
process_start = _ensure_process_start_porcelain()
whoami_delta = _new_tracked_changes_since(process_start, current)
@@ -405,6 +508,20 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_whoami_violation_files = whoami_delta
_preflight_whoami_baseline_porcelain = current
_preflight_whoami_called = True
if (
preserve_capability
and saved_capability is not None
and not whoami_delta
):
(
_preflight_capability_violation,
_preflight_capability_violation_files,
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
) = saved_capability
_preflight_capability_called = True
elif type_name == "capability":
baseline = _preflight_whoami_baseline_porcelain or ""
capability_delta = _new_tracked_changes_since(baseline, current)
@@ -414,18 +531,21 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_capability_called = True
if resolved_role:
_preflight_resolved_role = resolved_role
if resolved_task:
_preflight_resolved_task = resolved_task
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
"""#274: author file/branch mutations must run from a branches/ worktree.
Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a
Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
(#468).
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
(#468). Non-author namespaces use dedicated workspace env vars (#510).
"""
if _preflight_resolved_role in ("reviewer", "reconciler"):
role = _effective_workspace_role()
if role in nwb.NON_AUTHOR_ROLES:
return
ctx = _resolve_author_mutation_context(worktree_path)
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
assessment = author_mutation_worktree.assess_author_mutation_worktree(
@@ -439,7 +559,11 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
)
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
def verify_preflight_purity(
remote: str | None = None,
worktree_path: str | None = None,
task: str | None = None,
):
"""Verify that identity and capability were verified prior to session edits."""
global _preflight_reviewer_violation_files
@@ -458,12 +582,23 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
raise RuntimeError(
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if (
task is not None
and _preflight_resolved_task is not None
and task != _preflight_resolved_task
):
raise RuntimeError(
"Pre-flight task mismatch: "
f"resolved '{_preflight_resolved_task}' but mutation requires "
f"'{task}' (fail closed)"
)
ctx = _resolve_author_mutation_context(worktree_path)
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
canonical_root = ctx["canonical_repo_root"]
process_root = ctx["process_project_root"]
real_workspace = os.path.realpath(workspace)
role = ctx.get("workspace_role_kind") or _effective_workspace_role()
if real_workspace != process_root:
if not _preflight_in_test_mode():
@@ -480,11 +615,15 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
if dirty_files:
details = _preflight_workspace_details(workspace, dirty_files)
raise RuntimeError(
"Pre-flight order violation: Active task workspace has tracked "
"file edits before mutation (fail closed). "
f"{_format_preflight_workspace_details(details)}"
nwb.format_namespace_workspace_binding_error(
role_kind=role,
workspace_path=workspace,
binding_source=ctx.get("workspace_binding_source")
or "unknown binding source",
dirty_files=dirty_files,
ignored_bindings=ctx.get("ignored_bindings"),
)
)
else:
if _preflight_whoami_violation:
@@ -500,19 +639,59 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
f"{_format_preflight_files(_preflight_capability_violation_files)}"
)
if _preflight_resolved_role == "reviewer":
if role in {"reviewer", "merger"}:
current = _get_workspace_porcelain()
baseline = _preflight_capability_baseline_porcelain or ""
reviewer_delta = _new_tracked_changes_since(baseline, current)
_preflight_reviewer_violation_files = reviewer_delta
if reviewer_delta:
raise RuntimeError(
"Reviewer role violation: Reviewer profile is forbidden from modifying "
f"{role.title()} role violation: profile is forbidden from modifying "
"tracked workspace files (fail closed). Offending files: "
f"{_format_preflight_files(reviewer_delta)}"
)
_enforce_branches_only_author_mutation(worktree_path)
_clear_preflight_capability_state()
def _verify_role_mutation_workspace(
remote: str | None = None,
*,
worktree_path: str | None = None,
worktree: str | None = None,
task: str | None = None,
) -> str:
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
role = _effective_workspace_role()
git_state = issue_lock_worktree.read_worktree_git_state(
_resolve_preflight_workspace_path(worktree_path)
)
assessment = nwb.assess_namespace_mutation_workspace(
role_kind=role,
worktree_path=worktree_path,
worktree=worktree,
process_project_root=PROJECT_ROOT,
session_lease_worktree=(
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
),
profile_name=get_profile().get("profile_name"),
current_branch=git_state.get("current_branch"),
)
if assessment["block"]:
raise RuntimeError(
nwb.format_namespace_workspace_binding_error(
role_kind=role,
workspace_path=assessment["mutation_workspace"],
binding_source=assessment.get("workspace_binding_source")
or "unknown binding source",
reasons=assessment.get("reasons"),
ignored_bindings=assessment.get("ignored_bindings"),
)
)
resolved = assessment["mutation_workspace"]
verify_preflight_purity(remote, worktree_path=resolved, task=task)
return resolved
from mcp.server.fastmcp import FastMCP # noqa: E402
@@ -548,7 +727,6 @@ import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import reviewer_pr_lease # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import worktree_cleanup_audit # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
import review_merge_state_machine # noqa: E402
@@ -1229,7 +1407,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
@@ -1362,7 +1540,7 @@ def gitea_lock_issue(
)
else:
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue")
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
current_branch=git_state.get("current_branch"),
@@ -1586,7 +1764,7 @@ def gitea_create_pr(
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
@@ -2650,9 +2828,12 @@ def _evaluate_pr_review_submission(
*,
live: bool,
final_review_decision_ready: bool = False,
worktree_path: str | None = None,
) -> dict:
"""Shared gate chain for live submit and dry-run review tools."""
verify_preflight_purity(remote)
_verify_role_mutation_workspace(
remote, worktree_path=worktree_path, task="review_pr"
)
action = (action or "").strip().lower()
result = {
"requested_action": action,
@@ -3052,6 +3233,7 @@ def gitea_dry_run_pr_review(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Validate review submission mechanics without a live PR mutation."""
return _evaluate_pr_review_submission(
@@ -3064,6 +3246,7 @@ def gitea_dry_run_pr_review(
org=org,
repo=repo,
live=False,
worktree_path=worktree_path,
)
@@ -3079,6 +3262,7 @@ def gitea_submit_pr_review(
org: str | None = None,
repo: str | None = None,
final_review_decision_ready: bool = False,
worktree_path: str | None = None,
) -> dict:
"""Gated PR review mutation: comment findings, request changes, or approve.
@@ -3097,6 +3281,7 @@ def gitea_submit_pr_review(
repo=repo,
live=True,
final_review_decision_ready=final_review_decision_ready,
worktree_path=worktree_path,
)
@@ -3159,12 +3344,12 @@ def gitea_edit_pr(
if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
verify_preflight_purity(remote)
closing = payload.get("state") == "closed"
verify_preflight_purity(remote, task="close_pr" if closing else None)
# PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
# never touches the network.
closing = payload.get("state") == "closed"
if closing:
gate_reasons = _profile_operation_gate("gitea.pr.close")
if gate_reasons:
@@ -3409,7 +3594,7 @@ def gitea_commit_files(
branch="",
)
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="commit_files")
processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo)
@@ -3457,6 +3642,7 @@ def gitea_merge_pr(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Gated merge of a Gitea pull request (#16).
@@ -3503,6 +3689,10 @@ def gitea_merge_pr(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Merger workspace path under ``branches/`` or clean
control checkout; defaults to ``GITEA_MERGER_WORKTREE``,
``GITEA_ACTIVE_WORKTREE``, or the MCP server process root. Ignores
foreign ``GITEA_AUTHOR_WORKTREE`` bindings (#510).
Returns:
dict describing the attempt: performed, authenticated user, profile
@@ -3510,7 +3700,9 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
verify_preflight_purity(remote)
_verify_role_mutation_workspace(
remote, worktree_path=worktree_path, task="merge_pr"
)
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -4039,7 +4231,7 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="delete_branch")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
@@ -4070,59 +4262,6 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo
raise
@mcp.tool()
def gitea_capture_branches_worktree_snapshot(
open_pr_branches: list[str] | None = None,
active_lock_branch: str | None = None,
leased_paths: list[str] | None = None,
worktree_path: str | None = None,
) -> dict:
"""Read-only: capture ``branches/`` and worktree audit snapshot (#404)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
root = PROJECT_ROOT
if worktree_path:
root = os.path.realpath(os.path.abspath(worktree_path))
git_root = _get_git_root(root)
if git_root:
root = git_root
snapshot = worktree_cleanup_audit.capture_branches_worktree_snapshot(
root,
open_pr_branches=open_pr_branches,
active_lock_branch=active_lock_branch,
leased_paths=leased_paths,
)
return {"success": True, "snapshot": snapshot}
@mcp.tool()
def gitea_assess_worktree_cleanup_integrity(
before_snapshot: dict,
after_snapshot: dict,
removals: list[dict] | None = None,
explained_missing: dict[str, str] | None = None,
) -> dict:
"""Read-only: reconcile cleanup before/after snapshots (#404)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"integrity_passed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
return worktree_cleanup_audit.assess_worktree_cleanup_integrity(
before=before_snapshot,
after=after_snapshot,
removals=removals,
explained_missing=explained_missing,
)
@mcp.tool()
def gitea_reconcile_merged_cleanups(
dry_run: bool = True,
@@ -4201,7 +4340,7 @@ def gitea_reconcile_merged_cleanups(
report["executed"] = False
return {"success": True, "performed": False, **report}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="reconcile_merged_cleanups")
actions: list[dict] = []
for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or ""
@@ -4515,7 +4654,7 @@ def gitea_reconcile_already_landed_pr(
)
return result
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="reconcile_already_landed_pr")
if post_comment and comment_body.strip():
comment_block = _profile_operation_gate("gitea.pr.comment")
@@ -4618,7 +4757,7 @@ def gitea_close_issue(
task_capability_map.required_permission("close_issue"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="close_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
@@ -5046,7 +5185,7 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"),
}
verify_preflight_purity(remote)
_verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
@@ -5147,7 +5286,7 @@ def gitea_heartbeat_reviewer_pr_lease(
],
}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
body = reviewer_pr_lease.format_lease_body(
@@ -5323,7 +5462,7 @@ def gitea_create_issue_comment(
(permission blocks also carry a structured 'permission_report',
#142).
"""
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="comment_issue")
gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
@@ -6857,7 +6996,7 @@ def gitea_mark_issue(
task_capability_map.required_permission("mark_issue"))
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -6935,7 +7074,7 @@ def gitea_post_heartbeat(
task_capability_map.required_permission("post_heartbeat"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="post_heartbeat")
active_profile = profile or get_profile().get("profile_name")
body = issue_claim_heartbeat.format_heartbeat_body(
kind="progress",
@@ -7178,7 +7317,7 @@ def gitea_cleanup_stale_claims(
task_capability_map.required_permission("cleanup_stale_claims"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="cleanup_stale_claims")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -7332,7 +7471,7 @@ def gitea_set_issue_labels(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="set_issue_labels")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -7569,7 +7708,7 @@ def gitea_resolve_task_capability(
"exact_safe_next_action": next_safe_action,
}
record_preflight_check("capability", required_role)
record_preflight_check("capability", required_role, resolved_task=task)
# Try automatic dispatch switching
_ensure_matching_profile(required_permission, required_role, remote, host)
+307
View File
@@ -0,0 +1,307 @@
"""Namespace-scoped MCP workspace binding (#510).
Each role namespace (author, reviewer, merger, reconciler) resolves its own
active task workspace. Foreign role worktree environment variables must not
poison workspace purity checks in another namespace.
"""
from __future__ import annotations
import os
import author_mutation_worktree as amw
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
ROLE_WORKTREE_ENVS: dict[str, str] = {
"author": AUTHOR_WORKTREE_ENV,
"reviewer": REVIEWER_WORKTREE_ENV,
"merger": MERGER_WORKTREE_ENV,
"reconciler": RECONCILER_WORKTREE_ENV,
}
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
def normalize_role_kind(
role_kind: str | None,
*,
profile_name: str | None = None,
) -> str:
"""Map profile/task role to a workspace namespace key."""
role = (role_kind or "author").strip().lower()
profile = (profile_name or "").strip().lower()
if role == "reviewer" and "merger" in profile:
return "merger"
if role in ROLE_WORKTREE_ENVS:
return role
return "author"
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
text = (env.get(key) or "").strip()
return text or None
def resolve_namespace_workspace(
*,
role_kind: str,
worktree_path: str | None = None,
worktree: str | None = None,
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
profile_name: str | None = None,
) -> tuple[str, str]:
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
env_map = env if env is not None else os.environ
role = normalize_role_kind(role_kind, profile_name=profile_name)
role_env_key = ROLE_WORKTREE_ENVS[role]
for candidate, source in (
(worktree_path, "worktree_path argument"),
(worktree, "worktree argument"),
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
(session_lease_worktree if role in {"reviewer", "merger"} else None,
"reviewer PR lease worktree"),
):
text = (candidate or "").strip()
if text:
return os.path.realpath(os.path.abspath(text)), source
return os.path.realpath(process_project_root), "MCP server process root (default)"
def resolve_namespace_mutation_context(
*,
role_kind: str,
worktree_path: str | None,
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
worktree: str | None = None,
profile_name: str | None = None,
) -> dict:
"""Shared workspace resolution for runtime_context and mutation guards."""
workspace, binding_source = resolve_namespace_workspace(
role_kind=role_kind,
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_project_root,
env=env,
session_lease_worktree=session_lease_worktree,
profile_name=profile_name,
)
process_root = os.path.realpath(process_project_root)
role = normalize_role_kind(role_kind, profile_name=profile_name)
pollution = assess_foreign_role_worktree_pollution(
role_kind=role,
resolved_workspace=workspace,
binding_source=binding_source,
env=env,
profile_name=profile_name,
)
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
return {
"workspace_path": workspace,
"workspace_binding_source": binding_source,
"workspace_role_kind": role,
"ignored_bindings": pollution.get("ignored_bindings") or [],
"process_project_root": process_root,
"canonical_repo_root": canonical_root,
"roots_aligned": canonical_root == process_root,
}
def assess_foreign_role_worktree_pollution(
*,
role_kind: str,
resolved_workspace: str,
binding_source: str,
env: dict[str, str] | os._Environ | None = None,
profile_name: str | None = None,
) -> dict:
"""Detect when a foreign role env would have hijacked workspace binding."""
env_map = env if env is not None else os.environ
role = normalize_role_kind(role_kind, profile_name=profile_name)
if role == "author":
return {"would_pollute": False, "ignored_bindings": []}
ignored: list[str] = []
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
if author_path:
author_real = os.path.realpath(os.path.abspath(author_path))
resolved_real = os.path.realpath(resolved_workspace)
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
ignored.append(
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
)
return {
"would_pollute": bool(ignored),
"ignored_bindings": ignored,
}
def assess_metadata_only_worktree_binding(
*,
role_kind: str,
declared_worktree_path: str | None,
mutation_workspace: str,
process_project_root: str,
profile_name: str | None = None,
) -> dict:
"""Fail closed when declared worktree_path would not redirect mutations."""
declared = (declared_worktree_path or "").strip()
process_root = os.path.realpath(process_project_root)
mutation_root = os.path.realpath(mutation_workspace)
role = normalize_role_kind(role_kind, profile_name=profile_name)
if not declared:
return {"block": False, "reasons": [], "metadata_only": False}
declared_root = os.path.realpath(os.path.abspath(declared))
if declared_root == mutation_root:
return {"block": False, "reasons": [], "metadata_only": False}
if declared_root != process_root and mutation_root == process_root:
return {
"block": True,
"metadata_only": True,
"reasons": [
f"worktree_path is metadata-only for {role} mutations: preflight "
f"inspected '{declared_root}' but mutation tools would still "
f"validate MCP server process root '{process_root}'"
],
"declared_worktree_path": declared_root,
"mutation_workspace": mutation_root,
"process_project_root": process_root,
}
return {"block": False, "reasons": [], "metadata_only": False}
def format_namespace_workspace_binding_error(
*,
role_kind: str,
workspace_path: str,
binding_source: str,
reasons: list[str] | None = None,
ignored_bindings: list[str] | None = None,
dirty_files: list[str] | None = None,
) -> str:
"""Canonical error when namespace workspace binding blocks mutations."""
role = normalize_role_kind(role_kind)
workspace = os.path.realpath(workspace_path)
parts = [
f"Namespace workspace binding blocked ({role} namespace, #510): "
f"resolved workspace '{workspace}' via {binding_source}."
]
if ignored_bindings:
parts.append(
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
)
if dirty_files:
parts.append(
"Dirty tracked files in active task workspace: "
+ ", ".join(dirty_files)
+ "."
)
if reasons:
parts.append("Details: " + "; ".join(reasons) + ".")
parts.append(
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
f"branches/ {role} worktree, set "
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
"to that path, or pass worktree_path on mutation tools. Do not clean or "
"reset foreign role worktrees to unblock this namespace."
)
return " ".join(parts)
def assess_namespace_mutation_workspace(
*,
role_kind: str,
worktree_path: str | None,
worktree: str | None,
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
profile_name: str | None = None,
current_branch: str | None = None,
) -> dict:
"""Evaluate namespace workspace binding before preflight/mutation."""
ctx = resolve_namespace_mutation_context(
role_kind=role_kind,
worktree_path=worktree_path,
worktree=worktree,
process_project_root=process_project_root,
env=env,
session_lease_worktree=session_lease_worktree,
profile_name=profile_name,
)
mutation_workspace = ctx["workspace_path"]
binding_source = ctx["workspace_binding_source"]
role = ctx["workspace_role_kind"]
process_root = ctx["process_project_root"]
metadata = assess_metadata_only_worktree_binding(
role_kind=role,
declared_worktree_path=worktree_path,
mutation_workspace=mutation_workspace,
process_project_root=process_root,
profile_name=profile_name,
)
pollution = assess_foreign_role_worktree_pollution(
role_kind=role,
resolved_workspace=mutation_workspace,
binding_source=binding_source,
env=env,
profile_name=profile_name,
)
reasons = list(metadata.get("reasons") or [])
if role == "author":
branches = amw.assess_author_mutation_worktree(
workspace_path=mutation_workspace,
project_root=ctx["canonical_repo_root"],
current_branch=current_branch,
)
if branches["block"]:
reasons.extend(branches["reasons"])
elif (
role == "reviewer"
and mutation_workspace == process_root
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
):
reasons.append(
f"{role} mutation blocked: workspace is the stable control checkout; "
f"create or reconnect to a session-owned worktree under branches/ "
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
)
elif (
role in {"reviewer", "merger"}
and mutation_workspace != process_root
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
):
reasons.append(
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
f"'{ctx['canonical_repo_root']}/branches/'"
)
block = bool(reasons)
return {
"block": block,
"reasons": reasons,
"mutation_workspace": mutation_workspace,
"workspace_binding_source": binding_source,
"workspace_role_kind": role,
"process_project_root": process_root,
"canonical_repo_root": ctx["canonical_repo_root"],
"metadata_only": metadata.get("metadata_only", False),
"declared_worktree_path": metadata.get("declared_worktree_path"),
"ignored_bindings": pollution.get("ignored_bindings") or [],
}
+239
View File
@@ -0,0 +1,239 @@
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
from __future__ import annotations
import re
from typing import Any
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
_CLEANUP_SECTION_HINT = re.compile(
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
re.IGNORECASE,
)
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
_CLEANUP_BLOCKER_RE = re.compile(
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
re.IGNORECASE,
)
_REMOTE_DELETE_CLAIM_RE = re.compile(
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
r"deleted remote branch|delete_branch)",
re.IGNORECASE,
)
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
r"worktree cleanup performed)",
re.IGNORECASE,
)
_DELETE_CAPABILITY_RE = re.compile(
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
re.IGNORECASE,
)
_DELETE_TASK_RE = re.compile(
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(
r"merge result\s*:\s*(?:merged|success|performed)",
re.IGNORECASE,
)
_MERGE_COMMIT_SHA_RE = re.compile(
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_PR_HEAD_BRANCH_RE = re.compile(
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BRANCH_NOT_PROTECTED_RE = re.compile(
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
re.IGNORECASE,
)
_OPEN_PR_INVENTORY_RE = re.compile(
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
r"no other open pr references branch",
re.IGNORECASE,
)
_ACTIVE_CLAIM_LEASE_RE = re.compile(
r"(?:no active (?:heartbeat|claim|lease)|"
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
re.IGNORECASE,
)
_SESSION_OWNED_WORKTREE_RE = re.compile(
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
_CLEAN_TRACKED_RE = re.compile(
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
re.IGNORECASE,
)
_CLEAN_UNTRACKED_RE = re.compile(
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
re.IGNORECASE,
)
_WORKTREE_LIST_AFTER_RE = re.compile(
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
re.IGNORECASE,
)
_WRONG_BRANCH_RE = re.compile(
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
re.IGNORECASE,
)
def _claims_remote_delete(text: str) -> bool:
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
def _claims_worktree_remove(text: str) -> bool:
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
def _branch_safety_fields_present(text: str) -> list[str]:
missing: list[str] = []
if not _DELETE_CAPABILITY_RE.search(text):
missing.append("delete-branch capability resolved (gitea.branch.delete)")
if not _DELETE_TASK_RE.search(text):
missing.append("delete-branch task named (delete_branch or cleanup)")
if not _MERGE_RESULT_RE.search(text):
missing.append("merge result: merged")
if not _MERGE_COMMIT_SHA_RE.search(text):
missing.append("merge commit SHA")
if not _PR_HEAD_BRANCH_RE.search(text):
missing.append("merged PR head branch / deleted branch name")
if not _BRANCH_NOT_PROTECTED_RE.search(text):
missing.append("branch not protected proof")
if not _OPEN_PR_INVENTORY_RE.search(text):
missing.append("open PR inventory proof (no other PR references branch)")
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
missing.append("no active heartbeat/claim/lease proof")
return missing
def _worktree_cleanup_fields_present(text: str) -> list[str]:
missing: list[str] = []
match = _SESSION_OWNED_WORKTREE_RE.search(text)
path = match.group(1).strip() if match else ""
if not path:
missing.append("session-owned worktree path")
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
missing.append("worktree path under branches/")
if not _CLEAN_TRACKED_RE.search(text):
missing.append("pre-removal tracked state: clean")
if not _CLEAN_UNTRACKED_RE.search(text):
missing.append("pre-removal untracked state: clean")
if not _WORKTREE_LIST_AFTER_RE.search(text):
missing.append("git worktree list after removal")
return missing
def assess_post_merge_cleanup_proof(
report_text: str,
*,
cleanup_session: dict | None = None,
) -> dict[str, Any]:
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
text = report_text or ""
session = dict(cleanup_session or {})
reasons: list[str] = []
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
blocker = (session.get("blocker") or "").strip()
if not blocker:
match = _CLEANUP_BLOCKER_RE.search(text)
blocker = match.group(1).strip() if match else ""
if blocker.upper() == CLEANUP_SKIPPED:
blocker = ""
if not blocker:
reasons.append(
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
)
return {
"block": bool(reasons),
"proven": not reasons,
"outcome": CLEANUP_SKIPPED,
"remote_delete_claimed": False,
"worktree_remove_claimed": False,
"reasons": reasons,
"safe_next_action": (
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
if reasons
else "proceed"
),
}
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
return {
"block": False,
"proven": True,
"outcome": None,
"remote_delete_claimed": False,
"worktree_remove_claimed": False,
"reasons": [],
"safe_next_action": "proceed",
}
remote_delete = bool(
session.get("remote_delete_claimed") or _claims_remote_delete(text)
)
worktree_remove = bool(
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
)
if _WRONG_BRANCH_RE.search(text):
reasons.append(
"cleanup report claims deleted branch that is not the merged PR head branch"
)
if remote_delete:
reasons.extend(
f"remote branch deletion missing {field}"
for field in _branch_safety_fields_present(text)
)
if worktree_remove:
reasons.extend(
f"worktree removal missing {field}"
for field in _worktree_cleanup_fields_present(text)
)
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
pass
if not remote_delete and not worktree_remove:
cleanup_mutations = re.search(
r"cleanup mutations\s*:\s*(?!none\b)\S",
text,
re.IGNORECASE,
)
if cleanup_mutations:
reasons.append(
"cleanup mutations reported without post-merge cleanup proof checklist"
)
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
if remote_delete or worktree_remove:
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
block = bool(reasons)
return {
"block": block,
"proven": not block,
"outcome": outcome,
"remote_delete_claimed": remote_delete,
"worktree_remove_claimed": worktree_remove,
"reasons": reasons,
"safe_next_action": (
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
"checklist before claiming remote delete or worktree removal"
if reasons
else "proceed"
),
}
@@ -26,43 +26,3 @@ Steps:
Handoff: merge confirmed, issue closed, branch+worktree removed, checkout clean.
```
## Branches cleanup audit integrity (#404)
Any bulk or multi-path cleanup under `branches/` must capture auditable before/after
identity for every initial directory and registered worktree. Use
`worktree_cleanup_audit.capture_cleanup_snapshot` before and after cleanup, record
every intentional removal in a removal log (path, method, order, timestamp,
pre-removal proof), then run `reconcile_cleanup_audit` and
`assess_cleanup_audit_integrity`.
The cleanup report must include a reconciliation table:
* initial count
* removed count
* preserved count
* missing-unexplained count
* final count
Fail closed when:
* a preserved (active PR, dirty, claim/lease, or unsafe) worktree disappears without
a removal log entry or explicit explanation
* the removal log omits a removed clean-stale path
* final counts do not reconcile with initial minus removed
If another session removes or mutates a worktree during cleanup, record the path
under explained missing entries — never treat silent disappearance as success.
## Bulk `branches/` cleanup audit (#404)
Before removing multiple session-owned worktrees:
1. Call `gitea_capture_branches_worktree_snapshot` and record the before snapshot.
2. Remove only paths classified as `clean_stale_removable` with explicit per-path proof.
3. Log every removal with path, method, and timestamp/order.
4. Capture an after snapshot with the same tool.
5. Call `gitea_assess_worktree_cleanup_integrity` with before, after, and the removal log.
6. Fail closed when any protected path (active PR, dirty, claim/lease) disappears
without an explained state transition.
7. Final report must include the reconciliation table and `git worktree list` proof.
@@ -881,6 +881,40 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
If any local artifact is created after final cleanup, run and report a new final status check.
## 28A. Post-merge cleanup proof checklist (#402)
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
### Remote branch deletion checklist
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
* Merge result: merged
* Merge commit SHA: full 40-character SHA
* Merged PR head branch / deleted branch: exact branch name (must match)
* Branch protection: none / branch is not protected
* Open PR inventory proof: no other open PR references the branch
* Active heartbeat/claim/lease: none
### Local worktree removal checklist
When removing session-owned review/simulation worktrees under `branches/`, report:
* Session-owned worktree path: exact path under `branches/`
* Pre-removal tracked state: clean
* Pre-removal untracked state: clean
* Git worktree list after removal: command output or equivalent proof
### Skipped cleanup
If any gate fails, report:
* Cleanup outcome: `CLEANUP_SKIPPED`
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
## 29. Recovery handoff rules
If blocked, produce a recovery handoff with:
+6 -5
View File
@@ -3879,7 +3879,7 @@ class TestPreflightVerification(unittest.TestCase):
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
self.assertIn("forbidden from modifying tracked workspace files", str(ctx.exception))
self.assertIn("reviewer_edit.py", str(ctx.exception))
# Foreign pre-existing dirty state does not block when unchanged.
@@ -3945,7 +3945,8 @@ class TestPreflightVerification(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(worktree_path=worktree)
msg = str(ctx.exception)
self.assertIn("active task workspace root", msg)
self.assertIn("inspected git root", msg)
self.assertIn("dirty files: task_file.py", msg)
self.assertIn("dirty scope:", msg)
self.assertIn("resolved workspace", msg)
self.assertIn(worktree, msg)
self.assertIn("worktree_path argument", msg)
self.assertIn("task_file.py", msg)
self.assertIn("author namespace", msg)
+240
View File
@@ -0,0 +1,240 @@
"""Tests for namespace-scoped MCP workspace binding (#510)."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv # noqa: E402
import namespace_workspace_binding as nwb # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
if REPO_ROOT.parent.name == "branches":
CONTROL_ROOT = str(REPO_ROOT.parent.parent)
else:
CONTROL_ROOT = str(REPO_ROOT)
AUTHOR_DIRTY = f"{CONTROL_ROOT}/branches/mcp-author-worktree"
MERGER_CLEAN = f"{CONTROL_ROOT}/branches/merge-pr487-submit"
REVIEWER_CLEAN = f"{CONTROL_ROOT}/branches/review-pr487-submit"
RECONCILER_CLEAN = f"{CONTROL_ROOT}/branches/reconcile-pr487"
MCP_PROCESS_ROOT = CONTROL_ROOT
class TestNamespaceWorkspaceModule(unittest.TestCase):
def test_author_env_ignored_for_merger_namespace(self):
workspace, source = nwb.resolve_namespace_workspace(
role_kind="merger",
worktree_path=None,
process_project_root=MCP_PROCESS_ROOT,
env={
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
nwb.MERGER_WORKTREE_ENV: MERGER_CLEAN,
},
profile_name="gitea-merger",
)
self.assertEqual(workspace, os.path.realpath(MERGER_CLEAN))
self.assertEqual(source, f"{nwb.MERGER_WORKTREE_ENV} environment variable")
def test_author_env_ignored_for_reviewer_namespace(self):
workspace, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
worktree_path=None,
process_project_root=MCP_PROCESS_ROOT,
env={
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
nwb.REVIEWER_WORKTREE_ENV: REVIEWER_CLEAN,
},
)
self.assertEqual(workspace, os.path.realpath(REVIEWER_CLEAN))
self.assertEqual(source, f"{nwb.REVIEWER_WORKTREE_ENV} environment variable")
def test_author_env_ignored_for_reconciler_namespace(self):
workspace, source = nwb.resolve_namespace_workspace(
role_kind="reconciler",
worktree_path=None,
process_project_root=MCP_PROCESS_ROOT,
env={
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
nwb.RECONCILER_WORKTREE_ENV: RECONCILER_CLEAN,
},
)
self.assertEqual(workspace, os.path.realpath(RECONCILER_CLEAN))
self.assertEqual(source, f"{nwb.RECONCILER_WORKTREE_ENV} environment variable")
def test_merger_profile_maps_to_merger_namespace(self):
role = nwb.normalize_role_kind("reviewer", profile_name="gitea-merger")
self.assertEqual(role, "merger")
def test_error_message_includes_path_and_binding_source(self):
msg = nwb.format_namespace_workspace_binding_error(
role_kind="merger",
workspace_path=AUTHOR_DIRTY,
binding_source=f"{nwb.AUTHOR_WORKTREE_ENV} environment variable",
dirty_files=["gitea_mcp_server.py"],
ignored_bindings=[f"{nwb.AUTHOR_WORKTREE_ENV}={AUTHOR_DIRTY} (ignored for merger namespace)"],
)
self.assertIn(AUTHOR_DIRTY, msg)
self.assertIn("via", msg.lower())
self.assertIn(nwb.AUTHOR_WORKTREE_ENV, msg)
self.assertIn("Do not clean or reset foreign role worktrees", msg)
class TestNamespaceWorkspaceIntegration(unittest.TestCase):
def setUp(self):
self._saved = {
"whoami_called": srv._preflight_whoami_called,
"capability_called": srv._preflight_capability_called,
"resolved_role": srv._preflight_resolved_role,
"whoami_violation": srv._preflight_whoami_violation,
"capability_violation": srv._preflight_capability_violation,
"in_test": srv._preflight_in_test_mode,
}
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
srv._preflight_in_test_mode = lambda: False
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
self._env_patch.start()
def tearDown(self):
srv._preflight_whoami_called = self._saved["whoami_called"]
srv._preflight_capability_called = self._saved["capability_called"]
srv._preflight_resolved_role = self._saved["resolved_role"]
srv._preflight_whoami_violation = self._saved["whoami_violation"]
srv._preflight_capability_violation = self._saved["capability_violation"]
srv._preflight_in_test_mode = self._saved["in_test"]
self._env_patch.stop()
for key in (
nwb.AUTHOR_WORKTREE_ENV,
nwb.MERGER_WORKTREE_ENV,
nwb.REVIEWER_WORKTREE_ENV,
nwb.RECONCILER_WORKTREE_ENV,
nwb.ACTIVE_WORKTREE_ENV,
):
os.environ.pop(key, None)
def _merger_profile(self):
return {
"profile_name": "gitea-merger",
"allowed_operations": ["gitea.pr.merge", "gitea.read"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
}
def _reviewer_profile(self):
return {
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.approve", "gitea.pr.review", "gitea.read"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
}
def _reconciler_profile(self):
return {
"profile_name": "prgs-reconciler",
"allowed_operations": ["gitea.pr.close", "gitea.read"],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
],
}
def _author_profile(self):
return {
"profile_name": "prgs-author",
"allowed_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.read"],
"forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"],
}
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_dirty_author_worktree_does_not_block_merger_with_clean_workspace(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
srv._preflight_resolved_role = "reviewer"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_dirty_author_worktree_does_not_block_reviewer_with_clean_workspace(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
os.environ[nwb.REVIEWER_WORKTREE_ENV] = REVIEWER_CLEAN
srv._preflight_resolved_role = "reviewer"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reviewer_profile()):
srv.verify_preflight_purity("prgs", worktree_path=REVIEWER_CLEAN)
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_dirty_author_worktree_does_not_block_reconciler_with_clean_workspace(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
os.environ[nwb.RECONCILER_WORKTREE_ENV] = RECONCILER_CLEAN
srv._preflight_resolved_role = "reconciler"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reconciler_profile()):
srv.verify_preflight_purity("prgs", worktree_path=RECONCILER_CLEAN)
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_dirty_active_task_workspace_still_blocks_mutations(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
srv._preflight_resolved_role = "reviewer"
dirty = " M namespace_workspace_binding.py\n"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
with mock.patch("gitea_mcp_server._get_workspace_porcelain", return_value=dirty):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
self.assertIn(MERGER_CLEAN, str(ctx.exception))
self.assertIn("binding", str(ctx.exception).lower())
def test_root_workspace_mutation_still_blocked_for_author(self):
srv._preflight_resolved_role = "author"
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._author_profile()):
with mock.patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs")
self.assertIn("stable control checkout", str(ctx.exception))
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_pr487_style_merge_binds_clean_merger_workspace(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
srv._preflight_resolved_role = "reviewer"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
resolved = srv._verify_role_mutation_workspace("prgs")
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
+169
View File
@@ -0,0 +1,169 @@
"""Tests for post-merge cleanup proof enforcement (#402)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from post_merge_cleanup_proof import ( # noqa: E402
CLEANUP_SKIPPED,
assess_post_merge_cleanup_proof,
)
MERGE_SHA = "a" * 40
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
WORKTREE = "branches/review-pr374-conflicts"
def _full_remote_cleanup_report(**overrides):
fields = {
"Task": "review PR #374",
"Merge result": "merged",
"Merge commit SHA": MERGE_SHA,
"Cleanup status": "remote branch deleted",
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
"Merged PR head branch": HEAD_BRANCH,
"Deleted branch": HEAD_BRANCH,
"Branch protection": "none",
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
"Active heartbeat/claim/lease": "none",
"Cleanup mutations": "gitea_delete_branch on remote head branch",
}
fields.update(overrides)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
def _full_worktree_cleanup_report(**overrides):
fields = {
"Task": "review PR #374",
"Merge result": "merged",
"Cleanup status": "local worktree removed",
"Removed worktree path": WORKTREE,
"Pre-removal tracked state": "clean",
"Pre-removal untracked state": "clean",
"Git worktree list after removal": "only main checkout listed",
"Cleanup mutations": "git worktree remove on session-owned review worktree",
}
fields.update(overrides)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
class TestPostMergeCleanupProof(unittest.TestCase):
def test_no_cleanup_claim_passes(self):
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
result = assess_post_merge_cleanup_proof(report)
self.assertFalse(result["block"])
def test_missing_capability_proof_blocked(self):
report = _full_remote_cleanup_report(
**{"Delete-branch capability resolved": "delete succeeded"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
def test_unmerged_pr_blocked(self):
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
def test_wrong_branch_blocked(self):
report = _full_remote_cleanup_report(
**{"Deleted branch": "feat/other-branch"}
)
report += "\nDeleted branch does not match merged PR head branch"
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_protected_branch_blocked(self):
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_open_pr_reference_blocked(self):
report = _full_remote_cleanup_report(
**{"Open PR inventory proof": "PR #999 still references branch"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_active_claim_blocked(self):
report = _full_remote_cleanup_report(
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_dirty_worktree_blocked(self):
report = _full_worktree_cleanup_report(
**{"Pre-removal tracked state": "dirty"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
def test_foreign_worktree_blocked(self):
report = _full_worktree_cleanup_report(
**{"Removed worktree path": "/tmp/foreign-worktree"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
def test_cleanup_skipped_with_blocker_passes(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup outcome: CLEANUP_SKIPPED",
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
])
result = assess_post_merge_cleanup_proof(report)
self.assertFalse(result["block"])
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
def test_cleanup_skipped_without_blocker_blocked(self):
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_full_remote_cleanup_passes(self):
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
self.assertFalse(result["block"])
self.assertTrue(result["remote_delete_claimed"])
def test_full_worktree_cleanup_passes(self):
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
self.assertFalse(result["block"])
self.assertTrue(result["worktree_remove_claimed"])
def test_validator_integration_blocks_unproven_delete(self):
report = (
"## Controller Handoff\n"
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
)
result = assess_final_report_validator(report, task_kind="review_pr")
self.assertTrue(result["blocked"])
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
def test_validator_integration_allows_skipped(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup outcome: CLEANUP_SKIPPED",
"- Cleanup blocker: branch still referenced by open PR #414",
])
result = assess_final_report_validator(report, task_kind="review_pr")
cleanup_findings = [
f for f in result["findings"]
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
]
self.assertEqual(cleanup_findings, [])
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""#469: capability preflight survives interleaved read-only whoami calls."""
import os
import unittest
import gitea_mcp_server as mcp_server
class TestPreflightReadSurvival(unittest.TestCase):
def setUp(self):
self.orig_whoami = mcp_server._preflight_whoami_called
self.orig_capability = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_resolved_task = mcp_server._preflight_resolved_task
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._preflight_resolved_task = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
def tearDown(self):
mcp_server._preflight_whoami_called = self.orig_whoami
mcp_server._preflight_capability_called = self.orig_capability
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._preflight_resolved_task = self.orig_resolved_task
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
def test_interleaved_whoami_preserves_capability(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_missing_capability_still_fails_closed(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("has not been resolved", str(ctx.exception))
def test_task_mismatch_fails_closed(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("task mismatch", str(ctx.exception))
def test_capability_consumed_after_mutation_gate(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.verify_preflight_purity(task="create_issue")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="create_issue")
self.assertIn("has not been resolved", str(ctx.exception))
def test_whoami_recovery_after_violation_clears_capability(self):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_whoami_violation)
del os.environ["GITEA_TEST_FORCE_DIRTY"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server.record_preflight_check("whoami")
self.assertFalse(mcp_server._preflight_whoami_violation)
self.assertFalse(mcp_server._preflight_capability_called)
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.verify_preflight_purity(task="review_pr")
if __name__ == "__main__":
unittest.main()
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""Regression tests for worktree cleanup audit integrity (#404)."""
from __future__ import annotations
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from worktree_cleanup_audit import ( # noqa: E402
assess_cleanup_audit_final_report,
assess_cleanup_audit_integrity,
assess_worktree_cleanup_integrity,
classify_branches_entry,
parse_worktree_list_porcelain,
reconcile_cleanup_audit,
)
def _entry(
path: str,
*,
classification: str,
registered: bool = True,
preserve: bool | None = None,
) -> dict:
preserve_flag = preserve if preserve is not None else classification in {
"active_open_pr",
"active_issue_work",
"dirty_local_worktree",
"unsafe_unknown",
}
return {
"path": path,
"classification": classification,
"preserve": preserve_flag,
"registered_worktree": registered,
"worktree_state": {"exists": True, "clean": classification == "clean_stale_removable"},
"worktree_record": {"branch": "feat/x"} if registered else None,
}
def _snapshot(entries: list[dict]) -> dict:
return {"entries": entries}
class TestParseWorktreePorcelain(unittest.TestCase):
def test_parses_multiple_worktrees(self):
text = "\n".join([
"worktree /proj/branches/foo",
"HEAD abcdef0123456789abcdef0123456789abcdef0",
"branch refs/heads/feat/foo",
"",
"worktree /proj",
"HEAD 1111111111111111111111111111111111111111",
"branch refs/heads/master",
])
parsed = parse_worktree_list_porcelain(text)
self.assertEqual(len(parsed), 2)
self.assertEqual(parsed[0]["branch"], "feat/foo")
class TestClassifyEntry(unittest.TestCase):
def test_active_pr_classification(self):
result = classify_branches_entry(
rel_path="branches/feat-issue-1-x",
worktree_record={"branch": "feat/issue-1-x"},
worktree_state={"exists": True, "clean": True, "dirty_files": []},
open_pr_branches={"feat/issue-1-x"},
)
self.assertEqual(result, "active_open_pr")
def test_dirty_classification(self):
result = classify_branches_entry(
rel_path="branches/dirty-one",
worktree_record={"branch": "feat/dirty-one"},
worktree_state={"exists": True, "dirty_files": ["a.py"]},
open_pr_branches=set(),
)
self.assertEqual(result, "dirty_local_worktree")
class TestCleanupIntegrity(unittest.TestCase):
def test_preserved_worktree_remains(self):
path = "branches/keep-me"
before = _snapshot([_entry(path, classification="clean_stale_removable", preserve=False)])
after = _snapshot([_entry(path, classification="clean_stale_removable", preserve=False)])
result = assess_worktree_cleanup_integrity(before=before, after=after)
self.assertTrue(result["integrity_passed"])
def test_intentional_removal_passes(self):
path = "branches/remove-me"
before = _snapshot([_entry(path, classification="clean_stale_removable", preserve=False)])
after = _snapshot([])
result = assess_worktree_cleanup_integrity(
before=before,
after=after,
removals=[{
"path": path,
"method": "git worktree remove",
"pre_removal_proof": "clean status",
}],
)
self.assertTrue(result["integrity_passed"])
def test_dirty_worktree_disappears_fails(self):
path = "branches/dirty-wt"
before = _snapshot([_entry(path, classification="dirty_local_worktree")])
after = _snapshot([])
recon = reconcile_cleanup_audit(before, after)
result = assess_cleanup_audit_integrity(recon)
self.assertFalse(result["proven"])
def test_active_pr_worktree_disappears_fails(self):
path = "branches/review-pr99"
before = _snapshot([_entry(path, classification="active_open_pr")])
after = _snapshot([])
result = assess_worktree_cleanup_integrity(before=before, after=after)
self.assertFalse(result["integrity_passed"])
def test_explained_missing_allowed(self):
path = "branches/review-pr382"
before = _snapshot([
_entry(path, classification="detached_review_leftover", preserve=False),
])
after = _snapshot([])
result = assess_worktree_cleanup_integrity(
before=before,
after=after,
explained_missing={path: "removed concurrently by sibling session"},
)
self.assertTrue(result["integrity_passed"])
def test_removal_log_omits_clean_stale_fails(self):
path = "branches/a"
before = _snapshot([_entry(path, classification="clean_stale_removable", preserve=False)])
after = _snapshot([])
recon = reconcile_cleanup_audit(before, after, removal_log=[])
self.assertFalse(recon["removal_log_complete"])
class TestCleanupReportProof(unittest.TestCase):
def test_complete_report_passes(self):
report = "\n".join([
"Cleanup audit reconciliation table:",
"Initial count: 10",
"Removed count: 3",
"Preserved count: 7",
"Missing-unexplained count: 0",
"Final count: 7",
"Final verification: git worktree list proof attached",
])
result = assess_cleanup_audit_final_report(report)
self.assertTrue(result["proven"])
def test_incomplete_report_fails(self):
result = assess_cleanup_audit_final_report("removed some worktrees")
self.assertFalse(result["proven"])
if __name__ == "__main__":
unittest.main()
-554
View File
@@ -1,554 +0,0 @@
"""Worktree cleanup audit integrity and reconciliation (#404).
Captures before/after snapshots of ``branches/`` directories and registered
worktrees, reconciles every initial path to exactly one disposition, and fails
closed when preserved worktrees disappear without an explicit removal record.
"""
from __future__ import annotations
import os
import re
from typing import Any
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
from reviewer_worktree import parse_dirty_tracked_files
CLASSIFICATIONS = frozenset({
"active_open_pr",
"active_issue_work",
"dirty_local_worktree",
"clean_stale_removable",
"detached_review_leftover",
"orphan_directory",
"unsafe_unknown",
})
PRESERVE_CLASSIFICATIONS = frozenset({
"active_open_pr",
"active_issue_work",
"dirty_local_worktree",
"unsafe_unknown",
})
DISPOSITIONS = frozenset({
"removed_intentionally",
"preserved_exists",
"preserved_missing_explained",
"not_registered_worktree",
"unsafe_unknown",
})
REVIEW_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
re.IGNORECASE,
)
def normalize_path(path: str) -> str:
return os.path.normpath((path or "").strip())
def relative_branches_path(project_root: str, path: str) -> str:
root = normalize_path(project_root)
normalized = normalize_path(path)
if normalized.startswith(root + os.sep):
return normalized[len(root) + 1 :]
return normalized.replace("\\", "/")
def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]:
"""Parse ``git worktree list --porcelain`` into worktree records."""
entries: list[dict[str, Any]] = []
current: dict[str, Any] = {}
for raw in (porcelain or "").splitlines():
line = raw.strip()
if not line:
if current:
entries.append(current)
current = {}
continue
if line.startswith("worktree "):
if current:
entries.append(current)
current = {"path": line.split(" ", 1)[1].strip()}
elif line.startswith("HEAD "):
current["head_sha"] = line.split(" ", 1)[1].strip()
elif line.startswith("branch "):
current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/")
elif line == "detached":
current["detached"] = True
elif line == "bare":
current["bare"] = True
if current:
entries.append(current)
return entries
def list_branches_directories(project_root: str, dir_names: list[str] | None = None) -> list[str]:
"""Return relative ``branches/<name>`` paths for first-level directories."""
branches_root = os.path.join(project_root, "branches")
if dir_names is not None:
return sorted(
f"branches/{name}"
for name in dir_names
if name and not name.startswith(".")
)
if not os.path.isdir(branches_root):
return []
names: list[str] = []
for entry in sorted(os.listdir(branches_root)):
full = os.path.join(branches_root, entry)
if entry.startswith(".") or not os.path.isdir(full):
continue
names.append(f"branches/{entry}")
return names
def _untracked_dirty(porcelain: str) -> bool:
return any(line.startswith("??") for line in (porcelain or "").splitlines())
def classify_branches_entry(
*,
rel_path: str,
worktree_record: dict[str, Any] | None,
worktree_state: dict[str, Any] | None,
open_pr_branches: set[str] | None = None,
active_lock_branches: set[str] | None = None,
active_issue_branches: set[str] | None = None,
) -> str:
"""Classify a ``branches/`` directory for cleanup policy."""
open_pr_branches = open_pr_branches or set()
active_lock_branches = active_lock_branches or set()
active_issue_branches = active_issue_branches or set()
state = worktree_state or {}
record = worktree_record or {}
branch_name = (record.get("branch") or "").strip()
folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path
inferred_branch = folder_name.replace("-", "/") if "/" not in folder_name else folder_name
candidate_branches = {b for b in (branch_name, inferred_branch) if b}
open_folder_names = {branch_worktree_folder(b) for b in open_pr_branches}
if folder_name in open_folder_names or any(
b in open_pr_branches for b in candidate_branches
):
return "active_open_pr"
if any(b in active_lock_branches or b in active_issue_branches for b in candidate_branches):
return "active_issue_work"
dirty_tracked = bool(state.get("dirty_files"))
dirty_untracked = bool(state.get("dirty_untracked"))
if dirty_tracked or dirty_untracked:
return "dirty_local_worktree"
if not record:
return "orphan_directory"
if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
return "detached_review_leftover"
if state.get("exists") and state.get("clean"):
return "clean_stale_removable"
return "unsafe_unknown"
def capture_cleanup_snapshot(
project_root: str,
*,
branch_dirs: list[str] | None = None,
worktree_porcelain: str | None = None,
open_pr_branches: set[str] | None = None,
active_lock_branches: set[str] | None = None,
active_issue_branches: set[str] | None = None,
issue_lock_path: str | None = None,
) -> dict[str, Any]:
"""Capture audit snapshot for ``branches/`` dirs and registered worktrees."""
root = normalize_path(project_root)
rel_dirs = list_branches_directories(root, branch_dirs)
worktrees = parse_worktree_list_porcelain(worktree_porcelain or "")
worktree_by_rel: dict[str, dict[str, Any]] = {}
for wt in worktrees:
rel = relative_branches_path(root, wt.get("path") or "")
if rel.startswith("branches/"):
worktree_by_rel[rel] = wt
lock_branches = set(active_lock_branches or [])
lock = None
if issue_lock_path:
from merged_cleanup_reconcile import read_issue_lock
lock = read_issue_lock(issue_lock_path)
if lock and lock.get("branch_name"):
lock_branches.add(str(lock["branch_name"]))
entries: list[dict[str, Any]] = []
for rel_path in rel_dirs:
abs_path = os.path.join(root, rel_path)
wt_record = worktree_by_rel.get(rel_path)
state = read_local_worktree_state(abs_path) if os.path.isdir(abs_path) else {
"exists": False,
"clean": None,
"dirty_files": [],
}
if state.get("exists"):
status_res = state.get("porcelain_status")
if status_res is None and os.path.isdir(abs_path):
import subprocess
proc = subprocess.run(
["git", "-C", abs_path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
status_res = proc.stdout or ""
state["dirty_untracked"] = _untracked_dirty(status_res or "")
state["dirty_files"] = state.get("dirty_files") or parse_dirty_tracked_files(
status_res or ""
)
state["clean"] = not state["dirty_files"] and not state["dirty_untracked"]
classification = classify_branches_entry(
rel_path=rel_path,
worktree_record=wt_record,
worktree_state=state,
open_pr_branches=open_pr_branches,
active_lock_branches=lock_branches,
active_issue_branches=active_issue_branches,
)
entries.append(
{
"path": rel_path,
"absolute_path": abs_path,
"classification": classification,
"registered_worktree": bool(wt_record),
"worktree_record": wt_record or None,
"worktree_state": state,
"preserve": classification in PRESERVE_CLASSIFICATIONS,
}
)
return {
"project_root": root,
"branch_directory_count": len(rel_dirs),
"registered_branches_worktree_count": len(worktree_by_rel),
"entries": entries,
"worktrees": worktrees,
}
def _index_snapshot_entries(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {entry["path"]: entry for entry in snapshot.get("entries") or []}
def _removal_paths(removal_log: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]]:
indexed: dict[str, dict[str, Any]] = {}
for item in removal_log or []:
rel = (item.get("path") or "").strip().replace("\\", "/")
if rel:
indexed[rel] = item
return indexed
def reconcile_cleanup_audit(
before: dict[str, Any],
after: dict[str, Any],
removal_log: list[dict[str, Any]] | None = None,
*,
explained_missing: dict[str, str] | None = None,
concurrent_mutations: list[str] | None = None,
) -> dict[str, Any]:
"""Reconcile before/after snapshots to exactly one disposition per path."""
before_index = _index_snapshot_entries(before)
after_index = _index_snapshot_entries(after)
removals = _removal_paths(removal_log)
explained = {
(k or "").strip().replace("\\", "/"): (v or "").strip()
for k, v in (explained_missing or {}).items()
}
concurrent = {
(p or "").strip().replace("\\", "/")
for p in (concurrent_mutations or [])
}
rows: list[dict[str, Any]] = []
for path, before_entry in sorted(before_index.items()):
after_entry = after_index.get(path)
after_exists = bool(after_entry and after_entry.get("worktree_state", {}).get("exists"))
classification = before_entry.get("classification") or "unsafe_unknown"
preserve = bool(before_entry.get("preserve")) or classification in PRESERVE_CLASSIFICATIONS
removal = removals.get(path)
explanation = explained.get(path, "")
if removal:
disposition = "removed_intentionally"
reasons = []
elif after_exists:
disposition = "preserved_exists"
reasons = []
elif explanation:
disposition = "preserved_missing_explained"
reasons = [explanation]
elif not before_entry.get("registered_worktree"):
disposition = "not_registered_worktree"
reasons = ["directory was not a registered git worktree at audit start"]
elif path in concurrent:
disposition = "preserved_missing_explained"
reasons = ["removed or mutated by another session during cleanup"]
elif preserve:
disposition = "unsafe_unknown"
reasons = [
f"preserved classification '{classification}' disappeared without "
"removal log or explanation"
]
else:
disposition = "unsafe_unknown"
reasons = [
"clean/removable path disappeared without removal log entry"
]
rows.append(
{
"path": path,
"classification": classification,
"preserve": preserve,
"disposition": disposition,
"removed_intentionally": disposition == "removed_intentionally",
"removal_record": removal,
"after_exists": after_exists,
"reasons": reasons,
}
)
for path, removal in removals.items():
if path not in before_index:
rows.append(
{
"path": path,
"classification": "unsafe_unknown",
"preserve": False,
"disposition": "unsafe_unknown",
"removed_intentionally": True,
"removal_record": removal,
"after_exists": path in after_index,
"reasons": ["removal log references path absent from before snapshot"],
}
)
counts = {
"initial_count": len(before_index),
"removed_count": sum(1 for r in rows if r["disposition"] == "removed_intentionally"),
"preserved_count": sum(1 for r in rows if r["disposition"] == "preserved_exists"),
"missing_unexplained_count": sum(
1
for r in rows
if r["disposition"] == "unsafe_unknown"
and r.get("preserve")
and not r.get("after_exists")
),
"missing_explained_count": sum(
1 for r in rows if r["disposition"] == "preserved_missing_explained"
),
"final_count": len(after_index),
"orphan_directory_count": sum(
1 for r in rows if r["disposition"] == "not_registered_worktree"
),
}
expected_final = (
counts["initial_count"]
- counts["removed_count"]
- counts["missing_explained_count"]
)
counts["count_reconciles"] = counts["final_count"] == expected_final
return {
"rows": rows,
"counts": counts,
"removal_log_complete": _removal_log_complete(before_index, after_index, removals),
}
def _removal_log_complete(
before_index: dict[str, dict[str, Any]],
after_index: dict[str, dict[str, Any]],
removals: dict[str, dict[str, Any]],
) -> bool:
for path, before_entry in before_index.items():
if path in after_index:
continue
classification = before_entry.get("classification") or ""
if classification == "clean_stale_removable" and path not in removals:
return False
return True
def assess_cleanup_audit_integrity(reconciliation: dict[str, Any]) -> dict[str, Any]:
"""Fail closed when preserved worktrees vanish or counts do not reconcile."""
reasons: list[str] = []
counts = reconciliation.get("counts") or {}
if counts.get("missing_unexplained_count"):
reasons.append(
f"{counts['missing_unexplained_count']} preserved worktree(s) missing "
"without explanation"
)
for row in reconciliation.get("rows") or []:
if not row.get("preserve") or row.get("after_exists"):
continue
if row.get("disposition") == "removed_intentionally":
continue
message = (
f"preserved worktree {row.get('path')} missing "
f"({row.get('disposition')})"
)
if message not in reasons:
reasons.append(message)
for item in row.get("reasons") or []:
if item not in reasons:
reasons.append(item)
if not counts.get("count_reconciles"):
reasons.append(
"final directory count does not reconcile with initial minus removed "
f"(initial={counts.get('initial_count')}, removed={counts.get('removed_count')}, "
f"final={counts.get('final_count')})"
)
if reconciliation.get("removal_log_complete") is False:
reasons.append("removal log omits one or more removed clean-stale worktrees")
for row in reconciliation.get("rows") or []:
removal = row.get("removal_record") or {}
if row.get("disposition") == "removed_intentionally":
if not removal.get("method"):
reasons.append(f"removal log for {row.get('path')} missing method")
if not removal.get("pre_removal_proof"):
reasons.append(f"removal log for {row.get('path')} missing pre-removal proof")
block = bool(reasons)
return {
"block": block,
"proven": not block,
"reasons": reasons,
"counts": counts,
"safe_next_action": (
"capture before/after snapshots, record every removal with proof, and "
"explain any preserved path that disappears"
if block
else "proceed"
),
}
def build_cleanup_reconciliation_table(reconciliation: dict[str, Any]) -> dict[str, Any]:
"""Return the operator-facing reconciliation summary table."""
counts = dict(reconciliation.get("counts") or {})
return {
"initial_count": counts.get("initial_count", 0),
"removed_count": counts.get("removed_count", 0),
"preserved_count": counts.get("preserved_count", 0),
"missing_unexplained_count": counts.get("missing_unexplained_count", 0),
"missing_explained_count": counts.get("missing_explained_count", 0),
"final_count": counts.get("final_count", 0),
"count_reconciles": counts.get("count_reconciles", False),
}
def _read_worktree_porcelain(project_root: str) -> str:
import subprocess
try:
res = subprocess.run(
["git", "-C", project_root, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
except OSError:
return ""
return res.stdout if res.returncode == 0 else ""
def capture_branches_worktree_snapshot(
project_root: str,
*,
open_pr_branches: list[str] | None = None,
active_lock_branch: str | None = None,
leased_paths: list[str] | None = None,
issue_lock_path: str | None = None,
) -> dict[str, Any]:
"""MCP-facing snapshot capture for live ``branches/`` cleanup audits."""
root = normalize_path(project_root)
lock_branches = set()
if active_lock_branch:
lock_branches.add(active_lock_branch)
issue_branches = set()
for path in leased_paths or []:
rel = relative_branches_path(root, path)
if rel.startswith("branches/"):
issue_branches.add(rel.split("/", 1)[-1].replace("-", "/"))
return capture_cleanup_snapshot(
root,
worktree_porcelain=_read_worktree_porcelain(root),
open_pr_branches=set(open_pr_branches or []),
active_lock_branches=lock_branches,
active_issue_branches=issue_branches,
issue_lock_path=issue_lock_path,
)
def assess_worktree_cleanup_integrity(
*,
before: dict[str, Any],
after: dict[str, Any],
removals: list[dict[str, Any]] | None = None,
explained_missing: dict[str, str] | None = None,
) -> dict[str, Any]:
"""MCP-facing integrity assessment over before/after cleanup snapshots."""
reconciliation = reconcile_cleanup_audit(
before,
after,
removals,
explained_missing=explained_missing,
)
integrity = assess_cleanup_audit_integrity(reconciliation)
return {
**integrity,
"integrity_passed": integrity.get("proven", False),
"reconciliation": reconciliation,
"reconciliation_table": build_cleanup_reconciliation_table(reconciliation),
"rows": reconciliation.get("rows") or [],
}
_RECON_INITIAL_RE = re.compile(r"initial count\s*:\s*(\d+)", re.I)
_RECON_REMOVED_RE = re.compile(r"removed count\s*:\s*(\d+)", re.I)
_RECON_PRESERVED_RE = re.compile(r"preserved count\s*:\s*(\d+)", re.I)
_RECON_MISSING_RE = re.compile(r"missing-unexplained count\s*:\s*(\d+)", re.I)
_RECON_FINAL_RE = re.compile(r"final count\s*:\s*(\d+)", re.I)
_WORKTREE_LIST_RE = re.compile(r"git worktree list|worktree list proof", re.I)
def assess_cleanup_audit_final_report(report_text: str) -> dict[str, Any]:
"""Validate cleanup final report includes reconciliation proof (#404)."""
text = report_text or ""
reasons: list[str] = []
for pattern in (
_RECON_INITIAL_RE,
_RECON_REMOVED_RE,
_RECON_PRESERVED_RE,
_RECON_MISSING_RE,
_RECON_FINAL_RE,
):
if not pattern.search(text):
reasons.append(
f"cleanup report missing field matching /{pattern.pattern}/"
)
if not _WORKTREE_LIST_RE.search(text):
reasons.append("final verification missing git worktree list proof")
proven = not reasons
return {"proven": proven, "block": not proven, "reasons": reasons}