Align assess_author_issue_bootstrap with bootstrap_permits_control_checkout so gitea_bootstrap_author_issue_worktree can create the first branches/ worktree without the lock↔worktree deadlock. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
216 lines
8.0 KiB
Python
216 lines
8.0 KiB
Python
"""Regression: author worktree bootstrap from clean control checkout (#892).
|
|
|
|
#892 is the four-door deadlock where every documented recovery path is closed:
|
|
bootstrap refuses control, lock demands an existing worktree, worktree-start
|
|
demands a lock, and shell worktree add is outside the sanctioned MCP path.
|
|
|
|
Root cause: assess_author_issue_bootstrap returned allowed/proven for a clean
|
|
control checkout, but bootstrap_permits_control_checkout only accepted
|
|
create_issue assessments (task_scope=create_issue_only + empty reasons + full
|
|
base-tip field set). Author assessments never satisfied the shared predicate,
|
|
so the #274/#604 guards kept the ordinary control-checkout block.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import author_issue_bootstrap as aib
|
|
import create_issue_bootstrap as cib
|
|
|
|
|
|
CONTROL = "/repo/Gitea-Tools"
|
|
MASTER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
OTHER = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
|
|
|
|
def _assess(
|
|
*,
|
|
workspace=CONTROL,
|
|
root=CONTROL,
|
|
branch="master",
|
|
head=MASTER,
|
|
porcelain="",
|
|
remote=MASTER,
|
|
remote_error=None,
|
|
task="bootstrap_author_issue_worktree",
|
|
):
|
|
return aib.assess_author_issue_bootstrap(
|
|
workspace_path=workspace,
|
|
canonical_repo_root=root,
|
|
current_branch=branch,
|
|
head_sha=head,
|
|
porcelain_status=porcelain,
|
|
remote_master_sha=remote,
|
|
remote_master_sha_error=remote_error,
|
|
task=task,
|
|
)
|
|
|
|
|
|
class TestAuthorBootstrapAssessmentShape(unittest.TestCase):
|
|
def test_clean_control_emits_predicate_compatible_fields(self):
|
|
assessment = _assess()
|
|
self.assertTrue(assessment["allowed"])
|
|
self.assertTrue(assessment["proven"])
|
|
self.assertFalse(assessment["block"])
|
|
self.assertFalse(assessment["not_applicable"])
|
|
self.assertEqual(assessment["reasons"], [])
|
|
self.assertEqual(assessment["task_scope"], "author_issue_bootstrap")
|
|
self.assertEqual(
|
|
assessment["bootstrap_path"], "clean_canonical_control_checkout"
|
|
)
|
|
self.assertEqual(assessment["dirty_files"], [])
|
|
self.assertIs(assessment["under_branches"], False)
|
|
self.assertTrue(assessment["base_tips_verified"])
|
|
self.assertEqual(assessment["local_head_sha"], MASTER)
|
|
self.assertEqual(assessment["remote_master_sha"], MASTER)
|
|
self.assertEqual(assessment["workspace_path"], os.path.realpath(CONTROL))
|
|
self.assertEqual(
|
|
assessment["canonical_repo_root"], os.path.realpath(CONTROL)
|
|
)
|
|
|
|
def test_wrong_task_not_applicable(self):
|
|
assessment = _assess(task="lock_issue")
|
|
self.assertTrue(assessment["not_applicable"])
|
|
self.assertFalse(assessment["allowed"])
|
|
|
|
def test_branches_worktree_not_applicable_for_control_waiver(self):
|
|
branches = os.path.join(CONTROL, "branches", "fix-issue-1")
|
|
assessment = _assess(workspace=branches)
|
|
self.assertTrue(assessment["not_applicable"])
|
|
self.assertFalse(assessment["allowed"])
|
|
self.assertEqual(assessment["bootstrap_path"], "existing_branches_worktree")
|
|
|
|
def test_dirty_control_blocks(self):
|
|
assessment = _assess(porcelain=" M gitea_mcp_server.py\n")
|
|
self.assertTrue(assessment["block"])
|
|
self.assertFalse(assessment["allowed"])
|
|
self.assertTrue(any("tracked local edits" in r for r in assessment["reasons"]))
|
|
|
|
def test_head_remote_mismatch_blocks(self):
|
|
assessment = _assess(head=MASTER, remote=OTHER)
|
|
self.assertTrue(assessment["block"])
|
|
self.assertFalse(assessment["allowed"])
|
|
|
|
def test_missing_remote_tip_blocks(self):
|
|
assessment = _assess(remote=None)
|
|
self.assertTrue(assessment["block"])
|
|
self.assertFalse(assessment["allowed"])
|
|
|
|
|
|
class TestAuthorBootstrapPredicate(unittest.TestCase):
|
|
def _permits(self, assessment, task="bootstrap_author_issue_worktree"):
|
|
return cib.bootstrap_permits_control_checkout(
|
|
assessment,
|
|
task=task,
|
|
workspace_path=os.path.realpath(CONTROL),
|
|
canonical_repo_root=os.path.realpath(CONTROL),
|
|
)
|
|
|
|
def test_clean_author_bootstrap_permits(self):
|
|
self.assertTrue(self._permits(_assess()))
|
|
|
|
def test_tool_alias_permits(self):
|
|
assessment = _assess(task="gitea_bootstrap_author_issue_worktree")
|
|
self.assertTrue(
|
|
self._permits(assessment, task="gitea_bootstrap_author_issue_worktree")
|
|
)
|
|
|
|
def test_create_issue_scope_cannot_license_author_bootstrap(self):
|
|
# Cross-scope smuggling: a create_issue-shaped assessment must not
|
|
# authorize the author bootstrap task.
|
|
create_shaped = dict(_assess())
|
|
create_shaped["task_scope"] = "create_issue_only"
|
|
self.assertFalse(self._permits(create_shaped))
|
|
|
|
def test_author_scope_cannot_license_create_issue(self):
|
|
assessment = _assess()
|
|
self.assertFalse(
|
|
cib.bootstrap_permits_control_checkout(
|
|
assessment,
|
|
task="create_issue",
|
|
workspace_path=os.path.realpath(CONTROL),
|
|
canonical_repo_root=os.path.realpath(CONTROL),
|
|
)
|
|
)
|
|
|
|
def test_nonempty_reasons_fail_closed(self):
|
|
bad = dict(_assess(), reasons=["informational text must not be here"])
|
|
self.assertFalse(self._permits(bad))
|
|
|
|
def test_dirty_fails_closed(self):
|
|
self.assertFalse(self._permits(_assess(porcelain=" M x.py\n")))
|
|
|
|
def test_mismatch_fails_closed(self):
|
|
self.assertFalse(self._permits(_assess(remote=OTHER)))
|
|
|
|
|
|
class TestAuthorBootstrapPreflightIntegration(unittest.TestCase):
|
|
"""Server preflight path: clean control + author bootstrap task must not raise."""
|
|
|
|
def test_enforce_branches_only_allows_clean_control_for_bootstrap(self):
|
|
# Exercise the real enforcer wiring with a temporary clean repo.
|
|
import gitea_mcp_server as srv
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
repo = os.path.join(tmp, "repo")
|
|
os.makedirs(os.path.join(repo, "branches"))
|
|
# Minimal git repo on master at a known tip.
|
|
import subprocess
|
|
|
|
subprocess.check_call(["git", "init", "-b", "master", repo])
|
|
subprocess.check_call(
|
|
["git", "-C", repo, "commit", "--allow-empty", "-m", "init"]
|
|
)
|
|
head = subprocess.check_output(
|
|
["git", "-C", repo, "rev-parse", "HEAD"], text=True
|
|
).strip()
|
|
|
|
assessment = aib.assess_author_issue_bootstrap(
|
|
workspace_path=repo,
|
|
canonical_repo_root=repo,
|
|
current_branch="master",
|
|
head_sha=head,
|
|
porcelain_status="",
|
|
remote_master_sha=head,
|
|
task="bootstrap_author_issue_worktree",
|
|
)
|
|
self.assertTrue(
|
|
cib.bootstrap_permits_control_checkout(
|
|
assessment,
|
|
task="bootstrap_author_issue_worktree",
|
|
workspace_path=repo,
|
|
canonical_repo_root=repo,
|
|
)
|
|
)
|
|
|
|
# Simulate what _enforce_branches_only_author_mutation does when
|
|
# durable resolution blocks control: the shared predicate must waive.
|
|
durable_block = {
|
|
"block": True,
|
|
"workspace_path": repo,
|
|
"workspace_binding_source": "process_project_root",
|
|
"reasons": [
|
|
"author mutation blocked: workspace is the stable control checkout"
|
|
],
|
|
}
|
|
if cib.bootstrap_permits_control_checkout(
|
|
assessment,
|
|
task="bootstrap_author_issue_worktree",
|
|
workspace_path=repo,
|
|
canonical_repo_root=repo,
|
|
):
|
|
waived = True
|
|
else:
|
|
waived = False
|
|
self.assertTrue(waived)
|
|
# Keep durable_block referenced so the scenario is explicit.
|
|
self.assertTrue(durable_block["block"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|