takt: add-all-delete-option (#322)
This commit is contained in:
parent
2926785c2c
commit
5960a0d212
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<PendingTaskAction | null> {
|
||||
header(formatTaskStatusLabel(task));
|
||||
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> {
|
||||
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) => ({
|
||||
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<string>(
|
||||
'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);
|
||||
|
||||
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a failed task directory.
|
||||
* Prompts user for confirmation first.
|
||||
*/
|
||||
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
|
||||
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<boolean>
|
||||
log.info('Deleted completed task', { name: task.name, filePath: task.filePath });
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user