import simpleGit, { SimpleGit, TaskOptions } from 'simple-git'; import * as path from 'path'; import * as fs from 'fs-extra'; import { AgentWorkspace, FileChange, WorkspaceChange, DiffResult, DiffFile, DiffLine, MergeResult, ConflictInfo } from '../types/index'; import { GitAgentSyncError } from './errors'; export function createGit(basePath: string): SimpleGit { return simpleGit(basePath); } export async function getCurrentBranch(git: SimpleGit): Promise { const { current } = await git.branch(); if (!current) { throw new GitAgentSyncError('Could not determine current branch'); } return current; } export async function getMainBranch(git: SimpleGit, defaultMain: string = 'main'): Promise { const branches = await git.branch(); if (branches.all.includes('main')) { return 'main'; } if (branches.all.includes('master')) { return 'master'; } return defaultMain; } export async function createWorktree( basePath: string, workspacePath: string, branchName: string, mainBranch: string ): Promise { const git = createGit(basePath); await fs.ensureDir(workspacePath); try { await git.raw(['worktree', 'add', workspacePath, '-b', branchName]); } catch (error: any) { if (error.message?.includes('already exists')) { throw new GitAgentSyncError(`Worktree already exists at ${workspacePath}`); } throw new GitAgentSyncError(`Failed to create worktree: ${error.message}`); } } export async function removeWorktree(basePath: string, workspacePath: string, branchName: string): Promise { const git = createGit(basePath); try { await git.raw(['worktree', 'remove', workspacePath, '--force']); } catch (error: any) { throw new GitAgentSyncError(`Failed to remove worktree: ${error.message}`); } } export async function deleteBranch(git: SimpleGit, branchName: string, force: boolean = false): Promise { try { if (force) { await git.raw(['branch', '-D', branchName]); } else { await git.raw(['branch', '-d', branchName]); } } catch (error: any) { throw new GitAgentSyncError(`Failed to delete branch ${branchName}: ${error.message}`); } } export async function listWorktrees(basePath: string): Promise<{ path: string; branch: string }[]> { const git = createGit(basePath); const result = await git.raw(['worktree', 'list', '--porcelain']); const worktrees: { path: string; branch: string }[] = []; const lines = result.split('\n').filter(Boolean); for (const line of lines) { const match = line.match(/^worktree (.+)$/); if (match) { const worktreePath = match[1]; const branchMatch = lines.find(l => l.startsWith(`branch ${worktreePath}`)); if (branchMatch) { const branch = branchMatch.replace(/^branch\s+/, ''); worktrees.push({ path: worktreePath, branch }); } } } return worktrees; } export async function getWorktreeStatus(workspacePath: string): Promise { const git = createGit(workspacePath); const status = await git.status(); const branch = await getCurrentBranch(git); const agentName = extractAgentName(branch); const changes: FileChange[] = []; for (const file of status.created) { changes.push({ file, status: 'added', staged: true, author: 'agent', timestamp: new Date().toISOString(), diff: '' }); } for (const file of status.modified) { const diff = await git.diff(['--no-color', file]); changes.push({ file, status: 'modified', staged: status.staged.includes(file), author: 'agent', timestamp: new Date().toISOString(), diff }); } for (const file of status.deleted) { changes.push({ file, status: 'deleted', staged: status.staged.includes(file), author: 'agent', timestamp: new Date().toISOString(), diff: '' }); } return { agentName, branch, path: workspacePath, changes, lastUpdated: new Date().toISOString(), uncommittedCount: status.modified.length + status.created.length + status.deleted.length }; } export async function generateDiff( basePath: string, workspacePath: string, agentName: string, comparedBranch: string = 'main' ): Promise { const git = createGit(workspacePath); const branch = await getCurrentBranch(git); const diffOutput = await git.diff(['--no-color', '--unified=3', comparedBranch]); const files = parseDiffOutput(diffOutput); const summary = calculateDiffSummary(files); return { agentName, branch, comparedBranch, files, summary }; } export function parseDiffOutput(output: string): DiffFile[] { const files: DiffFile[] = []; const lines = output.split('\n'); let currentFile: DiffFile | null = null; let currentChanges: DiffLine[] = []; let oldLineNum: number | null = null; let newLineNum: number | null = null; for (const line of lines) { const fileMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); if (fileMatch) { if (currentFile) { currentFile.changes = currentChanges; files.push(currentFile); } currentFile = { file: fileMatch[2] || fileMatch[1], oldFile: fileMatch[1] !== fileMatch[2] ? fileMatch[1] : undefined, changes: [], additions: 0, deletions: 0 }; currentChanges = []; oldLineNum = null; newLineNum = null; continue; } const indexMatch = line.match(/^index [a-f0-9]+\.[a-f0-9]+/); if (indexMatch && currentFile) { continue; } const newFileMatch = line.match(/^new file mode .+$/); if (newFileMatch && currentFile) { continue; } const oldFileMatch = line.match(/^deleted file mode .+$/); if (oldFileMatch && currentFile) { continue; } const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/); if (hunkMatch && currentFile) { oldLineNum = parseInt(hunkMatch[1], 10); newLineNum = parseInt(hunkMatch[3], 10); continue; } if (currentFile) { let lineType: 'context' | 'added' | 'deleted' = 'context'; if (line.startsWith('+') && !line.startsWith('+++')) { lineType = 'added'; currentFile.additions++; } else if (line.startsWith('-') && !line.startsWith('---')) { lineType = 'deleted'; currentFile.deletions++; } currentChanges.push({ type: lineType, lineNumber: { old: line.startsWith('+') || line.startsWith(' ') ? oldLineNum : null, new: line.startsWith('-') || line.startsWith(' ') ? newLineNum : null }, content: line }); if (lineType !== 'added' && line !== '') { oldLineNum = oldLineNum ? oldLineNum + 1 : null; } if (lineType !== 'deleted' && line !== '') { newLineNum = newLineNum ? newLineNum + 1 : null; } } } if (currentFile) { currentFile.changes = currentChanges; files.push(currentFile); } return files; } function calculateDiffSummary(files: DiffFile[]): { filesChanged: number; additions: number; deletions: number } { let additions = 0; let deletions = 0; for (const file of files) { additions += file.additions; deletions += file.deletions; } return { filesChanged: files.length, additions, deletions }; } export async function mergeToMain( basePath: string, workspacePath: string, agentName: string, mainBranch: string, message?: string, dryRun: boolean = false ): Promise { const git = createGit(workspacePath); const workspaceGit = createGit(workspacePath); const branch = await getCurrentBranch(workspaceGit); const commitMessage = message || `Merge agent-${agentName} changes into ${mainBranch} Agent: ${agentName} Branch: ${branch} Date: ${new Date().toISOString()} This commit was automatically generated by git-agent-sync`; if (dryRun) { return { success: true, message: `[DRY RUN] Would merge ${branch} into ${mainBranch}`, commitSha: 'dry-run' }; } await workspaceGit.checkout(mainBranch); await workspaceGit.pull(); try { const mergeResult = await git.raw([ 'merge', '--no-ff', '-m', commitMessage, branch ]); return { success: true, message: mergeResult, commitSha: '' }; } catch (error: any) { if (error.message?.includes('conflict')) { const conflicts = await detectConflicts(workspacePath); return { success: false, conflicts, error: 'Merge conflicts detected' }; } throw new GitAgentSyncError(`Merge failed: ${error.message}`); } } async function detectConflicts(workspacePath: string): Promise { const git = createGit(workspacePath); const status = await git.status(); const conflicts: ConflictInfo[] = []; for (const file of status.conflicted) { const content = await fs.readFile(path.join(workspacePath, file), 'utf-8'); conflicts.push({ file, content }); } return conflicts; } export async function hasUncommittedChanges(workspacePath: string): Promise { const git = createGit(workspacePath); const status = await git.status(); return status.modified.length > 0 || status.created.length > 0 || status.deleted.length > 0; } export async function getLastCommitInfo(workspacePath: string): Promise<{ hash: string; message: string }> { const git = createGit(workspacePath); const log = await git.log({ maxCount: 1 }); if (log.latest) { return { hash: log.latest.hash, message: log.latest.message || '' }; } return { hash: '', message: '' }; } export function extractAgentName(branch: string): string { return branch.replace(/^agent-/, ''); } export async function stashChanges(workspacePath: string, message: string): Promise { const git = createGit(workspacePath); await git.stash(['push', '-m', message]); } export async function applyStash(workspacePath: string, stashRef: string): Promise { const git = createGit(workspacePath); await git.stash(['apply', stashRef]); } export async function getStashList(workspacePath: string): Promise<{ hash: string; message: string }[]> { const git = createGit(workspacePath); const stashList = await git.stash(['list']); const stashes: { hash: string; message: string }[] = []; const lines = stashList.split('\n').filter(Boolean); for (const line of lines) { const match = line.match(/^(\w+@.+): (.+)$/); if (match) { stashes.push({ hash: match[1], message: match[2] }); } } return stashes; } export async function exportChangesAsPatch(workspacePath: string, outputPath: string): Promise { const git = createGit(workspacePath); const diff = await git.diff(['--no-color', '--unified=3']); await fs.writeFile(outputPath, diff, 'utf-8'); }