diff --git a/packages/server/scripts/acceptance-p3.mjs b/packages/server/scripts/acceptance-p3.mjs index 0b3a6e0..4e1f514 100644 --- a/packages/server/scripts/acceptance-p3.mjs +++ b/packages/server/scripts/acceptance-p3.mjs @@ -3,7 +3,7 @@ // Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook // post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression. import { spawn, execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -119,6 +119,24 @@ try { const feat = wlist1.worktrees.find((w) => w.branch === 'feat'); check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live)); + // Session sur le checkout principal (« bosser sur la branche principale ») avec création de branche. + const mainSess = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'mainfeat', newBranch: true }); + const mainBody = await mainSess.json(); + check('POST repo session → 201 (bash)', mainSess.status === 201 && mainBody.session?.command === 'bash'); + check('session de branche principale : cwd = checkout principal', mainBody.session?.cwd === repoSummary.path); + const mainPush = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.isMain && m.worktree?.branch === 'mainfeat'); + check('broadcast worktree_update (branche principale basculée)', !!mainPush); + await sleep(300); + const wlistM = await (await j('/api/v1/worktrees', 'GET', cookie)).json(); + const mainWt = wlistM.worktrees.find((w) => w.isMain); + check('session corrélée au checkout principal', mainWt?.branch === 'mainfeat' && mainWt.sessions.some((s) => s.id === mainBody.session.id)); + + // Refus 409 quand le checkout principal est sale. + writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); + const dirtyRefused = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'other', newBranch: true }); + check('repo session sur checkout sale → 409', dirtyRefused.status === 409); + rmSync(join(repo, 'scratch.txt'), { force: true }); + // Suppression forcée (une session vit dans le worktree). const refused = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=false`, 'DELETE', cookie); check('delete sans force (session live) → 409', refused.status === 409); diff --git a/packages/server/src/core/git.ts b/packages/server/src/core/git.ts index ba7f7f0..ab7309b 100644 --- a/packages/server/src/core/git.ts +++ b/packages/server/src/core/git.ts @@ -190,6 +190,16 @@ export async function addWorktree( await git(repoPath, args); } +/** + * Crée/bascule une branche dans le checkout (worktree) en `repoPath` — utilisé pour démarrer une + * session sur la branche principale sans worktree dédié. `create` → `git switch -c ` (échoue + * si la branche existe) ; sinon `git switch ` (branche existante). Pas de `--` : l'argument + * est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag). + */ +export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise { + await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]); +} + export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise { await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]); } diff --git a/packages/server/src/core/worktree-manager.ts b/packages/server/src/core/worktree-manager.ts index fb900c6..94fd50c 100644 --- a/packages/server/src/core/worktree-manager.ts +++ b/packages/server/src/core/worktree-manager.ts @@ -30,6 +30,7 @@ import { listWorktrees, pruneWorktrees, removeWorktree, + switchBranch, worktreeStatus, type ParsedWorktree, } from './git.js'; @@ -355,6 +356,41 @@ export class WorktreeManager extends EventEmitter { }); } + /** + * Lance UNE session dans le checkout principal du repo (`repo.path`) — pour « bosser sur la branche + * principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche + * dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié). + * Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal + * est le dépôt réel de l'utilisateur, déjà configuré/approuvé. Sérialisé par repo (withLock). + */ + async startMainSession( + repoId: string, + req: { command?: 'claude' | 'bash'; branch?: string; newBranch?: boolean }, + ): Promise<{ session: SessionSummary; worktree: WorktreeSummary | null }> { + const row = this.getRepoRow(repoId); + if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); + const branch = req.branch?.trim() || undefined; + if (branch !== undefined && !isValidBranchName(branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${branch}`); + + return this.withLock(repoId, async () => { + if (branch !== undefined) { + // garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés. + if ((await worktreeStatus(row.path)).dirtyCount > 0) { + throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit or stash before switching branch'); + } + try { + await switchBranch(row.path, { branch, create: req.newBranch ?? true }); + } catch (err) { + throw httpError(400, 'SWITCH_FAILED', (err as Error).message); + } + this.factsCache.delete(repoId); + } + const session = this.ptyManager.spawn({ cwd: row.path, command: req.command ?? 'claude' }); + const worktree = await this.emitWorktree(row, row.path); + return { session, worktree }; + }); + } + async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> { const row = this.getRepoRow(repoId); if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); diff --git a/packages/server/src/routes/worktrees.ts b/packages/server/src/routes/worktrees.ts index a20d4e7..5d74b30 100644 --- a/packages/server/src/routes/worktrees.ts +++ b/packages/server/src/routes/worktrees.ts @@ -3,6 +3,8 @@ import type { AdoptWorktreeRequest, CreateWorktreeRequest, CreateWorktreeResponse, + SessionResponse, + StartRepoSessionRequest, WorktreeResponse, WorktreesListResponse, } from '@arboretum/shared'; @@ -43,6 +45,33 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager } }); + // 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) => { + const { id } = req.params as { id: string }; + const body = (req.body as Partial | null) ?? {}; + if (body.command != null && body.command !== 'claude' && body.command !== 'bash') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } }); + } + if (body.branch != null && typeof body.branch !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch must be a string' } }); + } + if (body.newBranch != null && typeof body.newBranch !== 'boolean') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'newBranch must be a boolean' } }); + } + try { + const out = await wt.startMainSession(id, { + ...(body.command !== undefined ? { command: body.command } : {}), + ...(body.branch !== undefined ? { branch: body.branch } : {}), + ...(body.newBranch !== undefined ? { newBranch: body.newBranch } : {}), + }); + const res: SessionResponse = { session: out.session }; + return reply.status(201).send(res); + } catch (err) { + return sendManagerError(reply, err); + } + }); + app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index ff03b5d..58d72b6 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -13,6 +13,7 @@ import { addWorktree, removeWorktree, pruneWorktrees, + switchBranch, isDirtyWorktreeError, } from '../src/core/git.js'; @@ -118,6 +119,20 @@ describe('opérations git (repo tmp réel)', () => { expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false); }); + it('switchBranch : create=true crée une branche, create=false bascule sur une existante', async () => { + const repo = makeTmpRepo(); + const cur = (): string => execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim(); + + await switchBranch(repo, { branch: 'feature/new', create: true }); + expect(cur()).toBe('feature/new'); + + await switchBranch(repo, { branch: 'main', create: false }); + expect(cur()).toBe('main'); + + // créer une branche déjà existante échoue (git refuse). + await expect(switchBranch(repo, { branch: 'feature/new', create: true })).rejects.toBeDefined(); + }); + it('prune retire un worktree dont le dossier a disparu', async () => { const repo = makeTmpRepo(); const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`); diff --git a/packages/server/test/worktree-manager.test.ts b/packages/server/test/worktree-manager.test.ts index 36d76ca..c717952 100644 --- a/packages/server/test/worktree-manager.test.ts +++ b/packages/server/test/worktree-manager.test.ts @@ -116,6 +116,46 @@ describe('WorktreeManager', () => { await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 }); }); + it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + const out = await wt.startMainSession(r.id, { command: 'bash' }); + expect(resolve(out.session.cwd)).toBe(resolve(repo)); + expect(out.session).toMatchObject({ command: 'bash', live: true }); + // pas de bascule de branche → toujours sur main. + expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main'); + const main = (await wt.listRepoWorktrees(r.id, true)).find((w) => w.isMain); + expect(main?.sessions.some((s) => s.id === out.session.id)).toBe(true); + }); + + it('startMainSession : newBranch crée et bascule la branche dans le checkout principal + event', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + const events: Array<{ worktree: WorktreeSummary }> = []; + wt.on('worktree_update', (e) => events.push(e)); + const out = await wt.startMainSession(r.id, { command: 'bash', branch: 'feature/x', newBranch: true }); + expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('feature/x'); + expect(out.worktree?.branch).toBe('feature/x'); + expect(events.some((e) => e.worktree.isMain && e.worktree.branch === 'feature/x')).toBe(true); + }); + + it('startMainSession : checkout principal sale → 409 DIRTY_TREE (pas de bascule)', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); // arbre sale + await expect(wt.startMainSession(r.id, { command: 'bash', branch: 'feature/y', newBranch: true })).rejects.toMatchObject({ + statusCode: 409, + code: 'DIRTY_TREE', + }); + expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main'); + }); + + it('startMainSession : branche invalide → 400', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + await expect(wt.startMainSession(r.id, { command: 'bash', branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 }); + }); + it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', 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 1b402a3..d899eda 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -128,6 +128,20 @@ export interface AdoptWorktreeRequest { preTrust?: boolean; } +/** + * POST /api/v1/repos/:id/session — lance UNE session dans le checkout principal du repo + * (`repo.path`) pour « bosser sur la branche principale » sans créer de worktree. + * Réponse : `SessionResponse`. + */ +export interface StartRepoSessionRequest { + /** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */ + command?: 'claude' | 'bash'; + /** si fourni : crée/bascule cette branche dans le checkout principal avant la session. */ + branch?: string; + /** true (défaut quand `branch` fourni) : `git switch -c` ; false : bascule sur une branche existante. */ + newBranch?: boolean; +} + // ---- Groupes de travail (P5) ---- export interface GroupsListResponse { groups: GroupSummary[]; diff --git a/packages/web/src/components/RepoSection.vue b/packages/web/src/components/RepoSection.vue index 82bfa2c..a6e8fa7 100644 --- a/packages/web/src/components/RepoSection.vue +++ b/packages/web/src/components/RepoSection.vue @@ -5,7 +5,7 @@ {{ t('repos.invalid') }} {{ repo.path }}
- {{ t('worktrees.new') }} + {{ t('worktrees.startWork') }} {{ t('worktrees.prune') }} -
- - - - - {{ t('worktrees.create') }} - + + +
+ {{ t('worktrees.modeLabel') }} + + +
+ +
+ + + + + + + + {{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }} + +

