diff --git a/src/git/diff.ts b/src/git/diff.ts index 64a2cc0..b03a19a 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -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; @@ -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(), + ...(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'); @@ -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(); @@ -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, }); @@ -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', + }).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], { + 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, staged: false, }; } @@ -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, }); @@ -101,12 +260,12 @@ 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'; } @@ -114,7 +273,10 @@ export function getBranchName(): string { 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 ''; } diff --git a/tests/git-diff.test.mjs b/tests/git-diff.test.mjs index fba896f..204c104 100644 --- a/tests/git-diff.test.mjs +++ b/tests/git-diff.test.mjs @@ -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();