Skip to content
Open
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
19 changes: 17 additions & 2 deletions packages/git/src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { exec, execLarge, execLines, execWithStdin } from './exec.js';

/**
* Flags that neutralize user git config which would otherwise alter the diff
* format and break parsing:
* - `--no-color` guards against `color.ui=always` / `color.diff=always`
* - `--no-ext-diff` guards against a configured `diff.external` driver
* - `--src-prefix`/`--dst-prefix` force the standard `a/`/`b/` prefixes,
* overriding `diff.mnemonicPrefix`, `diff.noprefix` and custom prefixes
*/
const DIFF_FORMAT_ARGS = [
'--no-color',
'--no-ext-diff',
'--src-prefix=a/',
'--dst-prefix=b/',
];

export function getDiff(args: string[] = []): string {
const cmd = ['git', 'diff', ...args].join(' ');
const cmd = ['git', 'diff', ...DIFF_FORMAT_ARGS, ...args].join(' ');
return execLarge(cmd);
}

Expand All @@ -14,7 +29,7 @@ export function getUntrackedDiff(files: string[]): string {

for (const file of files) {
try {
execLarge(`git diff --no-index -- /dev/null "${file}"`);
execLarge(`git diff ${DIFF_FORMAT_ARGS.join(' ')} --no-index -- /dev/null "${file}"`);
} catch (err: unknown) {
const error = err as { stdout?: string; status?: number };
if (error.status === 1 && error.stdout) {
Expand Down
70 changes: 70 additions & 0 deletions packages/git/tests/diff-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

let repoDir: string;
let origCwd: string;

function git(cmd: string) {
execSync(`git ${cmd}`, { cwd: repoDir, stdio: 'pipe' });
}

function writeFile(name: string, content: string) {
writeFileSync(join(repoDir, name), content);
}

beforeAll(() => {
origCwd = process.cwd();
repoDir = mkdtempSync(join(tmpdir(), 'diffity-format-test-'));

git('init -b main');
git('config user.email "test@test.com"');
git('config user.name "Test"');

// git config that alters the default diff format and previously broke parsing
git('config diff.mnemonicprefix true');
git('config color.ui always');

writeFile('base.txt', 'a\nb\n');
git('add .');
git('commit -m "initial commit"');

process.chdir(repoDir);
});

afterAll(() => {
process.chdir(origCwd);
rmSync(repoDir, { recursive: true, force: true });
});

describe('diff format neutralization', () => {
it('emits standard a/ b/ prefixes despite diff.mnemonicprefix', async () => {
const { resolveRef } = await import('../src/diff.js');
writeFile('base.txt', 'a\nc\n');

const raw = resolveRef('unstaged');

expect(raw).toContain('diff --git a/base.txt b/base.txt');
expect(raw).toContain('--- a/base.txt');
expect(raw).toContain('+++ b/base.txt');
// no ANSI color escapes even with color.ui=always
expect(raw).not.toMatch(/\x1b\[/);

git('checkout -- base.txt');
});

it('emits standard prefixes for untracked files', async () => {
const { resolveRef } = await import('../src/diff.js');
writeFile('untracked.txt', 'x\ny\n');

const raw = resolveRef('work');

expect(raw).toContain('diff --git a/untracked.txt b/untracked.txt');
expect(raw).toContain('+++ b/untracked.txt');
expect(raw).not.toMatch(/\x1b\[/);

execSync(`rm "${join(repoDir, 'untracked.txt')}"`, { stdio: 'pipe' });
});
});