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) {