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
190 changes: 176 additions & 14 deletions src/git/diff.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { execSync, spawnSync } from 'node:child_process';
import { writeFileSync, unlinkSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import {
accessSync,
copyFileSync,
existsSync,
constants,
mkdtempSync,
rmSync,
statSync,
writeFileSync,
unlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, normalize } from 'node:path';
import { delimiter, isAbsolute, join, normalize, resolve } from 'node:path';

export interface DiffResult {
diff: string;
Expand All @@ -16,14 +26,84 @@ export interface CommitResult {
}

const GIT_DIFF_MAX_BUFFER = 100 * 1024 * 1024;
const GIT_EXECUTABLE_NAME = process.platform === 'win32' ? 'git.exe' : 'git';
let gitExecutable: string | undefined;

function isExecutableFile(path: string): boolean {
try {
accessSync(path, constants.X_OK);
return statSync(path).isFile();
} catch {
return false;
}
}

function getGitExecPathCandidates(gitExecPath: string | undefined): string[] {
if (!gitExecPath || !isAbsolute(gitExecPath)) {
return [];
}

return process.platform === 'win32'
? [join(gitExecPath, '..', '..', GIT_EXECUTABLE_NAME)]
: [join(gitExecPath, '..', '..', 'bin', GIT_EXECUTABLE_NAME)];
}

function getWindowsGitCandidates(): string[] {
const programFiles = [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
String.raw`C:\Program Files`,
].filter((value): value is string => Boolean(value));
const programFileCandidates = programFiles.flatMap((root) => [
join(root, 'Git', 'cmd', GIT_EXECUTABLE_NAME),
join(root, 'Git', 'mingw64', 'bin', GIT_EXECUTABLE_NAME),
]);
const localAppData = process.env.LOCALAPPDATA;

return localAppData
? [...programFileCandidates, join(localAppData, 'Programs', 'Git', 'cmd', GIT_EXECUTABLE_NAME)]
: programFileCandidates;
}

function getUnixGitCandidates(): string[] {
return ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git', '/opt/local/bin/git', '/bin/git'];
}

function getPathGitCandidates(): string[] {
const pathValue = process.env.PATH ?? process.env.Path ?? '';
return pathValue
.split(delimiter)
.map((directory) => resolve(process.cwd(), directory || '.', GIT_EXECUTABLE_NAME));
}

function resolveGitExecutable(): string {
const candidates = [
...getGitExecPathCandidates(process.env.GIT_EXEC_PATH),
...getPathGitCandidates(),
Comment thread
404-Page-Found marked this conversation as resolved.
...(process.platform === 'win32' ? getWindowsGitCandidates() : getUnixGitCandidates()),
].map((candidate) => resolve(candidate));
const executable = candidates.find(isExecutableFile);

if (!executable) {
throw new Error('git is not installed or not found in a supported location');
}

return normalize(executable);
}

function getGitExecutable(): string {
gitExecutable ??= resolveGitExecutable();
return gitExecutable;
}

export function checkGitRepo(): void {
const executable = getGitExecutable();
try {
execSync('git rev-parse --git-dir', { encoding: 'utf-8', stdio: 'pipe' });
execFileSync(executable, ['rev-parse', '--git-dir'], { encoding: 'utf-8', stdio: 'pipe' });
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException & { stderr?: string };
if (nodeErr.code === 'ENOENT') {
throw new Error('git is not installed or not found on PATH');
throw new Error('git is not installed or not found in a supported location');
}
const stderr = nodeErr.stderr?.trim();
throw new Error(stderr || 'Not a git repository');
Expand All @@ -32,7 +112,7 @@ export function checkGitRepo(): void {

export function hasCommits(): boolean {
try {
const count = execSync('git rev-list --count HEAD', {
const count = execFileSync(getGitExecutable(), ['rev-list', '--count', 'HEAD'], {
encoding: 'utf-8',
stdio: 'pipe',
}).trim();
Expand All @@ -44,7 +124,7 @@ export function hasCommits(): boolean {
}

export function getStagedDiff(): DiffResult {
const diff = execSync('git diff --cached', {
const diff = execFileSync(getGitExecutable(), ['diff', '--cached'], {
encoding: 'utf-8',
maxBuffer: GIT_DIFF_MAX_BUFFER,
});
Expand All @@ -55,14 +135,93 @@ export function getStagedDiff(): DiffResult {
};
}

function getGitPath(path: string): string {
return resolve(
execFileSync(getGitExecutable(), ['rev-parse', '--git-path', path], {
encoding: 'utf-8',
stdio: 'pipe',
Comment thread
404-Page-Found marked this conversation as resolved.
}).trim(),
);
}

function getUntrackedDiff(): string {
const untrackedEntries = execFileSync(getGitExecutable(), ['ls-files', '--others', '--exclude-standard', '-z'], {
encoding: 'utf-8',
maxBuffer: GIT_DIFF_MAX_BUFFER,
})
.split('\0')
.filter(Boolean);
if (untrackedEntries.length === 0) {
return '';
}

const pathspecs = untrackedEntries.filter((entry) => {
if (!entry.endsWith('/')) {
return true;
}

try {
execFileSync(getGitExecutable(), ['rev-parse', '--verify', 'HEAD'], {
cwd: resolve(entry),
encoding: 'utf-8',
stdio: 'pipe',
});
return true;
} catch {
return false;
}
});
if (pathspecs.length === 0) {
return '';
}

const tempDir = mkdtempSync(join(tmpdir(), 'commit-echo-index-'));
const tempIndex = join(tempDir, 'index');

try {
const indexPath = getGitPath('index');
if (existsSync(indexPath)) {
copyFileSync(indexPath, tempIndex);
}

const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
const addResult = spawnSync(
getGitExecutable(),
['--literal-pathspecs', 'add', '--intent-to-add', '--pathspec-from-file=-', '--pathspec-file-nul'],
{
encoding: 'utf-8',
env,
input: `${pathspecs.join('\0')}\0`,
maxBuffer: GIT_DIFF_MAX_BUFFER,
stdio: 'pipe',
},
);
if (addResult.error) throw addResult.error;
if (addResult.status !== 0) {
const detail = [addResult.stderr, addResult.stdout].filter(Boolean).join('\n').trim();
throw new Error(detail || `git add --intent-to-add exited with code ${addResult.status}`);
}

return execFileSync(getGitExecutable(), ['--literal-pathspecs', 'diff', '--', ...pathspecs], {
Comment thread
404-Page-Found marked this conversation as resolved.
encoding: 'utf-8',
env,
maxBuffer: GIT_DIFF_MAX_BUFFER,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

export function getUnstagedDiff(): DiffResult {
const diff = execSync('git diff', {
const untrackedAwareDiff = getUntrackedDiff();
const trackedDiff = execFileSync(getGitExecutable(), ['diff'], {
encoding: 'utf-8',
maxBuffer: GIT_DIFF_MAX_BUFFER,
});
const diff = [trackedDiff.trim(), untrackedAwareDiff.trim()].filter(Boolean).join('\n');
return {
diff: diff.trim(),
hasChanges: diff.trim().length > 0,
diff,
hasChanges: diff.length > 0,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
staged: false,
};
}
Expand All @@ -83,7 +242,7 @@ export function commit(message: string, body?: string): CommitResult {
const tmpFile = join(tmpdir(), `commit-echo-msg-${process.pid}-${Date.now()}.txt`);
try {
writeFileSync(tmpFile, fullMessage, 'utf-8');
const result = spawnSync('git', ['commit', '-F', tmpFile], {
const result = spawnSync(getGitExecutable(), ['commit', '-F', tmpFile], {
encoding: 'utf-8',
shell: false,
});
Expand All @@ -101,20 +260,23 @@ export function commit(message: string, body?: string): CommitResult {
}

export function getRepoRoot(): string {
return normalize(execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim());
return normalize(execFileSync(getGitExecutable(), ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim());
}

export function getBranchName(): string {
try {
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
return execFileSync(getGitExecutable(), ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}

export function getLastCommitMessage(): string {
try {
return execSync('git log -1 --format=%s', { encoding: 'utf-8', stdio: 'pipe' }).trim();
return execFileSync(getGitExecutable(), ['log', '-1', '--format=%s'], {
encoding: 'utf-8',
stdio: 'pipe',
}).trim();
} catch {
return '';
}
Expand Down
89 changes: 89 additions & 0 deletions tests/git-diff.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,95 @@ test("getUnstagedDiff returns diff for unstaged changes", () => {
}
});

test("getUnstagedDiff includes untracked files", () => {
const repoDir = initRepo();

try {
git(["commit", "--allow-empty", "-m", "initial commit"], repoDir);
writeFileSync(join(repoDir, "new-file.txt"), "untracked content\n", "utf-8");

withCwd(repoDir, () => {
const result = getUnstagedDiff();

assert.equal(result.hasChanges, true);
assert.equal(result.staged, false);
assert.ok(result.diff.includes("new-file.txt"));
assert.ok(result.diff.includes("+untracked content"));
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});

test("getUnstagedDiff combines tracked and untracked changes without duplication", () => {
const repoDir = initRepo();

try {
writeFileSync(join(repoDir, "file.txt"), "hello\n", "utf-8");
git(["add", "file.txt"], repoDir);
git(["commit", "-m", "initial commit"], repoDir);
writeFileSync(join(repoDir, "file.txt"), "hello\nworld\n", "utf-8");
writeFileSync(join(repoDir, "new-file.txt"), "untracked content\n", "utf-8");

withCwd(repoDir, () => {
const result = getUnstagedDiff();
const trackedHunks = result.diff.match(/^diff --git a\/file\.txt b\/file\.txt$/gm) ?? [];

assert.equal(trackedHunks.length, 1);
assert.equal((result.diff.match(/\+world/g) ?? []).length, 1);
assert.ok(result.diff.includes("+untracked content"));
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});

test("getUnstagedDiff handles untracked filenames with pathspec magic", { skip: process.platform === "win32" }, () => {
const repoDir = initRepo();
const filename = ":(top)foo";

try {
git(["commit", "--allow-empty", "-m", "initial commit"], repoDir);
writeFileSync(join(repoDir, filename), "untracked content\n", "utf-8");

withCwd(repoDir, () => {
const result = getUnstagedDiff();

assert.equal(result.hasChanges, true);
assert.ok(result.diff.includes(filename));
assert.ok(result.diff.includes("+untracked content"));
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});

test("getUnstagedDiff includes an untracked embedded git repository", () => {
const repoDir = initRepo();

try {
git(["commit", "--allow-empty", "-m", "initial commit"], repoDir);
const nestedRepoDir = join(repoDir, "nested");
mkdirSync(nestedRepoDir);
git(["init"], nestedRepoDir);
writeFileSync(join(nestedRepoDir, "nested-file.txt"), "nested content\n", "utf-8");
git(["config", "user.name", "Nested Test User"], nestedRepoDir);
git(["config", "user.email", "nested-test@example.com"], nestedRepoDir);
git(["add", "nested-file.txt"], nestedRepoDir);
git(["commit", "-m", "initial nested commit"], nestedRepoDir);

withCwd(repoDir, () => {
const result = getUnstagedDiff();

assert.equal(result.hasChanges, true);
assert.ok(result.diff.includes("nested"));
assert.ok(result.diff.includes("new file mode 160000"));
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});

test("getUnstagedDiff handles diffs larger than the default execSync buffer", () => {
const repoDir = initRepo();

Expand Down