diff --git a/DECISIONS.md b/DECISIONS.md index e2ea35e..a7edf77 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -559,6 +559,28 @@ without reducing risk. Making `worktree_id` optional lets Smriti surface filesystem isolation when contention risk exists while preserving the lightweight claim path for work that does not need a separate git index. +### Why canonical project_root lives on RepoModel, lazily backfilled + +`CommitModel.project_root` remains per-checkpoint provenance: it records where +the agent was standing when a checkpoint was written. That is useful context, +but it is a poor operational anchor for worktrees because "latest checkpoint +wins" makes the chosen checkout depend on whichever agent wrote state most +recently. + +The canonical worktree anchor belongs on `RepoModel` because it is a property +of the space as a project, not of an individual chat session, checkpoint, or +worktree. Sessions and checkpoints can be created from multiple clones; worktree +rows represent outputs created from an anchor, not the source of truth for the +next anchor. + +Lazy backfill is deliberately preferred over a one-shot migration. Existing +spaces already have useful checkpoint-level roots, but the database cannot know +which clone a human wants as canonical without touching the filesystem and +making an intent guess. On first worktree open, Smriti resolves the known +checkpoint root, validates it at the point of use, and writes the resolved path +back to `repos.project_root`. Spaces therefore self-canonicalize through normal +use while preserving explicit override via `smriti space set-project-root`. + --- ## Open questions and deferred decisions diff --git a/REPO_STRUCTURE.md b/REPO_STRUCTURE.md index 91a489e..7185d34 100644 --- a/REPO_STRUCTURE.md +++ b/REPO_STRUCTURE.md @@ -62,14 +62,16 @@ smriti/ │ ├── config/ │ │ ├── providers.example.yaml Template — copy to providers.yaml │ │ └── providers.yaml Your keys (gitignored, not committed) -│ ├── alembic/ Database migrations (14 versions) +│ ├── alembic/ Database migrations (15 versions) │ ├── tests/ -│ │ ├── integration/ API integration tests (124 tests) +│ │ ├── integration/ API integration tests (134 tests) │ │ │ ├── test_api_v4_chat.py │ │ │ ├── test_api_v5_lineage.py │ │ │ ├── test_multi_branch_state.py │ │ │ ├── test_claims.py │ │ │ ├── test_claim_worktree_binding.py +│ │ │ ├── test_project_root_migration.py +│ │ │ ├── test_repos_project_root.py │ │ │ ├── test_worktrees.py │ │ │ ├── test_checkpoint_extract.py │ │ │ └── test_delete_endpoints.py @@ -116,12 +118,13 @@ smriti/ │ │ ├── template.md Single source of truth (v2.1, 15 sections) │ │ ├── renderer.py Pure-function render + versioned install │ │ └── targets.py Target configs (claude-code, codex) -│ └── tests/ CLI + MCP tests (129 tests) +│ └── tests/ CLI + MCP tests (141 tests) │ ├── test_branch_close.py │ ├── test_init.py │ ├── test_mcp_server.py │ ├── test_skill_pack.py │ ├── test_smoke.py +│ ├── test_space_cli.py │ ├── test_state_multi_branch.py │ ├── test_worktree_cli.py │ └── test_worktree_mcp.py @@ -164,11 +167,11 @@ make migration Create a new migration (usage: make migration msg="...") --- -## Test counts (as of V3 dirty paths) +## Test counts (as of V4 project_root canonicalization) | Suite | Count | Location | |---|---|---| -| Backend integration | 124 | `backend/tests/integration/` | +| Backend integration | 134 | `backend/tests/integration/` | | Backend unit | 129 | `backend/tests/unit/` | -| CLI + MCP | 129 | `cli/tests/` | -| **Total** | **382** | | +| CLI + MCP | 141 | `cli/tests/` | +| **Total** | **404** | | diff --git a/backend/alembic/versions/2026_05_04_0300_d1e2f3a4b5c6_add_project_root_to_repos.py b/backend/alembic/versions/2026_05_04_0300_d1e2f3a4b5c6_add_project_root_to_repos.py new file mode 100644 index 0000000..d623cf4 --- /dev/null +++ b/backend/alembic/versions/2026_05_04_0300_d1e2f3a4b5c6_add_project_root_to_repos.py @@ -0,0 +1,27 @@ +"""Add project_root to repos. + +Revision ID: d1e2f3a4b5c6 +Revises: c0db5e90f1a2 +Create Date: 2026-05-04 03:00:00.000000 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d1e2f3a4b5c6" +down_revision = "c0db5e90f1a2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "repos", + sa.Column("project_root", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("repos", "project_root") diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 5dbd0ba..f7eb9a2 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -331,6 +331,7 @@ class SpaceBrief(BaseModel): id: uuid.UUID name: str description: Optional[str] = None + project_root: Optional[str] = None class ActiveBranchSummary(BaseModel): @@ -1111,6 +1112,7 @@ def get_space_state( id=repo.id, name=repo.name, description=repo.description, + project_root=repo.project_root, ), head=head_resp, commit=commit_resp, diff --git a/backend/app/api/routes/repos.py b/backend/app/api/routes/repos.py index 37f7f9a..d120bc1 100644 --- a/backend/app/api/routes/repos.py +++ b/backend/app/api/routes/repos.py @@ -1,8 +1,6 @@ +import logging import uuid from datetime import datetime -from typing import Annotated - -from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, Field @@ -10,30 +8,43 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.db.database import get_db -from app.db.models import RepoModel, CommitModel +from app.db.models import CommitModel, RepoModel router = APIRouter(prefix="/repos", tags=["repos"]) DEMO_USER_ID = uuid.UUID("00000000-0000-0000-0000-00000000000a") +logger = logging.getLogger("uvicorn.error") + class RepoCreate(BaseModel): name: str = Field(..., description="Name of the repo/project") description: str = Field("", description="Optional description") + project_root: str | None = Field( + None, + description="Canonical project checkout path for worktree operations", + ) user_id: str | None = Field(None, description="Optional user ID, defaults to demo user") metadata_: dict = Field(default_factory=dict, alias="metadata") + +class SetProjectRootRequest(BaseModel): + project_root: str + + class RepoResponse(BaseModel): id: uuid.UUID repo_slug: str | None name: str description: str + project_root: str | None user_id: uuid.UUID metadata_: dict = Field(serialization_alias="metadata") created_at: datetime updated_at: datetime - + model_config = {"from_attributes": True} + class CommitResponse(BaseModel): id: uuid.UUID repo_id: uuid.UUID @@ -59,8 +70,6 @@ class CommitResponse(BaseModel): model_config = {"from_attributes": True} -import logging -logger = logging.getLogger("uvicorn.error") @router.post("", response_model=RepoResponse, status_code=201) def create_repo(payload: RepoCreate, db: Session = Depends(get_db)): @@ -70,7 +79,8 @@ def create_repo(payload: RepoCreate, db: Session = Depends(get_db)): user_id=uuid.UUID(payload.user_id) if payload.user_id else DEMO_USER_ID, name=payload.name, description=payload.description, - metadata_=payload.metadata_ + project_root=payload.project_root, + metadata_=payload.metadata_, ) logger.info("before DB call: add") db.add(new_repo) @@ -81,12 +91,36 @@ def create_repo(payload: RepoCreate, db: Session = Depends(get_db)): logger.info("after DB call / before response return") return new_repo + @router.get("", response_model=list[RepoResponse]) def list_repos(db: Session = Depends(get_db)): """List all repos for the current user.""" - stmt = select(RepoModel).where(RepoModel.user_id == DEMO_USER_ID).order_by(RepoModel.updated_at.desc()) + stmt = ( + select(RepoModel) + .where(RepoModel.user_id == DEMO_USER_ID) + .order_by(RepoModel.updated_at.desc()) + ) return db.scalars(stmt).all() + +@router.patch("/{repo_id}/project-root", response_model=RepoResponse) +def set_project_root( + repo_id: uuid.UUID, + payload: SetProjectRootRequest, + db: Session = Depends(get_db), +): + """Set the canonical project_root for a space.""" + repo = db.get(RepoModel, repo_id) + if not repo or repo.user_id != DEMO_USER_ID: + raise HTTPException(status_code=404, detail="Space not found") + if not payload.project_root.strip(): + raise HTTPException(status_code=400, detail="project_root cannot be empty") + repo.project_root = payload.project_root + db.commit() + db.refresh(repo) + return repo + + @router.get("/{repo_id}", response_model=RepoResponse) def get_repo(repo_id: uuid.UUID, db: Session = Depends(get_db)): """Get a specific repo.""" @@ -95,10 +129,11 @@ def get_repo(repo_id: uuid.UUID, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Repo not found") return repo + @router.get("/{repo_id}/commits", response_model=list[CommitResponse]) def list_repo_commits( repo_id: uuid.UUID, - branch: Optional[str] = Query(None, description="Filter by branch name"), + branch: str | None = Query(None, description="Filter by branch name"), db: Session = Depends(get_db), ): """List commits for a repo. Optionally filter by branch name.""" @@ -112,23 +147,27 @@ def list_repo_commits( stmt = stmt.order_by(CommitModel.created_at.desc()) return db.scalars(stmt).all() + @router.get("/{repo_id}/commits/latest", response_model=CommitResponse) def get_latest_commit(repo_id: uuid.UUID, branch: str = "main", db: Session = Depends(get_db)): """Get the latest commit for a repo (and optional branch).""" repo = db.get(RepoModel, repo_id) if not repo or repo.user_id != DEMO_USER_ID: raise HTTPException(status_code=404, detail="Repo not found") - - stmt = select(CommitModel).where( - CommitModel.repo_id == repo_id, - CommitModel.branch_name == branch - ).order_by(CommitModel.created_at.desc()).limit(1) - + + stmt = ( + select(CommitModel) + .where(CommitModel.repo_id == repo_id, CommitModel.branch_name == branch) + .order_by(CommitModel.created_at.desc()) + .limit(1) + ) + commit = db.scalars(stmt).first() if not commit: raise HTTPException(status_code=404, detail="No commits found for this repo/branch") return commit + @router.delete("/{repo_id}", status_code=204) def delete_repo(repo_id: uuid.UUID, db: Session = Depends(get_db)) -> Response: """Delete a space and cascade to all its commits, sessions, and turns.""" diff --git a/backend/app/api/routes/worktrees.py b/backend/app/api/routes/worktrees.py index 886844e..7604cc0 100644 --- a/backend/app/api/routes/worktrees.py +++ b/backend/app/api/routes/worktrees.py @@ -91,11 +91,30 @@ def _get_repo(space_id: uuid.UUID, db: Session) -> RepoModel: return repo -def _latest_project_root(space_id: uuid.UUID, db: Session) -> Path: +def _validate_project_root(project_root: str) -> Path: + 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 _resolve_project_root(repo: RepoModel, db: Session) -> Path: + """Resolve the worktree anchor path. + + Prefer the space-level canonical root. For existing spaces without one, + fall back to the latest checkpoint root and lazily backfill the canonical + field so future worktree opens are no longer agent-activity-dependent. + """ + if repo.project_root: + return _validate_project_root(repo.project_root) + stmt = ( select(CommitModel.project_root) .where( - CommitModel.repo_id == space_id, + CommitModel.repo_id == repo.id, CommitModel.project_root.is_not(None), CommitModel.project_root != "", ) @@ -107,17 +126,16 @@ def _latest_project_root(space_id: uuid.UUID, db: Session) -> Path: raise HTTPException( status_code=400, detail=( - "No checkpoint with project_root found for this space; " - "create a checkpoint from the project checkout first." + "No project_root configured for this space. Set it via " + "`smriti space set-project-root ` or create a " + "checkpoint with project_root set." ), ) - 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}", - ) + resolved = _validate_project_root(project_root) + repo.project_root = str(resolved) + db.commit() + db.refresh(repo) return resolved @@ -213,7 +231,7 @@ def create_worktree( if not agent: raise HTTPException(status_code=400, detail="agent must be non-empty") - project_root = _latest_project_root(space_id, db) + project_root = _resolve_project_root(repo, db) suffix = uuid.uuid4().hex[:8] branch_name = (payload.branch_name or "").strip() or _default_branch_name(agent, suffix) target_path = _resolve_target_path( @@ -324,7 +342,8 @@ def close_worktree( detail=f"Worktree has invalid status: {worktree.status}", ) - project_root = _latest_project_root(worktree.repo_id, db) + repo = _get_repo(worktree.repo_id, db) + project_root = _resolve_project_root(repo, db) worktree_path = Path(worktree.path).expanduser().resolve() if not force: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 98100b1..4ba8935 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -133,6 +133,7 @@ class RepoModel(Base): repo_slug: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(Text, default="") + project_root: Mapped[str | None] = mapped_column(Text, nullable=True) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), nullable=False, index=True ) diff --git a/backend/tests/integration/test_multi_branch_state.py b/backend/tests/integration/test_multi_branch_state.py index 8b790b5..1d0f3ff 100644 --- a/backend/tests/integration/test_multi_branch_state.py +++ b/backend/tests/integration/test_multi_branch_state.py @@ -18,8 +18,15 @@ from __future__ import annotations # ── Helpers (match the shape used in test_api_v5_lineage.py) ───────────────── -def _create_repo(client, name: str = "Multi-Branch State Test"): - r = client.post("/api/v2/repos", json={"name": name}) +def _create_repo( + client, + name: str = "Multi-Branch State Test", + project_root: str | None = None, +): + payload = {"name": name} + if project_root is not None: + payload["project_root"] = project_root + r = client.post("/api/v2/repos", json=payload) assert r.status_code == 201, r.text return r.json()["id"] @@ -85,6 +92,18 @@ def test_state_no_checkpoints(client): assert state["divergence"] is None +def test_state_space_header_includes_project_root(client): + repo_id = _create_repo( + client, + "Rooted State", + project_root="/tmp/rooted-state", + ) + + state = _get_state(client, repo_id) + + assert state["space"]["project_root"] == "/tmp/rooted-state" + + def test_state_main_only(client): """Single main checkpoint, no forks: main HEAD present, no extensions.""" repo_id = _create_repo(client, "Main Only") diff --git a/backend/tests/integration/test_project_root_migration.py b/backend/tests/integration/test_project_root_migration.py new file mode 100644 index 0000000..254146f --- /dev/null +++ b/backend/tests/integration/test_project_root_migration.py @@ -0,0 +1,43 @@ +"""SQLite-first regression test for the repos.project_root migration.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, inspect, text + + +def _load_migration(): + path = ( + Path(__file__).parents[2] + / "alembic" + / "versions" + / "2026_05_04_0300_d1e2f3a4b5c6_add_project_root_to_repos.py" + ) + spec = importlib.util.spec_from_file_location("project_root_migration", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_project_root_migration_upgrade_and_downgrade_on_sqlite(monkeypatch): + migration = _load_migration() + engine = create_engine("sqlite://") + + with engine.begin() as conn: + conn.execute(text("CREATE TABLE repos (id VARCHAR PRIMARY KEY)")) + context = MigrationContext.configure(conn) + operations = Operations(context) + monkeypatch.setattr(migration, "op", operations) + + migration.upgrade() + columns = {c["name"]: c for c in inspect(conn).get_columns("repos")} + assert columns["project_root"]["nullable"] is True + + migration.downgrade() + columns = {c["name"] for c in inspect(conn).get_columns("repos")} + assert "project_root" not in columns diff --git a/backend/tests/integration/test_repos_project_root.py b/backend/tests/integration/test_repos_project_root.py new file mode 100644 index 0000000..2ac8963 --- /dev/null +++ b/backend/tests/integration/test_repos_project_root.py @@ -0,0 +1,73 @@ +"""Regression coverage for canonical project_root on spaces.""" + +from __future__ import annotations + +import uuid + + +def test_create_repo_with_project_root(client): + r = client.post( + "/api/v2/repos", + json={"name": "Rooted Repo", "project_root": "/tmp/rooted-repo"}, + ) + + assert r.status_code == 201, r.text + assert r.json()["project_root"] == "/tmp/rooted-repo" + + +def test_create_repo_without_project_root(client): + r = client.post("/api/v2/repos", json={"name": "No Root Repo"}) + + assert r.status_code == 201, r.text + assert r.json()["project_root"] is None + + +def test_set_project_root_endpoint(client): + created = client.post("/api/v2/repos", json={"name": "Patch Root"}).json() + + r = client.patch( + f"/api/v2/repos/{created['id']}/project-root", + json={"project_root": "/tmp/patched-root"}, + ) + + assert r.status_code == 200, r.text + assert r.json()["project_root"] == "/tmp/patched-root" + + +def test_set_project_root_empty_rejected(client): + created = client.post("/api/v2/repos", json={"name": "Empty Root"}).json() + + r = client.patch( + f"/api/v2/repos/{created['id']}/project-root", + json={"project_root": " "}, + ) + + assert r.status_code == 400 + assert "project_root cannot be empty" in r.json()["detail"] + + +def test_set_project_root_nonexistent_repo(client): + r = client.patch( + f"/api/v2/repos/{uuid.uuid4()}/project-root", + json={"project_root": "/tmp/missing"}, + ) + + assert r.status_code == 404 + assert "Space not found" in r.json()["detail"] + + +def test_repo_responses_include_project_root(client): + created = client.post( + "/api/v2/repos", + json={"name": "Response Root", "project_root": "/tmp/response-root"}, + ).json() + + fetched = client.get(f"/api/v2/repos/{created['id']}") + listed = client.get("/api/v2/repos") + + assert fetched.status_code == 200, fetched.text + assert fetched.json()["project_root"] == "/tmp/response-root" + assert any( + repo["id"] == created["id"] and repo["project_root"] == "/tmp/response-root" + for repo in listed.json() + ) diff --git a/backend/tests/integration/test_worktrees.py b/backend/tests/integration/test_worktrees.py index 13b7fc2..eaaab22 100644 --- a/backend/tests/integration/test_worktrees.py +++ b/backend/tests/integration/test_worktrees.py @@ -29,8 +29,11 @@ def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProc return result -def _create_repo(client, name="Worktree Test Repo"): - r = client.post("/api/v2/repos", json={"name": name}) +def _create_repo(client, name="Worktree Test Repo", project_root: str | None = None): + payload = {"name": name} + if project_root is not None: + payload["project_root"] = project_root + r = client.post("/api/v2/repos", json=payload) assert r.status_code == 201, r.text return r.json()["id"] @@ -144,6 +147,62 @@ def test_create_worktree_existing_branch_rejected_without_row_or_directory( assert client.get(f"/api/v5/worktrees?space_id={space_id}").json() == [] +def test_create_worktree_uses_canonical_project_root(client, tmp_path): + canonical_repo = _init_git_repo(tmp_path, name="canonical-project") + fallback_repo = _init_git_repo(tmp_path, name="fallback-project") + repo_id = _create_repo( + client, + name="Canonical Root Repo", + project_root=str(canonical_repo), + ) + session_id = _create_session(client, repo_id) + _commit_with_root(client, repo_id, session_id, fallback_repo) + target = tmp_path / "canonical-worktree" + + r = _create_worktree( + client, + repo_id, + branch_name="feature/canonical-root", + base_path=str(target), + ) + + assert r.status_code == 201, r.text + assert target.exists() + assert ( + _git(canonical_repo, "show-ref", "--verify", "refs/heads/feature/canonical-root") + .returncode + == 0 + ) + assert ( + _git( + fallback_repo, + "show-ref", + "--verify", + "refs/heads/feature/canonical-root", + check=False, + ).returncode + != 0 + ) + + +def test_create_worktree_lazy_backfills_project_root_from_commit(client, tmp_path): + git_repo = _init_git_repo(tmp_path) + repo_id = _create_project_with_root(client, git_repo) + assert client.get(f"/api/v2/repos/{repo_id}").json()["project_root"] is None + + r = _create_worktree( + client, + repo_id, + branch_name="feature/lazy-backfill", + base_path=str(tmp_path / "lazy-backfill-worktree"), + ) + + assert r.status_code == 201, r.text + assert client.get(f"/api/v2/repos/{repo_id}").json()["project_root"] == str( + git_repo.resolve() + ) + + 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) @@ -284,6 +343,7 @@ def test_create_worktree_requires_project_root(client, tmp_path): assert r.status_code == 400 assert "project_root" in r.json()["detail"] + assert "set-project-root" in r.json()["detail"] assert not target.exists() @@ -316,8 +376,8 @@ def test_git_infrastructure_error_does_not_write_row_or_leave_target( assert client.get(f"/api/v5/worktrees?space_id={space_id}").json() == [] -def _init_git_repo(tmp_path: Path) -> Path: - repo = tmp_path / "project" +def _init_git_repo(tmp_path: Path, name: str = "project") -> Path: + repo = tmp_path / name repo.mkdir() _git(repo, "init") _git(repo, "config", "user.email", "test@example.com") diff --git a/cli/smriti_cli/client.py b/cli/smriti_cli/client.py index 9d32dbf..a4ceff7 100644 --- a/cli/smriti_cli/client.py +++ b/cli/smriti_cli/client.py @@ -86,11 +86,26 @@ class SmritiClient: def get_space(self, space_id: str) -> dict: return self._request("GET", f"/api/v2/repos/{space_id}") - def create_space(self, name: str, description: str = "") -> dict: + def create_space( + self, + name: str, + description: str = "", + project_root: str | None = None, + ) -> dict: + payload = {"name": name, "description": description} + if project_root is not None: + payload["project_root"] = project_root return self._request( "POST", "/api/v2/repos", - json={"name": name, "description": description}, + json=payload, + ) + + def set_project_root(self, space_id: str, path: str) -> dict: + return self._request( + "PATCH", + f"/api/v2/repos/{space_id}/project-root", + json={"project_root": path}, ) def delete_space(self, space_id: str) -> None: diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index e1d04e7..f711d19 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -404,6 +404,9 @@ def format_state_brief( parts.append(f"# {space.get('name', 'Untitled space')}\n") if space.get("description"): parts.append(space["description"].rstrip() + "\n") + canonical_project_root = _pretty_path(space.get("project_root")) + if canonical_project_root: + parts.append(f"Project root: {canonical_project_root}\n") commit_hash = commit.get("commit_hash") created_at = commit.get("created_at") or head.get("commit_hash") or "" diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 4b7d527..ceb7981 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -3,7 +3,8 @@ Commands for agent and programmatic use: smriti space list - smriti space create [--description] + smriti space create [--description] [--project-root ] [--no-project-root] + smriti space set-project-root smriti space delete [-y] smriti state [--preview] smriti fork [--branch ] @@ -161,11 +162,39 @@ def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_space_create(client: SmritiClient, args: argparse.Namespace) -> None: - space = client.create_space(name=args.name, description=args.description or "") + if args.no_project_root: + project_root: str | None = None + elif args.project_root: + project_root = args.project_root + else: + project_root = os.getcwd() + + space = client.create_space( + name=args.name, + description=args.description or "", + project_root=project_root, + ) if args.json: _print_json(space) else: print(f"Created space: {space['name']} `{space['id']}`") + if project_root is not None: + print(f"Project root: {project_root}") + + +def cmd_space_set_project_root(client: SmritiClient, args: argparse.Namespace) -> None: + space = client.resolve_space(args.space) + if args.here or args.path in {".", "--here"}: + path = os.getcwd() + elif args.path: + path = args.path + else: + _fail("error: path is required unless --here is passed") + updated = client.set_project_root(space["id"], path) + if args.json: + _print_json(updated) + else: + print(f"Set project_root for '{updated['name']}' to {updated['project_root']}") def cmd_space_delete(client: SmritiClient, args: argparse.Namespace) -> None: @@ -262,6 +291,8 @@ def _print_no_checkpoints(space: dict, args: argparse.Namespace) -> None: print(f"# {space.get('name', 'Untitled space')}") if space.get("description"): print(space["description"]) + if space.get("project_root"): + print(f"Project root: {space['project_root']}") print() print("No checkpoints yet. Create one with `smriti checkpoint create`.") @@ -966,9 +997,40 @@ def _build_parser() -> argparse.ArgumentParser: sp_create = space_sub.add_parser("create", help="Create a new space") sp_create.add_argument("name", help="Space name") sp_create.add_argument("--description", help="Optional description", default="") + root_group = sp_create.add_mutually_exclusive_group() + root_group.add_argument( + "--project-root", + help="Canonical project checkout path for worktree operations " + "(default: current working directory)", + ) + root_group.add_argument( + "--no-project-root", + action="store_true", + help="Leave the space without a canonical project_root", + ) sp_create.add_argument("--json", action="store_true", help="Output structured JSON") sp_create.set_defaults(func=cmd_space_create) + sp_set_project_root = space_sub.add_parser( + "set-project-root", + help="Set a space's canonical project_root", + ) + sp_set_project_root.add_argument("space", help="Space name or UUID") + sp_set_project_root.add_argument( + "path", + nargs="?", + help="Project checkout path. Use '.' for the current directory.", + ) + sp_set_project_root.add_argument( + "--here", + action="store_true", + help="Set project_root to the current directory.", + ) + sp_set_project_root.add_argument( + "--json", action="store_true", help="Output structured JSON" + ) + sp_set_project_root.set_defaults(func=cmd_space_set_project_root) + sp_delete = space_sub.add_parser( "delete", help="Delete a space and all its checkpoints, sessions, and turns", diff --git a/cli/tests/test_space_cli.py b/cli/tests/test_space_cli.py new file mode 100644 index 0000000..14a1774 --- /dev/null +++ b/cli/tests/test_space_cli.py @@ -0,0 +1,197 @@ +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 _space_dict(**overrides): + base = { + "id": "space-uuid", + "name": "my-project", + "description": "", + "project_root": "/tmp/project", + } + base.update(overrides) + return base + + +def test_space_create_parser_wiring(): + parser = cli_main._build_parser() + + args = parser.parse_args( + [ + "space", + "create", + "my-project", + "--description", + "Test", + "--project-root", + "/tmp/project", + ] + ) + + assert args.command == "space" + assert args.subcommand == "create" + assert args.name == "my-project" + assert args.description == "Test" + assert args.project_root == "/tmp/project" + assert args.no_project_root is False + assert args.func is cli_main.cmd_space_create + + +def test_space_create_parser_supports_no_project_root(): + parser = cli_main._build_parser() + + args = parser.parse_args(["space", "create", "my-project", "--no-project-root"]) + + assert args.project_root is None + assert args.no_project_root is True + + +def test_space_set_project_root_parser_wiring(): + parser = cli_main._build_parser() + + args = parser.parse_args( + ["space", "set-project-root", "my-project", "/tmp/project"] + ) + + assert args.command == "space" + assert args.subcommand == "set-project-root" + assert args.space == "my-project" + assert args.path == "/tmp/project" + assert args.here is False + assert args.func is cli_main.cmd_space_set_project_root + + +def test_space_set_project_root_parser_supports_here_flag(): + parser = cli_main._build_parser() + + args = parser.parse_args(["space", "set-project-root", "my-project", "--here"]) + + assert args.path is None + assert args.here is True + + +def test_cmd_space_create_defaults_project_root_to_cwd( + tmp_path, + monkeypatch, + capsys: pytest.CaptureFixture[str], +): + monkeypatch.chdir(tmp_path) + client = MagicMock(spec=SmritiClient) + client.create_space.return_value = _space_dict(project_root=str(tmp_path)) + args = argparse.Namespace( + name="my-project", + description="", + project_root=None, + no_project_root=False, + json=False, + ) + + cli_main.cmd_space_create(client, args) + + out = capsys.readouterr().out + assert "Created space: my-project" in out + assert f"Project root: {tmp_path}" in out + client.create_space.assert_called_once_with( + name="my-project", + description="", + project_root=str(tmp_path), + ) + + +def test_cmd_space_create_passes_explicit_project_root(): + client = MagicMock(spec=SmritiClient) + client.create_space.return_value = _space_dict(project_root="/tmp/explicit") + args = argparse.Namespace( + name="my-project", + description="Test", + project_root="/tmp/explicit", + no_project_root=False, + json=False, + ) + + cli_main.cmd_space_create(client, args) + + client.create_space.assert_called_once_with( + name="my-project", + description="Test", + project_root="/tmp/explicit", + ) + + +def test_cmd_space_create_can_leave_project_root_null( + capsys: pytest.CaptureFixture[str], +): + client = MagicMock(spec=SmritiClient) + client.create_space.return_value = _space_dict(project_root=None) + args = argparse.Namespace( + name="my-project", + description="", + project_root=None, + no_project_root=True, + json=False, + ) + + cli_main.cmd_space_create(client, args) + + out = capsys.readouterr().out + assert "Project root:" not in out + client.create_space.assert_called_once_with( + name="my-project", + description="", + project_root=None, + ) + + +def test_cmd_space_set_project_root_calls_patch(capsys: pytest.CaptureFixture[str]): + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = _space_dict(id="space-uuid", name="my-project") + client.set_project_root.return_value = _space_dict( + id="space-uuid", + name="my-project", + project_root="/tmp/project", + ) + args = argparse.Namespace( + space="my-project", + path="/tmp/project", + here=False, + json=False, + ) + + cli_main.cmd_space_set_project_root(client, args) + + assert ( + capsys.readouterr().out == "Set project_root for 'my-project' to /tmp/project\n" + ) + client.resolve_space.assert_called_once_with("my-project") + client.set_project_root.assert_called_once_with("space-uuid", "/tmp/project") + + +def test_cmd_space_set_project_root_dot_resolves_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = _space_dict(id="space-uuid") + client.set_project_root.return_value = _space_dict(project_root=str(tmp_path)) + args = argparse.Namespace(space="my-project", path=".", here=False, json=False) + + cli_main.cmd_space_set_project_root(client, args) + + client.set_project_root.assert_called_once_with("space-uuid", str(tmp_path)) + + +def test_cmd_space_set_project_root_here_flag_resolves_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + client = MagicMock(spec=SmritiClient) + client.resolve_space.return_value = _space_dict(id="space-uuid") + client.set_project_root.return_value = _space_dict(project_root=str(tmp_path)) + args = argparse.Namespace(space="my-project", path=None, here=True, json=False) + + cli_main.cmd_space_set_project_root(client, args) + + client.set_project_root.assert_called_once_with("space-uuid", str(tmp_path)) diff --git a/cli/tests/test_state_multi_branch.py b/cli/tests/test_state_multi_branch.py index d15540b..511439d 100644 --- a/cli/tests/test_state_multi_branch.py +++ b/cli/tests/test_state_multi_branch.py @@ -104,6 +104,28 @@ def test_format_state_brief_empty_branches_and_no_divergence_elided(): assert "## Divergence signal" not in out +def test_format_state_brief_shows_canonical_project_root(): + space = _base_space() + space["project_root"] = "/tmp/canonical-project" + commit = _base_commit() + + out = format_state_brief(space, _base_head(), commit) + + assert "Project root: /tmp/canonical-project" in out + assert "Latest checkpoint:" in out + assert "at `/tmp/test-project`" in out + + +def test_format_state_brief_omits_project_root_line_when_null(): + space = _base_space() + space["project_root"] = None + + out = format_state_brief(space, _base_head(), _base_commit()) + + assert "Project root:" not in out + assert "at `/tmp/test-project`" in out + + def test_format_state_brief_active_branches_only(): """Non-empty active_branches → Active branches section appears, Divergence signal section absent."""