mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
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.
This commit is contained in:
parent
648fa40ebd
commit
740ec551e3
3 changed files with 33 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue