fix: decode git cquoted status paths

This commit is contained in:
Neil 2026-05-31 01:26:32 -07:00
parent 18ed7b27da
commit dee34a653c
5 changed files with 128 additions and 12 deletions

View File

@ -0,0 +1,44 @@
import { execFileSync } from 'child_process'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import * as path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { getStatus, stageFile } from './status'
const tempRoots: string[] = []
async function createRepo(): Promise<string> {
const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-cquoted-'))
tempRoots.push(repo)
execFileSync('git', ['init', '-q'], { cwd: repo })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo })
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo })
return repo
}
function gitNames(repo: string, args: string[]): string[] {
const stdout = execFileSync('git', args, { cwd: repo, encoding: 'utf8' })
return stdout.split('\0').filter(Boolean)
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('git status C-quoted paths', () => {
it('returns the real path for untracked files whose names contain tabs', async () => {
const repo = await createRepo()
const filePath = 'tab\tfile.txt'
await writeFile(path.join(repo, filePath), 'new file\n')
const status = await getStatus(repo)
expect(status.entries).toEqual([
{ path: filePath, status: 'untracked', area: 'untracked', added: 1 }
])
await stageFile(repo, status.entries[0].path)
expect(gitNames(repo, ['diff', '--cached', '--name-only', '-z'])).toEqual([filePath])
})
})

View File

@ -28,6 +28,7 @@ import {
parseNumstat,
type GitLineStats
} from '../../shared/git-uncommitted-line-stats'
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner'
import {
removeSafeUntrackedDiscardTarget,
@ -130,8 +131,8 @@ export async function getStatus(
// 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[0].split(' ').slice(9).join(' ')
const oldPath = tabParts.slice(1).join('\t')
const path = decodeGitCQuotedPath(tabParts[0].split(' ').slice(9).join(' '))
const oldPath = decodeGitCQuotedPath(tabParts.slice(1).join('\t'))
if (indexStatus !== '.') {
entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath })
}
@ -145,7 +146,7 @@ export async function getStatus(
}
} else {
// Regular change entry
const path = parts.slice(8).join(' ')
const path = decodeGitCQuotedPath(parts.slice(8).join(' '))
if (indexStatus !== '.') {
entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged' })
}
@ -155,10 +156,10 @@ export async function getStatus(
}
} else if (line.startsWith('? ')) {
// Untracked file
const path = line.slice(2)
const path = decodeGitCQuotedPath(line.slice(2))
entries.push({ path, status: 'untracked', area: 'untracked' })
} else if (line.startsWith('! ')) {
ignoredPaths.push(line.slice(2))
ignoredPaths.push(decodeGitCQuotedPath(line.slice(2)))
} else if (line.startsWith('u ')) {
const unmergedEntry = await parseUnmergedEntry(worktreePath, line)
if (unmergedEntry) {
@ -345,7 +346,7 @@ async function parseUnmergedEntry(
const modeStage1 = parts[3]
const modeStage2 = parts[4]
const modeStage3 = parts[5]
const filePath = parts.slice(10).join(' ')
const filePath = decodeGitCQuotedPath(parts.slice(10).join(' '))
if (!filePath) {
return null
}
@ -839,15 +840,15 @@ function parseBranchChangeLine(line: string): GitBranchChangeEntry | null {
const status = parseBranchStatusChar(rawStatus[0] ?? 'M')
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
const oldPath = parts[1]
const path = parts[2]
const oldPath = decodeGitCQuotedPath(parts[1] ?? '')
const path = decodeGitCQuotedPath(parts[2] ?? '')
if (!path) {
return null
}
return { path, oldPath, status }
}
const path = parts[1]
const path = decodeGitCQuotedPath(parts[1] ?? '')
if (!path) {
return null
}

View File

@ -0,0 +1,62 @@
export function decodeGitCQuotedPath(value: string): string {
if (value.length < 2 || value[0] !== '"' || value.at(-1) !== '"') {
return value
}
let decoded = ''
for (let index = 1; index < value.length - 1; index += 1) {
const char = value[index]
if (char !== '\\') {
decoded += char
continue
}
index += 1
const escaped = value[index]
switch (escaped) {
case 'a':
decoded += '\u0007'
break
case 'b':
decoded += '\b'
break
case 'f':
decoded += '\f'
break
case 'n':
decoded += '\n'
break
case 'r':
decoded += '\r'
break
case 't':
decoded += '\t'
break
case 'v':
decoded += '\v'
break
case '\\':
case '"':
decoded += escaped
break
default:
if (/[0-7]/.test(escaped)) {
let octal = escaped
while (
index + 1 < value.length - 1 &&
octal.length < 3 &&
/[0-7]/.test(value[index + 1])
) {
index += 1
octal += value[index]
}
decoded += String.fromCharCode(Number.parseInt(octal, 8))
} else {
decoded += escaped
}
break
}
}
return decoded
}

View File

@ -57,6 +57,13 @@ describe('parseNumstat', () => {
expect(stats.get('new.ts')).toEqual({ added: 2, removed: 1 })
})
it('decodes Git C-quoted paths before keying stats', () => {
expect(parseNumstat('1\t1\t"tab\\tfile.txt"\n').get('tab\tfile.txt')).toEqual({
added: 1,
removed: 1
})
})
it('ignores blank lines', () => {
expect(parseNumstat('').size).toBe(0)
})

View File

@ -1,6 +1,7 @@
import { lstat, readFile } from 'fs/promises'
import * as path from 'path'
import { isBinaryBuffer } from './binary-buffer'
import { decodeGitCQuotedPath } from './git-cquoted-path'
export type GitLineStats = { added?: number; removed?: number }
@ -35,13 +36,14 @@ function parseNumstatCount(value: string): number | undefined {
// `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)
const decodedPath = decodeGitCQuotedPath(rawPath)
const braced = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(decodedPath)
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)
const markerIndex = decodedPath.lastIndexOf(marker)
return markerIndex === -1 ? decodedPath : decodedPath.slice(markerIndex + marker.length)
}
export function parseNumstat(stdout: string): Map<string, GitLineStats> {