Show real Claude project leaf names; stop stray-.git over-grouping (#540)

The 'By Project' panel collapsed sibling Claude Code projects to a parent
folder in two cases:

1) resolveCanonicalProjectPath walked up to ANY ancestor .git and used it as
   the canonical project. A stray .git in a parent of several projects (a
   dotfiles bare repo, an accidental git init) made every sibling resolve to
   that parent ('Projects'/'home'). Now only a real linked worktree (.git is a
   FILE pointing at <main>/.git/worktrees/<name>) canonicalizes to its main
   repo; an ordinary repo or a stray ancestor .git keeps the session's own cwd
   as the project. Genuine worktree grouping is preserved.

2) When a Claude session has no cwd metadata, the dir slug was unsanitized by
   replacing every '-' with '/', inventing path segments that overview then
   split on, so 'Projects-Content-OS' rendered as 'os'. The lossy slug is now
   kept intact, and overview only basenames ABSOLUTE paths.

Fixes #493
This commit is contained in:
ozymandiashh 2026-06-21 22:42:19 +02:00 committed by GitHub
parent da20ec6b92
commit a12db6e549
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 96 additions and 12 deletions

View file

@ -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
}

View file

@ -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 <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
@ -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)

View file

@ -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 ')
})
})

View file

@ -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 () => {