CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | /**
* Automated merge conflict resolution using Claude.
*
* When a merge has conflicts, this module:
* 1. Detects conflicting files
* 2. Sends each conflict to Claude for resolution
* 3. Applies the resolved content and completes the merge
*/
import { getAnthropic } from "./ai-client";
import { getRepoPath } from "../git/repository";
interface ConflictFile {
path: string;
content: string;
}
interface ResolvedFile {
path: string;
content: string;
}
interface MergeResult {
success: boolean;
resolvedFiles: string[];
error?: string;
commitSha?: string;
}
async function exec(
cmd: string[],
opts?: { cwd?: string; env?: Record<string, string> }
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(cmd, {
cwd: opts?.cwd,
env: { ...process.env, ...opts?.env },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
return { stdout, stderr, exitCode };
}
/**
* Attempt to merge with automatic conflict resolution via Claude.
*
* This works in a temporary worktree to avoid disturbing the bare repo state.
*/
export async function mergeWithAutoResolve(
owner: string,
repo: string,
baseBranch: string,
headBranch: string,
mergeMessage: string
): Promise<MergeResult> {
const repoDir = getRepoPath(owner, repo);
const worktree = `${repoDir}/_merge_worktree_${Date.now()}`;
try {
// Create a temporary worktree on the base branch
const addWt = await exec(
["git", "worktree", "add", worktree, baseBranch],
{ cwd: repoDir }
);
if (addWt.exitCode !== 0) {
return { success: false, resolvedFiles: [], error: `Failed to create worktree: ${addWt.stderr}` };
}
// Attempt the merge
const merge = await exec(
["git", "merge", "--no-commit", "--no-ff", `origin/${headBranch}`],
{ cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
);
// If merge succeeded clean (no conflicts), commit it
if (merge.exitCode === 0) {
const commit = await exec(
["git", "commit", "-m", mergeMessage],
{ cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
);
// Get the merge commit SHA
const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
// Update the bare repo's base branch ref
await exec(
["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
{ cwd: repoDir }
);
return { success: true, resolvedFiles: [], commitSha: sha.trim() };
}
// There are conflicts — get the list of conflicting files
const { stdout: statusOut } = await exec(["git", "diff", "--name-only", "--diff-filter=U"], { cwd: worktree });
const conflictPaths = statusOut.trim().split("\n").filter(Boolean);
if (conflictPaths.length === 0) {
return { success: false, resolvedFiles: [], error: "Merge failed but no conflicts detected" };
}
// Read each conflicting file and resolve with Claude
const resolvedFiles: string[] = [];
for (const filePath of conflictPaths) {
const { stdout: conflictContent } = await exec(["cat", filePath], { cwd: worktree });
const resolved = await resolveConflict(filePath, conflictContent);
if (resolved) {
// Write resolved content
await Bun.write(`${worktree}/${filePath}`, resolved.content);
await exec(["git", "add", filePath], { cwd: worktree });
resolvedFiles.push(filePath);
} else {
// Could not resolve this file — abort
await exec(["git", "merge", "--abort"], { cwd: worktree });
return {
success: false,
resolvedFiles: [],
error: `Could not auto-resolve conflict in ${filePath}`,
};
}
}
// All conflicts resolved — commit
const commit = await exec(
["git", "commit", "-m", `${mergeMessage}\n\nAuto-resolved conflicts in: ${resolvedFiles.join(", ")}`],
{ cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
);
if (commit.exitCode !== 0) {
return { success: false, resolvedFiles, error: `Commit failed: ${commit.stderr}` };
}
const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
// Update the bare repo ref
await exec(
["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
{ cwd: repoDir }
);
return { success: true, resolvedFiles, commitSha: sha.trim() };
} finally {
// Clean up the worktree
await exec(["git", "worktree", "remove", "--force", worktree], { cwd: repoDir }).catch((err) => {
console.warn(
`[merge-resolver] worktree cleanup failed for ${worktree}:`,
err instanceof Error ? err.message : err
);
});
}
}
/**
* Use Claude to resolve a single file's merge conflicts.
*/
async function resolveConflict(
filePath: string,
conflictContent: string
): Promise<ResolvedFile | null> {
const client = getAnthropic();
try {
const message = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 8192,
messages: [
{
role: "user",
content: `You are resolving a git merge conflict in the file "${filePath}".
The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Your job is to produce the correctly merged version of the file.
Rules:
- Keep BOTH sides' changes when they don't contradict
- When changes truly conflict, choose the version that preserves correctness and doesn't break functionality
- Remove ALL conflict markers (<<<<<<< HEAD, =======, >>>>>>>)
- The output must be valid, working code
- Output ONLY the resolved file content, no explanation, no code fences
File content with conflicts:
${conflictContent}`,
},
],
});
const text = message.content[0].type === "text" ? message.content[0].text : "";
// Verify no conflict markers remain
if (text.includes("<<<<<<<") || text.includes(">>>>>>>")) {
console.error(`[merge-resolver] Claude left conflict markers in ${filePath}`);
return null;
}
return { path: filePath, content: text };
} catch (err) {
console.error(`[merge-resolver] Failed to resolve ${filePath}:`, err);
return null;
}
}
|