refactor: consolidate git utilities and model constants

COMPLETED MIGRATIONS:
- Migrate git utilities from routes/common.ts (383 lines → 39 lines)
  - Replace duplicated code with imports from @automaker/git-utils
  - Keep only route-specific utilities (getErrorMessage, createLogError)
  - All git operations now use shared package consistently

- Remove duplicate model constants in UI
  - Update model-config.ts to import from @automaker/types
  - Update agent-context-parser.ts to use DEFAULT_MODELS.claude
  - Removed 40+ lines of duplicated code

DEFERRED (Server-Specific):
- enhancement-prompts.ts (456 lines) - Server-only, no UI usage
- app-spec-format.ts (318 lines) - Server-only, no UI usage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 00:20:11 +01:00
parent dd58b70730
commit 108d52ce9f
3 changed files with 24 additions and 403 deletions

View File

@@ -3,367 +3,23 @@
*/
import { createLogger } from "@automaker/utils";
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
// Re-export git utilities from shared package
export {
BINARY_EXTENSIONS,
GIT_STATUS_MAP,
type FileStatus,
isGitRepo,
parseGitStatus,
generateSyntheticDiffForNewFile,
appendUntrackedFileDiffs,
listAllFilesInDirectory,
generateDiffsForNonGitDirectory,
getGitRepositoryDiffs,
} from "@automaker/git-utils";
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
// Git porcelain format uses XY where X=staging area, Y=working tree
const GIT_STATUS_MAP: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
"!": "Ignored",
" ": "Unmodified",
};
/**
* Get a readable status text from git status codes
* Handles both single character and XY format status codes
*/
function getStatusText(indexStatus: string, workTreeStatus: string): string {
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
return "Untracked";
}
// Ignored files
if (indexStatus === "!" && workTreeStatus === "!") {
return "Ignored";
}
// Prioritize staging area status, then working tree
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
// Handle combined statuses
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
// Both staging and working tree have changes
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
if (indexText === workText) {
return indexText;
}
return `${indexText} (staged), ${workText} (unstaged)`;
}
return GIT_STATUS_MAP[primaryStatus] || "Changed";
}
/**
* 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
* Git porcelain format: XY PATH where X=staging area status, Y=working tree status
* For renamed files: XY ORIG_PATH -> NEW_PATH
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.filter(Boolean)
.map((line) => {
// Git porcelain format uses two status characters: XY
// X = status in staging area (index)
// Y = status in working tree
const indexStatus = line[0] || " ";
const workTreeStatus = line[1] || " ";
// File path starts at position 3 (after "XY ")
let filePath = line.slice(3);
// Handle renamed files (format: "R old_path -> new_path")
if (indexStatus === "R" || workTreeStatus === "R") {
const arrowIndex = filePath.indexOf(" -> ");
if (arrowIndex !== -1) {
filePath = filePath.slice(arrowIndex + 4); // Use new path
}
}
// Determine the primary status character for backwards compatibility
// Prioritize staging area status, then working tree
let primaryStatus: string;
if (indexStatus === "?" && workTreeStatus === "?") {
primaryStatus = "?"; // Untracked
} else if (indexStatus !== " " && indexStatus !== "?") {
primaryStatus = indexStatus; // Staged change
} else {
primaryStatus = workTreeStatus; // Working tree change
}
return {
status: primaryStatus,
path: filePath,
statusText: getStatusText(indexStatus, workTreeStatus),
};
});
}
/**
* 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
*/