feat: enhance git diff functionality for untracked files

- Implemented synthetic diff generation for untracked files in both git and non-git directories.
- Added fallback UI in the GitDiffPanel for files without diff content, ensuring better user experience.
- Improved error handling and logging for git operations, enhancing reliability in file diff retrieval.

This update allows users to see diffs for new files that are not yet tracked by git, improving the overall functionality of the diff panel.
This commit is contained in:
SuperComboGamer
2025-12-16 00:42:27 -05:00
parent 363be54303
commit 31bb069e75
6 changed files with 413 additions and 19 deletions

View File

@@ -620,6 +620,41 @@ export function GitDiffPanel({
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
{files.length > 0 && parsedDiffs.length === 0 && (
<div className="space-y-2">
{files.map((file) => (
<div
key={file.path}
className="border border-border rounded-lg overflow-hidden"
>
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
{getFileIcon(file.status)}
<span className="flex-1 text-sm font-mono truncate text-foreground">
{file.path}
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
getStatusBadgeColor(file.status)
)}
>
{getStatusDisplayName(file.status)}
</span>
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === "?" ? (
<span>New file - content preview not available</span>
) : file.status === "D" ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}

View File

@@ -3,9 +3,198 @@
*/
import { createLogger } from "../lib/logger.js";
import fs from "fs/promises";
import path from "path";
type Logger = ReturnType<typeof createLogger>;
// Max file size for generating synthetic diffs (1MB)
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
// Binary file extensions to skip
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".tar", ".gz", ".rar", ".7z",
".exe", ".dll", ".so", ".dylib",
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
".pyc", ".pyo", ".class", ".o", ".obj",
]);
/**
* Check if a file is likely binary based on extension
*/
function isBinaryFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}
/**
* Generate a synthetic unified diff for an untracked (new) file
* This is needed because `git diff HEAD` doesn't include untracked files
*/
export async function generateSyntheticDiffForNewFile(
basePath: string,
relativePath: string
): Promise<string> {
const fullPath = path.join(basePath, relativePath);
try {
// Check if it's a binary file
if (isBinaryFile(relativePath)) {
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
Binary file ${relativePath} added
`;
}
// Get file stats to check size
const stats = await fs.stat(fullPath);
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(stats.size / 1024);
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[File too large to display: ${sizeKB}KB]
`;
}
// Read file content
const content = await fs.readFile(fullPath, "utf-8");
const lines = content.split("\n");
// Remove trailing empty line if the file ends with newline
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
// Generate diff format
const lineCount = lines.length;
const addedLines = lines.map(line => `+${line}`).join("\n");
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1,${lineCount} @@
${addedLines}
`;
} catch (error) {
// If we can't read the file, return a placeholder diff
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[Unable to read file content]
`;
}
}
/**
* Generate synthetic diffs for all untracked files and combine with existing diff
*/
export async function appendUntrackedFileDiffs(
basePath: string,
existingDiff: string,
files: Array<{ status: string; path: string }>
): Promise<string> {
// Find untracked files (status "?")
const untrackedFiles = files.filter(f => f.status === "?");
if (untrackedFiles.length === 0) {
return existingDiff;
}
// Generate synthetic diffs for each untracked file
const syntheticDiffs = await Promise.all(
untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
// Combine existing diff with synthetic diffs
const combinedDiff = existingDiff + syntheticDiffs.join("");
return combinedDiff;
}
/**
* List all files in a directory recursively (for non-git repositories)
* Excludes hidden files/folders and common build artifacts
*/
export async function listAllFilesInDirectory(
basePath: string,
relativePath: string = ""
): Promise<string[]> {
const files: string[] = [];
const fullPath = path.join(basePath, relativePath);
// Directories to skip
const skipDirs = new Set([
"node_modules", ".git", ".automaker", "dist", "build",
".next", ".nuxt", "__pycache__", ".cache", "coverage"
]);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden files/folders (except we want to allow some)
if (entry.name.startsWith(".") && entry.name !== ".env") {
continue;
}
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
const subFiles = await listAllFilesInDirectory(basePath, entryRelPath);
files.push(...subFiles);
}
} else if (entry.isFile()) {
files.push(entryRelPath);
}
}
} catch {
// Ignore errors (permission denied, etc.)
}
return files;
}
/**
* Generate diffs for all files in a non-git directory
* Treats all files as "new" files
*/
export async function generateDiffsForNonGitDirectory(
basePath: string
): Promise<{ diff: string; files: Array<{ status: string; path: string; statusText: string }> }> {
const allFiles = await listAllFilesInDirectory(basePath);
const files = allFiles.map(filePath => ({
status: "?",
path: filePath,
statusText: "New",
}));
// Generate synthetic diffs for all files
const syntheticDiffs = await Promise.all(
files.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
return {
diff: syntheticDiffs.join(""),
files,
};
}
/**
* Get error message from error object
*/

View File

@@ -6,9 +6,22 @@ import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
import { appendUntrackedFileDiffs, generateDiffsForNonGitDirectory } from "../../common.js";
const execAsync = promisify(exec);
/**
* Check if a path is a git repository
*/
async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -19,6 +32,26 @@ export function createDiffsHandler() {
return;
}
// Check if it's a git repository
const isRepo = await isGitRepo(projectPath);
if (!isRepo) {
// Not a git repo - list all files and treat them as new
try {
const result = await generateDiffsForNonGitDirectory(projectPath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.files.length > 0,
});
} catch (error) {
logError(error, "Failed to list files in non-git directory");
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
return;
}
try {
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: projectPath,
@@ -50,13 +83,19 @@ export function createDiffsHandler() {
};
});
// Generate synthetic diffs for untracked (new) files
// git diff HEAD doesn't include untracked files, so we need to generate them
const combinedDiff = await appendUntrackedFileDiffs(projectPath, diff, files);
res.json({
success: true,
diff,
diff: combinedDiff,
files,
hasChanges: files.length > 0,
});
} catch {
} catch (innerError) {
// Log the error for debugging instead of silently swallowing it
logError(innerError, "Git command failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
} catch (error) {

View File

@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
const execAsync = promisify(exec);
@@ -25,16 +26,33 @@ export function createFileDiffHandler() {
}
try {
const { stdout: diff } = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
}
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: projectPath }
);
const isUntracked = status.trim().startsWith("??");
let diff: string;
if (isUntracked) {
// Generate synthetic diff for untracked file
diff = await generateSyntheticDiffForNewFile(projectPath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
}
);
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch {
} catch (innerError) {
logError(innerError, "Git file diff failed");
res.json({ success: true, diff: "", filePath });
}
} catch (error) {

View File

@@ -8,9 +8,22 @@ import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { appendUntrackedFileDiffs, generateDiffsForNonGitDirectory } from "../../common.js";
const execAsync = promisify(exec);
/**
* Check if a path is a git repository
*/
async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -38,6 +51,22 @@ export function createDiffsHandler() {
try {
await fs.access(worktreePath);
// Check if worktree is a git repository
const isRepo = await isGitRepo(worktreePath);
if (!isRepo) {
// Not a git repo - list all files and treat them as new
const result = await generateDiffsForNonGitDirectory(worktreePath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.files.length > 0,
});
return;
}
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
@@ -68,14 +97,79 @@ export function createDiffsHandler() {
};
});
// Generate synthetic diffs for untracked (new) files
// git diff HEAD doesn't include untracked files, so we need to generate them
const combinedDiff = await appendUntrackedFileDiffs(worktreePath, diff, files);
res.json({
success: true,
diff,
diff: combinedDiff,
files,
hasChanges: files.length > 0,
});
} catch {
res.json({ success: true, diff: "", files: [], hasChanges: false });
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
logError(innerError, "Worktree access failed, falling back to main project");
try {
// Check if main project is a git repo
const isRepo = await isGitRepo(projectPath);
if (!isRepo) {
// Not a git repo - list all files and treat them as new
const result = await generateDiffsForNonGitDirectory(projectPath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.files.length > 0,
});
return;
}
// Try main project path for git diffs
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: projectPath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
const statusMap: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
return {
status: statusChar,
path: filePath,
statusText: statusMap[statusChar] || "Unknown",
};
});
const combinedDiff = await appendUntrackedFileDiffs(projectPath, diff, files);
res.json({
success: true,
diff: combinedDiff,
files,
hasChanges: files.length > 0,
});
} catch (fallbackError) {
logError(fallbackError, "Fallback to main project also failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
}
} catch (error) {
logError(error, "Get worktree diffs failed");

View File

@@ -8,6 +8,7 @@ import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
const execAsync = promisify(exec);
@@ -37,16 +38,34 @@ export function createFileDiffHandler() {
try {
await fs.access(worktreePath);
const { stdout: diff } = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: worktreePath }
);
const isUntracked = status.trim().startsWith("??");
let diff: string;
if (isUntracked) {
// Generate synthetic diff for untracked file
diff = await generateSyntheticDiffForNewFile(worktreePath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
);
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch {
} catch (innerError) {
logError(innerError, "Worktree file diff failed");
res.json({ success: true, diff: "", filePath });
}
} catch (error) {