fix(source-control): preserve UTF-8 paths in status and branch diff (#1515)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-06 16:22:24 -07:00 committed by GitHub
parent a75e706a6c
commit e7fef4e14d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 188 additions and 8 deletions

View File

@ -278,6 +278,29 @@ describe('getStatus', () => {
expect(result.entries[0]?.status).toBe('modified')
expect(result.entries[0]?.conflictKind).toBe('added_by_us')
})
it('passes core.quotePath=false and round-trips UTF-8 paths', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a docs/日本語/sample.md\n'
})
const result = await getStatus('/repo')
// Why: without -c core.quotePath=false git would emit
// "docs/\346\227\245\346\234\254\350\252\236/sample.md" (octal-escaped,
// wrapped in double quotes) and the parser would store that literal
// string as entry.path, breaking sidebar display + downstream blob reads.
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
{ cwd: '/repo' }
)
expect(result.entries).toEqual([
{ path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' }
])
})
})
describe('detectConflictOperation', () => {
@ -388,4 +411,32 @@ describe('getBranchCompare', () => {
expect(result.summary.errorMessage).toContain('merge base')
expect(result.entries).toEqual([])
})
it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' })
.mockResolvedValueOnce({ stdout: '1\n' })
const result = await getBranchCompare('/repo', 'origin/main')
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
5,
[
'-c',
'core.quotePath=false',
'diff',
'--name-status',
'-M',
'-C',
'merge-base-oid',
'head-oid'
],
expect.objectContaining({ cwd: '/repo' })
)
expect(result.entries).toEqual([{ path: 'docs/日本語/sample.md', status: 'modified' }])
})
})

View File

