From 740ec551e3a0c3e7ecd91c8a438dcd777cb3e8d8 Mon Sep 17 00:00:00 2001 From: Mathieu Stanowski Date: Fri, 11 Sep 2026 17:29:55 +0200 Subject: [PATCH] fix(cli): expand a leading ~ in a project pattern A quoted '~/work/app' reaches the CLI unexpanded, and a pattern typed into a field has no shell behind it at all. It was neither rooted nor a substring of any stored path, so it selected nothing and said nothing. The README made it worse by telling people to quote it. A leading ~ is now expanded against the home directory and anchored like any absolute path, so the quoted form selects the same project as the one the shell would have expanded, and a wrong one is reported. Two more from reading the hot path: the compiled filter only pays for the project path key when some pattern is rooted and can read it, which the day cache runs per project, per day, per provider slice; and unmatchedRootedPatterns now calls matchesProjectPattern instead of open-coding it. --- README.md | 2 +- src/parser.ts | 27 ++++++++++++++++++++++----- tests/parser-filter.test.ts | 10 ++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fded41d4..b0625931 100644 --- a/README.md +++ b/README.md @@ -653,7 +653,7 @@ codeburn month --project api --project web # include multiple projects codeburn export --project inventory # export only "inventory" project data ``` -Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is the shell's to expand, so quote it and it is read as a plain word, not a path. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right. +Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is expanded against your home directory, so a quoted `'~/work/my-company'` selects the same project as the path the shell would have expanded. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right. ```bash codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window diff --git a/src/parser.ts b/src/parser.ts index b19b8ed1..1869765c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os' import { existsSync } from 'fs' import { lstat, readFile, readdir, stat } from 'fs/promises' import { createHash } from 'crypto' @@ -3170,6 +3171,10 @@ export function setInteractiveScanUI(active = true): void { interactiveScanUI = active } +export function isInteractiveScanUI(): boolean { + return interactiveScanUI +} + // Machine-readable scan progress for the desktop app's first-run splash. Plain // CLI/terminal usage is untouched: emission is gated on CODEBURN_PROGRESS=1, // which only the app's cold-start warmup spawn sets. Each event is one @@ -4415,7 +4420,16 @@ export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: str export function isRootedProjectPattern(pattern: string): boolean { const raw = pattern.trim().replace(/\\/g, '/') - return raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw) + return raw.startsWith('/') || raw === '~' || raw.startsWith('~/') || /^[a-zA-Z]:\//.test(raw) +} + +/// A quoted "~/proj" reaches us unexpanded, and so does one typed into a field +/// with no shell behind it. Left as a loose word it would match nothing and say +/// nothing, since no stored path contains a tilde. +function expandTilde(pattern: string): string { + const raw = pattern.trim().replace(/\\/g, '/') + if (raw !== '~' && !raw.startsWith('~/')) return raw + return homedir().replace(/\\/g, '/') + raw.slice(1) } /// A pattern is normalized once and matched many times: the day cache runs the @@ -4426,7 +4440,7 @@ export type ProjectFilterTarget = { project: string; projectPath?: string } function compile(patterns: readonly string[]): CompiledPattern[] { return patterns.map(pattern => isRootedProjectPattern(pattern) - ? { rooted: true as const, anchor: normalizeAbsProjectPathKey(pattern) } + ? { rooted: true as const, anchor: normalizeAbsProjectPathKey(expandTilde(pattern)) } : { rooted: false as const, needle: pattern.toLowerCase() }) } @@ -4452,8 +4466,12 @@ export function makeProjectFilter( ): (entry: ProjectFilterTarget) => boolean { const inc = compile(include ?? []) const exc = compile(exclude ?? []) + // The key costs a trim, a global replace and three regex passes. The day cache + // runs this per project, per day, per provider slice, so it is only paid when + // some pattern is rooted and can actually read it. + const needsKey = inc.some(p => p.rooted) || exc.some(p => p.rooted) return entry => { - const key = normalizeAbsProjectPathKey(entry.projectPath ?? '') + const key = needsKey ? normalizeAbsProjectPathKey(entry.projectPath ?? '') : null if (inc.length > 0 && !inc.some(pattern => hit(entry, pattern, key))) return false if (exc.length > 0 && exc.some(pattern => hit(entry, pattern, key))) return false return true @@ -4471,8 +4489,7 @@ export function matchesProjectPattern(project: ProjectFilterTarget, pattern: str export function unmatchedRootedPatterns(projects: readonly ProjectFilterTarget[], patterns: readonly string[]): string[] { return patterns.filter(pattern => { if (!isRootedProjectPattern(pattern)) return false - const matches = makeProjectFilter([pattern]) - return !projects.some(entry => matches(entry)) + return !projects.some(entry => matchesProjectPattern(entry, pattern)) }) } diff --git a/tests/parser-filter.test.ts b/tests/parser-filter.test.ts index 829522d4..49c5a742 100644 --- a/tests/parser-filter.test.ts +++ b/tests/parser-filter.test.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os' + import { describe, it, expect } from 'vitest' import { filterProjectsByName } from '../src/parser.js' @@ -104,6 +106,14 @@ describe('filterProjectsByName', () => { expect(filterProjectsByName(projects, undefined, ['//'])).toEqual(projects) }) + it('expands a leading ~ the shell did not, and anchors it', () => { + const home = homedir().replace(/\\/g, '/') + const mine = [makeProject('app', `${home}/work/app`), makeProject('app-kit', `${home}/work/app-kit`)] + expect(filterProjectsByName(mine, ['~/work/app']).map(p => p.project)).toEqual(['app']) + expect(filterProjectsByName(mine, undefined, ['~/work/app']).map(p => p.project)).toEqual(['app-kit']) + expect(filterProjectsByName(mine, ['~/nowhere'])).toEqual([]) + }) + it('exclude matches path substring', () => { const result = filterProjectsByName(projects, undefined, ['/tmp']) expect(result.map(p => p.project)).not.toContain('sandbox')