From 2c9871b2f2244a8b5d97a997af1688e4153df04e Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:12:03 +0300 Subject: [PATCH] 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. --- src/yield.ts | 5 ++++- tests/yield-repo-grouping.test.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/yield.ts b/src/yield.ts index c0c2e7e7..ce3cbd05 100644 --- a/src/yield.ts +++ b/src/yield.ts @@ -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 } } } diff --git a/tests/yield-repo-grouping.test.ts b/tests/yield-repo-grouping.test.ts index 35f174bd..afc80afd 100644 --- a/tests/yield-repo-grouping.test.ts +++ b/tests/yield-repo-grouping.test.ts @@ -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 {