Files
git-agent-sync/src/utils/git-utils.ts
7000pctAUTO 6d52c64b5d
Some checks failed
/ test (push) Has been cancelled
feat: add git utilities module
2026-02-03 09:02:34 +00:00

212 lines
9.0 KiB
TypeScript

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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<WorkspaceChange> {
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<DiffResult> {
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 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, 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<MergeResult> {
const workspaceGit = createGit(workspacePath);
const branch = await getCurrentBranch(workspaceGit);
const commitMessage = message || `Merge agent-${agentName} changes into ${mainBranch}\n\nAgent: ${agentName}\nBranch: ${branch}\nDate: ${new Date().toISOString()}`;
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 git = createGit(workspacePath);
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<ConflictInfo[]> {
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<boolean> {
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<void> {
const git = createGit(workspacePath);
await git.stash(['push', '-m', message]);
}
export async function exportChangesAsPatch(workspacePath: string, outputPath: string): Promise<void> {
const git = createGit(workspacePath);
const diff = await git.diff(['--no-color', '--unified=3']);
await fs.writeFile(outputPath, diff, 'utf-8');
}