From 31bb069e75604af35e86586b26140c82600d1690 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Tue, 16 Dec 2025 00:42:27 -0500 Subject: [PATCH 1/2] 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. --- apps/app/src/components/ui/git-diff-panel.tsx | 35 ++++ apps/server/src/routes/common.ts | 189 ++++++++++++++++++ apps/server/src/routes/git/routes/diffs.ts | 43 +++- .../server/src/routes/git/routes/file-diff.ts | 32 ++- .../src/routes/worktree/routes/diffs.ts | 100 ++++++++- .../src/routes/worktree/routes/file-diff.ts | 33 ++- 6 files changed, 413 insertions(+), 19 deletions(-) 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) { From ec6ec7d569e02b375740904875731ebe860d5036 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Tue, 16 Dec 2025 00:50:58 -0500 Subject: [PATCH 2/2] feat: integrate git repository diff handling into common route - Added functions to check if a path is a git repository and to parse git status output into a structured format. - Refactored diff handling in both git and worktree routes to utilize the new common functions, improving code reuse and maintainability. - Enhanced error logging for better debugging during git operations. This update streamlines the process of retrieving diffs for both git and non-git directories, ensuring a consistent approach across the application. --- apps/server/src/routes/common.ts | 124 +++++++++++++++-- apps/server/src/routes/git/routes/diffs.ts | 82 +---------- .../src/routes/worktree/routes/diffs.ts | 131 ++---------------- 3 files changed, 132 insertions(+), 205 deletions(-) diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts index 284c4d18..d1308b30 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -5,9 +5,14 @@ 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; @@ -23,6 +28,26 @@ const BINARY_EXTENSIONS = new Set([ ".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 */ @@ -31,6 +56,36 @@ function isBinaryFile(filePath: string): boolean { 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 @@ -67,10 +122,11 @@ index 0000000..0000000 // 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[lines.length - 1] === "") { + if (lines.length > 0 && lines.at(-1) === "") { lines.pop(); } @@ -78,16 +134,24 @@ index 0000000..0000000 const lineCount = lines.length; const addedLines = lines.map(line => `+${line}`).join("\n"); - return `diff --git a/${relativePath} b/${relativePath} + let diff = `diff --git a/${relativePath} b/${relativePath} new file mode 100644 index 0000000..0000000 --- /dev/null +++ b/${relativePath} @@ -0,0 +1,${lineCount} @@ -${addedLines} -`; +${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) { - // If we can't read the file, return a placeholder diff + // 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 @@ -162,8 +226,9 @@ export async function listAllFilesInDirectory( files.push(entryRelPath); } } - } catch { - // Ignore errors (permission denied, etc.) + } catch (error) { + // Log the error to help diagnose file system issues + logger.error(`Error reading directory ${fullPath}:`, error); } return files; @@ -175,10 +240,10 @@ export async function listAllFilesInDirectory( */ export async function generateDiffsForNonGitDirectory( basePath: string -): Promise<{ diff: string; files: Array<{ status: string; path: string; statusText: string }> }> { +): Promise<{ diff: string; files: FileStatus[] }> { const allFiles = await listAllFilesInDirectory(basePath); - const files = allFiles.map(filePath => ({ + const files: FileStatus[] = allFiles.map(filePath => ({ status: "?", path: filePath, statusText: "New", @@ -195,6 +260,47 @@ export async function generateDiffsForNonGitDirectory( }; } +/** + * 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 1c995c1a..eb532a03 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -3,24 +3,8 @@ */ 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; - } -} +import { getGitRepositoryDiffs } from "../../common.js"; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -32,70 +16,16 @@ 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, - 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", - }; - }); - - // 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); - + const result = await getGitRepositoryDiffs(projectPath); res.json({ success: true, - diff: combinedDiff, - files, - hasChanges: files.length > 0, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, }); } catch (innerError) { - // Log the error for debugging instead of silently swallowing it - logError(innerError, "Git command failed"); + logError(innerError, "Git diff failed"); res.json({ success: true, diff: "", files: [], hasChanges: false }); } } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index b048d951..b9823902 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -3,26 +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"; -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; - } -} +import { getGitRepositoryDiffs } from "../../common.js"; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -50,121 +34,28 @@ export function createDiffsHandler() { ); try { + // Check if worktree exists 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, - }); - 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", - }; - }); - - // 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); - + // Get diffs from worktree + const result = await getGitRepositoryDiffs(worktreePath); res.json({ success: true, - diff: combinedDiff, - files, - hasChanges: files.length > 0, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, }); } 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); - + const result = await getGitRepositoryDiffs(projectPath); res.json({ success: true, - diff: combinedDiff, - files, - hasChanges: files.length > 0, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, }); } catch (fallbackError) { logError(fallbackError, "Fallback to main project also failed");