diff --git a/src/utils/git-utils.ts b/src/utils/git-utils.ts new file mode 100644 index 0000000..cc2342c --- /dev/null +++ b/src/utils/git-utils.ts @@ -0,0 +1,421 @@ +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'); +}