takt: add-all-delete-option (#322)

This commit is contained in:
nrs 2026-02-20 00:29:07 +09:00 committed by GitHub
parent 2926785c2c
commit 5960a0d212
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 178 additions and 46 deletions

View File

@ -28,7 +28,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
import { confirm } from '../shared/prompt/index.js'; import { confirm } from '../shared/prompt/index.js';
import { success, error as logError } from '../shared/ui/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'; import type { TaskListItem } from '../infra/task/types.js';
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
@ -206,3 +206,127 @@ describe('taskDeleteActions', () => {
expect(mockSuccess).toHaveBeenCalledWith('Deleted completed task: completed-task'); 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);
});
});

View File

@ -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 { import {
TaskRunner, TaskRunner,
} from '../../../infra/task/index.js'; } from '../../../infra/task/index.js';
@ -23,7 +13,7 @@ import {
mergeBranch, mergeBranch,
instructBranch, instructBranch,
} from './taskActions.js'; } from './taskActions.js';
import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js'; import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js'; import { retryFailedTask } from './taskRetryActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js'; import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
@ -46,17 +36,11 @@ export {
runInstructMode, runInstructMode,
} from './instructMode.js'; } from './instructMode.js';
/** Task action type for pending task action selection menu */
type PendingTaskAction = 'delete'; type PendingTaskAction = 'delete';
/** Task action type for failed task action selection menu */
type FailedTaskAction = 'retry' | 'delete'; type FailedTaskAction = 'retry' | 'delete';
type CompletedTaskAction = ListAction; 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<PendingTaskAction | null> { async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<PendingTaskAction | null> {
header(formatTaskStatusLabel(task)); header(formatTaskStatusLabel(task));
info(` Created: ${task.createdAt}`); info(` Created: ${task.createdAt}`);
@ -71,10 +55,6 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<Pendi
); );
} }
/**
* Show failed task details and prompt for an action.
* Returns the selected action, or null if cancelled.
*/
async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> { async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> {
header(formatTaskStatusLabel(task)); header(formatTaskStatusLabel(task));
info(` Created: ${task.createdAt}`); info(` Created: ${task.createdAt}`);
@ -103,9 +83,6 @@ async function showCompletedTaskAndPromptAction(cwd: string, task: TaskListItem)
return await showDiffAndPromptActionForTask(cwd, task); return await showDiffAndPromptActionForTask(cwd, task);
} }
/**
* Main entry point: list branch-based tasks interactively.
*/
export async function listTasks( export async function listTasks(
cwd: string, cwd: string,
options?: TaskExecutionOptions, options?: TaskExecutionOptions,
@ -118,7 +95,6 @@ export async function listTasks(
const runner = new TaskRunner(cwd); const runner = new TaskRunner(cwd);
// Interactive loop
while (true) { while (true) {
const tasks = runner.listAllTaskItems(); const tasks = runner.listAllTaskItems();
@ -127,11 +103,14 @@ export async function listTasks(
return; return;
} }
const menuOptions = tasks.map((task, idx) => ({ const menuOptions = [
...tasks.map((task, idx) => ({
label: formatTaskStatusLabel(task), label: formatTaskStatusLabel(task),
value: `${task.kind}:${idx}`, value: `${task.kind}:${idx}`,
description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`, description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`,
})); })),
{ label: 'All Delete', value: '__all-delete__', description: 'Delete all tasks at once' },
];
const selected = await selectOption<string>( const selected = await selectOption<string>(
'List Tasks', 'List Tasks',
@ -142,6 +121,11 @@ export async function listTasks(
return; return;
} }
if (selected === '__all-delete__') {
await deleteAllTasks(tasks);
continue;
}
const colonIdx = selected.indexOf(':'); const colonIdx = selected.indexOf(':');
if (colonIdx === -1) continue; if (colonIdx === -1) continue;
const type = selected.slice(0, colonIdx); const type = selected.slice(0, colonIdx);

View File

@ -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 { dirname } from 'node:path';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } 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); return deleteBranch(projectDir, task);
} }
/**
* Delete a pending task file.
* Prompts user for confirmation first.
*/
export async function deletePendingTask(task: TaskListItem): Promise<boolean> { export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); const confirmed = await confirm(`Delete pending task "${task.name}"?`, false);
if (!confirmed) return false; if (!confirmed) return false;
@ -48,10 +37,6 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
return true; return true;
} }
/**
* Delete a failed task directory.
* Prompts user for confirmation first.
*/
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> { export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); const confirmed = await confirm(`Delete failed task "${task.name}"?`, false);
if (!confirmed) return false; if (!confirmed) return false;
@ -97,3 +82,42 @@ export async function deleteCompletedTask(task: TaskListItem): Promise<boolean>
log.info('Deleted completed task', { name: task.name, filePath: task.filePath }); log.info('Deleted completed task', { name: task.name, filePath: task.filePath });
return true; return true;
} }
export async function deleteAllTasks(tasks: TaskListItem[]): Promise<boolean> {
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;
}