fix: decode git cquoted status paths
This commit is contained in:
parent
18ed7b27da
commit
dee34a653c
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue