diff --git a/hindsight-integrations/claude-code/scripts/lib/bank.py b/hindsight-integrations/claude-code/scripts/lib/bank.py index 91e7b48415..bdd219faba 100644 --- a/hindsight-integrations/claude-code/scripts/lib/bank.py +++ b/hindsight-integrations/claude-code/scripts/lib/bank.py @@ -29,6 +29,43 @@ VALID_FIELDS = {"agent", "project", "session", "channel", "user"} +def _project_name_from_git(cwd: str): + """Ask git for the repository name at ``cwd``. + + Returns the repository basename, or None when ``cwd`` cannot be + resolved (not inside a repo, directory missing, git unavailable). + + The name is derived from --git-common-dir so that all worktrees of + the same repo share one name: + - regular repo / linked worktree: /.git → "repo" + - bare repo: /repo.git → "repo" + - submodule: /.git/modules/ → "" + """ + try: + result = subprocess.run( + ["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + + git_common_dir = result.stdout.strip() + name = os.path.basename(git_common_dir) + if name == ".git": + # e.g. /home/user/myproject/.git → parent is /home/user/myproject + return os.path.basename(os.path.dirname(git_common_dir)) + if name.endswith(".git"): + # bare repo gitdir, e.g. /srv/git/myproject.git + return name[: -len(".git")] + # submodule gitdir lives under /.git/modules/, so the + # common dir's own basename is the submodule name + return name + + def _resolve_project_name(cwd: str, config: dict) -> str: """Resolve the project name from the working directory. @@ -36,11 +73,11 @@ def _resolve_project_name(cwd: str, config: dict) -> str: resolves to the main repository basename so that all worktrees of the same repo share the same bank. - For a regular repo at /home/user/myproject: - git-common-dir → /home/user/myproject/.git → basename "myproject" - - For a worktree at /home/user/myproject-wt1 linked to /home/user/myproject: - git-common-dir → /home/user/myproject/.git → basename "myproject" + If ``cwd`` no longer exists on disk — e.g. an ephemeral subagent + worktree under /.claude/worktrees/ that was cleaned up before an + async hook ran — resolution is retried from the nearest existing + ancestor so the project still maps to the containing repository + instead of the generated leaf basename. """ if not cwd: return "unknown" @@ -48,21 +85,21 @@ def _resolve_project_name(cwd: str, config: dict) -> str: if not config.get("resolveWorktrees", True): return os.path.basename(cwd) - try: - result = subprocess.run( - ["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - git_common_dir = result.stdout.strip() - # git-common-dir returns the .git directory of the main repo - # e.g. /home/user/myproject/.git → parent is /home/user/myproject - main_repo_path = os.path.dirname(git_common_dir) - return os.path.basename(main_repo_path) - except (OSError, subprocess.TimeoutExpired): - pass + name = _project_name_from_git(cwd) + if name is not None: + return name + + if not os.path.exists(cwd): + ancestor = os.path.dirname(cwd) + while ancestor and not os.path.isdir(ancestor): + parent = os.path.dirname(ancestor) + if parent == ancestor: + break + ancestor = parent + if ancestor and os.path.isdir(ancestor): + name = _project_name_from_git(ancestor) + if name is not None: + return name # Fallback: not a git repo or git not available return os.path.basename(cwd) diff --git a/hindsight-integrations/claude-code/tests/test_bank.py b/hindsight-integrations/claude-code/tests/test_bank.py index 4c2b167c71..3fc305e577 100644 --- a/hindsight-integrations/claude-code/tests/test_bank.py +++ b/hindsight-integrations/claude-code/tests/test_bank.py @@ -163,6 +163,64 @@ def test_git_timeout(self, mock_run): def test_empty_cwd(self): assert _resolve_project_name("", _cfg()) == "unknown" + @patch("lib.bank.subprocess.run") + def test_submodule_resolves_to_submodule_name(self, mock_run): + # Submodule gitdir lives under /.git/modules/; the old + # dirname+basename parse returned the meaningless "modules" + mock_run.return_value = self._mock_git("/home/user/super/.git/modules/libfoo\n") + assert _resolve_project_name("/home/user/super/libfoo", _cfg()) == "libfoo" + + @patch("lib.bank.subprocess.run") + def test_nested_submodule_resolves_to_leaf_name(self, mock_run): + mock_run.return_value = self._mock_git("/home/user/super/.git/modules/libfoo/modules/inner\n") + assert _resolve_project_name("/home/user/super/libfoo/inner", _cfg()) == "inner" + + @patch("lib.bank.subprocess.run") + def test_bare_repo_gitdir_strips_suffix(self, mock_run): + mock_run.return_value = self._mock_git("/srv/git/myproject.git\n") + assert _resolve_project_name("/srv/checkouts/wt1", _cfg()) == "myproject" + + +class TestDeletedCwdResolution: + """Regression tests for cwds that no longer exist on disk (#3096). + + The async Stop retain hook can fire after an isolated Claude Code + subagent worktree under /.claude/worktrees/ has been cleaned up. + The resolver must still map the deleted path to the containing + repository, not to the generated leaf basename (e.g. "agent-deadbeef"). + + These tests run real git against tmp_path instead of mocking, so the + upward-discovery semantics of `git -C ` are exercised. + """ + + def _init_repo(self, path): + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "-q", str(path)], check=True) + + def test_deleted_worktree_resolves_to_parent_repo(self, tmp_path): + repo = tmp_path / "myproject" + self._init_repo(repo) + dead = repo / ".claude" / "worktrees" / "agent-deadbeef" + assert _resolve_project_name(str(dead), _cfg()) == "myproject" + + def test_deleted_path_outside_any_repo_falls_back_to_basename(self, tmp_path): + dead = tmp_path / "plaindir" / "gone" + assert _resolve_project_name(str(dead), _cfg()) == "gone" + + def test_existing_non_git_dir_keeps_basename(self, tmp_path): + # Ancestor retry is gated on the cwd being deleted; a live non-git + # directory keeps the current basename behavior + plain = tmp_path / "plaindir" + plain.mkdir() + assert _resolve_project_name(str(plain), _cfg()) == "plaindir" + + def test_derive_bank_id_deleted_worktree(self, tmp_path): + repo = tmp_path / "myproject" + self._init_repo(repo) + cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"]) + dead = repo / ".claude" / "worktrees" / "agent-deadbeef" + assert derive_bank_id({"cwd": str(dead), "session_id": "s"}, cfg) == "myproject" + class TestDirectoryBankMap: """Tests for explicit directory-to-bank mapping.""" @@ -245,10 +303,12 @@ def test_empty_cwd_skips_map(self): assert result == "fallback" def test_multiple_entries(self): - cfg = _cfg(directoryBankMap={ - "/home/user/project-a": "bank-a", - "/home/user/project-b": "bank-b", - }) + cfg = _cfg( + directoryBankMap={ + "/home/user/project-a": "bank-a", + "/home/user/project-b": "bank-b", + } + ) assert derive_bank_id(_hook(cwd="/home/user/project-a"), cfg) == "bank-a" assert derive_bank_id(_hook(cwd="/home/user/project-b"), cfg) == "bank-b" @@ -300,6 +360,7 @@ def test_different_banks_each_set_once(self, state_dir): @pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported") def test_directorybankmap_matches_symlinked_cwd(self, tmp_path): import os + real = os.path.realpath(tmp_path / "proj") os.makedirs(real) link = str(tmp_path / "proj-link")