diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ec4872cdd..d4b3ea063 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 { diff --git a/gitnexus/test/unit/detect-changes-eol.test.ts b/gitnexus/test/unit/detect-changes-eol.test.ts new file mode 100644 index 000000000..52a56c35b --- /dev/null +++ b/gitnexus/test/unit/detect-changes-eol.test.ts @@ -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 }); + } + }); +});