mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
Group git worktrees under main project (#375)
Some checks failed
CI / semgrep (push) Has been cancelled
Some checks failed
CI / semgrep (push) Has been cancelled
This commit is contained in:
parent
bbbdcd4eb8
commit
8ca074636b
4 changed files with 136 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue