diff --git a/src/config.ts b/src/config.ts index 466178b9..6762ff60 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,6 +70,9 @@ export type CodeburnConfig = { // Matched against the canonical project path: prefix on a path-segment // boundary, case-insensitive, trailing-slash and backslash tolerant. proxyPaths?: string[] + // Folder -> repository origin key (`codeburn project link`): the folder and + // everything under it join that repository's project row. + projectLinks?: Record // Vercel AI Gateway rows are DAILY AGGREGATES per model with no request id, // timestamp or attribution (see src/providers/vercel-gateway.ts), so they // cannot be matched against the local tools that were pointed at the gateway diff --git a/src/dashboard.tsx b/src/dashboard.tsx index df855809..844accc5 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -27,7 +27,7 @@ import { planDisplayName } from './plans.js' import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js' import { BSU, patchStdoutForWindows } from './ink-win.js' import { startUserTimingGuard } from './user-timing-guard.js' -import { folderNameOriginKey, isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' +import { folderNameOriginKey, isTemporaryProjectPath, linkedOriginKey, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' import { countSessions } from './session-output.js' type View = 'dashboard' | 'optimize' | 'compare' @@ -808,7 +808,7 @@ function getProjectBreakdownRowLimit(period: Period, dayMode = false): number { export function foldProjectsByRepository(projects: ProjectSummary[]): Array { const rows = new Map() projects.forEach((p, i) => { - const origin = projectOriginKey(p.projectPath) ?? folderNameOriginKey(p.projectPath) + const origin = linkedOriginKey(p.projectPath) ?? projectOriginKey(p.projectPath) ?? folderNameOriginKey(p.projectPath) const temporary = !origin && isTemporaryProjectPath(p.projectPath) const key = origin ?? (temporary ? TEMPORARY_PROJECTS : `\0${i}`) const held = rows.get(key) diff --git a/src/git-origin.ts b/src/git-origin.ts index fc38034e..1d3ff238 100644 --- a/src/git-origin.ts +++ b/src/git-origin.ts @@ -1,8 +1,9 @@ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { basename, dirname, isAbsolute, join } from 'node:path' +import { homedir, tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join, resolve, win32 } from 'node:path' import { getCodeburnCacheDir } from './cache-dir.js' +import { normalizeProxyPath } from './models.js' /// The `origin` remote of a checkout, read straight from .git/config. Two clones /// or worktrees of the same repository share it, which is what lets surfaces @@ -239,6 +240,44 @@ export function isTemporaryProjectPath(projectPath: string | undefined): boolean return tempRoots().some(root => key === root || key.startsWith(`${root}/`)) } +/** Every repository an earlier parse saw a checkout of. */ +export function knownOriginKeys(): string[] { + recorded ??= readRecorded() + return [...new Set(Object.values(recorded))] +} + +/** A folder as `project link` stores it: absolute, with ~ expanded. */ +export function projectLinkFolder(folder: string): string { + const raw = folder.trim() + const path = raw === '~' || /^~[\\/]/.test(raw) ? homedir() + raw.slice(1) : raw + return /^[a-zA-Z]:[\\/]/.test(path) ? win32.resolve(path) : resolve(path) +} + +// Folders the user put in a repository (`codeburn project link`), longest +// first so a nested link wins. Never stamped into the day cache, so an unlink +// takes effect on the next report. +let projectLinks: Array<[folder: string, origin: string]> = [] + +export function setProjectLinks(links: Record | undefined): void { + projectLinks = Object.entries(links && typeof links === 'object' ? links : {}) + .filter((e): e is [string, string] => typeof e[1] === 'string' && e[1] !== '') + .map(([folder, origin]): [string, string] => [normalizeProxyPath(projectLinkFolder(folder)), origin]) + .filter(([folder]) => folder !== '') + .sort((a, b) => b[0].length - a[0].length) +} + +/** The repository a project link puts `projectPath` in. It beats the folder's + * own origin. Same path rule as proxy paths: per-OS case, folder boundary. */ +export function linkedOriginKey(projectPath: string | undefined): string | null { + if (!projectPath || projectLinks.length === 0) return null + const path = normalizeProxyPath(projectPath) + return projectLinks.find(([folder]) => path === folder || path.startsWith(`${folder}/`))?.[1] ?? null +} + +export function getProjectLinksConfigHash(): string { + return projectLinks.length ? JSON.stringify(projectLinks) : '' +} + /** "github.com/org/repo" -> "repo". */ export function originRepoName(originKey: string): string { return originKey.split('/').filter(Boolean).pop() ?? originKey @@ -275,4 +314,5 @@ export function __resetGitOriginCache(): void { recorded = null checkoutsByParent = null folderNameMatch.clear() + projectLinks = [] } diff --git a/src/main.ts b/src/main.ts index e7e94e82..982384d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,6 +52,7 @@ import { runAgyStatusLineHook, uninstallAntigravityStatusLineHook, } from './antigravity-statusline.js' +import { getProjectLinksConfigHash, knownOriginKeys, originRepoName, projectLinkFolder, setProjectLinks } from './git-origin.js' import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, setIncludeGatewayInTotals, gatewayIncludedInTotals, type CodeburnConfig, type Plan, type PlanId, type PlanProvider } from './config.js' import { clampResetDay, copilotCreditsNote, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js' import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js' @@ -562,6 +563,7 @@ program.hook('preAction', async (thisCommand) => { setFlatRateModels(config.flatRateModels ?? []) setFlatRateRemoved(config.flatRateModelsRemoved ?? []) setProxyPaths(config.proxyPaths ?? []) + setProjectLinks(config.projectLinks) setIncludeGatewayInTotals(config.includeGatewayInTotals === true) if (thisCommand.opts<{ verbose?: boolean }>().verbose) { process.env['CODEBURN_VERBOSE'] = '1' @@ -1284,6 +1286,7 @@ program // serving costs priced under the old config until something // unrelated moves the corpus fingerprint. proxyPathsConfigHash: getProxyPathsConfigHash(), + projectLinksConfigHash: getProjectLinksConfigHash(), modelAliasesConfigHash: getModelAliasesConfigHash(), priceOverridesConfigHash: getPriceOverridesConfigHash(), localModelSavingsConfigHash: getLocalModelSavingsConfigHash(), @@ -2079,6 +2082,82 @@ program console.log(` Config: ${getConfigFilePath()}\n`) }) +program + .command('project [action] [folder] [project]') + .description('Put a folder (and everything under it) in a repository project: project link , project unlink , project links. is the name shown in the project list (e.g. codeburn project link ~/crewroom crewroom).') + .option('--format ', 'Output format: text, json', 'text') + .action(async (action?: string, folder?: string, project?: string, opts?: { format?: string }) => { + const format = opts?.format ?? 'text' + assertFormat(format, ['text', 'json'], 'project') + const config = await readConfig() + const links = config.projectLinks && typeof config.projectLinks === 'object' ? { ...config.projectLinks } : {} + const sameFolder = (a: string, b: string) => normalizeProxyPath(projectLinkFolder(a)) === normalizeProxyPath(projectLinkFolder(b)) + + if (!action || action === 'links') { + if (format === 'json') { + console.log(JSON.stringify(Object.entries(links).map(([f, origin]) => ({ folder: f, project: originRepoName(origin), origin })), null, 2)) + return + } + if (Object.keys(links).length === 0) { + console.log('\n No project links.') + console.log(' Add one with: codeburn project link \n') + return + } + console.log('\n Project links:') + for (const [f, origin] of Object.entries(links)) console.log(` ${f} -> ${originRepoName(origin)} (${origin})`) + console.log(` Config: ${getConfigFilePath()}\n`) + return + } + + if (action === 'unlink') { + const key = folder && Object.keys(links).find(f => sameFolder(f, folder)) + if (!key) { + console.error(`\n No project link for: ${folder ?? '(no folder given)'}\n`) + process.exitCode = 1 + return + } + delete links[key] + config.projectLinks = Object.keys(links).length ? links : undefined + await saveConfig(config) + console.log(`\n Removed project link: ${key}\n`) + return + } + + if (action !== 'link' || !folder || !project) { + console.error('\n Usage: codeburn project link | project unlink | project links\n') + process.exitCode = 1 + return + } + const target = projectLinkFolder(folder) + if (normalizeProxyPath(target) === '') { + console.error('\n The filesystem root is too broad to link.\n') + process.exitCode = 1 + return + } + // The names the project list shows for repository rows: "repo", or + // "org/repo" when two repositories share a name. + const wanted = project.trim().toLowerCase() + const origins = knownOriginKeys() + const matches = origins.filter(o => [o, o.split('/').slice(-2).join('/'), originRepoName(o)].includes(wanted)) + if (matches.length !== 1) { + if (matches.length > 1) { + console.error(`\n Several repositories are named "${project}": ${matches.map(o => o.split('/').slice(-2).join('/')).join(', ')}. Use one of those names.\n`) + } else { + const close = [...new Set(origins.map(originRepoName))].filter(n => n.includes(wanted) || wanted.includes(n)).sort().slice(0, 5) + console.error(`\n No repository project named "${project}".${close.length ? ` Did you mean: ${close.join(', ')}?` : ''}`) + console.error(' A folder can join a project that is a git repository CodeBurn has seen.\n') + } + process.exitCode = 1 + return + } + for (const f of Object.keys(links)) if (sameFolder(f, target)) delete links[f] + links[target] = matches[0]! + config.projectLinks = links + await saveConfig(config) + console.log(`\n Linked ${target} -> ${originRepoName(matches[0]!)} (${matches[0]})`) + console.log(` Config: ${getConfigFilePath()}\n`) + }) + program .command('gateway-totals [mode]') .description('Include or exclude Vercel AI Gateway spend in headline totals. Gateway reports are daily per-model aggregates with no request identity, so the same spend is usually already counted by the local tools you pointed at the gateway (Claude Code, Codex, OpenCode, Cline/Kilo, Cursor). Excluded by default; the gateway is always shown as its own row either way. Modes: include, exclude.') diff --git a/src/overview.ts b/src/overview.ts index a4465f71..d88cc1f7 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -15,7 +15,7 @@ import { normalizeAbsProjectPathKey } from './parser.js' import { dateKey } from './day-aggregator.js' import type { DailyEntry } from './daily-cache.js' import type { BudgetStatus, BudgetTier } from './budget.js' -import { folderNameOriginKey, isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' +import { folderNameOriginKey, isTemporaryProjectPath, linkedOriginKey, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' // Display-only helpers. The shared formatters omit thousands separators and // abbreviate; here we show full, comma-grouped numbers so the tables read like @@ -169,7 +169,7 @@ export function renderOverview( cost += p.totalCostUSD savings += p.totalSavingsUSD calls += p.totalApiCalls - const realOrigin = projectOriginKey(p.projectPath) + const realOrigin = linkedOriginKey(p.projectPath) ?? projectOriginKey(p.projectPath) const origin = realOrigin ?? folderNameOriginKey(p.projectPath) const temporary = !origin && isTemporaryProjectPath(p.projectPath) const pkey = origin ? `origin:${origin}` : temporary ? TEMPORARY_PROJECTS : projectAggKey(p) diff --git a/src/parser.ts b/src/parser.ts index fa938b76..445474e5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -58,7 +58,7 @@ import { classifyWslCachePath, isWslUncPath, refreshWslHomes, wslMode } from './ import { decideParseWorkers, parseFilesInOrder, ParseWorkerPool, type ClaudeWorkerParse, type ParseJob } from './parse-workers.js' import type { CodexFullParse } from './providers/codex.js' import { dateKey } from './day-aggregator.js' -import { folderNameOriginKey, isTemporaryProjectPath, projectOriginKey, saveGitOrigins, TEMPORARY_PROJECTS } from './git-origin.js' +import { folderNameOriginKey, isTemporaryProjectPath, linkedOriginKey, projectOriginKey, saveGitOrigins, TEMPORARY_PROJECTS } from './git-origin.js' import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js' import { gatewayIncludedInTotals } from './config.js' import { coverageFor, cursorImportPath, dropImportCoveredCalls, loadCursorImport, replacedProviders } from './cursor-import.js' @@ -5045,7 +5045,7 @@ function compile(patterns: readonly string[], widen: boolean): CompiledPattern[] const exact = pattern.startsWith('=') && isRootedProjectPattern(pattern.slice(1)) if (!exact && !isRootedProjectPattern(pattern)) return { rooted: false as const, needle: pattern.toLowerCase() } const path = expandTilde(exact ? pattern.slice(1) : pattern) - return { rooted: true as const, anchor: normalizeAbsProjectPathKey(path), origin: exactProjectPaths || !(widen || exact) ? null : projectOriginKey(path), exact } + return { rooted: true as const, anchor: normalizeAbsProjectPathKey(path), origin: exactProjectPaths || !(widen || exact) ? null : linkedOriginKey(path) ?? projectOriginKey(path), exact } }) } @@ -5060,12 +5060,12 @@ function hit(entry: ProjectFilterTarget, pattern: CompiledPattern, key: string | if (pattern.rooted) { // A folder with no known origin (deleted before it was recorded) falls // back to the folder rule, so a repo path still takes its subfolders. - const origin = pattern.origin && (entry.originKey ?? projectOriginKey(entry.projectPath) ?? folderNameOriginKey(entry.projectPath)) + const origin = pattern.origin && (linkedOriginKey(entry.projectPath) ?? entry.originKey ?? projectOriginKey(entry.projectPath) ?? folderNameOriginKey(entry.projectPath)) if (origin) return origin === pattern.origin const anchor = pattern.anchor return anchor !== null && key !== null && (key === anchor || (!pattern.exact && key.startsWith(anchor + '/'))) } - if (pattern.temporary) return isTemporaryProjectPath(entry.projectPath) && !(entry.originKey ?? projectOriginKey(entry.projectPath) ?? folderNameOriginKey(entry.projectPath)) + if (pattern.temporary) return isTemporaryProjectPath(entry.projectPath) && !(linkedOriginKey(entry.projectPath) ?? entry.originKey ?? projectOriginKey(entry.projectPath) ?? folderNameOriginKey(entry.projectPath)) return entry.project.toLowerCase().includes(pattern.needle) || (entry.projectPath ?? '').toLowerCase().includes(pattern.needle) } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 50bd76d2..33bc2f58 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -25,7 +25,7 @@ import { activityStreak } from './streak.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, projectDayIdentity, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' import { spendProjectIdentity } from './spend-flow.js' -import { folderNameOriginKey, isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' +import { folderNameOriginKey, isTemporaryProjectPath, linkedOriginKey, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js' import { AGGREGATE_ONLY_PROVIDER, excludeAggregateOnlyProjects, excludesAggregateOnlyProviders } from './parser.js' // Row caps for the by-PR / by-branch payload aggregations, ranked by cost. @@ -1572,7 +1572,7 @@ export function buildPayloadProjects( const groups = new Map() const byFolderName = new Set<(typeof rows)[number]>() for (const row of rows) { - const realOrigin = row.acc.originKey ?? projectOriginKey(row.path) + const realOrigin = linkedOriginKey(row.path) ?? row.acc.originKey ?? projectOriginKey(row.path) const nameOrigin = realOrigin ? null : folderNameOriginKey(row.path) if (nameOrigin) byFolderName.add(row) const key = realOrigin ?? nameOrigin ?? (isTemporaryProjectPath(row.path) ? TEMPORARY_KEY : `\0${row.acc.id}`) diff --git a/tests/cli-project-link.test.ts b/tests/cli-project-link.test.ts new file mode 100644 index 00000000..8e28d86d --- /dev/null +++ b/tests/cli-project-link.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.setConfig({ testTimeout: 60_000 }) + +let home: string + +afterEach(() => rmSync(home, { recursive: true, force: true })) + +function runCli(args: string[]) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, USERPROFILE: home, CODEBURN_CACHE_DIR: join(home, 'cache'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('codeburn project link', () => { + it('links a folder to a known repository, lists, and unlinks it', () => { + home = mkdtempSync(join(tmpdir(), 'codeburn-project-link-')) + mkdirSync(join(home, 'cache')) + writeFileSync(join(home, 'cache', 'git-origins.json'), JSON.stringify({ version: 1, paths: { '/w/crewroom': 'github.com/me/crewroom' } })) + + const unknown = runCli(['project', 'link', '~/crewroom', 'crew']) + expect(unknown.status).toBe(1) + expect(unknown.stderr).toContain('No repository project named "crew". Did you mean: crewroom?') + + expect(runCli(['project', 'link', '~/crewroom/', 'Crewroom']).status).toBe(0) + const config = JSON.parse(readFileSync(join(home, '.config', 'codeburn', 'config.json'), 'utf-8')) + expect(config.projectLinks).toEqual({ [join(home, 'crewroom')]: 'github.com/me/crewroom' }) + expect(runCli(['project', 'links']).stdout).toContain(`${join(home, 'crewroom')} -> crewroom (github.com/me/crewroom)`) + + expect(runCli(['project', 'unlink', join(home, 'crewroom')]).status).toBe(0) + expect(runCli(['project', 'links']).stdout).toContain('No project links.') + }) +}) diff --git a/tests/repo-grouping.test.ts b/tests/repo-grouping.test.ts index b7a03c44..3042f93b 100644 --- a/tests/repo-grouping.test.ts +++ b/tests/repo-grouping.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadDailyCache, projectDayIdentity, type DailyEntry } from '../src/daily-cache.js' import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' -import { __resetGitOriginCache, __setTempRoots, folderNameOriginKey, projectOriginKey, saveGitOrigins } from '../src/git-origin.js' +import { __resetGitOriginCache, __setTempRoots, folderNameOriginKey, linkedOriginKey, projectOriginKey, saveGitOrigins, setProjectLinks } from '../src/git-origin.js' import { filterProjectsByName, makeProjectFilter, setExactProjectPaths } from '../src/parser.js' import { spendProjectIdentity } from '../src/spend-flow.js' import type { ProjectSummary, SessionSummary } from '../src/types.js' @@ -284,3 +284,48 @@ describe('projects grouped by git repository', () => { }) }) }) + +describe('project links', () => { + it('puts a linked folder and its subfolders in the repository row, and unlinking restores them', () => { + const { a1, other, plain } = fixtures() + const sub = join(plain, 'site') + const projects = [live(a1, 5), live(other, 4), live(plain, 1), live(sub, 2)] + setProjectLinks({ [`${plain}/`]: 'github.com/getagentseal/codeburn' }) + + const rows = buildPayloadProjects(projects, null, homedir()) + expect(rows.map(r => [r.name, r.cost])).toEqual([['codeburn', 8], ['codeburn-app', 4]]) + expect(rows[0]!.path).toBe(a1) + expect(filterProjectsByName(projects, [a1]).map(p => p.projectPath).sort()).toEqual([a1, plain, sub].sort()) + expect(filterProjectsByName(projects, [sub]).map(p => p.projectPath).sort()).toEqual([a1, plain, sub].sort()) + + setProjectLinks({}) + expect(buildPayloadProjects(projects, null, homedir()).map(r => r.name)).toEqual(['codeburn', 'codeburn-app', 'site', 'codeburn-marketing']) + }) + + it('takes the longest link, and a link beats the folder\'s own origin', () => { + const { a1, other, plain } = fixtures() + const inner = join(plain, 'inner') + setProjectLinks({ [plain]: 'github.com/getagentseal/codeburn', [inner]: 'github.com/getagentseal/codeburn-app', [other]: 'github.com/getagentseal/codeburn' }) + + expect(linkedOriginKey(join(inner, 'deep'))).toBe('github.com/getagentseal/codeburn-app') + expect(linkedOriginKey(join(plain, 'other'))).toBe('github.com/getagentseal/codeburn') + expect(linkedOriginKey(`${plain}-ui`)).toBeNull() + const rows = buildPayloadProjects([live(a1, 5), live(other, 4), live(inner, 1)], null, homedir()) + expect(rows.map(r => [r.name, r.cost])).toEqual([['codeburn', 9], ['codeburn-app', 1]]) + }) + + it('does not widen an exclude', () => { + const { a1, a2, plain } = fixtures() + const projects = [live(a1, 5), live(a2, 3), live(plain, 1)] + setProjectLinks({ [plain]: 'github.com/getagentseal/codeburn' }) + expect(filterProjectsByName(projects, [], [plain]).map(p => p.projectPath).sort()).toEqual([a1, a2].sort()) + expect(filterProjectsByName(projects, [], [a1]).map(p => p.projectPath).sort()).toEqual([a2, plain].sort()) + }) + + it('matches Windows-style links on folder boundaries', () => { + setProjectLinks({ 'C:\\Work\\Proj\\': 'github.com/o/proj' }) + expect(linkedOriginKey('C:/Work/Proj/sub')).toBe('github.com/o/proj') + expect(linkedOriginKey('C:\\Work\\Proj')).toBe('github.com/o/proj') + expect(linkedOriginKey('C:/Work/Project')).toBeNull() + }) +})