mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
- 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.
320 lines
8.4 KiB
TypeScript
320 lines
8.4 KiB
TypeScript
/**
|
|
* Common utilities shared across all route modules
|
|
*/
|
|
|
|
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<typeof createLogger>;
|
|
|
|
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<string, string> = {
|
|
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<boolean> {
|
|
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<string> {
|
|
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<string> {
|
|
// 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<string[]> {
|
|
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
|
|
*/
|
|
export function getErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Unknown error";
|
|
}
|
|
|
|
/**
|
|
* Create a logError function for a specific logger
|
|
* This ensures consistent error logging format across all routes
|
|
*/
|
|
export function createLogError(logger: Logger) {
|
|
return (error: unknown, context: string): void => {
|
|
logger.error(`❌ ${context}:`, error);
|
|
};
|
|
}
|