From 0c4a5e51feac331b1507d2f0d073e6c0369ac2c5 Mon Sep 17 00:00:00 2001 From: Shweta-Mishra-ai Date: Wed, 21 Oct 2026 17:40:41 +0000 Subject: [PATCH] fix(global-discover): attribute sessions of removed Claude worktrees to their repository Claude Code creates worktrees at /.claude/worktrees/ and they are removed once the branch merges, but the sessions recorded under ~/.claude/projects stay. The scanner now discovers those sessions (#1315), but gitProject() returns null for a cwd that no longer exists, so resolveAndDeduplicate drops them: they are in tools.claude_code.total_sessions and in no repository's count. When the cwd is gone and sits at /.claude/worktrees/[/...], resolve the project from the repository it was created from. Any other missing directory is still unattributed, and a worktree that still exists resolves exactly as before. Fixture with 6 sessions in a main checkout and 40 in a removed worktree (46 discovered): repository count before 6, after 46. --- bin/gstack-global-discover.ts | 15 ++++++ test/global-discover.test.ts | 99 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index d8d8d0e9f..88cfbfdbe 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null { const up = prefix.split("/").filter(Boolean).map(() => ".."); project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common }; } + } else { + // A merged branch's worktree is removed but its session history stays: + // count those sessions with the repository the worktree was created from. + const parent = parentRepoOfClaudeWorktree(cwd); + if (parent) project = gitProject(parent); } PROJECT_MEMO.set(cwd, project); return project; } +/** + * Claude Code creates worktrees at `/.claude/worktrees/`. Given a + * cwd inside one (the worktree root or any subdirectory), the repository root + * it was created from; null for any other path. + */ +export function parentRepoOfClaudeWorktree(cwd: string): string | null { + const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/); + return m ? m[1] : null; +} + function getGitRemote(root: string): string | null { const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf-8", diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index fd0c80631..8e56a6b42 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -644,5 +644,104 @@ describe("gstack-global-discover", () => { expect(json.repos).toHaveLength(1); expect(json.repos[0].sessions.claude_code).toBe(1); }); + + // Claude Code worktrees live at /.claude/worktrees/ and are removed once + // their branch merges; the session history under ~/.claude/projects stays behind. + function removedWorktree(main: string, name = "feat-x"): string { + const wt = join(main, ".claude", "worktrees", name); + git(main, "worktree", "add", "-q", wt); + return wt; + } + + test("sessions of a removed Claude worktree count with the repository it was created from", () => { + const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git"); + const wt = removedWorktree(main); + session(main, "main-1"); + session(main, "main-2"); + session(wt, "wt-1"); + session(wt, "wt-2"); + session(wt, "wt-3"); + git(main, "worktree", "remove", "--force", wt); + expect(existsSync(wt)).toBe(false); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(5); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(5); + expect(json.repos[0].paths).toEqual([main]); + }); + + test("a session started in a subdirectory of a removed Claude worktree counts too", () => { + const main = repo("work/app", "git@github.com:acme/app.git"); + const wt = removedWorktree(main); + mkdirSync(join(wt, "packages", "web"), { recursive: true }); + session(join(wt, "packages", "web"), "sub-1"); + git(main, "worktree", "remove", "--force", wt); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!; + expect(r.sessions.claude_code).toBe(1); + expect(r.paths).toEqual([main]); + }); + + test("a removed Claude worktree of a repository with no remote counts with its repository", () => { + const main = repo("work/local-only"); + const wt = removedWorktree(main); + session(main, "l-1"); + session(wt, "l-2"); + git(main, "worktree", "remove", "--force", wt); + const local = discover().repos.filter((x) => x.remote.startsWith("local:")); + expect(local).toHaveLength(1); + expect(local[0].sessions.claude_code).toBe(2); + }); + + test("a live Claude worktree keeps its own path next to its repository's", () => { + const main = repo("work/live", "https://github.com/acme/live.git"); + const wt = removedWorktree(main); + session(main, "m-1"); + session(wt, "w-1"); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!; + expect(r.sessions.claude_code).toBe(2); + expect(r.paths.sort()).toEqual([main, wt].sort()); + }); + + test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => { + const gone = join(home, "work", "gone-repo"); + session(join(gone, ".claude", "worktrees", "feat-x"), "g-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(1); + expect(json.repos).toHaveLength(0); + }); + + test("a removed directory that is not a Claude worktree is not counted as a repository", () => { + const main = repo("work/real", "https://github.com/acme/real.git"); + session(join(main, "some", "deleted-dir"), "d-1"); + session(join(main, ".claude", "worktrees"), "d-2"); + session(main, "r-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(3); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(1); + }); + + describe("parentRepoOfClaudeWorktree", () => { + let parentRepoOfClaudeWorktree: (cwd: string) => string | null; + beforeEach(async () => { + parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree; + }); + + test("returns the repository root for a worktree path, with or without a subdirectory", () => { + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app"); + }); + + test("returns null for any other path", () => { + for (const p of [ + "/Users/me/code/app", + "/Users/me/code/app/.claude/worktrees", + "/Users/me/code/app/.claude/worktrees/", + "/Users/me/code/not.claude/worktrees/x", + "/.claude/worktrees/x", + ]) expect(parentRepoOfClaudeWorktree(p)).toBeNull(); + }); + }); }); });