Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc3460580 | ||
|
|
c54ac0d4de | ||
|
|
4d24c34548 | ||
|
|
3f3d6cb35d | ||
|
|
3d11e1f12b | ||
|
|
ce61e424f6 | ||
|
|
dad1dc8d51 | ||
|
|
3bce0a55fb | ||
|
|
afb4ba563c | ||
|
|
d6b1e4be3c | ||
|
|
44a7c62dc3 | ||
|
|
d5dc9f2708 |
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
|||||||
# profile's values. Leave unset for pure env-based configuration.
|
# profile's values. Leave unset for pure env-based configuration.
|
||||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||||
GITEA_MCP_PROFILE=prgs
|
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
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import subprocess
|
|||||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_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:
|
def _normalize_path(path: str) -> str:
|
||||||
|
|||||||
@@ -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
|
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
|
## Safety notes
|
||||||
|
|
||||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||||
|
|||||||
+16
-22
@@ -12,6 +12,7 @@ import re
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
|
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||||
from review_proofs import (
|
from review_proofs import (
|
||||||
HANDOFF_HEADING,
|
HANDOFF_HEADING,
|
||||||
assess_controller_handoff,
|
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(
|
def _rule_reviewer_validation_cwd_proof(
|
||||||
report_text: str,
|
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 = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_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_target_branch_freshness,
|
||||||
_rule_reviewer_mutation_ledger,
|
_rule_reviewer_mutation_ledger,
|
||||||
_rule_reviewer_review_mutation,
|
_rule_reviewer_review_mutation,
|
||||||
|
_rule_reviewer_post_merge_cleanup_proof,
|
||||||
_rule_reviewer_stale_head_proof,
|
_rule_reviewer_stale_head_proof,
|
||||||
],
|
],
|
||||||
"reconcile_already_landed": [
|
"reconcile_already_landed": [
|
||||||
@@ -1117,7 +1112,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
_rule_worktree_cleanup_audit_proof,
|
|
||||||
_rule_conflict_fix_push_proof,
|
_rule_conflict_fix_push_proof,
|
||||||
],
|
],
|
||||||
"issue_filing": [
|
"issue_filing": [
|
||||||
|
|||||||
+263
-124
@@ -165,6 +165,7 @@ _preflight_capability_called = False
|
|||||||
_preflight_whoami_violation = False
|
_preflight_whoami_violation = False
|
||||||
_preflight_capability_violation = False
|
_preflight_capability_violation = False
|
||||||
_preflight_resolved_role = None
|
_preflight_resolved_role = None
|
||||||
|
_preflight_resolved_task: str | None = None
|
||||||
_process_start_porcelain: str | None = None
|
_process_start_porcelain: str | None = None
|
||||||
_preflight_whoami_baseline_porcelain: str | None = None
|
_preflight_whoami_baseline_porcelain: str | None = None
|
||||||
_preflight_capability_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"
|
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_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:
|
def _preflight_in_test_mode() -> bool:
|
||||||
return "pytest" in sys.modules or "unittest" in sys.modules
|
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:
|
def _ensure_process_start_porcelain() -> str:
|
||||||
"""Capture the shared-worktree baseline once per MCP process (#252)."""
|
"""Capture the shared-worktree baseline once per MCP process (#252)."""
|
||||||
global _process_start_porcelain
|
global _process_start_porcelain
|
||||||
@@ -188,26 +253,6 @@ def _ensure_process_start_porcelain() -> str:
|
|||||||
return _process_start_porcelain
|
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:
|
def _get_git_root(path: str) -> str | None:
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
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:
|
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"]
|
workspace = ctx["workspace_path"]
|
||||||
inspected_root = _get_git_root(workspace)
|
inspected_root = _get_git_root(workspace)
|
||||||
process_root = ctx["process_project_root"]
|
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_files": list(dirty_files),
|
||||||
"dirty_scope": dirty_scope,
|
"dirty_scope": dirty_scope,
|
||||||
"workspace_roots_aligned": ctx["roots_aligned"],
|
"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"]:
|
if not ctx["roots_aligned"]:
|
||||||
details["workspace_root_mismatch"] = (
|
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:
|
def _format_preflight_workspace_details(details: dict) -> str:
|
||||||
return (
|
parts = [
|
||||||
f"MCP server process root: {details.get('mcp_server_process_root')}; "
|
f"MCP server process root: {details.get('mcp_server_process_root')}",
|
||||||
f"active task workspace root: {details.get('active_task_workspace_root')}; "
|
f"active task workspace root: {details.get('active_task_workspace_root')}",
|
||||||
f"inspected git root: {details.get('inspected_git_root')}; "
|
f"workspace role: {details.get('workspace_role_kind')}",
|
||||||
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}; "
|
f"binding source: {details.get('workspace_binding_source')}",
|
||||||
f"dirty scope: {details.get('dirty_scope')}"
|
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:
|
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 "
|
"Active task workspace has tracked file edits before mutation "
|
||||||
f"({_format_preflight_workspace_details(workspace_details)})"
|
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 {
|
return {
|
||||||
"preflight_ready": not reasons,
|
"preflight_ready": not reasons,
|
||||||
"preflight_block_reasons": 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."""
|
"""Record a pre-flight check (whoami or capability) with session-scoped deltas."""
|
||||||
global _preflight_whoami_called, _preflight_capability_called
|
global _preflight_whoami_called, _preflight_capability_called
|
||||||
global _preflight_whoami_violation, _preflight_capability_violation
|
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_baseline_porcelain, _preflight_capability_baseline_porcelain
|
||||||
global _preflight_whoami_violation_files, _preflight_capability_violation_files
|
global _preflight_whoami_violation_files, _preflight_capability_violation_files
|
||||||
global _preflight_reviewer_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()
|
current = _get_workspace_porcelain()
|
||||||
|
|
||||||
if type_name == "whoami":
|
if type_name == "whoami":
|
||||||
# Fresh whoami restarts the capability step and re-evaluates violations
|
# Re-evaluate whoami violations instead of replaying sticky state (#252).
|
||||||
# instead of replaying a sticky record (#252).
|
# Interleaved read-only whoami must not clear a valid capability proof (#469).
|
||||||
_preflight_capability_called = False
|
saved_capability = None
|
||||||
_preflight_capability_violation = False
|
preserve_capability = (
|
||||||
_preflight_capability_violation_files = []
|
_preflight_capability_called
|
||||||
_preflight_capability_baseline_porcelain = None
|
and _preflight_whoami_called
|
||||||
_preflight_reviewer_violation_files = []
|
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()
|
process_start = _ensure_process_start_porcelain()
|
||||||
whoami_delta = _new_tracked_changes_since(process_start, current)
|
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_violation_files = whoami_delta
|
||||||
_preflight_whoami_baseline_porcelain = current
|
_preflight_whoami_baseline_porcelain = current
|
||||||
_preflight_whoami_called = True
|
_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":
|
elif type_name == "capability":
|
||||||
baseline = _preflight_whoami_baseline_porcelain or ""
|
baseline = _preflight_whoami_baseline_porcelain or ""
|
||||||
capability_delta = _new_tracked_changes_since(baseline, current)
|
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
|
_preflight_capability_called = True
|
||||||
if resolved_role:
|
if resolved_role:
|
||||||
_preflight_resolved_role = 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:
|
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
|
||||||
"""#274: author file/branch mutations must run from a branches/ worktree.
|
"""#274: author file/branch mutations must run from a branches/ worktree.
|
||||||
|
|
||||||
Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a
|
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
||||||
Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
||||||
(#468).
|
(#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
|
return
|
||||||
ctx = _resolve_author_mutation_context(worktree_path)
|
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||||
workspace = ctx["workspace_path"]
|
workspace = ctx["workspace_path"]
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||||
assessment = author_mutation_worktree.assess_author_mutation_worktree(
|
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."""
|
"""Verify that identity and capability were verified prior to session edits."""
|
||||||
global _preflight_reviewer_violation_files
|
global _preflight_reviewer_violation_files
|
||||||
|
|
||||||
@@ -458,12 +582,23 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
|
"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"]
|
workspace = ctx["workspace_path"]
|
||||||
canonical_root = ctx["canonical_repo_root"]
|
canonical_root = ctx["canonical_repo_root"]
|
||||||
process_root = ctx["process_project_root"]
|
process_root = ctx["process_project_root"]
|
||||||
real_workspace = os.path.realpath(workspace)
|
real_workspace = os.path.realpath(workspace)
|
||||||
|
role = ctx.get("workspace_role_kind") or _effective_workspace_role()
|
||||||
|
|
||||||
if real_workspace != process_root:
|
if real_workspace != process_root:
|
||||||
if not _preflight_in_test_mode():
|
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)))
|
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
|
||||||
if dirty_files:
|
if dirty_files:
|
||||||
details = _preflight_workspace_details(workspace, dirty_files)
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Pre-flight order violation: Active task workspace has tracked "
|
nwb.format_namespace_workspace_binding_error(
|
||||||
"file edits before mutation (fail closed). "
|
role_kind=role,
|
||||||
f"{_format_preflight_workspace_details(details)}"
|
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:
|
else:
|
||||||
if _preflight_whoami_violation:
|
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)}"
|
f"{_format_preflight_files(_preflight_capability_violation_files)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if _preflight_resolved_role == "reviewer":
|
if role in {"reviewer", "merger"}:
|
||||||
current = _get_workspace_porcelain()
|
current = _get_workspace_porcelain()
|
||||||
baseline = _preflight_capability_baseline_porcelain or ""
|
baseline = _preflight_capability_baseline_porcelain or ""
|
||||||
reviewer_delta = _new_tracked_changes_since(baseline, current)
|
reviewer_delta = _new_tracked_changes_since(baseline, current)
|
||||||
_preflight_reviewer_violation_files = reviewer_delta
|
_preflight_reviewer_violation_files = reviewer_delta
|
||||||
if reviewer_delta:
|
if reviewer_delta:
|
||||||
raise RuntimeError(
|
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: "
|
"tracked workspace files (fail closed). Offending files: "
|
||||||
f"{_format_preflight_files(reviewer_delta)}"
|
f"{_format_preflight_files(reviewer_delta)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
_enforce_branches_only_author_mutation(worktree_path)
|
_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
|
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 issue_work_duplicate_gate # noqa: E402
|
||||||
import reviewer_pr_lease # noqa: E402
|
import reviewer_pr_lease # noqa: E402
|
||||||
import merged_cleanup_reconcile # noqa: E402
|
import merged_cleanup_reconcile # noqa: E402
|
||||||
import worktree_cleanup_audit # noqa: E402
|
|
||||||
import reconciler_profile # noqa: E402
|
import reconciler_profile # noqa: E402
|
||||||
import reconciliation_workflow # noqa: E402
|
import reconciliation_workflow # noqa: E402
|
||||||
import review_merge_state_machine # noqa: E402
|
import review_merge_state_machine # noqa: E402
|
||||||
@@ -1229,7 +1407,7 @@ def gitea_create_issue(
|
|||||||
)
|
)
|
||||||
if blocked:
|
if blocked:
|
||||||
return 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)
|
base = repo_api_url(h, o, r)
|
||||||
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||||
closed_issues = api_get_all(
|
closed_issues = api_get_all(
|
||||||
@@ -1362,7 +1540,7 @@ def gitea_lock_issue(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
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(
|
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
worktree_path=resolved_worktree,
|
worktree_path=resolved_worktree,
|
||||||
current_branch=git_state.get("current_branch"),
|
current_branch=git_state.get("current_branch"),
|
||||||
@@ -1586,7 +1764,7 @@ def gitea_create_pr(
|
|||||||
)
|
)
|
||||||
if blocked:
|
if blocked:
|
||||||
return 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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||||
@@ -2650,9 +2828,12 @@ def _evaluate_pr_review_submission(
|
|||||||
*,
|
*,
|
||||||
live: bool,
|
live: bool,
|
||||||
final_review_decision_ready: bool = False,
|
final_review_decision_ready: bool = False,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared gate chain for live submit and dry-run review tools."""
|
"""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()
|
action = (action or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
"requested_action": action,
|
"requested_action": action,
|
||||||
@@ -3052,6 +3233,7 @@ def gitea_dry_run_pr_review(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate review submission mechanics without a live PR mutation."""
|
"""Validate review submission mechanics without a live PR mutation."""
|
||||||
return _evaluate_pr_review_submission(
|
return _evaluate_pr_review_submission(
|
||||||
@@ -3064,6 +3246,7 @@ def gitea_dry_run_pr_review(
|
|||||||
org=org,
|
org=org,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
live=False,
|
live=False,
|
||||||
|
worktree_path=worktree_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3079,6 +3262,7 @@ def gitea_submit_pr_review(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
final_review_decision_ready: bool = False,
|
final_review_decision_ready: bool = False,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Gated PR review mutation: comment findings, request changes, or approve.
|
"""Gated PR review mutation: comment findings, request changes, or approve.
|
||||||
|
|
||||||
@@ -3097,6 +3281,7 @@ def gitea_submit_pr_review(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
live=True,
|
live=True,
|
||||||
final_review_decision_ready=final_review_decision_ready,
|
final_review_decision_ready=final_review_decision_ready,
|
||||||
|
worktree_path=worktree_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3159,12 +3344,12 @@ def gitea_edit_pr(
|
|||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
|
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
|
# PR closure is a first-class capability, distinct from retitling or
|
||||||
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
|
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
|
||||||
# never touches the network.
|
# never touches the network.
|
||||||
closing = payload.get("state") == "closed"
|
|
||||||
if closing:
|
if closing:
|
||||||
gate_reasons = _profile_operation_gate("gitea.pr.close")
|
gate_reasons = _profile_operation_gate("gitea.pr.close")
|
||||||
if gate_reasons:
|
if gate_reasons:
|
||||||
@@ -3409,7 +3594,7 @@ def gitea_commit_files(
|
|||||||
branch="",
|
branch="",
|
||||||
)
|
)
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="commit_files")
|
||||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
@@ -3457,6 +3642,7 @@ def gitea_merge_pr(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Gated merge of a Gitea pull request (#16).
|
"""Gated merge of a Gitea pull request (#16).
|
||||||
|
|
||||||
@@ -3503,6 +3689,10 @@ def gitea_merge_pr(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
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:
|
Returns:
|
||||||
dict describing the attempt: performed, authenticated user, profile
|
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
|
reasons/gates passed or blocked, and merge result / merge commit if
|
||||||
available. Never secrets.
|
available. Never secrets.
|
||||||
"""
|
"""
|
||||||
verify_preflight_purity(remote)
|
_verify_role_mutation_workspace(
|
||||||
|
remote, worktree_path=worktree_path, task="merge_pr"
|
||||||
|
)
|
||||||
do = (do or "").strip().lower()
|
do = (do or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
"performed": False,
|
"performed": False,
|
||||||
@@ -4039,7 +4231,7 @@ def gitea_delete_branch(
|
|||||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
"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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -4070,59 +4262,6 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo
|
|||||||
raise
|
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()
|
@mcp.tool()
|
||||||
def gitea_reconcile_merged_cleanups(
|
def gitea_reconcile_merged_cleanups(
|
||||||
dry_run: bool = True,
|
dry_run: bool = True,
|
||||||
@@ -4201,7 +4340,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
report["executed"] = False
|
report["executed"] = False
|
||||||
return {"success": True, "performed": False, **report}
|
return {"success": True, "performed": False, **report}
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="reconcile_merged_cleanups")
|
||||||
actions: list[dict] = []
|
actions: list[dict] = []
|
||||||
for entry in report.get("entries") or []:
|
for entry in report.get("entries") or []:
|
||||||
head_branch = entry.get("head_branch") or ""
|
head_branch = entry.get("head_branch") or ""
|
||||||
@@ -4515,7 +4654,7 @@ def gitea_reconcile_already_landed_pr(
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="reconcile_already_landed_pr")
|
||||||
|
|
||||||
if post_comment and comment_body.strip():
|
if post_comment and comment_body.strip():
|
||||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||||
@@ -4618,7 +4757,7 @@ def gitea_close_issue(
|
|||||||
task_capability_map.required_permission("close_issue"))
|
task_capability_map.required_permission("close_issue"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="close_issue")
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
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"),
|
"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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
profile = get_profile()
|
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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
body = reviewer_pr_lease.format_lease_body(
|
body = reviewer_pr_lease.format_lease_body(
|
||||||
@@ -5323,7 +5462,7 @@ def gitea_create_issue_comment(
|
|||||||
(permission blocks also carry a structured 'permission_report',
|
(permission blocks also carry a structured 'permission_report',
|
||||||
#142).
|
#142).
|
||||||
"""
|
"""
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="comment_issue")
|
||||||
gate_reasons = _profile_operation_gate("gitea.issue.comment")
|
gate_reasons = _profile_operation_gate("gitea.issue.comment")
|
||||||
reasons = list(gate_reasons)
|
reasons = list(gate_reasons)
|
||||||
if not (body or "").strip():
|
if not (body or "").strip():
|
||||||
@@ -6857,7 +6996,7 @@ def gitea_mark_issue(
|
|||||||
task_capability_map.required_permission("mark_issue"))
|
task_capability_map.required_permission("mark_issue"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return 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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
@@ -6935,7 +7074,7 @@ def gitea_post_heartbeat(
|
|||||||
task_capability_map.required_permission("post_heartbeat"))
|
task_capability_map.required_permission("post_heartbeat"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="post_heartbeat")
|
||||||
active_profile = profile or get_profile().get("profile_name")
|
active_profile = profile or get_profile().get("profile_name")
|
||||||
body = issue_claim_heartbeat.format_heartbeat_body(
|
body = issue_claim_heartbeat.format_heartbeat_body(
|
||||||
kind="progress",
|
kind="progress",
|
||||||
@@ -7178,7 +7317,7 @@ def gitea_cleanup_stale_claims(
|
|||||||
task_capability_map.required_permission("cleanup_stale_claims"))
|
task_capability_map.required_permission("cleanup_stale_claims"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="cleanup_stale_claims")
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
@@ -7332,7 +7471,7 @@ def gitea_set_issue_labels(
|
|||||||
task_capability_map.required_permission("set_issue_labels"))
|
task_capability_map.required_permission("set_issue_labels"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, task="set_issue_labels")
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
@@ -7569,7 +7708,7 @@ def gitea_resolve_task_capability(
|
|||||||
"exact_safe_next_action": next_safe_action,
|
"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
|
# Try automatic dispatch switching
|
||||||
_ensure_matching_profile(required_permission, required_role, remote, host)
|
_ensure_matching_profile(required_permission, required_role, remote, host)
|
||||||
|
|||||||
@@ -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 [],
|
||||||
|
}
|
||||||
@@ -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.
|
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.
|
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
|
## 29. Recovery handoff rules
|
||||||
|
|
||||||
If blocked, produce a recovery handoff with:
|
If blocked, produce a recovery handoff with:
|
||||||
|
|||||||
@@ -3879,7 +3879,7 @@ class TestPreflightVerification(unittest.TestCase):
|
|||||||
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
|
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
mcp_server.verify_preflight_purity()
|
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))
|
self.assertIn("reviewer_edit.py", str(ctx.exception))
|
||||||
|
|
||||||
# Foreign pre-existing dirty state does not block when unchanged.
|
# Foreign pre-existing dirty state does not block when unchanged.
|
||||||
@@ -3945,7 +3945,8 @@ class TestPreflightVerification(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("active task workspace root", msg)
|
self.assertIn("resolved workspace", msg)
|
||||||
self.assertIn("inspected git root", msg)
|
self.assertIn(worktree, msg)
|
||||||
self.assertIn("dirty files: task_file.py", msg)
|
self.assertIn("worktree_path argument", msg)
|
||||||
self.assertIn("dirty scope:", msg)
|
self.assertIn("task_file.py", msg)
|
||||||
|
self.assertIn("author namespace", msg)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
@@ -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}
|
|
||||||
Reference in New Issue
Block a user