b1d881a4b4
不新增顶层状态,用 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>
131 lines
6.1 KiB
TypeScript
131 lines
6.1 KiB
TypeScript
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);
|
||
}
|
||
|
||
/**
|
||
* 把任务分支合并进 defaultBranch(PR 闭环的"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 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<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 });
|
||
}
|
||
}
|