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:
parent
a75e706a6c
commit
e7fef4e14d
|
|
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue