Coverage for astrocyte/harness/project.py: 94%
34 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""One memory bank per project, derived identically everywhere.
3The hooks (which receive the session's ``cwd``) and the MCP server (launched
4with the session's cwd as its own — measured on Claude Code 2.1) must land in
5the *same* bank, or automatic capture and the agent's own ``memory_retain``
6calls never meet. Both call :func:`project_bank` on that directory.
8Identity prefers the git ``origin`` URL, so every clone and worktree of a repo
9shares one memory; without a remote it falls back to the repo root path. A
10directory outside any repo is its own project.
11"""
13from __future__ import annotations
15import hashlib
16import os
17import re
18import subprocess
19from pathlib import Path
21_SLUG = re.compile(r"[^a-z0-9._-]+")
24def _git(cwd: str, *args: str) -> str | None:
25 try:
26 out = subprocess.run(
27 ["git", "-C", cwd, *args], capture_output=True, text=True, timeout=2, check=False
28 )
29 except (OSError, subprocess.SubprocessError):
30 return None
31 return out.stdout.strip() or None if out.returncode == 0 else None
34def _normalise_remote(url: str) -> str:
35 """``git@github.com:Org/Repo.git`` and ``https://github.com/org/repo`` → ``github.com/org/repo``."""
36 u = url.strip().lower()
37 u = re.sub(r"^[a-z+]+://", "", u)
38 u = re.sub(r"^[^@/]+@", "", u) # user@ in ssh / https-with-credentials
39 u = u.replace(":", "/", 1) if "/" not in u.split(":", 1)[0] else u
40 return u.rstrip("/").removesuffix(".git").rstrip("/")
43def project_root(cwd: str) -> Path:
44 root = _git(cwd, "rev-parse", "--show-toplevel")
45 return Path(root) if root else Path(cwd).resolve()
48def project_bank(cwd: str | None = None) -> str:
49 cwd = cwd or os.getcwd()
50 root = project_root(cwd)
51 remote = _git(str(root), "config", "--get", "remote.origin.url")
52 if remote:
53 identity = _normalise_remote(remote)
54 name = identity.rsplit("/", 1)[-1]
55 else:
56 identity = str(root)
57 name = root.name
58 slug = _SLUG.sub("-", name.lower()).strip("-.")[:40] or "project"
59 digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:8]
60 return f"project:{slug}-{digest}"