Files
maestro/test/merge.test.ts
T
wangjia bb6186902a feat: 项目 logo + 侧栏拖动排序 + 合并修复(主检出干净时 in-place)
- 项目 logo:仓库内文件(logo/icon/favicon 等多路径)→git remote 头像→自定义(URL/相对路径)→
  首字母徽章兜底;GET /api/projects/:id/logo(文件流/302),配置面板加 Logo 输入
- 侧栏项目拖动排序:projects.sort_order + POST /api/projects/reorder,乐观更新
- 合并修复:默认分支正被主检出占用时,若工作区干净则直接在主检出 in-place 合并
  (用户手动合并的等价操作,安全);脏工作区拒绝并提示提交/暂存或仅通过
- schema: projects.logo / sort_order(ensureColumn 平滑迁移)

测试 75/75(merge 用例改为干净→成功/脏→拒绝)

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

156 lines
7.6 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('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');
});