Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/git-status-quoted-paths.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 10 additions & 17 deletions packages/agent-core-v2/src/app/git/gitParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,30 @@ export function parsePorcelain(
stdout: string,
filter: ReadonlySet<string> | undefined,
): FsGitStatusResponse {
const lines = stdout.split('\n');
const records = stdout.split('\0');
let branch = '';
let ahead = 0;
let behind = 0;
const entries: Record<string, FsGitStatus> = {};

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;
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-core-v2/src/app/git/gitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ 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}`);
}

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) {
Expand Down
28 changes: 18 additions & 10 deletions packages/agent-core-v2/test/app/git/gitParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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({
Expand All @@ -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', () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/agent-core-v2/test/app/git/gitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading