mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Show local repo drift in state output
This commit is contained in:
parent
c65e913666
commit
14e99a4616
3 changed files with 302 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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="",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue