diff --git a/src/main/git/status-cquoted-paths.test.ts b/src/main/git/status-cquoted-paths.test.ts new file mode 100644 index 000000000..0008b21bc --- /dev/null +++ b/src/main/git/status-cquoted-paths.test.ts @@ -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 { + 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]) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 891b23108..3b74681ba 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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 } diff --git a/src/shared/git-cquoted-path.ts b/src/shared/git-cquoted-path.ts new file mode 100644 index 000000000..74cb7a17e --- /dev/null +++ b/src/shared/git-cquoted-path.ts @@ -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 +} diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index a94ed71ed..fc98a3d82 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -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) }) diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index a1cf6966e..4330375c9 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -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 {