Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions hindsight-integrations/claude-code/scripts/lib/bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,40 +29,77 @@
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: <repo>/.git → "repo"
- bare repo: <path>/repo.git → "repo"
- submodule: <super>/.git/modules/<name> → "<name>"
"""
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 <super>/.git/modules/<name>, 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.

When resolveWorktrees is enabled (default), detects git worktrees and
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 <repo>/.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"

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)
Expand Down
69 changes: 65 additions & 4 deletions hindsight-integrations/claude-code/tests/test_bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <super>/.git/modules/<name>; 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 <repo>/.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 <ancestor>` 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."""
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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")
Expand Down