mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Populate worktree list probe columns
This commit is contained in:
parent
432502932a
commit
1bab89cdea
7 changed files with 208 additions and 10 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue