From 23a1bbfe08a66e93d1da7934d54a9ca274980ae5 Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 28 Jul 2026 18:15:42 +1000 Subject: [PATCH 1/7] fix(git): include untracked files in diffs Generate no-index diffs for untracked files during unstaged detection. Closes #198 --- src/git/diff.ts | 35 ++++++++++++++++++++++++++++++++--- tests/git-diff.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index 64a2cc0..fc38e0f 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -55,14 +55,43 @@ export function getStagedDiff(): DiffResult { }; } +function getUntrackedDiff(): string { + const files = spawnSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { + encoding: 'utf-8', + maxBuffer: GIT_DIFF_MAX_BUFFER, + }); + if (files.error) throw files.error; + if (files.status !== 0) { + throw new Error(files.stderr.trim() || `git ls-files exited with code ${files.status}`); + } + + return files.stdout + .split('\0') + .filter(Boolean) + .map((file) => { + const result = spawnSync('git', ['diff', '--no-index', '--', '/dev/null', file], { + encoding: 'utf-8', + maxBuffer: GIT_DIFF_MAX_BUFFER, + }); + if (result.error) throw result.error; + if (result.status !== 0 && result.status !== 1) { + throw new Error(result.stderr.trim() || `git diff --no-index exited with code ${result.status}`); + } + return result.stdout; + }) + .filter(Boolean) + .join('\n'); +} + export function getUnstagedDiff(): DiffResult { - const diff = execSync('git diff', { + const trackedDiff = execSync('git diff', { encoding: 'utf-8', maxBuffer: GIT_DIFF_MAX_BUFFER, }); + const diff = [trackedDiff, getUntrackedDiff()].filter(Boolean).join('\n').trim(); return { - diff: diff.trim(), - hasChanges: diff.trim().length > 0, + diff, + hasChanges: diff.length > 0, staged: false, }; } diff --git a/tests/git-diff.test.mjs b/tests/git-diff.test.mjs index fba896f..579916d 100644 --- a/tests/git-diff.test.mjs +++ b/tests/git-diff.test.mjs @@ -190,6 +190,26 @@ 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 handles diffs larger than the default execSync buffer", () => { const repoDir = initRepo(); From e6a96ace11c1945a06faa96bfe3294940670ddc3 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:15:10 +1000 Subject: [PATCH 2/7] fix(git): batch untracked diff generation Use a temporary index to generate untracked diffs in one Git pass, including embedded repositories without mutating the real index. Resolve Git to an absolute executable path before running commands. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/git/diff.ts | 183 +++++++++++++++++++++++++++++++++------- tests/git-diff.test.mjs | 26 ++++++ 2 files changed, 179 insertions(+), 30 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index fc38e0f..fbcc4b3 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,10 +26,72 @@ 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 resolveGitExecutable(): string { + const candidates: string[] = []; + const gitExecPath = process.env.GIT_EXEC_PATH; + + if (gitExecPath && isAbsolute(gitExecPath)) { + if (process.platform === 'win32') { + candidates.push(join(gitExecPath, '..', '..', GIT_EXECUTABLE_NAME)); + } else { + candidates.push(join(gitExecPath, '..', '..', 'bin', GIT_EXECUTABLE_NAME)); + } + } + + if (process.platform === 'win32') { + const programFiles = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], 'C:\\Program Files'].filter( + (value): value is string => Boolean(value), + ); + const localAppData = process.env.LOCALAPPDATA; + + for (const root of programFiles) { + candidates.push(join(root, 'Git', 'cmd', GIT_EXECUTABLE_NAME)); + candidates.push(join(root, 'Git', 'mingw64', 'bin', GIT_EXECUTABLE_NAME)); + } + if (localAppData) { + candidates.push(join(localAppData, 'Programs', 'Git', 'cmd', GIT_EXECUTABLE_NAME)); + } + } else { + candidates.push('/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git', '/opt/local/bin/git', '/bin/git'); + } + + const pathValue = process.env.PATH ?? process.env.Path ?? ''; + for (const directory of pathValue.split(delimiter)) { + if (isAbsolute(directory)) { + candidates.push(join(directory, GIT_EXECUTABLE_NAME)); + } + } + + for (const candidate of candidates) { + if (isExecutableFile(candidate)) { + return normalize(candidate); + } + } + + throw new Error('git is not installed or not found on PATH'); +} + +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') { @@ -32,7 +104,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 +116,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,36 +127,84 @@ 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 files = spawnSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { + const untrackedEntries = execFileSync(getGitExecutable(), ['ls-files', '--others', '--exclude-standard', '-z'], { encoding: 'utf-8', maxBuffer: GIT_DIFF_MAX_BUFFER, - }); - if (files.error) throw files.error; - if (files.status !== 0) { - throw new Error(files.stderr.trim() || `git ls-files exited with code ${files.status}`); + }) + .split('\0') + .filter(Boolean); + if (untrackedEntries.length === 0) { + return ''; } - return files.stdout - .split('\0') - .filter(Boolean) - .map((file) => { - const result = spawnSync('git', ['diff', '--no-index', '--', '/dev/null', file], { + const pathspecs = untrackedEntries.filter((entry) => { + if (!entry.endsWith('/')) { + return true; + } + + try { + execFileSync(getGitExecutable(), ['rev-parse', '--verify', 'HEAD'], { + cwd: resolve(entry), encoding: 'utf-8', - maxBuffer: GIT_DIFF_MAX_BUFFER, + stdio: 'pipe', }); - if (result.error) throw result.error; - if (result.status !== 0 && result.status !== 1) { - throw new Error(result.stderr.trim() || `git diff --no-index exited with code ${result.status}`); - } - return result.stdout; - }) - .filter(Boolean) - .join('\n'); + 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(), + ['add', '--intent-to-add', '--pathspec-from-file=-', '--pathspec-file-nul'], + { + encoding: 'utf-8', + env, + input: `${pathspecs.join('\0')}\0`, + 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(), ['diff'], { + encoding: 'utf-8', + env, + maxBuffer: GIT_DIFF_MAX_BUFFER, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } } export function getUnstagedDiff(): DiffResult { - const trackedDiff = execSync('git diff', { + const trackedDiff = execFileSync(getGitExecutable(), ['diff'], { encoding: 'utf-8', maxBuffer: GIT_DIFF_MAX_BUFFER, }); @@ -112,7 +232,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, }); @@ -130,12 +250,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'; } @@ -143,7 +263,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 579916d..d9d2a22 100644 --- a/tests/git-diff.test.mjs +++ b/tests/git-diff.test.mjs @@ -210,6 +210,32 @@ test("getUnstagedDiff includes untracked files", () => { } }); +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(); From 5225549f0363df9b0364c05816a10a7756e433a1 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:40 +1000 Subject: [PATCH 3/7] fix(git): avoid duplicate unstaged diff hunks Use the temporary-index diff as the complete unstaged result when untracked paths are present, and cover mixed tracked and untracked changes. Split Git executable candidate discovery into focused helpers and use only fixed installation locations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/git/diff.ts | 89 +++++++++++++++++++++-------------------- tests/git-diff.test.mjs | 23 +++++++++++ 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index fbcc4b3..2c2d1fc 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -11,7 +11,7 @@ import { unlinkSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { delimiter, isAbsolute, join, normalize, resolve } from 'node:path'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; export interface DiffResult { diff: string; @@ -38,49 +38,49 @@ function isExecutableFile(path: string): boolean { } } -function resolveGitExecutable(): string { - const candidates: string[] = []; - const gitExecPath = process.env.GIT_EXEC_PATH; - - if (gitExecPath && isAbsolute(gitExecPath)) { - if (process.platform === 'win32') { - candidates.push(join(gitExecPath, '..', '..', GIT_EXECUTABLE_NAME)); - } else { - candidates.push(join(gitExecPath, '..', '..', 'bin', GIT_EXECUTABLE_NAME)); - } +function getGitExecPathCandidates(gitExecPath: string | undefined): string[] { + if (!gitExecPath || !isAbsolute(gitExecPath)) { + return []; } - if (process.platform === 'win32') { - const programFiles = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], 'C:\\Program Files'].filter( - (value): value is string => Boolean(value), - ); - const localAppData = process.env.LOCALAPPDATA; + return process.platform === 'win32' + ? [join(gitExecPath, '..', '..', GIT_EXECUTABLE_NAME)] + : [join(gitExecPath, '..', '..', 'bin', GIT_EXECUTABLE_NAME)]; +} - for (const root of programFiles) { - candidates.push(join(root, 'Git', 'cmd', GIT_EXECUTABLE_NAME)); - candidates.push(join(root, 'Git', 'mingw64', 'bin', GIT_EXECUTABLE_NAME)); - } - if (localAppData) { - candidates.push(join(localAppData, 'Programs', 'Git', 'cmd', GIT_EXECUTABLE_NAME)); - } - } else { - candidates.push('/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git', '/opt/local/bin/git', '/bin/git'); - } +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; - const pathValue = process.env.PATH ?? process.env.Path ?? ''; - for (const directory of pathValue.split(delimiter)) { - if (isAbsolute(directory)) { - candidates.push(join(directory, GIT_EXECUTABLE_NAME)); - } - } + return localAppData + ? [...programFileCandidates, join(localAppData, 'Programs', 'Git', 'cmd', GIT_EXECUTABLE_NAME)] + : programFileCandidates; +} - for (const candidate of candidates) { - if (isExecutableFile(candidate)) { - return normalize(candidate); - } +function getUnixGitCandidates(): string[] { + return ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git', '/opt/local/bin/git', '/bin/git']; +} + +function resolveGitExecutable(): string { + const candidates = [ + ...getGitExecPathCandidates(process.env.GIT_EXEC_PATH), + ...(process.platform === 'win32' ? getWindowsGitCandidates() : getUnixGitCandidates()), + ]; + const executable = candidates.find(isExecutableFile); + + if (!executable) { + throw new Error('git is not installed or not found in a supported location'); } - throw new Error('git is not installed or not found on PATH'); + return normalize(executable); } function getGitExecutable(): string { @@ -95,7 +95,7 @@ export function checkGitRepo(): void { } 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'); @@ -204,11 +204,14 @@ function getUntrackedDiff(): string { } export function getUnstagedDiff(): DiffResult { - const trackedDiff = execFileSync(getGitExecutable(), ['diff'], { - encoding: 'utf-8', - maxBuffer: GIT_DIFF_MAX_BUFFER, - }); - const diff = [trackedDiff, getUntrackedDiff()].filter(Boolean).join('\n').trim(); + const untrackedAwareDiff = getUntrackedDiff(); + const diff = ( + untrackedAwareDiff || + execFileSync(getGitExecutable(), ['diff'], { + encoding: 'utf-8', + maxBuffer: GIT_DIFF_MAX_BUFFER, + }) + ).trim(); return { diff, hasChanges: diff.length > 0, diff --git a/tests/git-diff.test.mjs b/tests/git-diff.test.mjs index d9d2a22..c436167 100644 --- a/tests/git-diff.test.mjs +++ b/tests/git-diff.test.mjs @@ -210,6 +210,29 @@ test("getUnstagedDiff includes untracked files", () => { } }); +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 includes an untracked embedded git repository", () => { const repoDir = initRepo(); From 5e3dec3594b68746a6847085de12144133b85195 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:56:40 +1000 Subject: [PATCH 4/7] fix(git): handle untracked pathspec edge cases Scope temporary-index diffs to untracked paths, preserve literal filenames, and bound batched Git output. Restore absolute execution for Git installations discovered through relative or custom PATH entries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/git/diff.ts | 27 ++++++++++++++++----------- tests/git-diff.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index 2c2d1fc..9080edd 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -11,7 +11,7 @@ import { unlinkSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, join, normalize, resolve } from 'node:path'; +import { delimiter, isAbsolute, join, normalize, resolve } from 'node:path'; export interface DiffResult { diff: string; @@ -69,11 +69,17 @@ 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), ...(process.platform === 'win32' ? getWindowsGitCandidates() : getUnixGitCandidates()), - ]; + ...getPathGitCandidates(), + ].map((candidate) => resolve(candidate)); const executable = candidates.find(isExecutableFile); if (!executable) { @@ -179,11 +185,12 @@ function getUntrackedDiff(): string { const env = { ...process.env, GIT_INDEX_FILE: tempIndex }; const addResult = spawnSync( getGitExecutable(), - ['add', '--intent-to-add', '--pathspec-from-file=-', '--pathspec-file-nul'], + ['--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', }, ); @@ -193,7 +200,7 @@ function getUntrackedDiff(): string { throw new Error(detail || `git add --intent-to-add exited with code ${addResult.status}`); } - return execFileSync(getGitExecutable(), ['diff'], { + return execFileSync(getGitExecutable(), ['--literal-pathspecs', 'diff', '--', ...pathspecs], { encoding: 'utf-8', env, maxBuffer: GIT_DIFF_MAX_BUFFER, @@ -205,13 +212,11 @@ function getUntrackedDiff(): string { export function getUnstagedDiff(): DiffResult { const untrackedAwareDiff = getUntrackedDiff(); - const diff = ( - untrackedAwareDiff || - execFileSync(getGitExecutable(), ['diff'], { - encoding: 'utf-8', - maxBuffer: GIT_DIFF_MAX_BUFFER, - }) - ).trim(); + 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, hasChanges: diff.length > 0, diff --git a/tests/git-diff.test.mjs b/tests/git-diff.test.mjs index c436167..204c104 100644 --- a/tests/git-diff.test.mjs +++ b/tests/git-diff.test.mjs @@ -233,6 +233,26 @@ test("getUnstagedDiff combines tracked and untracked changes without duplication } }); +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(); From a50485e02f4499b95c0d59f54d92383e5c60b7f2 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:02:40 +1000 Subject: [PATCH 5/7] fix(git): preserve path-scoped untracked diffs Keep the temp-index diff scoped to untracked pathspecs while preserving literal filename handling and buffered Git execution. This prevents duplicate tracked hunks and argv growth in mixed diff cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/git/diff.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index 9080edd..3ab86d3 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -71,7 +71,10 @@ function getUnixGitCandidates(): string[] { function getPathGitCandidates(): string[] { const pathValue = process.env.PATH ?? process.env.Path ?? ''; - return pathValue.split(delimiter).map((directory) => resolve(process.cwd(), directory, GIT_EXECUTABLE_NAME)); + return pathValue + .split(delimiter) + .filter(Boolean) + .map((directory) => resolve(process.cwd(), directory, GIT_EXECUTABLE_NAME)); } function resolveGitExecutable(): string { From bb4170e985bda3171a8e5befd9c22e9833767c19 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:33:12 +1000 Subject: [PATCH 6/7] Update src/git/diff.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- src/git/diff.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index 3ab86d3..cf01f11 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -73,8 +73,7 @@ function getPathGitCandidates(): string[] { const pathValue = process.env.PATH ?? process.env.Path ?? ''; return pathValue .split(delimiter) - .filter(Boolean) - .map((directory) => resolve(process.cwd(), directory, GIT_EXECUTABLE_NAME)); + .map((directory) => resolve(process.cwd(), directory || '.', GIT_EXECUTABLE_NAME)); } function resolveGitExecutable(): string { From df69688513573c8832594516a85cc9808da0bc18 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:35:09 +1000 Subject: [PATCH 7/7] fix(git): prefer PATH before fallback locations Keep Git execution on resolved absolute paths, but let PATH candidates win ahead of fixed fallbacks so environment-managed Git installs behave normally. The untracked diff batching remains unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/git/diff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index cf01f11..b03a19a 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -79,8 +79,8 @@ function getPathGitCandidates(): string[] { function resolveGitExecutable(): string { const candidates = [ ...getGitExecPathCandidates(process.env.GIT_EXEC_PATH), - ...(process.platform === 'win32' ? getWindowsGitCandidates() : getUnixGitCandidates()), ...getPathGitCandidates(), + ...(process.platform === 'win32' ? getWindowsGitCandidates() : getUnixGitCandidates()), ].map((candidate) => resolve(candidate)); const executable = candidates.find(isExecutableFile);