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
+48 -7
View File
@@ -14,17 +14,52 @@ export function mergeWorktreeDirFor(taskId: string): string {
}
/**
* 把任务分支合并进 defaultBranchPR 闭环的"merge"步)。
* 绝不触碰用户工作区:在 <dataDir>/worktrees/_merge/<taskId>/ 临时 git worktree 检出 defaultBranch
* 在其中 `git merge --no-ff <branch>`,成功后删除临时 worktree。不 push。
* 全部 execFile('git')(见 worktree.ts 的 git()),不走 shell。
* 把任务分支合并进 defaultBranchPR 闭环的"merge"步)。不 push。全部 execFile('git'),不走 shell。
*
* 两条路径:
* - defaultBranch 正被主检出(repoPath)占用 → 直接在主检出里 `git merge --no-ff`(工作区必须干净,
* 否则拒绝并提示提交/暂存或「仅通过」)。这是用户手动合并会做的事,安全。
* - 否则 → 在 <dataDir>/worktrees/_merge/<taskId>/ 临时 worktree 检出 defaultBranch 后合并,不碰任何检出。
*
* 边界:
* - 分支不存在(已删/已清理)→ {ok:false, error}
* - defaultBranch 正被用户工作区检出 → git worktree add 失败 → {ok:false, error}(宁可失败也不动用户检出
* - 冲突 → `git merge --abort` 后清理临时 worktreeerror 含冲突文件列表;defaultBranch 与任务分支均无损
* - 重复合并(分支已在 defaultBranch 里)→ git 返回 Already up to dateok:true、不新建提交
* - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过
* - 冲突 → `git merge --abort` 后复原error 含冲突文件列表;分支均无损
* - 重复合并(分支已在 defaultBranch 里)→ Already up to dateok:true、不新建提交
*/
/**
* 目标分支正被主检出(repoPath)占用时的合并:直接在主检出里 `git merge --no-ff`。
* 这是用户手动合并会做的事,安全;但要求工作区干净,否则可能与未提交改动纠缠 → 拒绝。
*/
async function mergeInPrimaryCheckout(
repoPath: string,
branch: string,
defaultBranch: string,
taskId: string,
): Promise<MergeResult> {
const dirty = (await git(repoPath, ['status', '--porcelain']).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 files = conflicted ? `;冲突文件:${conflicted.split('\n').join('、')}` : '';
return { ok: false, error: `${(e as Error).message}${files}` };
}
}
export async function mergeBranch(
repoPath: string,
branch: string,
@@ -38,6 +73,12 @@ export async function mergeBranch(
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);