Add per-file line counts to the source control sidebar (#2865)

* Add per-file line counts to the source control sidebar

Show +N/-N (green/red) next to each file in the Changes, Untracked, and
Committed-on-branch sections so the magnitude of a change is visible at a
glance. Counts are computed per staging area via `git diff --numstat`;
untracked/new files count their full contents as additions and binary
files show no count.

Consolidate numstat parsing and binary-buffer detection into shared
modules reused by the local status path, the SSH/relay path, and the
existing branch-compare code. Include added/removed in the status-entry
equality check so the sidebar doesn't re-render on unchanged polls.

* fix: harden source control line counts

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Trevin Chow
2026-05-26 20:09:54 -07:00
committed by GitHub
co-authored by Neil
parent 6a9be249f5
commit b2b5cac9a9
15 changed files with 660 additions and 133 deletions
+135 -8
View File
@@ -2,14 +2,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, readFileMock, rmMock, existsSyncMock } =
vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
const {
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
lstatMock,
readFileMock,
rmMock,
existsSyncMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
lstatMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
@@ -21,6 +28,7 @@ vi.mock('./runner', () => ({
}))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
readFile: readFileMock,
rm: rmMock
}))
@@ -193,6 +201,7 @@ describe('getDiff', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
})
@@ -283,8 +292,14 @@ describe('getStatus', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
// Why: after the status call, getStatus may issue `git diff --numstat`
// calls to attach per-entry line counts. Tests that don't care about counts
// set only a `mockResolvedValueOnce` for the status output; this default
// keeps those follow-up numstat calls from returning undefined.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => {
@@ -518,6 +533,118 @@ describe('getStatus', () => {
expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/'])
expect(result.entries).toEqual([])
})
it('attaches per-area line counts from staged and unstaged numstat', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout:
'1 M. N... 100644 100644 100644 aaaa aaaa src/staged.ts\n' +
'1 .M N... 100644 100644 100644 bbbb bbbb src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({
stdout: args.includes('--cached') ? '10\t0\tsrc/staged.ts\n' : '3\t4\tsrc/unstaged.ts\n'
})
}
return Promise.resolve({ stdout: '' })
})
const result = await getStatus('/repo')
expect(result.entries).toEqual([
{ path: 'src/staged.ts', status: 'modified', area: 'staged', added: 10, removed: 0 },
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 }
])
})
it('attaches staged rename counts to the new path', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout: '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '2\t1\tsrc/old name.ts => src/new name.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
const result = await getStatus('/repo')
expect(result.entries).toEqual([
{
path: 'src/new name.ts',
oldPath: 'src/old name.ts',
status: 'renamed',
area: 'staged',
added: 2,
removed: 1
}
])
})
it('counts untracked file contents as additions', async () => {
existsSyncMock.mockReturnValue(false)
lstatMock.mockResolvedValue({
size: 14,
mtimeMs: 1,
ctimeMs: 1,
isFile: () => true,
isSymbolicLink: () => false
})
readFileMock.mockImplementation((target: string) =>
String(target).endsWith('.git')
? Promise.resolve('gitdir: /repo/.git/worktrees/feature\n')
: Promise.resolve(Buffer.from('one\ntwo\nthree\n'))
)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? src/brand-new.ts\n' })
const result = await getStatus('/repo')
expect(result.entries).toEqual([
{ path: 'src/brand-new.ts', status: 'untracked', area: 'untracked', added: 3 }
])
})
it('leaves binary working-tree changes without counts', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout: '1 .M N... 100644 100644 100644 cccc cccc assets/logo.png\n'
})
}
// git reports binary files as '-' in both numstat columns.
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '-\t-\tassets/logo.png\n' })
}
return Promise.resolve({ stdout: '' })
})
const result = await getStatus('/repo')
expect(result.entries).toEqual([
{ path: 'assets/logo.png', status: 'modified', area: 'unstaged' }
])
})
it('skips numstat entirely for a clean working tree', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await getStatus('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
})
describe('getStagedCommitContext', () => {
+65 -54
View File
@@ -21,6 +21,13 @@ import {
getEffectiveGitUpstreamStatus,
splitRemoteBranchName
} from '../../shared/git-effective-upstream'
import { isBinaryBuffer } from '../../shared/binary-buffer'
import {
applyLineStats,
collectUntrackedAdditions,
parseNumstat,
type GitLineStats
} from '../../shared/git-uncommitted-line-stats'
import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
@@ -115,10 +122,12 @@ export async function getStatus(
const worktreeStatus = xy[1]
if (line.startsWith('2 ')) {
// Rename entry - tab separated at the end
// Why: porcelain v2 type-2 records put the new path after 9 fixed
// space-delimited fields and the old path after the tab. Preserving
// spaces here keeps row actions and numstat counts keyed correctly.
const tabParts = line.split('\t')
const path = tabParts[1]
const oldPath = tabParts[2]
const path = tabParts[0].split(' ').slice(9).join(' ')
const oldPath = tabParts.slice(1).join('\t')
if (indexStatus !== '.') {
entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath })
}
@@ -170,6 +179,13 @@ export async function getStatus(
// Not a git repo or git not available
}
// Why: attach per-area line counts for the sidebar. Diffs run after status
// (we need the entry list first) and only for areas that have entries, so a
// clean tree costs zero extra git calls. Staged and unstaged are diffed
// separately so each row reflects only its own staging area; untracked files
// have no baseline and count their full contents as additions.
await attachLineStats(worktreePath, entries)
return {
entries,
conflictOperation,
@@ -193,6 +209,50 @@ export async function getStatus(
}
}
async function runNumstat(
worktreePath: string,
cached: boolean
): Promise<Map<string, GitLineStats>> {
try {
const { stdout } = await gitExecFileAsync(
['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'],
{ cwd: worktreePath, env: gitOptionalLocksDisabledEnv() }
)
return parseNumstat(stdout)
} catch {
// Why: a numstat failure (e.g. transient lock) should leave rows without
// counts rather than break the whole status refresh.
return new Map()
}
}
async function attachLineStats(worktreePath: string, entries: GitStatusEntry[]): Promise<void> {
if (entries.length === 0) {
return
}
const hasStaged = entries.some((entry) => entry.area === 'staged')
const hasUnstaged = entries.some((entry) => entry.area === 'unstaged')
const untrackedPaths = entries
.filter((entry) => entry.area === 'untracked')
.map((entry) => entry.path)
const emptyStats = new Map<string, GitLineStats>()
const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([
hasStaged ? runNumstat(worktreePath, true) : Promise.resolve(emptyStats),
hasUnstaged ? runNumstat(worktreePath, false) : Promise.resolve(emptyStats),
collectUntrackedAdditions(worktreePath, untrackedPaths)
])
for (const entry of entries) {
applyLineStats(
entry,
entry.area === 'staged'
? stagedStats.get(entry.path)
: entry.area === 'unstaged'
? unstagedStats.get(entry.path)
: untrackedStats.get(entry.path)
)
}
}
function getShortBranchName(branch: string | undefined): string | null {
const prefix = 'refs/heads/'
return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null
@@ -678,7 +738,7 @@ async function loadBranchChanges(
gitOptions
)
])
const statsByPath = parseBranchChangeNumstat(numstat)
const statsByPath = parseNumstat(numstat)
const entries: GitBranchChangeEntry[] = []
// [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows,
@@ -737,7 +797,7 @@ async function loadCommitChanges(
gitExecFileAsync(args, gitOptions),
gitExecFileAsync(numstatArgs, gitOptions)
])
const statsByPath = parseBranchChangeNumstat(numstat)
const statsByPath = parseNumstat(numstat)
const entries: GitBranchChangeEntry[] = []
for (const line of stdout.split(/\r?\n/)) {
@@ -752,45 +812,6 @@ async function loadCommitChanges(
return entries
}
function parseBranchChangeCount(value: string): number | undefined {
if (value === '-') {
return undefined
}
const count = Number.parseInt(value, 10)
return Number.isFinite(count) ? count : undefined
}
function normalizeBranchNumstatPath(path: string): string {
const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path)
if (bracedRename) {
return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}`
}
const renameMarker = ' => '
const markerIndex = path.lastIndexOf(renameMarker)
return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length)
}
function parseBranchChangeNumstat(
stdout: string
): Map<string, Pick<GitBranchChangeEntry, 'added' | 'removed'>> {
const stats = new Map<string, Pick<GitBranchChangeEntry, 'added' | 'removed'>>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const parts = line.split('\t')
const rawPath = parts.slice(2).join('\t')
if (!rawPath) {
continue
}
stats.set(normalizeBranchNumstatPath(rawPath), {
added: parseBranchChangeCount(parts[0] ?? ''),
removed: parseBranchChangeCount(parts[1] ?? '')
})
}
return stats
}
function parseBranchChangeLine(line: string): GitBranchChangeEntry | null {
const parts = line.split('\t')
const rawStatus = parts[0] ?? ''
@@ -932,16 +953,6 @@ function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult {
}
}
function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}
function buildDiffResult(
originalContent: string,
modifiedContent: string,
+3 -2
View File
@@ -1,5 +1,6 @@
import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops'
import { buildDiffResult, parseBranchDiff, parseBranchDiffNumstat } from './git-handler-utils'
import { buildDiffResult, parseBranchDiff } from './git-handler-utils'
import { parseNumstat } from '../shared/git-uncommitted-line-stats'
const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/
@@ -103,7 +104,7 @@ export async function commitCompare(git: GitExec, worktreePath: string, commitId
git(diffArgs, worktreePath),
git(numstatArgs, worktreePath)
])
const entries = parseBranchDiff(stdout, parseBranchDiffNumstat(numstat))
const entries = parseBranchDiff(stdout, parseNumstat(numstat))
summary.changedFiles = entries.length
return { summary, entries }
} catch (error) {
+64
View File
@@ -15,6 +15,12 @@ import {
getEffectiveGitUpstreamStatus,
splitRemoteBranchName
} from '../shared/git-effective-upstream'
import {
applyLineStats,
collectUntrackedAdditions,
parseNumstat,
type GitLineStats
} from '../shared/git-uncommitted-line-stats'
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
@@ -117,6 +123,13 @@ export async function getStatusOp(
// not a git repo or git not available
}
// Why: attach per-area line counts for the sidebar. Diffs run after status
// (we need the entry list first) and only for areas that have entries, so a
// clean tree costs zero extra git calls. Staged and unstaged are diffed
// separately so each row reflects only its own staging area; untracked files
// have no baseline and count their full contents as additions.
await attachLineStats(git, worktreePath, entries)
return {
entries,
conflictOperation,
@@ -127,6 +140,57 @@ export async function getStatusOp(
}
}
async function runNumstat(
git: GitExec,
worktreePath: string,
cached: boolean
): Promise<Map<string, GitLineStats>> {
try {
const { stdout } = await git(
['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'],
worktreePath,
{ disableOptionalLocks: true }
)
return parseNumstat(stdout)
} catch {
// Why: a numstat failure should leave rows without counts rather than break
// the whole status refresh.
return new Map()
}
}
async function attachLineStats(
git: GitExec,
worktreePath: string,
entries: Record<string, unknown>[]
): Promise<void> {
if (entries.length === 0) {
return
}
const hasStaged = entries.some((entry) => entry.area === 'staged')
const hasUnstaged = entries.some((entry) => entry.area === 'unstaged')
const untrackedPaths = entries
.filter((entry) => entry.area === 'untracked')
.map((entry) => entry.path as string)
const emptyStats = new Map<string, GitLineStats>()
const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([
hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats),
hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats),
collectUntrackedAdditions(worktreePath, untrackedPaths)
])
for (const entry of entries) {
const filePath = entry.path as string
applyLineStats(
entry as { added?: number; removed?: number },
entry.area === 'staged'
? stagedStats.get(filePath)
: entry.area === 'unstaged'
? unstagedStats.get(filePath)
: untrackedStats.get(filePath)
)
}
}
function getShortBranchName(branch: string | undefined): string | null {
const prefix = 'refs/heads/'
return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null
+10
View File
@@ -37,4 +37,14 @@ describe('parseStatusOutput', () => {
{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }
])
})
it('parses rename records with spaces in the paths', () => {
const result = parseStatusOutput(
'2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n'
)
expect(result.entries).toEqual([
{ path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' }
])
})
})
+3 -53
View File
@@ -7,6 +7,8 @@
*/
import * as path from 'path'
import { existsSync } from 'fs'
import { isBinaryBuffer } from '../shared/binary-buffer'
import type { GitLineStats } from '../shared/git-uncommitted-line-stats'
export function parseBranchStatusChar(char: string): string {
switch (char) {
@@ -100,51 +102,9 @@ export function parseUnmergedEntry(
/**
* Parse `git diff --name-status` output into structured change entries.
*/
export type BranchDiffLineStats = {
added?: number
removed?: number
}
function parseNumstatCount(value: string): number | undefined {
if (value === '-') {
return undefined
}
const count = Number.parseInt(value, 10)
return Number.isFinite(count) ? count : undefined
}
function normalizeNumstatPath(path: string): string {
const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path)
if (bracedRename) {
return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}`
}
const renameMarker = ' => '
const markerIndex = path.lastIndexOf(renameMarker)
return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length)
}
export function parseBranchDiffNumstat(stdout: string): Map<string, BranchDiffLineStats> {
const stats = new Map<string, BranchDiffLineStats>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const parts = line.split('\t')
const rawPath = parts.slice(2).join('\t')
if (!rawPath) {
continue
}
stats.set(normalizeNumstatPath(rawPath), {
added: parseNumstatCount(parts[0] ?? ''),
removed: parseNumstatCount(parts[1] ?? '')
})
}
return stats
}
export function parseBranchDiff(
stdout: string,
statsByPath: Map<string, BranchDiffLineStats> = new Map()
statsByPath: Map<string, GitLineStats> = new Map()
): Record<string, unknown>[] {
const entries: Record<string, unknown>[] = []
for (const line of stdout.split(/\r?\n/)) {
@@ -214,16 +174,6 @@ export function parseWorktreeList(output: string): Record<string, unknown>[] {
// ─── Binary / blob helpers ───────────────────────────────────────────
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i++) {
if (buffer[i] === 0) {
return true
}
}
return false
}
export const PREVIEWABLE_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
+27 -3
View File
@@ -115,12 +115,20 @@ describe('GitHandler', () => {
writeFileSync(path.join(tmpDir, 'new.txt'), 'new')
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
entries: {
path?: unknown
status?: unknown
area?: unknown
added?: unknown
removed?: unknown
}[]
}
const untracked = result.entries.find((e) => e.path === 'new.txt')
expect(untracked).toBeDefined()
expect(untracked!.status).toBe('untracked')
expect(untracked!.area).toBe('untracked')
expect(untracked!.added).toBe(1)
expect(untracked!.removed).toBeUndefined()
})
it('returns ignored paths only when requested', async () => {
@@ -171,12 +179,20 @@ describe('GitHandler', () => {
writeFileSync(path.join(tmpDir, 'file.txt'), 'modified')
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
entries: {
path?: unknown
status?: unknown
area?: unknown
added?: unknown
removed?: unknown
}[]
}
const modified = result.entries.find((e) => e.path === 'file.txt')
expect(modified).toBeDefined()
expect(modified!.status).toBe('modified')
expect(modified!.area).toBe('unstaged')
expect(modified!.added).toBe(1)
expect(modified!.removed).toBe(1)
})
it('detects staged files', async () => {
@@ -187,11 +203,19 @@ describe('GitHandler', () => {
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' })
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
entries: {
path?: unknown
status?: unknown
area?: unknown
added?: unknown
removed?: unknown
}[]
}
const staged = result.entries.find((e) => e.area === 'staged')
expect(staged).toBeDefined()
expect(staged!.status).toBe('modified')
expect(staged!.added).toBe(1)
expect(staged!.removed).toBe(1)
})
// Why: regression for issue #1503 — git's default core.quotePath=true
+3 -2
View File
@@ -7,7 +7,8 @@ import * as path from 'path'
import type { RelayDispatcher } from './dispatcher'
import type { RelayContext } from './context'
import { expandTilde } from './context'
import { parseBranchDiff, parseBranchDiffNumstat, parseWorktreeList } from './git-handler-utils'
import { parseBranchDiff, parseWorktreeList } from './git-handler-utils'
import { parseNumstat } from '../shared/git-uncommitted-line-stats'
import {
computeDiff,
branchCompare as branchCompareOp,
@@ -285,7 +286,7 @@ export class GitHandler {
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
worktreePath
)
return parseBranchDiff(stdout, parseBranchDiffNumstat(numstat))
return parseBranchDiff(stdout, parseNumstat(numstat))
})
}
+3 -4
View File
@@ -74,11 +74,10 @@ export function parseStatusOutput(stdout: string): {
if (line.startsWith('2 ')) {
// Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`.
// The new path is the last space-delimited token before the tab; origPath follows the tab.
// The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab.
const tabParts = line.split('\t')
const spaceParts = tabParts[0].split(' ')
const filePath = spaceParts.at(-1)!
const oldPath = tabParts[1]
const filePath = tabParts[0].split(' ').slice(9).join(' ')
const oldPath = tabParts.slice(1).join('\t')
if (indexStatus !== '.') {
entries.push({
path: filePath,
@@ -5919,6 +5919,30 @@ function SourceControlBranchTreeDirectoryRow({
)
}
// Why: a compact +added/-removed magnitude lets users gauge change size at a
// glance. Use git decoration tokens so the source-control sidebar follows the
// documented light/dark status palette.
function DiffLineCounts({
added,
removed
}: {
added?: number
removed?: number
}): React.JSX.Element | null {
const hasAdded = typeof added === 'number' && added > 0
const hasRemoved = typeof removed === 'number' && removed > 0
if (!hasAdded && !hasRemoved) {
return null
}
return (
<span className="shrink-0 tabular-nums text-[10px]">
{hasAdded && <span style={{ color: 'var(--git-decoration-added)' }}>+{added}</span>}
{hasAdded && hasRemoved && <span> </span>}
{hasRemoved && <span style={{ color: 'var(--git-decoration-deleted)' }}>-{removed}</span>}
</span>
)
}
const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
entryKey,
entry,
@@ -6046,12 +6070,15 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
{entry.conflictStatus ? (
<ConflictBadge entry={entry} />
) : (
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
<>
<DiffLineCounts added={entry.added} removed={entry.removed} />
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
</>
)}
<div className={SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS}>
{canDiscard && (
@@ -6191,6 +6218,7 @@ function BranchEntryRow({
<span className="tabular-nums">{commentCount}</span>
</span>
)}
<DiffLineCounts added={entry.added} removed={entry.removed} />
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
+3 -1
View File
@@ -3458,7 +3458,9 @@ function areGitStatusEntriesEqual(prev: GitStatusEntry[], next: GitStatusEntry[]
entry.oldPath === next[index].oldPath &&
entry.conflictKind === next[index].conflictKind &&
entry.conflictStatus === next[index].conflictStatus &&
entry.conflictStatusSource === next[index].conflictStatusSource
entry.conflictStatusSource === next[index].conflictStatusSource &&
entry.added === next[index].added &&
entry.removed === next[index].removed
)
)
}
+12
View File
@@ -0,0 +1,12 @@
// A NUL byte in the first chunk is git's own heuristic for "this is binary".
const BINARY_SNIFF_BYTES = 8192
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, BINARY_SNIFF_BYTES)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}
+5
View File
@@ -32,6 +32,11 @@ export type GitUncommittedEntry = {
conflictKind?: GitConflictKind
conflictStatus?: GitConflictResolutionStatus
conflictStatusSource?: GitConflictStatusSource
// Working-tree line counts for this entry's staging area (staged vs unstaged
// diffs are reported separately). Untracked files count their full contents
// as additions. Undefined for binary files and when the diff is unavailable.
added?: number
removed?: number
}
export type GitStatusEntry = GitUncommittedEntry
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { lstatMock, readFileMock } = vi.hoisted(() => ({
lstatMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('fs/promises', () => ({ lstat: lstatMock, readFile: readFileMock }))
import {
applyLineStats,
collectUntrackedAdditions,
MAX_UNTRACKED_LINE_COUNT_BYTES,
parseNumstat
} from './git-uncommitted-line-stats'
function mockFileStat(size: number, mtimeMs = 1) {
return {
size,
mtimeMs,
ctimeMs: mtimeMs,
isFile: () => true,
isSymbolicLink: () => false
}
}
describe('parseNumstat', () => {
it('parses added/removed counts keyed by path', () => {
const stats = parseNumstat('3\t4\tsrc/app.ts\n10\t0\tsrc/new.ts\n')
expect(stats.get('src/app.ts')).toEqual({ added: 3, removed: 4 })
expect(stats.get('src/new.ts')).toEqual({ added: 10, removed: 0 })
})
it('treats binary "-" columns as undefined counts', () => {
expect(parseNumstat('-\t-\tassets/logo.png\n').get('assets/logo.png')).toEqual({
added: undefined,
removed: undefined
})
})
it('keys renames to the post-rename path', () => {
const braced = parseNumstat('2\t1\tsrc/{old => new}/file.ts\n')
expect(braced.get('src/new/file.ts')).toEqual({ added: 2, removed: 1 })
const plain = parseNumstat('2\t1\told.ts => new.ts\n')
expect(plain.get('new.ts')).toEqual({ added: 2, removed: 1 })
})
it('ignores blank lines', () => {
expect(parseNumstat('').size).toBe(0)
})
})
describe('collectUntrackedAdditions', () => {
beforeEach(() => {
lstatMock.mockReset()
readFileMock.mockReset()
})
it('counts file lines as additions, with or without a trailing newline', async () => {
lstatMock.mockImplementation((target: string) =>
Promise.resolve(mockFileStat(String(target).endsWith('trailing.ts') ? 6 : 5))
)
readFileMock.mockImplementation((target: string) =>
Promise.resolve(
String(target).endsWith('trailing.ts') ? Buffer.from('a\nb\nc\n') : Buffer.from('a\nb\nc')
)
)
const stats = await collectUntrackedAdditions('/repo', ['trailing.ts', 'no-trailing.ts'])
expect(stats.get('trailing.ts')).toEqual({ added: 3 })
expect(stats.get('no-trailing.ts')).toEqual({ added: 3 })
})
it('reports an empty file as zero additions', async () => {
lstatMock.mockResolvedValue(mockFileStat(0))
readFileMock.mockResolvedValue(Buffer.from(''))
expect((await collectUntrackedAdditions('/repo', ['empty.ts'])).get('empty.ts')).toEqual({
added: 0
})
})
it('omits counts for binary files', async () => {
lstatMock.mockResolvedValue(mockFileStat(3))
readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02]))
expect((await collectUntrackedAdditions('/repo', ['bin.dat'])).get('bin.dat')).toEqual({})
})
it('counts untracked symbolic links without following the target', async () => {
lstatMock.mockResolvedValue({
size: 4,
mtimeMs: 2,
ctimeMs: 2,
isFile: () => false,
isSymbolicLink: () => true
})
expect((await collectUntrackedAdditions('/repo', ['link.txt'])).get('link.txt')).toEqual({
added: 1
})
expect(readFileMock).not.toHaveBeenCalled()
})
it('skips oversized untracked files instead of reading them during status polling', async () => {
lstatMock.mockResolvedValue(mockFileStat(MAX_UNTRACKED_LINE_COUNT_BYTES + 1, 3))
expect((await collectUntrackedAdditions('/repo', ['large.log'])).get('large.log')).toEqual({})
expect(readFileMock).not.toHaveBeenCalled()
})
it('reuses cached counts while size and mtime are unchanged', async () => {
lstatMock.mockResolvedValue(mockFileStat(5, 4))
readFileMock.mockResolvedValue(Buffer.from('a\nb\nc'))
await collectUntrackedAdditions('/repo', ['cached.ts'])
const stats = await collectUntrackedAdditions('/repo', ['cached.ts'])
expect(stats.get('cached.ts')).toEqual({ added: 3 })
expect(readFileMock).toHaveBeenCalledTimes(1)
})
})
describe('applyLineStats', () => {
it('copies defined counts onto the entry', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: 5, removed: 2 })
expect(entry).toEqual({ added: 5, removed: 2 })
})
it('leaves the entry untouched for undefined counts or missing stats', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: undefined, removed: undefined })
applyLineStats(entry, undefined)
expect(entry).toEqual({})
})
})
+159
View File
@@ -0,0 +1,159 @@
import { lstat, readFile } from 'fs/promises'
import * as path from 'path'
import { isBinaryBuffer } from './binary-buffer'
export type GitLineStats = { added?: number; removed?: number }
// Limits how many untracked files we read at once when counting their lines,
// so a worktree with thousands of new files cannot exhaust file descriptors.
const UNTRACKED_READ_CONCURRENCY = 8
// Keep status polling cheap: large untracked files are commonly generated
// assets, and reading them every poll can stall the source-control sidebar.
export const MAX_UNTRACKED_LINE_COUNT_BYTES = 2 * 1024 * 1024
const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2048
const NEWLINE_BYTE = 0x0a
type CachedUntrackedStats = {
size: number
mtimeMs: number
ctimeMs: number
stats: GitLineStats
}
const untrackedStatsCache = new Map<string, CachedUntrackedStats>()
function parseNumstatCount(value: string): number | undefined {
// git reports binary files as '-' in the numstat columns.
if (value === '-') {
return undefined
}
const count = Number.parseInt(value, 10)
return Number.isFinite(count) ? count : undefined
}
// `git diff -M` reports renames in the numstat path column as `old => new` or
// `dir/{old => new}/file`; normalize to the post-rename path so it keys to the
// porcelain status entry, which always reports the new path.
function normalizeNumstatPath(rawPath: string): string {
const braced = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(rawPath)
if (braced) {
return `${braced[1]}${braced[3]}${braced[4]}`
}
const marker = ' => '
const markerIndex = rawPath.lastIndexOf(marker)
return markerIndex === -1 ? rawPath : rawPath.slice(markerIndex + marker.length)
}
export function parseNumstat(stdout: string): Map<string, GitLineStats> {
const stats = new Map<string, GitLineStats>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const parts = line.split('\t')
const rawPath = parts.slice(2).join('\t')
if (!rawPath) {
continue
}
stats.set(normalizeNumstatPath(rawPath), {
added: parseNumstatCount(parts[0] ?? ''),
removed: parseNumstatCount(parts[1] ?? '')
})
}
return stats
}
async function countFileAdditions(absolutePath: string): Promise<GitLineStats> {
try {
const fileStat = await lstat(absolutePath)
const cached = untrackedStatsCache.get(absolutePath)
if (
cached &&
cached.size === fileStat.size &&
cached.mtimeMs === fileStat.mtimeMs &&
cached.ctimeMs === fileStat.ctimeMs
) {
return cached.stats
}
if (fileStat.isSymbolicLink()) {
return rememberUntrackedStats(absolutePath, fileStat, { added: 1 })
}
if (!fileStat.isFile() || fileStat.size > MAX_UNTRACKED_LINE_COUNT_BYTES) {
return rememberUntrackedStats(absolutePath, fileStat, {})
}
const buffer = await readFile(absolutePath)
if (isBinaryBuffer(buffer)) {
return rememberUntrackedStats(absolutePath, fileStat, {})
}
if (buffer.length === 0) {
return rememberUntrackedStats(absolutePath, fileStat, { added: 0 })
}
let newlineCount = 0
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === NEWLINE_BYTE) {
newlineCount += 1
}
}
// A trailing newline marks the final line as complete; without one the last
// partial line still counts as an added line (matching git's numstat).
const endsWithNewline = buffer.at(-1) === NEWLINE_BYTE
return rememberUntrackedStats(absolutePath, fileStat, {
added: endsWithNewline ? newlineCount : newlineCount + 1
})
} catch {
return {}
}
}
function rememberUntrackedStats(
absolutePath: string,
fileStat: { size: number; mtimeMs: number; ctimeMs: number },
stats: GitLineStats
): GitLineStats {
untrackedStatsCache.set(absolutePath, {
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
ctimeMs: fileStat.ctimeMs,
stats
})
if (untrackedStatsCache.size > UNTRACKED_STATS_CACHE_MAX_ENTRIES) {
const oldestKey = untrackedStatsCache.keys().next().value
if (oldestKey) {
untrackedStatsCache.delete(oldestKey)
}
}
return stats
}
// Untracked files have no git-tracked baseline, so `git diff` ignores them.
// We count their contents directly to show an additions magnitude.
export async function collectUntrackedAdditions(
worktreePath: string,
untrackedPaths: readonly string[]
): Promise<Map<string, GitLineStats>> {
const result = new Map<string, GitLineStats>()
for (let i = 0; i < untrackedPaths.length; i += UNTRACKED_READ_CONCURRENCY) {
const chunk = untrackedPaths.slice(i, i + UNTRACKED_READ_CONCURRENCY)
await Promise.all(
chunk.map(async (relativePath) => {
result.set(relativePath, await countFileAdditions(path.join(worktreePath, relativePath)))
})
)
}
return result
}
export function applyLineStats(
entry: { added?: number; removed?: number },
stats: GitLineStats | undefined
): void {
if (!stats) {
return
}
if (stats.added !== undefined) {
entry.added = stats.added
}
if (stats.removed !== undefined) {
entry.removed = stats.removed
}
}