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:
@@ -0,0 +1,77 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Project } from '../model/types.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** logo 解析结果:file=读仓库内文件流;redirect=重定向到外链头像 */
|
||||
export type LogoResolution =
|
||||
| { type: 'file'; path: string }
|
||||
| { type: 'redirect'; url: string }
|
||||
| null;
|
||||
|
||||
/** 仓库内常见 logo 文件候选(按优先级),相对 repoPath */
|
||||
const CANDIDATES = [
|
||||
'logo.svg', 'logo.png', 'logo.webp', 'logo.jpg', 'logo.jpeg',
|
||||
'icon.svg', 'icon.png', 'app_icon.png', 'favicon.svg', 'favicon.png', 'favicon.ico',
|
||||
'public/logo.svg', 'public/logo.png', 'public/favicon.png', 'public/favicon.ico',
|
||||
'assets/logo.svg', 'assets/logo.png', 'static/logo.svg', 'static/logo.png',
|
||||
'web/favicon.png', 'web/logo.png', 'client/web/favicon.png', 'client/web/logo.png',
|
||||
'src/assets/logo.svg', 'src/assets/logo.png', 'app/src/main/res/mipmap-hdpi/ic_launcher.png',
|
||||
];
|
||||
|
||||
const URL_RE = /^https?:\/\//i;
|
||||
|
||||
/** 安全地把 repo 内相对路径解析为绝对路径(防穿越) */
|
||||
function safeRepoPath(repoPath: string, rel: string): string | null {
|
||||
const base = resolve(repoPath);
|
||||
const abs = resolve(base, '.' + sep + rel);
|
||||
if (abs !== base && !abs.startsWith(base + sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** 从 git remote 推导头像 URL(GitHub owner 头像;其他 Gitea 主机尽力而为,失败由前端兜底) */
|
||||
async function remoteAvatar(repoPath: string): Promise<string | null> {
|
||||
let url = '';
|
||||
try {
|
||||
url = (await exec('git', ['-C', repoPath, 'remote', 'get-url', 'origin'])).stdout.trim();
|
||||
} catch { return null; }
|
||||
if (!url) return null;
|
||||
// github.com/<owner>/<repo>(.git) — ssh 或 https 形式
|
||||
const gh = url.match(/github\.com[:/]([^/]+)\//i);
|
||||
if (gh) return `https://github.com/${gh[1]}.png?size=80`;
|
||||
// 通用 Gitea:<scheme|ssh>://[git@]<host>[:port]/<owner>/<repo> → https://<host>/<owner>.png(尽力,前端 onerror 兜底)
|
||||
const m = url.match(/(?:https?:\/\/|ssh:\/\/[^@]*@|[^@]*@)([^/:]+)(?::\d+)?[/:]([^/]+)\//);
|
||||
if (m) return `https://${m[1]}/${m[2]}.png`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析项目 logo,优先级:
|
||||
* 1. 自定义 logo(project.logo):URL → 重定向;否则视作仓库内相对路径 → 文件
|
||||
* 2. 仓库内常见 logo 文件
|
||||
* 3. git remote 推导头像(重定向)
|
||||
* 4. null(前端用首字母徽章兜底)
|
||||
*/
|
||||
export async function resolveLogo(project: Project): Promise<LogoResolution> {
|
||||
if (project.logo) {
|
||||
if (URL_RE.test(project.logo)) return { type: 'redirect', url: project.logo };
|
||||
const abs = safeRepoPath(project.repoPath, project.logo);
|
||||
if (abs && existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
|
||||
return null; // 自定义路径无效 → 兜底
|
||||
}
|
||||
for (const rel of CANDIDATES) {
|
||||
const abs = resolve(project.repoPath, rel);
|
||||
if (existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
|
||||
}
|
||||
const avatar = await remoteAvatar(project.repoPath);
|
||||
if (avatar) return { type: 'redirect', url: avatar };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LOGO_MIME: Record<string, string> = {
|
||||
svg: 'image/svg+xml', png: 'image/png', webp: 'image/webp',
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', ico: 'image/x-icon', gif: 'image/gif',
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
|
||||
import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { mergeBranch } from '../executor/merge.js';
|
||||
import { git, removeWorktree } from '../executor/worktree.js';
|
||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
|
||||
|
||||
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||||
@@ -70,9 +72,30 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
if (b.status !== undefined) patch.status = b.status as 'active' | 'paused';
|
||||
if (b.verifyCmd !== undefined) patch.verifyCmd = b.verifyCmd === null ? null : String(b.verifyCmd);
|
||||
if (b.model !== undefined) patch.model = b.model === null ? null : String(b.model);
|
||||
if (b.logo !== undefined) patch.logo = b.logo === null || b.logo === '' ? null : String(b.logo);
|
||||
return projectOut(store.patchProject(id, patch));
|
||||
});
|
||||
|
||||
// 项目重排序(侧栏拖动):body { order: [projectId, ...] }
|
||||
app.post('/api/projects/reorder', (req) => {
|
||||
const b = (req.body ?? {}) as { order?: unknown };
|
||||
if (!Array.isArray(b.order)) throw new StoreError('order 必须是项目 id 数组');
|
||||
return store.reorderProjects(b.order.map(String)).map(projectOut);
|
||||
});
|
||||
|
||||
// 项目 logo:仓库内文件 → 流式返回;外链头像 → 302;无 → 404(前端用首字母徽章兜底)
|
||||
app.get('/api/projects/:id/logo', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const p = store.getProject(id);
|
||||
if (!p) return reply.code(404).send();
|
||||
const r = await resolveLogo(p);
|
||||
if (!r) return reply.code(404).send();
|
||||
if (r.type === 'redirect') return reply.redirect(r.url);
|
||||
const ext = r.path.split('.').pop()?.toLowerCase() ?? '';
|
||||
reply.header('cache-control', 'no-cache').type(LOGO_MIME[ext] ?? 'application/octet-stream');
|
||||
return reply.send(createReadStream(r.path));
|
||||
});
|
||||
|
||||
// 单向同步 <repoPath>/todo/todo.json → maestro(导入=首次同步,幂等)
|
||||
app.post('/api/projects/:id/sync', (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
+48
-7
@@ -14,17 +14,52 @@ export function mergeWorktreeDirFor(taskId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任务分支合并进 defaultBranch(PR 闭环的"merge"步)。
|
||||
* 绝不触碰用户工作区:在 <dataDir>/worktrees/_merge/<taskId>/ 临时 git worktree 检出 defaultBranch,
|
||||
* 在其中 `git merge --no-ff <branch>`,成功后删除临时 worktree。不 push。
|
||||
* 全部 execFile('git')(见 worktree.ts 的 git()),不走 shell。
|
||||
* 把任务分支合并进 defaultBranch(PR 闭环的"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` 后清理临时 worktree,error 含冲突文件列表;defaultBranch 与任务分支均无损
|
||||
* - 重复合并(分支已在 defaultBranch 里)→ git 返回 Already up to date,ok:true、不新建提交
|
||||
* - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过)
|
||||
* - 冲突 → `git merge --abort` 后复原,error 含冲突文件列表;两分支均无损
|
||||
* - 重复合并(分支已在 defaultBranch 里)→ Already up to date,ok: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);
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Project {
|
||||
model: string | null;
|
||||
concurrency: number; // 每项目并发执行上限
|
||||
status: 'active' | 'paused';
|
||||
logo: string | null; // 自定义 logo(URL/仓库内相对路径;null=自动解析)
|
||||
sortOrder: number; // 侧栏排序(小在前)
|
||||
createdAt: string;
|
||||
lastSyncAt: string | null; // 最近一次 todo.json 同步完成时间
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function openDb(file: string): Database.Database {
|
||||
db.pragma('foreign_keys = ON');
|
||||
ensureColumn(db, 'tasks', 'source_ref', 'source_ref TEXT');
|
||||
ensureColumn(db, 'projects', 'last_sync_at', 'last_sync_at TEXT');
|
||||
ensureColumn(db, 'projects', 'logo', 'logo TEXT'); // 自定义 logo(URL/仓库内相对路径,null=自动解析)
|
||||
ensureColumn(db, 'projects', 'sort_order', 'sort_order INTEGER NOT NULL DEFAULT 0'); // 侧栏排序
|
||||
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ProjectRow {
|
||||
id: string; name: string; repo_path: string; default_branch: string;
|
||||
verify_cmd: string | null; autonomy: string; model: string | null;
|
||||
concurrency: number; status: string; created_at: string;
|
||||
last_sync_at: string | null;
|
||||
last_sync_at: string | null; logo: string | null; sort_order: number;
|
||||
}
|
||||
export interface TaskRow {
|
||||
id: string; project_id: string; parent_id: string | null; depth: number;
|
||||
@@ -35,7 +35,7 @@ export function rowToProject(r: ProjectRow): Project {
|
||||
id: r.id, name: r.name, repoPath: r.repo_path, defaultBranch: r.default_branch,
|
||||
verifyCmd: r.verify_cmd, autonomy: r.autonomy as Autonomy, model: r.model,
|
||||
concurrency: r.concurrency, status: r.status as Project['status'], createdAt: r.created_at,
|
||||
lastSyncAt: r.last_sync_at,
|
||||
lastSyncAt: r.last_sync_at, logo: r.logo, sortOrder: r.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
concurrency INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | paused
|
||||
created_at TEXT NOT NULL,
|
||||
last_sync_at TEXT -- 最近一次 todo.json 同步时间
|
||||
last_sync_at TEXT, -- 最近一次 todo.json 同步时间
|
||||
logo TEXT, -- 自定义 logo(URL / 仓库内相对路径;null=自动)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 -- 侧栏排序(小在前)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
|
||||
+23
-5
@@ -27,7 +27,7 @@ export interface CreateTaskInput {
|
||||
}
|
||||
export interface PatchProjectInput {
|
||||
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
|
||||
model?: string | null; status?: 'active' | 'paused';
|
||||
model?: string | null; status?: 'active' | 'paused'; logo?: string | null;
|
||||
}
|
||||
export interface PatchTaskInput {
|
||||
title?: string; priority?: number; complexity?: Complexity;
|
||||
@@ -89,26 +89,43 @@ export class Store {
|
||||
|
||||
// ---------- Projects ----------
|
||||
createProject(input: CreateProjectInput): Project {
|
||||
const maxOrder = (this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM projects`).get() as { m: number }).m;
|
||||
const row: ProjectRow = {
|
||||
id: id('prj'), name: input.name, repo_path: input.repoPath,
|
||||
default_branch: input.defaultBranch ?? 'main', verify_cmd: input.verifyCmd ?? null,
|
||||
autonomy: input.autonomy ?? 'manual', model: input.model ?? null,
|
||||
concurrency: input.concurrency ?? 1, status: 'active', created_at: now(),
|
||||
last_sync_at: null,
|
||||
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at)
|
||||
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at)`,
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at,logo,sort_order)
|
||||
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at,@logo,@sort_order)`,
|
||||
).run(row);
|
||||
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
|
||||
return rowToProject(row);
|
||||
}
|
||||
|
||||
listProjects(): Project[] {
|
||||
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY created_at`).all() as ProjectRow[];
|
||||
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY sort_order, created_at`).all() as ProjectRow[];
|
||||
return rows.map(rowToProject);
|
||||
}
|
||||
|
||||
/** 重排项目(侧栏拖动):按给定 id 顺序写 sort_order;未列出的排在后面、相对顺序不变。 */
|
||||
reorderProjects(orderedIds: string[]): Project[] {
|
||||
const txn = this.db.transaction(() => {
|
||||
let i = 0;
|
||||
const upd = this.db.prepare(`UPDATE projects SET sort_order = ? WHERE id = ?`);
|
||||
for (const pid of orderedIds) upd.run(i++, pid);
|
||||
// 未列出的项目顺延到末尾(保持原相对序)
|
||||
const rest = this.db.prepare(
|
||||
`SELECT id FROM projects WHERE id NOT IN (${orderedIds.map(() => '?').join(',') || 'NULL'}) ORDER BY sort_order, created_at`,
|
||||
).all(...orderedIds) as Array<{ id: string }>;
|
||||
for (const r of rest) upd.run(i++, r.id);
|
||||
});
|
||||
txn();
|
||||
return this.listProjects();
|
||||
}
|
||||
|
||||
getProject(projectId: string): Project | null {
|
||||
const row = this.db.prepare(`SELECT * FROM projects WHERE id = ?`).get(projectId) as ProjectRow | undefined;
|
||||
return row ? rowToProject(row) : null;
|
||||
@@ -141,6 +158,7 @@ export class Store {
|
||||
}
|
||||
if (patch.verifyCmd !== undefined) { sets.push('verify_cmd = @verify_cmd'); args.verify_cmd = patch.verifyCmd; }
|
||||
if (patch.model !== undefined) { sets.push('model = @model'); args.model = patch.model; }
|
||||
if (patch.logo !== undefined) { sets.push('logo = @logo'); args.logo = patch.logo; }
|
||||
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
|
||||
|
||||
Reference in New Issue
Block a user