diff --git a/.changeset/git-status-quoted-paths.md b/.changeset/git-status-quoted-paths.md new file mode 100644 index 00000000000..5b4eb59172e --- /dev/null +++ b/.changeset/git-status-quoted-paths.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Parse `git status --porcelain` with `-z` so non-ASCII paths are no longer mangled into bogus quoted directory segments. diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index c23f254a8cf..3885f7fdb35 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -4,33 +4,30 @@ export function parsePorcelain( stdout: string, filter: ReadonlySet | undefined, ): FsGitStatusResponse { - const lines = stdout.split('\n'); + const records = stdout.split('\0'); let branch = ''; let ahead = 0; let behind = 0; const entries: Record = {}; - for (const line of lines) { - if (line.length === 0) continue; - if (line.startsWith('## ')) { - const parsed = parseBranchHeader(line.slice(3)); + for (let i = 0; i < records.length; i++) { + const record = records[i]!; + if (record.length === 0) continue; + if (record.startsWith('## ')) { + const parsed = parseBranchHeader(record.slice(3)); branch = parsed.branch; ahead = parsed.ahead; behind = parsed.behind; continue; } - if (line.length < 4) continue; - const xy = line.slice(0, 2); - let rest = line.slice(3); + if (record.length < 4) continue; + const xy = record.slice(0, 2); + const wirePath = record.slice(3); if (xy.startsWith('R') || xy.startsWith('C')) { - const arrow = rest.indexOf(' -> '); - if (arrow >= 0) { - rest = rest.slice(arrow + 4); - } + i++; } - const wirePath = posix(rest.trim()); if (filter !== undefined && !filter.has(wirePath)) continue; const status = collapseXY(xy); entries[wirePath] = status; @@ -116,10 +113,6 @@ function collapseXY(xy: string): FsGitStatus { return 'clean'; } -function posix(p: string): string { - return p.replaceAll('\\', '/'); -} - export function parsePullRequest(stdout: string): FsPullRequest | null { let raw: unknown; try { diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index f2c100bf7a0..512b5c4d9f2 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -34,7 +34,7 @@ export class GitService implements IGitService { throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`); } - const porc = await this.runCommand('git', ['status', '--porcelain=v1', '--branch'], cwd); + const porc = await this.runCommand('git', ['status', '--porcelain=v1', '--branch', '-z'], cwd); if (porc.exitCode !== 0) { throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`); } @@ -42,8 +42,8 @@ export class GitService implements IGitService { const result = parsePorcelain(porc.stdout, pathFilter); const dirty = porc.stdout - .split('\n') - .some((line) => line.length > 0 && !line.startsWith('## ')); + .split('\0') + .some((record) => record.length > 0 && !record.startsWith('## ')); if (dirty) { const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd); if (head.exitCode === 0) { diff --git a/packages/agent-core-v2/test/app/git/gitParsers.test.ts b/packages/agent-core-v2/test/app/git/gitParsers.test.ts index b7162cfebcb..700e7c986bd 100644 --- a/packages/agent-core-v2/test/app/git/gitParsers.test.ts +++ b/packages/agent-core-v2/test/app/git/gitParsers.test.ts @@ -4,7 +4,7 @@ import { parseNumstat, parsePorcelain, parsePullRequest } from '#/app/git/gitPar describe('parsePorcelain', () => { it('parses branch header and ahead/behind', () => { - const out = '## main...origin/main [ahead 2, behind 3]\n'; + const out = '## main...origin/main [ahead 2, behind 3]\0'; const result = parsePorcelain(out, undefined); expect(result.branch).toBe('main'); expect(result.ahead).toBe(2); @@ -13,14 +13,9 @@ describe('parsePorcelain', () => { }); it('classifies modified, untracked, renamed, and deleted entries', () => { - const out = [ - '## dev', - ' M src/a.ts', - '?? src/b.ts', - 'R old.ts -> new.ts', - 'D src/c.ts', - '', - ].join('\n'); + const out = ['## dev', ' M src/a.ts', '?? src/b.ts', 'R new.ts', 'old.ts', 'D src/c.ts', ''].join( + '\0', + ); const result = parsePorcelain(out, undefined); expect(result.branch).toBe('dev'); expect(result.entries).toEqual({ @@ -32,10 +27,23 @@ describe('parsePorcelain', () => { }); it('applies the path filter when provided', () => { - const out = '## main\n M src/a.ts\n M src/b.ts\n'; + const out = '## main\0 M src/a.ts\0 M src/b.ts\0'; const result = parsePorcelain(out, new Set(['src/a.ts'])); expect(result.entries).toEqual({ 'src/a.ts': 'modified' }); }); + + it('keeps non-ASCII paths intact', () => { + const path = 'my-ai-workspace/output/2026-08-31-bilibili-BV175t86pEre-26.8.31-总能等到回踩的.md'; + const out = ` M ${path}\0`; + const result = parsePorcelain(out, undefined); + expect(result.entries).toEqual({ [path]: 'modified' }); + }); + + it('uses the new path of a rename and skips the old one', () => { + const out = 'R dir/新名字.md\0dir/旧名字.md\0'; + const result = parsePorcelain(out, undefined); + expect(result.entries).toEqual({ 'dir/新名字.md': 'renamed' }); + }); }); describe('parseNumstat', () => { diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index bb7fc218244..ffa48cea879 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -109,6 +109,26 @@ describe('GitService', () => { expect(result.entries).toEqual({ 'a.txt': 'modified' }); }); + it('reports a non-ASCII path without quoting', async () => { + const name = 'output/2026-08-31-bilibili-BV175t86pEre-26.8.31-总能等到回踩的.md'; + mkdirSync(join(repo, 'output'), { recursive: true }); + writeFileSync(join(repo, name), 'line1\n'); + commitAll('init'); + writeFileSync(join(repo, name), 'line1\nline2\n'); + + const result = await service.status(repo); + expect(result.entries).toEqual({ [name]: 'modified' }); + }); + + it('reports the new path of a non-ASCII rename', async () => { + writeFileSync(join(repo, '旧名字.md'), 'line1\n'); + commitAll('init'); + git(repo, 'mv', '旧名字.md', '新名字.md'); + + const result = await service.status(repo); + expect(result.entries).toEqual({ '新名字.md': 'renamed' }); + }); + it('throws FS_GIT_UNAVAILABLE when not a repo', async () => { const notRepo = mkdtempSync(join(tmpdir(), 'not-repo-')); try {