diff --git a/src/overview.ts b/src/overview.ts index 856d029b..bec118a0 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -16,10 +16,14 @@ function formatCost(usd: number): string { function formatTokens(n: number): string { return Math.round(n).toLocaleString() } +function isAbsoluteProjectPath(path: string): boolean { + return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(path) +} function projectName(p: ProjectSummary): string { const path = p.projectPath if (path) { if (path === homedir()) return 'Home' + if (!isAbsoluteProjectPath(path)) return p.project || path const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() if (base) return base } diff --git a/src/parser.ts b/src/parser.ts index 2a936191..eca39f8d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -44,6 +44,13 @@ function unsanitizePath(dirName: string): string { return dirName.replace(/-/g, '/') } +function claudeSlugFallbackPath(dirName: string): string { + // Claude project directory names are lossy: a dash may be either a path + // separator from the original cwd or a literal dash in the leaf name. + // Without cwd metadata, keep the slug intact instead of inventing segments. + return dirName +} + function normalizeProjectPathKey(projectPath: string): string { const normalized = projectPath.trim().replace(/\\/g, '/') return (normalized.replace(/\/+$/, '') || normalized).toLowerCase() @@ -76,12 +83,11 @@ async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; const trimmed = cwd.trim() if (!trimmed) return { path: cwd, isWorktree: false } - // Walk up the directory tree to find the nearest .git entry. This handles - // three cases: (1) cwd IS the repo root (.git is a directory), (2) cwd is a - // git worktree (.git is a file pointing back to the main repo), (3) cwd is a - // subdirectory of a repo — we resolve up to the repo root so subdirectory - // sessions group with the rest of the project even when the subdir no longer - // exists on disk. + // Walk up the directory tree to find a real git worktree marker. Ordinary + // repos use a .git directory; linked worktrees use a .git file pointing back + // to
/.git/worktrees/. Only the latter should canonicalize to + // the main repo. A parent directory with a stray .git directory must not + // absorb sibling projects. // Guard against foreign paths (e.g. a Windows path recorded on a machine // that now runs macOS): only walk paths that look like absolute paths on the // current platform. A relative or foreign-format path cannot be walked on @@ -95,17 +101,19 @@ async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; while (true) { const gitEntry = join(dir, '.git') const entryStat = await lstat(gitEntry).catch(() => null) - if (entryStat?.isDirectory()) return { path: dir, isWorktree: false } + if (entryStat?.isDirectory()) { + return { path: dir === trimmed ? dir : cwd, isWorktree: false } + } if (entryStat?.isFile()) { const gitFile = await readFile(gitEntry, 'utf-8').catch(() => null) - if (gitFile === null) return { path: dir, isWorktree: false } + if (gitFile === null) return { path: dir === trimmed ? dir : cwd, isWorktree: false } const match = gitFile.match(/^gitdir:\s*(.+?)\s*$/m) - if (!match?.[1]) return { path: dir, isWorktree: false } + if (!match?.[1]) return { path: dir === trimmed ? dir : cwd, isWorktree: false } const gitDir = resolve(dir, match[1]) const normalizedGitDir = gitDir.replace(/\\/g, '/') const worktreeMarker = '/.git/worktrees/' const markerIndex = normalizedGitDir.lastIndexOf(worktreeMarker) - if (markerIndex === -1) return { path: dir, isWorktree: false } + if (markerIndex === -1) return { path: dir === trimmed ? dir : cwd, isWorktree: false } return { path: normalizedGitDir.slice(0, markerIndex), isWorktree: true } } const parent = dirname(dir) @@ -1611,7 +1619,7 @@ async function scanProjectDirs( if (classifiedTurns.length === 0) continue const sessionId = basename(filePath, '.jsonl') - const projectPath = cachedFile.canonicalCwd ?? unsanitizePath(dirName) + const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName) const projectName = cachedFile.canonicalProjectName ?? dirName const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv) diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 20c1767c..5f2dd759 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -100,4 +100,19 @@ describe('renderOverview', () => { const out = renderOverview([], { label: 'June 2026', color: false }) expect(out).toContain('No usage found for June 2026') }) + + it('does not split a slug-only Claude project path into fake path segments', () => { + const out = renderOverview([makeProject({ + project: 'Projects-Content-OS', + projectPath: 'Projects/Content/OS', + cost: 3.25, + calls: 1, + model: 'claude-sonnet-4-5', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('Projects-Content-OS') + expect(out).not.toContain(' OS ') + }) }) diff --git a/tests/parser-claude-cwd.test.ts b/tests/parser-claude-cwd.test.ts index 57ab04b1..9a379dbc 100644 --- a/tests/parser-claude-cwd.test.ts +++ b/tests/parser-claude-cwd.test.ts @@ -155,7 +155,64 @@ describe('Claude cwd project paths', () => { expect(projects).toHaveLength(1) expect(projects[0]!.sessions).toHaveLength(4) - expect(projects[0]!.projectPath).toBe('fallback/slug') + expect(projects[0]!.projectPath).toBe('fallback-slug') + }) + + it('keeps a hyphenated slug intact when cwd is absent', async () => { + const slug = 'Projects-Content-OS' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'no-cwd.jsonl') + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId: 'no-cwd', + timestamp: '2099-05-09T10:00:00.000Z', + message: { + id: 'msg-no-cwd', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(filePath, new Date('2099-05-09T10:00:00.000Z'), new Date('2099-05-09T10:00:00.000Z')) + + const projects = await parseAllSessions(dayRange('2099-05-09'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe(slug) + expect(projects[0]!.projectPath).toBe(slug) + expect(projects[0]!.projectPath).not.toBe('Projects/Content/OS') + }) + + it('does not group sibling projects under a parent directory that merely contains .git', async () => { + const projectsRoot = join(tmpDir, 'Projects') + const swiftbar = join(projectsRoot, 'Swiftbar') + const contentOs = join(projectsRoot, 'Content-OS') + await mkdir(join(projectsRoot, '.git'), { recursive: true }) + await mkdir(swiftbar, { recursive: true }) + await mkdir(contentOs, { recursive: true }) + + await writeClaudeSession( + 'tmp-Projects-Swiftbar', + 'swiftbar-session', + swiftbar, + '2099-05-10T10:00:00.000Z', + ) + await writeClaudeSession( + 'tmp-Projects-Content-OS', + 'content-os-session', + contentOs, + '2099-05-10T11:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-10'), 'claude') + const projectPaths = projects.map(project => project.projectPath).sort() + + expect(projects).toHaveLength(2) + expect(projectPaths).toEqual([contentOs, swiftbar].sort()) + expect(projectPaths).not.toContain(projectsRoot) }) it('groups git worktrees under the main repository project', async () => {