diff --git a/backend/app/api/routes/worktrees.py b/backend/app/api/routes/worktrees.py index dda6246..d1dda67 100644 --- a/backend/app/api/routes/worktrees.py +++ b/backend/app/api/routes/worktrees.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import Session from app.db.database import get_db from app.db.models import CommitModel, RepoModel, WorkTree +from app.services.worktree_probe import _probe_worktree router = APIRouter(prefix="/worktrees", tags=["worktrees-v5"]) @@ -180,6 +181,18 @@ class WorkTreeResponse(BaseModel): model_config = {"from_attributes": True} +class WorkTreeProbe(BaseModel): + dirty_files: int + ahead: int + behind: int + last_commit_sha: str | None = None + last_commit_relative: str | None = None + + +class WorkTreeListEntry(WorkTreeResponse): + probe: WorkTreeProbe | None = None + + # -- Endpoints ---------------------------------------------------------------- @@ -249,7 +262,7 @@ def create_worktree( return worktree -@router.get("", response_model=list[WorkTreeResponse]) +@router.get("", response_model=list[WorkTreeListEntry]) def list_worktrees( space_id: uuid.UUID = Query(..., description="Space UUID"), include_closed: bool = Query(False, description="Include closed worktrees"), @@ -264,7 +277,23 @@ def list_worktrees( ) if not include_closed: stmt = stmt.where(WorkTree.status == "active") - return list(db.scalars(stmt).all()) + entries = [] + for worktree in db.scalars(stmt).all(): + probe = None + if worktree.status == "active": + probed = _probe_worktree( + str(worktree.id), + worktree.path, + worktree.branch_name, + ) + if probed: + probe = WorkTreeProbe(**probed) + entries.append( + WorkTreeListEntry.model_validate(worktree).model_copy( + update={"probe": probe}, + ) + ) + return entries @router.get("/{worktree_id}", response_model=WorkTreeResponse) diff --git a/backend/tests/integration/test_worktrees.py b/backend/tests/integration/test_worktrees.py index 99f7d9c..3066b6a 100644 --- a/backend/tests/integration/test_worktrees.py +++ b/backend/tests/integration/test_worktrees.py @@ -173,6 +173,66 @@ def test_list_show_and_close_clean_worktree(client, tmp_path): assert len(all_r.json()) == 1 +def test_list_includes_probe_data_for_active_worktrees(client, tmp_path, monkeypatch): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + target = tmp_path / "probe-worktree" + created = _create_worktree(client, space_id, base_path=str(target)).json() + + def fake_probe(worktree_id, path, branch): + assert worktree_id == created["id"] + assert path == str(target.resolve()) + assert branch == created["branch_name"] + return { + "id": worktree_id, + "path": path, + "branch": branch, + "dirty_files": 3, + "ahead": 1, + "behind": 0, + "last_commit_sha": "abc1234", + "last_commit_relative": "5 minutes ago", + } + + monkeypatch.setattr(worktrees, "_probe_worktree", fake_probe) + + r = client.get(f"/api/v5/worktrees?space_id={space_id}") + + assert r.status_code == 200, r.text + data = r.json() + assert data[0]["id"] == created["id"] + assert data[0]["probe"] == { + "dirty_files": 3, + "ahead": 1, + "behind": 0, + "last_commit_sha": "abc1234", + "last_commit_relative": "5 minutes ago", + } + + +def test_list_probe_null_for_closed_worktrees(client, tmp_path, monkeypatch): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + created = _create_worktree( + client, + space_id, + base_path=str(tmp_path / "closed-probe"), + ).json() + close_r = client.delete(f"/api/v5/worktrees/{created['id']}") + assert close_r.status_code == 200, close_r.text + + def fail_probe(worktree_id, path, branch): + raise AssertionError("closed worktrees should not be probed") + + monkeypatch.setattr(worktrees, "_probe_worktree", fail_probe) + + r = client.get(f"/api/v5/worktrees?space_id={space_id}&include_closed=true") + + assert r.status_code == 200, r.text + assert r.json()[0]["id"] == created["id"] + assert r.json()[0]["probe"] is None + + def test_show_nonexistent_worktree_returns_404(client): r = client.get(f"/api/v5/worktrees/{uuid.uuid4()}") assert r.status_code == 404 diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index c319fca..a5f0d62 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -26,6 +26,32 @@ def _pretty_path(path: str | None) -> str | None: return path +def format_worktree_dirty(worktree: dict) -> str: + """Render the worktree dirty-file count, preserving dash on unknown rows.""" + if worktree.get("status") == "closed": + return "—" + probe = worktree.get("probe") + if not probe: + return "—" + return str(probe.get("dirty_files", "—")) + + +def format_worktree_ahead(worktree: dict) -> str: + """Render compact ahead/behind drift for the existing AHEAD column.""" + if worktree.get("status") == "closed": + return "—" + probe = worktree.get("probe") + if not probe: + return "—" + ahead = int(probe.get("ahead") or 0) + behind = int(probe.get("behind") or 0) + if ahead > 0: + return f"+{ahead}" + if behind > 0: + return f"-{behind}" + return "0" + + def _relative_time(iso_ts: str) -> str: """Format an ISO-8601 UTC timestamp as a relative-time string.""" try: diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 0a91f1e..4b7d527 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -62,6 +62,8 @@ from .formatters import ( format_review, format_space_list, format_state_brief, + format_worktree_ahead, + format_worktree_dirty, ) @@ -832,8 +834,8 @@ def _print_worktree_table(worktrees: list[dict]) -> None: _short_id(str(w.get("id", ""))), str(w.get("agent", "")), str(w.get("branch_name", "")), - "—", - "—", + format_worktree_dirty(w), + format_worktree_ahead(w), _display_path(str(w.get("path", ""))), ] for w in worktrees @@ -1262,7 +1264,15 @@ def _build_parser() -> argparse.ArgumentParser: wt_open.add_argument("--json", action="store_true") wt_open.set_defaults(func=cmd_worktree_open) - wt_list = worktree_sub.add_parser("list", help="List worktrees for a space") + wt_list = worktree_sub.add_parser( + "list", + help="List worktrees for a space", + description=( + "List worktrees for a space. DIRTY shows dirty file count when " + "available. AHEAD shows +N when ahead of origin/main, -N when " + "behind, 0 when even, or — when unknown." + ), + ) wt_list.add_argument("space", help="Space name or UUID") wt_list.add_argument("--include-closed", action="store_true", help="Include closed worktrees") wt_list.add_argument("--json", action="store_true") diff --git a/cli/smriti_cli/mcp_server.py b/cli/smriti_cli/mcp_server.py index 0093af1..d95424a 100644 --- a/cli/smriti_cli/mcp_server.py +++ b/cli/smriti_cli/mcp_server.py @@ -29,6 +29,8 @@ from .formatters import ( format_review, format_space_list, format_state_brief, + format_worktree_ahead, + format_worktree_dirty, ) @@ -645,8 +647,8 @@ def _format_worktree_list(worktrees: list[dict]) -> str: f"{short_id} | " f"{worktree.get('agent', '')} | " f"{worktree.get('branch_name', '')} | " - "— | " - "— | " + f"{format_worktree_dirty(worktree)} | " + f"{format_worktree_ahead(worktree)} | " f"{worktree.get('path', '')} |" ) return "\n".join(lines) @@ -700,9 +702,9 @@ def smriti_worktree_open( def smriti_worktree_list(space: str, include_closed: bool = False) -> str: """List worktrees for a Smriti space. - Use this to see active agent worktree directories. Dirty/ahead columns - are placeholders in V1 and always render as unknown until a future git - status enrichment pass lands. + Use this to see active agent worktree directories. Dirty shows dirty + file count when available. Ahead shows +N when ahead of origin/main, + -N when behind, 0 when even, or — when unknown. Args: space: Space name or UUID. diff --git a/cli/tests/test_worktree_cli.py b/cli/tests/test_worktree_cli.py index 93ca112..6ad5a40 100644 --- a/cli/tests/test_worktree_cli.py +++ b/cli/tests/test_worktree_cli.py @@ -126,6 +126,65 @@ def test_cmd_worktree_list_calls_client(capsys: pytest.CaptureFixture[str]): client.list_worktrees.assert_called_once_with("space-uuid", include_closed=False) +def test_list_renders_probe_data(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + client.list_worktrees.return_value = [ + _worktree_dict( + probe={ + "dirty_files": 3, + "ahead": 1, + "behind": 0, + "last_commit_sha": "abc1234", + "last_commit_relative": "5 minutes ago", + }, + ) + ] + args = argparse.Namespace(space="my-project", include_closed=False, json=False) + + cli_main.cmd_worktree_list(client, args) + + out = capsys.readouterr().out + assert "DIRTY" in out + assert "AHEAD" in out + assert "3" in out + assert "+1" in out + + +def test_list_renders_dash_when_probe_null(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + client.list_worktrees.return_value = [_worktree_dict(probe=None)] + args = argparse.Namespace(space="my-project", include_closed=False, json=False) + + cli_main.cmd_worktree_list(client, args) + + out = capsys.readouterr().out + assert "—" in out + + +def test_list_renders_negative_ahead_for_behind_only(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + client.list_worktrees.return_value = [ + _worktree_dict( + probe={ + "dirty_files": 0, + "ahead": 0, + "behind": 2, + "last_commit_sha": "abc1234", + "last_commit_relative": "5 minutes ago", + }, + ) + ] + args = argparse.Namespace(space="my-project", include_closed=False, json=False) + + cli_main.cmd_worktree_list(client, args) + + out = capsys.readouterr().out + assert "-2" in out + + def test_cmd_worktree_show_calls_client(capsys: pytest.CaptureFixture[str]): client = MagicMock(spec=SmritiClient) client.get_worktree.return_value = _worktree_dict() diff --git a/cli/tests/test_worktree_mcp.py b/cli/tests/test_worktree_mcp.py index 6318677..469f7f2 100644 --- a/cli/tests/test_worktree_mcp.py +++ b/cli/tests/test_worktree_mcp.py @@ -59,6 +59,18 @@ def test_mcp_worktree_list_calls_client(mock_client): ) +def test_mcp_worktree_list_renders_probe_data(mock_client): + mock_client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + mock_client.list_worktrees.return_value = [ + _worktree_dict(probe={"dirty_files": 3, "ahead": 0, "behind": 2}) + ] + + result = mcp_server.smriti_worktree_list(space="my-project") + + assert "3" in result + assert "-2" in result + + def test_mcp_worktree_show_calls_client(mock_client): mock_client.get_worktree.return_value = _worktree_dict()