feat(worktrees): création intelligente (auto) + commit/push/promotion + grille agrandissable

- addWorktree résout la branche par dépôt (branchExists) : checkout si présente, suivi de origin/<b> si seulement remote, sinon création (-b) depuis la branche par défaut. Corrige le 'fatal: invalid reference' sur les groupes hétérogènes ; newBranch -> mode (déprécié, mappé en route).
- Nouvelles opérations git : commit (add -A), push (upstream auto), promotion « en principal » (la branche du worktree devient le checkout principal, sans merge ni conflit, ancienne branche conservée). Routes /worktrees/{commit,push,promote} auditées + GET /repos/:id/branches.
- Web : actions Commit/Push/Promouvoir sur WorktreeCard, sélecteur de branche de base (datalist) dans RepoSection et GroupSessionModal, agrandissement d'un terminal au choix dans TerminalGrid.
- Court-circuite la session de groupe quand aucun worktree n'a pu être créé (fini le masquage par « no resolvable worktree »).
- Tests : git.ts (branchExists/listBranches/commitAll/auto), worktree-manager (commit/promote/branches) ; acceptance P5 étend le scénario worktree de groupe sur branche neuve + commit + promotion.
This commit is contained in:
2026-06-22 19:55:45 +02:00
parent e84c6a7f6d
commit f4fb6c3b52
18 changed files with 732 additions and 62 deletions

View File

@@ -4,7 +4,7 @@
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership // 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). // quand le repo est supprimé (PRAGMA foreign_keys = ON).
import { spawn, execFileSync } from 'node:child_process'; 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 { tmpdir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; 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); const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
check('broadcast WS session_update (session de groupe)', !!pushedSession); 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. // 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' }); 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); check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);

View File

@@ -172,7 +172,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerSessionRoutes(app, manager, discovery, db); registerSessionRoutes(app, manager, discovery, db);
registerRepoRoutes(app, worktrees, db); registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups, db, worktrees, manager); registerGroupRoutes(app, groups, db, worktrees, manager);
registerWorktreeRoutes(app, worktrees); registerWorktreeRoutes(app, worktrees, db);
registerPushRoutes(app, push, db); registerPushRoutes(app, push, db);
registerSettingsRoutes(app, db, config, serverVersion, push); registerSettingsRoutes(app, db, config, serverVersion, push);
registerFsRoutes(app); registerFsRoutes(app);

View File

