import React, { useEffect, useState } from 'react'; import type { CommandResultDisplay } from 'src/commands.js'; import { logEvent } from 'src/services/analytics/index.js'; import { logForDebugging } from 'src/utils/debug.js'; import { Box, Text } from '../ink.js'; import { execFileNoThrow } from '../utils/execFileNoThrow.js'; import { getPlansDirectory } from '../utils/plans.js'; import { setCwd } from '../utils/Shell.js'; import { cleanupWorktree, getCurrentWorktreeSession, keepWorktree, killTmuxSession } from '../utils/worktree.js'; import { Select } from './CustomSelect/select.js'; import { Dialog } from './design-system/Dialog.js'; import { Spinner } from './Spinner.js'; // Inline require breaks the cycle this file would otherwise close: // sessionStorage → commands → exit → ExitFlow → here. All call sites // are inside callbacks, so the lazy require never sees an undefined import. function recordWorktreeExit(): void { /* eslint-disable @typescript-eslint/no-require-imports */ ; (require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js')).saveWorktreeState(null); /* eslint-enable @typescript-eslint/no-require-imports */ } type Props = { onDone: (result?: string, options?: { display?: CommandResultDisplay; }) => void; onCancel?: () => void; }; export function WorktreeExitDialog({ onDone, onCancel }: Props): React.ReactNode { const [status, setStatus] = useState<'loading' | 'asking' | 'keeping' | 'removing' | 'done'>('loading'); const [changes, setChanges] = useState([]); const [commitCount, setCommitCount] = useState(0); const [resultMessage, setResultMessage] = useState(); const worktreeSession = getCurrentWorktreeSession(); useEffect(() => { async function loadChanges() { let changeLines: string[] = []; const gitStatus = await execFileNoThrow('git', ['status', '--porcelain']); if (gitStatus.stdout) { changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== ''); setChanges(changeLines); } // Check for commits to eject if (worktreeSession) { // Get commits in worktree that are not in original branch const { stdout: commitsStr } = await execFileNoThrow('git', ['rev-list', '--count', `${worktreeSession.originalHeadCommit}..HEAD`]); const count = parseInt(commitsStr.trim()) || 0; setCommitCount(count); // If no changes and no commits, clean up silently if (changeLines.length === 0 && count === 0) { setStatus('removing'); void cleanupWorktree().then(() => { process.chdir(worktreeSession.originalCwd); setCwd(worktreeSession.originalCwd); recordWorktreeExit(); getPlansDirectory.cache.clear?.(); setResultMessage('Worktree removed (no changes)'); }).catch(error => { logForDebugging(`Failed to clean up worktree: ${error}`, { level: 'error' }); setResultMessage('Worktree cleanup failed, exiting anyway'); }).then(() => { setStatus('done'); }); return; } else { setStatus('asking'); } } } void loadChanges(); // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, [worktreeSession]); useEffect(() => { if (status === 'done') { onDone(resultMessage); } }, [status, onDone, resultMessage]); if (!worktreeSession) { onDone('No active worktree session found', { display: 'system' }); return null; } if (status === 'loading' || status === 'done') { return null; } async function handleSelect(value: string) { if (!worktreeSession) return; const hasTmux = Boolean(worktreeSession.tmuxSessionName); if (value === 'keep' || value === 'keep-with-tmux') { setStatus('keeping'); logEvent('tengu_worktree_kept', { commits: commitCount, changed_files: changes.length }); await keepWorktree(); process.chdir(worktreeSession.originalCwd); setCwd(worktreeSession.originalCwd); recordWorktreeExit(); getPlansDirectory.cache.clear?.(); if (hasTmux) { setResultMessage(`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Reattach to tmux session with: tmux attach -t ${worktreeSession.tmuxSessionName}`); } else { setResultMessage(`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}`); } setStatus('done'); } else if (value === 'keep-kill-tmux') { setStatus('keeping'); logEvent('tengu_worktree_kept', { commits: commitCount, changed_files: changes.length }); if (worktreeSession.tmuxSessionName) { await killTmuxSession(worktreeSession.tmuxSessionName); } await keepWorktree(); process.chdir(worktreeSession.originalCwd); setCwd(worktreeSession.originalCwd); recordWorktreeExit(); getPlansDirectory.cache.clear?.(); setResultMessage(`Worktree kept at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Tmux session terminated.`); setStatus('done'); } else if (value === 'remove' || value === 'remove-with-tmux') { setStatus('removing'); logEvent('tengu_worktree_removed', { commits: commitCount, changed_files: changes.length }); if (worktreeSession.tmuxSessionName) { await killTmuxSession(worktreeSession.tmuxSessionName); } try { await cleanupWorktree(); process.chdir(worktreeSession.originalCwd); setCwd(worktreeSession.originalCwd); recordWorktreeExit(); getPlansDirectory.cache.clear?.(); } catch (error) { logForDebugging(`Failed to clean up worktree: ${error}`, { level: 'error' }); setResultMessage('Worktree cleanup failed, exiting anyway'); setStatus('done'); return; } const tmuxNote = hasTmux ? ' Tmux session terminated.' : ''; if (commitCount > 0 && changes.length > 0) { setResultMessage(`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} and uncommitted changes were discarded.${tmuxNote}`); } else if (commitCount > 0) { setResultMessage(`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${worktreeSession.worktreeBranch} ${commitCount === 1 ? 'was' : 'were'} discarded.${tmuxNote}`); } else if (changes.length > 0) { setResultMessage(`Worktree removed. Uncommitted changes were discarded.${tmuxNote}`); } else { setResultMessage(`Worktree removed.${tmuxNote}`); } setStatus('done'); } } if (status === 'keeping') { return Keeping worktree… ; } if (status === 'removing') { return Removing worktree… ; } const branchName = worktreeSession.worktreeBranch; const hasUncommitted = changes.length > 0; const hasCommits = commitCount > 0; let subtitle = ''; if (hasUncommitted && hasCommits) { subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.`; } else if (hasUncommitted) { subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.`; } else if (hasCommits) { subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.`; } else { subtitle = 'You are working in a worktree. Keep it to continue working there, or remove it to clean up.'; } function handleCancel() { if (onCancel) { // Abort exit and return to the session onCancel(); return; } // Fallback: treat Escape as "keep" if no onCancel provided void handleSelect('keep'); } const removeDescription = hasUncommitted || hasCommits ? 'All changes and commits will be lost.' : 'Clean up the worktree directory.'; const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName); const options = hasTmuxSession ? [{ label: 'Keep worktree and tmux session', value: 'keep-with-tmux', description: `Stays at ${worktreeSession.worktreePath}. Reattach with: tmux attach -t ${worktreeSession.tmuxSessionName}` }, { label: 'Keep worktree, kill tmux session', value: 'keep-kill-tmux', description: `Keeps worktree at ${worktreeSession.worktreePath}, terminates tmux session.` }, { label: 'Remove worktree and tmux session', value: 'remove-with-tmux', description: removeDescription }] : [{ label: 'Keep worktree', value: 'keep', description: `Stays at ${worktreeSession.worktreePath}` }, { label: 'Remove worktree', value: 'remove', description: removeDescription }]; const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep'; return