import { mkdirSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { git, worktreeBase } from './worktree.js'; export interface MergeResult { ok: boolean; mergeCommit?: string; // 成功时为合并后 defaultBranch 的 HEAD(重复合并时为当前 HEAD,不新建提交) error?: string; conflictFiles?: string[]; // 失败为冲突时的冲突文件列表(结构化,供事件/时间线展示) } /** 临时合并 worktree 目录(确定性):/worktrees/_merge// */ export function mergeWorktreeDirFor(taskId: string): string { return join(worktreeBase(), '_merge', taskId); } /** * 把任务分支合并进 defaultBranch(PR 闭环的"merge"步)。不 push。全部 execFile('git'),不走 shell。 * * 两条路径: * - defaultBranch 正被主检出(repoPath)占用 → 直接在主检出里 `git merge --no-ff`(工作区必须干净, * 否则拒绝并提示提交/暂存或「仅通过」)。这是用户手动合并会做的事,安全。 * - 否则 → 在 /worktrees/_merge// 临时 worktree 检出 defaultBranch 后合并,不碰任何检出。 * * 边界: * - 分支不存在(已删/已清理)→ {ok:false, error} * - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过) * - 冲突 → `git merge --abort` 后复原,error 含冲突文件列表;两分支均无损 * - 重复合并(分支已在 defaultBranch 里)→ Already up to date,ok:true、不新建提交 */ /** * 目标分支正被主检出(repoPath)占用时的合并:直接在主检出里 `git merge --no-ff`。 * 这是用户手动合并会做的事,安全;但要求工作区无【已跟踪】的未提交改动,否则可能纠缠 → 拒绝。 * 注意:只看已跟踪改动(--untracked-files=no)。未跟踪文件(项目常有的本地杂物 GEMINI.md/.claude/…) * 不算脏——它们不会被合并纠缠;万一某未跟踪文件恰好会被合并覆盖,git merge 会自己报错、走下方 catch。 */ async function mergeInPrimaryCheckout( repoPath: string, branch: string, defaultBranch: string, taskId: string, ): Promise { const dirty = (await git(repoPath, ['status', '--porcelain', '--untracked-files=no']).catch(() => '')).trim(); if (dirty) { return { ok: false, error: `默认分支 ${defaultBranch} 检出于工作区且有未提交改动,无法安全合并;请先提交/暂存这些改动,或点「仅通过」(任务保留在审核闸)`, }; } try { await git(repoPath, [ '-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'merge', '--no-ff', branch, '-m', `merge: ${branch} [${taskId}]`, ]); const mergeCommit = (await git(repoPath, ['rev-parse', 'HEAD'])).trim(); return { ok: true, mergeCommit }; } catch (e) { let conflicted = ''; try { conflicted = (await git(repoPath, ['diff', '--name-only', '--diff-filter=U'])).trim(); } catch { /* ignore */ } await git(repoPath, ['merge', '--abort']).catch(() => undefined); const conflictFiles = conflicted ? conflicted.split('\n') : []; const files = conflictFiles.length ? `;冲突文件:${conflictFiles.join('、')}` : ''; return { ok: false, error: `${(e as Error).message}${files}`, conflictFiles }; } } export async function mergeBranch( repoPath: string, branch: string, defaultBranch: string, taskId: string, ): Promise { // 0) 分支必须存在 try { await git(repoPath, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]); } catch { return { ok: false, error: `任务分支不存在:${branch}(可能已被删除或清理)` }; } // 0.5) 目标分支正被主检出占用 → 直接在主检出里合并(工作区干净时),避免临时 worktree 二次检出失败 const headBranch = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim(); if (headBranch === defaultBranch) { return mergeInPrimaryCheckout(repoPath, branch, defaultBranch, taskId); } // 1) 清理残留的临时合并 worktree(上次中断/失败遗留),再新建 const dir = mergeWorktreeDirFor(taskId); await git(repoPath, ['worktree', 'prune']).catch(() => undefined); await git(repoPath, ['worktree', 'remove', '--force', dir]).catch(() => undefined); rmSync(dir, { recursive: true, force: true }); mkdirSync(dirname(dir), { recursive: true }); try { await git(repoPath, ['worktree', 'add', dir, defaultBranch]); } catch (e) { const msg = (e as Error).message; if (/already checked out|already used by worktree/i.test(msg)) { return { ok: false, error: `默认分支 ${defaultBranch} 正被某个工作区检出,无法在临时 worktree 合并(不会改动你的检出);请先把该工作区切到其他分支`, }; } return { ok: false, error: `创建临时合并 worktree 失败:${msg}` }; } try { // 2) 合并(--no-ff:保留任务分支的合并节点) await git(dir, [ '-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'merge', '--no-ff', branch, '-m', `merge: ${branch} [${taskId}]`, ]); const mergeCommit = (await git(dir, ['rev-parse', 'HEAD'])).trim(); return { ok: true, mergeCommit }; } catch (e) { // 3) 冲突/失败:收集冲突文件 → abort → 返回错误(defaultBranch 与任务分支无损) let conflicted = ''; try { conflicted = (await git(dir, ['diff', '--name-only', '--diff-filter=U'])).trim(); } catch { /* 收集失败不影响报错 */ } await git(dir, ['merge', '--abort']).catch(() => undefined); const conflictFiles = conflicted ? conflicted.split('\n') : []; const files = conflictFiles.length ? `;冲突文件:${conflictFiles.join('、')}` : ''; return { ok: false, error: `${(e as Error).message}${files}`, conflictFiles }; } finally { // 4) 无论成败都清理临时 worktree await git(repoPath, ['worktree', 'remove', '--force', dir]).catch(() => undefined); await git(repoPath, ['worktree', 'prune']).catch(() => undefined); rmSync(dir, { recursive: true, force: true }); } }