import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
export interface PatchResult {
success: boolean;
branch_name?: string;
git_result?: string;
error?: string;
files_changed?: string[];
}
export class Patcher {
private projectRoot: string;
private sentinelDir: string;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.sentinelDir = path.join(process.env.HOME || '', '.sentinel');
}
public async applyPatch(unifiedDiff: string): Promise<PatchResult> {
try {
// Extract patch from sentinel fences if present
const cleanDiff = this.extractPatch(unifiedDiff);
// Check if repo is clean, stash if needed
const needsStash = await this.checkNeedsStash();
if (needsStash) {
await this.executeGit(['stash', 'push', '-m', 'Sentinel auto-stash']);
}
// Create new branch
const branchName = `ai/fix-${this.generateTimestamp()}`;
await this.executeGit(['checkout', '-b', branchName]);
// Write patch to file
const patchPath = path.join(this.sentinelDir, 'patch.diff');
await fs.writeFile(patchPath, cleanDiff);
// Apply patch
await this.executeGit(['apply', '--whitespace=fix', patchPath]);
// Stage and commit changes
await this.executeGit(['add', '-A']);
const commitMessage = `ai: patch applied from sentinel\n\n🤖 Generated with Sentinel MCP`;
await this.executeGit(['commit', '-m', commitMessage]);
// Get list of changed files
const filesChanged = await this.getChangedFiles();
// Restore stash if needed
if (needsStash) {
await this.executeGit(['stash', 'pop']);
}
return {
success: true,
branch_name: branchName,
git_result: `Applied patch successfully on branch ${branchName}`,
files_changed: filesChanged
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// Try to recover by going back to original branch
try {
await this.executeGit(['checkout', '-']);
} catch (recoveryError) {
// Ignore recovery errors
}
return {
success: false,
error: errorMsg
};
}
}
private extractPatch(input: string): string {
// Look for sentinel patch fences
const beginMarker = '*** begin patch';
const endMarker = '*** end patch';
const beginIndex = input.indexOf(beginMarker);
const endIndex = input.indexOf(endMarker);
if (beginIndex !== -1 && endIndex !== -1) {
const patchContent = input.substring(beginIndex + beginMarker.length, endIndex).trim();
// Remove "*** update file:" lines and extract actual diff
const lines = patchContent.split('\n');
const diffLines = lines.filter(line =>
!line.startsWith('*** update file:') &&
!line.trim().startsWith('***')
);
return diffLines.join('\n');
}
// If no fences found, assume the entire input is a unified diff
return input;
}
private async checkNeedsStash(): Promise<boolean> {
try {
const output = await this.executeGit(['status', '--porcelain']);
return output.trim().length > 0;
} catch (error) {
return false;
}
}
private async getChangedFiles(): Promise<string[]> {
try {
const output = await this.executeGit(['diff', '--name-only', 'HEAD~1', 'HEAD']);
return output.trim().split('\n').filter(line => line.length > 0);
} catch (error) {
return [];
}
}
private generateTimestamp(): string {
const now = new Date();
return now.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '-');
}
private async executeGit(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd: this.projectRoot });
let stdout = '';
let stderr = '';
if (child.stdout) {
child.stdout.on('data', (data) => {
stdout += data.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (data) => {
stderr += data.toString();
});
}
child.on('close', (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(stderr || `Git command failed with code ${code}`));
}
});
child.on('error', reject);
});
}
public async getCurrentBranch(): Promise<string> {
try {
const output = await this.executeGit(['branch', '--show-current']);
return output.trim();
} catch (error) {
return 'main';
}
}
public async getRecentDiff(): Promise<string> {
try {
return await this.executeGit(['diff', 'HEAD~1..HEAD']);
} catch (error) {
return '';
}
}
}