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");