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
|
// 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'" />
|
||||||
</label>
|
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
|
||||||
<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>
|
||||||
<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.baseRefLabel') }}
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
<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>
|
|
||||||
</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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 repository’s 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',
|
||||||
|
|||||||
@@ -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: 'créer la branche',
|
branchHint: 'Créé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é (l’ancienne 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 d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
'Cette session tourne hors d’Arboretum — 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',
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user