mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -3,367 +3,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from "@automaker/utils";
|
import { createLogger } from "@automaker/utils";
|
||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
// Re-export git utilities from shared package
|
||||||
import { exec } from "child_process";
|
export {
|
||||||
import { promisify } from "util";
|
BINARY_EXTENSIONS,
|
||||||
|
GIT_STATUS_MAP,
|
||||||
|
type FileStatus,
|
||||||
|
isGitRepo,
|
||||||
|
parseGitStatus,
|
||||||
|
generateSyntheticDiffForNewFile,
|
||||||
|
appendUntrackedFileDiffs,
|
||||||
|
listAllFilesInDirectory,
|
||||||
|
generateDiffsForNonGitDirectory,
|
||||||
|
getGitRepositoryDiffs,
|
||||||
|
} from "@automaker/git-utils";
|
||||||
|
|
||||||
type Logger = ReturnType<typeof createLogger>;
|
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
|
* Get error message from error object
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,48 +6,12 @@
|
|||||||
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
|
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
// Import shared model constants and types
|
||||||
* Claude model aliases for convenience
|
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from "@automaker/types";
|
||||||
*/
|
import { resolveModelString } from "@automaker/model-resolver";
|
||||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
|
||||||
haiku: "claude-haiku-4-5",
|
|
||||||
sonnet: "claude-sonnet-4-20250514",
|
|
||||||
opus: "claude-opus-4-5-20251101",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
// Re-export for backward compatibility
|
||||||
* Default models per use case
|
export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, resolveModelString };
|
||||||
*/
|
|
||||||
export const DEFAULT_MODELS = {
|
|
||||||
chat: "claude-opus-4-5-20251101",
|
|
||||||
default: "claude-opus-4-5-20251101",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a model alias to a full model string
|
|
||||||
*/
|
|
||||||
export function resolveModelString(
|
|
||||||
modelKey?: string,
|
|
||||||
defaultModel: string = DEFAULT_MODELS.default
|
|
||||||
): string {
|
|
||||||
if (!modelKey) {
|
|
||||||
return defaultModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full Claude model string - pass through
|
|
||||||
if (modelKey.includes("claude-")) {
|
|
||||||
return modelKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check alias map
|
|
||||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
|
||||||
if (resolved) {
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown key - use default
|
|
||||||
return defaultModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the model for chat operations
|
* Get the model for chat operations
|
||||||
@@ -64,13 +28,13 @@ export function getChatModel(explicitModel?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envModel =
|
const envModel =
|
||||||
process.env.AUTOMAKER_MODEL_CHAT || process.env.AUTOMAKER_MODEL_DEFAULT;
|
import.meta.env.AUTOMAKER_MODEL_CHAT || import.meta.env.AUTOMAKER_MODEL_DEFAULT;
|
||||||
|
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
return resolveModelString(envModel);
|
return resolveModelString(envModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DEFAULT_MODELS.chat;
|
return DEFAULT_MODELS.claude;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,4 +55,3 @@ export const CHAT_TOOLS = [
|
|||||||
* Default max turns for chat
|
* Default max turns for chat
|
||||||
*/
|
*/
|
||||||
export const CHAT_MAX_TURNS = 1000;
|
export const CHAT_MAX_TURNS = 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Extracts useful information from agent context files for display in kanban cards
|
* Extracts useful information from agent context files for display in kanban cards
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_MODELS } from "@automaker/types";
|
||||||
|
|
||||||
export interface AgentTaskInfo {
|
export interface AgentTaskInfo {
|
||||||
// Task list extracted from TodoWrite tool calls
|
// Task list extracted from TodoWrite tool calls
|
||||||
todos: {
|
todos: {
|
||||||
@@ -27,7 +29,7 @@ export interface AgentTaskInfo {
|
|||||||
/**
|
/**
|
||||||
* Default model used by the feature executor
|
* Default model used by the feature executor
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_MODEL = "claude-opus-4-5-20251101";
|
export const DEFAULT_MODEL = DEFAULT_MODELS.claude;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a model name for display
|
* Formats a model name for display
|
||||||
|
|||||||
Reference in New Issue
Block a user