diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index cb53a14361d..6badf8de49f 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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', () => { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a72dd3832bf..966fbf97ddc 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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> { + 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 { + 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() + 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> { - const stats = new Map>() - 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, diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts index 96ff993240e..c24eee3ba2e 100644 --- a/src/relay/git-handler-commit-diff-ops.ts +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -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) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index df4815da4db..02cfc3f5195 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -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 { 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> { + 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[] +): Promise { + 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() + 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 diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index ad2b7964960..8345c7e3fa8 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -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' } + ]) + }) }) diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index e84e04a170e..ce7bcea9a74 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -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 { - const stats = new Map() - 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 = new Map() + statsByPath: Map = new Map() ): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { @@ -214,16 +174,6 @@ export function parseWorktreeList(output: string): Record[] { // ─── 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 = { '.png': 'image/png', '.jpg': 'image/jpeg', diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 2af779f6ef3..cc67cb8ef1c 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -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[] + 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[] + 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[] + 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 diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index e341e89618e..8a91781db64 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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)) }) } diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts index 78f66e2129a..7dbb1fc33f7 100644 --- a/src/relay/git-status-output-parser.ts +++ b/src/relay/git-status-output-parser.ts @@ -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, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 43c98603fef..bb42a5419a0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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 ( + + {hasAdded && +{added}} + {hasAdded && hasRemoved && } + {hasRemoved && -{removed}} + + ) +} + const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ entryKey, entry, @@ -6046,12 +6070,15 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ {entry.conflictStatus ? ( ) : ( - - {STATUS_LABELS[entry.status]} - + <> + + + {STATUS_LABELS[entry.status]} + + )}
{canDiscard && ( @@ -6191,6 +6218,7 @@ function BranchEntryRow({ {commentCount} )} + ({ + 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({}) + }) +}) diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts new file mode 100644 index 00000000000..9a1d3766a97 --- /dev/null +++ b/src/shared/git-uncommitted-line-stats.ts @@ -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() + +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 { + const stats = new Map() + 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 { + 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> { + const result = new Map() + 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 + } +}