diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index 879d61c..ea81bb1 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -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. diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index f9a2054..833db20 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -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="", ) diff --git a/cli/tests/test_state_multi_branch.py b/cli/tests/test_state_multi_branch.py index 6cfb23a..9bf3737 100644 --- a/cli/tests/test_state_multi_branch.py +++ b/cli/tests/test_state_multi_branch.py @@ -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 = [ {