Detect repo drift against the last checkpoint

The repo-state section added by the previous commit shows working-tree
and git-upstream drift. It does not answer the question Smriti's trust
story actually needs: has the repo moved since the last checkpoint?

Record git provenance on checkpoints and compare against it. The CLI
captures the git HEAD and branch when it creates a checkpoint; the V4
commit endpoint stores that under the commit's context_blob and returns
it on read. `smriti state` then compares the working repo against the
latest checkpoint's recorded HEAD/branch and surfaces:
  - repo unchanged since the last checkpoint
  - repo is N commit(s) ahead — recorded state may be stale
  - repo history has diverged from the last checkpoint
  - the last checkpoint was taken on a different branch

All local and read-only: no fetch, no reconciliation. Checkpoints made
before this feature (or by the MCP server) record no git state and are
simply left uncompared.
This commit is contained in:
Himanshu Dongre 2026-05-19 23:22:22 +05:30
parent 14e99a4616
commit 3b6ef000dc
5 changed files with 393 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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