diff --git a/src/cli/handlers/compareHandler.ts b/src/cli/handlers/compareHandler.ts index 634493a..5cfdc8c 100644 --- a/src/cli/handlers/compareHandler.ts +++ b/src/cli/handlers/compareHandler.ts @@ -23,6 +23,7 @@ import { removeWorktree, createBaselinePaths, cleanupBaselinePaths, + filterGitIgnoredFiles, type GitBaselinePaths, } from '../../utils/git.js'; @@ -461,7 +462,70 @@ async function handleGitBaselineCompare(options: { gitBaseline: true, // Enable path normalization for git baseline comparisons }; - const result = await multiFileCompare(multiCompareOptions); + let result = await multiFileCompare(multiCompareOptions); + + // Filter out git-ignored files from comparison results + // This prevents false positives where git-ignored files (like next-env.d.ts) + // exist in working directory but not in git worktree + const projectRoot = process.cwd(); + for (const folder of result.folders) { + if (folder.componentResult) { + const cr = folder.componentResult; + // Filter added components that are git-ignored + cr.added = await filterGitIgnoredFiles(cr.added, projectRoot); + // Filter removed components that are git-ignored + cr.removed = await filterGitIgnoredFiles(cr.removed, projectRoot); + // Filter changed components that are git-ignored + cr.changed = (await Promise.all( + cr.changed.map(async ({ id, deltas }) => { + const filtered = await filterGitIgnoredFiles([id], projectRoot); + return filtered.length > 0 ? { id, deltas } : null; + }) + )).filter((item): item is { id: string; deltas: any[] } => item !== null); + + // Recalculate status if all changes were filtered out + if (cr.added.length === 0 && cr.removed.length === 0 && cr.changed.length === 0) { + folder.status = 'PASS'; + // Update component result status as well + cr.status = 'PASS'; + } + } + } + + // Recalculate summary counts after filtering + const addedFolders = result.folders.filter(f => f.status === 'ADDED').length; + const orphanedFolders = result.folders.filter(f => f.status === 'ORPHANED').length; + const driftFolders = result.folders.filter(f => f.status === 'DRIFT').length; + const passFolders = result.folders.filter(f => f.status === 'PASS').length; + + // Recalculate component counts from filtered results + let totalComponentsAdded = 0; + let totalComponentsRemoved = 0; + let totalComponentsChanged = 0; + + for (const folder of result.folders) { + if (folder.componentResult && folder.status === 'DRIFT') { + totalComponentsAdded += folder.componentResult.added.length; + totalComponentsRemoved += folder.componentResult.removed.length; + totalComponentsChanged += folder.componentResult.changed.length; + } + } + + // Update summary + result.summary = { + totalFolders: result.folders.length, + addedFolders, + orphanedFolders, + driftFolders, + passFolders, + totalComponentsAdded, + totalComponentsRemoved, + totalComponentsChanged, + }; + + // Recalculate overall status + result.status = addedFolders > 0 || orphanedFolders > 0 || driftFolders > 0 ? 'DRIFT' : 'PASS'; + displayMultiFileCompareResult(result, stats, quiet); // Step 5: Clean up diff --git a/src/utils/git.ts b/src/utils/git.ts index 82c029f..2d05728 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -3,15 +3,12 @@ * Handles worktree creation, ref validation, and cleanup */ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn } from 'node:child_process'; import { join } from 'node:path'; import { access, rm, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { debugLog, debugError } from './debug.js'; -const execAsync = promisify(exec); - /** * Result from creating a git worktree */ @@ -36,6 +33,7 @@ export interface GitOptions { /** * Execute a git command and return stdout + * Uses spawn instead of exec for better security (no shell interpretation) * @throws Error if git command fails */ async function execGit( @@ -48,33 +46,57 @@ async function execGit( debugLog('git', `Executing: ${command}`, { cwd }); - try { - const { stdout, stderr } = await execAsync(command, { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { cwd, timeout, - maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large repos + stdio: ['ignore', 'pipe', 'pipe'], }); - if (stderr && !stderr.includes('Preparing worktree')) { - // Some git commands output to stderr even on success - debugLog('git', `stderr: ${stderr.trim()}`); + let stdout = ''; + let stderr = ''; + + // TypeScript needs explicit type assertion for stdio pipes + if (child.stdout) { + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); } - return stdout.trim(); - } catch (error) { - const err = error as Error & { stderr?: string; code?: number }; - debugError('git', 'execGit', { - command, - cwd, - message: err.message, - stderr: err.stderr, - code: err.code, + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + } + + child.on('error', (error: Error) => { + debugError('git', 'execGit spawn error', { + command, + cwd, + message: error.message, + }); + reject(new Error(`Git command failed: ${error.message}`)); }); - // Extract meaningful error message from stderr - const errorMessage = err.stderr?.trim() || err.message; - throw new Error(`Git command failed: ${errorMessage}`); - } + child.on('close', (code: number | null) => { + if (code === 0) { + if (stderr && !stderr.includes('Preparing worktree')) { + // Some git commands output to stderr even on success + debugLog('git', `stderr: ${stderr.trim()}`); + } + resolve(stdout.trim()); + } else { + debugError('git', 'execGit', { + command, + cwd, + code, + stderr: stderr.trim(), + }); + const errorMessage = stderr.trim() || `Command failed with exit code ${code}`; + reject(new Error(`Git command failed: ${errorMessage}`)); + } + }); + }); } /** @@ -362,6 +384,89 @@ export async function createBaselinePaths( }; } +/** + * Check if a file is git-ignored + * @param filePath - File path relative to git root or absolute path + * @param options - Git options + * @returns true if the file is git-ignored, false otherwise + */ +export async function isGitIgnored( + filePath: string, + options: GitOptions = {} +): Promise { + try { + // git check-ignore --quiet returns exit code 0 if file is ignored, 1 if not ignored + // execGit throws on non-zero exit codes, so if it succeeds, file is ignored + await execGit(['check-ignore', '--quiet', filePath], options); + return true; // Command succeeded, file is ignored + } catch { + // Command failed (exit code 1), file is not ignored + return false; + } +} + +/** + * Filter out git-ignored files from an array of file paths + * @param filePaths - Array of file paths to filter (may be normalized basenames in git baseline mode) + * @param projectRoot - Project root directory (for resolving relative paths) + * @param options - Git options + * @returns Array of file paths that are NOT git-ignored + */ +export async function filterGitIgnoredFiles( + filePaths: string[], + projectRoot: string, + options: GitOptions = {} +): Promise { + const { relative } = await import('node:path'); + + const filtered: string[] = []; + + for (const filePath of filePaths) { + let isIgnored = false; + + // If the path contains a slash, it's a full relative path + if (filePath.includes('/')) { + // Convert to relative path from project root if needed + let relativePath: string; + if (filePath.startsWith('/') || filePath.match(/^[A-Z]:/)) { + // Absolute path - convert to relative + relativePath = relative(projectRoot, filePath).replace(/\\/g, '/'); + } else { + relativePath = filePath; + } + + // Check if file is git-ignored + isIgnored = await isGitIgnored(relativePath, { ...options, cwd: projectRoot }); + } else { + // It's just a basename (normalized in git baseline mode) + // Check if the basename itself is git-ignored (works for root-level files like next-env.d.ts) + isIgnored = await isGitIgnored(filePath, { ...options, cwd: projectRoot }); + + // Also check common patterns that might match this basename + if (!isIgnored) { + // Check common git-ignore patterns that might match + const patternsToCheck = [ + `**/${filePath}`, + `*/${filePath}`, + ]; + + for (const pattern of patternsToCheck) { + if (await isGitIgnored(pattern, { ...options, cwd: projectRoot })) { + isIgnored = true; + break; + } + } + } + } + + if (!isIgnored) { + filtered.push(filePath); + } + } + + return filtered; +} + /** * Clean up git baseline comparison directories */ diff --git a/tests/unit/handlers/compareHandler.test.ts b/tests/unit/handlers/compareHandler.test.ts index dca50ee..b9e7ef7 100644 --- a/tests/unit/handlers/compareHandler.test.ts +++ b/tests/unit/handlers/compareHandler.test.ts @@ -28,6 +28,7 @@ const { mockRemoveWorktree, mockCreateBaselinePaths, mockCleanupBaselinePaths, + mockFilterGitIgnoredFiles, } = vi.hoisted(() => ({ mockExistsSync: vi.fn(), mockMkdir: vi.fn(), @@ -55,6 +56,7 @@ const { mockRemoveWorktree: vi.fn(), mockCreateBaselinePaths: vi.fn(), mockCleanupBaselinePaths: vi.fn(), + mockFilterGitIgnoredFiles: vi.fn(), })); vi.mock('node:fs', () => ({ @@ -94,6 +96,7 @@ vi.mock('../../../src/utils/git.js', () => ({ removeWorktree: mockRemoveWorktree, createBaselinePaths: mockCreateBaselinePaths, cleanupBaselinePaths: mockCleanupBaselinePaths, + filterGitIgnoredFiles: mockFilterGitIgnoredFiles, })); vi.mock('node:readline', () => ({ @@ -139,6 +142,9 @@ describe('handleCompare', () => { mockRemoveWorktree.mockReset(); mockCreateBaselinePaths.mockReset(); mockCleanupBaselinePaths.mockReset(); + mockFilterGitIgnoredFiles.mockReset(); + // Default implementation: pass through all files (not filtered) + mockFilterGitIgnoredFiles.mockImplementation(async (files: string[]) => files); // Set default mock implementations mockExistsSync.mockReturnValue(true); @@ -1103,6 +1109,10 @@ describe('handleCompare', () => { describe('git baseline mode', () => { beforeEach(() => { + // Reset all mocks + mockFilterGitIgnoredFiles.mockReset(); + mockDisplayMultiFileCompareResult.mockReset(); + // Set up default git mock implementations mockParseGitBaseline.mockReturnValue({ ref: 'main' }); mockIsGitRepo.mockResolvedValue(true); @@ -1122,6 +1132,9 @@ describe('handleCompare', () => { mockCleanupBaselinePaths.mockResolvedValue(undefined); mockContextCommand.mockResolvedValue(undefined); mockMultiFileCompare.mockResolvedValue({ status: 'PASS', folders: [], summary: { totalFolders: 0, addedFolders: 0, orphanedFolders: 0, driftFolders: 0, passFolders: 0, totalComponentsAdded: 0, totalComponentsRemoved: 0, totalComponentsChanged: 0 } }); + // Default implementation: pass through all files (not filtered) + mockFilterGitIgnoredFiles.mockImplementation(async (files: string[]) => files); + mockDisplayMultiFileCompareResult.mockImplementation(() => {}); }); it('should reject invalid baseline format', async () => { @@ -1368,5 +1381,171 @@ describe('handleCompare', () => { expect(process.exit).toHaveBeenCalledWith(0); }); + + it('should filter git-ignored files from comparison results', async () => { + // Mock comparison result with git-ignored file showing as added + const mockResult = { + status: 'DRIFT' as const, + folders: [ + { + folderPath: '.', + contextFile: 'context.json', + status: 'DRIFT' as const, + componentResult: { + status: 'DRIFT' as const, + added: ['next-env.d.ts', 'src/components/Button.tsx'], + removed: [], + changed: [], + }, + }, + ], + summary: { + totalFolders: 1, + addedFolders: 0, + orphanedFolders: 0, + driftFolders: 1, + passFolders: 0, + totalComponentsAdded: 2, + totalComponentsRemoved: 0, + totalComponentsChanged: 0, + }, + }; + + mockMultiFileCompare.mockResolvedValue(mockResult); + + // Mock filterGitIgnoredFiles to filter out next-env.d.ts but keep Button.tsx + mockFilterGitIgnoredFiles.mockImplementation(async (files: string[]) => { + return files.filter(f => f !== 'next-env.d.ts'); + }); + + // Ensure process.exit doesn't interrupt execution + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + vi.spyOn(parser, 'parseCompareArgs').mockReturnValue({ + stats: false, + approve: false, + cleanOrphaned: false, + quiet: false, + skipGitignore: false, + baseline: 'git:main', + positionalArgs: [], + }); + + await handleCompare(['--baseline', 'git:main']); + + // Verify multiFileCompare was called first + expect(mockMultiFileCompare).toHaveBeenCalled(); + + // Verify filterGitIgnoredFiles was called for added components + expect(mockFilterGitIgnoredFiles).toHaveBeenCalledWith( + ['next-env.d.ts', 'src/components/Button.tsx'], + expect.any(String) + ); + // Should also be called for removed (empty array) + expect(mockFilterGitIgnoredFiles).toHaveBeenCalledWith( + [], + expect.any(String) + ); + + // Verify the filtered result was passed to displayMultiFileCompareResult + expect(mockDisplayMultiFileCompareResult).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'DRIFT', + folders: [ + expect.objectContaining({ + componentResult: expect.objectContaining({ + added: ['src/components/Button.tsx'], // next-env.d.ts should be filtered out + }), + }), + ], + summary: expect.objectContaining({ + totalComponentsAdded: 1, // Should be recalculated after filtering + }), + }), + false, + false + ); + }); + + it('should change folder status to PASS when all changes are filtered out', async () => { + // Mock comparison result where all changes are git-ignored + const mockResult = { + status: 'DRIFT' as const, + folders: [ + { + folderPath: '.', + contextFile: 'context.json', + status: 'DRIFT' as const, + componentResult: { + status: 'DRIFT' as const, + added: ['next-env.d.ts'], + removed: [], + changed: [], + }, + }, + ], + summary: { + totalFolders: 1, + addedFolders: 0, + orphanedFolders: 0, + driftFolders: 1, + passFolders: 0, + totalComponentsAdded: 1, + totalComponentsRemoved: 0, + totalComponentsChanged: 0, + }, + }; + + mockMultiFileCompare.mockResolvedValue(mockResult); + + // Mock filterGitIgnoredFiles to filter out all files + mockFilterGitIgnoredFiles.mockResolvedValue([]); + + vi.spyOn(parser, 'parseCompareArgs').mockReturnValue({ + stats: false, + approve: false, + cleanOrphaned: false, + quiet: false, + skipGitignore: false, + baseline: 'git:main', + positionalArgs: [], + }); + + await handleCompare(['--baseline', 'git:main']); + + // Verify multiFileCompare was called first + expect(mockMultiFileCompare).toHaveBeenCalled(); + + // Verify filterGitIgnoredFiles was called + expect(mockFilterGitIgnoredFiles).toHaveBeenCalledWith( + ['next-env.d.ts'], + expect.any(String) + ); + + // Verify the folder status was changed to PASS + expect(mockDisplayMultiFileCompareResult).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'PASS', // Overall status should be PASS + folders: [ + expect.objectContaining({ + status: 'PASS', // Folder status should be PASS + componentResult: expect.objectContaining({ + status: 'PASS', // Component result status should be PASS + added: [], // All git-ignored files filtered out + }), + }), + ], + summary: expect.objectContaining({ + driftFolders: 0, // Should be recalculated + passFolders: 1, // Should be recalculated + totalComponentsAdded: 0, // Should be recalculated + }), + }), + false, + false + ); + + expect(process.exit).toHaveBeenCalledWith(0); + }); }); }); diff --git a/tests/unit/utils/git.test.ts b/tests/unit/utils/git.test.ts index 75705d9..48177e5 100644 --- a/tests/unit/utils/git.test.ts +++ b/tests/unit/utils/git.test.ts @@ -9,28 +9,60 @@ import { describeGitRef, hasUncommittedChanges, getCurrentBranch, + isGitIgnored, + filterGitIgnoredFiles, } from '../../../src/utils/git.js'; -// Mock child_process and util.promisify -// Use vi.hoisted to declare mockExec so it's available to hoisted mocks -const mockExec = vi.hoisted(() => vi.fn()); +// Mock child_process spawn +// Use vi.hoisted to declare mockSpawn and mockSpawnResult +const mockSpawn = vi.hoisted(() => vi.fn()); +const mockSpawnResult = vi.hoisted(() => { + return vi.fn(() => ({ + stdout: '', + stderr: '', + code: 0, + error: null as Error | null, + })); +}); vi.mock('node:child_process', () => ({ - exec: (command: string, options: any, callback?: Function) => { - if (callback) { - mockExec(command, options, callback); - } else { - mockExec(command, options); - } - return { on: vi.fn() }; - }, -})); - -// Mock util.promisify - promisify takes a function and returns a promisified version -vi.mock('node:util', () => ({ - promisify: (fn: Function) => { - // Return mockExec when promisify(exec) is called - return mockExec; + spawn: (command: string, args: string[], options: any) => { + // Store the call for assertions + mockSpawn(command, args, options); + + const result = mockSpawnResult(); + + // Create a mock ChildProcess-like object + const mockChild = { + stdout: { + on: vi.fn((event: string, handler: Function) => { + if (event === 'data' && result.stdout) { + // Simulate stdout data asynchronously + setImmediate(() => handler(Buffer.from(result.stdout))); + } + }), + }, + stderr: { + on: vi.fn((event: string, handler: Function) => { + if (event === 'data' && result.stderr) { + // Simulate stderr data asynchronously + setImmediate(() => handler(Buffer.from(result.stderr))); + } + }), + }, + on: vi.fn((event: string, handler: Function) => { + if (event === 'error' && result.error) { + // Simulate error asynchronously + setImmediate(() => handler(result.error)); + } else if (event === 'close') { + // Simulate close with exit code asynchronously + const code = result.error ? 1 : result.code; + setImmediate(() => handler(code)); + } + }), + }; + + return mockChild; }, })); @@ -48,6 +80,13 @@ describe('git utilities', () => { vi.clearAllMocks(); mockMkdir.mockResolvedValue(undefined); mockRm.mockResolvedValue(undefined); + // Reset spawn result to default success + mockSpawnResult.mockReturnValue({ + stdout: '', + stderr: '', + code: 0, + error: null, + }); }); describe('parseGitBaseline', () => { @@ -146,7 +185,7 @@ describe('git utilities', () => { }; // Mock successful worktree remove - mockExec.mockResolvedValueOnce({ stdout: '', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 0, error: null }); await cleanupBaselinePaths(paths); @@ -162,7 +201,7 @@ describe('git utilities', () => { }; // Mock failed worktree remove - mockExec.mockRejectedValueOnce(new Error('Worktree not found')); + mockSpawnResult.mockReturnValue({ stdout: '', stderr: 'Worktree not found', code: 1, error: null }); mockRm.mockRejectedValueOnce(new Error('Directory not found')); // Should not throw @@ -172,14 +211,19 @@ describe('git utilities', () => { describe('isGitRepo', () => { it('should return true for git repository', async () => { - mockExec.mockResolvedValueOnce({ stdout: '.git', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: '.git', stderr: '', code: 0, error: null }); const result = await isGitRepo(); expect(result).toBe(true); }); it('should return false for non-git directory', async () => { - mockExec.mockRejectedValueOnce(new Error('Not a git repository')); + mockSpawnResult.mockReturnValue({ + stdout: '', + stderr: 'Not a git repository', + code: 1, + error: null + }); const result = await isGitRepo(); expect(result).toBe(false); @@ -188,14 +232,19 @@ describe('git utilities', () => { describe('resolveGitRef', () => { it('should resolve ref to commit hash', async () => { - mockExec.mockResolvedValueOnce({ stdout: 'abc123def456', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: 'abc123def456', stderr: '', code: 0, error: null }); const result = await resolveGitRef('main'); expect(result).toBe('abc123def456'); }); it('should throw for invalid ref', async () => { - mockExec.mockRejectedValueOnce(new Error('fatal: bad revision')); + mockSpawnResult.mockReturnValue({ + stdout: '', + stderr: 'fatal: bad revision', + code: 1, + error: null + }); await expect(resolveGitRef('nonexistent')).rejects.toThrow( 'Invalid git ref "nonexistent": ref does not exist' @@ -205,7 +254,7 @@ describe('git utilities', () => { describe('describeGitRef', () => { it('should return branch name for branch ref', async () => { - mockExec.mockResolvedValueOnce({ stdout: 'main', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: 'main', stderr: '', code: 0, error: null }); const result = await describeGitRef('main'); expect(result).toBe('main'); @@ -213,17 +262,17 @@ describe('git utilities', () => { it('should return short hash for detached HEAD', async () => { // First call returns HEAD (not a branch) - mockExec.mockResolvedValueOnce({ stdout: 'HEAD', stderr: '' }); + mockSpawnResult.mockReturnValueOnce({ stdout: 'HEAD', stderr: '', code: 0, error: null }); // Second call returns short hash - mockExec.mockResolvedValueOnce({ stdout: 'abc123d', stderr: '' }); + mockSpawnResult.mockReturnValueOnce({ stdout: 'abc123d', stderr: '', code: 0, error: null }); const result = await describeGitRef('abc123def456'); expect(result).toBe('abc123d'); }); it('should return original ref if all lookups fail', async () => { - mockExec.mockRejectedValueOnce(new Error('Failed')); - mockExec.mockRejectedValueOnce(new Error('Failed')); + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: 'Failed', code: 1, error: null }); + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: 'Failed', code: 1, error: null }); const result = await describeGitRef('weird-ref'); expect(result).toBe('weird-ref'); @@ -232,21 +281,21 @@ describe('git utilities', () => { describe('hasUncommittedChanges', () => { it('should return true when there are changes', async () => { - mockExec.mockResolvedValueOnce({ stdout: 'M file.ts', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: 'M file.ts', stderr: '', code: 0, error: null }); const result = await hasUncommittedChanges(); expect(result).toBe(true); }); it('should return false when working tree is clean', async () => { - mockExec.mockResolvedValueOnce({ stdout: '', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 0, error: null }); const result = await hasUncommittedChanges(); expect(result).toBe(false); }); it('should return false on error', async () => { - mockExec.mockRejectedValueOnce(new Error('Not a git repo')); + mockSpawnResult.mockReturnValue({ stdout: '', stderr: 'Not a git repo', code: 1, error: null }); const result = await hasUncommittedChanges(); expect(result).toBe(false); @@ -255,17 +304,137 @@ describe('git utilities', () => { describe('getCurrentBranch', () => { it('should return current branch name', async () => { - mockExec.mockResolvedValueOnce({ stdout: 'feature-branch', stderr: '' }); + mockSpawnResult.mockReturnValue({ stdout: 'feature-branch', stderr: '', code: 0, error: null }); const result = await getCurrentBranch(); expect(result).toBe('feature-branch'); }); it('should return HEAD when detached', async () => { - mockExec.mockRejectedValueOnce(new Error('Not on a branch')); + mockSpawnResult.mockReturnValue({ stdout: '', stderr: 'Not on a branch', code: 1, error: null }); const result = await getCurrentBranch(); expect(result).toBe('HEAD'); }); }); + + describe('isGitIgnored', () => { + it('should return true for git-ignored file', async () => { + // git check-ignore --quiet returns exit code 0 (success) if file is ignored + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 0, error: null }); + + const result = await isGitIgnored('next-env.d.ts'); + expect(result).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['check-ignore', '--quiet', 'next-env.d.ts'], + expect.any(Object) + ); + }); + + it('should return false for non-ignored file', async () => { + // git check-ignore --quiet returns exit code 1 (failure) if file is not ignored + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 1, error: null }); + + const result = await isGitIgnored('src/components/Button.tsx'); + expect(result).toBe(false); + }); + + it('should use custom cwd option', async () => { + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 0, error: null }); + + await isGitIgnored('file.ts', { cwd: '/custom/path' }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['check-ignore', '--quiet', 'file.ts'], + expect.objectContaining({ cwd: '/custom/path' }) + ); + }); + }); + + describe('filterGitIgnoredFiles', () => { + it('should filter out git-ignored files', async () => { + const filePaths = ['src/components/Button.tsx', 'next-env.d.ts', 'src/utils/helper.ts']; + + // Button.tsx is not ignored (check fails) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + // next-env.d.ts is ignored (check succeeds) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 0, error: null }); + // helper.ts is not ignored (check fails) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual(['src/components/Button.tsx', 'src/utils/helper.ts']); + expect(result).not.toContain('next-env.d.ts'); + }); + + it('should handle relative paths', async () => { + const filePaths = ['next-env.d.ts', 'src/file.ts']; + + // next-env.d.ts is ignored (check succeeds) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 0, error: null }); + // src/file.ts is not ignored (check fails) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual(['src/file.ts']); + }); + + it('should handle absolute paths', async () => { + const filePaths = ['/project/next-env.d.ts', '/project/src/file.ts']; + + // next-env.d.ts is ignored (check succeeds) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 0, error: null }); + // file.ts is not ignored (check fails) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual(['/project/src/file.ts']); + }); + + it('should return all files if none are ignored', async () => { + const filePaths = ['src/file1.ts', 'src/file2.ts']; + + // Both files are not ignored (checks fail) + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 1, error: null }); + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual(filePaths); + }); + + it('should return empty array if all files are ignored', async () => { + const filePaths = ['next-env.d.ts', '.env.local']; + + // Both files are ignored + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 0, error: null }); + mockSpawnResult.mockReturnValueOnce({ stdout: '', stderr: '', code: 0, error: null }); + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual([]); + }); + + it('should check basename patterns for normalized paths', async () => { + const filePaths = ['next-env.d.ts']; // Normalized basename (no path) + + // Check basename directly - file is ignored (check succeeds) + mockSpawnResult.mockReturnValue({ stdout: '', stderr: '', code: 0, error: null }); + // Pattern check should not be called if basename check succeeds + + const result = await filterGitIgnoredFiles(filePaths, '/project'); + + expect(result).toEqual([]); + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['check-ignore', '--quiet', 'next-env.d.ts'], + expect.any(Object) + ); + }); + }); });