diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index 0a81403..cca1c49 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -28,7 +28,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ import { confirm } from '../shared/prompt/index.js'; import { success, error as logError } from '../shared/ui/index.js'; -import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from '../features/tasks/list/taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from '../features/tasks/list/taskDeleteActions.js'; import type { TaskListItem } from '../infra/task/types.js'; const mockConfirm = vi.mocked(confirm); @@ -206,3 +206,127 @@ describe('taskDeleteActions', () => { expect(mockSuccess).toHaveBeenCalledWith('Deleted completed task: completed-task'); }); }); + +describe('deleteAllTasks', () => { + it('should confirm once and delete all non-running tasks', async () => { + const tasksFile = setupTasksFile(tmpDir); + const tasks: TaskListItem[] = [ + { kind: 'pending', name: 'pending-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'pending' }, + { kind: 'failed', name: 'failed-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'failed' }, + { kind: 'completed', name: 'completed-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'completed', branch: 'takt/completed-task', worktreePath: '/tmp/takt/completed-task' }, + ]; + mockConfirm.mockResolvedValue(true); + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(true); + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockConfirm).toHaveBeenCalledWith('Delete all 3 tasks?', false); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('pending-task'); + expect(raw).not.toContain('failed-task'); + expect(raw).not.toContain('completed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted 3 of 3 tasks.'); + }); + + it('should skip running tasks', async () => { + const tasksFile = setupTasksFile(tmpDir); + const tasks: TaskListItem[] = [ + { kind: 'pending', name: 'pending-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'pending' }, + { kind: 'running', name: 'running-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'running' }, + ]; + mockConfirm.mockResolvedValue(true); + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(true); + expect(mockConfirm).toHaveBeenCalledWith('Delete all 1 tasks?', false); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('pending-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted 1 of 1 tasks.'); + }); + + it('should return false when user cancels', async () => { + const tasksFile = setupTasksFile(tmpDir); + const tasks: TaskListItem[] = [ + { kind: 'pending', name: 'pending-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'pending' }, + ]; + mockConfirm.mockResolvedValue(false); + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(false); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).toContain('pending-task'); + expect(mockSuccess).not.toHaveBeenCalled(); + }); + + it('should return false when no deletable tasks (only running)', async () => { + const tasks: TaskListItem[] = [ + { kind: 'running', name: 'running-task', createdAt: '2025-01-15', filePath: '/tmp/fake', content: 'running' }, + ]; + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(false); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('should return false when no tasks', async () => { + const result = await deleteAllTasks([]); + + expect(result).toBe(false); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('should skip task when branch cleanup fails but continue with others', async () => { + const tasksFile = setupTasksFile(tmpDir); + const tasks: TaskListItem[] = [ + { kind: 'pending', name: 'pending-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'pending' }, + { kind: 'completed', name: 'completed-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'completed', branch: 'takt/completed-task', worktreePath: '/tmp/takt/completed-task' }, + ]; + mockConfirm.mockResolvedValue(true); + mockDeleteBranch.mockReturnValue(false); + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(true); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('pending-task'); + expect(raw).toContain('completed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted 1 of 2 tasks.'); + }); + + it('should return false when all tasks fail branch cleanup', async () => { + const tasksFile = setupTasksFile(tmpDir); + const tasks: TaskListItem[] = [ + { kind: 'completed', name: 'completed-task', createdAt: '2025-01-15', filePath: tasksFile, content: 'completed', branch: 'takt/completed-task', worktreePath: '/tmp/takt/completed-task' }, + ]; + mockConfirm.mockResolvedValue(true); + mockDeleteBranch.mockReturnValue(false); + + const result = await deleteAllTasks(tasks); + + expect(result).toBe(false); + expect(mockSuccess).not.toHaveBeenCalled(); + }); + + it('should cleanup branches for completed and failed tasks', async () => { + const tasksFile = setupTasksFile(tmpDir); + const completedTask: TaskListItem = { + kind: 'completed', + name: 'completed-task', + createdAt: '2025-01-15', + filePath: tasksFile, + content: 'completed', + branch: 'takt/completed-task', + worktreePath: '/tmp/takt/completed-task', + }; + const tasks: TaskListItem[] = [completedTask]; + mockConfirm.mockResolvedValue(true); + + await deleteAllTasks(tasks); + + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, completedTask); + }); +}); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 7367161..5ef5cbe 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -1,13 +1,3 @@ -/** - * List tasks command — main entry point. - * - * Interactive UI for reviewing branch-based task results, - * pending tasks (.takt/tasks.yaml), and failed tasks. - * Individual actions (merge, delete, instruct, diff) are in taskActions.ts. - * Task delete actions are in taskDeleteActions.ts. - * Non-interactive mode is in listNonInteractive.ts. - */ - import { TaskRunner, } from '../../../infra/task/index.js'; @@ -23,7 +13,7 @@ import { mergeBranch, instructBranch, } from './taskActions.js'; -import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js'; @@ -46,17 +36,11 @@ export { runInstructMode, } from './instructMode.js'; -/** Task action type for pending task action selection menu */ type PendingTaskAction = 'delete'; -/** Task action type for failed task action selection menu */ type FailedTaskAction = 'retry' | 'delete'; type CompletedTaskAction = ListAction; -/** - * Show pending task details and prompt for an action. - * Returns the selected action, or null if cancelled. - */ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(formatTaskStatusLabel(task)); info(` Created: ${task.createdAt}`); @@ -71,10 +55,6 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(formatTaskStatusLabel(task)); info(` Created: ${task.createdAt}`); @@ -103,9 +83,6 @@ async function showCompletedTaskAndPromptAction(cwd: string, task: TaskListItem) return await showDiffAndPromptActionForTask(cwd, task); } -/** - * Main entry point: list branch-based tasks interactively. - */ export async function listTasks( cwd: string, options?: TaskExecutionOptions, @@ -118,7 +95,6 @@ export async function listTasks( const runner = new TaskRunner(cwd); - // Interactive loop while (true) { const tasks = runner.listAllTaskItems(); @@ -127,11 +103,14 @@ export async function listTasks( return; } - const menuOptions = tasks.map((task, idx) => ({ - label: formatTaskStatusLabel(task), - value: `${task.kind}:${idx}`, - description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`, - })); + const menuOptions = [ + ...tasks.map((task, idx) => ({ + label: formatTaskStatusLabel(task), + value: `${task.kind}:${idx}`, + description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`, + })), + { label: 'All Delete', value: '__all-delete__', description: 'Delete all tasks at once' }, + ]; const selected = await selectOption( 'List Tasks', @@ -142,6 +121,11 @@ export async function listTasks( return; } + if (selected === '__all-delete__') { + await deleteAllTasks(tasks); + continue; + } + const colonIdx = selected.indexOf(':'); if (colonIdx === -1) continue; const type = selected.slice(0, colonIdx); diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index c3219fb..8ca8516 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -1,10 +1,3 @@ -/** - * Delete actions for pending and failed tasks. - * - * Provides interactive deletion (with confirm prompt) - * for pending/failed tasks in .takt/tasks.yaml. - */ - import { dirname } from 'node:path'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; @@ -27,10 +20,6 @@ function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean return deleteBranch(projectDir, task); } -/** - * Delete a pending task file. - * Prompts user for confirmation first. - */ export async function deletePendingTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); if (!confirmed) return false; @@ -48,10 +37,6 @@ export async function deletePendingTask(task: TaskListItem): Promise { return true; } -/** - * Delete a failed task directory. - * Prompts user for confirmation first. - */ export async function deleteFailedTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); if (!confirmed) return false; @@ -97,3 +82,42 @@ export async function deleteCompletedTask(task: TaskListItem): Promise log.info('Deleted completed task', { name: task.name, filePath: task.filePath }); return true; } + +export async function deleteAllTasks(tasks: TaskListItem[]): Promise { + const deletable = tasks.filter(t => t.kind !== 'running'); + if (deletable.length === 0) return false; + + const confirmed = await confirm(`Delete all ${deletable.length} tasks?`, false); + if (!confirmed) return false; + + let deletedCount = 0; + for (const task of deletable) { + const projectDir = getProjectDir(task); + try { + if (!cleanupBranchIfPresent(task, projectDir)) { + logError(`Failed to cleanup branch for task "${task.name}", skipping`); + log.error('Branch cleanup failed, skipping task', { name: task.name, kind: task.kind }); + continue; + } + const runner = new TaskRunner(projectDir); + if (task.kind === 'pending') { + runner.deletePendingTask(task.name); + } else if (task.kind === 'failed') { + runner.deleteFailedTask(task.name); + } else if (task.kind === 'completed') { + runner.deleteCompletedTask(task.name); + } + deletedCount++; + log.info('Deleted task in bulk delete', { name: task.name, kind: task.kind }); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to delete task "${task.name}": ${msg}`); + log.error('Failed to delete task in bulk delete', { name: task.name, kind: task.kind, error: msg }); + } + } + + if (deletedCount > 0) { + success(`Deleted ${deletedCount} of ${deletable.length} tasks.`); + } + return deletedCount > 0; +}