mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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:
parent
da20ec6b92
commit
a12db6e549
4 changed files with 96 additions and 12 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue