/** * Git Engine - Repository operations * Handles git repository initialization, cloning, and operations */ import { deleteRepoFromStorage, isCloudStorage, parseStoragePath, } from "@/lib/git-storage"; import { logger } from "@/lib/logger"; import { spawn } from "child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { basename, dirname, extname, join, resolve } from "path"; import { simpleGit, SimpleGit, SimpleGitOptions } from "simple-git"; // Memory-bounded operation limits (read once at module load, mirroring the // GIT_PROCESS_TIMEOUT pattern in src/lib/git-server.ts) const BLAME_MAX_LINES = Math.max( 1, parseInt(process.env.BLAME_MAX_LINES || "20000", 10), ); const DIFF_MAX_BYTES = Math.max( 1024, parseInt(process.env.DIFF_MAX_BYTES || "2000000", 10), ); const SEARCH_MAX_RESULTS = Math.max( 1, parseInt(process.env.SEARCH_MAX_RESULTS || "500", 10), ); const ACTIVITY_MAX_COMMITS = Math.max( 1, parseInt(process.env.ACTIVITY_MAX_COMMITS || "50000", 10), ); const FILE_CONTENT_MAX_BYTES = Math.max( 1024, parseInt(process.env.FILE_CONTENT_MAX_BYTES || "1000000", 10), ); // Truncate a string to at most maxBytes UTF-8 bytes without splitting a // multi-byte character (walks back over continuation bytes) function utf8SafeSlice(input: string, maxBytes: number): string { if (Buffer.byteLength(input, "utf8") <= maxBytes) { return input; } const buf = Buffer.from(input, "utf8"); let end = maxBytes; while (end > 0 && (buf[end] & 0xc0) === 0x80) { end--; } return buf.subarray(0, end).toString("utf8"); } export interface RepoInitOptions { defaultBranch?: string; readme?: boolean; gitignoreTemplate?: string; licenseType?: string; repoName?: string; ownerName?: string; skipHooks?: boolean; } export type MergeResult = { success: boolean; message: string; sha?: string; // SHA of the merge commit if successful }; export interface FileEntry { name: string; path: string; type: "file" | "directory" | "submodule"; size?: number; mode?: string; sha?: string; } export interface CommitInfo { sha: string; message: string; body?: string; authorName: string; authorEmail: string; authorDate: Date; committerName: string; committerEmail: string; committerDate: Date; parentShas: string[]; verification?: { status: "G" | "B" | "U" | "E" | "N" | "X" | "Y" | "R"; // G=Good, B=Bad, U=Untrusted, E=Error, N=None signerKeyId?: string; signerName?: string; }; } export interface BranchInfo { name: string; sha: string; isDefault: boolean; isProtected?: boolean; ahead?: number; behind?: number; } export interface TagInfo { name: string; sha: string; message?: string; taggerName?: string; taggerEmail?: string; taggedAt?: Date; } export interface DiffInfo { file: string; additions: number; deletions: number; changes: number; status: "added" | "deleted" | "modified" | "renamed" | "copied"; oldPath?: string; patch?: string; } export interface BlameInfo { sha: string; line: number; content: string; author: string; email: string; date: Date; truncated?: boolean; } /** * Initialize a new bare git repository * The caller is responsible for providing the correct local path. * For cloud storage, the caller should use initRepoInStorage before calling this, * and finalizeRepoInit after this completes. */ export async function initRepository( repoPath: string, options: RepoInitOptions = {}, ): Promise { const { defaultBranch = "main", readme = true, gitignoreTemplate, licenseType, repoName = "New Repository", ownerName = "Owner", } = options; logger.info(`[initRepository] Starting initialization at: ${repoPath}`); // repoPath should already be a valid local path (provided by caller) const localRepoPath = repoPath; // Ensure parent directory exists const dir = dirname(localRepoPath); if (!existsSync(dir)) { logger.info(`[initRepository] Creating parent directory: ${dir}`); mkdirSync(dir, { recursive: true }); } // Ensure the repo directory itself exists if (!existsSync(localRepoPath)) { logger.info(`[initRepository] Creating repo directory: ${localRepoPath}`); mkdirSync(localRepoPath, { recursive: true }); } // Initialize bare repository try { logger.info( `[initRepository] Running git init --bare at: ${localRepoPath}`, ); const git = createSimpleGit({ baseDir: localRepoPath }); await git.init(true); logger.info(`[initRepository] git init completed`); } catch (err) { logger.error(`[initRepository] git init failed:`, err); throw err; } // If we need initial content, create a temporary working copy if (readme || gitignoreTemplate || licenseType) { const tempPath = `${localRepoPath}.tmp`; logger.info(`[initRepository] Creating temp working copy at: ${tempPath}`); mkdirSync(tempPath, { recursive: true }); try { const workingGit = createSimpleGit({ baseDir: tempPath }); await workingGit.init(); await workingGit.addConfig("user.name", ownerName); await workingGit.addConfig("user.email", "git@opencodehub.local"); // Create README.md if (readme) { const readmeContent = `# ${repoName}\n\nA new repository created with OpenCodeHub.\n`; const readmePath = join(tempPath, "README.md"); const fs = await import("fs/promises"); await fs.writeFile(readmePath, readmeContent); await workingGit.add("README.md"); logger.info(`[initRepository] Created README.md`); } // Create .gitignore if (gitignoreTemplate) { const gitignorePath = join(tempPath, ".gitignore"); const gitignoreContent = getGitignoreTemplate(gitignoreTemplate); const fs = await import("fs/promises"); await fs.writeFile(gitignorePath, gitignoreContent); await workingGit.add(".gitignore"); logger.info(`[initRepository] Created .gitignore`); } // Create LICENSE if (licenseType) { const licensePath = join(tempPath, "LICENSE"); const licenseContent = getLicenseTemplate(licenseType, ownerName); if (licenseContent) { const fs = await import("fs/promises"); await fs.writeFile(licensePath, licenseContent); await workingGit.add("LICENSE"); logger.info(`[initRepository] Created LICENSE`); } } // Create initial commit await workingGit.commit("Initial commit"); logger.info(`[initRepository] Created initial commit`); // Rename branch to default if (defaultBranch !== "master") { await workingGit.branch(["-M", defaultBranch]); } // Push to bare repository // Use --no-verify to skip hooks: either they aren't installed yet (normal case) // or they're leftover from a previous failed init (repo dir already existed) await workingGit.addRemote("origin", localRepoPath); await workingGit.push(["origin", defaultBranch, "--no-verify"]); logger.info(`[initRepository] Pushed to bare repo`); // Clean up temp directory rmSync(tempPath, { recursive: true, force: true }); logger.info(`[initRepository] Cleaned up temp directory`); } catch (err) { logger.error(`[initRepository] Working copy operations failed:`, err); // Clean up temp if it exists if (existsSync(tempPath)) { rmSync(tempPath, { recursive: true, force: true }); } throw err; } } // Install hooks AFTER the initial push to avoid pre-receive hook callback // during initialization (which would fail if server isn't reachable yet) if (!options.skipHooks) { try { logger.info(`[initRepository] Installing hooks`); await installHooks(localRepoPath); logger.info(`[initRepository] Hooks installed`); } catch (err) { logger.error(`[initRepository] Hook installation failed:`, err); throw err; } } // Set default branch in bare repo try { const bareGit = createSimpleGit({ baseDir: localRepoPath }); await bareGit.raw(["symbolic-ref", "HEAD", `refs/heads/${defaultBranch}`]); logger.info(`[initRepository] Set default branch to ${defaultBranch}`); } catch (err) { logger.error(`[initRepository] Failed to set default branch:`, err); throw err; } // Verify files were created const files = readdirSync(localRepoPath); logger.info(`[initRepository] Completed. Files in repo: ${files.join(", ")}`); } /** * Delete a repository from disk and cloud storage */ export async function deleteRepository(repoPath: string): Promise { // Delete from local filesystem if exists if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } // If cloud storage, also delete from cloud if (await isCloudStorage()) { // repoPath might be a logical path (repos/owner/name.git) // or a physical path - try to delete from storage either way try { await deleteRepoFromStorage(repoPath); } catch (err) { // Also try parsing it as a storage path const parsed = parseStoragePath(repoPath); if (parsed) { await deleteRepoFromStorage( `repos/${parsed.owner}/${parsed.repoName}.git`, ); } } } } /** * Fork a repository by cloning it as a bare repo */ export async function forkRepository( sourcePath: string, targetPath: string, ): Promise { // Handle paths - if already absolute, use as-is const fullSourcePath = sourcePath.startsWith("/") ? sourcePath : join(process.cwd(), sourcePath); const fullTargetPath = targetPath.startsWith("/") ? targetPath : join(process.cwd(), targetPath); // Create parent directory if needed const parentDir = dirname(fullTargetPath); if (!existsSync(parentDir)) { mkdirSync(parentDir, { recursive: true }); } // Check if source exists if (!existsSync(fullSourcePath)) { throw new Error(`Source repository not found: ${fullSourcePath}`); } // Clone as bare repository const git = createSimpleGit(); await git.clone(fullSourcePath, fullTargetPath, ["--bare"]); logger.info(`Repository forked from ${sourcePath} to ${targetPath}`); } /** * Centralised factory for `simple-git` instances. * * OpenCodeHub is a git server; some workflows (force-deleting a branch, * `git checkout`/merge/rebase which may spawn `$EDITOR`, custom SSH * command for the SSH transport, `--upload-pack` / `--receive-pack` for * the smart-HTTP server, and credential helpers for HTTPS remotes) * legitimately use the operations that simple-git's safety plugin * blocks by default. We opt-in to the *minimum* set required and * document each flag. Everything else stays blocked. * * Use this helper everywhere instead of calling `simpleGit()` directly. */ export const SIMPLE_GIT_UNSAFE_OPTS = { allowUnsafeEditor: true, // git checkout/merge/rebase may spawn EDITOR allowUnsafePack: true, // git server uses --upload-pack / --receive-pack allowUnsafeSshCommand: true, // SSH transport uses -c ssh.command= allowUnsafeCredentialHelper: true, // for HTTPS remotes that use a credential helper } as const; export function getSanitizedGitEnv(ceilingDir?: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.PAGER; delete env.GIT_PAGER; // Inject internal secrets needed by git hooks if they are missing from process.env const metaEnv = (import.meta as any).env; const hookSecret = process.env.INTERNAL_HOOK_SECRET || metaEnv?.INTERNAL_HOOK_SECRET; if (hookSecret) { env.INTERNAL_HOOK_SECRET = hookSecret; } // Pass system user for internal pushes to satisfy hook checks if REMOTE_USER is missing if (!env.REMOTE_USER) { env.REMOTE_USER = "system"; } if (ceilingDir) { env.GIT_CEILING_DIRECTORIES = ceilingDir; } return env; } export function createSimpleGit(options: Partial = {}): SimpleGit { const git = simpleGit({ binary: "git", maxConcurrentProcesses: 6, unsafe: { ...SIMPLE_GIT_UNSAFE_OPTS }, ...options, }); git.env(getSanitizedGitEnv()); return git; } export function getGit(repoPath: string): SimpleGit { // logger.info(`[getGit] Initializing for path: ${repoPath} (Exists: ${require('fs').existsSync(repoPath)})`); if (!repoPath || repoPath === "." || repoPath === "./") { logger.warn( `[getGit] WARNING: repoPath is empty or current directory! This might read project git history. Path: '${repoPath}'`, ); } // Prevent git from looking in parent directories if the repoPath is not a git repo // This fixes the issue where empty temp dirs cause git to read the project's own history const ceilingDir = dirname(resolve(repoPath)); const options: Partial = { baseDir: repoPath, binary: "git", maxConcurrentProcesses: 6, unsafe: { ...SIMPLE_GIT_UNSAFE_OPTS }, }; const git = simpleGit(options); git.env(getSanitizedGitEnv(ceilingDir)); return git; } /** * Check if a repository is empty (has no commits) */ export async function isRepoEmpty(repoPath: string): Promise { const git = getGit(repoPath); try { // Try to get HEAD ref - if it fails, repo is empty await git.raw(["rev-parse", "HEAD"]); return false; } catch { return true; } } /** * Get the actual default branch of a repository (from git, not database) * Returns null if repository is empty */ export async function getActualDefaultBranch( repoPath: string, ): Promise { const git = getGit(repoPath); try { const headRef = await git.raw(["symbolic-ref", "--short", "HEAD"]); return headRef.trim(); } catch { // Repository might be empty or HEAD detached return null; } } // Helper to check for expected errors function isExpectedGitError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error); return ( msg.includes("outside repository") || msg.includes("not a git repository") || msg.includes("does not exist") || msg.includes("Not a valid object name") || msg.includes("unknown revision") || msg.includes("ambiguous argument") ); } /** * List files in a directory at a specific ref */ export async function listFiles( repoPath: string, ref: string = "HEAD", path: string = "", ): Promise { const git = getGit(repoPath); try { // Check if repository is empty first if (await isRepoEmpty(repoPath)) { return []; } // Use ls-tree to get files // Append / to path to list contents of directory, otherwise it lists the directory entry itself const targetPath = path ? (path.endsWith("/") ? path : `${path}/`) : "."; const treeOutput = await git.raw([ "ls-tree", "-l", // Include size ref, targetPath, ]); const entries: FileEntry[] = []; const lines = treeOutput.trim().split("\n").filter(Boolean); for (const line of lines) { // Format: mode type sha size path const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(-|\d+)\s+(.+)$/); if (match) { const [, mode, type, sha, size, filePath] = match; entries.push({ name: basename(filePath), path: filePath, type: type === "tree" ? "directory" : type === "commit" ? "submodule" : "file", size: size === "-" ? undefined : parseInt(size, 10), mode, sha, }); } } // Sort: directories first, then files entries.sort((a, b) => { if (a.type === "directory" && b.type !== "directory") return -1; if (a.type !== "directory" && b.type === "directory") return 1; return a.name.localeCompare(b.name); }); return entries; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error listing files"); } return []; } } /** * Get file content at a specific ref */ export async function getFileContent( repoPath: string, filePath: string, ref: string = "HEAD", ): Promise<{ content: string; isBinary: boolean; size: number; truncated?: boolean; } | null> { const git = getGit(repoPath); try { // Get file blob const content = await git.show([`${ref}:${filePath}`]); // Check if binary const isBinary = await isFileBinary(repoPath, filePath, ref); const size = Buffer.byteLength(content, "utf8"); if (!isBinary && size > FILE_CONTENT_MAX_BYTES) { logger.warn( { repoPath, filePath, ref, size, max: FILE_CONTENT_MAX_BYTES }, "File content exceeds FILE_CONTENT_MAX_BYTES, truncating", ); return { content: utf8SafeSlice(content, FILE_CONTENT_MAX_BYTES), isBinary, size, truncated: true, }; } return { content: isBinary ? "" : content, isBinary, size, }; } catch (error) { return null; } } /** * Check if a file is binary */ async function isFileBinary( repoPath: string, filePath: string, ref: string, ): Promise { const git = getGit(repoPath); try { // Use git diff to check if file is binary const result = await git.raw([ "diff", "--numstat", "4b825dc642cb6eb9a060e54bf8d69288fbee4904", // Empty tree SHA ref, "--", filePath, ]); // Binary files show as "-\t-\t" in numstat return result.startsWith("-\t-\t"); } catch { // Check by extension as fallback const binaryExtensions = [ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".zip", ".tar", ".gz", ".rar", ".7z", ".exe", ".dll", ".so", ".dylib", ".mp3", ".mp4", ".wav", ".avi", ".mov", ".ttf", ".woff", ".woff2", ".eot", ]; const ext = extname(filePath).toLowerCase(); return binaryExtensions.includes(ext); } } /** * Get commit history */ export async function getCommits( repoPath: string, options: { ref?: string; path?: string; limit?: number; skip?: number; } = {}, ): Promise { const git = getGit(repoPath); const { ref = "HEAD", path, limit = 30, skip = 0 } = options; try { // Check if repository is empty first if (await isRepoEmpty(repoPath)) { return []; } const args = [ "log", // Use ASCII record separator (0x1e) as delimiter to avoid issues with | in commit messages "--format=%H%x1e%s%x1e%b%x1e%an%x1e%ae%x1e%ai%x1e%cn%x1e%ce%x1e%ci%x1e%P%x1e%G?%x1e%GK%x1e%GS", `-n${limit}`, `--skip=${skip}`, ref, ]; if (path) { args.push("--", path); } const output = await git.raw(args); const commits: CommitInfo[] = []; const DELIMITER = "\x1e"; // ASCII record separator for (const line of output.trim().split("\n").filter(Boolean)) { const parts = line.split(DELIMITER); // Ensure we have at least the required fields if (parts.length < 9) continue; const [ sha, subject, body, authorName, authorEmail, authorDate, committerName, committerEmail, committerDate, parents, gpgStatus, gpgKeyId, gpgSigner, ] = parts; commits.push({ sha, message: subject, body: body || undefined, authorName: authorName || "Unknown", authorEmail: authorEmail || "unknown@unknown", authorDate: new Date(authorDate), committerName: committerName || "Unknown", committerEmail: committerEmail || "unknown@unknown", committerDate: new Date(committerDate), parentShas: parents ? parents.split(" ").filter(Boolean) : [], verification: { status: gpgStatus as any, signerKeyId: gpgKeyId || undefined, signerName: gpgSigner || undefined, }, }); } return commits; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting commits"); } return []; } } /** * Get commit activity (daily commit counts) */ export async function getCommitActivity( repoPath: string, since: string = "1 year ago", ): Promise<{ date: string; count: number }[]> { const git = getGit(repoPath); try { if (await isRepoEmpty(repoPath)) { return []; } // Get just the dates of commits // -n caps the scan so huge histories cannot produce unbounded output const output = await git.raw([ "log", `--since=${since}`, "--date=short", "--format=%ad", `-n${ACTIVITY_MAX_COMMITS}`, ]); const dates = output.trim().split("\n").filter(Boolean); const counts = new Map(); for (const date of dates) { counts.set(date, (counts.get(date) || 0) + 1); } return Array.from(counts.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting commit activity"); } return []; } } export interface SearchResult { file: string; line: number; content: string; truncated?: boolean; } /** * Search code in repository using git grep */ export async function searchCode( repoPath: string, query: string, ref: string = "HEAD", ): Promise { const git = getGit(repoPath); try { if (await isRepoEmpty(repoPath)) { return []; } // git grep -n -I const output = await git.raw([ "grep", "-n", // Line numbers "-I", // Ignore binary files query, ref, ]); const results: SearchResult[] = []; for (const line of output.trim().split("\n").filter(Boolean)) { // Format: ::: const parts = line.split(":"); if (parts.length < 4) continue; const filePath = parts[1]; const lineNumber = parseInt(parts[2], 10); const content = parts.slice(3).join(":"); results.push({ file: filePath, line: lineNumber, content: content.trim(), }); } if (results.length > SEARCH_MAX_RESULTS) { const truncatedResults = results.slice(0, SEARCH_MAX_RESULTS); for (const result of truncatedResults) { result.truncated = true; } logger.warn( { repoPath, query, ref, total: results.length, max: SEARCH_MAX_RESULTS }, "Search results exceed SEARCH_MAX_RESULTS, truncating", ); return truncatedResults; } return results; } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes("exit code 1")) { return []; // No matches } if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error searching code"); } return []; } } /** * Get total commit count */ export async function getCommitCount( repoPath: string, ref: string = "HEAD", ): Promise { const git = getGit(repoPath); try { // Check if repository is empty first if (await isRepoEmpty(repoPath)) { return 0; } const count = await git.raw(["rev-list", "--count", ref]); return parseInt(count.trim(), 10); } catch (error) { return 0; } } /** * Get a single commit */ export async function getCommit( repoPath: string, sha: string, ): Promise { const commits = await getCommits(repoPath, { ref: sha, limit: 1 }); return commits[0] || null; } /** * Get branches */ export async function getBranches(repoPath: string): Promise { const git = getGit(repoPath); try { const output = await git.raw([ "for-each-ref", "--format=%(refname:short)|%(objectname)", "refs/heads/", ]); // Get default branch let defaultBranch = "main"; try { const headRef = await git.raw(["symbolic-ref", "--short", "HEAD"]); defaultBranch = headRef.trim(); } catch { // Ignore if HEAD doesn't point to a branch } const branches: BranchInfo[] = []; for (const line of output.trim().split("\n").filter(Boolean)) { const [name, sha] = line.split("|"); // Filter out HEAD and other invalid branch names if (!name || name === "HEAD" || name.includes("HEAD")) { continue; } branches.push({ name, sha, isDefault: name === defaultBranch, }); } // Sort: default branch first, then alphabetically branches.sort((a, b) => { if (a.isDefault) return -1; if (b.isDefault) return 1; return a.name.localeCompare(b.name); }); return branches; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting branches"); } return []; } } /** * Get list of changed files between two refs */ export async function getChangedFiles( repoPath: string, baseRef: string, headRef: string, ): Promise { const git = getGit(repoPath); try { // git diff --name-only base...head // using 3 dots for merge-base check, or 2 dots? // usually for PRs we want 3 dots (changes from common ancestor) const output = await git.raw([ "diff", "--name-only", `${baseRef}...${headRef}`, ]); return output.trim().split("\n").filter(Boolean); } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting changed files"); } return []; } } /** * Get tags */ export async function getTags(repoPath: string): Promise { const git = getGit(repoPath); try { const output = await git.raw([ "for-each-ref", "--format=%(refname:short)|%(*objectname)|%(objectname)|%(taggername)|%(taggeremail)|%(taggerdate:iso)|%(contents:subject)", "--sort=-version:refname", "refs/tags/", ]); const tags: TagInfo[] = []; for (const line of output.trim().split("\n").filter(Boolean)) { const [name, deref, sha, taggerName, taggerEmail, taggedAt, message] = line.split("|"); tags.push({ name, sha: deref || sha, // Use dereferenced sha for annotated tags message: message || undefined, taggerName: taggerName || undefined, taggerEmail: taggerEmail?.replace(/[<>]/g, "") || undefined, taggedAt: taggedAt ? new Date(taggedAt) : undefined, }); } return tags; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting tags"); } return []; } } /** * Get diff for a commit */ export async function getCommitDiff( repoPath: string, sha: string, ): Promise { const git = getGit(repoPath); try { const output = await git.raw([ "diff-tree", "--no-commit-id", "--numstat", "-r", "-M", // Detect renames sha, ]); const diffs: DiffInfo[] = []; for (const line of output.trim().split("\n").filter(Boolean)) { const parts = line.split("\t"); if (parts.length >= 3) { const [additions, deletions, file] = parts; const add = additions === "-" ? 0 : parseInt(additions, 10); const del = deletions === "-" ? 0 : parseInt(deletions, 10); // Check for rename let filePath = file; let oldPath: string | undefined; let status: DiffInfo["status"] = "modified"; if (file.includes(" => ")) { const [old, newPath] = file.split(" => "); oldPath = old; filePath = newPath; status = "renamed"; } diffs.push({ file: filePath, additions: add, deletions: del, changes: add + del, status, oldPath, }); } } return diffs; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting commit diff"); } return []; } } /** * Get the raw unified diff (patch) for a single commit */ export async function getCommitPatchDiff( repoPath: string, sha: string, ): Promise { const git = getGit(repoPath); try { const output = await git.raw([ "diff-tree", "-p", "--no-commit-id", "-r", "-M", sha, ]); if (Buffer.byteLength(output, "utf8") > DIFF_MAX_BYTES) { logger.warn( { repoPath, sha, max: DIFF_MAX_BYTES }, "Commit patch diff exceeds DIFF_MAX_BYTES, truncating", ); return utf8SafeSlice(output, DIFF_MAX_BYTES); } return output; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting commit patch diff"); } return ""; } } /** * Get blame for a file */ export async function getBlame( repoPath: string, filePath: string, ref: string = "HEAD", ): Promise { const git = getGit(repoPath); try { // -L 1, caps blame output upstream; git clamps the end line to EOF // so this is safe for files smaller than the cap const output = await git.raw([ "blame", "--line-porcelain", "-L", `1,${BLAME_MAX_LINES}`, ref, "--", filePath, ]); const blameLines: BlameInfo[] = []; const lines = output.split("\n"); let currentBlame: Partial = {}; let lineContent = ""; for (const line of lines) { if (line.match(/^[a-f0-9]{40}/)) { if (currentBlame.sha && currentBlame.line !== undefined) { blameLines.push({ ...currentBlame, content: lineContent, } as BlameInfo); } const [sha, , , lineNum] = line.split(" "); currentBlame = { sha, line: parseInt(lineNum, 10) }; } else if (line.startsWith("author ")) { currentBlame.author = line.slice(7); } else if (line.startsWith("author-mail ")) { currentBlame.email = line.slice(12).replace(/[<>]/g, ""); } else if (line.startsWith("author-time ")) { currentBlame.date = new Date(parseInt(line.slice(12), 10) * 1000); } else if (line.startsWith("\t")) { lineContent = line.slice(1); } } // Add last blame if (currentBlame.sha && currentBlame.line !== undefined) { blameLines.push({ ...currentBlame, content: lineContent, } as BlameInfo); } // If we filled the cap, probe one line past it to detect truncation if (blameLines.length >= BLAME_MAX_LINES) { try { await git.raw([ "blame", "--line-porcelain", "-L", `${BLAME_MAX_LINES + 1},${BLAME_MAX_LINES + 1}`, ref, "--", filePath, ]); for (const blameLine of blameLines) { blameLine.truncated = true; } } catch { // Probe failed: file ends at/below the cap, nothing truncated } } return blameLines; } catch (error) { if (!isExpectedGitError(error)) { logger.error({ err: error }, "Error getting blame"); } return []; } } /** * Get repository size */ export async function getRepoSize(repoPath: string): Promise { const git = getGit(repoPath); try { const output = await git.raw(["count-objects", "-vH"]); const match = output.match(/size-pack:\s*([\d.]+)\s*(\w+)/); if (match) { const size = parseFloat(match[1]); const unit = match[2].toLowerCase(); const multipliers: Record = { bytes: 1, kib: 1024, mib: 1024 * 1024, gib: 1024 * 1024 * 1024, }; return Math.round(size * (multipliers[unit] || 1)); } return 0; } catch (error) { return 0; } } /** * Create a new branch */ export async function createBranch( repoPath: string, branchName: string, startPoint: string = "HEAD", ): Promise { const git = getGit(repoPath); try { await git.branch([branchName, startPoint]); } catch (error) { logger.error({ err: error }, "Error creating branch"); throw new Error(`Failed to create branch: ${error}`); } } /** * Delete a branch */ export async function deleteBranch( repoPath: string, branchName: string, ): Promise { const git = getGit(repoPath); try { await git.branch(["-D", branchName]); } catch (error) { logger.error({ err: error }, "Error deleting branch"); throw new Error(`Failed to delete branch: ${error}`); } } /** * Get merge base of two commits */ export async function getMergeBase( repoPath: string, base: string, head: string, ): Promise { const git = getGit(repoPath); try { const output = await git.raw(["merge-base", base, head]); return output.trim(); } catch (error) { logger.error({ err: error }, "Error getting merge base"); return ""; } } /** * Compare two branches to get commits and diff stats */ export async function compareBranches( repoPath: string, base: string, head: string, ): Promise<{ commits: CommitInfo[]; diffs: DiffInfo[]; truncated?: boolean }> { // Verify branches exist first to avoid "ambiguous argument" errors const fullBase = base.startsWith("refs/") ? base : `refs/heads/${base}`; const fullHead = head.startsWith("refs/") ? head : `refs/heads/${head}`; const git = getGit(repoPath); try { await git.raw(["rev-parse", "--verify", fullBase]); } catch { logger.warn({ base, fullBase }, "Base branch not found in compareBranches"); return { commits: [], diffs: [] }; } try { await git.raw(["rev-parse", "--verify", fullHead]); } catch { // Only warn if head is missing but base exists (uncommon but possible) logger.warn({ head, fullHead }, "Head branch not found in compareBranches"); return { commits: [], diffs: [] }; } try { // Get commits between base and head // git log base..head const commits = await getCommits(repoPath, { ref: `${fullBase}..${fullHead}`, limit: 100, // Limit to prevent overload }); // Get diff stats // git diff --numstat base...head (triple dot for merge base comparison) const output = await git.raw([ "diff", "--numstat", `${fullBase}...${fullHead}`, // Triple dot finds merge base automatically ]); const numstatTruncated = Buffer.byteLength(output, "utf8") > DIFF_MAX_BYTES; const numstat = numstatTruncated ? utf8SafeSlice(output, DIFF_MAX_BYTES) : output; const diffs: DiffInfo[] = []; for (const line of numstat.trim().split("\n").filter(Boolean)) { const parts = line.split("\t"); if (parts.length >= 3) { const [additions, deletions, file] = parts; const add = additions === "-" ? 0 : parseInt(additions, 10); const del = deletions === "-" ? 0 : parseInt(deletions, 10); diffs.push({ file, additions: add, deletions: del, changes: add + del, status: "modified", // Simplified status }); } } if (numstatTruncated) { logger.warn( { repoPath, base, head, max: DIFF_MAX_BYTES }, "Branch comparison numstat exceeds DIFF_MAX_BYTES, truncating", ); } return { commits, diffs, truncated: numstatTruncated || undefined }; } catch (error: any) { // Fallback for any other errors logger.error({ err: error }, "Error comparing branches"); return { commits: [], diffs: [] }; } } /** * Get unified patch diff between two refs (for compare view) * Returns raw unified diff output suitable for rendering in a diff viewer */ export async function getComparePatchDiff( repoPath: string, base: string, head: string, ): Promise { const fullBase = base.startsWith("refs/") ? base : `refs/heads/${base}`; const fullHead = head.startsWith("refs/") ? head : `refs/heads/${head}`; const git = getGit(repoPath); try { await git.raw(["rev-parse", "--verify", fullBase]); await git.raw(["rev-parse", "--verify", fullHead]); } catch { return ""; } try { const output = await git.raw([ "diff", `${fullBase}...${fullHead}`, "--unified=5", "-M", // Detect renames ]); if (Buffer.byteLength(output, "utf8") > DIFF_MAX_BYTES) { logger.warn( { repoPath, base, head, max: DIFF_MAX_BYTES }, "Compare patch diff exceeds DIFF_MAX_BYTES, truncating", ); return utf8SafeSlice(output, DIFF_MAX_BYTES); } return output; } catch (error: any) { logger.error({ err: error }, "Error getting compare patch diff"); return ""; } } /** * Merge a branch into another * Uses --shared + --no-checkout for performance (avoids full clone) */ export async function mergeBranch( repoPath: string, base: string, head: string, message?: string, method: "merge" | "squash" | "rebase" = "merge", ): Promise { const tempDir = mkdtempSync(join(tmpdir(), "och-merge-")); try { const tempGit = createSimpleGit(); // Use --shared to avoid duplicating objects (local repos only) + shallow depth await tempGit.clone(repoPath, tempDir, [ "--shared", "--no-checkout", "--single-branch", "--branch", base, ]); const workGit = createSimpleGit({ baseDir: tempDir }); await workGit.checkout(base); // --single-branch clones restrict the fetch refspec to the base branch, so // a bare `fetch origin ` only writes FETCH_HEAD and never creates // `refs/remotes/origin/`; use an explicit refspec instead. The clone // is `--shared`, so objects are already local and no --depth is needed // (shallow fetches would make the head look like unrelated history). await workGit.fetch("origin", `refs/heads/${head}:refs/remotes/origin/${head}`); const commitMsg = message || `Merge pull request from ${head} into ${base}`; if (method === "squash") { // Squash merge: merge --squash stages the combined diff, then commit await workGit.merge(["--squash", `origin/${head}`]); await workGit.commit(commitMsg); } else if (method === "rebase") { // Rebase merge: replay head commits onto base, then fast-forward base await workGit.checkout(["-b", "och-rebase", `origin/${head}`]); await workGit.rebase([base]); await workGit.checkout(base); await workGit.merge(["--ff-only", "och-rebase"]); } else { await workGit.merge(["--no-ff", "-m", commitMsg, `origin/${head}`]); } const newCommitSha = (await workGit.revparse(["HEAD"])).trim(); await workGit.push("origin", `${base}:${base}`); return { success: true, message: "Merged successfully", sha: newCommitSha }; } catch (error: any) { logger.error("Error merging branches:", error); return { success: false, message: error.message || "Merge failed" }; } finally { rmSync(tempDir, { recursive: true, force: true }); } } /** * Get gitignore templates */ function getGitignoreTemplate(template: string): string { const templates: Record = { node: `# Dependencies node_modules/ .pnp/ .pnp.js # Build dist/ build/ .next/ out/ # Environment .env .env.local .env.*.local # Logs logs/ *.log npm-debug.log* # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Testing coverage/ .nyc_output/ `, python: `# Byte-compiled __pycache__/ *.py[cod] *$py.class # Virtual environments venv/ .env/ .venv/ # Distribution dist/ build/ *.egg-info/ # IDE .vscode/ .idea/ *.swp # Testing .pytest_cache/ .coverage htmlcov/ # Environment .env .env.local `, rust: `# Build target/ # IDE .vscode/ .idea/ # Environment .env # Cargo.lock for libraries # Cargo.lock `, go: `# Binaries *.exe *.dll *.so *.dylib # Test *.test coverage.txt # Dependency vendor/ # IDE .vscode/ .idea/ `, }; return templates[template] || templates.node; } /** * Get license templates */ function getLicenseTemplate(license: string, owner: string): string | null { const year = new Date().getFullYear(); const templates: Record = { mit: `MIT License Copyright (c) ${year} ${owner} Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. `, apache2: `Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ Copyright ${year} ${owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. `, gpl3: `GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) ${year} ${owner} This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . `, }; return templates[license.toLowerCase()] || null; } export async function getLastCommit( repoPath: string, ref: string, filePath: string, ): Promise { const git = getGit(repoPath); try { // Format: hash|author|date|message const output = await git.raw([ "log", "-1", "--format=%H|%an|%aI|%s", ref, "--", filePath, ]); if (!output.trim()) return null; const [sha, authorName, dateStr, message] = output.trim().split("|"); const date = new Date(dateStr); return { sha, message, authorName, authorEmail: "", // Not fetching email to save bandwidth/parsing authorDate: date, committerName: authorName, committerEmail: "", committerDate: date, parentShas: [], }; } catch (e) { return null; } } export async function getFileRawContent( repoPath: string, filePath: string, ref: string = "HEAD", ): Promise { return new Promise((resolve, reject) => { const git = spawn("git", ["show", `${ref}:${filePath}`], { cwd: repoPath }); const chunks: Buffer[] = []; git.stdout.on("data", (chunk) => { chunks.push(Buffer.from(chunk)); }); git.on("close", (code) => { if (code !== 0) { resolve(null); } else { resolve(Buffer.concat(chunks)); } }); git.on("error", (err) => { logger.error({ err }, "Git show error"); resolve(null); }); }); } /** * Get contributors list with commit counts */ export async function getContributors( repoPath: string, ref: string = "HEAD", ): Promise<{ name: string; email: string; commits: number }[]> { const git = getGit(repoPath); try { // Check if repository is empty first if (await isRepoEmpty(repoPath)) { return []; } // git shortlog -sne (summary, numbered, email) const output = await git.raw(["shortlog", "-sne", ref]); const contributors: { name: string; email: string; commits: number }[] = []; output .trim() .split("\n") .filter(Boolean) .forEach((line) => { const match = line.match(/^\s*(\d+)\s+(.+)\s+<(.+)>$/); if (match) { contributors.push({ commits: parseInt(match[1], 10), name: match[2].trim(), email: match[3].trim(), }); } }); return contributors; } catch (error) { // Catch "outside repository" error and others const errorMessage = error instanceof Error ? error.message : String(error); if ( errorMessage.includes("outside repository") || errorMessage.includes("not a git repository") || errorMessage.includes("does not exist") ) { // This is expected for empty/missing repos return []; } logger.error({ err: error }, "Error getting contributors"); return []; } } export async function installHooks(repoPath: string) { const hooksDir = join(repoPath, "hooks"); const fs = await import("fs/promises"); // Ensure hooks dir exists if (!existsSync(hooksDir)) { await fs.mkdir(hooksDir, { recursive: true }); } // Internal hooks should always call the local server instance, regardless of SITE_URL // This prevents connection timeouts if SITE_URL is an external IP that the host cannot route to itself. const metaEnv = (import.meta as ImportMeta & { env?: Record }).env; const port = process.env.PORT || metaEnv?.PORT || "4321"; const internalApiUrl = `http://127.0.0.1:${port}`; const hookSecret = process.env.INTERNAL_HOOK_SECRET || metaEnv?.INTERNAL_HOOK_SECRET; if (!hookSecret) { throw new Error( "INTERNAL_HOOK_SECRET environment variable is required to install git hooks", ); } const postReceiveScript = `#!/bin/bash # Read hook secret from environment (set by the server process, not stored in this file) HOOK_SECRET="\${INTERNAL_HOOK_SECRET}" if [ -z "$HOOK_SECRET" ]; then echo "Warning: INTERNAL_HOOK_SECRET not set, hook call may fail" fi while read oldrev newrev refname do curl -sS -X POST \\ -H "Content-Type: application/json" \\ -H "X-Hook-Secret: $HOOK_SECRET" \\ -d "{\\"oldrev\\":\\"$oldrev\\",\\"newrev\\":\\"$newrev\\",\\"refname\\":\\"$refname\\",\\"pusher\\":\\"$REMOTE_USER\\"}" \\ ${internalApiUrl}/api/internal/hooks/post-receive?repo=${encodeURIComponent(repoPath)} done `; const preReceiveScript = `#!/bin/bash HOOK_SECRET="\${INTERNAL_HOOK_SECRET}" if [ -z "$HOOK_SECRET" ]; then echo "Warning: INTERNAL_HOOK_SECRET not set, hook call may fail" fi while read oldrev newrev refname do RESPONSE=$(curl -sS -w "%{http_code}" -X POST \\ -H "Content-Type: application/json" \\ -H "X-Hook-Secret: $HOOK_SECRET" \\ -d "{\\"oldrev\\":\\"$oldrev\\",\\"newrev\\":\\"$newrev\\",\\"refname\\":\\"$refname\\",\\"pusher\\":\\"$REMOTE_USER\\"}" \\ ${internalApiUrl}/api/internal/hooks/pre-receive?repo=${encodeURIComponent(repoPath)}) HTTP_CODE=\${RESPONSE: -3} BODY=\${RESPONSE:0:\${#RESPONSE}-3} if [ "$HTTP_CODE" -ne 200 ]; then echo "Error: $BODY" exit 1 fi done exit 0 `; await fs.writeFile(join(hooksDir, "post-receive"), postReceiveScript, { mode: 0o755, }); await fs.writeFile(join(hooksDir, "pre-receive"), preReceiveScript, { mode: 0o755, }); } /** * Commit a file modification to the repository */ export async function commitFile( repoPath: string, branch: string, filePath: string, content: string, message: string, author: { name: string; email: string }, ): Promise { const git = getGit(repoPath); // We need to use plumbing commands because it's a bare repo const resolvedRepoPath = resolve(repoPath); const tempIndexFile = join( resolvedRepoPath, `temp_index_${Date.now()}_${Math.random().toString(36).substring(7)}`, ); try { // 1. Read the tree of the branch into a temporary index // We set GIT_INDEX_FILE environment variable for these commands const env = { ...getSanitizedGitEnv(dirname(repoPath)), GIT_INDEX_FILE: tempIndexFile, }; // Get the tree of the current branch/commit // If branch doesn't exist (empty repo), start with an empty index let branchExists = true; try { await git.env(env).raw(["read-tree", branch]); } catch (e) { branchExists = false; await git.env(env).raw(["read-tree", "--empty"]); } // 2. Hash the new object using spawn to pipe content const hashObjectOutput = await new Promise((resolve, reject) => { const gitProcess = spawn("git", ["hash-object", "-w", "--stdin"], { cwd: repoPath, }); let output = ""; gitProcess.stdout.on("data", (data) => { output += data.toString(); }); gitProcess.on("close", (code) => { if (code === 0) resolve(output.trim()); else reject(new Error(`git hash-object failed with code ${code}`)); }); gitProcess.stdin.write(content); gitProcess.stdin.end(); }); const blobSha = hashObjectOutput; // 3. Update the index with the new blob // git update-index --add --cacheinfo 100644 await git .env(env) .raw([ "update-index", "--add", "--cacheinfo", "100644", blobSha, filePath, ]); // 4. Write the new tree const writeTreeOutput = await git.env(env).raw(["write-tree"]); const treeSha = writeTreeOutput.trim(); // 5. Create new commit // We need the parent commit (if the branch has commits yet) let parentSha = ""; try { parentSha = (await git.revparse([branch])).trim(); } catch (e) { // Branch has no commits yet (empty repo) — this will be the root commit parentSha = ""; } const commitTreeArgs = parentSha ? ["commit-tree", treeSha, "-p", parentSha, "-m", message] : ["commit-tree", treeSha, "-m", message]; // Set author/committer const commitEnv = { ...env, GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email, }; const commitTreeOutput = await git.env(commitEnv).raw(commitTreeArgs); const newCommitSha = commitTreeOutput.trim(); // 6. Update the branch ref await git.raw(["update-ref", `refs/heads/${branch}`, newCommitSha]); return newCommitSha; } finally { // Cleanup temporary index const fs = await import("fs/promises"); try { await fs.unlink(tempIndexFile); } catch (e) { // Ignore cleanup error } } } /** * Get the physical path for a repository */ export function getRepoPath(owner: string, name: string): string { return join(process.cwd(), "repos", owner, `${name}.git`); } /** * Create a speculative branch combining multiple PRs */ export async function createSpeculativeBranch( repoPath: string, baseBranch: string, prs: { headBranch: string; number: number }[], ): Promise<{ branchName: string; success: boolean; message?: string }> { const git = getGit(repoPath); // Generate unique temp branch name const timestamp = Date.now(); const prNumbers = prs.map((p) => p.number).join("-"); const branchName = `mq-spec-${timestamp}-${prNumbers}`; try { // 1. Create temp branch from base await git.checkout(baseBranch); await git.pull(); await git.checkoutLocalBranch(branchName); // 2. Merge each PR in order for (const pr of prs) { try { // Must fetch the PR branch first await git.fetch("origin", pr.headBranch); await git.merge([`origin/${pr.headBranch}`]); } catch (e: any) { // Conflict detected await git.checkout(baseBranch); // Attempt to delete the failed branch try { await git.deleteLocalBranch(branchName, true); } catch {} return { branchName, success: false, message: `Conflict merging PR #${pr.number} during speculative build: ${e.message}`, }; } } // 3. Push to trigger CI await git.push("origin", branchName, ["--force"]); return { branchName, success: true }; } catch (error: any) { return { branchName, success: false, message: error.message }; } }