Add canonical project root for spaces

This commit is contained in:
Himanshu Dongre 2026-05-04 13:43:44 +05:30
parent 3cb514b279
commit 8c963198e1
16 changed files with 652 additions and 45 deletions

View file

@ -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

View file

@ -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** | |

View file

@ -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")

View file

@ -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,

View file

@ -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."""

View file

@ -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 <space> <path>` 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:

View file

@ -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
)

View file

@ -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")

View file

@ -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

View file

@ -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()
)

View file

@ -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")

View file

@ -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:

View file

@ -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 ""

View file

@ -3,7 +3,8 @@
Commands for agent and programmatic use:
smriti space list
smriti space create <name> [--description]
smriti space create <name> [--description] [--project-root <path>] [--no-project-root]
smriti space set-project-root <space> <path>
smriti space delete <space> [-y]
smriti state <space> [--preview]
smriti fork <checkpoint-id> [--branch <name>]
@ -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",

197
cli/tests/test_space_cli.py Normal file
View file

@ -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))

View file

@ -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."""