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>
This commit is contained in:
wangjia
2026-06-13 03:45:24 +08:00
parent 740d2c2637
commit bb6186902a
12 changed files with 290 additions and 31 deletions
+23 -7
View File
@@ -111,19 +111,35 @@ test('mergeBranch:分支不存在(已删)→ 明确错误', async (t) => {
assert.match(r.error ?? '', /分支不存在/);
});
test('mergeBranchdefaultBranch 正被工作区检出失败且不动用户检出', async (t) => {
const repo = setup(t); // main 在主工作区检出
gitSync(repo, ['branch', 'maestro/tsk_co']); // 分支存在但 main 被占用
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, 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', '用户检出不应被切走');
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']);