Merge remote-tracking branch 'prgs/master' into feat/issue-532-merged-cleanup-worktree-aliases
# Conflicts: # gitea_mcp_server.py # merged_cleanup_reconcile.py
This commit is contained in:
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
@@ -408,5 +408,206 @@ class TestMergedCleanupReport(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user