{{ error }}

@@ -66,20 +101,37 @@ const toasts = useToastsStore(); // applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo. const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id))); const creating = ref(false); +const mode = ref<'worktree' | 'main'>('worktree'); const branch = ref(''); const newBranch = ref(true); const startSession = ref<'claude' | 'bash' | null>(null); +const mainBranchMode = ref<'current' | 'new'>('current'); +const mainCommand = ref<'claude' | 'bash'>('claude'); const busy = ref(false); const error = ref(null); +// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK. +const canSubmit = computed(() => + mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '', +); + async function onCreate(): Promise { busy.value = true; error.value = null; try { - await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value }); + if (mode.value === 'worktree') { + await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value }); + toasts.success(t('toast.worktreeCreated')); + } else { + // bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle. + await store.startMainSession(props.repo.id, { + command: mainCommand.value, + ...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}), + }); + toasts.success(t('toast.sessionCreated')); + } branch.value = ''; creating.value = false; - toasts.success(t('toast.worktreeCreated')); } catch (err) { error.value = err instanceof Error ? err.message : String(err); } finally { diff --git a/packages/web/src/components/WorktreeCard.vue b/packages/web/src/components/WorktreeCard.vue index de96940..d5f1d3b 100644 --- a/packages/web/src/components/WorktreeCard.vue +++ b/packages/web/src/components/WorktreeCard.vue @@ -26,6 +26,30 @@ {{ t('worktrees.noSession') }} + + +
+ + + +
+ {{ startError }} +