From 59397e8de320323e05d88947b0f6c70a8cc5bf45 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Mon, 1 Jun 2026 19:15:11 -0700 Subject: [PATCH] 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) --- .claude/scripts/recover_session.py | 74 +++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/.claude/scripts/recover_session.py b/.claude/scripts/recover_session.py index 0e1fdde..047c218 100644 --- a/.claude/scripts/recover_session.py +++ b/.claude/scripts/recover_session.py @@ -132,6 +132,71 @@ def _identity() -> dict: 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: """Compute ``~/.claude/projects/`` from identity's claudetools_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 if msp_dir.exists(): 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: base = root / "session-logs"