diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 26e679b81..4efa161df 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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' }]) + }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index f05c510bd..fbdbf298e 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -27,9 +27,14 @@ export async function getStatus(worktreePath: string): Promise // 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 { + // 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 } ) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 8d89a85d3..a24ff2745 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -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) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 5f55d8614..98aa116f8 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -56,8 +56,12 @@ export async function getStatusOp( const entries: Record[] = [] 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) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index a9c756cf7..514b92c3d 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -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[] + } + 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[] + } + 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; entries: Record[] } + + 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[] + + // 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', () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 49123ad2d..e044a776e 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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)