Show local repo drift in state output

This commit is contained in:
Himanshu Dongre 2026-05-19 22:43:46 +05:30
parent c65e913666
commit 14e99a4616
3 changed files with 302 additions and 0 deletions

View file

@ -410,6 +410,7 @@ def format_state_brief(
compact: bool = False,
stats: bool = False,
space_state: dict | None = None,
repo_state: dict | None = None,
) -> str:
"""A continuation-oriented markdown brief for the current project state.
@ -498,6 +499,7 @@ def format_state_brief(
parts.append(
_format_divergence_signal_section(space_state.get("divergence"))
)
parts.append(_format_repo_state_section(repo_state))
result = "\n".join(p for p in parts if p).rstrip() + "\n"
@ -513,6 +515,57 @@ def format_state_brief(
return result
def _format_repo_state_section(repo_state: dict | None) -> str:
if not repo_state:
return ""
branch = repo_state.get("branch")
detached = bool(repo_state.get("detached"))
branch_label = "detached HEAD" if detached else (branch or "unknown")
head = repo_state.get("head_short")
if not head:
raw_head = repo_state.get("head")
head = _short_hash(raw_head) if raw_head else "unknown"
root = _pretty_path(repo_state.get("git_root")) or "unknown"
status_bits = [
f"branch `{branch_label}`",
f"HEAD `{head}`",
f"root `{root}`",
]
if repo_state.get("upstream_known"):
ahead = int(repo_state.get("ahead") or 0)
behind = int(repo_state.get("behind") or 0)
status_bits.append(f"upstream +{ahead}/-{behind}")
else:
status_bits.append("upstream unknown")
lines = ["## Repo state", " · ".join(status_bits)]
root_match = repo_state.get("project_root_matches")
if root_match is True:
lines.append("- project root: matches this space")
elif root_match is False:
project_root = _pretty_path(repo_state.get("project_root")) or "unknown"
lines.append(f"- project root: differs from this space (`{project_root}`)")
signals = repo_state.get("signals") or []
if signals:
lines.append("### Attention")
for signal in signals:
if isinstance(signal, dict):
severity = signal.get("severity") or "attention"
message = signal.get("message") or signal.get("kind") or str(signal)
lines.append(f"- [{severity}] {message}")
else:
lines.append(f"- {signal}")
else:
lines.append("- clean: no local repo drift signals")
return "\n".join(lines) + "\n"
def _direction_text(value) -> str:
"""Normalize current_direction to a readable paragraph.

View file

@ -162,6 +162,122 @@ def _git_context(cwd: str | os.PathLike[str] | None = None) -> dict:
}
def _git_porcelain_count(cwd: str | os.PathLike[str] | None, prefix: str) -> int:
out = _git_output_at(cwd, "status", "--porcelain")
if not out:
return 0
return sum(1 for line in out.splitlines() if line.startswith(prefix))
def _git_dirty_count(cwd: str | os.PathLike[str] | None) -> int:
out = _git_output_at(cwd, "status", "--porcelain")
if not out:
return 0
return sum(1 for line in out.splitlines() if not line.startswith("??"))
def _git_ahead_behind(cwd: str | os.PathLike[str] | None) -> tuple[int | None, int | None]:
out = _git_output_at(cwd, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
if not out:
return None, None
parts = out.split()
if len(parts) != 2:
return None, None
try:
# With "@{upstream}...HEAD", the left side is commits only on upstream
# (local is behind) and the right side is commits only on HEAD (local is ahead).
behind = int(parts[0])
ahead = int(parts[1])
except ValueError:
return None, None
return ahead, behind
def _paths_same(a: str | None, b: str | None) -> bool | None:
if not a or not b:
return None
try:
return Path(a).expanduser().resolve() == Path(b).expanduser().resolve()
except OSError:
return False
def _build_repo_state(space: dict) -> 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.
"""
info = _git_context()
git_root = info.get("git_root")
if not git_root:
return None
ahead, behind = _git_ahead_behind(git_root)
branch = info.get("branch")
detached = branch == "HEAD"
canonical_root = space.get("project_root")
root_matches = _paths_same(git_root, canonical_root)
dirty = _git_dirty_count(git_root)
untracked = _git_porcelain_count(git_root, "??")
signals: list[dict[str, str]] = []
if root_matches is False:
signals.append({
"kind": "project_root_mismatch",
"message": "current git root differs from this space's project_root",
"severity": "attention",
})
if dirty:
signals.append({
"kind": "dirty_worktree",
"message": f"{dirty} tracked file(s) have uncommitted changes",
"severity": "attention",
})
if untracked:
signals.append({
"kind": "untracked_files",
"message": f"{untracked} untracked file(s) present",
"severity": "attention",
})
if detached:
signals.append({
"kind": "detached_head",
"message": "repository is on a detached HEAD",
"severity": "attention",
})
if behind:
signals.append({
"kind": "behind_upstream",
"message": f"local branch is {behind} commit(s) behind upstream",
"severity": "attention",
})
if ahead:
signals.append({
"kind": "ahead_upstream",
"message": f"local branch is {ahead} commit(s) ahead of upstream",
"severity": "info",
})
return {
"git_root": git_root,
"branch": None if detached else branch,
"detached": detached,
"head": info.get("git_sha"),
"head_short": info.get("git_sha_short"),
"dirty_files": dirty,
"untracked_files": untracked,
"ahead": ahead,
"behind": behind,
"upstream_known": ahead is not None and behind is not None,
"project_root": canonical_root,
"project_root_matches": root_matches,
"signals": signals,
}
def _is_smriti_source_root(path: str | None) -> bool:
if not path:
return False
@ -707,8 +823,11 @@ 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)
if args.json:
payload = {"space": space, "head": head, "commit": commit}
if repo_state is not None:
payload["repo_state"] = repo_state
if space_state is not None:
payload["active_branches"] = space_state["active_branches"]
payload["active_claims"] = space_state["active_claims"]
@ -722,6 +841,7 @@ def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None:
compact=compact,
stats=show_stats,
space_state=space_state,
repo_state=repo_state,
),
end="",
)

View file

@ -17,10 +17,12 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from smriti_cli import main as cli_main
from smriti_cli import mcp_server
from smriti_cli.formatters import (
_format_active_branches_section,
_format_divergence_signal_section,
_format_repo_state_section,
_relative_time,
_task_section,
_normalize_task_item,
@ -219,6 +221,133 @@ def test_format_active_branches_empty_returns_empty():
assert _format_active_branches_section([]) == ""
def test_format_repo_state_section_clean_repo():
out = _format_repo_state_section(
{
"git_root": "/tmp/project",
"branch": "main",
"detached": False,
"head_short": "abc1234",
"dirty_files": 0,
"untracked_files": 0,
"ahead": 0,
"behind": 0,
"upstream_known": True,
"project_root": "/tmp/project",
"project_root_matches": True,
"signals": [],
}
)
assert "## Repo state" in out
assert "branch `main`" in out
assert "HEAD `abc1234`" in out
assert "upstream +0/-0" in out
assert "project root: matches this space" in out
assert "clean: no local repo drift signals" in out
def test_format_repo_state_section_attention_signals():
out = _format_repo_state_section(
{
"git_root": "/tmp/other",
"branch": None,
"detached": True,
"head_short": "def5678",
"dirty_files": 2,
"untracked_files": 1,
"ahead": 3,
"behind": 4,
"upstream_known": True,
"project_root": "/tmp/project",
"project_root_matches": False,
"signals": [
{
"severity": "attention",
"message": "current git root differs from this space's project_root",
},
{
"severity": "attention",
"message": "2 tracked file(s) have uncommitted changes",
},
],
}
)
assert "branch `detached HEAD`" in out
assert "upstream +3/-4" in out
assert "project root: differs from this space" in out
assert "### Attention" in out
assert "current git root differs" in out
assert "uncommitted changes" in out
def test_format_state_brief_appends_repo_state():
out = format_state_brief(
_base_space(),
_base_head(),
_base_commit(),
repo_state={
"git_root": "/tmp/project",
"branch": "main",
"detached": False,
"head_short": "abc1234",
"upstream_known": False,
"project_root_matches": None,
"signals": [],
},
)
assert "## Repo state" in out
assert "upstream unknown" in out
def test_build_repo_state_flags_practical_drift(monkeypatch):
monkeypatch.setattr(
cli_main,
"_git_context",
lambda cwd=None: {
"git_root": "/tmp/actual",
"git_sha": "abcdef123456",
"git_sha_short": "abcdef1",
"branch": "feature",
},
)
monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (2, 1))
monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 3)
monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 1)
repo_state = cli_main._build_repo_state({"project_root": "/tmp/expected"})
assert repo_state is not None
assert repo_state["git_root"] == "/tmp/actual"
assert repo_state["ahead"] == 2
assert repo_state["behind"] == 1
assert repo_state["dirty_files"] == 3
assert repo_state["untracked_files"] == 1
kinds = {signal["kind"] for signal in repo_state["signals"]}
assert "project_root_mismatch" in kinds
assert "dirty_worktree" in kinds
assert "untracked_files" in kinds
assert "behind_upstream" in kinds
assert "ahead_upstream" in kinds
def test_build_repo_state_returns_none_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._build_repo_state({"project_root": "/tmp/expected"}) is None
def test_format_active_branches_multiple_branches_one_line_each():
branches = [
{