From b5ccdce7581d3a89286d145b2d02cff1d93e503c Mon Sep 17 00:00:00 2001 From: Himanshu Dongre Date: Mon, 4 May 2026 00:58:30 +0530 Subject: [PATCH] Add V1 worktree primitive Adds WorkTree schema and migration, /api/v5/worktrees CRUD, CLI and MCP worktree surfaces, targeted regression tests, health capability, and minimal docs. Live Postgres migration/manual localhost verification intentionally remain pending until the full backend provider-config gate is resolved. --- ARCHITECTURE.md | 13 +- REPO_STRUCTURE.md | 35 +- ..._0100_b9cadbef0102_add_work_trees_table.py | 61 ++++ backend/app/api/routes/worktrees.py | 328 ++++++++++++++++++ backend/app/db/models.py | 28 ++ backend/app/main.py | 15 +- backend/tests/integration/test_health.py | 1 + backend/tests/integration/test_worktrees.py | 266 ++++++++++++++ backend/tests/unit/test_worktree_paths.py | 33 ++ cli/smriti_cli/client.py | 39 +++ cli/smriti_cli/main.py | 130 +++++++ cli/smriti_cli/mcp_server.py | 134 ++++++- cli/tests/test_worktree_cli.py | 150 ++++++++ cli/tests/test_worktree_mcp.py | 77 ++++ 14 files changed, 1291 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/2026_05_04_0100_b9cadbef0102_add_work_trees_table.py create mode 100644 backend/app/api/routes/worktrees.py create mode 100644 backend/tests/integration/test_worktrees.py create mode 100644 backend/tests/unit/test_worktree_paths.py create mode 100644 cli/tests/test_worktree_cli.py create mode 100644 cli/tests/test_worktree_mcp.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05957f8..d6a5cd0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -349,7 +349,7 @@ Surfaces: own reasoning in their own context, using their own LLM provider. Smriti is their shared memory, not their runtime. - **MCP server** (`cli/smriti_cli/mcp_server.py`, entry point `smriti-mcp`) — - the same surface wrapped as 17 MCP tools for hosts that speak the Model + the same surface wrapped as 21 MCP tools for hosts that speak the Model Context Protocol natively (Claude Code, Cursor, Windsurf). Stdio transport. Each tool is a thin shim: build a `SmritiClient`, call one or two methods, run the result through an existing formatter, return markdown. Feature @@ -564,11 +564,20 @@ decide. No scheduler, no assignment, no orchestrator. **Backend capabilities manifest.** The `/health` endpoint returns `git_sha` and a `capabilities` list (`claims`, `structured_tasks`, `task_ids`, -`checkpoint_notes`, `branch_disposition`, `freshness`, `compact_state`). +`checkpoint_notes`, `branch_disposition`, `freshness`, `compact_state`, +`worktrees`). Agents probe this when a 404 or missing section suggests the backend is running stale code. The capabilities list is hardcoded in `main.py` and updated when new features ship. +**Worktree isolation.** Worktrees are the filesystem/index isolation primitive +for multi-agent project work. `WorkTree` rows record an agent, absolute path, +branch name, base git SHA, and lifecycle status while the backend creates and +removes the corresponding git worktree on disk via `/api/v5/worktrees`. V1 is +intentionally not claim-bound and does not appear in the state brief yet; a +future integration pass should connect active claims to worktrees and surface +that operational state where agents already read `## Active work`. + --- ## What Is Not Yet In the Architecture diff --git a/REPO_STRUCTURE.md b/REPO_STRUCTURE.md index 66bd6d6..e1cfb90 100644 --- a/REPO_STRUCTURE.md +++ b/REPO_STRUCTURE.md @@ -28,7 +28,7 @@ smriti/ │ │ ├── db/ │ │ │ ├── database.py SQLAlchemy engine and session factory │ │ │ └── models.py ORM models: RepoModel, CommitModel, ChatSession, -│ │ │ TurnEvent, WorkClaim +│ │ │ TurnEvent, WorkClaim, WorkTree │ │ ├── domain/ │ │ │ └── enums.py SessionStatus, TargetTool, etc. │ │ ├── api/ @@ -39,6 +39,7 @@ smriti/ │ │ │ ├── lineage.py V5: fork, branch tree, checkpoint compare, │ │ │ │ reachable checkpoints │ │ │ ├── claims.py V5: work claims (create, update, list) +│ │ │ ├── worktrees.py V5: git worktrees (open, list, show, close) │ │ │ ├── repos.py V2: Space CRUD, Checkpoint CRUD │ │ │ ├── commits.py V2: direct commit creation │ │ │ └── context_git.py V2: context extraction @@ -60,19 +61,21 @@ smriti/ │ ├── config/ │ │ ├── providers.example.yaml Template — copy to providers.yaml │ │ └── providers.yaml Your keys (gitignored, not committed) -│ ├── alembic/ Database migrations (10 versions) +│ ├── alembic/ Database migrations (13 versions) │ ├── tests/ -│ │ ├── integration/ API integration tests (177 tests) +│ │ ├── integration/ API integration tests (116 tests) │ │ │ ├── test_api_v4_chat.py │ │ │ ├── test_api_v5_lineage.py │ │ │ ├── test_multi_branch_state.py │ │ │ ├── test_claims.py +│ │ │ ├── test_worktrees.py │ │ │ ├── test_checkpoint_extract.py │ │ │ └── test_delete_endpoints.py -│ │ └── unit/ Unit tests (97 tests) +│ │ └── unit/ Unit tests (120 tests) │ │ ├── test_config_loader.py │ │ ├── test_extractor.py │ │ ├── test_golden_outputs.py +│ │ ├── test_worktree_paths.py │ │ ├── test_pack_generator.py │ │ └── test_parser.py │ └── pyproject.toml Python dependencies (includes python-dotenv) @@ -101,22 +104,24 @@ smriti/ │ ├── smriti_cli/ │ │ ├── main.py argparse dispatcher: init, space, state, │ │ │ checkpoint, fork, restore, compare, -│ │ │ branch, claim, skills -│ │ ├── mcp_server.py FastMCP server (17 tools, stdio transport) -│ │ ├── client.py SmritiClient HTTP wrapper (includes claims) +│ │ │ branch, claim, worktree, skills +│ │ ├── mcp_server.py FastMCP server (21 tools, stdio transport) +│ │ ├── client.py SmritiClient HTTP wrapper (includes claims/worktrees) │ │ ├── formatters.py Continuation-oriented markdown renderers │ │ │ (multi-branch, active claims, divergence) │ │ └── skill_pack/ Agent skill pack source and renderer │ │ ├── template.md Single source of truth (v1.9, 15 sections) │ │ ├── renderer.py Pure-function render + versioned install │ │ └── targets.py Target configs (claude-code, codex) -│ └── tests/ CLI + MCP tests (107 tests) +│ └── tests/ CLI + MCP tests (117 tests) │ ├── test_branch_close.py │ ├── test_init.py │ ├── test_mcp_server.py │ ├── test_skill_pack.py │ ├── test_smoke.py -│ └── test_state_multi_branch.py +│ ├── test_state_multi_branch.py +│ ├── test_worktree_cli.py +│ └── test_worktree_mcp.py │ ├── docs/ │ └── API.md V2, V4, and V5 endpoint reference @@ -135,7 +140,7 @@ smriti/ | `/api/v1` | `sessions.py` | Legacy | Transcript paste ingestion | | `/api/v2` | `repos.py`, `commits.py` | Current | Space CRUD, checkpoint read/list. `CommitResponse` includes `assumptions` and `artifacts`. | | `/api/v4` | `chat.py` | Current | Chat sessions, send_message, commit, head, multi-branch state (`/state` with active branches, active claims, and divergence signal). Provider status. | -| `/api/v5` | `checkpoint.py`, `lineage.py`, `claims.py` | Current | Checkpoint draft/review/extract, fork, lineage tree, compare, work claims. | +| `/api/v5` | `checkpoint.py`, `lineage.py`, `claims.py`, `worktrees.py` | Current | Checkpoint draft/review/extract, fork, lineage tree, compare, work claims, git worktrees. | --- @@ -156,11 +161,11 @@ make migration Create a new migration (usage: make migration msg="...") --- -## Test counts (as of v1.9 skill pack + task IDs + metrics) +## Test counts (as of V1 worktree primitive) | Suite | Count | Location | |---|---|---| -| Backend integration | 107 | `backend/tests/integration/` | -| Backend unit | 117 | `backend/tests/unit/` | -| CLI + MCP | 107 | `cli/tests/` | -| **Total** | **331** | | +| Backend integration | 116 | `backend/tests/integration/` | +| Backend unit | 120 | `backend/tests/unit/` | +| CLI + MCP | 117 | `cli/tests/` | +| **Total** | **353** | | diff --git a/backend/alembic/versions/2026_05_04_0100_b9cadbef0102_add_work_trees_table.py b/backend/alembic/versions/2026_05_04_0100_b9cadbef0102_add_work_trees_table.py new file mode 100644 index 0000000..aa7571f --- /dev/null +++ b/backend/alembic/versions/2026_05_04_0100_b9cadbef0102_add_work_trees_table.py @@ -0,0 +1,61 @@ +"""Add work_trees table. + +Revision ID: b9cadbef0102 +Revises: a8b9cadbef01 +Create Date: 2026-05-04 01:00:00.000000 + +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b9cadbef0102" +down_revision = "a8b9cadbef01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "work_trees", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "repo_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("repos.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("agent", sa.String(100), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("branch_name", sa.String(255), nullable=False), + sa.Column("base_commit_sha", sa.String(64), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("idx_work_trees_repo", "work_trees", ["repo_id"], unique=False) + op.create_index( + "idx_work_trees_active", + "work_trees", + ["repo_id", "status"], + unique=False, + postgresql_where=sa.text("status = 'active'"), + ) + + +def downgrade() -> None: + op.drop_index("idx_work_trees_active", table_name="work_trees") + op.drop_index("idx_work_trees_repo", table_name="work_trees") + op.drop_table("work_trees") diff --git a/backend/app/api/routes/worktrees.py b/backend/app/api/routes/worktrees.py new file mode 100644 index 0000000..dda6246 --- /dev/null +++ b/backend/app/api/routes/worktrees.py @@ -0,0 +1,328 @@ +""" +V5 Worktree API routes. + +Worktrees provide filesystem-level isolation for agents: each agent gets +its own working directory and git index while sharing the same repository +object store. V1 intentionally stays narrow and does not link worktrees to +claims or enrich the state brief. + +Endpoints: + POST /api/v5/worktrees - create a git worktree + GET /api/v5/worktrees?space_id=... - list worktrees for a space + GET /api/v5/worktrees/{worktree_id} - show one worktree row + DELETE /api/v5/worktrees/{worktree_id} - close/remove a worktree +""" +from __future__ import annotations + +import re +import shutil +import subprocess +import uuid +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.database import get_db +from app.db.models import CommitModel, RepoModel, WorkTree + +router = APIRouter(prefix="/worktrees", tags=["worktrees-v5"]) + +DEMO_USER_ID = uuid.UUID("00000000-0000-0000-0000-00000000000a") +VALID_STATUSES = {"active", "closed"} + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _slugify(value: str, fallback: str = "item") -> str: + """Lowercase a user/project label into a filesystem/branch-safe slug.""" + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or fallback + + +def _default_branch_name(agent: str, suffix: str) -> str: + return f"smriti/{_slugify(agent, fallback='agent')}/{suffix}" + + +def _default_worktree_path(space_name: str, agent: str, suffix: str) -> str: + space_slug = _slugify(space_name, fallback="space") + agent_slug = _slugify(agent, fallback="agent") + return str( + Path.home() + / ".smriti" + / "worktrees" + / space_slug + / f"{agent_slug}-{suffix}" + ) + + +def _run_git( + args: list[str], + *, + cwd: str | Path | None = None, + timeout: float = 30.0, +) -> subprocess.CompletedProcess[str]: + """Run git with captured output and consistent infrastructure errors.""" + try: + return subprocess.run( + ["git", *args], + cwd=str(cwd) if cwd is not None else None, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except FileNotFoundError: + raise HTTPException(status_code=500, detail="git executable not found") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="git command timed out") + + +def _get_repo(space_id: uuid.UUID, db: Session) -> RepoModel: + repo = db.get(RepoModel, space_id) + if not repo or repo.user_id != DEMO_USER_ID: + raise HTTPException(status_code=404, detail="Space not found") + return repo + + +def _latest_project_root(space_id: uuid.UUID, db: Session) -> Path: + stmt = ( + select(CommitModel.project_root) + .where( + CommitModel.repo_id == space_id, + CommitModel.project_root.is_not(None), + CommitModel.project_root != "", + ) + .order_by(CommitModel.created_at.desc()) + .limit(1) + ) + project_root = db.scalars(stmt).first() + if not project_root: + raise HTTPException( + status_code=400, + detail=( + "No checkpoint with project_root found for this space; " + "create a checkpoint from the project checkout first." + ), + ) + + resolved = Path(project_root).expanduser().resolve() + if not resolved.is_dir(): + raise HTTPException( + status_code=400, + detail=f"project_root does not exist on disk: {resolved}", + ) + return resolved + + +def _current_head(project_root: Path) -> str: + result = _run_git(["rev-parse", "HEAD"], cwd=project_root) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "could not resolve HEAD" + raise HTTPException(status_code=500, detail=detail) + return result.stdout.strip() + + +def _branch_exists(project_root: Path, branch_name: str) -> bool: + result = _run_git( + ["show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"], + cwd=project_root, + ) + if result.returncode == 0: + return True + if result.returncode == 1: + return False + detail = result.stderr.strip() or "could not check branch existence" + raise HTTPException(status_code=500, detail=detail) + + +def _resolve_target_path(path_value: str) -> Path: + target = Path(path_value).expanduser() + if not target.is_absolute(): + raise HTTPException(status_code=400, detail="base_path must be absolute") + return target.resolve() + + +def _git_error(result: subprocess.CompletedProcess[str], fallback: str) -> str: + return result.stderr.strip() or result.stdout.strip() or fallback + + +# -- Request / Response schemas ---------------------------------------------- + + +class CreateWorkTreeRequest(BaseModel): + space_id: str + agent: str + branch_name: str | None = None + base_commit_sha: str | None = None + base_path: str | None = Field( + default=None, + description="Absolute target path for the new worktree.", + ) + + +class WorkTreeResponse(BaseModel): + id: uuid.UUID + repo_id: uuid.UUID + agent: str + path: str + branch_name: str + base_commit_sha: str | None = None + status: str + created_at: datetime + closed_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# -- Endpoints ---------------------------------------------------------------- + + +@router.post("", response_model=WorkTreeResponse, status_code=201) +def create_worktree( + payload: CreateWorkTreeRequest, + db: Session = Depends(get_db), +): + """Create a git worktree and record it after git succeeds.""" + try: + space_id = uuid.UUID(payload.space_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid space_id") + + repo = _get_repo(space_id, db) + agent = payload.agent.strip() + if not agent: + raise HTTPException(status_code=400, detail="agent must be non-empty") + + project_root = _latest_project_root(space_id, db) + suffix = uuid.uuid4().hex[:8] + branch_name = (payload.branch_name or "").strip() or _default_branch_name(agent, suffix) + target_path = _resolve_target_path( + payload.base_path or _default_worktree_path(repo.name, agent, suffix) + ) + + if _branch_exists(project_root, branch_name): + raise HTTPException( + status_code=409, + detail=f"Branch already exists: {branch_name}", + ) + + base_commit_sha = (payload.base_commit_sha or "").strip() or _current_head(project_root) + target_existed = target_path.exists() + target_path.parent.mkdir(parents=True, exist_ok=True) + + result = _run_git( + [ + "worktree", + "add", + "-b", + branch_name, + str(target_path), + base_commit_sha, + ], + cwd=project_root, + ) + if result.returncode != 0: + if target_path.exists() and not target_existed: + shutil.rmtree(target_path, ignore_errors=True) + raise HTTPException( + status_code=500, + detail=_git_error(result, "git worktree add failed"), + ) + + worktree = WorkTree( + repo_id=space_id, + agent=agent, + path=str(target_path), + branch_name=branch_name, + base_commit_sha=base_commit_sha, + status="active", + ) + db.add(worktree) + db.commit() + db.refresh(worktree) + return worktree + + +@router.get("", response_model=list[WorkTreeResponse]) +def list_worktrees( + space_id: uuid.UUID = Query(..., description="Space UUID"), + include_closed: bool = Query(False, description="Include closed worktrees"), + db: Session = Depends(get_db), +): + """List worktrees for a space, newest first.""" + _get_repo(space_id, db) + stmt = ( + select(WorkTree) + .where(WorkTree.repo_id == space_id) + .order_by(WorkTree.created_at.desc()) + ) + if not include_closed: + stmt = stmt.where(WorkTree.status == "active") + return list(db.scalars(stmt).all()) + + +@router.get("/{worktree_id}", response_model=WorkTreeResponse) +def get_worktree(worktree_id: uuid.UUID, db: Session = Depends(get_db)): + """Return one worktree row.""" + worktree = db.get(WorkTree, worktree_id) + if not worktree: + raise HTTPException(status_code=404, detail="Worktree not found") + return worktree + + +@router.delete("/{worktree_id}", response_model=WorkTreeResponse) +def close_worktree( + worktree_id: uuid.UUID, + force: bool = Query(False, description="Force removal even if dirty"), + db: Session = Depends(get_db), +): + """Remove a git worktree and mark its row closed.""" + worktree = db.get(WorkTree, worktree_id) + if not worktree: + raise HTTPException(status_code=404, detail="Worktree not found") + if worktree.status == "closed": + raise HTTPException(status_code=409, detail="Worktree is already closed") + if worktree.status not in VALID_STATUSES: + raise HTTPException( + status_code=409, + detail=f"Worktree has invalid status: {worktree.status}", + ) + + project_root = _latest_project_root(worktree.repo_id, db) + worktree_path = Path(worktree.path).expanduser().resolve() + + if not force: + status = _run_git(["status", "--porcelain"], cwd=worktree_path) + if status.returncode != 0: + raise HTTPException( + status_code=500, + detail=_git_error(status, "git status failed"), + ) + if status.stdout.strip(): + raise HTTPException( + status_code=409, + detail="Worktree has uncommitted changes; pass force=true to remove it.", + ) + + args = ["worktree", "remove"] + if force: + args.append("--force") + args.append(str(worktree_path)) + result = _run_git(args, cwd=project_root) + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=_git_error(result, "git worktree remove failed"), + ) + + worktree.status = "closed" + worktree.closed_at = _utcnow() + db.commit() + db.refresh(worktree) + return worktree diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 46c74b7..922e5a9 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -146,6 +146,9 @@ class RepoModel(Base): commits: Mapped[list["CommitModel"]] = relationship( back_populates="repo", cascade="all, delete-orphan", order_by="CommitModel.created_at.desc()" ) + worktrees: Mapped[list["WorkTree"]] = relationship( + back_populates="repo", cascade="all, delete-orphan" + ) class CommitModel(Base): __tablename__ = "commits" @@ -314,3 +317,28 @@ class WorkClaim(Base): DateTime(timezone=True), nullable=False, ) + +class WorkTree(Base): + """A git worktree allocated for an agent working in a space. + + Worktrees provide filesystem-level isolation: separate working + directories and git indexes backed by the same repository object store. + """ + __tablename__ = "work_trees" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("repos.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + agent: Mapped[str] = mapped_column(String(100), nullable=False) + path: Mapped[str] = mapped_column(Text, nullable=False) + branch_name: Mapped[str] = mapped_column(String(255), nullable=False) + base_commit_sha: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column(String(20), default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + repo: Mapped["RepoModel"] = relationship(back_populates="worktrees") diff --git a/backend/app/main.py b/backend/app/main.py index b276301..a622577 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ import logging import pathlib import re import subprocess + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -52,7 +53,17 @@ def create_app() -> FastAPI: for handler in logging.root.handlers: handler.addFilter(SecretGuardFilter()) - from app.api.routes import repos, commits, context_git, chat, checkpoint, lineage, claims, metrics + from app.api.routes import ( + chat, + checkpoint, + claims, + commits, + context_git, + lineage, + metrics, + repos, + worktrees, + ) # V2 Routes (Git for memory) app.include_router(repos.router, prefix="/api/v2", tags=["repos"]) @@ -67,6 +78,7 @@ def create_app() -> FastAPI: app.include_router(lineage.router, prefix="/api/v5", tags=["lineage-v5"]) app.include_router(claims.router, prefix="/api/v5", tags=["claims-v5"]) app.include_router(metrics.router, prefix="/api/v5", tags=["metrics-v5"]) + app.include_router(worktrees.router, prefix="/api/v5", tags=["worktrees-v5"]) # ── Capabilities manifest ──────────────────────────────────────── # Computed once at startup so /health is zero-cost at request time. @@ -83,6 +95,7 @@ def create_app() -> FastAPI: "branch_disposition", # PATCH /api/v5/lineage/branches/disposition "freshness", # since_commit_id on state endpoint "compact_state", # --compact mode on state brief + "worktrees", # /api/v5/worktrees ] @app.get("/health") diff --git a/backend/tests/integration/test_health.py b/backend/tests/integration/test_health.py index fcafb4c..339d8be 100644 --- a/backend/tests/integration/test_health.py +++ b/backend/tests/integration/test_health.py @@ -34,6 +34,7 @@ def test_health_includes_required_capabilities(client): "branch_disposition", "freshness", "compact_state", + "worktrees", ] for cap in required: assert cap in caps, f"Missing capability: {cap}" diff --git a/backend/tests/integration/test_worktrees.py b/backend/tests/integration/test_worktrees.py new file mode 100644 index 0000000..99f7d9c --- /dev/null +++ b/backend/tests/integration/test_worktrees.py @@ -0,0 +1,266 @@ +"""Integration tests for the V5 worktree API. + +These tests use a real temporary git repository on disk. That is the +important safety boundary for this feature: V1 is about filesystem/index +isolation, so the API must exercise real `git worktree` behavior. +""" +from __future__ import annotations + +import subprocess +import uuid +from pathlib import Path +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.api.routes import worktrees + + +def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if check: + assert result.returncode == 0, result.stderr + return result + + +def _create_repo(client, name="Worktree Test Repo"): + r = client.post("/api/v2/repos", json={"name": name}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _create_session(client, repo_id, title="worktree test"): + r = client.post( + f"/api/v4/chat/spaces/{repo_id}/sessions", + json={"title": title, "provider": "openrouter", "model": "mock"}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _commit_with_root(client, repo_id, session_id, project_root: Path): + payload = { + "repo_id": repo_id, + "session_id": session_id, + "message": "base", + "summary": "base", + "project_root": str(project_root), + } + r = client.post("/api/v4/chat/commit", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +def _create_project_with_root(client, git_repo: Path): + repo_id = _create_repo(client) + session_id = _create_session(client, repo_id) + _commit_with_root(client, repo_id, session_id, git_repo) + return repo_id + + +def _create_worktree(client, space_id: str, **kwargs): + payload = { + "space_id": space_id, + "agent": kwargs.pop("agent", "codex-local"), + **kwargs, + } + return client.post("/api/v5/worktrees", json=payload) + + +def test_create_worktree_inserts_row_creates_directory_and_branch( + client, + tmp_path, + monkeypatch, +): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + monkeypatch.setattr(worktrees.Path, "home", lambda: tmp_path / "home") + + r = _create_worktree(client, space_id, agent="Codex Local") + + assert r.status_code == 201, r.text + data = r.json() + worktree_path = Path(data["path"]) + assert worktree_path.exists() + assert data["agent"] == "Codex Local" + assert data["branch_name"].startswith("smriti/codex-local/") + assert data["base_commit_sha"] == _git(git_repo, "rev-parse", "HEAD").stdout.strip() + assert data["status"] == "active" + assert _git( + git_repo, + "show-ref", + "--verify", + f"refs/heads/{data['branch_name']}", + ).returncode == 0 + + +def test_create_worktree_honors_explicit_branch_base_and_path(client, tmp_path): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + base_sha = _git(git_repo, "rev-parse", "HEAD").stdout.strip() + target = tmp_path / "custom-worktree" + + r = _create_worktree( + client, + space_id, + branch_name="feature/custom-worktree", + base_commit_sha=base_sha, + base_path=str(target), + ) + + assert r.status_code == 201, r.text + data = r.json() + assert data["branch_name"] == "feature/custom-worktree" + assert data["base_commit_sha"] == base_sha + assert data["path"] == str(target.resolve()) + assert target.exists() + + +def test_create_worktree_existing_branch_rejected_without_row_or_directory( + client, + tmp_path, +): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + _git(git_repo, "branch", "existing-worktree-branch") + target = tmp_path / "should-not-exist" + + r = _create_worktree( + client, + space_id, + branch_name="existing-worktree-branch", + base_path=str(target), + ) + + assert r.status_code == 409 + assert "Branch already exists" in r.json()["detail"] + assert not target.exists() + assert client.get(f"/api/v5/worktrees?space_id={space_id}").json() == [] + + +def test_list_show_and_close_clean_worktree(client, tmp_path): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + target = tmp_path / "list-show-close" + created = _create_worktree(client, space_id, base_path=str(target)).json() + + list_r = client.get(f"/api/v5/worktrees?space_id={space_id}") + assert list_r.status_code == 200 + assert [w["id"] for w in list_r.json()] == [created["id"]] + + show_r = client.get(f"/api/v5/worktrees/{created['id']}") + assert show_r.status_code == 200 + assert show_r.json()["path"] == str(target.resolve()) + + close_r = client.delete(f"/api/v5/worktrees/{created['id']}") + assert close_r.status_code == 200, close_r.text + closed = close_r.json() + assert closed["status"] == "closed" + assert closed["closed_at"] is not None + assert not target.exists() + + active_r = client.get(f"/api/v5/worktrees?space_id={space_id}") + assert active_r.status_code == 200 + assert active_r.json() == [] + + all_r = client.get(f"/api/v5/worktrees?space_id={space_id}&include_closed=true") + assert len(all_r.json()) == 1 + + +def test_show_nonexistent_worktree_returns_404(client): + r = client.get(f"/api/v5/worktrees/{uuid.uuid4()}") + assert r.status_code == 404 + + +def test_close_dirty_worktree_requires_force(client, tmp_path): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + target = tmp_path / "dirty-worktree" + created = _create_worktree(client, space_id, base_path=str(target)).json() + (target / "dirty.txt").write_text("uncommitted\n") + + r = client.delete(f"/api/v5/worktrees/{created['id']}") + + assert r.status_code == 409 + assert "uncommitted changes" in r.json()["detail"] + assert target.exists() + + force_r = client.delete(f"/api/v5/worktrees/{created['id']}?force=true") + assert force_r.status_code == 200, force_r.text + assert force_r.json()["status"] == "closed" + assert not target.exists() + + +def test_close_already_closed_returns_409(client, tmp_path): + 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 / "already-closed"), + ).json() + first = client.delete(f"/api/v5/worktrees/{created['id']}") + assert first.status_code == 200 + + second = client.delete(f"/api/v5/worktrees/{created['id']}") + + assert second.status_code == 409 + assert "already closed" in second.json()["detail"] + + +def test_create_worktree_requires_project_root(client, tmp_path): + space_id = _create_repo(client) + target = tmp_path / "no-root-worktree" + + r = _create_worktree(client, space_id, base_path=str(target)) + + assert r.status_code == 400 + assert "project_root" in r.json()["detail"] + assert not target.exists() + + +def test_git_infrastructure_error_does_not_write_row_or_leave_target( + client, + tmp_path, + monkeypatch, +): + git_repo = _init_git_repo(tmp_path) + space_id = _create_project_with_root(client, git_repo) + target = tmp_path / "git-missing" + head_sha = _git(git_repo, "rev-parse", "HEAD").stdout.strip() + + def fake_run_git(args, *, cwd=None, timeout=30.0): + if args[0] == "show-ref": + return SimpleNamespace(returncode=1, stdout="", stderr="") + if args[0] == "rev-parse": + return SimpleNamespace(returncode=0, stdout=f"{head_sha}\n", stderr="") + if args[:2] == ["worktree", "add"]: + raise HTTPException(status_code=500, detail="git executable not found") + raise AssertionError(f"unexpected git call: {args}") + + monkeypatch.setattr(worktrees, "_run_git", fake_run_git) + + r = _create_worktree(client, space_id, base_path=str(target)) + + assert r.status_code == 500 + assert "git executable not found" in r.json()["detail"] + assert not target.exists() + assert client.get(f"/api/v5/worktrees?space_id={space_id}").json() == [] + + +def _init_git_repo(tmp_path: Path) -> Path: + repo = tmp_path / "project" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test User") + (repo / "README.md").write_text("hello\n") + _git(repo, "add", "README.md") + _git(repo, "commit", "-m", "initial") + return repo diff --git a/backend/tests/unit/test_worktree_paths.py b/backend/tests/unit/test_worktree_paths.py new file mode 100644 index 0000000..664fdc4 --- /dev/null +++ b/backend/tests/unit/test_worktree_paths.py @@ -0,0 +1,33 @@ +"""Unit tests for worktree naming/path helpers.""" +from __future__ import annotations + +from pathlib import Path + +from app.api.routes import worktrees + + +def test_slugify_lowercases_and_replaces_non_alphanumeric(): + assert worktrees._slugify("Smriti Dev!") == "smriti-dev" + assert worktrees._slugify("Claude_Code v2") == "claude-code-v2" + assert worktrees._slugify("!!!", fallback="fallback") == "fallback" + + +def test_default_branch_name_uses_agent_slug(): + assert ( + worktrees._default_branch_name("Claude Code", "abc12345") + == "smriti/claude-code/abc12345" + ) + + +def test_default_worktree_path_uses_home_space_agent_and_suffix(monkeypatch, tmp_path): + monkeypatch.setattr(worktrees.Path, "home", lambda: Path(tmp_path)) + + path = worktrees._default_worktree_path( + "Smriti Dev", + "Codex Local", + "abc12345", + ) + + assert path == str( + tmp_path / ".smriti" / "worktrees" / "smriti-dev" / "codex-local-abc12345" + ) diff --git a/cli/smriti_cli/client.py b/cli/smriti_cli/client.py index 3c52b92..cee9e08 100644 --- a/cli/smriti_cli/client.py +++ b/cli/smriti_cli/client.py @@ -212,6 +212,45 @@ class SmritiClient: params["include_expired"] = "true" return self._request("GET", "/api/v5/claims", params=params) + # ── Worktrees ─────────────────────────────────────────────────── + + def create_worktree( + self, + space_id: str, + agent: str, + branch_name: str | None = None, + base_commit_sha: str | None = None, + base_path: str | None = None, + ) -> dict: + """POST /api/v5/worktrees — create a git worktree.""" + payload = { + "space_id": space_id, + "agent": agent, + } + if branch_name: + payload["branch_name"] = branch_name + if base_commit_sha: + payload["base_commit_sha"] = base_commit_sha + if base_path: + payload["base_path"] = base_path + return self._request("POST", "/api/v5/worktrees", json=payload) + + def list_worktrees(self, space_id: str, include_closed: bool = False) -> list[dict]: + """GET /api/v5/worktrees?space_id=... — list worktrees.""" + params = {"space_id": space_id} + if include_closed: + params["include_closed"] = "true" + return self._request("GET", "/api/v5/worktrees", params=params) + + def get_worktree(self, worktree_id: str) -> dict: + """GET /api/v5/worktrees/{id} — show one worktree.""" + return self._request("GET", f"/api/v5/worktrees/{worktree_id}") + + def close_worktree(self, worktree_id: str, force: bool = False) -> dict: + """DELETE /api/v5/worktrees/{id} — close/remove a worktree.""" + params = {"force": "true"} if force else None + return self._request("DELETE", f"/api/v5/worktrees/{worktree_id}", params=params) + # ── Metrics ───────────────────────────────────────────────────── def get_space_metrics(self, space_id: str) -> dict: diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index c90a98e..19750a3 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -18,6 +18,10 @@ Commands for agent and programmatic use: smriti checkpoint list smriti checkpoint review smriti checkpoint delete [--cascade] [-y] + smriti worktree open --agent + smriti worktree list + smriti worktree show + smriti worktree close Multi-branch workflow: use `smriti fork ` to start a new session on a new branch, then `smriti checkpoint create --session @@ -804,6 +808,99 @@ def cmd_claim_list(client: SmritiClient, args: argparse.Namespace) -> None: print(f" - `{agent}` [{intent}] on `{branch}` — {scope} (id: {c['id']})") +# ── worktree subcommand handlers ──────────────────────────────────────────── + + +def _short_id(value: str) -> str: + return f"{value[:8]}…" if len(value) > 8 else value + + +def _display_path(path: str) -> str: + home = os.path.expanduser("~") + if path == home: + return "~" + if path.startswith(home + os.sep): + return "~" + path[len(home):] + return path + + +def _print_worktree_table(worktrees: list[dict]) -> None: + headers = ["ID", "AGENT", "BRANCH", "DIRTY", "AHEAD", "PATH"] + rows = [ + [ + _short_id(str(w.get("id", ""))), + str(w.get("agent", "")), + str(w.get("branch_name", "")), + "—", + "—", + _display_path(str(w.get("path", ""))), + ] + for w in worktrees + ] + widths = [ + max(len(headers[i]), *(len(row[i]) for row in rows)) + for i in range(len(headers)) + ] + print(" ".join(headers[i].ljust(widths[i]) for i in range(len(headers)))) + for row in rows: + print(" ".join(row[i].ljust(widths[i]) for i in range(len(row)))) + + +def cmd_worktree_open(client: SmritiClient, args: argparse.Namespace) -> None: + """Create a git worktree for an agent and print its path.""" + space = client.resolve_space(args.space) + worktree = client.create_worktree( + space_id=space["id"], + agent=args.agent, + branch_name=args.branch, + base_commit_sha=args.base_commit, + base_path=args.base_path, + ) + if args.json: + _print_json(worktree) + else: + print(worktree["path"]) + + +def cmd_worktree_list(client: SmritiClient, args: argparse.Namespace) -> None: + """List worktrees for a space.""" + space = client.resolve_space(args.space) + worktrees = client.list_worktrees(space["id"], include_closed=args.include_closed) + if args.json: + _print_json(worktrees) + return + if not worktrees: + print("No worktrees." if args.include_closed else "No active worktrees.") + return + _print_worktree_table(worktrees) + + +def cmd_worktree_show(client: SmritiClient, args: argparse.Namespace) -> None: + """Show one worktree.""" + worktree = client.get_worktree(args.worktree_id) + if args.json: + _print_json(worktree) + return + print(f"id: {worktree['id']}") + print(f"status: {worktree['status']}") + print(f"agent: {worktree['agent']}") + print(f"branch: {worktree['branch_name']}") + print(f"base_commit: {worktree.get('base_commit_sha') or ''}") + print(f"path: {worktree['path']}") + print(f"created_at: {worktree['created_at']}") + if worktree.get("closed_at"): + print(f"closed_at: {worktree['closed_at']}") + + +def cmd_worktree_close(client: SmritiClient, args: argparse.Namespace) -> None: + """Close/remove a git worktree.""" + worktree = client.close_worktree(args.worktree_id, force=args.force) + if args.json: + _print_json(worktree) + else: + print(f"Closed worktree `{worktree['id']}` at {worktree['path']}.") + + def cmd_restore(client: SmritiClient, args: argparse.Namespace) -> None: commit = client.get_commit(args.checkpoint_id) space = client.get_space(str(commit.get("repo_id", ""))) @@ -1147,6 +1244,39 @@ def _build_parser() -> argparse.ArgumentParser: cl_list.add_argument("--json", action="store_true") cl_list.set_defaults(func=cmd_claim_list) + # worktree — filesystem/index isolation for agent work + worktree_parser = subparsers.add_parser( + "worktree", + help="Manage git worktrees for isolated agent working directories", + ) + worktree_sub = worktree_parser.add_subparsers(dest="subcommand", required=True) + + wt_open = worktree_sub.add_parser("open", help="Create a worktree for an agent") + wt_open.add_argument("space", help="Space name or UUID") + wt_open.add_argument("--agent", required=True, help="Agent identifier (e.g. claude-code)") + wt_open.add_argument("--branch", help="Branch name for the new worktree", default=None) + wt_open.add_argument("--base-commit", dest="base_commit", help="Git SHA to base the worktree on", default=None) + wt_open.add_argument("--base-path", dest="base_path", help="Absolute target path for the new worktree", default=None) + 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.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") + wt_list.set_defaults(func=cmd_worktree_list) + + wt_show = worktree_sub.add_parser("show", help="Show one worktree") + wt_show.add_argument("worktree_id", help="Worktree UUID") + wt_show.add_argument("--json", action="store_true") + wt_show.set_defaults(func=cmd_worktree_show) + + wt_close = worktree_sub.add_parser("close", help="Close/remove a worktree") + wt_close.add_argument("worktree_id", help="Worktree UUID") + wt_close.add_argument("--force", action="store_true", help="Force removal even if dirty") + wt_close.add_argument("--json", action="store_true") + wt_close.set_defaults(func=cmd_worktree_close) + # skills — install the Smriti agent skill pack into an agent host's # project directory. The skill pack teaches agents when and why to # use Smriti's tools (the load-bearing anti-pattern section is in diff --git a/cli/smriti_cli/mcp_server.py b/cli/smriti_cli/mcp_server.py index fb89418..a97fa0e 100644 --- a/cli/smriti_cli/mcp_server.py +++ b/cli/smriti_cli/mcp_server.py @@ -1,6 +1,6 @@ """Smriti MCP server — stdio transport. -Exposes the Smriti CLI surface as 12 MCP tools so agents inside MCP-aware +Exposes the Smriti CLI surface as 21 MCP tools so agents inside MCP-aware hosts (Claude Code, Cursor, Windsurf) can read and write reasoning state without shelling out to the `smriti` binary. @@ -616,6 +616,138 @@ def smriti_claim_done(claim_id: str, abandon: bool = False) -> str: return f"Claim `{claim['id']}` marked {claim['status']}." +def _format_worktree(worktree: dict) -> str: + lines = [ + f"id: {worktree['id']}", + f"status: {worktree['status']}", + f"agent: {worktree['agent']}", + f"branch: {worktree['branch_name']}", + f"base_commit: {worktree.get('base_commit_sha') or ''}", + f"path: {worktree['path']}", + f"created_at: {worktree['created_at']}", + ] + if worktree.get("closed_at"): + lines.append(f"closed_at: {worktree['closed_at']}") + return "\n".join(lines) + + +def _format_worktree_list(worktrees: list[dict]) -> str: + if not worktrees: + return "No worktrees." + lines = ["| ID | Agent | Branch | Dirty | Ahead | Path |", "|---|---|---|---|---|---|"] + for worktree in worktrees: + short_id = str(worktree.get("id", ""))[:8] + lines.append( + "| " + f"{short_id} | " + f"{worktree.get('agent', '')} | " + f"{worktree.get('branch_name', '')} | " + "— | " + "— | " + f"{worktree.get('path', '')} |" + ) + return "\n".join(lines) + + +@mcp.tool() +def smriti_worktree_open( + space: str, + agent: str = "claude-code", + branch: str = "", + base_commit: str = "", + base_path: str = "", +) -> str: + """Create a git worktree for isolated agent work. + + Use this when an agent is about to work in a repository and needs a + separate filesystem path and git index instead of sharing the main + checkout. V1 only creates the worktree and records it; it does not + bind the worktree to a claim or change the state brief. + + Args: + space: Space name or UUID. + agent: Agent identifier (default: claude-code). + branch: Optional branch name for the new worktree. + base_commit: Optional git SHA to base the worktree on. Default: + current HEAD of the project repo. + base_path: Optional absolute target path. Default: + ~/.smriti/worktrees//-. + """ + client = _client() + try: + s = client.resolve_space(space) + worktree = client.create_worktree( + space_id=s["id"], + agent=agent, + branch_name=branch or None, + base_commit_sha=base_commit or None, + base_path=base_path or None, + ) + except SmritiError as e: + _raise_from(e) + return ( + f"Created worktree `{worktree['id']}` for `{worktree['agent']}`.\n" + f"Path: `{worktree['path']}`\n" + f"Branch: `{worktree['branch_name']}`\n" + f"Base: `{worktree.get('base_commit_sha') or ''}`" + ) + + +@mcp.tool() +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. + + Args: + space: Space name or UUID. + include_closed: Include closed worktrees. Default False. + """ + client = _client() + try: + s = client.resolve_space(space) + worktrees = client.list_worktrees(s["id"], include_closed=include_closed) + except SmritiError as e: + _raise_from(e) + return _format_worktree_list(worktrees) + + +@mcp.tool() +def smriti_worktree_show(worktree_id: str) -> str: + """Show one worktree row by UUID. + + Args: + worktree_id: Worktree UUID returned by smriti_worktree_open or + smriti_worktree_list. + """ + try: + worktree = _client().get_worktree(worktree_id) + except SmritiError as e: + _raise_from(e) + return _format_worktree(worktree) + + +@mcp.tool() +def smriti_worktree_close(worktree_id: str, force: bool = False) -> str: + """Close and remove a git worktree. + + By default this refuses to remove a dirty worktree. Pass force=True + only when the uncommitted contents are intentionally disposable. + + Args: + worktree_id: Worktree UUID returned by smriti_worktree_open or + smriti_worktree_list. + force: Force removal even if the worktree is dirty. + """ + try: + worktree = _client().close_worktree(worktree_id, force=force) + except SmritiError as e: + _raise_from(e) + return f"Closed worktree `{worktree['id']}` at `{worktree['path']}`." + + @mcp.tool() def smriti_install_skill(target: str) -> str: """Return the Smriti agent skill pack for an agent target. diff --git a/cli/tests/test_worktree_cli.py b/cli/tests/test_worktree_cli.py new file mode 100644 index 0000000..c4be013 --- /dev/null +++ b/cli/tests/test_worktree_cli.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import argparse +from unittest.mock import MagicMock + +import pytest + +from smriti_cli import main as cli_main +from smriti_cli.client import SmritiClient + + +def _worktree_dict(**overrides): + base = { + "id": "11111111-2222-3333-4444-555555555555", + "repo_id": "space-uuid", + "agent": "claude-1", + "path": "/tmp/worktree", + "branch_name": "smriti/claude-1/abc12345", + "base_commit_sha": "abc123", + "status": "active", + "created_at": "2026-05-04T00:00:00Z", + "closed_at": None, + } + base.update(overrides) + return base + + +def test_worktree_open_parser_wiring(): + parser = cli_main._build_parser() + + args = parser.parse_args( + [ + "worktree", + "open", + "my-project", + "--agent", + "claude-1", + "--branch", + "feature/wt", + "--base-commit", + "abc123", + "--base-path", + "/tmp/wt", + ] + ) + + assert args.command == "worktree" + assert args.subcommand == "open" + assert args.space == "my-project" + assert args.agent == "claude-1" + assert args.branch == "feature/wt" + assert args.base_commit == "abc123" + assert args.base_path == "/tmp/wt" + assert args.func is cli_main.cmd_worktree_open + + +def test_cmd_worktree_open_calls_client_and_prints_path(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + client.create_worktree.return_value = _worktree_dict(path="/tmp/wt") + args = argparse.Namespace( + space="my-project", + agent="claude-1", + branch=None, + base_commit=None, + base_path=None, + json=False, + ) + + cli_main.cmd_worktree_open(client, args) + + out = capsys.readouterr().out + assert out == "/tmp/wt\n" + client.resolve_space.assert_called_once_with("my-project") + client.create_worktree.assert_called_once_with( + space_id="space-uuid", + agent="claude-1", + branch_name=None, + base_commit_sha=None, + base_path=None, + ) + + +def test_cmd_worktree_open_json_path(): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + worktree = _worktree_dict(path="/tmp/json-wt") + client.create_worktree.return_value = worktree + args = argparse.Namespace( + space="my-project", + agent="claude-1", + branch="feature/wt", + base_commit="abc123", + base_path="/tmp/json-wt", + json=True, + ) + captured: list[dict] = [] + original = cli_main._print_json + cli_main._print_json = captured.append + try: + cli_main.cmd_worktree_open(client, args) + finally: + cli_main._print_json = original + + assert captured == [worktree] + client.create_worktree.assert_called_once_with( + space_id="space-uuid", + agent="claude-1", + branch_name="feature/wt", + base_commit_sha="abc123", + base_path="/tmp/json-wt", + ) + + +def test_cmd_worktree_list_calls_client(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()] + args = argparse.Namespace(space="my-project", include_closed=False, json=False) + + cli_main.cmd_worktree_list(client, args) + + out = capsys.readouterr().out + assert "AGENT" in out + assert "claude-1" in out + client.list_worktrees.assert_called_once_with("space-uuid", include_closed=False) + + +def test_cmd_worktree_show_calls_client(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.get_worktree.return_value = _worktree_dict() + args = argparse.Namespace(worktree_id="wt-uuid", json=False) + + cli_main.cmd_worktree_show(client, args) + + out = capsys.readouterr().out + assert "branch: smriti/claude-1/abc12345" in out + client.get_worktree.assert_called_once_with("wt-uuid") + + +def test_cmd_worktree_close_calls_client(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.close_worktree.return_value = _worktree_dict(status="closed") + args = argparse.Namespace(worktree_id="wt-uuid", force=True, json=False) + + cli_main.cmd_worktree_close(client, args) + + out = capsys.readouterr().out + assert "Closed worktree" in out + client.close_worktree.assert_called_once_with("wt-uuid", force=True) diff --git a/cli/tests/test_worktree_mcp.py b/cli/tests/test_worktree_mcp.py new file mode 100644 index 0000000..ee9af4a --- /dev/null +++ b/cli/tests/test_worktree_mcp.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from smriti_cli import mcp_server + + +def _worktree_dict(**overrides): + base = { + "id": "11111111-2222-3333-4444-555555555555", + "repo_id": "space-uuid", + "agent": "claude-1", + "path": "/tmp/worktree", + "branch_name": "smriti/claude-1/abc12345", + "base_commit_sha": "abc123", + "status": "active", + "created_at": "2026-05-04T00:00:00Z", + "closed_at": None, + } + base.update(overrides) + return base + + +def test_mcp_worktree_open_calls_client(mock_client): + mock_client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + mock_client.create_worktree.return_value = _worktree_dict(path="/tmp/wt") + + result = mcp_server.smriti_worktree_open( + space="my-project", + agent="claude-1", + branch="feature/wt", + base_commit="abc123", + base_path="/tmp/wt", + ) + + assert "/tmp/wt" in result + mock_client.resolve_space.assert_called_once_with("my-project") + mock_client.create_worktree.assert_called_once_with( + space_id="space-uuid", + agent="claude-1", + branch_name="feature/wt", + base_commit_sha="abc123", + base_path="/tmp/wt", + ) + + +def test_mcp_worktree_list_calls_client(mock_client): + mock_client.resolve_space.return_value = {"id": "space-uuid", "name": "my-project"} + mock_client.list_worktrees.return_value = [_worktree_dict()] + + result = mcp_server.smriti_worktree_list( + space="my-project", + include_closed=True, + ) + + assert "claude-1" in result + mock_client.resolve_space.assert_called_once_with("my-project") + mock_client.list_worktrees.assert_called_once_with( + "space-uuid", + include_closed=True, + ) + + +def test_mcp_worktree_show_calls_client(mock_client): + mock_client.get_worktree.return_value = _worktree_dict() + + result = mcp_server.smriti_worktree_show("wt-uuid") + + assert "smriti/claude-1/abc12345" in result + mock_client.get_worktree.assert_called_once_with("wt-uuid") + + +def test_mcp_worktree_close_calls_client(mock_client): + mock_client.close_worktree.return_value = _worktree_dict(status="closed") + + result = mcp_server.smriti_worktree_close("wt-uuid", force=True) + + assert "Closed worktree" in result + mock_client.close_worktree.assert_called_once_with("wt-uuid", force=True)