diff --git a/apps/app/src/components/ui/git-diff-panel.tsx b/apps/app/src/components/ui/git-diff-panel.tsx index d6789547..b00e9d8e 100644 --- a/apps/app/src/components/ui/git-diff-panel.tsx +++ b/apps/app/src/components/ui/git-diff-panel.tsx @@ -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 && ( +
+ {files.map((file) => ( +
+
+ {getFileIcon(file.status)} + + {file.path} + + + {getStatusDisplayName(file.status)} + +
+
+ {file.status === "?" ? ( + New file - content preview not available + ) : file.status === "D" ? ( + File deleted + ) : ( + Diff content not available + )} +
+
+ ))} +
+ )} )} diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts index 8a1fcc68..284c4d18 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -3,9 +3,198 @@ */ import { createLogger } from "../lib/logger.js"; +import fs from "fs/promises"; +import path from "path"; type Logger = ReturnType; +// 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 { + 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 { + // 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 { + 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 */ diff --git a/apps/server/src/routes/git/routes/diffs.ts b/apps/server/src/routes/git/routes/diffs.ts index dd0e809f..1c995c1a 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -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 { + 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 => { 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) { diff --git a/apps/server/src/routes/git/routes/file-diff.ts b/apps/server/src/routes/git/routes/file-diff.ts index 7f480a6f..fdf66998 100644 --- a/apps/server/src/routes/git/routes/file-diff.ts +++ b/apps/server/src/routes/git/routes/file-diff.ts @@ -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) { diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index cc2c6cf4..b048d951 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -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 { + 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 => { 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 = { + 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"); diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 27fafba5..a9cc4faf 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -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) {