Break out test and generated lines in branch line total (#13057)

* rm comments

* reduce comment
This commit is contained in:
Jinjing 2026-08-07 09:55:54 -07:00 committed by GitHub
parent 02a1251c2d
commit 46b9d3b13a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 995 additions and 68 deletions

View File

@ -67,6 +67,8 @@ import {
} from './status'
const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5)
// No fixture path here looks like test or generated code, so it is all source.
const NO_LINES = { added: 0, removed: 0 }
const tempRoots: string[] = []
function git(repo: string, args: string[]): string {
@ -257,7 +259,13 @@ describe('branch line total exec budget', () => {
getStatus(repo, { branchLineTotalMergeBase: mergeBase })
])
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
expect(first.branchLineTotal).toEqual({
added: 1,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
expect(rangedDiffCalls()).toHaveLength(1)
})
@ -280,7 +288,13 @@ describe('branch line total exec budget', () => {
getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 })
])
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
expect(first.branchLineTotal).toEqual({
added: 1,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
// Proves the saving came from the branch-total coalescer: both passes really
// ran their own `git status`, so the status lease merged nothing.
@ -306,7 +320,13 @@ describe('branch line total exec budget', () => {
})
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
expect(second.branchLineTotal).toEqual({
added: 1,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(numstatCalls()).toEqual([])
})
@ -324,7 +344,13 @@ describe('branch line total exec budget', () => {
reuseLineStats: true
})
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase })
expect(result.branchLineTotal).toEqual({
added: 0,
removed: 0,
mergeBase: laterMergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(rangedDiffCalls()).toHaveLength(1)
})
})

View File

