Files
maestro/src/executor/merge.ts
T
wangjia b1d881a4b4 feat: 合并失败→补救→完成 全程事件轨迹与自动收口 [tsk_BagzbKZNAHXV]
不新增顶层状态,用 events + result 字段记录完整轨迹:
- 新增 merge.failed / merge.remediated 两类事件
- merge 失败时在原任务时间线记 merge.failed 节点(结构化冲突文件 + 补救任务链接)
- 补救任务成功合并后,findMergeOriginTask 反查原任务,remediateOrigin 自动把原任务标 done
  并记 merge.remediated 节点,无需人工 merge:false
- 看板:归档时间线渲染失败/补救节点;结果评审闸显示「合并失败 → 补救任务」横幅

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:04:39 +08:00

131 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 目录(确定性):<dataDir>/worktrees/_merge/<taskId>/ */
export function mergeWorktreeDirFor(taskId: string): string {
return join(worktreeBase(), '_merge', taskId);
}
/**
* 把任务分支合并进 defaultBranchPR 闭环的"merge"步)。不 push。全部 execFile('git'),不走 shell。
*
* 两条路径:
* - defaultBranch 正被主检出(repoPath)占用 → 直接在主检出里 `git merge --no-ff`(工作区必须干净,
* 否则拒绝并提示提交/暂存或「仅通过」)。这是用户手动合并会做的事,安全。
* - 否则 → 在 <dataDir>/worktrees/_merge/<taskId>/ 临时 worktree 检出 defaultBranch 后合并,不碰任何检出。
*
* 边界:
* - 分支不存在(已删/已清理)→ {ok:false, error}
* - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过)
* - 冲突 → `git merge --abort` 后复原,error 含冲突文件列表;两分支均无损
* - 重复合并(分支已在 defaultBranch 里)→ Already up to dateok: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<MergeResult> {
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<MergeResult> {
// 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 });
}
}