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.
This commit is contained in:
Tony Nekola
2025-12-24 23:16:12 +02:00
parent 8a0226512d
commit 8f2d134d03
2 changed files with 56 additions and 13 deletions

View File

@@ -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<string> {
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]']);
}
}

View File

@@ -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');
});
});