fix(yield): case-insensitive canonical repo grouping via realpathSync.native

Follow-up to #719, closing the two deltas flagged in the #714 discussion:

- fs.realpathSync preserves the caller's path casing on case-insensitive
  filesystems (macOS APFS, Windows), so the same repo reached through
  differently cased paths produced distinct canonical keys and split into
  separate groups. fs.realpathSync.native returns the on-disk casing, making
  the canonical key truly canonical. Regression test creates a real repo,
  references it through altered casing (guarded to case-insensitive
  filesystems), and asserts one group.

- Documents the accepted residual: a main repo moved on disk after
  'git worktree add' leaves git's linked-worktree metadata stale, so its
  worktrees may not unify; that is git-state staleness, not a grouping bug.
This commit is contained in:
ozymandiashh 2026-07-19 03:12:03 +03:00
parent 916119da21
commit 2c9871b2f2
2 changed files with 37 additions and 1 deletions

View file

@ -71,7 +71,7 @@ type RepoIdentity = {
function canonicalPath(path: string): string {
try {
return realpathSync(path)
return realpathSync.native(path)
} catch {
return path
}
@ -110,6 +110,9 @@ function resolveRepoIdentity(
// common-dir as a realpath but the main worktree's as ".git" relative to
// its (possibly symlinked) path, so both must be canonicalized to collapse
// to one key.
// Accepted residual: moving the main repo after `git worktree add` leaves
// Git's linked-worktree metadata stale, so its worktrees may not unify;
// that is Git-state staleness, not a grouping bug.
identity = { key: canonicalPath(resolve(dir, commonDir)), gitDir: dir }
}
}

View file

@ -1,4 +1,5 @@
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -81,6 +82,38 @@ const broadWindow = {
}
describe('yield repo grouping by canonical repository identity (issue #713)', () => {
it('groups path casing variants on case-insensitive filesystems', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-yield-path-case-'))
try {
initRepo(repoDir)
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: shipped once', '2026-01-01T10:30:00Z')
const caseAlteredRepoDir = join(repoDir, '..', repoDir.split('/').pop()!.toUpperCase())
if (!existsSync(caseAlteredRepoDir)) return
const sessionOriginal = makeSession({ sessionId: 'case-original', project: 'app', ...tightWindow, totalCostUSD: 5 })
const sessionAltered = makeSession({ sessionId: 'case-altered', project: 'app', ...broadWindow, totalCostUSD: 3 })
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [sessionOriginal] } as ProjectSummary,
{ project: 'app', projectPath: caseAlteredRepoDir, sessions: [sessionAltered] } as ProjectSummary,
])
const summary = await computeYield(range, repoDir)
const productive = summary.details.filter(d => d.category === 'productive')
expect(productive.map(d => d.sessionId)).toEqual(['case-original'])
expect(productive[0]!.commitCount).toBe(1)
const detailAltered = summary.details.find(d => d.sessionId === 'case-altered')!
expect(detailAltered.category).toBe('ambiguous')
expect(detailAltered.commitCount).toBe(0)
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('credits one commit once across two monorepo subdirectory sessions', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-yield-monorepo-'))
try {