From f158485f4e310ea4235a27ee092318a618b9cf99 Mon Sep 17 00:00:00 2001 From: poppinpixels Date: Fri, 10 Jul 2026 10:36:28 +0200 Subject: [PATCH] feat: attribute worktree sessions to their parent project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's per-session worktrees live at /.claude/worktrees//, so each worktree session showed up as its own project (e.g. "worktrees/zen-joliot-603975") in cost-by-project views, splitting a project's real usage across many ephemeral rows. - project_name_from_cwd now folds any .claude/worktrees path back to the parent project before taking the last two components (POSIX + Windows). - One-time backfill renames existing worktree sessions using the cwd already stored on their turns — no transcript re-read, turns untouched, token totals cannot drift. Gated by schema_meta 'worktree_fold_done', mirroring the topic backfill. Co-Authored-By: Claude Fable 5 --- scanner.py | 48 +++++++++++++++++++++++++++ tests/test_scanner.py | 75 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/scanner.py b/scanner.py index b448dbb..22dd8db 100644 --- a/scanner.py +++ b/scanner.py @@ -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 + # /.claude/worktrees//; 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 ``/.claude/worktrees/`` got project + names like ``worktrees/``. 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). @@ -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 /.claude/worktrees// were named "worktrees/"; + # 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 diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 31c0ba8..39d1bfc 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -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, ) @@ -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, @@ -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)