@ -40,6 +40,9 @@ function commitAll(repo: string, message: string): string {
return git(repo, ['rev-parse', 'HEAD'])
}
// No fixture path here looks like test or generated code, so it is all source.
const NO_LINES = { added: 0, removed: 0 }
/** What a plain `git diff <mergeBase>` reports, i.e. the number the chip must not disagree with. */
function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } {
let added = 0
@ -80,7 +83,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 1,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([
['staged', 1, 0],
['unstaged', 1, 1]
@ -97,7 +106,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 0,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('keeps the total across a commit and updates it on the next status call after an edit', async () => {
@ -107,15 +122,33 @@ describe('branch line total against a real repository', () => {
await write(repo, 'f.txt', 'a\nb\nc\n')
const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
expect(beforeCommit.branchLineTotal).toEqual({
added: 2,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
commitAll(repo, 'commit the edit')
const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
expect(afterCommit.branchLineTotal).toEqual({
added: 2,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
await write(repo, 'f.txt', 'a\nb\nc\nd\n')
const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
expect(afterSecondEdit.branchLineTotal).toEqual({
added: 3,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('counts a rename across the commit boundary once, using the post-rename path', async () => {
@ -129,7 +162,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 1,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('counts an untracked-only branch from the untracked file contents', async () => {
@ -141,7 +180,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(result.entries.map((entry) => entry.area)).toEqual(['untracked'])
expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 3,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('excludes a binary-only change, matching numstat reporting it as "-"', async () => {
@ -153,7 +198,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/)
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 0,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('contributes nothing for a pure rename', async () => {
@ -164,7 +215,33 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 0,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
it('splits test-path and generated files out of the published total', async () => {
const repo = await createFixtureRepo()
await write(repo, 'src/a.ts', 'a\n')
const mergeBase = commitAll(repo, 'base')
await write(repo, 'src/a.ts', 'a\nb\n')
await write(repo, 'src/a.test.ts', 't1\nt2\nt3\n')
await write(repo, 'pnpm-lock.yaml', 'l1\nl2\nl3\nl4\nl5\n')
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
expect(result.branchLineTotal).toEqual({
added: 9,
removed: 0,
mergeBase,
test: { added: 3, removed: 0 },
generated: { added: 5, removed: 0 }
})
})
it('includes untracked additions alongside tracked range changes', async () => {
@ -178,7 +255,13 @@ describe('branch line total against a real repository', () => {
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 5,
removed: 0,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
// Documented deviation from a pure `git diff`: the untracked half is added on
@ -198,6 +281,12 @@ describe('branch line total against a real repository', () => {
['untracked', 'gone.txt']
])
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 })
expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase })
expect(result.branchLineTotal).toEqual({
added: 2,
removed: 3,
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
})
})

View File

@ -26,7 +26,13 @@ const execFileAsync = promisify(execFile)
// flip.txt 0 (added in the branch commit, removed again in the worktree)
// moved.txt 0 (pure rename)
// fresh.txt +3 (untracked)
const EXPECTED_TOTAL = { added: 6, removed: 0 }
// No fixture path here looks like test or generated code, so it is all source.
const EXPECTED_TOTAL = {
added: 6,
removed: 0,
test: { added: 0, removed: 0 },
generated: { added: 0, removed: 0 }
}
// Summing the per-area status rows instead would give this — the wrong answer
// the shared module exists to avoid.
const AREA_ROW_SUM = { added: 5, removed: 2 }

View File

@ -24,6 +24,8 @@ const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98'
// Untracked additions ride on top of the ranged diff, so the fixture needs a real file.
const UNTRACKED_FILE = 'notes.md'
const UNTRACKED_LINES = 4
// No fixture path here looks like test or generated code, so it is all source.
const NO_LINES = { added: 0, removed: 0 }
const STATUS_OUTPUT = [
'# branch.oid 1111111111111111111111111111111111111111',
'# branch.head (detached)',
@ -166,7 +168,9 @@ describe('getStatusOp branch line total', () => {
expect(result.branchLineTotal).toEqual({
added: 3 + UNTRACKED_LINES,
removed: 2,
mergeBase
mergeBase,
test: NO_LINES,
generated: NO_LINES
})
expect(rangedDiffCalls(git.mock.calls)).toEqual([
['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
@ -352,7 +356,9 @@ describe('getStatusOp branch line total', () => {
expect(first.branchLineTotal).toEqual({
added: 12 + UNTRACKED_LINES,
removed: 5,
mergeBase: MERGE_BASE
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
expect(reused.branchLineTotal).toEqual(first.branchLineTotal)
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
@ -391,7 +397,9 @@ describe('getStatusOp branch line total', () => {
expect(first.branchLineTotal).toEqual({
added: 12 + UNTRACKED_LINES,
removed: 5,
mergeBase: MERGE_BASE
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
})
@ -411,7 +419,9 @@ describe('getStatusOp branch line total', () => {
expect(moved.branchLineTotal).toEqual({
added: 12 + UNTRACKED_LINES,
removed: 5,
mergeBase: OTHER_MERGE_BASE
mergeBase: OTHER_MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2)
})
@ -466,7 +476,9 @@ describe('getStatusOp branch line total', () => {
expect(result.branchLineTotal).toEqual({
added: UNTRACKED_LINES,
removed: 0,
mergeBase: MERGE_BASE
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true)
expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true)

View File

@ -207,6 +207,12 @@ function chip(): HTMLElement | null {
return container.querySelector<HTMLElement>('[data-testid="source-control-branch-line-total"]')
}
function loadingChip(): HTMLElement | null {
return container.querySelector<HTMLElement>(
'[data-testid="source-control-branch-line-total-loading"]'
)
}
describe('SourceControl branch line total request gate', () => {
it('asks for a total when the panel is visible and compare is ready', () => {
renderSourceControl()
@ -277,14 +283,14 @@ describe('SourceControl branch line total chip', () => {
})
renderSourceControl()
expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions')
expect(chip()?.getAttribute('aria-label')).toBe('8259 lines added, 670 lines deleted')
// Grouping is pinned to the app locale (`en`), not the runner's host locale.
expect(chip()?.textContent).toBe('+8,259-670')
})
it('drops a total whose fork point has since moved', () => {
// Why: status and branch compare refresh on different cadences, so a total
// can outlive the merge base it measured. Hidden beats a stale number.
// can outlive the merge base it measured. Stale digits must not render.
resetState({
gitBranchLineTotalByWorktree: {
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' }
@ -293,6 +299,7 @@ describe('SourceControl branch line total chip', () => {
renderSourceControl()
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
})
it('drops a published total while branch compare has no ready summary', () => {
@ -307,12 +314,27 @@ describe('SourceControl branch line total chip', () => {
renderSourceControl()
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
})
it('renders nothing when no total was published', () => {
renderSourceControl()
// Why: a pending total and one this host will never send look identical from
// here, so a pulsing placeholder would keep pulsing forever on an old host,
// after a hard failure, or during the ranged-diff cooldown.
it('shows no placeholder while the total is still pending', () => {
vi.useFakeTimers()
try {
renderSourceControl()
expect(chip()).toBeNull()
expect(chip()).toBeNull()
act(() => {
vi.advanceTimersByTime(20_000)
})
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
expect(container.innerHTML).not.toContain('animate-pulse')
} finally {
vi.useRealTimers()
}
})
it('renders nothing for an exact zero total', () => {
@ -324,6 +346,7 @@ describe('SourceControl branch line total chip', () => {
renderSourceControl()
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
})
it('omits the zero half of a one-sided total', () => {
@ -335,6 +358,6 @@ describe('SourceControl branch line total chip', () => {
renderSourceControl()
expect(chip()?.textContent).toBe('+42')
expect(chip()?.getAttribute('aria-label')).toBe('42 additions')
expect(chip()?.getAttribute('aria-label')).toBe('42 lines added')
})
})

View File

@ -11,6 +11,12 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
const readySummary: GitBranchCompareSummary = {
baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip',
baseOid: 'base',
@ -241,7 +247,7 @@ describe('SourceControlBranchContextRow branch line total', () => {
expect(markup).toContain('+8,259')
expect(markup).toContain('-670')
// Label reads raw digits; the grouped spans are decoration.
expect(markup).toContain('aria-label="8259 additions, 670 deletions"')
expect(markup).toContain('aria-label="8259 lines added, 670 lines deleted"')
expect(markup).toContain('tabular-nums')
expect(markup).toContain('text-[color:var(--git-decoration-added)]')
expect(markup).toContain('text-[color:var(--git-decoration-deleted)]')
@ -286,12 +292,12 @@ describe('SourceControlBranchContextRow branch line total', () => {
const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' })
expect(addedOnly).toContain('>+42<')
expect(addedOnly).not.toContain('>-0<')
expect(addedOnly).toContain('aria-label="42 additions"')
expect(addedOnly).toContain('aria-label="42 lines added"')
const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' })
expect(removedOnly).toContain('>-7<')
expect(removedOnly).not.toContain('>+0<')
expect(removedOnly).toContain('aria-label="7 deletions"')
expect(removedOnly).toContain('aria-label="7 lines deleted"')
})
it('hides the chip when both counts are zero', () => {
@ -302,10 +308,13 @@ describe('SourceControlBranchContextRow branch line total', () => {
expect(markup).not.toContain('>-0<')
})
it('hides the chip when the total is absent', () => {
it('renders nothing at all when the total is absent', () => {
// Why: a null total is equally "still computing" and "this host will never
// send one", so there is no placeholder to show — the slot stays empty.
for (const total of [null, undefined]) {
const markup = renderWithLineTotal(total)
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('animate-pulse')
expect(markup).not.toContain('NaN')
}
})
@ -382,3 +391,156 @@ describe('SourceControlBranchContextRow branch line total', () => {
}
})
})
// The hover-card mock at the top renders content inline, so these assert the
// breakdown copy without driving a real hover.
describe('SourceControlBranchContextRow line total test split', () => {
it('breaks the panel down into test and non-test halves', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 }
})
expect(markup).toContain('Code breakdown')
expect(markup).toContain('Lines of code')
expect(markup).toContain('data-testid="source-control-branch-line-total-breakdown"')
expect(markup).toContain('Non-test')
expect(markup).toContain('>+123<')
expect(markup).toContain('>-109<')
expect(markup).toContain('Tests')
expect(markup).toContain('>+120<')
expect(markup).toContain('>-40<')
// Non-test first so the primary share leads the panel.
expect(markup.indexOf('Non-test')).toBeLessThan(markup.indexOf('Tests'))
})
it('spells the split into the label so the summary is announced closed', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 }
})
expect(markup).toContain(
'aria-label="243 lines added, 149 lines deleted — test code: 120 lines added, 40 lines deleted"'
)
})
it('keeps a zero test share visible rather than hiding the row', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 0, removed: 0 }
})
expect(markup).toContain('>+0<')
expect(markup).toContain('>-0<')
expect(markup).toContain('>+243<')
expect(markup).toContain('>-149<')
// Panel keeps the zero row; the spoken label stays the main totals only.
expect(markup).toContain('aria-label="243 lines added, 149 lines deleted"')
expect(markup).not.toContain('test code:')
})
// A host predating the split omits the field; inventing a 0% test share there
// would be a confidently wrong claim.
it('renders exactly as before when the host reported no split', () => {
const withoutSplit = renderWithLineTotal({ added: 243, removed: 149, mergeBase: 'base' })
expect(withoutSplit).not.toContain('Non-test')
expect(withoutSplit).not.toContain('Code breakdown')
expect(withoutSplit).toContain('aria-label="243 lines added, 149 lines deleted"')
})
it('labels the remainder Source once generated is known, and shows that row', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 20, removed: 10 },
generated: { added: 100, removed: 50 }
})
expect(markup).toContain('Source')
expect(markup).not.toContain('Non-test')
expect(markup).toContain('Generated')
expect(markup).toContain('>+123<') // 243-20-100
expect(markup).toContain('>-89<') // 149-10-50
expect(markup).toContain('>+100<')
expect(markup).toContain('>-50<')
expect(markup).toContain(
'aria-label="243 lines added, 149 lines deleted — test code: 20 lines added, 10 lines deleted — generated: 100 lines added, 50 lines deleted"'
)
})
// The remainder here still contains tests, so calling it "Source" would claim
// a split the host never sent.
it('labels the remainder Non-generated when the host omitted the test field', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
generated: { added: 100, removed: 50 }
})
expect(markup).toContain('Non-generated')
expect(markup).toContain('>Generated<')
expect(markup).not.toContain('>Source<')
expect(markup).not.toContain('Tests')
expect(markup).toContain('>+143<') // 243-100
expect(markup).toContain('>-99<') // 149-50
})
// "No tests in this branch" is worth stating; "nothing was generated" is the
// normal case, so that row is dropped rather than shown as +0 -0.
it('drops the generated row and its announcement when nothing was generated', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 },
generated: { added: 0, removed: 0 }
})
expect(markup).not.toContain('Generated')
expect(markup).not.toContain('generated:')
// The host did classify and found nothing, so the remainder is still Source.
expect(markup).toContain('Source')
expect(markup).toContain('Tests')
expect(markup).toContain('>+123<')
})
it('orders the rows source, tests, generated', () => {
const markup = renderWithLineTotal({
added: 1243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 },
generated: { added: 1000, removed: 0 }
})
expect(markup.indexOf('Source')).toBeLessThan(markup.indexOf('Tests'))
expect(markup.indexOf('Tests')).toBeLessThan(markup.indexOf('Generated'))
expect(markup).toContain('>+1,000<')
})
// The three rows must account for every line, or the panel contradicts the chip.
it('keeps the three rows summing back to the chip total', () => {
const markup = renderWithLineTotal({
added: 500,
removed: 200,
mergeBase: 'base',
test: { added: 150, removed: 60 },
generated: { added: 300, removed: 100 }
})
expect(markup).toContain('>+50<') // 500-150-300
expect(markup).toContain('>-40<') // 200-60-100
expect(markup).toContain('>+150<')
expect(markup).toContain('>+300<')
})
})

View File

@ -1,5 +1,6 @@
import React, { useMemo } from 'react'
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { getIntlLocale, translate } from '@/i18n/i18n'
// Why: raw digits, not the grouped display string — screen readers announce
@ -8,26 +9,106 @@ function buildAccessibleLabel(added: number, removed: number): string {
if (added > 0 && removed > 0) {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b',
'{{value0}} additions, {{value1}} deletions',
'{{value0}} lines added, {{value1}} lines deleted',
{ value0: added, value1: removed }
)
}
if (added > 0) {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666',
'{{value0}} additions',
'{{value0}} lines added',
{ value0: added }
)
}
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d',
'{{value0}} deletions',
'{{value0}} lines deleted',
{ value0: removed }
)
}
// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no
// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower.
// The chip takes no focus; the split still reaches assistive tech through the
// label rather than the hover-only panel.
function appendTestSplitToLabel(label: string, testAdded: number, testRemoved: number): string {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.4c1f70ba92',
'{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted',
{ value0: label, value1: testAdded, value2: testRemoved }
)
}
function appendGeneratedSplitToLabel(label: string, added: number, removed: number): string {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.7f3e1a9c24',
'{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted',
{ value0: label, value1: added, value2: removed }
)
}
type LineTotalSplitRow = { key: string; label: string; added: number; removed: number }
function LineCountPair({
added,
removed,
locale
}: {
added: number
removed: number
locale: string
}): React.JSX.Element {
return (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap tabular-nums">
<span className="text-[color:var(--git-decoration-added)]">
+{added.toLocaleString(locale)}
</span>
<span className="text-[color:var(--git-decoration-deleted)]">
-{removed.toLocaleString(locale)}
</span>
</span>
)
}
function CodeBreakdownPanel({
rows,
locale
}: {
rows: LineTotalSplitRow[]
locale: string
}): React.JSX.Element {
return (
<div className="min-w-[14rem]" data-testid="source-control-branch-line-total-breakdown">
<div className="border-b border-border/60 px-3 py-2.5">
<div className="text-sm font-semibold text-popover-foreground">
{translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.a1b2c3d4e5',
'Code breakdown'
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.b2c3d4e5f6',
'Lines of code'
)}
</div>
</div>
<div className="space-y-1.5 px-3 py-2.5">
{rows.map((row) => (
<div key={row.key} className="flex items-center justify-between gap-4 text-xs leading-5">
<span className="text-muted-foreground">{row.label}</span>
<LineCountPair added={row.added} removed={row.removed} locale={locale} />
</div>
))}
</div>
</div>
)
}
// Genuinely-empty (`+0 -0`), not-yet-published and unknown-after-timeout all
// render as nothing: no reserved width, no placeholder. There is no loading
// state because a null total is indistinguishable from permanent absence (old
// host, failed diff, cooldown), and the store now keeps the last published
// total across a soft miss so the slot rarely empties once filled.
// Not clickable — `openBranchAllDiffs` is narrower.
export const SourceControlBranchLineTotalChip = React.memo(
function SourceControlBranchLineTotalChip({
branchLineTotal
@ -38,12 +119,93 @@ export const SourceControlBranchLineTotalChip = React.memo(
const removed = branchLineTotal?.removed ?? 0
const hasAdded = added > 0
const hasRemoved = removed > 0
// Why: hosts predating either split omit the field; inventing a zero share
// there would be confidently wrong, so the breakdown is dropped per field.
const testTotal = branchLineTotal?.test
const testAdded = testTotal?.added
const testRemoved = testTotal?.removed
const generatedTotal = branchLineTotal?.generated
const generatedAdded = generatedTotal?.added
const generatedRemoved = generatedTotal?.removed
// Full precision and app-locale-aware; a status tick that leaves the counts
// alone must not rebuild these strings.
const locale = getIntlLocale()
const addedText = useMemo(() => added.toLocaleString(locale), [added, locale])
const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale])
const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed])
const accessibleLabel = useMemo(() => {
let label = buildAccessibleLabel(added, removed)
// Zero test share still shows in the hover panel; don't announce noise.
if (testAdded != null && testRemoved != null && (testAdded > 0 || testRemoved > 0)) {
label = appendTestSplitToLabel(label, testAdded, testRemoved)
}
if (
generatedAdded != null &&
generatedRemoved != null &&
(generatedAdded > 0 || generatedRemoved > 0)
) {
label = appendGeneratedSplitToLabel(label, generatedAdded, generatedRemoved)
}
return label
}, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved])
const splitRows = useMemo<LineTotalSplitRow[]>(() => {
// Why: the fields ship together today but the type is independent so a
// host can gain one before the other; either alone is enough to render.
const hasTest = testAdded != null && testRemoved != null
const hasGenerated = generatedAdded != null && generatedRemoved != null
if (!hasTest && !hasGenerated) {
return []
}
const rows: LineTotalSplitRow[] = [
{
key: 'source',
// Only "Source" once both splits are known — with one missing the
// remainder still contains the other bucket, so name what was taken out.
label:
hasTest && hasGenerated
? translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.c8d5b21e07',
'Source'
)
: hasTest
? translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.9e4a3c5081',
'Non-test'
)
: translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.3d7e9b1042',
'Non-generated'
),
added: added - (testAdded ?? 0) - (generatedAdded ?? 0),
removed: removed - (testRemoved ?? 0) - (generatedRemoved ?? 0)
}
]
if (hasTest) {
rows.push({
key: 'test',
label: translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.6b2d0f14a7',
'Tests'
),
added: testAdded,
removed: testRemoved
})
}
// Why: "no tests in this branch" is worth showing as +0 -0; "nothing was
// generated" is the normal case, so that row is dropped instead.
if (hasGenerated && (generatedAdded > 0 || generatedRemoved > 0)) {
rows.push({
key: 'generated',
label: translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.7a04c6f8b3',
'Generated'
),
added: generatedAdded,
removed: generatedRemoved
})
}
return rows
}, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved])
if (!hasAdded && !hasRemoved) {
return null
@ -51,12 +213,18 @@ export const SourceControlBranchLineTotalChip = React.memo(
// Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone
// keeps digits from jittering between refreshes.
return (
// `cursor-help` signals hover detail without implying a click target.
const hasBreakdown = splitRows.length > 0
const chip = (
<span
role="group"
aria-label={accessibleLabel}
data-testid="source-control-branch-line-total"
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums"
className={
hasBreakdown
? 'inline-flex shrink-0 cursor-help items-center gap-1 whitespace-nowrap tabular-nums'
: 'inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums'
}
>
{hasAdded ? (
<span aria-hidden="true" className="text-[color:var(--git-decoration-added)]">
@ -70,5 +238,18 @@ export const SourceControlBranchLineTotalChip = React.memo(
) : null}
</span>
)
if (!hasBreakdown) {
return chip
}
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>{chip}</HoverCardTrigger>
<HoverCardContent align="end" side="bottom" sideOffset={6} className="w-auto p-0">
<CodeBreakdownPanel rows={splitRows} locale={locale} />
</HoverCardContent>
</HoverCard>
)
}
)

View File

@ -132,7 +132,7 @@ describe('SourceControlHeaderToolbar branch identity', () => {
it('threads the branch line total down to the base-ref line', () => {
const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } })
expect(markup).toContain('aria-label="24 additions, 3 deletions"')
expect(markup).toContain('aria-label="24 lines added, 3 lines deleted"')
expect(markup).toContain('+24')
expect(markup).toContain('-3')
})

View File

@ -11359,9 +11359,18 @@
"line": {
"total": {
"chip": {
"daa8e8e59b": "{{value0}} additions, {{value1}} deletions",
"8a9b97b666": "{{value0}} additions",
"52c366d88d": "{{value0}} deletions"
"daa8e8e59b": "{{value0}} lines added, {{value1}} lines deleted",
"8a9b97b666": "{{value0}} lines added",
"52c366d88d": "{{value0}} lines deleted",
"4c1f70ba92": "{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted",
"6b2d0f14a7": "Tests",
"9e4a3c5081": "Non-test",
"3d7e9b1042": "Non-generated",
"a1b2c3d4e5": "Code breakdown",
"b2c3d4e5f6": "Lines of code",
"7f3e1a9c24": "{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted",
"c8d5b21e07": "Source",
"7a04c6f8b3": "Generated"
}
}
}

View File

@ -69,7 +69,7 @@ describe('createEditorSlice branch line total', () => {
})
})
it('clears a stored total when a later status omits the field', () => {
it('keeps the published total when a later status omits the field', () => {
const store = createEditorStore()
store
.getState()
@ -77,12 +77,25 @@ describe('createEditorSlice branch line total', () => {
'wt-1',
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
)
const published = store.getState().gitBranchLineTotalByWorktree['wt-1']
// Why: an omitted field means "not known exact" — an old server, a failed
// numstat, or a timeout. Keeping the old number renders a confident lie.
// Why: an omitted field is "not computed on this pass" — a soft-deadline
// miss or a cooldown — and the host keeps backfilling its cache. Clearing
// it blanked the chip mid-poll and then flashed it back.
store.getState().setGitStatus('wt-1', status())
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBe(published)
})
it('does not produce a new state object when a status merely omits the total', () => {
const store = createEditorStore()
const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
store.getState().setGitStatus('wt-1', tick)
const before = store.getState()
store.getState().setGitStatus('wt-1', { ...tick, branchLineTotal: undefined })
expect(store.getState()).toBe(before)
})
it('clears a stored total when the listing hits the entry cap', () => {
@ -132,7 +145,7 @@ describe('createEditorSlice branch line total', () => {
status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } })
)
store.getState().setGitStatus('wt-1', status())
store.getState().setGitStatus('wt-1', status({ didHitLimit: true, statusLength: 2 }))
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({
@ -203,6 +216,69 @@ describe('createEditorSlice branch line total', () => {
})
})
// Moving a line from a source file into a test file leaves the totals
// identical; without the split in the equality check the hover would keep
// showing the old share.
it('produces a new state object when only the test split changed', () => {
const store = createEditorStore()
const tick = status({
branchLineTotal: {
added: 24,
removed: 3,
mergeBase: MERGE_BASE,
test: { added: 4, removed: 0 }
}
})
store.getState().setGitStatus('wt-1', tick)
const before = store.getState()
store.getState().setGitStatus('wt-1', {
...tick,
branchLineTotal: {
added: 24,
removed: 3,
mergeBase: MERGE_BASE,
test: { added: 5, removed: 1 }
}
})
expect(store.getState()).not.toBe(before)
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.test).toEqual({
added: 5,
removed: 1
})
})
it('produces a new state object when only the generated split changed', () => {
const store = createEditorStore()
const tick = status({
branchLineTotal: {
added: 24,
removed: 3,
mergeBase: MERGE_BASE,
generated: { added: 8, removed: 0 }
}
})
store.getState().setGitStatus('wt-1', tick)
const before = store.getState()
store.getState().setGitStatus('wt-1', {
...tick,
branchLineTotal: {
added: 24,
removed: 3,
mergeBase: MERGE_BASE,
generated: { added: 12, removed: 1 }
}
})
expect(store.getState()).not.toBe(before)
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.generated).toEqual({
added: 12,
removed: 1
})
})
it('leaves other worktree entries referentially stable when one total changes', () => {
const store = createEditorStore()
store

View File

@ -4193,15 +4193,29 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null)
const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null
// Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip.
const nextBranchLineTotal = status.branchLineTotal ?? null
// Why: an omitted field means "not computed on this pass" — soft-deadline
// miss, cooldown, old host — not "zero", so dropping it blanks a published
// chip between polls. Staleness is handled where it can be: the host
// carries its cache forward, and SourceControl hides any total whose
// mergeBase no longer matches the fork point. A capped listing skips the
// ranged diff outright, so nothing will refresh it — clear it there.
const nextBranchLineTotal = status.didHitLimit
? null
: (status.branchLineTotal ?? prevBranchLineTotal)
const branchLineTotalUnchanged =
prevBranchLineTotal === nextBranchLineTotal ||
(prevBranchLineTotal !== null &&
nextBranchLineTotal !== null &&
prevBranchLineTotal.added === nextBranchLineTotal.added &&
prevBranchLineTotal.removed === nextBranchLineTotal.removed &&
prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase)
prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase &&
(prevBranchLineTotal.test?.added ?? null) === (nextBranchLineTotal.test?.added ?? null) &&
(prevBranchLineTotal.test?.removed ?? null) ===
(nextBranchLineTotal.test?.removed ?? null) &&
(prevBranchLineTotal.generated?.added ?? null) ===
(nextBranchLineTotal.generated?.added ?? null) &&
(prevBranchLineTotal.generated?.removed ?? null) ===
(nextBranchLineTotal.generated?.removed ?? null))
const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId]
const nextStatusHead = getKnownGitHead(status.head)

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { isGeneratedCodePath } from './generated-code-path'
describe('isGeneratedCodePath', () => {
it('recognizes dependency lockfiles across ecosystems', () => {
for (const filePath of [
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'bun.lockb',
'Cargo.lock',
'poetry.lock',
'uv.lock',
'Gemfile.lock',
'composer.lock',
'go.sum',
'flake.lock',
'mobile/pubspec.lock',
'services/api/Pipfile.lock'
]) {
expect(isGeneratedCodePath(filePath), filePath).toBe(true)
}
})
it('recognizes tool-stamped filename suffixes', () => {
for (const filePath of [
'api/service.pb.go',
// grpc-gateway / protoc-gen-validate stack extra segments onto `.pb.`
'api/service.pb.gw.go',
'api/service.pb.validate.go',
'api/service_pb2.py',
'api/service_pb2_grpc.py',
'src/schema.gen.ts',
'internal/bindings_generated.go',
'src/Api.generated.ts',
'Forms/Main.Designer.cs',
'lib/model.g.dart',
'lib/model.freezed.dart',
'public/app.min.js',
'public/app.js.map',
'src/components/__snapshots__/Chip.tsx.snap'
]) {
expect(isGeneratedCodePath(filePath), filePath).toBe(true)
}
})
it('recognizes generated directories on either separator', () => {
expect(isGeneratedCodePath('dist/renderer/index.js')).toBe(true)
expect(isGeneratedCodePath('src/__generated__/schema.ts')).toBe(true)
expect(isGeneratedCodePath('vendor/github.com/pkg/errors/errors.go')).toBe(true)
expect(isGeneratedCodePath('src\\__pycache__\\mod.pyc')).toBe(true)
})
it('leaves hand-written paths alone, including the ambiguous directory names', () => {
for (const filePath of [
'src/shared/git-branch-line-total.ts',
// Excluded on purpose: all common as authored source directories.
'build/scripts/release.ts',
'target/tracker.rs',
'src/out/renderer.ts',
'cmd/bin/main.go',
// Substrings, not whole segments or suffixes.
'src/distributed/queue.ts',
'src/vendors/stripe.ts',
'src/generator/emit.ts',
'docs/generated-code-policy.md',
'src/shared/generated-code-path.ts'
]) {
expect(isGeneratedCodePath(filePath), filePath).toBe(false)
}
})
})

View File

@ -0,0 +1,50 @@
/**
* Path-only heuristic for "was this file written by a tool?", used to carve
* machine-authored lines out of the branch total. A regenerated lockfile or
* protobuf stub can dwarf every hand-written line in a branch, which is what
* makes a bare `+8,259` misleading.
*
* Conservative on purpose: a false positive understates the real work, so only
* names that are unambiguous across ecosystems are listed. Matched against the
* raw path with anchored regexes, for the reasons in `test-code-path.ts`.
*/
// Deliberately excludes `build`, `target`, `out` and `bin` — all common as
// hand-written source directories.
const GENERATED_DIRECTORY =
/(?:^|[/\\])(?:__generated__|__pycache__|\.next|\.nuxt|coverage|dist|generated|node_modules|vendor)[/\\]/i
// Every dependency lockfile is regenerated wholesale, so its diff is churn
// rather than authored change. Kept separate from the suffix alternation below
// rather than merged into it: measured over 12.5k paths, two narrow anchored
// regexes reject a non-match faster than one wide alternation.
const LOCKFILE =
/(?:^|[/\\])(?:bun\.lockb?|cargo\.lock|composer\.lock|flake\.lock|gemfile\.lock|go\.sum|gradle\.lockfile|mix\.lock|npm-shrinkwrap\.json|package-lock\.json|packages\.lock\.json|pipfile\.lock|pnpm-lock\.yaml|poetry\.lock|pubspec\.lock|uv\.lock|yarn\.lock)$/i
// The suffixes tools stamp onto their own output.
const GENERATED_BASENAME = new RegExp(
`(?:${[
/\.(?:generated|designer)\.[^./\\]+/, // Foo.generated.ts, Form.Designer.cs
// Trailing segments are optional so plugin output keeps matching:
// service.pb.go, service_pb2.py, service.pb.gw.go, service.pb.validate.go.
/[._](?:pb|pb2|pb2_grpc)\.(?:[^./\\]+\.)*[^./\\]+/,
/[._]gen\.[^./\\]+/, // schema.gen.ts, mock_gen.go
/_generated\.[^./\\]+/, // bindings_generated.go
/\.(?:g|freezed)\.dart/, // model.g.dart
/\.min\.(?:js|css|mjs)/,
/\.(?:js|css|mjs)\.map/, // source maps
/\.snap/ // vitest/jest snapshots, rewritten by `-u`
]
.map((pattern) => pattern.source)
.join('|')})$`,
'i'
)
/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */
export function isGeneratedCodePath(filePath: string): boolean {
return (
GENERATED_BASENAME.test(filePath) ||
LOCKFILE.test(filePath) ||
GENERATED_DIRECTORY.test(filePath)
)
}

View File

@ -15,6 +15,7 @@ import {
import type { GitLineStats } from './git-uncommitted-line-stats'
const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4'
const NO_LINES = { added: 0, removed: 0 }
const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c'
function statsMap(entries: Record<string, GitLineStats>): ReadonlyMap<string, GitLineStats> {
@ -58,7 +59,7 @@ describe('sumGitBranchLineTotal', () => {
}),
untracked: statsMap({})
})
).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE })
).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('counts a half-binary entry on the side git did report', () => {
@ -68,7 +69,7 @@ describe('sumGitBranchLineTotal', () => {
tracked: statsMap({ 'odd.txt': { added: 3 } }),
untracked: statsMap({ 'new.bin': {} })
})
).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE })
).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('adds untracked additions on top of the tracked range and echoes the merge base', () => {
@ -78,7 +79,7 @@ describe('sumGitBranchLineTotal', () => {
tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }),
untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } })
})
).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE })
).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('returns an all-zero total for a pure rename rather than omitting it', () => {
@ -88,7 +89,71 @@ describe('sumGitBranchLineTotal', () => {
tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }),
untracked: statsMap({})
})
).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE })
).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('splits out the test-file share of both halves, untracked files included', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'e2e/login.ts': { added: 4, removed: 1 }
}),
untracked: statsMap({ 'src/b.spec.ts': { added: 6 }, 'src/b.ts': { added: 2 } })
})
).toEqual({
added: 52,
removed: 8,
mergeBase: MERGE_BASE,
test: { added: 40, removed: 6 },
generated: NO_LINES
})
})
it('splits generated lines out of both the total and the test share', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'pnpm-lock.yaml': { added: 900, removed: 400 },
'api/service.pb.go': { added: 120, removed: 0 },
// Generated wins the overlap: a snapshot is both, and counts as churn.
'src/__snapshots__/a.tsx.snap': { added: 50, removed: 20 }
}),
untracked: statsMap({ 'dist/bundle.js': { added: 5000 } })
})
).toEqual({
added: 6110,
removed: 427,
mergeBase: MERGE_BASE,
test: { added: 30, removed: 5 },
generated: { added: 6070, removed: 420 }
})
})
it('keeps the three buckets disjoint so they sum back to the total', () => {
const total = sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'go.sum': { added: 7, removed: 3 },
'tests/fixtures/repo.json': { added: 4, removed: 1 }
}),
untracked: statsMap({ 'src/b.ts': { added: 2 } })
})
const source = {
added: total.added - total.test!.added - total.generated!.added,
removed: total.removed - total.test!.removed - total.generated!.removed
}
expect(source).toEqual({ added: 12, removed: 2 })
expect(total.test).toEqual({ added: 34, removed: 6 })
expect(total.generated).toEqual({ added: 7, removed: 3 })
})
})
@ -178,7 +243,13 @@ describe('computeGitBranchLineTotal', () => {
untrackedPaths: ['new.txt'],
runDiffNumstat: async () => '4\t1\tsrc/a.ts\0'
})
).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE })
).resolves.toEqual({
added: 7,
removed: 1,
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
})
it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => {
@ -218,8 +289,8 @@ describe('computeGitBranchLineTotal', () => {
release()
expect(await both).toEqual([
{ added: 4, removed: 1, mergeBase: MERGE_BASE },
{ added: 4, removed: 1, mergeBase: MERGE_BASE }
{ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES },
{ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }
])
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
})
@ -365,7 +436,13 @@ describe('computeGitBranchLineTotal ranged-diff cooldown', () => {
}
}
const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE }
const TOTAL = {
added: 10,
removed: 2,
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
}
beforeEach(() => {
nowMs = 0

View File

@ -5,6 +5,8 @@ import {
parseNumstat,
type GitLineStats
} from './git-uncommitted-line-stats'
import { isGeneratedCodePath } from './generated-code-path'
import { isTestCodePath } from './test-code-path'
export type { GitBranchLineTotal }
@ -87,17 +89,37 @@ export function sumGitBranchLineTotal(input: {
}): GitBranchLineTotal {
let added = 0
let removed = 0
for (const stats of input.tracked.values()) {
// Binary files parse to undefined in numstat and contribute nothing, matching
// the per-file rows.
added += stats.added ?? 0
removed += stats.removed ?? 0
let testAdded = 0
let testRemoved = 0
let generatedAdded = 0
let generatedRemoved = 0
for (const source of [input.tracked, input.untracked]) {
for (const [filePath, stats] of source) {
// Binary files parse to undefined in numstat and contribute nothing, matching
// the per-file rows.
const fileAdded = stats.added ?? 0
const fileRemoved = stats.removed ?? 0
added += fileAdded
removed += fileRemoved
// Generated wins the overlap (a snapshot is both): the point of the bucket
// is to separate authored lines from churn, and a regenerated file is churn
// wherever it lives.
if (isGeneratedCodePath(filePath)) {
generatedAdded += fileAdded
generatedRemoved += fileRemoved
} else if (isTestCodePath(filePath)) {
testAdded += fileAdded
testRemoved += fileRemoved
}
}
}
for (const stats of input.untracked.values()) {
added += stats.added ?? 0
removed += stats.removed ?? 0
return {
added,
removed,
mergeBase: input.mergeBase,
test: { added: testAdded, removed: testRemoved },
generated: { added: generatedAdded, removed: generatedRemoved }
}
return { added, removed, mergeBase: input.mergeBase }
}
// Why: one shared exec per (host, worktree, mergeBase). The renderer's own

View File

@ -59,6 +59,10 @@ export type GitBranchLineTotal = {
added: number
removed: number
mergeBase: string
// Path-heuristic buckets of added/removed; generated wins on overlap. Optional
// for pre-split hosts (absent = unknown, not zero).
test?: { added: number; removed: number }
generated?: { added: number; removed: number }
}
export type GitStatusResult = {

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { isTestCodePath } from './test-code-path'
describe('isTestCodePath', () => {
it('recognizes the per-ecosystem filename conventions', () => {
for (const filePath of [
'src/renderer/src/components/Chip.test.tsx',
'src/shared/git-status.spec.js',
'internal/server/handler_test.go',
'app/models/user_spec.rb',
'tools/test_migration.py',
'src/main/java/com/orca/GitTest.java',
'src/Orca.Core/GitStatusTests.cs',
'src/UserTest.kt',
'AppTests.swift',
'FooSpec.rb',
'python/conftest.py',
'src/components/__snapshots__/Chip.tsx.snap'
]) {
expect(isTestCodePath(filePath), filePath).toBe(true)
}
})
it('recognizes test directories anywhere in the path, on either separator', () => {
expect(isTestCodePath('src/__tests__/git-status.ts')).toBe(true)
expect(isTestCodePath('tests/fixtures/repo.json')).toBe(true)
expect(isTestCodePath('e2e/login.ts')).toBe(true)
expect(isTestCodePath('src\\__mocks__\\fs.ts')).toBe(true)
})
it('matches whole segments, so production paths that merely contain the word do not count', () => {
for (const filePath of [
'src/latest/index.ts',
'src/contest/leaderboard.ts',
'src/testing-library-setup.ts',
'docs/protest.md',
'src/shared/git-status-types.ts',
// A `specs/` folder is usually specifications, not tests.
'src/cli/specs/account.ts',
// Case-insensitive "…test.ext" suffixes hit ordinary type names.
'src/Contest.java',
'src/Latest.kt',
'src/MyContest.php',
'packages/spec.rb',
// The `test_` prefix is pytest's; outside Python it hits fixtures and pages.
'src/pages/test_page.tsx',
'fixtures/test_data.json'
]) {
expect(isTestCodePath(filePath), filePath).toBe(false)
}
// ...but a real test inside one still matches on its basename.
expect(isTestCodePath('src/cli/specs/account.test.ts')).toBe(true)
expect(isTestCodePath('spec/models/user_spec.rb')).toBe(true)
})
it('handles a bare filename and an empty path without throwing', () => {
expect(isTestCodePath('chip.test.ts')).toBe(true)
expect(isTestCodePath('chip.ts')).toBe(false)
expect(isTestCodePath('')).toBe(false)
})
})

View File

@ -0,0 +1,43 @@
// Path-only test heuristic for branch line-total buckets. Conservative and
// anchored on the raw path (no lowercasing/splitting per file).
// Whole path segments only, so `src/latest/x.ts` and `contest/` don't count.
// The trailing separator is what keeps a *file* named `test.ts` out of here.
// `spec/` is RSpec's; plural `specs/` is left out because it far more often
// holds specifications (this repo's `src/cli/specs/` is production code), and a
// real test inside one still matches on its `.spec.`/`_spec.` basename.
const TEST_DIRECTORY =
/(?:^|[/\\])(?:__mocks__|__snapshots__|__tests__|cypress|e2e|spec|tests?|testdata)[/\\]/i
// One alternative per ecosystem convention; `[^./\\]` keeps each anchored match
// inside the basename. Joined into a single regex so V8 walks the path once.
const TEST_BASENAME = new RegExp(
`(?:${[
/\.(?:test|spec)\.[^./\\]+/, // Chip.test.tsx, git-status.spec.js
/[-_](?:test|spec)s?\.[^./\\]+/, // handler_test.go, user_spec.rb
// `.py` only: the bare `test_` prefix is pytest's convention, and widening it
// to any extension swept in fixtures and pages (test_data.json, test_page.tsx).
// A production helper that happens to be named `test_client.py` is
// indistinguishable from a real module here — pytest collects it too.
/(?:^|[/\\])test_[^./\\]*\.py/,
/(?:^|[/\\])conftest\.py/,
/\.snap/
]
.map((pattern) => pattern.source)
.join('|')})$`,
'i'
)
// Case-sensitive on purpose: a case-insensitive `test`/`tests`/`spec` suffix
// falsely matches ordinary type names like Contest.java, Latest.kt, MyContest.php.
// JVM/C#/Swift/PHP/Ruby test types use a capital T/S in the conventional suffix.
const TEST_TYPE_NAME_SUFFIX = /(?:Test|Tests|Spec)\.(?:java|kt|kts|scala|groovy|cs|swift|php|rb)$/
/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */
export function isTestCodePath(filePath: string): boolean {
return (
TEST_DIRECTORY.test(filePath) ||
TEST_BASENAME.test(filePath) ||
TEST_TYPE_NAME_SUFFIX.test(filePath)
)
}