diff --git a/packages/server/scripts/acceptance-p5.mjs b/packages/server/scripts/acceptance-p5.mjs index 009c0de..f00cffb 100644 --- a/packages/server/scripts/acceptance-p5.mjs +++ b/packages/server/scripts/acceptance-p5.mjs @@ -4,7 +4,7 @@ // group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership // quand le repo est supprimé (PRAGMA foreign_keys = ON). import { spawn, execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -142,6 +142,34 @@ try { const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id); check('broadcast WS session_update (session de groupe)', !!pushedSession); + // ---- Worktree de groupe sur une NOUVELLE branche (scénario corrigé : mode auto, plus de -b raté) ---- + const wtA = await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'POST', cookie, { branch: 'feature/cross', mode: 'auto', startSession: null }); + const wtABody = await wtA.json(); + check('POST /repos/:id/worktrees (branche neuve) → 201 + action=created', wtA.status === 201 && wtABody.action === 'created'); + const wtB = await j(`/api/v1/repos/${repo2Summary.id}/worktrees`, 'POST', cookie, { branch: 'feature/cross', mode: 'auto', startSession: null }); + check('POST /repos (2e repo) worktree branche neuve → 201', wtB.status === 201); + + // GET /branches : la nouvelle branche apparaît (alimente le sélecteur de base côté UI). + const branchesA = await (await j(`/api/v1/repos/${repoSummary.id}/branches`, 'GET', cookie)).json(); + check('GET /repos/:id/branches : feature/cross présente', Array.isArray(branchesA.local) && branchesA.local.includes('feature/cross')); + + // session de groupe SUR cette branche → résout les worktrees créés (le cas qui échouait avant). + const gsBranch = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'feature/cross' }); + const gsBranchBody = await gsBranch.json(); + check('POST /groups/:id/session (branche) → 201 + 2 dirs', gsBranch.status === 201 && gsBranchBody.dirs?.length === 2); + await j(`/api/v1/sessions/${gsBranchBody.session.id}`, 'DELETE', cookie); + + // commit : fichier neuf dans le worktree de repo1 puis commit via l'endpoint. + writeFileSync(join(wtABody.worktree.path, 'cross.txt'), 'wip\n'); + const commitRes = await j(`/api/v1/repos/${repoSummary.id}/worktrees/commit`, 'POST', cookie, { path: wtABody.worktree.path, message: 'wip cross' }); + check('POST /worktrees/commit → 200 + dirty=0', commitRes.status === 200 && (await commitRes.json()).worktree?.git?.dirtyCount === 0); + + // promotion « en principal » : feature/cross devient le checkout principal de repo1, worktree supprimé. + const promRes = await j(`/api/v1/repos/${repoSummary.id}/worktrees/promote`, 'POST', cookie, { path: wtABody.worktree.path }); + check('POST /worktrees/promote → 200', promRes.status === 200); + const wtsAfter = await (await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'GET', cookie)).json(); + check('promotion : checkout principal désormais sur feature/cross', wtsAfter.worktrees?.find((w) => w.isMain)?.branch === 'feature/cross'); + // groupe sans worktree résolu (branche inexistante) → 400. const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' }); check('POST /groups/:id/session branche absente → 400', gsBad.status === 400); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index f8d18ee..b7b0e9e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -172,7 +172,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund registerSessionRoutes(app, manager, discovery, db); registerRepoRoutes(app, worktrees, db); registerGroupRoutes(app, groups, db, worktrees, manager); - registerWorktreeRoutes(app, worktrees); + registerWorktreeRoutes(app, worktrees, db); registerPushRoutes(app, push, db); registerSettingsRoutes(app, db, config, serverVersion, push); registerFsRoutes(app); diff --git a/packages/server/src/core/git.ts b/packages/server/src/core/git.ts index ab7309b..823fb7d 100644 --- a/packages/server/src/core/git.ts +++ b/packages/server/src/core/git.ts @@ -2,9 +2,11 @@ // les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant. import { execFile } from 'node:child_process'; import { resolve, sep } from 'node:path'; -import type { WorktreeGitStatus } from '@arboretum/shared'; +import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode } from '@arboretum/shared'; const GIT_TIMEOUT_MS = 10_000; +// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large. +const GIT_PUSH_TIMEOUT_MS = 120_000; const GIT_MAX_BUFFER = 8 * 1024 * 1024; interface GitError extends Error { @@ -12,14 +14,14 @@ interface GitError extends Error { code?: number | string; } -function git(cwd: string, args: string[]): Promise { +function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise { return new Promise((resolveP, reject) => { execFile( 'git', args, { cwd, - timeout: GIT_TIMEOUT_MS, + timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, // GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une // session claude active) ; LC_ALL=C : sortie stable pour le parsing. @@ -140,6 +142,52 @@ export async function defaultBranch(repoPath: string): Promise { } } +/** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */ +export async function currentBranch(repoPath: string): Promise { + try { + const b = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + return b === 'HEAD' ? null : b; + } catch { + return null; + } +} + +/** Existence d'une branche, en local (`refs/heads`) et/ou suivie du remote (`refs/remotes/origin`). */ +export async function branchExists(repoPath: string, branch: string): Promise<{ local: boolean; remote: boolean }> { + const verify = async (ref: string): Promise => { + try { + await git(repoPath, ['rev-parse', '--verify', '--quiet', ref]); + return true; + } catch { + return false; + } + }; + const [local, remote] = await Promise.all([verify(`refs/heads/${branch}`), verify(`refs/remotes/origin/${branch}`)]); + return { local, remote }; +} + +/** Branches locales + suivies de `origin` (noms courts) + branche par défaut — pour un sélecteur de base. */ +export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> { + const local: string[] = []; + const remote: string[] = []; + try { + const out = await git(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin']); + for (const line of out.split('\n')) { + const name = line.trim(); + if (!name) continue; + if (name.startsWith('origin/')) { + const short = name.slice('origin/'.length); + if (short && short !== 'HEAD') remote.push(short); + } else { + local.push(name); + } + } + } catch { + /* dépôt sans refs encore (premier commit absent) → listes vides */ + } + return { local, remote, default: await defaultBranch(repoPath) }; +} + export async function listWorktrees(repoPath: string): Promise { return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain'])); } @@ -175,19 +223,69 @@ export async function worktreeStatus(worktreePath: string): Promise { + if (baseRef) return baseRef; + const def = await defaultBranch(repoPath); + if (!def) return undefined; + const { local, remote } = await branchExists(repoPath, def); + if (local) return def; + if (remote) return `origin/${def}`; + return undefined; +} + +/** + * Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action + * effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de + * la branche — indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement). + */ export async function addWorktree( repoPath: string, - opts: { path: string; branch: string; newBranch: boolean; baseRef?: string }, -): Promise { + opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string }, +): Promise { + const { local, remote } = await branchExists(repoPath, opts.branch); + let action: WorktreeBranchAction; + if (opts.mode === 'create') action = 'created'; + else if (opts.mode === 'checkout') action = local ? 'reused' : 'tracked'; + else action = local ? 'reused' : remote ? 'tracked' : 'created'; // auto + const args = ['worktree', 'add']; - if (opts.newBranch) args.push('-b', opts.branch); - args.push('--', opts.path); - if (opts.newBranch) { - if (opts.baseRef) args.push(opts.baseRef); + if (action === 'reused') { + args.push('--', opts.path, opts.branch); + } else if (action === 'tracked') { + args.push('--track', '-b', opts.branch, '--', opts.path, `origin/${opts.branch}`); } else { - args.push(opts.branch); + const start = await resolveStartPoint(repoPath, opts.baseRef); + args.push('-b', opts.branch, '--', opts.path); + if (start) args.push(start); } await git(repoPath, args); + return action; +} + +/** `git add -A` puis commit. L'appelant garantit qu'il y a quelque chose à committer. */ +export async function commitAll(repoPath: string, message: string): Promise { + await git(repoPath, ['add', '-A']); + await git(repoPath, ['commit', '-m', message]); +} + +/** Pousse la branche courante. Si aucun upstream n'est configuré : `git push -u origin `. */ +export async function push(repoPath: string): Promise { + let hasUpstream = false; + try { + await git(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); + hasUpstream = true; + } catch { + hasUpstream = false; + } + if (hasUpstream) { + await git(repoPath, ['push'], GIT_PUSH_TIMEOUT_MS); + } else { + const branch = await currentBranch(repoPath); + if (!branch) throw new Error('cannot push a detached HEAD'); + await git(repoPath, ['push', '-u', 'origin', branch], GIT_PUSH_TIMEOUT_MS); + } } /** diff --git a/packages/server/src/core/worktree-manager.ts b/packages/server/src/core/worktree-manager.ts index 94fd50c..2edde00 100644 --- a/packages/server/src/core/worktree-manager.ts +++ b/packages/server/src/core/worktree-manager.ts @@ -12,6 +12,8 @@ import type { PostCreateHook, RepoSummary, SessionSummary, + WorktreeBranchAction, + WorktreeBranchMode, WorktreeGitStatus, WorktreeSummary, } from '@arboretum/shared'; @@ -22,13 +24,16 @@ import { scanForRepos } from './repo-scanner.js'; import { preTrustProject } from './claude-trust.js'; import { addWorktree, + commitAll, defaultBranch, isDirtyWorktreeError, isRepo, isSafeAbsolutePath, isValidBranchName, + listBranches, listWorktrees, pruneWorktrees, + push, removeWorktree, switchBranch, worktreeStatus, @@ -322,8 +327,8 @@ export class WorktreeManager extends EventEmitter { async createWorktree( repoId: string, - req: { branch: string; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null }, - ): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null }> { + req: { branch: string; mode?: WorktreeBranchMode; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null }, + ): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null; action: WorktreeBranchAction }> { const row = this.getRepoRow(repoId); if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`); @@ -332,8 +337,9 @@ export class WorktreeManager extends EventEmitter { if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`); return this.withLock(repoId, async () => { + let action: WorktreeBranchAction; try { - await addWorktree(row.path, { path, branch: req.branch, newBranch: req.newBranch, ...(req.baseRef ? { baseRef: req.baseRef } : {}) }); + action = await addWorktree(row.path, { path, branch: req.branch, mode: req.mode ?? 'auto', ...(req.baseRef ? { baseRef: req.baseRef } : {}) }); } catch (err) { throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message); } @@ -352,7 +358,93 @@ export class WorktreeManager extends EventEmitter { if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession }); const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null }); - return { worktree, hookResults, session }; + return { worktree, hookResults, session, action }; + }); + } + + /** Branches locales/remote + branche par défaut d'un repo — alimente le sélecteur de base côté UI. */ + async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> { + const row = this.getRepoRow(repoId); + if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); + return listBranches(row.path); + } + + /** `git add -A` + commit dans le worktree visé (le checkout principal est un worktree valide ici). */ + async commitWorktree(repoId: string, path: string, message: string): Promise { + const row = this.getRepoRow(repoId); + if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); + const w = await this.findWorktree(row, path); + if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo'); + return this.withLock(repoId, async () => { + if ((await worktreeStatus(w.path)).dirtyCount === 0) { + throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean'); + } + try { + await commitAll(w.path, message); + } catch (err) { + throw httpError(400, 'COMMIT_FAILED', (err as Error).message); + } + this.factsCache.delete(repoId); + return (await this.emitWorktree(row, w.path)) as WorktreeSummary; + }); + } + + /** Pousse la branche du worktree visé (upstream auto si absent). */ + async pushWorktree(repoId: string, path: string): Promise { + const row = this.getRepoRow(repoId); + if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); + const w = await this.findWorktree(row, path); + if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo'); + return this.withLock(repoId, async () => { + try { + await push(w.path); + } catch (err) { + throw httpError(400, 'PUSH_FAILED', (err as Error).message); + } + this.factsCache.delete(repoId); + return (await this.emitWorktree(row, w.path)) as WorktreeSummary; + }); + } + + /** + * « Passer en principal » : la branche du worktree devient le checkout principal du dépôt. Une branche + * ne pouvant être extraite qu'à un seul endroit, on retire d'abord le worktree (libère la branche) puis + * on bascule le checkout principal dessus. Sans merge ni conflit possible. L'ancienne branche principale + * est conservée (jamais supprimée). Garde-fous d'arbre sale outrepassables par `force`. + */ + async promoteWorktree(repoId: string, path: string, force = false): Promise { + const row = this.getRepoRow(repoId); + if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); + const w = await this.findWorktree(row, path); + if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo'); + if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'This is already the main checkout'); + if (!w.branch || w.detached) throw httpError(400, 'DETACHED_WORKTREE', 'Worktree has no branch to promote (detached HEAD)'); + const branch = w.branch; + return this.withLock(repoId, async () => { + if (!force) { + if ((await worktreeStatus(w.path)).dirtyCount > 0) { + throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — commit or pass force'); + } + if ((await worktreeStatus(row.path)).dirtyCount > 0) { + throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit/stash or pass force'); + } + } + try { + await removeWorktree(row.path, w.path, force); + } catch (err) { + if (!force && isDirtyWorktreeError(err)) { + throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to promote anyway'); + } + throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message); + } + try { + await switchBranch(row.path, { branch, create: false }); + } catch (err) { + throw httpError(500, 'SWITCH_FAILED', (err as Error).message); + } + this.factsCache.delete(repoId); + this.emit('worktree_removed', { repoId, path: w.path }); + return this.emitWorktree(row, row.path); // le checkout principal est désormais sur `branch` }); } diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts index bcfe889..88fd591 100644 --- a/packages/server/src/routes/groups.ts +++ b/packages/server/src/routes/groups.ts @@ -139,7 +139,8 @@ export function registerGroupRoutes( } if (dirs.length === 0) { - return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } }); + const detail = branch ? `no repo has a worktree on branch "${branch}" (create it first)` : 'no repo has a resolvable main checkout'; + return reply.status(400).send({ error: { code: 'NO_RESOLVABLE_WORKTREE', message: `Cannot start group session: ${detail}` } }); } // cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd. const { cwd, addDirs } = resolveGroupCwd(dirs); diff --git a/packages/server/src/routes/worktrees.ts b/packages/server/src/routes/worktrees.ts index 5d74b30..10022fe 100644 --- a/packages/server/src/routes/worktrees.ts +++ b/packages/server/src/routes/worktrees.ts @@ -1,17 +1,32 @@ import type { FastifyInstance } from 'fastify'; import type { AdoptWorktreeRequest, + CommitWorktreeRequest, CreateWorktreeRequest, CreateWorktreeResponse, + PromoteWorktreeRequest, + PushWorktreeRequest, + RepoBranchesResponse, SessionResponse, StartRepoSessionRequest, + WorktreeBranchMode, WorktreeResponse, WorktreesListResponse, } from '@arboretum/shared'; import type { WorktreeManager } from '../core/worktree-manager.js'; +import type { Db } from '../db/index.js'; +import { recordAudit } from '../core/audit-log.js'; import { sendManagerError } from './repos.js'; -export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void { +/** Mappe l'API (mode prioritaire ; `newBranch` déprécié) vers la stratégie de résolution de branche. */ +function resolveMode(body: { mode?: unknown; newBranch?: unknown }): WorktreeBranchMode { + if (body.mode === 'auto' || body.mode === 'create' || body.mode === 'checkout') return body.mode; + if (body.newBranch === true) return 'create'; + if (body.newBranch === false) return 'checkout'; + return 'auto'; +} + +export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void { app.get('/api/v1/worktrees', async (): Promise => ({ worktrees: await wt.listAllWorktrees() })); app.get('/api/v1/repos/:id/worktrees', async (req): Promise => { @@ -31,7 +46,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager try { const out = await wt.createWorktree(id, { branch: body.branch, - newBranch: body.newBranch !== false, // défaut : créer la branche + mode: resolveMode(body), // défaut : auto (détecte créer / checkout / suivi remote) ...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}), ...(body.path !== undefined ? { path: body.path } : {}), ...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}), @@ -45,6 +60,17 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager } }); + // Branches du repo (locales + suivies de origin + défaut) — alimente le sélecteur de base côté UI. + app.get('/api/v1/repos/:id/branches', async (req, reply) => { + const { id } = req.params as { id: string }; + try { + const out = await wt.listRepoBranches(id); + return reply.send(out satisfies RepoBranchesResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + // Session sur le checkout principal (« bosser sur la branche principale » sans worktree), // avec création/bascule de branche optionnelle côté serveur. app.post('/api/v1/repos/:id/session', async (req, reply) => { @@ -101,6 +127,57 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager } }); + // Commit (git add -A + commit) dans un worktree (ou le checkout principal). + app.post('/api/v1/repos/:id/worktrees/commit', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = req.body as Partial | null; + if (!body || typeof body.path !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); + } + if (typeof body.message !== 'string' || body.message.trim() === '') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } }); + } + try { + const worktree = await wt.commitWorktree(id, body.path, body.message.trim()); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path } }); + return reply.send({ worktree } satisfies WorktreeResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + // Push de la branche d'un worktree (upstream auto si absent). + app.post('/api/v1/repos/:id/worktrees/push', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = req.body as Partial | null; + if (!body || typeof body.path !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); + } + try { + const worktree = await wt.pushWorktree(id, body.path); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.push', resourceId: id, details: { path: body.path } }); + return reply.send({ worktree } satisfies WorktreeResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + // Promotion « en principal » : la branche du worktree devient le checkout principal (worktree supprimé). + app.post('/api/v1/repos/:id/worktrees/promote', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = req.body as Partial | null; + if (!body || typeof body.path !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); + } + try { + const worktree = await wt.promoteWorktree(id, body.path, body.force === true); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.promote', resourceId: id, details: { path: body.path } }); + return reply.send({ worktree }); + } catch (err) { + return sendManagerError(reply, err); + } + }); + app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => { const { id } = req.params as { id: string }; const query = req.query as { path?: string; force?: string }; diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index 58d72b6..f2a9a61 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -15,6 +15,10 @@ import { pruneWorktrees, switchBranch, isDirtyWorktreeError, + branchExists, + currentBranch, + listBranches, + commitAll, } from '../src/core/git.js'; const dirs: string[] = []; @@ -96,7 +100,7 @@ describe('opérations git (repo tmp réel)', () => { const repo = makeTmpRepo(); const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`); dirs.push(wtPath); - await addWorktree(repo, { path: wtPath, branch: 'feat', newBranch: true }); + await addWorktree(repo, { path: wtPath, branch: 'feat', mode: 'create' }); const list = await listWorktrees(repo); const wt = list.find((w) => resolve(w.path) === resolve(wtPath)); @@ -136,9 +140,57 @@ describe('opérations git (repo tmp réel)', () => { it('prune retire un worktree dont le dossier a disparu', async () => { const repo = makeTmpRepo(); const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`); - await addWorktree(repo, { path: wtPath, branch: 'gone', newBranch: true }); + await addWorktree(repo, { path: wtPath, branch: 'gone', mode: 'create' }); rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main" await pruneWorktrees(repo); expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false); }); }); + +describe('branche : existence, liste, courante, commit', () => { + it('branchExists / currentBranch / listBranches', async () => { + const repo = makeTmpRepo(); + expect(await currentBranch(repo)).toBe('main'); + expect(await branchExists(repo, 'main')).toEqual({ local: true, remote: false }); + expect(await branchExists(repo, 'nope')).toEqual({ local: false, remote: false }); + + execFileSync('git', ['branch', 'dev'], { cwd: repo }); + const b = await listBranches(repo); + expect(b.local).toContain('main'); + expect(b.local).toContain('dev'); + expect(b.remote).toEqual([]); + }); + + it('commitAll : add -A + commit, fait baisser dirtyCount à 0', async () => { + const repo = makeTmpRepo(); + writeFileSync(join(repo, 'new.txt'), 'hello\n'); + expect((await worktreeStatus(repo)).dirtyCount).toBeGreaterThan(0); + await commitAll(repo, 'add new.txt'); + expect((await worktreeStatus(repo)).dirtyCount).toBe(0); + }); +}); + +describe('addWorktree : résolution auto (créer / réutiliser)', () => { + it('auto crée la branche si absente, la réutilise si présente', async () => { + const repo = makeTmpRepo(); + // branche absente → création (renvoie "created") + const wt1 = join(dirname(repo), `${basename(repo)}-wt-feat`); + dirs.push(wt1); + expect(await addWorktree(repo, { path: wt1, branch: 'feat', mode: 'auto' })).toBe('created'); + expect((await listWorktrees(repo)).find((w) => resolve(w.path) === resolve(wt1))?.branch).toBe('feat'); + + // la même branche existe désormais ; un AUTRE worktree dessus → checkout (renvoie "reused") + await removeWorktree(repo, wt1, true); // libère la branche + const wt2 = join(dirname(repo), `${basename(repo)}-wt-feat2`); + dirs.push(wt2); + expect(await addWorktree(repo, { path: wt2, branch: 'feat', mode: 'auto' })).toBe('reused'); + }); + + it('mode create échoue si la branche existe déjà', async () => { + const repo = makeTmpRepo(); + execFileSync('git', ['branch', 'dup'], { cwd: repo }); + const wt = join(dirname(repo), `${basename(repo)}-wt-dup`); + dirs.push(wt); + await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined(); + }); +}); diff --git a/packages/server/test/worktree-manager.test.ts b/packages/server/test/worktree-manager.test.ts index 33e0d9d..4eaf820 100644 --- a/packages/server/test/worktree-manager.test.ts +++ b/packages/server/test/worktree-manager.test.ts @@ -97,7 +97,8 @@ describe('WorktreeManager', () => { const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`); dirs.push(wtPath); - const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true }); + const out = await wt.createWorktree(r.id, { branch: 'feat', mode: 'create', runHooks: true }); + expect(out.action).toBe('created'); expect(resolve(out.worktree.path)).toBe(resolve(wtPath)); expect(out.worktree.branch).toBe('feat'); @@ -113,7 +114,7 @@ describe('WorktreeManager', () => { it('createWorktree : branche invalide → 400', async () => { const repo = makeTmpRepo(); const r = await wt.addRepo({ path: repo }); - await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 }); + await expect(wt.createWorktree(r.id, { branch: '../evil', mode: 'create' })).rejects.toMatchObject({ statusCode: 400 }); }); it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => { @@ -163,7 +164,7 @@ describe('WorktreeManager', () => { const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`); dirs.push(wtPath); - await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false }); + await wt.createWorktree(r.id, { branch: 'x', mode: 'create', runHooks: false }); writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' }); @@ -176,7 +177,7 @@ describe('WorktreeManager', () => { const r = await wt.addRepo({ path: repo }); const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`); dirs.push(wtPath); - await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false }); + await wt.createWorktree(r.id, { branch: 'sess', mode: 'create', runHooks: false }); pty.spawn({ cwd: wtPath, command: 'bash' }); const list = await wt.listRepoWorktrees(r.id, true); @@ -190,11 +191,49 @@ describe('WorktreeManager', () => { const r = await wt.addRepo({ path: repo }); const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`); dirs.push(wtPath); - await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false }); + await wt.createWorktree(r.id, { branch: 'busy', mode: 'create', runHooks: false }); pty.spawn({ cwd: wtPath, command: 'bash' }); await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' }); }); + it('listRepoBranches : renvoie les branches locales (dont main)', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + const b = await wt.listRepoBranches(r.id); + expect(b.local).toContain('main'); + }); + + it('commitWorktree : arbre propre → 409 NOTHING_TO_COMMIT ; arbre sale → commit (dirty=0)', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + await expect(wt.commitWorktree(r.id, repo, 'noop')).rejects.toMatchObject({ statusCode: 409, code: 'NOTHING_TO_COMMIT' }); + writeFileSync(join(repo, 'f.txt'), 'x\n'); + const w = await wt.commitWorktree(r.id, repo, 'add f'); + expect(w.git.dirtyCount).toBe(0); + }); + + it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + const wtPath = join(dirname(repo), `${basename(repo)}-wt-prom`); + dirs.push(wtPath); + await wt.createWorktree(r.id, { branch: 'prom', mode: 'create', runHooks: false }); + + await wt.promoteWorktree(r.id, wtPath); + + expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('prom'); + expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false); + const branches = await wt.listRepoBranches(r.id); + expect(branches.local).toContain('main'); // ancienne branche principale conservée + expect(branches.local).toContain('prom'); + }); + + it('promoteWorktree : checkout principal refusé (400 IS_MAIN_WORKTREE)', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + await expect(wt.promoteWorktree(r.id, repo)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' }); + }); + it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => { const repo = makeTmpRepo(); const r = await wt.addRepo({ path: repo }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 9c623bd..abbc07f 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -109,11 +109,25 @@ export interface HookRunResult { output: string; durationMs: number; } +/** + * Stratégie de résolution de la branche d'un nouveau worktree : + * - `auto` (défaut) : détecte l'existence de la branche — checkout si elle existe en local, suivi de + * `origin/` si elle n'existe que sur le remote, sinon création (`-b`). Robuste pour les + * groupes hétérogènes où la branche peut déjà exister dans certains dépôts mais pas d'autres. + * - `create` : force la création (`-b`), échoue si la branche existe déjà. + * - `checkout` : force le checkout d'une branche existante, échoue si elle est absente. + */ +export type WorktreeBranchMode = 'auto' | 'create' | 'checkout'; +/** Action réellement effectuée par la résolution `auto` (feedback UI). */ +export type WorktreeBranchAction = 'created' | 'reused' | 'tracked'; + export interface CreateWorktreeRequest { branch: string; - /** true : créer la branche (`-b`) ; false : checkout d'une branche existante. */ - newBranch: boolean; - /** point de départ de la nouvelle branche (défaut : HEAD courant du repo). */ + /** stratégie de résolution de la branche (défaut : `auto`). Voir {@link WorktreeBranchMode}. */ + mode?: WorktreeBranchMode; + /** @deprecated remplacé par `mode` ; mappé : true→create, false→checkout, absent→auto. */ + newBranch?: boolean; + /** point de départ d'une branche créée (défaut : branche par défaut du dépôt, sinon HEAD). */ baseRef?: string; /** chemin cible du worktree (défaut : `/-wt-`). */ path?: string; @@ -126,6 +140,35 @@ export interface CreateWorktreeResponse { worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null; + /** action effective de la résolution de branche (créée / réutilisée / suivie du remote). */ + action: WorktreeBranchAction; +} + +/** GET /api/v1/repos/:id/branches — branches locales/remote pour alimenter un sélecteur de base. */ +export interface RepoBranchesResponse { + local: string[]; + remote: string[]; + /** branche par défaut du dépôt (origin/HEAD), ou null si indéterminée. */ + default: string | null; +} + +/** POST /api/v1/repos/:id/worktrees/commit — `git add -A` puis commit dans le worktree visé. */ +export interface CommitWorktreeRequest { + path: string; + message: string; +} +/** POST /api/v1/repos/:id/worktrees/push — pousse la branche du worktree (upstream auto si absent). */ +export interface PushWorktreeRequest { + path: string; +} +/** + * POST /api/v1/repos/:id/worktrees/promote — « passer en principal » : la branche du worktree devient + * le checkout principal du dépôt (le worktree est supprimé, l'ancienne branche principale est conservée). + */ +export interface PromoteWorktreeRequest { + path: string; + /** outrepasse les garde-fous d'arbre sale (worktree/checkout principal). */ + force?: boolean; } export interface AdoptWorktreeRequest { path: string; diff --git a/packages/web/src/components/GroupSessionModal.vue b/packages/web/src/components/GroupSessionModal.vue index 9cda131..8f57ff1 100644 --- a/packages/web/src/components/GroupSessionModal.vue +++ b/packages/web/src/components/GroupSessionModal.vue @@ -21,21 +21,20 @@ - +