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

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

View File

@@ -21,21 +21,20 @@
</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'">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.branchLabel') }}
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
<datalist id="group-base-branches">
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
</datalist>
</label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
</label>
</div>
</template>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
@@ -78,11 +77,12 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
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 { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
@@ -91,12 +91,29 @@ const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const mode = ref<'feature' | 'main'>('feature');
const branch = ref('');
const newBranch = ref(true);
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 wtResults = ref<Record<string, CrossRepoResult>>({});
const outcome = ref<GroupFeatureOutcome | null>(null);
@@ -136,7 +153,7 @@ async function onSubmit(): Promise<void> {
{
command: command.value,
...(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) => {

View File

@@ -27,12 +27,19 @@
</div>
<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'">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
</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">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input">
@@ -41,7 +48,6 @@
<option value="bash">bash</option>
</select>
</label>
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
</template>
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
@@ -81,10 +87,10 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
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 { useWorktreeView } from '../composables/useWorktreeView';
import { useToastsStore } from '../stores/toasts';
@@ -103,13 +109,30 @@ const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id
const creating = ref(false);
const mode = ref<'worktree' | 'main'>('worktree');
const branch = ref('');
const newBranch = ref(true);
const baseRef = ref('');
const startSession = ref<'claude' | 'bash' | null>(null);
const mainBranchMode = ref<'current' | 'new'>('current');
const mainCommand = ref<'claude' | 'bash'>('claude');
const busy = ref(false);
const error = ref<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.
const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
@@ -120,7 +143,12 @@ async function onCreate(): Promise<void> {
error.value = null;
try {
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'));
} else {
// 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'));
}
branch.value = '';
baseRef.value = '';
creating.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);

View File

@@ -12,6 +12,14 @@
{{ t('groups.spanBadge', spannedCount) }}
</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>
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
<div class="min-h-0 flex-1">
@@ -23,13 +31,15 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Maximize2, Minimize2 } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.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 { t } = useI18n();

View File

@@ -2,19 +2,24 @@
<div class="flex flex-col gap-2">
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
<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 }) }}
</p>
<!-- 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
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
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
v-for="s in shown"
:key="s.id"
:session="s"
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
@toggle-maximize="toggleMax(s.id)"
/>
</div>
</template>
@@ -22,7 +27,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import TerminalCell from './TerminalCell.vue';
@@ -32,4 +37,14 @@ const { t } = useI18n();
const MAX_CELLS = 6;
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>

View File

@@ -62,6 +62,41 @@
</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) -->
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
</div>
@@ -135,6 +170,73 @@ const branchLabel = computed(() =>
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 {
confirming.value = false;
forceNeeded.value = false;

View File

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

View File

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

View File

@@ -28,9 +28,9 @@ export interface CrossRepoResult {
/** Options de lancement d'une session de groupe (P6). */
export interface GroupFeatureOptions {
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;
newBranch?: boolean;
/** branche de base d'une branche créée (défaut serveur : branche par défaut du dépôt). */
baseRef?: string;
}
@@ -157,7 +157,7 @@ export const useGroupsStore = defineStore('groups', () => {
try {
const res = await wt.createWorktree(repoId, {
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 } : {}),
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, {
command: opts.command,
...(opts.branch ? { branch: opts.branch } : {}),

View File

@@ -4,12 +4,14 @@ import type {
CreateWorktreeRequest,
CreateWorktreeResponse,
DiscoverReposResponse,
RepoBranchesResponse,
RepoResponse,
ReposListResponse,
RepoSummary,
SessionResponse,
SessionSummary,
StartRepoSessionRequest,
WorktreeResponse,
WorktreeSummary,
WorktreesListResponse,
} from '@arboretum/shared';
@@ -127,6 +129,32 @@ export const useWorktreesStore = defineStore('worktrees', () => {
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 {
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
}
@@ -151,6 +179,10 @@ export const useWorktreesStore = defineStore('worktrees', () => {
startMainSession,
deleteWorktree,
prune,
fetchBranches,
commitWorktree,
pushWorktree,
promoteWorktree,
startRealtime,
stopRealtime,
};