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..d1308b30 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -3,9 +3,304 @@ */ import { createLogger } from "../lib/logger.js"; +import fs from "fs/promises"; +import path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; type Logger = ReturnType; +const execAsync = promisify(exec); +const logger = createLogger("Common"); + +// 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", +]); + +// Status map for git status codes +const GIT_STATUS_MAP: Record = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + U: "Updated", + "?": "Untracked", +}; + +/** + * File status interface for git status results + */ +export interface FileStatus { + status: string; + path: string; + statusText: string; +} + +/** + * 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); +} + +/** + * Check if a path is a git repository + */ +export async function isGitRepo(repoPath: string): Promise { + try { + await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath }); + return true; + } catch { + return false; + } +} + +/** + * Parse the output of `git status --porcelain` into FileStatus array + */ +export function parseGitStatus(statusOutput: string): FileStatus[] { + return statusOutput + .split("\n") + .filter(Boolean) + .map((line) => { + const statusChar = line[0]; + const filePath = line.slice(3); + return { + status: statusChar, + path: filePath, + statusText: GIT_STATUS_MAP[statusChar] || "Unknown", + }; + }); +} + +/** + * 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 hasTrailingNewline = content.endsWith("\n"); + const lines = content.split("\n"); + + // Remove trailing empty line if the file ends with newline + if (lines.length > 0 && lines.at(-1) === "") { + lines.pop(); + } + + // Generate diff format + const lineCount = lines.length; + const addedLines = lines.map(line => `+${line}`).join("\n"); + + let diff = `diff --git a/${relativePath} b/${relativePath} +new file mode 100644 +index 0000000..0000000 +--- /dev/null ++++ b/${relativePath} +@@ -0,0 +1,${lineCount} @@ +${addedLines}`; + + // Add "No newline at end of file" indicator if needed + if (!hasTrailingNewline && content.length > 0) { + diff += "\n\\ No newline at end of file"; + } + + return diff + "\n"; + } catch (error) { + // Log the error for debugging + logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error); + // 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 (error) { + // Log the error to help diagnose file system issues + logger.error(`Error reading directory ${fullPath}:`, error); + } + + 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: FileStatus[] }> { + const allFiles = await listAllFilesInDirectory(basePath); + + const files: FileStatus[] = 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 git repository diffs for a given path + * Handles both git repos and non-git directories + */ +export async function getGitRepositoryDiffs( + repoPath: string +): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> { + // Check if it's a git repository + const isRepo = await isGitRepo(repoPath); + + if (!isRepo) { + // Not a git repo - list all files and treat them as new + const result = await generateDiffsForNonGitDirectory(repoPath); + return { + diff: result.diff, + files: result.files, + hasChanges: result.files.length > 0, + }; + } + + // Get git diff and status + const { stdout: diff } = await execAsync("git diff HEAD", { + cwd: repoPath, + maxBuffer: 10 * 1024 * 1024, + }); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: repoPath, + }); + + const files = parseGitStatus(status); + + // Generate synthetic diffs for untracked (new) files + const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files); + + return { + diff: combinedDiff, + files, + hasChanges: files.length > 0, + }; +} + /** * 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..eb532a03 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -3,11 +3,8 @@ */ import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; import { getErrorMessage, logError } from "../common.js"; - -const execAsync = promisify(exec); +import { getGitRepositoryDiffs } from "../../common.js"; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -20,43 +17,15 @@ export function createDiffsHandler() { } try { - 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 result = await getGitRepositoryDiffs(projectPath); res.json({ success: true, - diff, - files, - hasChanges: files.length > 0, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, }); - } catch { + } catch (innerError) { + logError(innerError, "Git diff 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..b9823902 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -3,13 +3,10 @@ */ import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; import path from "path"; import fs from "fs/promises"; import { getErrorMessage, logError } from "../common.js"; - -const execAsync = promisify(exec); +import { getGitRepositoryDiffs } from "../../common.js"; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -37,45 +34,33 @@ export function createDiffsHandler() { ); try { + // Check if worktree exists await fs.access(worktreePath); - const { stdout: diff } = await execAsync("git diff HEAD", { - cwd: worktreePath, - maxBuffer: 10 * 1024 * 1024, - }); - const { stdout: status } = await execAsync("git status --porcelain", { - cwd: worktreePath, - }); - - 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", - }; - }); + // Get diffs from worktree + const result = await getGitRepositoryDiffs(worktreePath); res.json({ success: true, - diff, - files, - hasChanges: files.length > 0, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, }); - } 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 { + const result = await getGitRepositoryDiffs(projectPath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + }); + } 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) {