- 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.
178 lines
7.5 KiB
Vue
178 lines
7.5 KiB
Vue
<template>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<header class="flex items-center gap-2">
|
|
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
|
</header>
|
|
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
|
|
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
|
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.modeLabel') }}
|
|
<div class="flex flex-wrap gap-3">
|
|
<label class="flex items-center gap-1.5">
|
|
<input v-model="mode" type="radio" value="feature" :disabled="running" /> {{ t('crossRepo.modeFeature') }}
|
|
</label>
|
|
<label class="flex items-center gap-1.5">
|
|
<input v-model="mode" type="radio" value="main" :disabled="running" /> {{ t('crossRepo.modeMain') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
</template>
|
|
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.commandLabel') }}
|
|
<select v-model="command" class="input">
|
|
<option value="claude">claude</option>
|
|
<option value="bash">bash</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.reposLabel') }}
|
|
<div class="flex flex-col gap-1">
|
|
<div
|
|
v-for="repo in repos"
|
|
:key="repo.id"
|
|
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
|
>
|
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
|
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
|
{{ repoStatus(repo.id) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
|
|
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
|
</p>
|
|
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
|
{{ running ? t('crossRepo.launching') : t('crossRepo.launch') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
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[] }>();
|
|
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 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);
|
|
const errorMsg = ref<string | null>(null);
|
|
const running = ref(false);
|
|
|
|
const skippedById = computed<Record<string, string>>(() => {
|
|
const m: Record<string, string> = {};
|
|
for (const s of outcome.value?.skipped ?? []) m[s.repoId] = s.reason;
|
|
return m;
|
|
});
|
|
|
|
function repoStatus(repoId: string): string {
|
|
const wt = wtResults.value[repoId];
|
|
if (wt) return wt.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${wt.message ?? ''}`;
|
|
const skip = skippedById.value[repoId];
|
|
if (skip) return `${t('crossRepo.skippedLabel')}: ${skip}`;
|
|
return '';
|
|
}
|
|
function repoStatusClass(repoId: string): string {
|
|
const wt = wtResults.value[repoId];
|
|
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
|
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
|
}
|
|
|
|
async function onSubmit(): Promise<void> {
|
|
if (running.value || props.repos.length === 0) return;
|
|
if (mode.value === 'feature' && branch.value.trim() === '') return;
|
|
running.value = true;
|
|
wtResults.value = {};
|
|
outcome.value = null;
|
|
errorMsg.value = null;
|
|
try {
|
|
outcome.value = await groups.createGroupFeature(
|
|
props.groupId,
|
|
props.repos.map((r) => r.id),
|
|
{
|
|
command: command.value,
|
|
...(mode.value === 'feature'
|
|
? { branch: branch.value.trim(), ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
|
: {}),
|
|
},
|
|
(result) => {
|
|
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
|
},
|
|
);
|
|
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
|
|
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
|
if (outcome.value.session) {
|
|
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
|
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
|
else toasts.success(t('toast.groupSessionLaunched'));
|
|
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
|
|
}
|
|
} catch (err) {
|
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
running.value = false;
|
|
}
|
|
}
|
|
</script>
|