fix(mcp): ignore CR-only line ending diffs (#2839)

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Parafee41 2026-08-05 19:17:44 +08:00 committed by GitHub
parent 9372b17049
commit 905a1e191a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 18 deletions

View file

@ -799,6 +799,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string
return repoPath;
}
export function buildDetectChangesDiffArgs(scope: string, baseRef?: string): string[] | null {
const args = ['diff', '--ignore-cr-at-eol'];
switch (scope) {
case 'staged':
return [...args, '--staged', '-U0'];
case 'all':
return [...args, 'HEAD', '-U0'];
case 'compare':
return baseRef ? [...args, baseRef, '-U0'] : null;
case 'unstaged':
default:
return [...args, '-U0'];
}
}
/**
* Length of the path-derived suffix appended to a colliding repo id.
* Exported so tests can pin the suffix shape without re-deriving the
@ -4917,24 +4932,10 @@ export class LocalBackend {
const scope = params.scope || 'unstaged';
const { execFileSync } = await import('child_process');
// Build git diff args based on scope (using execFileSync to avoid shell injection)
let diffArgs: string[];
switch (scope) {
case 'staged':
diffArgs = ['diff', '--staged', '-U0'];
break;
case 'all':
diffArgs = ['diff', 'HEAD', '-U0'];
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffArgs = ['diff', params.base_ref, '-U0'];
break;
case 'unstaged':
default:
diffArgs = ['diff', '-U0'];
break;
}
// Ignore CR-only EOL differences, while preserving meaningful whitespace changes.
// execFileSync receives an argv array, so refs never pass through a shell.
const diffArgs = buildDetectChangesDiffArgs(scope, params.base_ref);
if (!diffArgs) return { error: 'base_ref is required for "compare" scope' };
let diffOutput: string;
try {

View file

@ -0,0 +1,53 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildDetectChangesDiffArgs } from '../../src/mcp/local/local-backend.js';
describe('detect_changes EOL filtering', () => {
it.each([
['unstaged', undefined, ['diff', '--ignore-cr-at-eol', '-U0']],
['staged', undefined, ['diff', '--ignore-cr-at-eol', '--staged', '-U0']],
['all', undefined, ['diff', '--ignore-cr-at-eol', 'HEAD', '-U0']],
['compare', 'main', ['diff', '--ignore-cr-at-eol', 'main', '-U0']],
])('adds the EOL guard for %s scope', (scope, baseRef, expected) => {
expect(buildDetectChangesDiffArgs(scope, baseRef)).toEqual(expected);
});
it('requires a base ref for compare scope', () => {
expect(buildDetectChangesDiffArgs('compare')).toBeNull();
});
it('suppresses CRLF-only changes but retains other whitespace changes', () => {
const repoDir = mkdtempSync(path.join(tmpdir(), 'gitnexus-detect-eol-'));
try {
execFileSync('git', ['init', '-q'], { cwd: repoDir });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir });
writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\r\nconst second = 2;\r\n');
execFileSync('git', ['add', 'sample.ts'], { cwd: repoDir });
execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repoDir });
writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\nconst second = 2;\n');
const diffArgs = buildDetectChangesDiffArgs('unstaged');
if (!diffArgs) throw new Error('unstaged scope must produce git diff arguments');
expect(
execFileSync('git', diffArgs, {
cwd: repoDir,
encoding: 'utf8',
}),
).toBe('');
writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\n const second = 2;\n');
expect(
execFileSync('git', diffArgs, {
cwd: repoDir,
encoding: 'utf8',
}),
).toContain('+ const second = 2;');
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
});