mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
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:
parent
f63fd960f6
commit
eb51aba46a
9 changed files with 221 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
}
|
||||
|
|
|
|||
79
src/main.ts
79
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 <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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
41
tests/cli-project-link.test.ts
Normal file
41
tests/cli-project-link.test.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue