Files
maestro/test/merge.test.ts
T
wangjia 740d2c2637 feat: PR 审核流程 + 自动合并 + macOS 通知 + 订阅额度透传 + 归档详情
PR 流程(开发→提PR→CodeReview→安全审计→审核→通过即合并):
- reviewer 拆分为 code review 与安全审计两个独立 CC run(kind=reviewer/security),
  TaskResult 四字段(summary/verdict + securitySummary/securityVerdict),闸上两节报告两枚结论徽章
- executor/merge.ts: 通过即合并——临时 worktree 内 merge --no-ff,绝不碰用户工作区/不 push;
  冲突安全拒绝(报冲突文件);重复合并幂等;合并后回收执行 worktree + 删分支
- decide 路由 merge:false 逃生口 + 看板「仅通过」按钮(目标分支被工作区检出时用)

通知(daemon/notify.ts):
- macOS 原生通知: 进审核闸(复审建议拒绝标⚠)/连续失败需人工/合并完成
- 同任务同类型 60s 抑制、osascript 转义截断、MAESTRO_NOTIFY=0 关闭

订阅额度透传(daemon/usage.ts):
- OAuth usage API(与 Claude Code/claude-hud 同源),凭证 keychain→内存零泄漏
- 60s 成败双缓存+并发去重+5s 超时,失败降级 null
- GET /api/agents 顶层 usage 字段;Agent 面板显示 5h/周用量条+重置倒计时(>80%琥珀/>95%红)

看板与生命周期:
- 归档区: 深度1整树完成沉底,时间倒序分页(尺寸 chip 10/20/50/100 置底)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(GET /api/tasks/:id/events)
- 容器收口: 已拆解 Hard 子任务全 done 自动 done(afterDone 逐级向上)
- 同步按钮收进配置面板;执行白名单扩测试命令(npm/go/shellcheck/make/pytest)
- Agent 面板显示调度模式与各复杂度模型;被依赖阻塞→被阻塞

测试: 74/74(新增 merge 6/notify 10/usage 7/容器收口/双复审适配)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:22:30 +08:00

