diff --git a/libs/git-utils/package.json b/libs/git-utils/package.json new file mode 100644 index 00000000..7e03158e --- /dev/null +++ b/libs/git-utils/package.json @@ -0,0 +1,22 @@ +{ + "name": "@automaker/git-utils", + "version": "1.0.0", + "description": "Git operations utilities for AutoMaker", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "keywords": ["automaker", "git", "utils"], + "author": "", + "license": "MIT", + "dependencies": { + "@automaker/types": "^1.0.0", + "@automaker/utils": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/libs/git-utils/src/diff.ts b/libs/git-utils/src/diff.ts new file mode 100644 index 00000000..9e41e8ed --- /dev/null +++ b/libs/git-utils/src/diff.ts @@ -0,0 +1,240 @@ +/** + * Git diff generation utilities + */ + +import { createLogger } from '@automaker/utils'; +import fs from "fs/promises"; +import path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { BINARY_EXTENSIONS, type FileStatus } from './types'; +import { isGitRepo, parseGitStatus } from './status'; + +const execAsync = promisify(exec); +const logger = createLogger("GitUtils"); + +// Max file size for generating synthetic diffs (1MB) +const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024; + +/** + * 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 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 { + // 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 (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, + }; +} diff --git a/libs/git-utils/src/index.ts b/libs/git-utils/src/index.ts new file mode 100644 index 00000000..6d7138b6 --- /dev/null +++ b/libs/git-utils/src/index.ts @@ -0,0 +1,26 @@ +/** + * @automaker/git-utils + * Git operations utilities for AutoMaker + */ + +// Export types and constants +export { + BINARY_EXTENSIONS, + GIT_STATUS_MAP, + type FileStatus, +} from './types'; + +// Export status utilities +export { + isGitRepo, + parseGitStatus, +} from './status'; + +// Export diff utilities +export { + generateSyntheticDiffForNewFile, + appendUntrackedFileDiffs, + listAllFilesInDirectory, + generateDiffsForNonGitDirectory, + getGitRepositoryDiffs, +} from './diff'; diff --git a/libs/git-utils/src/status.ts b/libs/git-utils/src/status.ts new file mode 100644 index 00000000..7055b883 --- /dev/null +++ b/libs/git-utils/src/status.ts @@ -0,0 +1,99 @@ +/** + * Git status parsing utilities + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import { GIT_STATUS_MAP, type FileStatus } from './types'; + +const execAsync = promisify(exec); + +/** + * 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"; +} + +/** + * 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 + * 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), + }; + }); +} diff --git a/libs/git-utils/src/types.ts b/libs/git-utils/src/types.ts new file mode 100644 index 00000000..9499e570 --- /dev/null +++ b/libs/git-utils/src/types.ts @@ -0,0 +1,38 @@ +/** + * Git utilities types and constants + */ + +// Binary file extensions to skip +export 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 +export const GIT_STATUS_MAP: Record = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + U: "Updated", + "?": "Untracked", + "!": "Ignored", + " ": "Unmodified", +}; + +/** + * File status interface for git status results + */ +export interface FileStatus { + status: string; + path: string; + statusText: string; +} diff --git a/libs/git-utils/tsconfig.json b/libs/git-utils/tsconfig.json new file mode 100644 index 00000000..54e9774b --- /dev/null +++ b/libs/git-utils/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "types": ["node"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}