Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

merge-resolver.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

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