fix(recovery): never write recovered logs into a git submodule

compute_output_path now parses .gitmodules and, for a project scope whose
dir is a submodule (guru-rmm, guru-connect, youtube-sync-docker), falls
back to the MAIN repo root session-logs/ per convention. Non-submodule
projects (gururmm-agent, dataforth-dos) unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:15:11 -07:00
parent aa9bd26df8
commit 59397e8de3

View File

@@ -132,6 +132,71 @@ def _identity() -> dict:
return {} return {}
# Cache for parsed .gitmodules paths (keyed by repo root path string).
_SUBMODULE_PATHS_CACHE: dict[str, frozenset[str]] = {}
# Matches `path = projects/foo` lines in .gitmodules (leading whitespace, any
# spacing around `=`). The captured value is the repo-relative submodule path.
_GITMODULES_PATH_RE = re.compile(r"^\s*path\s*=\s*(.+?)\s*$", re.MULTILINE)
def _submodule_paths() -> frozenset[str]:
"""Return the set of repo-relative submodule paths from ``.gitmodules``.
Parses the ``path = ...`` lines via a simple line scan (no git calls).
Paths are normalized to forward slashes with no trailing slash so they can
be compared against forward-slashed, repo-relative work paths. Robust to a
missing ``.gitmodules`` (returns an empty set -> no submodules). Cached per
repo root for the life of the process.
"""
root = repo_root()
key = str(root)
cached = _SUBMODULE_PATHS_CACHE.get(key)
if cached is not None:
return cached
paths: set[str] = set()
gitmodules = root / ".gitmodules"
try:
text = gitmodules.read_text(encoding="utf-8")
except OSError:
text = ""
for m in _GITMODULES_PATH_RE.finditer(text):
rel = m.group(1).strip().replace("\\", "/").rstrip("/")
if rel:
paths.add(rel)
result = frozenset(paths)
_SUBMODULE_PATHS_CACHE[key] = result
return result
def _is_inside_submodule(target_dir: Path) -> bool:
"""True if ``target_dir`` is at or under any submodule path.
Comparison is done on repo-relative, forward-slashed path components so a
submodule ``projects/msp-tools/guru-rmm`` matches that directory and
anything beneath it (e.g. its ``session-logs/``), but does NOT match a
sibling like ``projects/msp-tools/guru-rmm-extra``.
"""
subs = _submodule_paths()
if not subs:
return False
root = repo_root()
try:
rel = target_dir.resolve().relative_to(root.resolve())
except (OSError, ValueError):
# target is not under the repo root (or cannot resolve) -> not a submodule.
return False
rel_parts = rel.parts
for sub in subs:
sub_parts = tuple(p for p in sub.split("/") if p)
if not sub_parts:
continue
if rel_parts[: len(sub_parts)] == sub_parts:
return True
return False
def transcript_base_dir() -> Path: def transcript_base_dir() -> Path:
"""Compute ``~/.claude/projects/<slug>`` from identity's claudetools_root.""" """Compute ``~/.claude/projects/<slug>`` from identity's claudetools_root."""
root = _identity().get("claudetools_root") or str(repo_root()) root = _identity().get("claudetools_root") or str(repo_root())
@@ -923,7 +988,14 @@ def compute_output_path(parsed: ParsedTranscript, scope: dict, title: str) -> Pa
msp_dir = root / "projects" / "msp-tools" / slug msp_dir = root / "projects" / "msp-tools" / slug
if msp_dir.exists(): if msp_dir.exists():
proj_dir = msp_dir proj_dir = msp_dir
base = proj_dir / "session-logs" # If the project dir is a git submodule (or inside one), its working
# tree must NOT be written to -- repo convention keeps those session
# logs in the MAIN repo root instead, and an unattended write would
# dirty the submodule. Fall back to the main root session-logs dir.
if _is_inside_submodule(proj_dir):
base = root / "session-logs"
else:
base = proj_dir / "session-logs"
else: else:
base = root / "session-logs" base = root / "session-logs"