Files
maestro/test/merge.test.ts
T
wangjia d160d27645 fix(merge): in-place 合并脏树守卫忽略未跟踪文件(--untracked-files=no)
之前 mergeInPrimaryCheckout 用 git status --porcelain 判脏,未跟踪文件(GEMINI.md/.claude/
本地杂物)也算进去 → 误判拒绝合并(jiu/dudu 等带未跟踪文件的项目「通过并合并」必失败)。
改为 --untracked-files=no 只看已跟踪改动;未跟踪文件不挡合并(真要被覆盖时 git merge 自报错走 catch)。
测试加:仅未跟踪文件 → 仍 in-place 合并成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:40:31 +08:00

172 lines
8.5 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('mergeBranchmain 被主检出占用且工作区干净 → 直接在主检出里合并成功', async (t) => {
const repo = setup(t); // main 在主工作区检出,干净
gitSync(repo, ['checkout', '-b', 'maestro/tsk_co']);
commitFile(repo, 'c.txt', 'C\n', 'maestro(tsk_co): add c');
gitSync(repo, ['checkout', 'main']); // 回到 main(仍被主检出占用)
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_co', 'main', 'tsk_co');
assert.equal(r.ok, true, r.error);
assert.notEqual(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 应前进');
assert.equal(gitSync(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), 'main', '仍在 main');
assert.equal(gitSync(repo, ['show', 'main:c.txt']), 'C\n', '合并内容已进工作区');
assertMergeWorktreeCleaned(repo, 'tsk_co');
});
test('mergeBranchmain 被主检出占用但工作区有未提交改动 → 拒绝,提示提交/暂存', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_dirty']);
commitFile(repo, 'd.txt', 'D\n', 'maestro(tsk_dirty): add d');
gitSync(repo, ['checkout', 'main']);
writeFileSync(join(repo, 'README.md'), '# demo\nlocal edit\n'); // 制造脏工作区
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_dirty', 'main', 'tsk_dirty');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /未提交改动/, `应提示未提交改动:${r.error}`);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 无损');
});
test('mergeBranchmain 被主检出占用、仅有未跟踪文件 → 不算脏,仍 in-place 合并成功', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_ut']);
commitFile(repo, 'a.txt', 'A\n', 'maestro(tsk_ut): add a');
gitSync(repo, ['checkout', 'main']);
// 主检出有未跟踪文件(项目常见本地杂物,如 GEMINI.md/.claude/…)——不应挡合并
writeFileSync(join(repo, 'LOCAL_NOTES.md'), 'untracked junk\n');
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_ut', 'main', 'tsk_ut');
assert.equal(r.ok, true, r.error);
assert.notEqual(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 应前进');
assert.equal(gitSync(repo, ['show', 'main:a.txt']), 'A\n');
assert.ok(existsSync(join(repo, 'LOCAL_NOTES.md')), '未跟踪文件应原样保留');
});
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');
});