@@ -2,9 +2,11 @@
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant. // les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { resolve, sep } from 'node:path'; 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; 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; const GIT_MAX_BUFFER = 8 * 1024 * 1024;
interface GitError extends Error { interface GitError extends Error {
@@ -12,14 +14,14 @@ interface GitError extends Error {
code?: number | string; code?: number | string;
} }
function git(cwd: string, args: string[]): Promise<string> { function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<string> {
return new Promise((resolveP, reject) => { return new Promise((resolveP, reject) => {
execFile( execFile(
'git', 'git',
args, args,
{ {
cwd, cwd,
timeout: GIT_TIMEOUT_MS, timeout: timeoutMs,
maxBuffer: GIT_MAX_BUFFER, maxBuffer: GIT_MAX_BUFFER,
// GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une // 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. // session claude active) ; LC_ALL=C : sortie stable pour le parsing.
@@ -140,6 +142,52 @@ export async function defaultBranch(repoPath: string): Promise<string | null> {
} }
} }
/** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */
export async function currentBranch(repoPath: string): Promise<string | null> {
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<boolean> => {
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<ParsedWorktree[]> { export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain'])); return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
} }
@@ -175,19 +223,69 @@ export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitS
return { ahead, behind, dirtyCount, upstream }; return { ahead, behind, dirtyCount, upstream };
} }
/** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt
* (locale ou suivie de `origin`), sinon undefined (git part alors du HEAD courant). */
async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<string | undefined> {
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( export async function addWorktree(
repoPath: string, repoPath: string,
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string }, opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string },
): Promise<void> { ): Promise<WorktreeBranchAction> {
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']; const args = ['worktree', 'add'];
if (opts.newBranch) args.push('-b', opts.branch); if (action === 'reused') {
args.push('--', opts.path); args.push('--', opts.path, opts.branch);
if (opts.newBranch) { } else if (action === 'tracked') {
if (opts.baseRef) args.push(opts.baseRef); args.push('--track', '-b', opts.branch, '--', opts.path, `origin/${opts.branch}`);
} else { } 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); 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<void> {
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 <branche>`. */
export async function push(repoPath: string): Promise<void> {
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);
}
} }
/** /**

View File

@@ -12,6 +12,8 @@ import type {
PostCreateHook, PostCreateHook,
RepoSummary, RepoSummary,
SessionSummary, SessionSummary,
WorktreeBranchAction,
WorktreeBranchMode,
WorktreeGitStatus, WorktreeGitStatus,
WorktreeSummary, WorktreeSummary,
} from '@arboretum/shared'; } from '@arboretum/shared';
@@ -22,13 +24,16 @@ import { scanForRepos } from './repo-scanner.js';
import { preTrustProject } from './claude-trust.js'; import { preTrustProject } from './claude-trust.js';
import { import {
addWorktree, addWorktree,
commitAll,
defaultBranch, defaultBranch,
isDirtyWorktreeError, isDirtyWorktreeError,
isRepo, isRepo,
isSafeAbsolutePath, isSafeAbsolutePath,
isValidBranchName, isValidBranchName,
listBranches,
listWorktrees, listWorktrees,
pruneWorktrees, pruneWorktrees,
push,
removeWorktree, removeWorktree,
switchBranch, switchBranch,
worktreeStatus, worktreeStatus,
@@ -322,8 +327,8 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
async createWorktree( async createWorktree(
repoId: string, repoId: string,
req: { branch: string; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | 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 }> { ): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null; action: WorktreeBranchAction }> {
const row = this.getRepoRow(repoId); const row = this.getRepoRow(repoId);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); 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}`); if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
@@ -332,8 +337,9 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`); if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);
return this.withLock(repoId, async () => { return this.withLock(repoId, async () => {
let action: WorktreeBranchAction;
try { 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) { } catch (err) {
throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message); throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
} }
@@ -352,7 +358,93 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession }); 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 }); 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<WorktreeSummary> {
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<WorktreeSummary> {
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<WorktreeSummary | null> {
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`
}); });
} }

View File

@@ -139,7 +139,8 @@ export function registerGroupRoutes(
} }
if (dirs.length === 0) { 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. // cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
const { cwd, addDirs } = resolveGroupCwd(dirs); const { cwd, addDirs } = resolveGroupCwd(dirs);

View File

@@ -1,17 +1,32 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { import type {
AdoptWorktreeRequest, AdoptWorktreeRequest,
CommitWorktreeRequest,
CreateWorktreeRequest, CreateWorktreeRequest,
CreateWorktreeResponse, CreateWorktreeResponse,
PromoteWorktreeRequest,
PushWorktreeRequest,
RepoBranchesResponse,
SessionResponse, SessionResponse,
StartRepoSessionRequest, StartRepoSessionRequest,
WorktreeBranchMode,
WorktreeResponse, WorktreeResponse,
WorktreesListResponse, WorktreesListResponse,
} from '@arboretum/shared'; } from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js'; 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'; 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<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() })); app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => { app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
@@ -31,7 +46,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
try { try {
const out = await wt.createWorktree(id, { const out = await wt.createWorktree(id, {
branch: body.branch, 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.baseRef !== undefined ? { baseRef: body.baseRef } : {}),
...(body.path !== undefined ? { path: body.path } : {}), ...(body.path !== undefined ? { path: body.path } : {}),
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}), ...(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), // Session sur le checkout principal (« bosser sur la branche principale » sans worktree),
// avec création/bascule de branche optionnelle côté serveur. // avec création/bascule de branche optionnelle côté serveur.
app.post('/api/v1/repos/:id/session', async (req, reply) => { 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<CommitWorktreeRequest> | 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<PushWorktreeRequest> | 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<PromoteWorktreeRequest> | 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) => { app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const query = req.query as { path?: string; force?: string }; const query = req.query as { path?: string; force?: string };

View File

@@ -15,6 +15,10 @@ import {
pruneWorktrees, pruneWorktrees,
switchBranch, switchBranch,
isDirtyWorktreeError, isDirtyWorktreeError,
branchExists,
currentBranch,
listBranches,
commitAll,
} from '../src/core/git.js'; } from '../src/core/git.js';
const dirs: string[] = []; const dirs: string[] = [];
@@ -96,7 +100,7 @@ describe('opérations git (repo tmp réel)', () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
dirs.push(wtPath); 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 list = await listWorktrees(repo);
const wt = list.find((w) => resolve(w.path) === resolve(wtPath)); 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 () => { it('prune retire un worktree dont le dossier a disparu', async () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`); 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" rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
await pruneWorktrees(repo); await pruneWorktrees(repo);
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false); 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();
});
});

View File

@@ -97,7 +97,8 @@ describe('WorktreeManager', () => {
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
dirs.push(wtPath); 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(resolve(out.worktree.path)).toBe(resolve(wtPath));
expect(out.worktree.branch).toBe('feat'); expect(out.worktree.branch).toBe('feat');
@@ -113,7 +114,7 @@ describe('WorktreeManager', () => {
it('createWorktree : branche invalide → 400', async () => { it('createWorktree : branche invalide → 400', async () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo }); 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 () => { 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`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
dirs.push(wtPath); 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 writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' }); 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 r = await wt.addRepo({ path: repo });
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
dirs.push(wtPath); 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' }); pty.spawn({ cwd: wtPath, command: 'bash' });
const list = await wt.listRepoWorktrees(r.id, true); const list = await wt.listRepoWorktrees(r.id, true);
@@ -190,11 +191,49 @@ describe('WorktreeManager', () => {
const r = await wt.addRepo({ path: repo }); const r = await wt.addRepo({ path: repo });
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
dirs.push(wtPath); 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' }); pty.spawn({ cwd: wtPath, command: 'bash' });
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' }); 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 () => { it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo }); const r = await wt.addRepo({ path: repo });

View File

@@ -109,11 +109,25 @@ export interface HookRunResult {
output: string; output: string;
durationMs: number; 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/<branch>` 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 { export interface CreateWorktreeRequest {
branch: string; branch: string;
/** true : créer la branche (`-b`) ; false : checkout d'une branche existante. */ /** stratégie de résolution de la branche (défaut : `auto`). Voir {@link WorktreeBranchMode}. */
newBranch: boolean; mode?: WorktreeBranchMode;
/** point de départ de la nouvelle branche (défaut : HEAD courant du repo). */ /** @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; baseRef?: string;
/** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */ /** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */
path?: string; path?: string;
@@ -126,6 +140,35 @@ export interface CreateWorktreeResponse {
worktree: WorktreeSummary; worktree: WorktreeSummary;
hookResults: HookRunResult[]; hookResults: HookRunResult[];
session: SessionSummary | null; 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 { export interface AdoptWorktreeRequest {
path: string; path: string;

View File

@@ -21,21 +21,20 @@
</div> </div>
</div> </div>
<!-- mode feature : branche commune créée dans chaque repo --> <!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
<template v-if="mode === 'feature'"> <template v-if="mode === 'feature'">
<label class="flex flex-col gap-1 text-xs text-zinc-400"> <label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.branchLabel') }} {{ t('crossRepo.branchLabel') }}
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" /> <input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
<datalist id="group-base-branches">
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
</datalist>
</label> </label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
</label>
</div>
</template> </template>
<label class="flex flex-col gap-1 text-xs text-zinc-400"> <label class="flex flex-col gap-1 text-xs text-zinc-400">
@@ -78,11 +77,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared'; import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups'; import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>(); const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
@@ -91,12 +91,29 @@ const emit = defineEmits<{ close: [] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const groups = useGroupsStore(); const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore(); const toasts = useToastsStore();
const mode = ref<'feature' | 'main'>('feature'); const mode = ref<'feature' | 'main'>('feature');
const branch = ref(''); const branch = ref('');
const newBranch = ref(true);
const baseRef = ref(''); const baseRef = ref('');
// Sélecteur de branche de base : branches du 1er repo du groupe (indicatif ; vide → défaut serveur par repo).
const branches = ref<RepoBranchesResponse | null>(null);
const baseBranchOptions = computed<string[]>(() => {
const b = branches.value;
if (!b) return [];
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
});
onMounted(async () => {
const first = props.repos[0];
if (!first) return;
try {
branches.value = await worktrees.fetchBranches(first.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
const command = ref<'claude' | 'bash'>('claude'); const command = ref<'claude' | 'bash'>('claude');
const wtResults = ref<Record<string, CrossRepoResult>>({}); const wtResults = ref<Record<string, CrossRepoResult>>({});
const outcome = ref<GroupFeatureOutcome | null>(null); const outcome = ref<GroupFeatureOutcome | null>(null);
@@ -136,7 +153,7 @@ async function onSubmit(): Promise<void> {
{ {
command: command.value, command: command.value,
...(mode.value === 'feature' ...(mode.value === 'feature'
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) } ? { branch: branch.value.trim(), ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
: {}), : {}),
}, },
(result) => { (result) => {

View File

@@ -27,12 +27,19 @@
</div> </div>
<div class="flex flex-wrap items-end gap-2"> <div class="flex flex-wrap items-end gap-2">
<!-- mode worktree : branche + créer/existante --> <!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
<template v-if="mode === 'worktree'"> <template v-if="mode === 'worktree'">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400"> <label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }} {{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required /> <input v-model="branch" class="input font-mono" placeholder="feature/…" required />
</label> </label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
<datalist :id="datalistId">
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
</datalist>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400"> <label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.start') }} {{ t('worktrees.start') }}
<select v-model="startSession" class="input"> <select v-model="startSession" class="input">
@@ -41,7 +48,6 @@
<option value="bash">bash</option> <option value="bash">bash</option>
</select> </select>
</label> </label>
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
</template> </template>
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande --> <!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
@@ -81,10 +87,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue'; import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
import type { RepoSummary } from '@arboretum/shared'; import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView'; import { useWorktreeView } from '../composables/useWorktreeView';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
@@ -103,13 +109,30 @@ const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id
const creating = ref(false); const creating = ref(false);
const mode = ref<'worktree' | 'main'>('worktree'); const mode = ref<'worktree' | 'main'>('worktree');
const branch = ref(''); const branch = ref('');
const newBranch = ref(true); const baseRef = ref('');
const startSession = ref<'claude' | 'bash' | null>(null); const startSession = ref<'claude' | 'bash' | null>(null);
const mainBranchMode = ref<'current' | 'new'>('current'); const mainBranchMode = ref<'current' | 'new'>('current');
const mainCommand = ref<'claude' | 'bash'>('claude'); const mainCommand = ref<'claude' | 'bash'>('claude');
const busy = ref(false); const busy = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
// Sélecteur de branche de base (chargé paresseusement à l'ouverture du formulaire).
const branches = ref<RepoBranchesResponse | null>(null);
const datalistId = computed(() => `wt-base-branches-${props.repo.id}`);
const baseBranchOptions = computed<string[]>(() => {
const b = branches.value;
if (!b) return [];
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
});
watch(creating, async (open) => {
if (!open || branches.value) return;
try {
branches.value = await store.fetchBranches(props.repo.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK. // worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
const canSubmit = computed(() => const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '', mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
@@ -120,7 +143,12 @@ async function onCreate(): Promise<void> {
error.value = null; error.value = null;
try { try {
if (mode.value === 'worktree') { if (mode.value === 'worktree') {
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value }); await store.createWorktree(props.repo.id, {
branch: branch.value.trim(),
mode: 'auto', // détecte créer / checkout / suivi remote
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
startSession: startSession.value,
});
toasts.success(t('toast.worktreeCreated')); toasts.success(t('toast.worktreeCreated'));
} else { } else {
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle. // bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
@@ -131,6 +159,7 @@ async function onCreate(): Promise<void> {
toasts.success(t('toast.sessionCreated')); toasts.success(t('toast.sessionCreated'));
} }
branch.value = ''; branch.value = '';
baseRef.value = '';
creating.value = false; creating.value = false;
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err); error.value = err instanceof Error ? err.message : String(err);

View File

@@ -12,6 +12,14 @@
{{ t('groups.spanBadge', spannedCount) }} {{ t('groups.spanBadge', spannedCount) }}
</span> </span>
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span> <span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
<button
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
:title="maximized ? t('terminal.restore') : t('terminal.maximize')"
@click="emit('toggle-maximize')"
>
<component :is="maximized ? Minimize2 : Maximize2" class="h-3.5 w-3.5" />
</button>
</header> </header>
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" /> <DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1">
@@ -23,13 +31,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Maximize2, Minimize2 } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue'; import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.vue'; import DialogPrompt from './DialogPrompt.vue';
import TerminalView from './TerminalView.vue'; import TerminalView from './TerminalView.vue';
const props = defineProps<{ session: SessionSummary }>(); const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
const emit = defineEmits<{ 'toggle-maximize': [] }>();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -2,19 +2,24 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p> <p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
<template v-else> <template v-else>
<p v-if="sessions.length > MAX_CELLS" class="text-xs text-amber-400"> <p v-if="!activeMax && sessions.length > MAX_CELLS" class="text-xs text-amber-400">
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }} {{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
</p> </p>
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux. <!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver. (dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
Plancher min-h-80 pour les fenêtres courtes. --> Plancher min-h-80 pour les fenêtres courtes. -->
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3"> <!-- une cellule maximisée plein cadre ; sinon la grille responsive -->
<div v-if="activeMax" class="grid grid-cols-1">
<TerminalCell :session="activeMax" maximized class="h-[calc(100dvh-8rem)] min-h-80" @toggle-maximize="toggleMax(activeMax.id)" />
</div>
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
<TerminalCell <TerminalCell
v-for="s in shown" v-for="s in shown"
:key="s.id" :key="s.id"
:session="s" :session="s"
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]" class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
@toggle-maximize="toggleMax(s.id)"
/> />
</div> </div>
</template> </template>
@@ -22,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import TerminalCell from './TerminalCell.vue'; import TerminalCell from './TerminalCell.vue';
@@ -32,4 +37,14 @@ const { t } = useI18n();
const MAX_CELLS = 6; const MAX_CELLS = 6;
const shown = computed(() => props.sessions.slice(0, MAX_CELLS)); const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
const maximizedId = ref<string | null>(null);
function toggleMax(id: string): void {
maximizedId.value = maximizedId.value === id ? null : id;
}
const activeMax = computed<SessionSummary | null>(() =>
maximizedId.value ? (shown.value.find((s) => s.id === maximizedId.value) ?? null) : null,
);
</script> </script>

View File

@@ -62,6 +62,41 @@
</div> </div>
</div> </div>
<!-- actions git : commit / push / promotion en principal (sans ouvrir le terminal) -->
<div v-if="canPush || worktree.git.dirtyCount || canPromote" class="mt-1 flex flex-wrap items-center gap-2">
<form v-if="committingOpen" class="flex flex-wrap items-center gap-1" @submit.prevent="onCommit">
<input v-model="commitMessage" class="input w-44 text-xs" :placeholder="t('worktrees.commitPlaceholder')" />
<button type="submit" class="btn-primary text-xs" :disabled="!!gitBusy || commitMessage.trim() === ''">
{{ gitBusy === 'commit' ? t('worktrees.committing') : t('worktrees.commit') }}
</button>
<button type="button" class="btn text-xs" @click="committingOpen = false">{{ t('common.cancel') }}</button>
</form>
<button v-else-if="worktree.git.dirtyCount" class="btn text-xs" :disabled="!!gitBusy" @click="committingOpen = true">
{{ t('worktrees.commit') }}
</button>
<button v-if="canPush" class="btn text-xs" :disabled="!!gitBusy" @click="onPush">
{{ gitBusy === 'push' ? t('worktrees.pushing') : t('worktrees.push') }}<span v-if="worktree.git.ahead"> {{ worktree.git.ahead }}</span>
</button>
<button v-if="canPromote && !promoteConfirming" class="btn text-xs" :disabled="!!gitBusy" @click="promoteConfirming = true">
{{ t('worktrees.promote') }}
</button>
<span v-if="gitError" class="text-xs text-amber-400">{{ gitError }}</span>
</div>
<!-- confirmation explicite de la promotion (action destructive : ce worktree sera supprimé) -->
<div v-if="promoteConfirming" class="mt-1 rounded border border-amber-700/40 bg-amber-950/20 p-2 text-xs text-amber-200">
{{ promoteConfirmText }}
<div class="mt-1 flex items-center gap-2">
<button class="btn-primary text-xs" :disabled="!!gitBusy" @click="onPromote">
{{ gitBusy === 'promote' ? t('worktrees.promoting') : forcePromoteNeeded ? t('worktrees.forcePromote') : t('worktrees.promote') }}
</button>
<button class="btn text-xs" @click="cancelPromote">{{ t('common.cancel') }}</button>
</div>
</div>
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) --> <!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" /> <DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
</div> </div>
@@ -135,6 +170,73 @@ const branchLabel = computed(() =>
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'), props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'),
); );
// --- actions git : commit / push / promotion en principal ---
const gitBusy = ref<null | 'commit' | 'push' | 'promote'>(null);
const gitError = ref<string | null>(null);
const committingOpen = ref(false);
const commitMessage = ref('');
const promoteConfirming = ref(false);
const forcePromoteNeeded = ref(false);
const canPush = computed(() => !!props.worktree.branch && !props.worktree.detached);
const canPromote = computed(() => !props.worktree.isMain && !!props.worktree.branch && !props.worktree.detached);
const promoteConfirmText = computed(() => t('worktrees.promoteConfirm', { branch: props.worktree.branch ?? '' }));
function gitMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function cancelPromote(): void {
promoteConfirming.value = false;
forcePromoteNeeded.value = false;
}
async function onCommit(): Promise<void> {
if (gitBusy.value || commitMessage.value.trim() === '') return;
gitBusy.value = 'commit';
gitError.value = null;
try {
await store.commitWorktree(props.worktree.repoId, props.worktree.path, commitMessage.value.trim());
committingOpen.value = false;
commitMessage.value = '';
} catch (err) {
gitError.value = gitMsg(err);
} finally {
gitBusy.value = null;
}
}
async function onPush(): Promise<void> {
if (gitBusy.value) return;
gitBusy.value = 'push';
gitError.value = null;
try {
await store.pushWorktree(props.worktree.repoId, props.worktree.path);
} catch (err) {
gitError.value = gitMsg(err);
} finally {
gitBusy.value = null;
}
}
async function onPromote(): Promise<void> {
if (gitBusy.value) return;
gitBusy.value = 'promote';
gitError.value = null;
try {
await store.promoteWorktree(props.worktree.repoId, props.worktree.path, forcePromoteNeeded.value);
// succès : la branche est passée en principal et ce worktree est supprimé → la carte se démonte.
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
forcePromoteNeeded.value = true; // arbre modifié → proposer la promotion forcée
gitError.value = err.message;
} else {
gitError.value = gitMsg(err);
}
} finally {
gitBusy.value = null;
}
}
function reset(): void { function reset(): void {
confirming.value = false; confirming.value = false;
forceNeeded.value = false; forceNeeded.value = false;

View File

@@ -113,14 +113,15 @@ export default {
modeMain: 'Main checkouts', modeMain: 'Main checkouts',
branchLabel: 'Branch name', branchLabel: 'Branch name',
branchPlaceholder: 'feature/…', branchPlaceholder: 'feature/…',
newBranch: 'create branch', branchHint: 'Created where missing, reused where it already exists in the repo.',
baseRefLabel: 'Base ref (optional)', baseRefLabel: 'Base branch (optional)',
baseRefPlaceholder: "repo's default branch",
commandLabel: 'Session', commandLabel: 'Session',
reposLabel: 'Repos in this session', reposLabel: 'Repos in this session',
launch: 'Launch session', launch: 'Launch session',
launching: 'Launching…', launching: 'Launching…',
close: 'Close', close: 'Close',
ok: 'worktree created', ok: 'worktree ready',
error: 'failed', error: 'failed',
skippedLabel: 'skipped', skippedLabel: 'skipped',
done: 'Group session started.', done: 'Group session started.',
@@ -146,6 +147,8 @@ export default {
creating: 'Creating…', creating: 'Creating…',
branch: 'Branch', branch: 'Branch',
newBranch: 'create branch', newBranch: 'create branch',
baseRefLabel: 'Base branch',
baseRefPlaceholder: 'default branch',
start: 'Start', start: 'Start',
starting: 'Starting…', starting: 'Starting…',
startNone: 'no session', startNone: 'no session',
@@ -161,6 +164,16 @@ export default {
confirmDelete: 'Confirm delete', confirmDelete: 'Confirm delete',
forceDelete: 'Force delete', forceDelete: 'Force delete',
prune: 'Prune', prune: 'Prune',
commit: 'Commit',
commitPlaceholder: 'Commit message',
committing: 'Committing…',
push: 'Push',
pushing: 'Pushing…',
promote: 'Make main',
promoting: 'Promoting…',
promoteConfirm:
'Branch “{branch}” will become the repositorys main checkout; this worktree will be removed (the previous main branch is kept). Continue?',
forcePromote: 'Force (dirty tree)',
main: 'main', main: 'main',
detached: 'detached', detached: 'detached',
locked: 'locked', locked: 'locked',
@@ -176,6 +189,8 @@ export default {
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.', notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
fullscreen: 'Fullscreen', fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen', exitFullscreen: 'Exit fullscreen',
maximize: 'Maximize',
restore: 'Restore',
}, },
push: { push: {
enable: 'Enable notifications', enable: 'Enable notifications',

View File

@@ -115,14 +115,15 @@ const fr: typeof en = {
modeMain: 'Checkouts principaux', modeMain: 'Checkouts principaux',
branchLabel: 'Nom de branche', branchLabel: 'Nom de branche',
branchPlaceholder: 'feature/…', branchPlaceholder: 'feature/…',
newBranch: 'cer la branche', branchHint: 'Cée si absente, réutilisée si déjà présente dans le dépôt.',
baseRefLabel: 'Réf. de base (optionnel)', baseRefLabel: 'Branche de base (optionnel)',
baseRefPlaceholder: 'branche par défaut du dépôt',
commandLabel: 'Session', commandLabel: 'Session',
reposLabel: 'Dépôts de la session', reposLabel: 'Dépôts de la session',
launch: 'Lancer la session', launch: 'Lancer la session',
launching: 'Lancement…', launching: 'Lancement…',
close: 'Fermer', close: 'Fermer',
ok: 'worktree créé', ok: 'worktree prêt',
error: 'échec', error: 'échec',
skippedLabel: 'ignoré', skippedLabel: 'ignoré',
done: 'Session de groupe démarrée.', done: 'Session de groupe démarrée.',
@@ -148,6 +149,8 @@ const fr: typeof en = {
creating: 'Création…', creating: 'Création…',
branch: 'Branche', branch: 'Branche',
newBranch: 'créer la branche', newBranch: 'créer la branche',
baseRefLabel: 'Branche de base',
baseRefPlaceholder: 'branche par défaut',
start: 'Démarrer', start: 'Démarrer',
starting: 'Démarrage…', starting: 'Démarrage…',
startNone: 'aucune session', startNone: 'aucune session',
@@ -163,6 +166,16 @@ const fr: typeof en = {
confirmDelete: 'Confirmer', confirmDelete: 'Confirmer',
forceDelete: 'Forcer la suppression', forceDelete: 'Forcer la suppression',
prune: 'Élaguer', prune: 'Élaguer',
commit: 'Commit',
commitPlaceholder: 'Message de commit',
committing: 'Commit…',
push: 'Push',
pushing: 'Push…',
promote: 'Passer en principal',
promoting: 'Promotion…',
promoteConfirm:
'La branche « {branch} » deviendra le checkout principal du dépôt ; ce worktree sera supprimé (lancienne branche principale est conservée). Continuer ?',
forcePromote: 'Forcer (arbre modifié)',
main: 'principal', main: 'principal',
detached: 'détaché', detached: 'détaché',
locked: 'verrouillé', locked: 'verrouillé',
@@ -179,6 +192,8 @@ const fr: typeof en = {
'Cette session tourne hors dArboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.', 'Cette session tourne hors dArboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
fullscreen: 'Plein écran', fullscreen: 'Plein écran',
exitFullscreen: 'Quitter le plein écran', exitFullscreen: 'Quitter le plein écran',
maximize: 'Agrandir',
restore: 'Réduire',
}, },
push: { push: {
enable: 'Activer les notifications', enable: 'Activer les notifications',

View File

@@ -28,9 +28,9 @@ export interface CrossRepoResult {
/** Options de lancement d'une session de groupe (P6). */ /** Options de lancement d'une session de groupe (P6). */
export interface GroupFeatureOptions { export interface GroupFeatureOptions {
command: 'claude' | 'bash'; command: 'claude' | 'bash';
/** présent → crée le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */ /** présent → crée/réutilise le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
branch?: string; branch?: string;
newBranch?: boolean; /** branche de base d'une branche créée (défaut serveur : branche par défaut du dépôt). */
baseRef?: string; baseRef?: string;
} }
@@ -157,7 +157,7 @@ export const useGroupsStore = defineStore('groups', () => {
try { try {
const res = await wt.createWorktree(repoId, { const res = await wt.createWorktree(repoId, {
branch, branch,
newBranch: opts.newBranch ?? true, mode: 'auto', // détecte créer / checkout / suivi remote selon l'existence par dépôt
...(opts.baseRef ? { baseRef: opts.baseRef } : {}), ...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
startSession: null, startSession: null,
}); });
@@ -176,6 +176,11 @@ export const useGroupsStore = defineStore('groups', () => {
}), }),
); );
} }
// Mode feature : si AUCUN worktree n'a pu être créé/résolu, ne pas tenter la session (elle
// échouerait en « no resolvable worktree » et masquerait les vraies erreurs par dépôt).
if (opts.branch && worktreeResults.length > 0 && !worktreeResults.some((r) => r.status === 'ok')) {
return { worktreeResults, session: null, dirs: [], skipped: [] };
}
const res = await createGroupSession(groupId, { const res = await createGroupSession(groupId, {
command: opts.command, command: opts.command,
...(opts.branch ? { branch: opts.branch } : {}), ...(opts.branch ? { branch: opts.branch } : {}),

View File

@@ -4,12 +4,14 @@ import type {
CreateWorktreeRequest, CreateWorktreeRequest,
CreateWorktreeResponse, CreateWorktreeResponse,
DiscoverReposResponse, DiscoverReposResponse,
RepoBranchesResponse,
RepoResponse, RepoResponse,
ReposListResponse, ReposListResponse,
RepoSummary, RepoSummary,
SessionResponse, SessionResponse,
SessionSummary, SessionSummary,
StartRepoSessionRequest, StartRepoSessionRequest,
WorktreeResponse,
WorktreeSummary, WorktreeSummary,
WorktreesListResponse, WorktreesListResponse,
} from '@arboretum/shared'; } from '@arboretum/shared';
@@ -127,6 +129,32 @@ export const useWorktreesStore = defineStore('worktrees', () => {
await refreshRepoWorktrees(repoId); await refreshRepoWorktrees(repoId);
} }
/** Branches du repo (locales/remote + défaut) pour le sélecteur de branche de base. */
async function fetchBranches(repoId: string): Promise<RepoBranchesResponse> {
return api.get<RepoBranchesResponse>(`/api/v1/repos/${repoId}/branches`);
}
/** Commit (add -A + commit) dans un worktree ; le worktree mis à jour arrive aussi par WS. */
async function commitWorktree(repoId: string, path: string, message: string): Promise<WorktreeSummary> {
const res = await api.post<WorktreeResponse>(`/api/v1/repos/${repoId}/worktrees/commit`, { path, message });
upsertWorktree(res.worktree);
return res.worktree;
}
/** Push de la branche d'un worktree (upstream auto si absent). */
async function pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
const res = await api.post<WorktreeResponse>(`/api/v1/repos/${repoId}/worktrees/push`, { path });
upsertWorktree(res.worktree);
return res.worktree;
}
/** « Passer en principal » : la branche du worktree devient le checkout principal (worktree supprimé). */
async function promoteWorktree(repoId: string, path: string, force = false): Promise<void> {
await api.post<{ worktree: WorktreeSummary | null }>(`/api/v1/repos/${repoId}/worktrees/promote`, { path, force });
removeWorktreeLocal(path);
await refreshRepoWorktrees(repoId);
}
function startRealtime(): void { function startRealtime(): void {
unsubscribe ??= wsClient.subscribeWorktrees(onEvent); unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
} }
@@ -151,6 +179,10 @@ export const useWorktreesStore = defineStore('worktrees', () => {
startMainSession, startMainSession,
deleteWorktree, deleteWorktree,
prune, prune,
fetchBranches,
commitWorktree,
pushWorktree,
promoteWorktree,
startRealtime, startRealtime,
stopRealtime, stopRealtime,
}; };