Skip to content
Open
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
48 changes: 48 additions & 0 deletions scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,48 @@ def project_name_from_cwd(cwd):
return "unknown"
# Normalize to forward slashes, take last 2 components
parts = cwd.replace("\\", "/").rstrip("/").split("/")
# Claude Code's per-session worktrees live at
# <project>/.claude/worktrees/<generated-name>/; attribute those sessions
# to the parent project instead of one ephemeral "project" per worktree.
for i in range(len(parts) - 1):
if parts[i] == ".claude" and parts[i + 1] == "worktrees":
parts = parts[:i]
if not any(parts):
return "unknown"
break
if len(parts) >= 2:
return "/".join(parts[-2:])
return parts[-1] if parts else "unknown"


def _backfill_worktree_project_names(conn):
"""One-time rename of worktree sessions scanned before worktree folding.

Sessions whose cwd was ``<project>/.claude/worktrees/<name>`` got project
names like ``worktrees/<name>``. Re-derive the name from the cwd already
stored on the session's turns — no transcript re-read, turns are left
untouched, so token totals cannot drift. Runs once, gated by a flag in
schema_meta (see scan()). Returns the number of sessions renamed.
"""
rows = conn.execute(
"SELECT s.session_id, s.project_name, MIN(t.cwd) AS cwd "
"FROM sessions s JOIN turns t ON t.session_id = s.session_id "
"WHERE t.cwd LIKE '%/.claude/worktrees/%' "
" OR t.cwd LIKE '%\\.claude\\worktrees\\%' "
"GROUP BY s.session_id"
).fetchall()
renamed = 0
for r in rows:
new_name = project_name_from_cwd(r["cwd"])
if new_name != r["project_name"]:
conn.execute(
"UPDATE sessions SET project_name = ? WHERE session_id = ?",
(new_name, r["session_id"]))
renamed += 1
conn.commit()
return renamed


def is_subagent_record(record, source_path=""):
"""True if a record belongs to a dispatched subagent (Task/Agent tool).

Expand Down Expand Up @@ -606,6 +643,17 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True):
if verbose and filled:
print(f"Backfilled topic for {filled} existing session(s).")

# One-time rename for DBs whose sessions predate worktree folding: sessions
# run in <project>/.claude/worktrees/<name>/ were named "worktrees/<name>";
# re-derive their project_name from the cwd already stored on turns. Gated
# by the schema_meta 'worktree_fold_done' marker; no-ops on a fresh DB.
if _meta_get(conn, "worktree_fold_done") != "1":
renamed = _backfill_worktree_project_names(conn)
_meta_set(conn, "worktree_fold_done", "1")
conn.commit()
if verbose and renamed:
print(f"Reattributed {renamed} worktree session(s) to their parent project.")

new_files = 0
updated_files = 0
skipped_files = 0
Expand Down
75 changes: 74 additions & 1 deletion tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from scanner import (
get_db, init_db, project_name_from_cwd, parse_jsonl_file,
aggregate_sessions, upsert_sessions, insert_turns, scan,
_backfill_topics, _meta_get, _meta_set,
_backfill_topics, _backfill_worktree_project_names, _meta_get, _meta_set,
)


Expand All @@ -36,6 +36,28 @@ def test_empty_string(self):
def test_none(self):
self.assertEqual(project_name_from_cwd(None), "unknown")

def test_worktree_folds_to_parent_project(self):
self.assertEqual(
project_name_from_cwd("/home/user/proj/.claude/worktrees/zen-joliot-603975"),
"user/proj")

def test_worktree_subdirectory_folds_too(self):
self.assertEqual(
project_name_from_cwd("/home/user/proj/.claude/worktrees/zen-joliot-603975/src/lib"),
"user/proj")

def test_worktree_windows_path(self):
self.assertEqual(
project_name_from_cwd("C:\\Users\\me\\proj\\.claude\\worktrees\\busy-saha-3b4e6c"),
"me/proj")

def test_plain_worktrees_dir_is_not_folded(self):
# Only Claude Code's .claude/worktrees marker triggers folding
self.assertEqual(project_name_from_cwd("/home/user/worktrees/foo"), "worktrees/foo")

def test_worktree_at_filesystem_root(self):
self.assertEqual(project_name_from_cwd("/.claude/worktrees/foo"), "unknown")


def _make_assistant_record(session_id="sess-1", model="claude-sonnet-4-6",
input_tokens=100, output_tokens=50,
Expand Down Expand Up @@ -383,6 +405,57 @@ def test_empty_turns(self):
self.assertEqual(sessions[0]["turn_count"], 0)


class TestWorktreeBackfill(unittest.TestCase):
def setUp(self):
self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmpfile.close()
self.db_path = Path(self.tmpfile.name)
self.conn = get_db(self.db_path)
init_db(self.conn)

def tearDown(self):
self.conn.close()
os.unlink(self.db_path)

def test_renames_worktree_sessions_and_leaves_others(self):
self.conn.execute(
"INSERT INTO sessions (session_id, project_name) "
"VALUES ('s1', 'worktrees/zen-joliot-603975')")
self.conn.execute(
"INSERT INTO turns (session_id, cwd) "
"VALUES ('s1', '/home/user/proj/.claude/worktrees/zen-joliot-603975')")
self.conn.execute(
"INSERT INTO sessions (session_id, project_name) VALUES ('s2', 'user/other')")
self.conn.execute(
"INSERT INTO turns (session_id, cwd) VALUES ('s2', '/home/user/other')")

renamed = _backfill_worktree_project_names(self.conn)
self.assertEqual(renamed, 1)

row = self.conn.execute(
"SELECT project_name FROM sessions WHERE session_id = 's1'").fetchone()
self.assertEqual(row["project_name"], "user/proj")
row = self.conn.execute(
"SELECT project_name FROM sessions WHERE session_id = 's2'").fetchone()
self.assertEqual(row["project_name"], "user/other")

def test_windows_worktree_cwd_is_renamed(self):
self.conn.execute(
"INSERT INTO sessions (session_id, project_name) "
"VALUES ('s1', 'worktrees/busy-saha-3b4e6c')")
self.conn.execute(
"INSERT INTO turns (session_id, cwd) "
"VALUES ('s1', 'C:\\Users\\me\\proj\\.claude\\worktrees\\busy-saha-3b4e6c')")
renamed = _backfill_worktree_project_names(self.conn)
self.assertEqual(renamed, 1)
row = self.conn.execute(
"SELECT project_name FROM sessions WHERE session_id = 's1'").fetchone()
self.assertEqual(row["project_name"], "me/proj")

def test_noop_on_empty_db(self):
self.assertEqual(_backfill_worktree_project_names(self.conn), 0)


class TestDatabaseOperations(unittest.TestCase):
def setUp(self):
self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
Expand Down