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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<string> {
|
||||
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<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[]> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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(
|
||||
repoPath: string,
|
||||
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string },
|
||||
): Promise<void> {
|
||||
opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string },
|
||||
): 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'];
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<WorktreeManagerEvents> {
|
||||
|
||||
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<WorktreeManagerEvents> {
|
||||
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<WorktreeManagerEvents> {
|
||||
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<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`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
|
||||
|
||||
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
|
||||
@@ -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<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) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { path?: string; force?: string };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user