@ -27,9 +27,14 @@ export async function getStatus(worktreePath: string): Promise<GitStatusResult>
// Why: detectConflictOperation (4 existsSync + readFile) and git status are
// independent. Running them concurrently saves one round-trip of I/O latency.
const conflictPromise = detectConflictOperation(worktreePath)
const statusPromise = gitExecFileAsync(['status', '--porcelain=v2', '--untracked-files=all'], {
cwd: worktreePath
})
// Why: -c core.quotePath=false keeps non-ASCII filenames (Japanese, emoji,
// etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped
// in double quotes. Without it, the parsed entry.path is unreadable in the
// sidebar and downstream `git show :"docs/\346..."` lookups silently miss.
const statusPromise = gitExecFileAsync(
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
{ cwd: worktreePath }
)
const conflictOperation = await conflictPromise
try {
@ -430,8 +435,10 @@ async function loadBranchChanges(
mergeBase: string,
headOid: string
): Promise<GitBranchChangeEntry[]> {
// Why: see core.quotePath=false rationale in getStatus — same reason here so
// branch-diff entries render with their real UTF-8 paths.
const { stdout } = await gitExecFileAsync(
['diff', '--name-status', '-M', '-C', mergeBase, headOid],
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
{ cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES }
)

View File

@ -217,8 +217,9 @@ export async function branchDiffEntries(
return []
}
// Why: see core.quotePath rationale in getStatusOp — keep UTF-8 paths intact.
const { stdout } = await git(
['diff', '--name-status', '-M', '-C', mergeBase, headOid],
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
worktreePath
)
const allChanges = parseBranchDiff(stdout)

View File

@ -56,8 +56,12 @@ export async function getStatusOp(
const entries: Record<string, unknown>[] = []
try {
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in
// git's stdout instead of C-style octal escapes; without it the parsed
// entry.path renders as gibberish in the source-control sidebar and
// downstream blob lookups miss.
const { stdout } = await git(
['status', '--porcelain=v2', '--untracked-files=all'],
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
worktreePath
)
const parsed = parseStatusOutput(stdout)

View File

@ -4,7 +4,7 @@ import { GitHandler } from './git-handler'
import { RelayContext } from './context'
import * as fs from 'fs/promises'
import * as path from 'path'
import { mkdtempSync, writeFileSync } from 'fs'
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { execFileSync } from 'child_process'
import {
@ -114,6 +114,51 @@ describe('GitHandler', () => {
expect(staged).toBeDefined()
expect(staged!.status).toBe('modified')
})
// Why: regression for issue #1503 — git's default core.quotePath=true
// emits non-ASCII paths as octal-escaped, double-quoted strings (e.g.
// "docs/\346\227\245\346\234\254\350\252\236/sample.md"), which made the
// sidebar show gibberish and broke downstream blob reads.
it('preserves UTF-8 paths in status output', async () => {
gitInit(tmpDir)
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
mkdirSync(utf8Dir, { recursive: true })
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
}
const entry = result.entries.find((e) =>
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
)
expect(entry).toBeDefined()
expect(entry!.path).toBe('docs/日本語/sample.md')
})
// Why: regression for issue #1503 on the porcelain v2 type-1 entry parser
// branch (tracked + modified). The existing UTF-8 test exercises only the
// untracked '?' branch; this one exercises the path-reconstruction code in
// parseStatusOutput that joins parts.slice(8).
it('preserves UTF-8 paths for tracked-modified entries', async () => {
gitInit(tmpDir)
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
mkdirSync(utf8Dir, { recursive: true })
const utf8File = path.join(utf8Dir, 'sample.md')
writeFileSync(utf8File, 'original')
gitCommit(tmpDir, 'initial')
writeFileSync(utf8File, 'modified')
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
}
const entry = result.entries.find((e) =>
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
)
expect(entry).toBeDefined()
expect(entry!.path).toBe('docs/日本語/sample.md')
expect(entry!.status).toBe('modified')
expect(entry!.area).toBe('unstaged')
})
})
describe('stage and unstage', () => {
@ -248,6 +293,76 @@ describe('GitHandler', () => {
expect(result.summary.commitsAhead).toBe(1)
}
})
// Why: regression for issue #1503 on the branch-diff path. Without
// -c core.quotePath=false the diff --name-status output is octal-escaped,
// which broke the "Committed on branch" file list.
it('preserves UTF-8 paths in branch-compare entries', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
// Capture the default branch name before switching, so the test works
// regardless of whether git's init.defaultBranch is master or main.
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
mkdirSync(utf8Dir, { recursive: true })
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
gitCommit(tmpDir, 'feature commit')
const result = (await dispatcher.callRequest('git.branchCompare', {
worktreePath: tmpDir,
baseRef
})) as { summary: Record<string, unknown>; entries: Record<string, unknown>[] }
expect(result.summary.status).toBe('ready')
const entry = result.entries.find((e) =>
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
)
expect(entry).toBeDefined()
expect(entry!.path).toBe('docs/日本語/sample.md')
})
})
describe('branchDiff', () => {
// Why: regression for issue #1503 on git.branchDiff. The branchCompare test
// covers loadBranchChanges in git-handler.ts, but branchDiffEntries in
// git-handler-ops.ts is a separate code path that also passes
// -c core.quotePath=false and must round-trip UTF-8.
it('preserves UTF-8 paths in branch-diff entries', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
mkdirSync(utf8Dir, { recursive: true })
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
gitCommit(tmpDir, 'feature commit')
const result = (await dispatcher.callRequest('git.branchDiff', {
worktreePath: tmpDir,
baseRef,
filePath: 'docs/日本語/sample.md'
})) as Record<string, unknown>[]
// Without includePatch, branchDiffEntries returns one stub entry per
// changed file. Asserting length===1 confirms the filter matched the
// raw UTF-8 path emitted by `git diff --name-status` — if quotePath
// were left at default, the entry's path would be the octal-quoted
// form and the filter at git-handler-ops.ts:230-237 would not match.
expect(result).toHaveLength(1)
})
})
describe('remote operations', () => {

View File

@ -190,8 +190,10 @@ export class GitHandler {
}
const gitBound = this.git.bind(this)
return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => {
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8;
// without it parseBranchDiff would yield C-style octal-escaped paths.
const { stdout } = await gitBound(
['diff', '--name-status', '-M', '-C', mergeBase, headOid],
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
worktreePath
)
return parseBranchDiff(stdout)