diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index e29d58f..1ab0dfa 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -282,6 +282,11 @@ class ManualCommitRequest(BaseModel): artifacts: list[dict] = Field(default_factory=list) author_agent: Optional[str] = None project_root: Optional[str] = None + # Git provenance the CLI captures at checkpoint time: {head, head_short, + # branch}. Lets repo-state drift detection compare the working repo + # against where it was when this checkpoint was recorded. Empty for + # clients that cannot inspect a repo (e.g. the MCP server). + repo_state: dict = Field(default_factory=dict) class CommitResponse(BaseModel): @@ -301,6 +306,7 @@ class CommitResponse(BaseModel): open_questions: list entities: list artifacts: list + context_blob: dict = Field(default_factory=dict) created_at: datetime model_config = {"from_attributes": True} @@ -782,6 +788,10 @@ def manual_commit(payload: ManualCommitRequest, db: Session = Depends(get_db)): commit_hash = _generate_commit_hash(str(repo_id), payload.message) + # Git provenance for repo-state drift detection. Namespaced under + # "repo_state" so context_blob stays open for other uses. + context_blob = {"repo_state": payload.repo_state} if payload.repo_state else {} + commit = CommitModel( repo_id=repo_id, commit_hash=commit_hash, @@ -805,6 +815,7 @@ def manual_commit(payload: ManualCommitRequest, db: Session = Depends(get_db)): entities=payload.entities, artifacts=payload.artifacts, metadata_={"session_id": str(session_id)}, + context_blob=context_blob, ) db.add(commit) repo.updated_at = _utcnow() diff --git a/backend/tests/integration/test_api_v4_chat.py b/backend/tests/integration/test_api_v4_chat.py index 5c4410e..b03d51f 100644 --- a/backend/tests/integration/test_api_v4_chat.py +++ b/backend/tests/integration/test_api_v4_chat.py @@ -288,6 +288,50 @@ def test_manual_commit_author_agent_falls_back_to_session_provider(client): assert payload["project_root"] is None +def test_manual_commit_records_repo_state(client): + """Git provenance passed as repo_state is stored in the commit's + context_blob and survives the V4 state round trip — the path + `smriti state` reads for repo-state drift detection.""" + repo_id = _create_repo(client, "Repo State Round Trip") + s = client.post(f"/api/v4/chat/spaces/{repo_id}/sessions", json={ + "provider": "openrouter", "model": "mock", + }) + session_id = s.json()["id"] + + repo_state = {"head": "abc123def456", "head_short": "abc123d", "branch": "main"} + commit_r = client.post("/api/v4/chat/commit", json={ + "repo_id": repo_id, + "session_id": session_id, + "message": "Checkpoint with git provenance", + "repo_state": repo_state, + }) + assert commit_r.status_code == 201, commit_r.text + assert commit_r.json()["context_blob"] == {"repo_state": repo_state} + + # The V4 state endpoint feeds `smriti state` — repo_state must survive it. + state_r = client.get(f"/api/v4/chat/spaces/{repo_id}/state") + assert state_r.status_code == 200, state_r.text + assert state_r.json()["commit"]["context_blob"]["repo_state"] == repo_state + + +def test_manual_commit_without_repo_state_has_empty_context_blob(client): + """A commit created without git provenance carries an empty context_blob; + drift detection treats this as 'no recorded git state'.""" + repo_id = _create_repo(client, "No Repo State") + s = client.post(f"/api/v4/chat/spaces/{repo_id}/sessions", json={ + "provider": "openrouter", "model": "mock", + }) + session_id = s.json()["id"] + + commit_r = client.post("/api/v4/chat/commit", json={ + "repo_id": repo_id, + "session_id": session_id, + "message": "No git provenance", + }) + assert commit_r.status_code == 201, commit_r.text + assert commit_r.json()["context_blob"] == {} + + def test_head_endpoint(client): repo_id = _create_repo(client, "Head Repo") diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index ea81bb1..41aa6c4 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -550,6 +550,26 @@ def _format_repo_state_section(repo_state: dict | None) -> str: project_root = _pretty_path(repo_state.get("project_root")) or "unknown" lines.append(f"- project root: differs from this space (`{project_root}`)") + checkpoint = repo_state.get("checkpoint") + if checkpoint: + relation = checkpoint.get("relation") + if relation == "ahead": + rel = f"repo is {checkpoint.get('ahead') or 0} commit(s) ahead" + elif relation == "behind": + rel = f"repo is {checkpoint.get('behind') or 0} commit(s) behind" + elif relation == "diverged": + rel = "repo history has diverged from it" + elif relation == "unknown": + rel = "checkpoint commit not found in this repo" + else: + rel = "repo unchanged since it" + ckpt_line = ( + f"- vs last checkpoint `{checkpoint.get('head_short') or 'unknown'}`: {rel}" + ) + if checkpoint.get("branch_changed") and checkpoint.get("branch"): + ckpt_line += f" — checkpoint on branch `{checkpoint['branch']}`" + lines.append(ckpt_line) + signals = repo_state.get("signals") or [] if signals: lines.append("### Attention") diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 833db20..eff1d31 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -162,6 +162,24 @@ def _git_context(cwd: str | os.PathLike[str] | None = None) -> dict: } +def _checkpoint_repo_state() -> dict: + """Git HEAD/branch of the current repo, recorded on a checkpoint so later + `smriti state` runs can detect how far the repo has drifted since it. + + Empty when not inside a git repo (or for clients with no repo to inspect). + """ + info = _git_context() + head = info.get("git_sha") + if not head: + return {} + branch = info.get("branch") + return { + "head": head, + "head_short": info.get("git_sha_short"), + "branch": None if branch == "HEAD" else branch, + } + + def _git_porcelain_count(cwd: str | os.PathLike[str] | None, prefix: str) -> int: out = _git_output_at(cwd, "status", "--porcelain") if not out: @@ -202,12 +220,112 @@ def _paths_same(a: str | None, b: str | None) -> bool | None: return False -def _build_repo_state(space: dict) -> dict | None: +def _git_rev_count(cwd: str | os.PathLike[str] | None, rev_range: str) -> int | None: + """Count commits in a local git range (e.g. "A..B"). None when the range + cannot be resolved — typically because one side is not in this repo.""" + out = _git_output_at(cwd, "rev-list", "--count", rev_range) + if out is None: + return None + try: + return int(out) + except ValueError: + return None + + +def _compare_to_checkpoint( + commit: dict | None, + git_root: str, + current_head: str | None, + current_branch: str | None, +) -> dict | None: + """Compare the working repo against the git HEAD/branch the latest + checkpoint recorded. + + Returns a render-ready dict plus drift signals, or None when the checkpoint + carries no recorded git state — checkpoints created before this feature, or + by the MCP server, have none. + """ + recorded = ((commit or {}).get("context_blob") or {}).get("repo_state") or {} + ckpt_head = recorded.get("head") + if not ckpt_head: + return None + + ckpt_branch = recorded.get("branch") + branch_changed = bool( + ckpt_branch and current_branch and ckpt_branch != current_branch + ) + + ahead: int | None = 0 + behind: int | None = 0 + if current_head and ckpt_head == current_head: + relation = "in_sync" + else: + ahead = _git_rev_count(git_root, f"{ckpt_head}..HEAD") + behind = _git_rev_count(git_root, f"HEAD..{ckpt_head}") + if ahead is None or behind is None: + relation = "unknown" # checkpoint commit not in this repo's history + elif ahead and behind: + relation = "diverged" + elif ahead: + relation = "ahead" + elif behind: + relation = "behind" + else: + relation = "in_sync" + + signals: list[dict[str, str]] = [] + if relation == "ahead": + signals.append({ + "kind": "ahead_of_checkpoint", + "message": ( + f"repo is {ahead} commit(s) ahead of the last checkpoint — " + "recorded state may be stale" + ), + "severity": "attention", + }) + elif relation == "diverged": + signals.append({ + "kind": "diverged_from_checkpoint", + "message": ( + "repo history has diverged from the last checkpoint — " + "recorded state may be stale" + ), + "severity": "attention", + }) + elif relation == "unknown": + signals.append({ + "kind": "checkpoint_commit_missing", + "message": "the last checkpoint's commit is not in this repo's history", + "severity": "attention", + }) + if branch_changed: + signals.append({ + "kind": "checkpoint_branch_changed", + "message": ( + f"the last checkpoint was taken on a different branch (`{ckpt_branch}`)" + ), + "severity": "attention", + }) + + return { + "head_short": recorded.get("head_short") or ckpt_head[:7], + "branch": ckpt_branch, + "relation": relation, + "ahead": ahead, + "behind": behind, + "branch_changed": branch_changed, + "signals": signals, + } + + +def _build_repo_state(space: dict, commit: dict | None = None) -> dict | None: """Inspect the caller's local git repo for state/drift rendering. This is intentionally read-only and cheap: no fetch, no network, no merge base search beyond local refs. Remote freshness is represented only by the current upstream ahead/behind counters already present in the local clone. + When the latest checkpoint recorded its git state, the result also carries + a checkpoint-relative comparison (see `_compare_to_checkpoint`). """ info = _git_context() git_root = info.get("git_root") @@ -261,6 +379,14 @@ def _build_repo_state(space: dict) -> dict | None: "severity": "info", }) + # Checkpoint-relative drift: how far the working repo has moved since the + # latest checkpoint recorded its git HEAD/branch. + checkpoint = _compare_to_checkpoint( + commit, git_root, info.get("git_sha"), None if detached else branch + ) + if checkpoint: + signals.extend(checkpoint["signals"]) + return { "git_root": git_root, "branch": None if detached else branch, @@ -274,6 +400,7 @@ def _build_repo_state(space: dict) -> dict | None: "upstream_known": ahead is not None and behind is not None, "project_root": canonical_root, "project_root_matches": root_matches, + "checkpoint": checkpoint, "signals": signals, } @@ -823,7 +950,7 @@ def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None: full_artifacts = not args.preview and not args.compact compact = args.compact show_stats = getattr(args, "stats", False) - repo_state = _build_repo_state(space) + repo_state = _build_repo_state(space, commit) if args.json: payload = {"space": space, "head": head, "commit": commit} if repo_state is not None: @@ -1119,6 +1246,13 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non if author_agent is not None: commit_payload["author_agent"] = author_agent + # Record local git HEAD/branch so later `smriti state` runs can detect + # how far the repo has drifted since this checkpoint. Best-effort: empty + # outside a git repo, which the backend stores as no repo_state. + repo_state = _checkpoint_repo_state() + if repo_state: + commit_payload["repo_state"] = repo_state + commit = client.create_chat_commit(commit_payload) if args.json: diff --git a/cli/tests/test_state_multi_branch.py b/cli/tests/test_state_multi_branch.py index 9bf3737..bdf401d 100644 --- a/cli/tests/test_state_multi_branch.py +++ b/cli/tests/test_state_multi_branch.py @@ -961,3 +961,185 @@ def _worktree_claim(dirty_files: int, dirty_paths: list[str]) -> dict: "base_commit_hash": "abc1234", "claimed_at": datetime.now(timezone.utc).isoformat(), } + + +# ── repo-state drift: checkpoint-relative comparison ────────────────────────── + + +def _commit_with_repo_state(head, head_short="", branch="main"): + """A head-checkpoint dict carrying recorded git provenance, shaped like + what the V4 state endpoint returns to `smriti state`.""" + return { + "context_blob": { + "repo_state": { + "head": head, + "head_short": head_short or head[:7], + "branch": branch, + } + } + } + + +def test_compare_to_checkpoint_none_when_unrecorded(): + # Checkpoints created before this feature, or by the MCP server, carry no + # recorded git state — the comparison is skipped, never faked. + assert cli_main._compare_to_checkpoint(None, "/repo", "abc123", "main") is None + assert cli_main._compare_to_checkpoint({}, "/repo", "abc123", "main") is None + assert cli_main._compare_to_checkpoint( + {"context_blob": {}}, "/repo", "abc123", "main" + ) is None + + +def test_compare_to_checkpoint_in_sync(): + commit = _commit_with_repo_state("abc123def", branch="main") + result = cli_main._compare_to_checkpoint(commit, "/repo", "abc123def", "main") + assert result["relation"] == "in_sync" + assert result["branch_changed"] is False + assert result["signals"] == [] + + +def test_compare_to_checkpoint_ahead_flags_stale(monkeypatch): + commit = _commit_with_repo_state("oldsha", branch="main") + # "oldsha..HEAD" -> 3 commits ahead; "HEAD..oldsha" -> 0 behind. + monkeypatch.setattr( + cli_main, "_git_rev_count", + lambda cwd, rng: 3 if rng.endswith("..HEAD") else 0, + ) + result = cli_main._compare_to_checkpoint(commit, "/repo", "newsha", "main") + assert result["relation"] == "ahead" + assert result["ahead"] == 3 + assert {s["kind"] for s in result["signals"]} == {"ahead_of_checkpoint"} + assert any("stale" in s["message"] for s in result["signals"]) + + +def test_compare_to_checkpoint_diverged(monkeypatch): + commit = _commit_with_repo_state("oldsha", branch="main") + monkeypatch.setattr(cli_main, "_git_rev_count", lambda cwd, rng: 2) + result = cli_main._compare_to_checkpoint(commit, "/repo", "newsha", "main") + assert result["relation"] == "diverged" + assert {s["kind"] for s in result["signals"]} == {"diverged_from_checkpoint"} + + +def test_compare_to_checkpoint_unknown_when_commit_missing(monkeypatch): + commit = _commit_with_repo_state("missingsha", branch="main") + monkeypatch.setattr(cli_main, "_git_rev_count", lambda cwd, rng: None) + result = cli_main._compare_to_checkpoint(commit, "/repo", "currentsha", "main") + assert result["relation"] == "unknown" + assert {s["kind"] for s in result["signals"]} == {"checkpoint_commit_missing"} + + +def test_compare_to_checkpoint_flags_branch_change(): + commit = _commit_with_repo_state("abc123", branch="main") + # Same HEAD, but the repo is now on a different branch than the checkpoint. + result = cli_main._compare_to_checkpoint(commit, "/repo", "abc123", "feature-x") + assert result["relation"] == "in_sync" + assert result["branch_changed"] is True + signal = next( + s for s in result["signals"] if s["kind"] == "checkpoint_branch_changed" + ) + assert "main" in signal["message"] + + +def test_build_repo_state_includes_checkpoint_comparison(monkeypatch): + monkeypatch.setattr( + cli_main, "_git_context", + lambda cwd=None: { + "git_root": "/repo", "git_sha": "newsha", + "git_sha_short": "newsha1", "branch": "main", + }, + ) + monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (0, 0)) + monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 0) + monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 0) + monkeypatch.setattr( + cli_main, "_git_rev_count", + lambda cwd, rng: 4 if rng.endswith("..HEAD") else 0, + ) + commit = _commit_with_repo_state("oldsha", branch="main") + repo_state = cli_main._build_repo_state({"project_root": "/repo"}, commit) + + assert repo_state is not None + assert repo_state["checkpoint"]["relation"] == "ahead" + assert repo_state["checkpoint"]["ahead"] == 4 + # The checkpoint drift signal is merged into the section's signal list. + assert any(s["kind"] == "ahead_of_checkpoint" for s in repo_state["signals"]) + + +def test_build_repo_state_checkpoint_none_without_commit(monkeypatch): + monkeypatch.setattr( + cli_main, "_git_context", + lambda cwd=None: { + "git_root": "/repo", "git_sha": "sha", + "git_sha_short": "sha1", "branch": "main", + }, + ) + monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (0, 0)) + monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 0) + monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 0) + repo_state = cli_main._build_repo_state({"project_root": "/repo"}) + assert repo_state["checkpoint"] is None + + +def test_format_repo_state_section_renders_checkpoint_line(): + out = _format_repo_state_section({ + "git_root": "/repo", "branch": "main", "detached": False, + "head_short": "newsha1", "upstream_known": False, + "project_root_matches": None, + "checkpoint": { + "head_short": "oldsha1", "branch": "main", "relation": "ahead", + "ahead": 3, "behind": 0, "branch_changed": False, "signals": [], + }, + "signals": [], + }) + assert "vs last checkpoint `oldsha1`" in out + assert "repo is 3 commit(s) ahead" in out + + +def test_format_repo_state_section_checkpoint_in_sync_and_branch_change(): + out = _format_repo_state_section({ + "git_root": "/repo", "branch": "feature", "detached": False, + "head_short": "sha1", "upstream_known": False, + "project_root_matches": None, + "checkpoint": { + "head_short": "sha1", "branch": "main", "relation": "in_sync", + "ahead": 0, "behind": 0, "branch_changed": True, "signals": [], + }, + "signals": [], + }) + assert "repo unchanged since it" in out + assert "checkpoint on branch `main`" in out + + +def test_checkpoint_repo_state_captures_head_and_branch(monkeypatch): + monkeypatch.setattr( + cli_main, "_git_context", + lambda cwd=None: { + "git_root": "/repo", "git_sha": "fullsha123", + "git_sha_short": "fullsha", "branch": "main", + }, + ) + assert cli_main._checkpoint_repo_state() == { + "head": "fullsha123", "head_short": "fullsha", "branch": "main", + } + + +def test_checkpoint_repo_state_empty_outside_git(monkeypatch): + monkeypatch.setattr( + cli_main, "_git_context", + lambda cwd=None: { + "git_root": None, "git_sha": None, + "git_sha_short": None, "branch": None, + }, + ) + assert cli_main._checkpoint_repo_state() == {} + + +def test_checkpoint_repo_state_detached_head_records_no_branch(monkeypatch): + monkeypatch.setattr( + cli_main, "_git_context", + lambda cwd=None: { + "git_root": "/repo", "git_sha": "sha", + "git_sha_short": "sha1", "branch": "HEAD", + }, + ) + assert cli_main._checkpoint_repo_state()["branch"] is None