mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
+135
-8
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user