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