mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Add canonical project root for spaces
This commit is contained in:
parent
3cb514b279
commit
8c963198e1
16 changed files with 652 additions and 45 deletions
22
DECISIONS.md
22
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
|
||||
|
|
|
|||
|
|
@ -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** | |
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
43
backend/tests/integration/test_project_root_migration.py
Normal file
43
backend/tests/integration/test_project_root_migration.py
Normal 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
|
||||
73
backend/tests/integration/test_repos_project_root.py
Normal file
73
backend/tests/integration/test_repos_project_root.py
Normal 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()
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
197
cli/tests/test_space_cli.py
Normal 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))
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue