feat: project links put a folder in a repo project (#1695)

Adds codeburn project link <folder> <project>, project unlink <folder> and
project links, so a folder that is not a checkout of a repo can join that
repo's project row. Links live in config.json, apply at report time
wherever rows are grouped or filtered, cover subfolders (longest link
wins), and never touch the day cache, so unlink works on cached days.
This commit is contained in:
Resham Joshi 2026-10-08 17:41:23 +02:00 • committed by GitHub
parent f63fd960f6
commit eb51aba46a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 221 additions and 13 deletions

View file

@ -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<string, string>
// 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

View file

@ -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<ProjectSummary & { repo?: string }> {
const rows = new Map<string, ProjectSummary & { repo?: string }>()
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)

View file

@ -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<string, string> | 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 = []
}

View file

@ -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 <folder> <project>, project unlink <folder>, project links. <project> is the name shown in the project list (e.g. codeburn project link ~/crewroom crewroom).')
.option('--format <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 <folder> <project>\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 <folder> <project> | project unlink <folder> | 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.')

View file

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

View file

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

View file

@ -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<string, typeof rows>()
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}`)

View file

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

View file

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