mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
14e99a4616
commit
3b6ef000dc
5 changed files with 393 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue