- 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.
220 lines
8.0 KiB
TypeScript
220 lines
8.0 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { ref } from 'vue';
|
||
import type {
|
||
CreateGroupRequest,
|
||
CreateGroupSessionRequest,
|
||
GroupResponse,
|
||
GroupSessionResponse,
|
||
GroupsListResponse,
|
||
GroupSummary,
|
||
RepoSummary,
|
||
SessionSummary,
|
||
UpdateGroupRequest,
|
||
WorktreeSummary,
|
||
} from '@arboretum/shared';
|
||
import { api, ApiError } from '../lib/api';
|
||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||
import { useWorktreesStore } from './worktrees';
|
||
import { useSessionsStore } from './sessions';
|
||
|
||
/** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */
|
||
export interface CrossRepoResult {
|
||
repoId: string;
|
||
status: 'ok' | 'error';
|
||
message?: string;
|
||
worktreePath?: string;
|
||
}
|
||
|
||
/** Options de lancement d'une session de groupe (P6). */
|
||
export interface GroupFeatureOptions {
|
||
command: 'claude' | 'bash';
|
||
/** présent → crée/réutilise le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
||
branch?: string;
|
||
/** branche de base d'une branche créée (défaut serveur : branche par défaut du dépôt). */
|
||
baseRef?: string;
|
||
}
|
||
|
||
/** Bilan d'un lancement de session de groupe. */
|
||
export interface GroupFeatureOutcome {
|
||
/** résultats de création de worktree par repo ([] en mode checkouts principaux). */
|
||
worktreeResults: CrossRepoResult[];
|
||
session: SessionSummary | null;
|
||
dirs: string[];
|
||
skipped: Array<{ repoId: string; reason: string }>;
|
||
}
|
||
|
||
export const useGroupsStore = defineStore('groups', () => {
|
||
const groups = ref<GroupSummary[]>([]);
|
||
const selectedGroupId = ref<string | null>(null);
|
||
const loading = ref(false);
|
||
const loadError = ref<string | null>(null);
|
||
let unsubscribe: (() => void) | null = null;
|
||
|
||
function upsert(group: GroupSummary): void {
|
||
const idx = groups.value.findIndex((g) => g.id === group.id);
|
||
if (idx >= 0) groups.value.splice(idx, 1, group);
|
||
else groups.value.push(group);
|
||
}
|
||
function removeLocal(id: string): void {
|
||
groups.value = groups.value.filter((g) => g.id !== id);
|
||
if (selectedGroupId.value === id) selectedGroupId.value = null;
|
||
}
|
||
function onEvent(e: GroupEvent): void {
|
||
if (e.type === 'group_update') upsert(e.group);
|
||
else removeLocal(e.groupId);
|
||
}
|
||
|
||
function byId(id: string): GroupSummary | undefined {
|
||
return groups.value.find((g) => g.id === id);
|
||
}
|
||
|
||
// ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ----
|
||
function reposInGroup(groupId: string): RepoSummary[] {
|
||
const g = byId(groupId);
|
||
if (!g) return [];
|
||
const wt = useWorktreesStore();
|
||
return g.repoIds
|
||
.map((id) => wt.repos.find((r) => r.id === id))
|
||
.filter((r): r is RepoSummary => r !== undefined);
|
||
}
|
||
function worktreesInGroup(groupId: string): WorktreeSummary[] {
|
||
const g = byId(groupId);
|
||
if (!g) return [];
|
||
const ids = new Set(g.repoIds);
|
||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||
}
|
||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||
// Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) },
|
||
// dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois.
|
||
const byId = new Map<string, SessionSummary>();
|
||
for (const w of worktreesInGroup(groupId)) for (const s of w.sessions) byId.set(s.id, s);
|
||
for (const s of useSessionsStore().sessions) if (s.groupId === groupId) byId.set(s.id, s);
|
||
return [...byId.values()];
|
||
}
|
||
|
||
async function fetchGroups(): Promise<void> {
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const res = await api.get<GroupsListResponse>('/api/v1/groups');
|
||
groups.value = res.groups;
|
||
} catch (err) {
|
||
loadError.value = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function createGroup(req: CreateGroupRequest): Promise<GroupSummary> {
|
||
const res = await api.post<GroupResponse>('/api/v1/groups', req);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function updateGroup(id: string, patch: UpdateGroupRequest): Promise<GroupSummary> {
|
||
const res = await api.patch<GroupResponse>(`/api/v1/groups/${id}`, patch);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function deleteGroup(id: string): Promise<void> {
|
||
await api.delete<{ ok: true }>(`/api/v1/groups/${id}`);
|
||
removeLocal(id);
|
||
}
|
||
async function addRepoToGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||
const res = await api.post<GroupResponse>(`/api/v1/groups/${groupId}/repos`, { repoId });
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function removeRepoFromGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||
const res = await api.delete<GroupResponse>(`/api/v1/groups/${groupId}/repos/${repoId}`);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
|
||
/** Lance UNE session Claude couvrant tous les repos du groupe (--add-dir côté serveur). */
|
||
async function createGroupSession(groupId: string, req: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||
// La session est upsertée dans le store sessions par le broadcast WS `session_update`.
|
||
return api.post<GroupSessionResponse>(`/api/v1/groups/${groupId}/session`, req);
|
||
}
|
||
|
||
/**
|
||
* Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie),
|
||
* crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante
|
||
* aux échecs partiels, feedback par repo — PUIS une seule session couvrant ces worktrees. En mode
|
||
* « checkouts principaux » (sans branch), la session couvre directement le worktree principal de chaque repo.
|
||
*/
|
||
async function createGroupFeature(
|
||
groupId: string,
|
||
repoIds: string[],
|
||
opts: GroupFeatureOptions,
|
||
onProgress?: (result: CrossRepoResult) => void,
|
||
): Promise<GroupFeatureOutcome> {
|
||
const wt = useWorktreesStore();
|
||
let worktreeResults: CrossRepoResult[] = [];
|
||
if (opts.branch) {
|
||
const branch = opts.branch;
|
||
worktreeResults = await Promise.all(
|
||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||
try {
|
||
const res = await wt.createWorktree(repoId, {
|
||
branch,
|
||
mode: 'auto', // détecte créer / checkout / suivi remote selon l'existence par dépôt
|
||
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
||
startSession: null,
|
||
});
|
||
const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path };
|
||
onProgress?.(result);
|
||
return result;
|
||
} catch (err) {
|
||
const result: CrossRepoResult = {
|
||
repoId,
|
||
status: 'error',
|
||
message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err),
|
||
};
|
||
onProgress?.(result);
|
||
return result;
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
// 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, {
|
||
command: opts.command,
|
||
...(opts.branch ? { branch: opts.branch } : {}),
|
||
});
|
||
return { worktreeResults, session: res.session, dirs: res.dirs, skipped: res.skipped };
|
||
}
|
||
|
||
function startRealtime(): void {
|
||
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||
}
|
||
function stopRealtime(): void {
|
||
unsubscribe?.();
|
||
unsubscribe = null;
|
||
}
|
||
|
||
return {
|
||
groups,
|
||
selectedGroupId,
|
||
loading,
|
||
loadError,
|
||
byId,
|
||
reposInGroup,
|
||
worktreesInGroup,
|
||
sessionsInGroup,
|
||
fetchGroups,
|
||
createGroup,
|
||
updateGroup,
|
||
deleteGroup,
|
||
addRepoToGroup,
|
||
removeRepoFromGroup,
|
||
createGroupSession,
|
||
createGroupFeature,
|
||
startRealtime,
|
||
stopRealtime,
|
||
};
|
||
});
|