"""Tests for merged PR cleanup reconciliation (#269).""" import os import sys import tempfile import unittest from unittest.mock import MagicMock, Mock, patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import merged_cleanup_reconcile as mcr # noqa: E402 class TestMergedCleanupAssessment(unittest.TestCase): def test_extract_linked_issue_from_closes(self): issue = mcr.extract_linked_issue( "feat: cleanup (Closes #269)", "No other refs", ) self.assertEqual(issue, 269) def test_merged_remote_branch_safe_when_gates_pass(self): result = mcr.assess_remote_branch_cleanup( pr_number=42, head_branch="feat/issue-269-merged-pr-cleanup-reconcile", merged=True, remote_branch_exists=True, open_pr_heads=set(), head_on_master=True, delete_capability_allowed=True, active_lock=False, ) self.assertTrue(result["safe_to_delete_remote"]) self.assertEqual(result["block_reasons"], []) def test_open_pr_blocks_remote_delete(self): result = mcr.assess_remote_branch_cleanup( pr_number=42, head_branch="feat/issue-9-example", merged=True, remote_branch_exists=True, open_pr_heads={"feat/issue-9-example"}, head_on_master=True, delete_capability_allowed=True, active_lock=False, ) self.assertFalse(result["safe_to_delete_remote"]) self.assertIn("open PR", result["block_reasons"][0]) def test_protected_branch_blocks_remote_delete(self): result = mcr.assess_remote_branch_cleanup( pr_number=1, head_branch="master", merged=True, remote_branch_exists=True, open_pr_heads=set(), head_on_master=True, delete_capability_allowed=True, active_lock=False, ) self.assertFalse(result["safe_to_delete_remote"]) self.assertIn("protected", result["block_reasons"][0]) def test_active_lock_blocks_remote_and_worktree_cleanup(self): remote = mcr.assess_remote_branch_cleanup( pr_number=42, head_branch="feat/issue-9-example", merged=True, remote_branch_exists=True, open_pr_heads=set(), head_on_master=True, delete_capability_allowed=True, active_lock=True, ) local = mcr.assess_local_worktree_cleanup( pr_number=42, head_branch="feat/issue-9-example", merged=True, worktree_state={ "exists": True, "clean": True, "current_branch": "feat/issue-9-example", "worktree_path": "/repo/branches/feat-issue-9-example", }, active_lock=True, ) self.assertFalse(remote["safe_to_delete_remote"]) self.assertFalse(local["safe_to_remove_worktree"]) self.assertIn("active issue lock", remote["block_reasons"][0]) def test_dirty_worktree_blocks_local_cleanup(self): result = mcr.assess_local_worktree_cleanup( pr_number=42, head_branch="feat/issue-9-example", merged=True, worktree_state={ "exists": True, "clean": False, "dirty_files": ["gitea_mcp_server.py"], "current_branch": "feat/issue-9-example", "worktree_path": "/repo/branches/feat-issue-9-example", }, active_lock=False, ) self.assertFalse(result["safe_to_remove_worktree"]) self.assertIn("tracked edits", result["block_reasons"][0]) class TestMergedCleanupReport(unittest.TestCase): def test_build_report_skips_unmerged_closed_prs(self): with tempfile.TemporaryDirectory() as tmp: report = mcr.build_reconciliation_report( project_root=tmp, closed_prs=[ { "number": 10, "title": "closed not merged", "body": "Closes #10", "head": {"ref": "feat/issue-10-x"}, "merged_at": None, }, { "number": 11, "title": "merged", "body": "Closes #11", "head": {"ref": "feat/issue-11-x"}, "merged_at": "2026-07-06T12:00:00Z", "merge_commit_sha": "abc123", }, ], open_prs=[], remote_branch_exists={"feat/issue-11-x": True}, head_on_master={11: True}, delete_capability_allowed=False, ) self.assertEqual(report["merged_pr_count"], 1) entry = report["entries"][0] self.assertEqual(entry["pr_number"], 11) self.assertEqual(entry["issue_number"], 11) self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"]) self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0]) def test_has_active_issue_lock_reads_lock_file(self): with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: handle.write('{"branch_name": "feat/issue-9-example"}') lock_path = handle.name try: self.assertTrue( mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path) ) self.assertFalse( mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path) ) finally: os.remove(lock_path) @patch("subprocess.run") def test_discover_local_worktrees(self, mock_run): mock_output = ( "worktree /repo/Gitea-Tools\n" "bare\n\n" "worktree /repo/Gitea-Tools/branches/custom-name\n" "branch refs/heads/feat/issue-11-x\n" "HEAD cafebabe\n\n" ) mock_run.return_value = Mock(returncode=0, stdout=mock_output) res = mcr.discover_local_worktrees("/repo/Gitea-Tools") self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"}) @patch("subprocess.run") @patch("merged_cleanup_reconcile.read_local_worktree_state") def test_build_report_discovers_non_canonical_worktree_by_branch(self, mock_state, mock_run): mock_output = ( "worktree /repo/Gitea-Tools\n" "bare\n\n" "worktree /repo/Gitea-Tools/branches/custom-name\n" "branch refs/heads/feat/issue-11-x\n" "HEAD cafebabe\n\n" ) mock_run.return_value = Mock(returncode=0, stdout=mock_output) mock_state.side_effect = [ { "exists": False, "clean": None, "current_branch": None, "head_sha": None, "dirty_files": [], }, { "exists": True, "clean": True, "current_branch": "feat/issue-11-x", "head_sha": "cafebabe", "dirty_files": [], }, ] report = mcr.build_reconciliation_report( project_root="/repo/Gitea-Tools", closed_prs=[ { "number": 11, "title": "merged", "body": "Closes #11", "head": {"ref": "feat/issue-11-x"}, "merged_at": "2026-07-06T12:00:00Z", "merge_commit_sha": "abc123", } ], open_prs=[], remote_branch_exists={"feat/issue-11-x": True}, head_on_master={11: True}, delete_capability_allowed=True, ) self.assertEqual(report["merged_pr_count"], 1) entry = report["entries"][0] local = entry["local_worktree"] self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name") self.assertEqual(local["match_type"], "branch") @patch("merged_cleanup_reconcile.read_local_worktree_state") @patch("subprocess.run") def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state): mock_run.return_value = Mock( returncode=0, stdout=( "worktree /repo/Gitea-Tools\n" "HEAD aaaa\n" "branch refs/heads/master\n\n" "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n" "HEAD cafebabe\n" "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" ), ) mock_state.side_effect = [ { "exists": False, "clean": None, "current_branch": None, "head_sha": None, "dirty_files": [], }, { "exists": True, "clean": True, "current_branch": "feat/issue-510-workspace-binding-isolation", "head_sha": "cafebabe", "dirty_files": [], }, ] report = mcr.build_reconciliation_report( project_root="/repo/Gitea-Tools", closed_prs=[ { "number": 512, "title": "merged cleanup", "body": "Closes #510", "head": { "ref": "feat/issue-510-workspace-binding-isolation", "sha": "cafebabe", }, "merged_at": "2026-07-06T12:00:00Z", "merge_commit_sha": "abc123", } ], open_prs=[], remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True}, head_on_master={512: True}, delete_capability_allowed=True, target_ref="prgs/master", ) local = report["entries"][0]["local_worktree"] self.assertTrue(local["safe_to_remove_worktree"]) self.assertEqual(local["match_type"], "branch") self.assertEqual( local["expected_worktree_path"], "/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation", ) self.assertEqual( local["discovered_worktree_path"], "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", ) @patch("merged_cleanup_reconcile.read_local_worktree_state") @patch("subprocess.run") def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state): mock_run.return_value = Mock( returncode=0, stdout=( "worktree /repo/Gitea-Tools/branches/issue-510-one\n" "HEAD cafebabe\n" "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" "worktree /repo/Gitea-Tools/branches/issue-510-two\n" "HEAD cafebabe\n" "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" ), ) mock_state.return_value = { "exists": False, "clean": None, "current_branch": None, "head_sha": None, "dirty_files": [], } report = mcr.build_reconciliation_report( project_root="/repo/Gitea-Tools", closed_prs=[ { "number": 512, "title": "merged cleanup", "body": "Closes #510", "head": { "ref": "feat/issue-510-workspace-binding-isolation", "sha": "cafebabe", }, "merged_at": "2026-07-06T12:00:00Z", "merge_commit_sha": "abc123", } ], open_prs=[], remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True}, head_on_master={512: True}, delete_capability_allowed=True, target_ref="prgs/master", ) local = report["entries"][0]["local_worktree"] self.assertFalse(local["safe_to_remove_worktree"]) self.assertEqual(local["match_type"], "ambiguous_alias") self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0]) @patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True) @patch("merged_cleanup_reconcile.read_local_worktree_state") @patch("subprocess.run") def test_alias_head_on_target_branch_is_safe_cleanup_commit( self, mock_run, mock_state, _ancestor ): mock_run.return_value = Mock( returncode=0, stdout=( "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n" "HEAD deadbeef\n" "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" ), ) mock_state.side_effect = [ { "exists": False, "clean": None, "current_branch": None, "head_sha": None, "dirty_files": [], }, { "exists": True, "clean": True, "current_branch": "feat/issue-510-workspace-binding-isolation", "head_sha": "deadbeef", "dirty_files": [], }, ] state = mcr.resolve_cleanup_worktree_state( project_root="/repo/Gitea-Tools", head_branch="feat/issue-510-workspace-binding-isolation", issue_number=510, pr_head_sha="cafebabe", target_ref="prgs/master", ) self.assertTrue(state["exists"]) self.assertEqual(state["match_type"], "branch") self.assertEqual( state["discovered_worktree_path"], "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", ) @patch("os.path.isdir", return_value=True) @patch("subprocess.run") def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir): mock_run.return_value = Mock(returncode=0, stdout="", stderr="") result = mcr.remove_local_worktree( "/repo/Gitea-Tools", "feat/issue-510-workspace-binding-isolation", worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", ) self.assertTrue(result["success"]) self.assertEqual( result["worktree_path"], "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", ) mock_run.assert_called_once_with( [ "git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", ], capture_output=True, text=True, check=False, ) class TestReviewerScratchCleanup(unittest.TestCase): """#534: authorized cleanup path for reviewer scratch worktrees.""" def test_parse_reviewer_scratch_folder(self): self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr512-submit"), 512) self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr99"), 99) self.assertIsNone(mcr.parse_reviewer_scratch_folder("feat-issue-11-x")) self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-feat-issue-11")) self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-pr512-extra-tail")) def test_assess_safe_clean_detached_after_merge(self): assessment = mcr.assess_reviewer_scratch_cleanup( pr_number=512, worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", folder_name="review-pr512-submit", pr_merged=True, pr_closed=True, worktree_state={ "exists": True, "clean": True, "current_branch": None, "head_sha": "abc", "dirty_files": [], }, active_reviewer_lease=False, ) self.assertTrue(assessment["safe_to_remove_worktree"]) self.assertFalse(assessment["author_worktree_cleanup"]) self.assertEqual( assessment["recommended_action"], "remove_reviewer_scratch_worktree" ) self.assertEqual(assessment["worktree_kind"], "reviewer_scratch") def test_assess_blocks_dirty_scratch(self): assessment = mcr.assess_reviewer_scratch_cleanup( pr_number=512, worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", folder_name="review-pr512-submit", pr_merged=True, pr_closed=True, worktree_state={ "exists": True, "clean": False, "current_branch": None, "head_sha": "abc", "dirty_files": ["foo.py"], }, active_reviewer_lease=False, ) self.assertFalse(assessment["safe_to_remove_worktree"]) self.assertTrue( any("tracked edits" in r for r in assessment["block_reasons"]) ) def test_assess_blocks_active_lease(self): assessment = mcr.assess_reviewer_scratch_cleanup( pr_number=512, worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", folder_name="review-pr512-submit", pr_merged=True, pr_closed=True, worktree_state={ "exists": True, "clean": True, "current_branch": None, "head_sha": "abc", "dirty_files": [], }, active_reviewer_lease=True, ) self.assertFalse(assessment["safe_to_remove_worktree"]) self.assertTrue( any("active reviewer lease" in r for r in assessment["block_reasons"]) ) def test_assess_blocks_open_pr(self): assessment = mcr.assess_reviewer_scratch_cleanup( pr_number=512, worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", folder_name="review-pr512-submit", pr_merged=False, pr_closed=False, worktree_state={ "exists": True, "clean": True, "current_branch": None, "head_sha": "abc", "dirty_files": [], }, active_reviewer_lease=False, ) self.assertFalse(assessment["safe_to_remove_worktree"]) self.assertTrue(any("still open" in r for r in assessment["block_reasons"])) @patch("subprocess.run") @patch("merged_cleanup_reconcile.read_local_worktree_state") def test_report_lists_scratch_separately_from_author(self, mock_state, mock_run): mock_output = ( "worktree /repo/Gitea-Tools\n" "bare\n\n" "worktree /repo/Gitea-Tools/branches/feat-issue-11-x\n" "branch refs/heads/feat/issue-11-x\n" "HEAD deadbeef\n\n" "worktree /repo/Gitea-Tools/branches/review-pr512-submit\n" "HEAD abcdef0\n" "detached\n\n" ) mock_run.return_value = MagicMock(returncode=0, stdout=mock_output) def _state(path): if "review-pr512" in path: return { "exists": True, "clean": True, "current_branch": None, "head_sha": "abcdef0", "dirty_files": [], } return { "exists": True, "clean": True, "current_branch": "feat/issue-11-x", "head_sha": "deadbeef", "dirty_files": [], } mock_state.side_effect = _state report = mcr.build_reconciliation_report( project_root="/repo/Gitea-Tools", closed_prs=[ { "number": 11, "title": "merged author", "body": "Closes #11", "head": {"ref": "feat/issue-11-x"}, "merged_at": "2026-07-06T12:00:00Z", }, { "number": 512, "title": "merged reviewed", "body": "Closes #512", "head": {"ref": "feat/issue-512-x"}, "merged_at": "2026-07-06T12:00:00Z", }, ], open_prs=[], remote_branch_exists={ "feat/issue-11-x": False, "feat/issue-512-x": False, }, head_on_master={11: True, 512: True}, delete_capability_allowed=True, active_reviewer_leases={512: False}, ) self.assertEqual(report["merged_pr_count"], 2) self.assertEqual(report["reviewer_scratch_count"], 1) scratch = report["reviewer_scratch_entries"][0] self.assertEqual(scratch["pr_number"], 512) self.assertEqual(scratch["worktree_kind"], "reviewer_scratch") self.assertFalse(scratch["author_worktree_cleanup"]) self.assertTrue(scratch["safe_to_remove_worktree"]) # Author entries must not swallow the scratch path as local_worktree. author_paths = { (e.get("local_worktree") or {}).get("worktree_path") for e in report["entries"] } self.assertNotIn( "/repo/Gitea-Tools/branches/review-pr512-submit", author_paths ) @patch("subprocess.run") def test_remove_reviewer_scratch_worktree_path_guard(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") with patch("os.path.isdir", return_value=True): bad = mcr.remove_reviewer_scratch_worktree( "/repo/Gitea-Tools", "/repo/Gitea-Tools/branches/feat-issue-11-x", ) self.assertFalse(bad["performed"]) good = mcr.remove_reviewer_scratch_worktree( "/repo/Gitea-Tools", "/repo/Gitea-Tools/branches/review-pr512-submit", ) self.assertTrue(good["success"]) self.assertTrue(good["performed"]) self.assertFalse(good["author_worktree_cleanup"]) mock_run.assert_any_call( [ "git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/review-pr512-submit", ], capture_output=True, text=True, check=False, ) if __name__ == "__main__": unittest.main()