From 8f2d134d03993ea2e0719f23862384b99572480b Mon Sep 17 00:00:00 2001 From: Tony Nekola Date: Wed, 24 Dec 2025 23:16:12 +0200 Subject: [PATCH] fix: expand untracked directories to show individual file diffs Previously, when git status reported an untracked directory (e.g., "?? apps/"), the code would try to read the directory as a file, which failed and showed "[Unable to read file content]". Now, when encountering a directory: - Strip trailing slash from path (git reports dirs as "dirname/") - Check if path is a directory using stats.isDirectory() - Recursively list all files inside using listAllFilesInDirectory - Generate synthetic diffs for each file found This ensures users see the actual file contents in the diff view instead of an error placeholder. --- libs/git-utils/src/diff.ts | 34 ++++++++++++++++++++---------- libs/git-utils/tests/diff.test.ts | 35 +++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/libs/git-utils/src/diff.ts b/libs/git-utils/src/diff.ts index f052f48d..c88b2f16 100644 --- a/libs/git-utils/src/diff.ts +++ b/libs/git-utils/src/diff.ts @@ -45,20 +45,24 @@ ${addedLines} /** * Generate a synthetic unified diff for an untracked (new) file * This is needed because `git diff HEAD` doesn't include untracked files + * + * If the path is a directory, this will recursively generate diffs for all files inside */ export async function generateSyntheticDiffForNewFile( basePath: string, relativePath: string ): Promise { - const fullPath = path.join(basePath, relativePath); + // Remove trailing slash if present (git status reports directories with trailing /) + const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + const fullPath = path.join(basePath, cleanPath); try { // Check if it's a binary file - if (isBinaryFile(relativePath)) { - return `diff --git a/${relativePath} b/${relativePath} + if (isBinaryFile(cleanPath)) { + return `diff --git a/${cleanPath} b/${cleanPath} new file mode 100644 index 0000000..0000000 -Binary file ${relativePath} added +Binary file ${cleanPath} added `; } @@ -66,15 +70,23 @@ Binary file ${relativePath} added const stats = await secureFs.stat(fullPath); // Check if it's a directory (can happen with untracked directories from git status) + // If so, recursively list all files and generate diffs for each if (stats.isDirectory()) { - return createNewFileDiff(relativePath, '040000', ['[Directory]']); + const filesInDir = await listAllFilesInDirectory(basePath, cleanPath); + if (filesInDir.length === 0) { + // Empty directory + return createNewFileDiff(cleanPath, '040000', ['[Empty directory]']); + } + // Generate diffs for all files in the directory + const diffs = await Promise.all( + filesInDir.map((filePath) => generateSyntheticDiffForNewFile(basePath, filePath)) + ); + return diffs.join(''); } if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) { const sizeKB = Math.round(stats.size / 1024); - return createNewFileDiff(relativePath, '100644', [ - `[File too large to display: ${sizeKB}KB]`, - ]); + return createNewFileDiff(cleanPath, '100644', [`[File too large to display: ${sizeKB}KB]`]); } // Read file content @@ -91,11 +103,11 @@ Binary file ${relativePath} added const lineCount = lines.length; const addedLines = lines.map((line) => `+${line}`).join('\n'); - let diff = `diff --git a/${relativePath} b/${relativePath} + let diff = `diff --git a/${cleanPath} b/${cleanPath} new file mode 100644 index 0000000..0000000 --- /dev/null -+++ b/${relativePath} ++++ b/${cleanPath} @@ -0,0 +1,${lineCount} @@ ${addedLines}`; @@ -109,7 +121,7 @@ ${addedLines}`; // Log the error for debugging logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error); // Return a placeholder diff - return createNewFileDiff(relativePath, '100644', ['[Unable to read file content]']); + return createNewFileDiff(cleanPath, '100644', ['[Unable to read file content]']); } } diff --git a/libs/git-utils/tests/diff.test.ts b/libs/git-utils/tests/diff.test.ts index 953d2763..4b03878e 100644 --- a/libs/git-utils/tests/diff.test.ts +++ b/libs/git-utils/tests/diff.test.ts @@ -118,7 +118,7 @@ describe('diff.ts', () => { expect(diff).toContain('[Unable to read file content]'); }); - it('should handle directory path gracefully', async () => { + it('should handle empty directory path gracefully', async () => { const dirName = 'some-directory'; const dirPath = path.join(tempDir, dirName); await fs.mkdir(dirPath); @@ -127,7 +127,38 @@ describe('diff.ts', () => { expect(diff).toContain(`diff --git a/${dirName} b/${dirName}`); expect(diff).toContain('new file mode 040000'); - expect(diff).toContain('[Directory]'); + expect(diff).toContain('[Empty directory]'); + }); + + it('should expand directory with files and generate diffs for each file', async () => { + const dirName = 'new-feature'; + const dirPath = path.join(tempDir, dirName); + await fs.mkdir(dirPath); + await fs.writeFile(path.join(dirPath, 'index.ts'), 'export const foo = 1;\n'); + await fs.writeFile(path.join(dirPath, 'utils.ts'), 'export const bar = 2;\n'); + + const diff = await generateSyntheticDiffForNewFile(tempDir, dirName); + + // Should contain diffs for both files in the directory + expect(diff).toContain(`diff --git a/${dirName}/index.ts b/${dirName}/index.ts`); + expect(diff).toContain(`diff --git a/${dirName}/utils.ts b/${dirName}/utils.ts`); + expect(diff).toContain('+export const foo = 1;'); + expect(diff).toContain('+export const bar = 2;'); + // Should NOT contain a diff for the directory itself + expect(diff).not.toContain('[Empty directory]'); + }); + + it('should handle directory path with trailing slash', async () => { + const dirName = 'trailing-slash-dir'; + const dirPath = path.join(tempDir, dirName); + await fs.mkdir(dirPath); + await fs.writeFile(path.join(dirPath, 'file.txt'), 'content\n'); + + // git status reports untracked directories with trailing slash + const diff = await generateSyntheticDiffForNewFile(tempDir, `${dirName}/`); + + expect(diff).toContain(`diff --git a/${dirName}/file.txt b/${dirName}/file.txt`); + expect(diff).toContain('+content'); }); });