From fc50b41fd9d1fe02a5ae09d34cb61115a086ffc2 Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Tue, 26 May 2026 02:02:08 -0400 Subject: [PATCH 01/14] style(desktop): promote worktrees and submodules to header buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move worktree and submodule actions out of the BranchMenu "Advanced" accordion into standalone header buttons, matching tags/stash visibility. Removes the accordion disclosure pattern from BranchMenu entirely. ๐Ÿช„ Commit via GitWand --- apps/desktop/src/components/AppHeader.vue | 40 +++++-- .../src/components/header/BranchMenu.vue | 101 +----------------- apps/desktop/src/locales/en.ts | 2 +- apps/desktop/src/locales/fr.ts | 2 +- 4 files changed, 34 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/components/AppHeader.vue b/apps/desktop/src/components/AppHeader.vue index 1c0c9ee6..a2d79fe9 100644 --- a/apps/desktop/src/components/AppHeader.vue +++ b/apps/desktop/src/components/AppHeader.vue @@ -21,7 +21,7 @@ * - SyncSplitButton โ€” primary sync action (publish / push / pull / * sync / up-to-date) with a state-aware dropdown * - BranchMenu โ€” secondary branch-op menu (merge, rebase, - * rename, delete, rewind, worktrees, submodules) + * rename, delete, rewind) * * Anything still inline here is coupled specifically to the header * layout rather than a reusable slice: the merge-into picker (triggered @@ -295,12 +295,6 @@ function onBranchMenuDelete() { function onBranchMenuRewind() { openUndoPopover(); } -function onBranchMenuWorktrees() { - emit("openWorktrees"); -} -function onBranchMenuSubmodules() { - emit("openSubmodules"); -} // โ”€โ”€โ”€ Close popovers on click outside โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // BranchSelector owns its own click-outside handling now; we only need @@ -489,8 +483,6 @@ onUnmounted(() => document.removeEventListener("click", onDocClick, true)); @open-rename-modal="onBranchMenuRename" @open-delete-modal="onBranchMenuDelete" @open-rewind="onBranchMenuRewind" - @open-worktrees="onBranchMenuWorktrees" - @open-submodules="onBranchMenuSubmodules" @discard-all="emit('discardAll')" /> @@ -548,6 +540,36 @@ onUnmounted(() => document.removeEventListener("click", onDocClick, true)); {{ t('tags.title') }} + + + + + +
diff --git a/apps/desktop/src/components/header/BranchMenu.vue b/apps/desktop/src/components/header/BranchMenu.vue index 0e4c7bfe..9be45cf9 100644 --- a/apps/desktop/src/components/header/BranchMenu.vue +++ b/apps/desktop/src/components/header/BranchMenu.vue @@ -3,7 +3,7 @@ * BranchMenu โ€” secondary "Branch" dropdown in the header. * * Groups all branch-level operations that used to live as individual header - * buttons (merge, worktrees, submodules, rebase, rewind) plus two new ones + * buttons (merge, rebase, rewind) plus two new ones * (rename, delete) behind a single dropdown. Keeps the header uncluttered * and gives each action a full label instead of a mystery icon. * @@ -16,9 +16,6 @@ * BranchMenu just signals intent and closes. We used to do an inline * rename panel + window.confirm for delete, but both were pulled out * for a consistent modal UX with a type-the-name guard on delete. - * - "Advanced" expands to reveal Worktrees + Submodules. We avoid a real - * submenu (flyout positioning is fiddly with overflow) and just - * disclose inline, accordion-style. * * All labels come from the `branchMenu.*` locale group. */ @@ -44,8 +41,6 @@ const emit = defineEmits<{ /** User clicked "Deleteโ€ฆ" โ€” parent should pop the delete modal. */ openDeleteModal: []; openRewind: []; - openWorktrees: []; - openSubmodules: []; discardAll: []; }>(); @@ -53,21 +48,16 @@ const { t } = useI18n(); // โ”€โ”€โ”€ Menu open/close โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const showMenu = ref(false); -const showAdvanced = ref(false); const wrapperRef = ref(null); function toggleMenu() { if (props.disabled) return; showMenu.value = !showMenu.value; - if (!showMenu.value) { - showAdvanced.value = false; - } } function closeMenu() { showMenu.value = false; - showAdvanced.value = false; } function onDocClick(ev: MouseEvent) { @@ -80,12 +70,6 @@ function onDocClick(ev: MouseEvent) { function onEsc(ev: KeyboardEvent) { if (ev.key !== "Escape" || !showMenu.value) return; - // Escape unwinds one level at a time: if the Advanced accordion is - // open, close it first; next Escape closes the whole menu. - if (showAdvanced.value) { - showAdvanced.value = false; - return; - } closeMenu(); } @@ -115,16 +99,6 @@ function onRewind() { emit("openRewind"); } -function onWorktrees() { - closeMenu(); - emit("openWorktrees"); -} - -function onSubmodules() { - closeMenu(); - emit("openSubmodules"); -} - function onRenameClick() { if (!props.currentBranch) return; closeMenu(); @@ -253,53 +227,6 @@ const hasBranch = computed(() => !!props.currentBranch); {{ t('branchMenu.rewind') }} - - - - -
- - -
@@ -387,30 +314,4 @@ const hasBranch = computed(() => !!props.currentBranch); background: var(--color-border); margin: var(--space-2) var(--space-3); } - -.branch-menu__item--toggle { - justify-content: flex-start; -} - -.branch-menu__item-chevron { - margin-left: auto; - transition: transform var(--transition-base); - opacity: 0.7; -} -.branch-menu__item-chevron--open { - transform: rotate(180deg); -} - -.branch-menu__nested { - display: flex; - flex-direction: column; - padding-left: var(--space-3); - margin-top: var(--space-1); - border-left: 1px solid var(--color-border); - margin-left: var(--space-4); -} - -.branch-menu__item--nested { - padding: var(--space-2) var(--space-4); -} diff --git a/apps/desktop/src/locales/en.ts b/apps/desktop/src/locales/en.ts index ac587109..3bd38c55 100644 --- a/apps/desktop/src/locales/en.ts +++ b/apps/desktop/src/locales/en.ts @@ -1787,7 +1787,7 @@ const en = { title: "Git workflow", intro: "GitWand supports a full Git workflow from a single window.", branchTitle: "Branches", - branch: "Create, switch, rename and delete branches from the branch chip in the header. The branch menu (\u2261) gives access to merge, rebase, and worktrees.", + branch: "Create, switch, rename and delete branches from the branch chip in the header. Use the branch menu (\u2261) for merge and rebase, and the standalone Worktrees and Submodules buttons for advanced repository management.", stageTitle: "Staging", stage: "Stage individual files, entire directories, or specific hunks. Right-click a file for contextual options including discard and add to .gitignore.", historyTitle: "History", diff --git a/apps/desktop/src/locales/fr.ts b/apps/desktop/src/locales/fr.ts index f78c3b6c..1f84b2ce 100644 --- a/apps/desktop/src/locales/fr.ts +++ b/apps/desktop/src/locales/fr.ts @@ -1760,7 +1760,7 @@ const fr: Locale = { title: "Workflow Git", intro: "GitWand supporte un workflow Git complet depuis une seule fenรชtre.", branchTitle: "Branches", - branch: "Crรฉez, changez, renommez et supprimez des branches depuis le sรฉlecteur de branche dans le header. Le menu branche (โ‰ก) donne accรจs aux opรฉrations merge, rebase et worktrees.", + branch: "Crรฉez, changez, renommez et supprimez des branches depuis le sรฉlecteur de branche dans le header. Utilisez le menu branche (โ‰ก) pour le merge et le rebase, et les boutons Worktrees et Submodules pour la gestion avancรฉe du dรฉpรดt.", stageTitle: "Indexation", stage: "Indexez des fichiers individuels, des rรฉpertoires entiers ou des hunks spรฉcifiques. Faites un clic droit sur un fichier pour les options contextuelles (annuler, ajouter au .gitignore, etc.).", historyTitle: "Historique", From 5c583ce24b62a5d7b5fee02d45ca9af013a3876b Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Tue, 26 May 2026 02:12:15 -0400 Subject: [PATCH 02/14] style(desktop): center-align BaseModal header and normalize padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿช„ Commit via GitWand --- apps/desktop/src/components/BaseModal.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/BaseModal.vue b/apps/desktop/src/components/BaseModal.vue index 944b7340..3bf16029 100644 --- a/apps/desktop/src/components/BaseModal.vue +++ b/apps/desktop/src/components/BaseModal.vue @@ -194,9 +194,9 @@ onUnmounted(() => { /* โ”€โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .base-modal__header { display: flex; - align-items: flex-start; + align-items: center; gap: var(--space-4); - padding: var(--space-6) var(--space-7) var(--space-5); + padding: var(--space-5) var(--space-7); border-bottom: 1px solid var(--color-border); } From 85a74677d840ca922d8eb75df15a2ad62a43e2be Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Tue, 26 May 2026 02:22:18 -0400 Subject: [PATCH 03/14] style(desktop): Remove worktree cleanup/prune and relocate 'New Worktree' button This change streamlines the worktree management interface by removing the dedicated cleanup and prune functionalities. The primary 'New Worktree' action is now repositioned to the modal footer with a distinct style for improved prominence and user experience. --- .../src/components/WorktreeManager.vue | 157 +++++------------- 1 file changed, 44 insertions(+), 113 deletions(-) diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index 67c885c0..dc3c790f 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -97,43 +97,6 @@ const confirmRemovePath = ref(null); const forceRemove = ref(false); const removing = ref(false); -// โ”€โ”€ Cleanup (merged worktrees) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const showCleanup = ref(false); -const cleanupSelected = ref>(new Set()); -const cleaningUp = ref(false); - -/** Non-main, non-locked worktrees with ahead === 0 (safe to discard). */ -const cleanupCandidates = computed(() => - worktrees.value.filter((wt) => { - if (wt.is_main || wt.is_locked) return false; - const st = statusFor(wt.path); - return st ? st.ahead === 0 : false; - }) -); - -function toggleCleanup(path: string) { - const s = new Set(cleanupSelected.value); - s.has(path) ? s.delete(path) : s.add(path); - cleanupSelected.value = s; -} - -async function doCleanup() { - if (cleanupSelected.value.size === 0) return; - cleaningUp.value = true; - error.value = null; - for (const path of cleanupSelected.value) { - try { - await gitWorktreeRemove(props.cwd, path, false); - } catch (err: any) { - error.value = t("worktree.errorRemove").replace("{0}", String(err?.message ?? err)); - } - } - cleanupSelected.value = new Set(); - showCleanup.value = false; - cleaningUp.value = false; - await loadWorktrees(); -} - // โ”€โ”€ Core actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadWorktrees() { @@ -197,16 +160,6 @@ async function confirmRemove() { } } -async function prune() { - error.value = null; - try { - await gitWorktreePrune(props.cwd); - await loadWorktrees(); - } catch (err: any) { - error.value = t("worktree.errorPrune").replace("{0}", String(err?.message ?? err)); - } -} - function shortPath(path: string): string { const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); return parts.length <= 2 ? path : "โ€ฆ/" + parts.slice(-2).join("/"); @@ -249,39 +202,15 @@ onMounted(async () => { @@ -367,47 +296,6 @@ onMounted(async () => { - -
-
- {{ t("worktree.cleanupTitle") }} - {{ t("worktree.cleanupEmpty") }} -
-
- {{ t("worktree.cleanupEmpty") }} -
-
- -
- - -
-
-
-
{{ error }}
@@ -491,6 +379,18 @@ onMounted(async () => { + + + @@ -834,4 +734,35 @@ onMounted(async () => { color: var(--color-accent); border-color: var(--color-accent); } + +.wt-footer-add-btn { + width: 100%; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + background: var(--color-accent); + color: var(--color-accent-text); + border: none; + border-radius: var(--radius-md); + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: background var(--transition-base), transform var(--transition-fast); +} + +.wt-footer-add-btn:hover { + background: var(--color-accent-hover); +} + +.wt-footer-add-btn:active { + transform: translateY(1px); +} + +.wt-footer-add-btn__icon { + font-size: 16px; + font-weight: var(--font-weight-medium); + margin-top: -1px; +} From ed34271c90c822a23e1e1802f60c382a0022928f Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Tue, 26 May 2026 02:22:45 -0400 Subject: [PATCH 04/14] style(desktop): Remove worktree cleanup panel and prune API import --- .../src/components/WorktreeManager.vue | 70 +------------------ 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index dc3c790f..6a6d5149 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -4,7 +4,6 @@ import { gitWorktreeList, gitWorktreeAdd, gitWorktreeRemove, - gitWorktreePrune, gitWorktreeStatusAll, type WorktreeEntry, type WorkspaceRepoStatus, @@ -385,7 +384,7 @@ onMounted(async () => { + @@ -264,11 +298,12 @@ onMounted(async () => {
@@ -295,6 +330,14 @@ onMounted(async () => {
+ +
+ {{ t("worktree.prunableAlert") }} + +
+
{{ error }}
@@ -332,12 +375,21 @@ onMounted(async () => { {{ t("worktree.statusTitle") }}โ€ฆ -
+
{{ t("worktree.main") }} - {{ t("worktree.locked") }} + ๐Ÿ”’ {{ t("worktree.locked") }} {{ t("worktree.bare") }} + {{ t("worktree.prunable") }}
โ†‘{{ st!.ahead }} - โ†“{{ st!.behind }} + + โš  {{ st!.conflicted }} + + + {{ t("worktree.noUpstreamShort") }} + ~{{ st!.modified }} - โœ“ + + โœ“ โš 
@@ -362,6 +422,7 @@ onMounted(async () => {
+ + +
+ {{ t("worktree.onlyMainHint") }} +
@@ -554,6 +623,18 @@ onMounted(async () => { font-style: italic; } +.wt-only-main-hint { + margin-top: var(--space-4); + padding: var(--space-4) var(--space-5); + background: var(--color-surface-raised, var(--color-bg-subtle)); + border: 1px dashed var(--color-border-muted, var(--color-border)); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-muted); + text-align: center; + line-height: 1.5; +} + /* โ”€โ”€ List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .wt-list { flex: 1; @@ -659,6 +740,7 @@ onMounted(async () => { .wt-pill-clean { background: rgba(72, 187, 120, 0.15); color: #48bb78; } .wt-pill-muted { background: var(--color-bg-tertiary); color: var(--color-text-muted); } .wt-pill-error { background: var(--color-danger-soft); color: var(--color-danger); cursor: help; } +.wt-pill-conflict { background: var(--color-danger-soft); color: var(--color-danger); font-weight: var(--font-weight-semibold); } /* โ”€โ”€ Active ghost button state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .bm-btn--active { @@ -667,9 +749,35 @@ onMounted(async () => { border-color: var(--color-accent); } +/* โ”€โ”€ Prunable alert banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.wt-alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-7); + background: var(--color-warning-soft, rgba(245, 158, 11, 0.1)); + color: var(--color-warning, #f59e0b); + font-size: var(--font-size-sm); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +/* โ”€โ”€ Prunable badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.badge-prunable { + background: var(--color-danger-soft); + color: var(--color-danger); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + padding: 1px var(--space-2); + border-radius: var(--radius-pill); + cursor: help; +} + +/* โ”€โ”€ Footer add button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .wt-footer-add-btn { width: 100%; - height: 40px; + height: var(--space-10); display: flex; align-items: center; justify-content: center; @@ -693,7 +801,7 @@ onMounted(async () => { } .wt-footer-add-btn__icon { - font-size: 16px; + font-size: var(--font-size-md); font-weight: var(--font-weight-medium); margin-top: -1px; } diff --git a/apps/desktop/src/locales/en.ts b/apps/desktop/src/locales/en.ts index 3bd38c55..978096a1 100644 --- a/apps/desktop/src/locales/en.ts +++ b/apps/desktop/src/locales/en.ts @@ -1496,6 +1496,16 @@ const en = { cleanupConfirm: "Remove {0} merged worktree(s)?", cleanupAction: "Clean up", statusTitle: "Worktree status", + pruning: "Pruningโ€ฆ", + prunable: "Stale", + prunableTooltip: "This worktree's directory no longer exists on disk", + prunableAlert: "Some worktrees have stale metadata. Prune them to clean up.", + noUpstream: "No remote configured for this branch", + noUpstreamShort: "no remote", + conflicted: "Files in conflict", + localBranches: "Local branches", + remoteBranches: "Remote tracking branches", + onlyMainHint: "No additional worktrees yet. Use โšก New task to work on another branch in parallel without switching.", }, // โ”€โ”€โ”€ Automations (v2.8) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/apps/desktop/src/locales/es.ts b/apps/desktop/src/locales/es.ts index 19f638c9..04337a56 100644 --- a/apps/desktop/src/locales/es.ts +++ b/apps/desktop/src/locales/es.ts @@ -1454,6 +1454,16 @@ const es: Locale = { cleanupConfirm: "ยฟEliminar {0} worktree(s) fusionado(s)?", cleanupAction: "Limpiar", statusTitle: "Estado de los worktrees", + pruning: "Limpiandoโ€ฆ", + prunable: "Obsoleto", + prunableTooltip: "El directorio de este worktree ya no existe en el disco", + prunableAlert: "Algunos worktrees tienen metadatos obsoletos. Lรญmpialos.", + noUpstream: "Sin remoto configurado para esta rama", + noUpstreamShort: "sin remoto", + conflicted: "Archivos en conflicto", + localBranches: "Ramas locales", + remoteBranches: "Ramas de seguimiento remoto", + onlyMainHint: "Aรบn no hay worktrees adicionales. Usa โšก Nueva tarea para trabajar en otra rama en paralelo sin cambiar de contexto.", }, diff --git a/apps/desktop/src/locales/fr.ts b/apps/desktop/src/locales/fr.ts index 1f84b2ce..bc01539c 100644 --- a/apps/desktop/src/locales/fr.ts +++ b/apps/desktop/src/locales/fr.ts @@ -1437,7 +1437,7 @@ const fr: Locale = { remove: "Supprimer", removeConfirm: "Supprimer le worktree \u00ab\u00a0{0}\u00a0\u00bb\u00a0? Les changements non commit\u00e9s seront perdus.", removeForce: "Forcer la suppression (abandonner les changements)", - prune: "Nettoyer", + prune: "ร‰laguer", pruneTooltip: "Supprimer les fichiers administratifs des worktrees dont le dossier n\u2019existe plus", newWorktree: "Nouveau worktree", formPath: "Chemin du dossier", @@ -1466,6 +1466,16 @@ const fr: Locale = { cleanupConfirm: "Supprimer {0} worktree(s) mergรฉ(s)ย ?", cleanupAction: "Nettoyer", statusTitle: "Statut des worktrees", + pruning: "ร‰lagageโ€ฆ", + prunable: "Obsolรจte", + prunableTooltip: "Le rรฉpertoire de ce worktree n'existe plus sur le disque", + prunableAlert: "Des worktrees ont des mรฉtadonnรฉes obsolรจtes. ร‰laguez-les pour nettoyer.", + noUpstream: "Aucun remote configurรฉ pour cette branche", + noUpstreamShort: "sans remote", + conflicted: "Fichiers en conflit", + localBranches: "Branches locales", + remoteBranches: "Branches remote de suivi", + onlyMainHint: "Aucun worktree supplรฉmentaire pour l'instant. Utilisez โšก Nouvelle tรขche pour travailler sur une autre branche en parallรจle sans changer de contexte.", }, diff --git a/apps/desktop/src/locales/pt-BR.ts b/apps/desktop/src/locales/pt-BR.ts index 832f0717..927282c1 100644 --- a/apps/desktop/src/locales/pt-BR.ts +++ b/apps/desktop/src/locales/pt-BR.ts @@ -1454,6 +1454,16 @@ const ptBR: Locale = { cleanupConfirm: "Remover {0} worktree(s) mesclado(s)?", cleanupAction: "Limpar", statusTitle: "Status dos worktrees", + pruning: "Limpandoโ€ฆ", + prunable: "Obsoleto", + prunableTooltip: "O diretรณrio deste worktree nรฃo existe mais no disco", + prunableAlert: "Alguns worktrees possuem metadados obsoletos. Limpe-os.", + noUpstream: "Sem remoto configurado para este branch", + noUpstreamShort: "sem remoto", + conflicted: "Arquivos em conflito", + localBranches: "Branches locais", + remoteBranches: "Branches de rastreamento remoto", + onlyMainHint: "Nenhum worktree adicional ainda. Use โšก Nova tarefa para trabalhar em outro branch em paralelo sem trocar de contexto.", }, diff --git a/apps/desktop/src/locales/zh-CN.ts b/apps/desktop/src/locales/zh-CN.ts index 28aa5a92..09fb7077 100644 --- a/apps/desktop/src/locales/zh-CN.ts +++ b/apps/desktop/src/locales/zh-CN.ts @@ -1439,6 +1439,16 @@ const zhCN: Locale = { cleanupConfirm: "็งป้™ค {0} ไธชๅทฒๅˆๅนถ็š„ worktree๏ผŸ", cleanupAction: "ๆธ…็†", statusTitle: "Worktree ็Šถๆ€", + pruning: "ๆธ…็†ไธญโ€ฆ", + prunable: "ๅทฒ่ฟ‡ๆœŸ", + prunableTooltip: "ๆญค worktree ็š„็›ฎๅฝ•ๅœจ็ฃ็›˜ไธŠๅทฒไธๅญ˜ๅœจ", + prunableAlert: "้ƒจๅˆ† worktree ๅญ˜ๅœจ่ฟ‡ๆœŸๅ…ƒๆ•ฐๆฎ๏ผŒ่ฏทๆ‰ง่กŒๆธ…็†ใ€‚", + noUpstream: "ๆญคๅˆ†ๆ”ฏๆœช้…็ฝฎ่ฟœ็จ‹่ทŸ่ธช", + noUpstreamShort: "ๆ— ่ฟœ็จ‹", + conflicted: "ๅญ˜ๅœจๅ†ฒ็ชๆ–‡ไปถ", + localBranches: "ๆœฌๅœฐๅˆ†ๆ”ฏ", + remoteBranches: "่ฟœ็จ‹่ทŸ่ธชๅˆ†ๆ”ฏ", + onlyMainHint: "ๆš‚ๆ— ๅ…ถไป–ๅทฅไฝœๆ ‘ใ€‚ไฝฟ็”จ โšก ๆ–ฐๅปบไปปๅŠกๅœจไธๅˆ‡ๆขไธŠไธ‹ๆ–‡็š„ๆƒ…ๅ†ตไธ‹ๅนถ่กŒๅค„็†ๅ…ถไป–ๅˆ†ๆ”ฏใ€‚", }, diff --git a/apps/desktop/src/utils/backend.ts b/apps/desktop/src/utils/backend.ts index 9ca51088..3a1e2bbc 100644 --- a/apps/desktop/src/utils/backend.ts +++ b/apps/desktop/src/utils/backend.ts @@ -2157,7 +2157,9 @@ export interface WorkspaceRepoStatus { branch: string; ahead: number; behind: number; + has_upstream: boolean; modified: number; + conflicted: number; error: string | null; } @@ -2426,7 +2428,10 @@ export interface WorktreeEntry { head: string; is_main: boolean; is_locked: boolean; + lock_reason: string | null; is_bare: boolean; + is_prunable: boolean; + prunable_reason: string | null; } /** List all git worktrees for the given repo. */ @@ -2491,6 +2496,20 @@ export async function gitWorktreePrune(cwd: string): Promise { if (!res.ok) throw new Error(`Failed to prune worktrees: ${res.status}`); } +/** Repair worktree administrative files after a manual move or copy of the repo. */ +export async function gitWorktreeRepair(cwd: string, paths?: string[]): Promise { + if (isTauri()) { + await tauriInvoke("git_worktree_repair", { cwd, paths: paths ?? [] }); + return; + } + const res = await devFetch(`${DEV_SERVER}/api/git-worktree-repair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd, paths: paths ?? [] }), + }); + if (!res.ok) throw new Error(`Failed to repair worktrees: ${res.status}`); +} + // โ”€โ”€โ”€ Agent Sessions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export interface AgentSession { From f165efaed4598092a69ea3d2ca920be97702cdda Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 28 May 2026 17:27:46 +0200 Subject: [PATCH 06/14] test(desktop): add unit tests for worktree porcelain parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract pure parsing helpers from ops.rs into a TS module so the porcelain, status, and quick-path derivation logic can be covered by vitest without a real git repo or Tauri runtime. ๐Ÿช„ Commit via GitWand --- .../src/__tests__/worktreeParser.test.ts | 378 ++++++++++++++++++ apps/desktop/src/utils/worktreeParser.ts | 161 ++++++++ 2 files changed, 539 insertions(+) create mode 100644 apps/desktop/src/__tests__/worktreeParser.test.ts create mode 100644 apps/desktop/src/utils/worktreeParser.ts diff --git a/apps/desktop/src/__tests__/worktreeParser.test.ts b/apps/desktop/src/__tests__/worktreeParser.test.ts new file mode 100644 index 00000000..1eee5972 --- /dev/null +++ b/apps/desktop/src/__tests__/worktreeParser.test.ts @@ -0,0 +1,378 @@ +/** + * Worktree parser โ€” jeu de tests complet + * + * Couvre les fonctions pures de `utils/worktreeParser.ts` qui reflรจtent + * la logique Rust de `src-tauri/src/commands/ops.rs`. + * + * Fixtures basรฉes sur un dรฉpรดt fictif (/home/user/projects/myrepo). + * Aucune dรฉpendance rรฉseau, aucun Tauri : tests purement unitaires (vitest). + * + * Scรฉnarios couverts : + * 1. Worktree principal seul (git โ‰ฅ 2.36, attribut `main`) + * 2. Worktree principal + 1 worktree additionnel + * 3. Attribut `locked` sans raison + * 4. Attribut `locked` avec raison inline + * 5. Attribut `prunable` sans raison + * 6. Attribut `prunable` avec raison inline + * 7. HEAD dรฉtachรฉ (detached HEAD) + * 8. Bare worktree + * 9. Fallback git < 2.36 (pas d'attribut `main` โ†’ premier = main) + * 10. Sortie vide โ†’ tableau vide + * 11. Plusieurs worktrees, ordre prรฉservรฉ + * 12. Branche refs/heads/ strippรฉe correctement + * 13. Status : zรฉro ligne โ†’ 0/0 + * 14. Status : uniquement modifiรฉs + * 15. Status : uniquement conflits (UU, AA, DD, AU, UA, DU, UD) + * 16. Status : mรฉlange conflits + modifiรฉs + * 17. Status : ligne vide ignorรฉe + * 18. deriveQuickWorktreePath : nom simple + * 19. deriveQuickWorktreePath : nom avec slash (fix/login) + * 20. deriveQuickWorktreePath : nom avec caractรจres spรฉciaux sanitisรฉs + * 21. hasPrunableWorktrees : aucun prunable + * 22. hasPrunableWorktrees : un prunable + */ + +import { describe, expect, it } from "vitest"; +import { + parseWorktreePorcelain, + parseWorktreeStatus, + deriveQuickWorktreePath, + hasPrunableWorktrees, + CONFLICT_CODES, +} from "../utils/worktreeParser"; + +// โ”€โ”€โ”€ Chemins de rรฉfรฉrence (repo fictif pour les tests) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const TURBULLES = "/home/user/projects/myrepo"; +const TURBULLES_FIX = "/home/user/projects/myrepo-fix-login"; +const TURBULLES_FEAT = "/home/user/projects/myrepo-feat-dashboard"; +const TURBULLES_HEAD = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Indente un porcelain brut comme git le fait (ligne vide entre stanzas). */ +function porcelain(...stanzas: string[]): string { + return stanzas.join("\n\n") + "\n"; +} + +function mainStanza(path = TURBULLES, head = TURBULLES_HEAD, branch = "master"): string { + return `worktree ${path}\nHEAD ${head}\nbranch refs/heads/${branch}\nmain`; +} + +function wtStanza(path: string, branch: string, head = "aabbcc0011223344556677889900aabbcc001122"): string { + return `worktree ${path}\nHEAD ${head}\nbranch refs/heads/${branch}`; +} + +// โ”€โ”€โ”€ 1. parseWorktreePorcelain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("parseWorktreePorcelain", () => { + + // โ”€โ”€ Cas 1 : Worktree principal seul (git โ‰ฅ 2.36) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 1 โ€” worktree principal seul (attribut main prรฉsent)", () => { + const raw = porcelain(mainStanza()); + const result = parseWorktreePorcelain(raw); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + path: TURBULLES, + branch: "master", + head: TURBULLES_HEAD, + is_main: true, + is_locked: false, + lock_reason: null, + is_bare: false, + is_prunable: false, + prunable_reason: null, + }); + }); + + // โ”€โ”€ Cas 2 : Principal + 1 worktree additionnel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 2 โ€” principal + un worktree additionnel", () => { + const raw = porcelain( + mainStanza(), + wtStanza(TURBULLES_FIX, "fix/login-bug"), + ); + const result = parseWorktreePorcelain(raw); + + expect(result).toHaveLength(2); + + expect(result[0].is_main).toBe(true); + expect(result[0].path).toBe(TURBULLES); + expect(result[0].branch).toBe("master"); + + expect(result[1].is_main).toBe(false); + expect(result[1].path).toBe(TURBULLES_FIX); + expect(result[1].branch).toBe("fix/login-bug"); + }); + + // โ”€โ”€ Cas 3 : locked sans raison โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 3 โ€” attribut locked sans raison", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/fix/auth\nlocked`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].is_locked).toBe(true); + expect(result[1].lock_reason).toBeNull(); + }); + + // โ”€โ”€ Cas 4 : locked avec raison inline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 4 โ€” attribut locked avec raison inline", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/fix/auth\nlocked added manually by CI pipeline`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].is_locked).toBe(true); + expect(result[1].lock_reason).toBe("added manually by CI pipeline"); + }); + + // โ”€โ”€ Cas 5 : prunable sans raison โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 5 โ€” attribut prunable sans raison", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/fix/auth\nprunable`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].is_prunable).toBe(true); + expect(result[1].prunable_reason).toBeNull(); + }); + + // โ”€โ”€ Cas 6 : prunable avec raison inline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 6 โ€” attribut prunable avec raison inline", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/fix/auth\nprunable gitdir file points to non-existent location`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].is_prunable).toBe(true); + expect(result[1].prunable_reason).toBe("gitdir file points to non-existent location"); + }); + + // โ”€โ”€ Cas 7 : detached HEAD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 7 โ€” detached HEAD", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\ndetached`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].branch).toBe("(detached HEAD)"); + }); + + // โ”€โ”€ Cas 8 : bare worktree โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 8 โ€” bare worktree", () => { + const raw = `worktree /srv/git/turbulles.git\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/master\nbare\n`; + const result = parseWorktreePorcelain(raw); + + expect(result[0].is_bare).toBe(true); + }); + + // โ”€โ”€ Cas 9 : fallback git < 2.36 (pas d'attribut `main`) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 9 โ€” fallback git < 2.36 : premier worktree marquรฉ main", () => { + // Pas de ligne "main" dans la sortie (git < 2.36) + const raw = porcelain( + `worktree ${TURBULLES}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/master`, + wtStanza(TURBULLES_FIX, "fix/auth"), + ); + const result = parseWorktreePorcelain(raw); + + expect(result[0].is_main).toBe(true); + expect(result[1].is_main).toBe(false); + }); + + // โ”€โ”€ Cas 10 : sortie vide โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 10 โ€” sortie vide โ†’ tableau vide", () => { + expect(parseWorktreePorcelain("")).toEqual([]); + expect(parseWorktreePorcelain("\n\n")).toEqual([]); + }); + + // โ”€โ”€ Cas 11 : plusieurs worktrees, ordre prรฉservรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 11 โ€” trois worktrees, ordre prรฉservรฉ", () => { + const raw = porcelain( + mainStanza(), + wtStanza(TURBULLES_FIX, "fix/login-bug"), + wtStanza(TURBULLES_FEAT, "feat/dashboard"), + ); + const result = parseWorktreePorcelain(raw); + + expect(result).toHaveLength(3); + expect(result.map((e) => e.path)).toEqual([TURBULLES, TURBULLES_FIX, TURBULLES_FEAT]); + }); + + // โ”€โ”€ Cas 12 : refs/heads/ strippรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("cas 12 โ€” prรฉfixe refs/heads/ strippรฉ", () => { + const raw = `worktree ${TURBULLES}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/feature/super-long-branch-name\nmain\n`; + const result = parseWorktreePorcelain(raw); + + expect(result[0].branch).toBe("feature/super-long-branch-name"); + }); + + // โ”€โ”€ Cas bonus : locked + prunable simultanรฉment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("bonus โ€” locked et prunable simultanรฉment", () => { + const raw = porcelain( + mainStanza(), + `worktree ${TURBULLES_FIX}\nHEAD ${TURBULLES_HEAD}\nbranch refs/heads/fix/auth\nlocked CI lock\nprunable directory gone`, + ); + const result = parseWorktreePorcelain(raw); + + expect(result[1].is_locked).toBe(true); + expect(result[1].lock_reason).toBe("CI lock"); + expect(result[1].is_prunable).toBe(true); + expect(result[1].prunable_reason).toBe("directory gone"); + }); +}); + +// โ”€โ”€โ”€ 2. parseWorktreeStatus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("parseWorktreeStatus", () => { + + it("cas 13 โ€” sortie vide โ†’ 0 conflits, 0 modifiรฉs", () => { + expect(parseWorktreeStatus("")).toEqual({ conflicted: 0, modified: 0 }); + }); + + it("cas 14 โ€” uniquement fichiers modifiรฉs", () => { + const raw = [ + " M src/components/WorktreeManager.vue", + "M apps/desktop/src/utils/backend.ts", + "A apps/desktop/src/utils/worktreeParser.ts", + ].join("\n"); + expect(parseWorktreeStatus(raw)).toEqual({ conflicted: 0, modified: 3 }); + }); + + it("cas 15 โ€” uniquement conflits (tous les codes)", () => { + const conflictLines = [...CONFLICT_CODES].map((code) => `${code} path/to/file-${code}.ts`).join("\n"); + const result = parseWorktreeStatus(conflictLines); + expect(result.conflicted).toBe(CONFLICT_CODES.size); // 7 codes + expect(result.modified).toBe(0); + }); + + it("cas 15b โ€” code UU seul (le plus courant)", () => { + const raw = "UU src/conflict.ts\nUU src/another.ts"; + expect(parseWorktreeStatus(raw)).toEqual({ conflicted: 2, modified: 0 }); + }); + + it("cas 16 โ€” mรฉlange conflits + modifiรฉs", () => { + const raw = [ + "UU src/models/user.ts", // conflit + " M src/views/Login.vue", // modifiรฉ + "AA CHANGELOG.md", // conflit + "M package.json", // modifiรฉ + "DU src/old-file.ts", // conflit + ].join("\n"); + expect(parseWorktreeStatus(raw)).toEqual({ conflicted: 3, modified: 2 }); + }); + + it("cas 17 โ€” lignes vides ignorรฉes", () => { + const raw = "\n M src/file.ts\n\nUU src/conflict.ts\n\n"; + expect(parseWorktreeStatus(raw)).toEqual({ conflicted: 1, modified: 1 }); + }); +}); + +// โ”€โ”€โ”€ 3. deriveQuickWorktreePath โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("deriveQuickWorktreePath", () => { + + it("cas 18 โ€” nom simple", () => { + expect(deriveQuickWorktreePath(TURBULLES, "fix-auth")).toBe( + `${TURBULLES}-fix-auth`, + ); + }); + + it("cas 19 โ€” nom avec slash (fix/login)", () => { + expect(deriveQuickWorktreePath(TURBULLES, "fix/login")).toBe( + `${TURBULLES}-fix/login`, + ); + }); + + it("cas 20 โ€” caractรจres spรฉciaux sanitisรฉs", () => { + const result = deriveQuickWorktreePath(TURBULLES, "Fix Auth Bug!!!"); + // Lettres majuscules conservรฉes, espaces et ! โ†’ tirets fusionnรฉs + expect(result).toBe(`${TURBULLES}-Fix-Auth-Bug`); + }); + + it("cas 20b โ€” tirets redondants fusionnรฉs", () => { + const result = deriveQuickWorktreePath(TURBULLES, "fix--double--dash"); + expect(result).toBe(`${TURBULLES}-fix-double-dash`); + }); + + it("tirets initiaux et finaux supprimรฉs du slug", () => { + const result = deriveQuickWorktreePath(TURBULLES, "-fix-auth-"); + expect(result).toBe(`${TURBULLES}-fix-auth`); + }); + + it("chemin principal avec trailing slash nettoyรฉ", () => { + expect(deriveQuickWorktreePath(`${TURBULLES}/`, "fix")).toBe( + `${TURBULLES}-fix`, + ); + }); +}); + +// โ”€โ”€โ”€ 4. hasPrunableWorktrees โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("hasPrunableWorktrees", () => { + const makeEntry = (is_prunable: boolean) => ({ + path: TURBULLES, + branch: "master", + head: TURBULLES_HEAD, + is_main: true, + is_locked: false, + lock_reason: null, + is_bare: false, + is_prunable, + prunable_reason: null, + }); + + it("cas 21 โ€” tableau vide โ†’ false", () => { + expect(hasPrunableWorktrees([])).toBe(false); + }); + + it("cas 21b โ€” tous propres โ†’ false", () => { + expect(hasPrunableWorktrees([makeEntry(false), makeEntry(false)])).toBe(false); + }); + + it("cas 22 โ€” un prunable โ†’ true", () => { + expect(hasPrunableWorktrees([makeEntry(false), makeEntry(true)])).toBe(true); + }); + + it("cas 22b โ€” le principal lui-mรชme prunable โ†’ true", () => { + expect(hasPrunableWorktrees([makeEntry(true)])).toBe(true); + }); +}); + +// โ”€โ”€โ”€ 5. CONFLICT_CODES โ€” exhaustivitรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("CONFLICT_CODES", () => { + it("contient exactement les 7 codes de conflit git", () => { + // Source : git documentation โ€” git-status(1) XY format + const expected = ["UU", "AA", "DD", "AU", "UA", "DU", "UD"]; + for (const code of expected) { + expect(CONFLICT_CODES.has(code)).toBe(true); + } + expect(CONFLICT_CODES.size).toBe(expected.length); + }); + + it("ne contient pas de codes de modification simples", () => { + const notConflicts = ["M ", " M", "MM", "A ", " A", "D ", " D", "R ", "C ", "??"]; + for (const code of notConflicts) { + expect(CONFLICT_CODES.has(code)).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/utils/worktreeParser.ts b/apps/desktop/src/utils/worktreeParser.ts new file mode 100644 index 00000000..396b622c --- /dev/null +++ b/apps/desktop/src/utils/worktreeParser.ts @@ -0,0 +1,161 @@ +/** + * Pure TypeScript implementations of the worktree parsing logic. + * + * These functions mirror the Rust implementations in + * `src-tauri/src/commands/ops.rs` (git_worktree_list, git_worktree_status_all) + * and the Node.js mock in `dev-server.mjs`. + * + * Keeping the logic here as pure functions enables: + * 1. Unit tests that run without Tauri or a real git repo (vitest). + * 2. A single source of truth for the parser contract shared between + * the dev-server mock and the test suite. + * + * If you modify the parsing logic in ops.rs, update this file in lockstep. + */ + +import type { WorktreeEntry, WorkspaceRepoStatus } from "./backend"; + +/** Conflict codes as defined in `git status --porcelain` (XY format). */ +export const CONFLICT_CODES = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]); + +/** + * Parse the output of `git worktree list --porcelain` into a list of + * WorktreeEntry objects. + * + * Handles: + * - `main` attribute (git โ‰ฅ 2.36) + * - `locked [reason]` inline reason on the same line + * - `prunable [reason]` inline reason on the same line + * - `bare` worktrees + * - `detached` HEAD + * - git < 2.36 fallback: first entry is marked as main when no `main` attr + * + * @param raw Raw stdout from `git worktree list --porcelain` + */ +export function parseWorktreePorcelain(raw: string): WorktreeEntry[] { + const entries: WorktreeEntry[] = []; + let current: WorktreeEntry | null = null; + + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + if (current) entries.push(current); + current = { + path: line.slice("worktree ".length), + branch: "", + head: "", + is_main: false, + is_locked: false, + lock_reason: null, + is_bare: false, + is_prunable: false, + prunable_reason: null, + }; + } else if (current) { + if (line === "main") { + current.is_main = true; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length); + } else if (line.startsWith("branch ")) { + const full = line.slice("branch ".length); + current.branch = full.startsWith("refs/heads/") ? full.slice("refs/heads/".length) : full; + } else if (line === "bare") { + current.is_bare = true; + } else if (line.startsWith("locked")) { + current.is_locked = true; + const reason = line.slice("locked".length).trim(); + if (reason) current.lock_reason = reason; + } else if (line.startsWith("prunable")) { + current.is_prunable = true; + const reason = line.slice("prunable".length).trim(); + if (reason) current.prunable_reason = reason; + } else if (line === "detached") { + current.branch = "(detached HEAD)"; + } + } + } + if (current) entries.push(current); + + // git < 2.36 fallback: if no entry has the `main` attribute, mark the first. + if (entries.length > 0 && entries.every((e) => !e.is_main)) { + entries[0].is_main = true; + } + + return entries; +} + +/** + * Parse `git status --porcelain --untracked-files=no` output lines into + * separate `conflicted` and `modified` counts. + * + * Rules: + * - Lines with XY in CONFLICT_CODES โ†’ conflicted (UU, AA, DD, AU, UA, DU, UD) + * - All other non-empty lines โ†’ modified + * - Empty lines are ignored + * + * @param raw Raw stdout from `git status --porcelain --untracked-files=no` + */ +export function parseWorktreeStatus(raw: string): { conflicted: number; modified: number } { + const lines = raw.split("\n").filter(Boolean); + let conflicted = 0; + let modified = 0; + for (const line of lines) { + if (line.length >= 2 && CONFLICT_CODES.has(line.slice(0, 2))) { + conflicted++; + } else { + modified++; + } + } + return { conflicted, modified }; +} + +/** + * Derive a quick-create worktree path from the main worktree path + task name. + * Mirrors the `deriveQuickPath` function in WorktreeManager.vue. + * + * @param mainPath Absolute path of the main worktree + * @param name Task name (e.g. "fix/login-bug") + */ +export function deriveQuickWorktreePath(mainPath: string, name: string): string { + const base = mainPath.replace(/\\/g, "/").replace(/\/+$/, ""); + const slug = name + .trim() + .replace(/[^a-zA-Z0-9/_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^[-/]+|[-/]+$/g, ""); + return `${base}-${slug}`; +} + +/** + * Given a list of WorktreeEntry, determine if any are prunable. + * Mirrors the `hasPrunableWorktrees` computed in WorktreeManager.vue. + */ +export function hasPrunableWorktrees(entries: WorktreeEntry[]): boolean { + return entries.some((e) => e.is_prunable); +} + +/** + * Build a WorkspaceRepoStatus object from parsed status data. + * Used by the dev-server mock and tests to keep field shapes consistent. + */ +export function buildWorktreeStatus( + path: string, + branch: string, + ahead: number, + behind: number, + hasUpstream: boolean, + conflicted: number, + modified: number, + error: string | null = null, +): WorkspaceRepoStatus { + return { + path, + name: branch, + branch, + ahead, + behind, + has_upstream: hasUpstream, + modified, + conflicted, + error, + }; +} From 4c48cf001dee0c94857bb4cd3a0c6ad8d6ae055e Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 28 May 2026 17:30:57 +0200 Subject: [PATCH 07/14] fix(desktop): repair worktree admin links on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silently runs `git worktree repair` when the manager opens to recover from repos moved manually on disk. Idempotent and near-free when the links are already valid. ๐Ÿช„ Commit via GitWand --- apps/desktop/src/components/WorktreeManager.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index bbe766aa..6e04452a 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -5,6 +5,7 @@ import { gitWorktreeAdd, gitWorktreeRemove, gitWorktreePrune, + gitWorktreeRepair, gitWorktreeStatusAll, type WorktreeEntry, type WorkspaceRepoStatus, @@ -193,6 +194,9 @@ function shortPath(path: string): string { watch(worktrees, () => { loadStatuses(); }, { immediate: false }); onMounted(async () => { + // Rรฉpare silencieusement les liens administratifs cassรฉs (idempotent, + // < 5 ms si tout va bien). Couvre le cas "repo dรฉplacรฉ manuellement". + try { await gitWorktreeRepair(props.cwd); } catch { /* best-effort */ } await loadWorktrees(); if (props.suggestedBranch) { formBranch.value = props.suggestedBranch; From 52ec28fb50ca2615704fa1878c276c63bb7c1167 Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Thu, 28 May 2026 23:22:55 -0400 Subject: [PATCH 08/14] feat(desktop): ensure parent directories and standardize worktree paths Automatically creates missing parent directories when adding a new worktree, preventing `git worktree add` failures. Derives new worktree paths into a dedicated `.{main}.worktrees` directory for better organization. Pre-fills the worktree path in the UI based on the selected or new branch. --- apps/desktop/dev-server.mjs | 7 +++ apps/desktop/src-tauri/src/commands/ops.rs | 9 ++++ .../src/components/WorktreeManager.vue | 52 ++++++++++++------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/desktop/dev-server.mjs b/apps/desktop/dev-server.mjs index 4c58c7a6..242c3f6a 100644 --- a/apps/desktop/dev-server.mjs +++ b/apps/desktop/dev-server.mjs @@ -3845,6 +3845,13 @@ async function handleRequest(req, res) { try { const { cwd, path: wtPath, branch, new_branch } = await readBody(req); const resolvedCwd = resolve(cwd); + + // Ensure parent directories exist + const parentDir = dirname(wtPath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + let cmd = `git worktree add "${wtPath}"`; if (new_branch) cmd += ` -b "${new_branch}" "${branch}"`; else cmd += ` "${branch}"`; diff --git a/apps/desktop/src-tauri/src/commands/ops.rs b/apps/desktop/src-tauri/src/commands/ops.rs index aad47319..44e0c3ce 100644 --- a/apps/desktop/src-tauri/src/commands/ops.rs +++ b/apps/desktop/src-tauri/src/commands/ops.rs @@ -1677,6 +1677,15 @@ pub(crate) fn git_worktree_add( branch: String, new_branch: Option, ) -> Result { + // Note, create folders if they dont exist. + let target_path = std::path::Path::new(&path); + if let Some(parent) = target_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create worktree base directory: {}", e))?; + } + } + let mut cmd = git_cmd(); cmd.arg("worktree").arg("add").arg(&path); diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index 6e04452a..f5069459 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -60,26 +60,32 @@ const formBranch = ref(""); const formNewBranch = ref(""); const creating = ref(false); +/** Base directory for worktrees (sibling of the main repo). */ +const worktreeBaseDir = computed(() => { + const main = worktrees.value.find((w) => w.is_main); + const basePath = main ? main.path : props.cwd; + const normalizedBase = basePath.replace(/\\/g, "/").replace(/\/+$/, ""); + return `${normalizedBase}.worktrees`; +}); + +/** Derive a worktree path from a branch name. */ +function derivePath(name: string): string { + const slug = name.trim().replace(/[^a-zA-Z0-9/_-]/g, "-").replace(/-+/g, "-").replace(/^[-/]+|[-/]+$/g, ""); + return `${worktreeBaseDir.value}/${slug}`; +} + // โ”€โ”€ Quick-create form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const showQuickCreate = ref(false); const quickName = ref(""); const quickCreating = ref(false); -/** Derive the new worktree path from the main worktree path + task name. */ -function deriveQuickPath(name: string): string { - const main = worktrees.value.find((w) => w.is_main); - const base = main ? main.path.replace(/\\/g, "/").replace(/\/+$/, "") : props.cwd.replace(/\\/g, "/"); - const slug = name.trim().replace(/[^a-zA-Z0-9/_-]/g, "-").replace(/-+/g, "-").replace(/^[-/]+|[-/]+$/g, ""); - return `${base}-${slug}`; -} - async function quickCreate() { const name = quickName.value.trim(); if (!name) return; quickCreating.value = true; error.value = null; try { - const path = deriveQuickPath(name); + const path = derivePath(name); const branch = name.includes("/") ? name : `task/${name}`; await gitWorktreeAdd(props.cwd, path, "", branch); quickName.value = ""; @@ -190,6 +196,14 @@ function shortPath(path: string): string { return parts.length <= 2 ? path : "โ€ฆ/" + parts.slice(-2).join("/"); } +// Watch for branch changes to pre-fill the path +watch([formBranch, formNewBranch], ([branch, newBranch]) => { + const target = newBranch.trim() || branch.trim(); + if (target) { + formPath.value = derivePath(target); + } +}); + // Reload statuses whenever worktrees change watch(worktrees, () => { loadStatuses(); }, { immediate: false }); @@ -288,16 +302,6 @@ onMounted(async () => {
-
- - -
+
+ + +
Date: Thu, 28 May 2026 23:30:05 -0400 Subject: [PATCH 09/14] fix(desktop): fix deriveQuickPath reference in WorktreeManager template --- apps/desktop/src/components/WorktreeManager.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index f5069459..a465300a 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -293,7 +293,7 @@ onMounted(async () => {

- โ†’ {{ deriveQuickPath(quickName) }} + โ†’ {{ derivePath(quickName) }} ({{ quickName.includes("/") ? quickName.trim() : `task/${quickName.trim()}` }}) From 146bb57e2d7ca131eca76709c539776c2ae965dd Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Thu, 28 May 2026 23:47:10 -0400 Subject: [PATCH 10/14] feat(worktree): standardize sibling path logic and improve UI form layout --- apps/desktop/src/components/WorktreeManager.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/components/WorktreeManager.vue b/apps/desktop/src/components/WorktreeManager.vue index a465300a..e0fa5cfc 100644 --- a/apps/desktop/src/components/WorktreeManager.vue +++ b/apps/desktop/src/components/WorktreeManager.vue @@ -334,6 +334,16 @@ onMounted(async () => { @keydown.enter="createWorktree" />

+
+ + +