diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index 33b4d3a..cb59585 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -763,6 +763,8 @@ def format_doctor(report: dict) -> str: """Readable diagnostics for backend/runtime consistency.""" backend = report.get("backend") or {} local = report.get("local") or {} + source = report.get("source") or local + cwd = report.get("cwd") or {} cli = report.get("cli") or {} checks = report.get("checks") or {} hints = report.get("hints") or [] @@ -819,14 +821,36 @@ def format_doctor(report: dict) -> str: parts.append("PATH matches executable: unknown") parts.append("") - parts.append("## Local repo") - parts.append(f"Branch: `{local.get('branch') or 'unknown'}`") - parts.append(f"HEAD: `{local.get('git_sha_short') or local.get('git_sha') or 'unknown'}`") + parts.append("## Smriti source") + if source.get("git_root"): + parts.append(f"Repo: `{source.get('git_root')}`") + parts.append(f"Branch: `{source.get('branch') or 'unknown'}`") + source_head = source.get("git_sha_short") or source.get("git_sha") or "unknown" + parts.append(f"HEAD: `{source_head}`") + else: + parts.append("Repo: not detected (installed package or non-git install)") + parts.append("HEAD: not available") + parts.append("") + + parts.append("## Current directory") + parts.append(f"Path: `{cwd.get('path') or 'unknown'}`") + if cwd.get("git_root"): + parts.append(f"Git repo: `{cwd.get('git_root')}`") + parts.append(f"Branch: `{cwd.get('branch') or 'unknown'}`") + cwd_head = cwd.get("git_sha_short") or cwd.get("git_sha") or "unknown" + parts.append(f"HEAD: `{cwd_head}`") + if source.get("git_root") and cwd.get("git_root") != source.get("git_root"): + parts.append( + "Runtime match is checked against the Smriti source above, " + "not this project repo." + ) + else: + parts.append("Git repo: none detected") parts.append("") parts.append("## Checks") runtime_match = checks.get("runtime_match") or "unknown" - parts.append(f"- runtime match: {runtime_match}") + parts.append(f"- runtime match: {str(runtime_match).replace('_', ' ')}") missing = checks.get("missing_capabilities") if missing is None: parts.append("- missing capabilities: unknown (backend unreachable)") diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index ef2c33a..ec05803 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -123,11 +123,14 @@ EXPECTED_HEALTH_CAPABILITIES = { } -def _git_output(*args: str) -> str | None: +def _git_output_at(cwd: str | os.PathLike[str] | None, *args: str) -> str | None: """Return trimmed git output for diagnostics, or None outside a git repo.""" + cmd = ["git", *args] + if cwd is not None: + cmd = ["git", "-C", str(cwd), *args] try: result = subprocess.run( - ["git", *args], + cmd, check=True, capture_output=True, text=True, @@ -137,6 +140,65 @@ def _git_output(*args: str) -> str | None: return result.stdout.strip() or None +def _git_output(*args: str) -> str | None: + """Return trimmed git output for the current directory.""" + return _git_output_at(None, *args) + + +def _git_context(cwd: str | os.PathLike[str] | None = None) -> dict: + root = _git_output_at(cwd, "rev-parse", "--show-toplevel") + sha = _git_output_at(cwd, "rev-parse", "HEAD") + short = _git_output_at(cwd, "rev-parse", "--short", "HEAD") + branch = ( + _git_output_at(cwd, "branch", "--show-current") + or _git_output_at(cwd, "rev-parse", "--abbrev-ref", "HEAD") + ) + return { + "git_root": root, + "git_sha": sha, + "git_sha_short": short, + "branch": branch, + } + + +def _is_smriti_source_root(path: str | None) -> bool: + if not path: + return False + root = Path(path) + return ( + (root / "cli" / "smriti_cli" / "main.py").exists() + and (root / "backend" / "app" / "main.py").exists() + ) + + +def _build_cwd_info() -> dict: + info = _git_context() + info["path"] = os.getcwd() + info["is_smriti_source"] = _is_smriti_source_root(info.get("git_root")) + return info + + +def _build_smriti_source_info() -> dict: + """Detect the Smriti source checkout backing this CLI, if available. + + `smriti doctor` is often run from a user's project repo. Runtime freshness + must compare the backend to Smriti's own source checkout, not the caller's + app repo. Editable installs have `__file__` inside the Smriti checkout; a + wheel/global install may not, in which case the git comparison is simply + unavailable rather than a mismatch. + """ + module_dir = Path(__file__).resolve().parent + info = _git_context(module_dir) + info["path"] = str(module_dir) + info["is_smriti_source"] = _is_smriti_source_root(info.get("git_root")) + if not info["is_smriti_source"]: + info["git_root"] = None + info["git_sha"] = None + info["git_sha_short"] = None + info["branch"] = None + return info + + def _git_sha_matches(backend_sha: str | None, local_sha: str | None) -> bool | None: if not backend_sha or not local_sha: return None @@ -268,12 +330,11 @@ def _background_provider_check(providers: dict) -> str: def _build_doctor_report(client: SmritiClient) -> dict: """Build a small diagnostics report without attempting repairs.""" - local_sha = _git_output("rev-parse", "HEAD") - local_branch = ( - _git_output("branch", "--show-current") - or _git_output("rev-parse", "--abbrev-ref", "HEAD") - ) - local_short = _git_output("rev-parse", "--short", "HEAD") + source_info = _build_smriti_source_info() + cwd_info = _build_cwd_info() + if not source_info.get("git_sha") and cwd_info.get("is_smriti_source"): + source_info = dict(cwd_info) + source_sha = source_info.get("git_sha") report = { "api_url": client.base_url, @@ -284,11 +345,11 @@ def _build_doctor_report(client: SmritiClient) -> dict: "capabilities": [], "error": None, }, - "local": { - "git_sha": local_sha, - "git_sha_short": local_short, - "branch": local_branch, - }, + # Backward-compatible alias: historically "local" meant cwd. It now + # means the local Smriti source/install used for runtime comparison. + "local": source_info, + "source": source_info, + "cwd": cwd_info, "cli": _build_cli_info(), "checks": { "runtime_match": "unknown", @@ -322,7 +383,7 @@ def _build_doctor_report(client: SmritiClient) -> dict: database = health.get("database") or {} providers = health.get("providers") or {} missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities)) - match = _git_sha_matches(backend_sha, local_sha) + match = _git_sha_matches(backend_sha, source_sha) report["backend"].update({ "reachable": True, @@ -354,14 +415,16 @@ def _build_doctor_report(client: SmritiClient) -> dict: elif match is False: report["checks"]["runtime_match"] = "mismatch" report["hints"].append( - "Backend git_sha differs from local HEAD; restart the backend " - "after syncing code, or check out the commit the backend is running." + "Backend git_sha differs from the local Smriti source HEAD; " + "restart the backend after syncing code, or check out the commit " + "the backend is running." ) else: - report["checks"]["runtime_match"] = "unknown" - report["hints"].append( - "Could not compare backend git_sha with local HEAD." - ) + report["checks"]["runtime_match"] = "not_applicable" + if source_info.get("git_root"): + report["hints"].append( + "Could not compare backend git_sha with the local Smriti source checkout." + ) if health.get("status") != "ok": report["hints"].append( diff --git a/cli/tests/test_doctor_cli.py b/cli/tests/test_doctor_cli.py index 9b02479..0a4527a 100644 --- a/cli/tests/test_doctor_cli.py +++ b/cli/tests/test_doctor_cli.py @@ -17,15 +17,43 @@ def _client(health: dict | None = None) -> MagicMock: return client -def _patch_git(monkeypatch: pytest.MonkeyPatch, *, sha: str = "c470947abcdef", branch: str = "main") -> None: +def _patch_git( + monkeypatch: pytest.MonkeyPatch, + *, + sha: str = "c470947abcdef", + branch: str = "main", + cwd_sha: str | None = None, + cwd_branch: str = "main", + source_detected: bool = True, +) -> None: short = sha[:7] - values = { - ("rev-parse", "HEAD"): sha, - ("rev-parse", "--short", "HEAD"): short, - ("branch", "--show-current"): branch, - ("rev-parse", "--abbrev-ref", "HEAD"): branch, - } - monkeypatch.setattr(cli_main, "_git_output", lambda *args: values.get(args)) + cwd_sha = cwd_sha if cwd_sha is not None else sha + cwd_short = cwd_sha[:7] if cwd_sha else None + + monkeypatch.setattr( + cli_main, + "_build_smriti_source_info", + lambda: { + "path": "/repo/cli/smriti_cli", + "git_root": "/repo" if source_detected else None, + "git_sha": sha if source_detected else None, + "git_sha_short": short if source_detected else None, + "branch": branch if source_detected else None, + "is_smriti_source": source_detected, + }, + ) + monkeypatch.setattr( + cli_main, + "_build_cwd_info", + lambda: { + "path": "/work/project", + "git_root": "/work/project" if cwd_sha else None, + "git_sha": cwd_sha, + "git_sha_short": cwd_short, + "branch": cwd_branch if cwd_sha else None, + "is_smriti_source": False, + }, + ) def _patch_cli( @@ -110,10 +138,84 @@ def test_doctor_report_flags_mismatch_and_missing_capability( assert report["checks"]["runtime_match"] == "mismatch" assert report["checks"]["missing_capabilities"] == ["worktree_binding"] - assert any("differs from local HEAD" in hint for hint in report["hints"]) + assert any( + "differs from the local Smriti source HEAD" in hint + for hint in report["hints"] + ) assert any("missing capabilities" in hint for hint in report["hints"]) +def test_doctor_report_ignores_external_project_git_head( + monkeypatch: pytest.MonkeyPatch, +): + _patch_git(monkeypatch, sha="c470947abcdef", cwd_sha="deadbeefeedface") + _patch_cli(monkeypatch) + client = _client(_health(git_sha="c470947")) + + report = cli_main._build_doctor_report(client) + + assert report["checks"]["runtime_match"] == "ok" + assert report["source"]["git_sha_short"] == "c470947" + assert report["cwd"]["git_sha_short"] == "deadbee" + assert not any( + "differs from the local Smriti source HEAD" in hint + for hint in report["hints"] + ) + + +def test_doctor_report_does_not_mismatch_without_source_repo( + monkeypatch: pytest.MonkeyPatch, +): + _patch_git( + monkeypatch, + sha="ignored", + cwd_sha="deadbeefeedface", + source_detected=False, + ) + _patch_cli(monkeypatch) + client = _client(_health(git_sha="c470947")) + + report = cli_main._build_doctor_report(client) + + assert report["checks"]["runtime_match"] == "not_applicable" + assert report["source"]["git_sha"] is None + assert report["cwd"]["git_sha_short"] == "deadbee" + assert not any( + "differs from the local Smriti source HEAD" in hint + for hint in report["hints"] + ) + + +def test_doctor_report_uses_cwd_when_it_is_smriti_source( + monkeypatch: pytest.MonkeyPatch, +): + _patch_git( + monkeypatch, + sha="ignored", + cwd_sha="c470947abcdef", + source_detected=False, + ) + monkeypatch.setattr( + cli_main, + "_build_cwd_info", + lambda: { + "path": "/repo", + "git_root": "/repo", + "git_sha": "c470947abcdef", + "git_sha_short": "c470947", + "branch": "main", + "is_smriti_source": True, + }, + ) + _patch_cli(monkeypatch) + client = _client(_health(git_sha="c470947")) + + report = cli_main._build_doctor_report(client) + + assert report["checks"]["runtime_match"] == "ok" + assert report["source"]["git_root"] == "/repo" + + def test_doctor_report_flags_cli_path_mismatch(monkeypatch: pytest.MonkeyPatch): _patch_git(monkeypatch) _patch_cli( @@ -205,3 +307,5 @@ def test_cmd_doctor_prints_activation_details( assert "Background intelligence: ready (`openai` / `gpt-4o-mini`)" in out assert "## CLI" in out assert "PATH matches executable: yes" in out + assert "## Smriti source" in out + assert "## Current directory" in out