140 lines
6.7 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 { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mergeBranch, mergeWorktreeDirFor } from '../src/executor/merge.js';
function gitSync(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8' });
}
/** /tmp 下建一个带 1 个 commit 的真实 git repomain 分支) */
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), 'maestro-merge-repo-'));
gitSync(repo, ['init', '-b', 'main']);
gitSync(repo, ['config', 'user.name', 'maestro-test']);
gitSync(repo, ['config', 'user.email', 'test@maestro.local']);
writeFileSync(join(repo, 'README.md'), '# demo\n');
gitSync(repo, ['add', '-A']);
gitSync(repo, ['commit', '-m', 'init']);
return repo;
}
function commitFile(repo: string, file: string, content: string, msg: string): void {
writeFileSync(join(repo, file), content);
gitSync(repo, ['add', '-A']);
gitSync(repo, ['commit', '-m', msg]);
}
/** 每个用例独立的 dataDir + repot.after 收尾(不碰 ~/.maestro */
function setup(t: { after: (fn: () => void) => void }): string {
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-merge-data-'));
const prevDataDir = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dataDir;
const repo = makeRepo();
t.after(() => {
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prevDataDir;
rmSync(dataDir, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
});
return repo;
}
/** 临时合并 worktree 已彻底清理:目录不存在 + git worktree list 无 _merge */
function assertMergeWorktreeCleaned(repo: string, taskId: string): void {
assert.ok(!existsSync(mergeWorktreeDirFor(taskId)), '临时合并 worktree 目录应被删除');
const list = gitSync(repo, ['worktree', 'list']);
assert.ok(!list.includes('_merge'), `git worktree list 不应再含 _merge${list}`);
}
test('mergeBranch:可 ff 的分支也产出 --no-ff 合并提交,main 前进、临时 worktree 清理', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_ff']);
commitFile(repo, 'a.txt', 'A\n', 'maestro(tsk_ff): add a');
gitSync(repo, ['switch', '--detach']); // 让 main 不被任何工作区检出
const r = await mergeBranch(repo, 'maestro/tsk_ff', 'main', 'tsk_ff');
assert.equal(r.ok, true, r.error);
assert.ok(r.mergeCommit);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), r.mergeCommit);
// --no-ff:合并提交有 2 个父
const parents = gitSync(repo, ['rev-list', '--parents', '-n', '1', 'main']).trim().split(/\s+/);
assert.equal(parents.length, 3, '应是双亲合并提交(--no-ff');
assert.match(gitSync(repo, ['log', '-1', '--format=%s', 'main']), /merge: maestro\/tsk_ff \[tsk_ff\]/);
assert.equal(gitSync(repo, ['show', 'main:a.txt']), 'A\n');
assertMergeWorktreeCleaned(repo, 'tsk_ff');
});
test('mergeBranch:分叉历史(非 ff)无冲突合并成功,两边改动都在', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_div']);
commitFile(repo, 'b.txt', 'B\n', 'maestro(tsk_div): add b');
gitSync(repo, ['checkout', 'main']);
commitFile(repo, 'c.txt', 'C\n', 'main: add c'); // main 也前进 → 分叉
gitSync(repo, ['switch', '--detach']);
const r = await mergeBranch(repo, 'maestro/tsk_div', 'main', 'tsk_div');
assert.equal(r.ok, true, r.error);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), r.mergeCommit);
assert.equal(gitSync(repo, ['show', 'main:b.txt']), 'B\n');
assert.equal(gitSync(repo, ['show', 'main:c.txt']), 'C\n');
assertMergeWorktreeCleaned(repo, 'tsk_div');
});
test('mergeBranch:冲突 → 返回错误含冲突文件列表,main 与用户分支均无损,临时 worktree 清理', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_cf']);
commitFile(repo, 'README.md', '# demo\nbranch version\n', 'maestro(tsk_cf): edit readme');
gitSync(repo, ['checkout', 'main']);
commitFile(repo, 'README.md', '# demo\nmain version\n', 'main: edit readme');
gitSync(repo, ['switch', '--detach']);
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const branchBefore = gitSync(repo, ['rev-parse', 'maestro/tsk_cf']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_cf', 'main', 'tsk_cf');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /README\.md/, `错误应含冲突文件列表:${r.error}`);
// 双方无损
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 应无损');
assert.equal(gitSync(repo, ['rev-parse', 'maestro/tsk_cf']).trim(), branchBefore, '用户分支应无损');
assertMergeWorktreeCleaned(repo, 'tsk_cf');
});
test('mergeBranch:分支不存在(已删)→ 明确错误', async (t) => {
const repo = setup(t);
gitSync(repo, ['switch', '--detach']);
const r = await mergeBranch(repo, 'maestro/tsk_gone', 'main', 'tsk_gone');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /分支不存在/);
});
test('mergeBranchdefaultBranch 正被工作区检出 → 失败且不动用户检出', async (t) => {
const repo = setup(t); // main 仍在主工作区检出
gitSync(repo, ['branch', 'maestro/tsk_co']); // 分支存在但 main 被占用
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_co', 'main', 'tsk_co');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /检出/, `应提示 defaultBranch 被检出:${r.error}`);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore);
assert.equal(gitSync(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), 'main', '用户检出不应被切走');
assertMergeWorktreeCleaned(repo, 'tsk_co');
});
test('mergeBranch:重复合并(分支已在 main 里)→ ok,不新建提交', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_re']);
commitFile(repo, 'd.txt', 'D\n', 'maestro(tsk_re): add d');
gitSync(repo, ['switch', '--detach']);
const first = await mergeBranch(repo, 'maestro/tsk_re', 'main', 'tsk_re');
assert.equal(first.ok, true, first.error);
const second = await mergeBranch(repo, 'maestro/tsk_re', 'main', 'tsk_re');
assert.equal(second.ok, true, second.error);
assert.equal(second.mergeCommit, first.mergeCommit, '重复合并不应产生新提交(Already up to date');
assertMergeWorktreeCleaned(repo, 'tsk_re');
});