Group git worktrees under main project (#375)
Some checks failed
CI / semgrep (push) Has been cancelled

This commit is contained in:
ozymandiashh 2026-05-22 12:23:41 +03:00 committed by GitHub
parent bbbdcd4eb8
commit 8ca074636b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 136 additions and 12 deletions

View file

@ -1,5 +1,5 @@
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { lstat, readFile, readdir, stat } from 'fs/promises'
import { basename, join, resolve } from 'path'
import { readSessionLines } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
@ -46,6 +46,36 @@ function normalizeProjectPathKey(projectPath: string): string {
return (normalized.replace(/\/+$/, '') || normalized).toLowerCase()
}
function projectNameFromPath(projectPath: string, fallback: string): string {
const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.split('/').filter(Boolean).pop() ?? fallback
}
async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
const trimmed = cwd.trim()
if (!trimmed) return { path: cwd, isWorktree: false }
const gitEntry = join(trimmed, '.git')
const entryStat = await lstat(gitEntry).catch(() => null)
if (!entryStat) return { path: cwd, isWorktree: false }
if (entryStat.isDirectory()) return { path: cwd, isWorktree: false }
if (!entryStat.isFile()) return { path: cwd, isWorktree: false }
const gitFile = await readFile(gitEntry, 'utf-8').catch(() => null)
if (gitFile === null) return { path: cwd, isWorktree: false }
const match = gitFile.match(/^gitdir:\s*(.+?)\s*$/m)
if (!match?.[1]) return { path: cwd, isWorktree: false }
const gitDir = resolve(trimmed, match[1])
const normalizedGitDir = gitDir.replace(/\\/g, '/')
const worktreeMarker = '/.git/worktrees/'
const markerIndex = normalizedGitDir.lastIndexOf(worktreeMarker)
if (markerIndex === -1) return { path: cwd, isWorktree: false }
return { path: normalizedGitDir.slice(0, markerIndex), isWorktree: true }
}
const LARGE_JSONL_LINE_BYTES = 32 * 1024
export function parseJsonlLine(line: string | Buffer): JournalEntry | null {
@ -1419,10 +1449,13 @@ async function scanProjectDirs(
if (!entries) continue
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds)
const cwd = extractCanonicalCwd(entries)
const canonical = cwd ? await resolveCanonicalProjectPath(cwd) : undefined
section.files[filePath] = {
fingerprint: info.fp,
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
canonicalCwd: extractCanonicalCwd(entries),
canonicalCwd: canonical?.path,
canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined,
mcpInventory: extractMcpInventory(entries),
turns: turns.map(parsedTurnToCachedTurn),
}
@ -1438,7 +1471,7 @@ async function scanProjectDirs(
}
}
const projectMap = new Map<string, { project: string; projectPath: string; sessions: SessionSummary[] }>()
const projectMap = new Map<string, { project: string; projectPath: string; sessions: SessionSummary[]; dirNames: Set<string> }>()
const allFiles = [
...unchangedFiles.map(f => ({ filePath: f.filePath, dirName: f.dirName })),
@ -1465,8 +1498,9 @@ async function scanProjectDirs(
const sessionId = basename(filePath, '.jsonl')
const projectPath = cachedFile.canonicalCwd ?? unsanitizePath(dirName)
const projectName = cachedFile.canonicalProjectName ?? dirName
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
const session = buildSessionSummary(sessionId, dirName, classifiedTurns, mcpInv)
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv)
if (session.apiCalls > 0) {
const projectKey = cachedFile.canonicalCwd
@ -1475,8 +1509,9 @@ async function scanProjectDirs(
const existing = projectMap.get(projectKey)
if (existing) {
existing.sessions.push(session)
existing.dirNames.add(dirName)
} else {
projectMap.set(projectKey, { project: dirName, projectPath, sessions: [session] })
projectMap.set(projectKey, { project: projectName, projectPath, sessions: [session], dirNames: new Set([dirName]) })
}
}
}
@ -1484,8 +1519,9 @@ async function scanProjectDirs(
// Fold slug-keyed entries into cwd-keyed entries
const cwdKeyByDirName = new Map<string, string>()
for (const [key, entry] of projectMap) {
if (!key.startsWith('slug:') && !cwdKeyByDirName.has(entry.project)) {
cwdKeyByDirName.set(entry.project, key)
if (key.startsWith('slug:')) continue
for (const dirName of entry.dirNames) {
if (!cwdKeyByDirName.has(dirName)) cwdKeyByDirName.set(dirName, key)
}
}
for (const [key, entry] of [...projectMap]) {
@ -1578,6 +1614,19 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
}
}
async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promise<ParsedProviderCall> {
if (!call.projectPath) return call
const canonical = await resolveCanonicalProjectPath(call.projectPath)
if (!canonical.isWorktree) return call
return {
...call,
project: projectNameFromPath(canonical.path, call.project ?? canonical.path),
projectPath: canonical.path,
}
}
function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
return {
provider: call.provider,
@ -1805,7 +1854,8 @@ async function parseProviderSources(
for await (const call of parser.parse()) {
providerCalls.push(call)
}
const turns = providerCallsToCachedTurns(providerCalls)
const canonicalCalls = await Promise.all(providerCalls.map(canonicalizeProviderCallProject))
const turns = providerCallsToCachedTurns(canonicalCalls)
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns }
didParse = true
;(diskCache as { _dirty?: boolean })._dirty = true

View file

@ -54,6 +54,7 @@ export type CachedFile = {
fingerprint: FileFingerprint
lastCompleteLineOffset?: number
canonicalCwd?: string
canonicalProjectName?: string
mcpInventory: string[]
turns: CachedTurn[]
}
@ -90,6 +91,15 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
'ibm-bob': ['XDG_CONFIG_HOME'],
}
const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'worktree-project-grouping-v1',
cline: 'worktree-project-grouping-v1',
'ibm-bob': 'worktree-project-grouping-v1',
'kilo-code': 'worktree-project-grouping-v1',
'roo-code': 'worktree-project-grouping-v1',
warp: 'worktree-project-grouping-v1',
}
// ── Cache Dir ──────────────────────────────────────────────────────────
function getCacheDir(): string {
@ -105,6 +115,8 @@ function getCachePath(): string {
export function computeEnvFingerprint(provider: string): string {
const vars = PROVIDER_ENV_VARS[provider] ?? []
const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`)
const parseVersion = PROVIDER_PARSE_VERSIONS[provider]
if (parseVersion) parts.push(`parser=${parseVersion}`)
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
}
@ -191,6 +203,7 @@ function validateCachedFile(f: unknown): f is CachedFile {
return validateFingerprint(o['fingerprint'])
&& isOptionalNum(o['lastCompleteLineOffset'])
&& isOptionalString(o['canonicalCwd'])
&& isOptionalString(o['canonicalProjectName'])
&& isStringArray(o['mcpInventory'])
&& Array.isArray(o['turns'])
&& (o['turns'] as unknown[]).every(validateTurn)
@ -360,5 +373,3 @@ export async function cleanupOrphanedTempFiles(): Promise<void> {
}
} catch {}
}

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join } from 'path'
import { join, relative } from 'path'
import { tmpdir } from 'os'
import { parseAllSessions } from '../src/parser.js'
@ -161,6 +161,64 @@ describe('Claude cwd project paths', () => {
expect(projects[0]!.sessions).toHaveLength(4)
expect(projects[0]!.projectPath).toBe('fallback/slug')
})
it('groups git worktrees under the main repository project', async () => {
const mainRepo = join(tmpDir, 'repos', 'codeburn')
const worktreeA = join(tmpDir, 'worktrees', 'codeburn-feature-a')
const worktreeB = join(tmpDir, 'worktrees', 'codeburn-feature-b')
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-a'), { recursive: true })
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-b'), { recursive: true })
await mkdir(worktreeA, { recursive: true })
await mkdir(worktreeB, { recursive: true })
await writeFile(join(worktreeA, '.git'), `gitdir: ${join(mainRepo, '.git', 'worktrees', 'feature-a')}\n`)
await writeFile(join(worktreeB, '.git'), `gitdir: ${relative(worktreeB, join(mainRepo, '.git', 'worktrees', 'feature-b'))}\n`)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-a',
'worktree-a-session',
worktreeA,
'2099-05-07T10:00:00.000Z',
)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-b',
'worktree-b-session',
worktreeB,
'2099-05-07T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-07'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('codeburn')
expect(projects[0]!.projectPath).toBe(mainRepo)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'worktree-a-session',
'worktree-b-session',
])
})
it('does not group separate-git-dir projects that are not git worktrees', async () => {
const externalGitDir = join(tmpDir, 'external-git-dirs', 'project.git')
const projectDir = join(tmpDir, 'standalone', 'separate-git-dir')
await mkdir(externalGitDir, { recursive: true })
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, '.git'), `gitdir: ${externalGitDir}\n`)
await writeClaudeSession(
'tmp-standalone-separate-git-dir',
'separate-git-dir-session',
projectDir,
'2099-05-08T10:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-08'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.projectPath).toBe(projectDir)
expect(projects[0]!.project).toBe('tmp-standalone-separate-git-dir')
})
})
describe('Claude cache creation pricing', () => {

View file

@ -163,6 +163,11 @@ describe('computeEnvFingerprint', () => {
const b = computeEnvFingerprint('unknown-provider')
expect(a).toBe(b)
})
it('includes parser versions in provider fingerprints', () => {
expect(computeEnvFingerprint('claude')).not.toBe(computeEnvFingerprint('unknown-provider'))
expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider'))
})
})
// ── fingerprintFile ────────────────────────────────────────────────────