feat: add worktree hygiene dashboard to web UI (Closes #432)
Adds read-only /worktrees and /api/worktrees routes that scan branches/ directories, classify worktree state, flag missing preserved worktrees from the issue lock, and surface the canonical cleanup prompt without deletion. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -46,10 +46,13 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Gitea-Tools", response.text)
|
||||
|
||||
def test_worktrees_is_implemented(self):
|
||||
response = self.client.get("/worktrees")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Worktree hygiene", response.text)
|
||||
|
||||
def test_extra_stub_routes(self):
|
||||
for path in ("/worktrees", "/leases"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.get(path).status_code, 200)
|
||||
self.assertEqual(self.client.get("/leases").status_code, 200)
|
||||
|
||||
def test_post_is_rejected(self):
|
||||
response = self.client.post("/health")
|
||||
@@ -62,10 +65,10 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertIn("Live queue", response.text)
|
||||
|
||||
def test_nav_links_on_all_pages(self):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees"):
|
||||
with self.subTest(path=path):
|
||||
text = self.client.get(path).text
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees"):
|
||||
self.assertIn(f'href="{href}"', text)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for web UI worktree hygiene dashboard (#432)."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.worktree_scanner import (
|
||||
classify_entry,
|
||||
load_hygiene_snapshot,
|
||||
parse_worktree_list_porcelain,
|
||||
snapshot_to_dict,
|
||||
)
|
||||
|
||||
|
||||
class TestWorktreeScanner(unittest.TestCase):
|
||||
def test_parse_worktree_porcelain(self):
|
||||
porcelain = (
|
||||
"worktree /repo/branches/feat-issue-1-foo\n"
|
||||
"HEAD abcdef0123456789\n"
|
||||
"branch refs/heads/feat/issue-1-foo\n"
|
||||
"\n"
|
||||
)
|
||||
entries = parse_worktree_list_porcelain(porcelain)
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0]["branch"], "feat/issue-1-foo")
|
||||
|
||||
def test_classify_active_pr(self):
|
||||
classification, _ = classify_entry(
|
||||
rel_path="branches/feat-issue-99-demo",
|
||||
worktree_record={"branch": "feat/issue-99-demo"},
|
||||
state={"exists": True, "clean": True, "dirty_files": []},
|
||||
open_pr_branches={"feat/issue-99-demo"},
|
||||
active_lock_branch=None,
|
||||
)
|
||||
self.assertEqual(classification, "active-pr")
|
||||
|
||||
def test_classify_dirty(self):
|
||||
classification, _ = classify_entry(
|
||||
rel_path="branches/feat-issue-2-bar",
|
||||
worktree_record={"branch": "feat/issue-2-bar"},
|
||||
state={"exists": True, "clean": False, "dirty_files": ["webui/app.py"]},
|
||||
open_pr_branches=set(),
|
||||
active_lock_branch=None,
|
||||
)
|
||||
self.assertEqual(classification, "dirty")
|
||||
|
||||
def test_missing_lock_worktree_anomaly(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
branches = Path(tmp) / "branches" / "other-worktree"
|
||||
branches.mkdir(parents=True)
|
||||
lock_path = Path(tmp) / "lock.json"
|
||||
lock_path.write_text(
|
||||
json.dumps({
|
||||
"issue_number": 432,
|
||||
"branch_name": "feat/issue-432-worktree-hygiene",
|
||||
"worktree_path": str(Path(tmp) / "branches" / "missing-tree"),
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = load_hygiene_snapshot(
|
||||
project_root=tmp,
|
||||
worktree_porcelain="",
|
||||
open_pr_branches=set(),
|
||||
issue_lock_path=str(lock_path),
|
||||
)
|
||||
self.assertTrue(snapshot.anomalies)
|
||||
self.assertIn("Missing preserved worktree", snapshot.anomalies[0])
|
||||
|
||||
def test_snapshot_to_dict(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "branches").mkdir()
|
||||
snapshot = load_hygiene_snapshot(
|
||||
project_root=tmp,
|
||||
worktree_porcelain="",
|
||||
open_pr_branches=set(),
|
||||
issue_lock_path=str(Path(tmp) / "no-lock.json"),
|
||||
)
|
||||
data = snapshot_to_dict(snapshot)
|
||||
self.assertIn("entries", data)
|
||||
self.assertIn("cleanup_prompt", data)
|
||||
|
||||
|
||||
class TestWorktreeRoutes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_worktrees_page_renders(self):
|
||||
response = self.client.get("/worktrees")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Worktree hygiene", response.text)
|
||||
self.assertIn("Canonical cleanup prompt", response.text)
|
||||
self.assertIn("Copy cleanup prompt", response.text)
|
||||
self.assertNotIn("child issue", response.text.lower())
|
||||
|
||||
def test_api_worktrees_json(self):
|
||||
response = self.client.get("/api/worktrees")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("entries", data)
|
||||
self.assertIn("cleanup_prompt", data)
|
||||
self.assertIn("anomalies", data)
|
||||
|
||||
def test_nav_includes_worktrees(self):
|
||||
self.assertIn('href="/worktrees"', self.client.get("/").text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user