Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
30 changes: 19 additions & 11 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 <main>/.git/worktrees/<name>. 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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ')
})
})
59 changes: 58 additions & 1 deletion tests/parser-claude-cwd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading