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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user