list コマンドのリファクタリング: non-interactive モード分離、delete アクション追加、console.log を info に統一

This commit is contained in:
nrslib 2026-02-06 08:56:00 +09:00
parent a2af2f23e3
commit 378f5477e4
11 changed files with 975 additions and 165 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "takt",
"version": "0.6.0-rc2",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "takt",
"version": "0.6.0-rc2",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.19",

View File

@ -0,0 +1,181 @@
/**
* Tests for listNonInteractive non-interactive list output and branch actions.
*/
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listTasks } from '../features/tasks/list/index.js';
describe('listTasks non-interactive text output', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-'));
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should output pending tasks in text format', async () => {
// Given
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[pending] my-task'));
expect(calls).toContainEqual(expect.stringContaining('Fix the login bug'));
logSpy.mockRestore();
});
it('should output failed tasks in text format', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-task'));
expect(calls).toContainEqual(expect.stringContaining('This failed'));
logSpy.mockRestore();
});
it('should output both pending and failed tasks in text format', async () => {
// Given
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'pending-one.md'), 'Pending task');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-one');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-one.md'), 'Failed task');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[pending] pending-one'));
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one'));
logSpy.mockRestore();
});
it('should show info message when no tasks exist', async () => {
// Given: no tasks, no branches
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls.some((c) => c.includes('No tasks to list'))).toBe(true);
logSpy.mockRestore();
});
});
describe('listTasks non-interactive action errors', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-err-'));
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
// Create a pending task so the "no tasks" early return is not triggered
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'dummy.md'), 'dummy');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should exit with code 1 when --action specified without --branch', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'diff' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 for invalid action', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'invalid', branch: 'some-branch' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 when branch not found', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'diff', branch: 'takt/nonexistent' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 for delete without --yes', async () => {
// Given: create a branch so it's found
execFileSync('git', ['checkout', '-b', 'takt/20250115-test-branch'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['checkout', 'main'], { cwd: tmpDir, stdio: 'pipe' });
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, {
enabled: true,
action: 'delete',
branch: 'takt/20250115-test-branch',
}),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
});

View File

@ -2,14 +2,21 @@
* Tests for list-tasks command
*/
import { describe, it, expect, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
parseTaktBranches,
extractTaskSlug,
buildListItems,
type BranchInfo,
} from '../infra/task/branchList.js';
import { TaskRunner } from '../infra/task/runner.js';
import type { TaskListItem } from '../infra/task/types.js';
import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js';
import { listTasks } from '../features/tasks/list/index.js';
describe('parseTaktBranches', () => {
it('should parse takt/ branches from git branch output', () => {
@ -178,3 +185,206 @@ describe('isBranchMerged', () => {
expect(result).toBe(false);
});
});
describe('TaskRunner.listFailedTasks', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array for empty failed directory', () => {
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toEqual([]);
});
it('should parse failed task directories correctly', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: taskDir,
content: 'Fix the login bug',
});
});
it('should skip malformed directory names', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
// No underscore → malformed, should be skipped
fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true });
// Valid one
const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task');
fs.mkdirSync(validDir, { recursive: true });
fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('valid-task');
});
it('should extract task content from task file in directory', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test');
fs.mkdirSync(taskDir, { recursive: true });
// report.md and log.json should be skipped; the actual task file should be read
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('task: Do something important');
});
it('should return empty content when no task file exists', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file');
fs.mkdirSync(taskDir, { recursive: true });
// Only report.md and log.json, no actual task file
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('');
});
it('should handle task name with underscores', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name');
fs.mkdirSync(taskDir, { recursive: true });
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('my_task_name');
});
it('should skip non-directory entries', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
fs.mkdirSync(failedDir, { recursive: true });
// Create a file (not a directory) in the failed dir
fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(0);
});
});
describe('TaskRunner.listPendingTaskItems', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array when no pending tasks', () => {
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toEqual([]);
});
it('should convert TaskInfo to TaskListItem with kind=pending', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.kind).toBe('pending');
expect(result[0]!.name).toBe('my-task');
expect(result[0]!.content).toBe('Fix the login bug');
});
it('should truncate content to first line (max 80 chars)', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
const longLine = 'A'.repeat(120) + '\nSecond line';
fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine);
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('A'.repeat(80));
});
});
describe('listTasks non-interactive JSON output', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-'));
// Initialize as a git repo so detectDefaultBranch works
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => {
// Given: a pending task and a failed task
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When: listTasks is called in non-interactive JSON mode
await listTasks(tmpDir, undefined, {
enabled: true,
format: 'json',
});
// Then: output is an object with branches, pendingTasks, failedTasks
expect(logSpy).toHaveBeenCalledTimes(1);
const output = JSON.parse(logSpy.mock.calls[0]![0] as string);
expect(output).toHaveProperty('branches');
expect(output).toHaveProperty('pendingTasks');
expect(output).toHaveProperty('failedTasks');
expect(Array.isArray(output.branches)).toBe(true);
expect(Array.isArray(output.pendingTasks)).toBe(true);
expect(Array.isArray(output.failedTasks)).toBe(true);
expect(output.pendingTasks).toHaveLength(1);
expect(output.pendingTasks[0].name).toBe('my-task');
expect(output.failedTasks).toHaveLength(1);
expect(output.failedTasks[0].name).toBe('failed-task');
logSpy.mockRestore();
});
});

View File

@ -0,0 +1,180 @@
/**
* Tests for taskDeleteActions pending/failed task deletion
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
error: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
error: vi.fn(),
}),
}));
import { confirm } from '../shared/prompt/index.js';
import { success, error as logError } from '../shared/ui/index.js';
import { deletePendingTask, deleteFailedTask } from '../features/tasks/list/taskDeleteActions.js';
import type { TaskListItem } from '../infra/task/types.js';
const mockConfirm = vi.mocked(confirm);
const mockSuccess = vi.mocked(success);
const mockLogError = vi.mocked(logError);
let tmpDir: string;
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('deletePendingTask', () => {
it('should delete pending task file when confirmed', async () => {
// Given
const filePath = path.join(tmpDir, 'my-task.md');
fs.writeFileSync(filePath, 'task content');
const task: TaskListItem = {
kind: 'pending',
name: 'my-task',
createdAt: '2025-01-15',
filePath,
content: 'task content',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deletePendingTask(task);
// Then
expect(result).toBe(true);
expect(fs.existsSync(filePath)).toBe(false);
expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task');
});
it('should not delete when user declines confirmation', async () => {
// Given
const filePath = path.join(tmpDir, 'my-task.md');
fs.writeFileSync(filePath, 'task content');
const task: TaskListItem = {
kind: 'pending',
name: 'my-task',
createdAt: '2025-01-15',
filePath,
content: 'task content',
};
mockConfirm.mockResolvedValue(false);
// When
const result = await deletePendingTask(task);
// Then
expect(result).toBe(false);
expect(fs.existsSync(filePath)).toBe(true);
expect(mockSuccess).not.toHaveBeenCalled();
});
it('should return false and show error when file does not exist', async () => {
// Given
const filePath = path.join(tmpDir, 'non-existent.md');
const task: TaskListItem = {
kind: 'pending',
name: 'non-existent',
createdAt: '2025-01-15',
filePath,
content: '',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deletePendingTask(task);
// Then
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalled();
expect(mockSuccess).not.toHaveBeenCalled();
});
});
describe('deleteFailedTask', () => {
it('should delete failed task directory when confirmed', async () => {
// Given
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, 'my-task.md'), 'content');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: 'content',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(true);
expect(fs.existsSync(dirPath)).toBe(false);
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: my-task');
});
it('should not delete when user declines confirmation', async () => {
// Given
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(dirPath, { recursive: true });
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: '',
};
mockConfirm.mockResolvedValue(false);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(false);
expect(fs.existsSync(dirPath)).toBe(true);
expect(mockSuccess).not.toHaveBeenCalled();
});
it('should return false and show error when directory does not exist', async () => {
// Given
const dirPath = path.join(tmpDir, 'non-existent-dir');
const task: TaskListItem = {
kind: 'failed',
name: 'non-existent',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: '',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalled();
expect(mockSuccess).not.toHaveBeenCalled();
});
});

View File

@ -1,21 +1,23 @@
/**
* List tasks command main entry point.
*
* Interactive UI for reviewing branch-based task results.
* Interactive UI for reviewing branch-based task results,
* pending tasks (.takt/tasks/), and failed tasks (.takt/failed/).
* 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 { execFileSync } from 'node:child_process';
import {
detectDefaultBranch,
listTaktBranches,
buildListItems,
detectDefaultBranch,
TaskRunner,
} from '../../../infra/task/index.js';
import type { TaskListItem } from '../../../infra/task/index.js';
import { selectOption, confirm } from '../../../shared/prompt/index.js';
import { info } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
import { info, header, blankLine } from '../../../shared/ui/index.js';
import type { TaskExecutionOptions } from '../execute/types.js';
import type { BranchListItem } from '../../../infra/task/index.js';
import {
type ListAction,
showFullDiff,
@ -25,6 +27,10 @@ import {
deleteBranch,
instructBranch,
} from './taskActions.js';
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
export {
type ListAction,
@ -36,100 +42,25 @@ export {
instructBranch,
} from './taskActions.js';
const log = createLogger('list-tasks');
/** Task action type for the task action selection menu */
type TaskAction = 'delete';
export interface ListNonInteractiveOptions {
enabled: boolean;
action?: string;
branch?: string;
format?: string;
yes?: boolean;
}
function isValidAction(action: string): action is ListAction {
return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete';
}
function printNonInteractiveList(items: BranchListItem[], format?: string): void {
const outputFormat = format ?? 'text';
if (outputFormat === 'json') {
console.log(JSON.stringify(items, null, 2));
return;
/**
* Show task details and prompt for an action.
* Returns the selected action, or null if cancelled.
*/
async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction | null> {
header(`[${task.kind}] ${task.name}`);
info(` Created: ${task.createdAt}`);
if (task.content) {
info(` ${task.content}`);
}
blankLine();
for (const item of items) {
const worktreeLabel = item.info.worktreePath ? ' (worktree)' : '';
const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : '';
console.log(`${item.info.branch}${worktreeLabel} (${item.filesChanged} files)${instruction}`);
}
}
function showDiffStat(projectDir: string, defaultBranch: string, branch: string): void {
try {
const stat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
console.log(stat);
} catch {
info('Could not generate diff stat');
}
}
async function listTasksNonInteractive(
cwd: string,
_options: TaskExecutionOptions | undefined,
nonInteractive: ListNonInteractiveOptions,
): Promise<void> {
const defaultBranch = detectDefaultBranch(cwd);
const branches = listTaktBranches(cwd);
if (branches.length === 0) {
info('No tasks to list.');
return;
}
const items = buildListItems(cwd, branches, defaultBranch);
if (!nonInteractive.action) {
printNonInteractiveList(items, nonInteractive.format);
return;
}
if (!nonInteractive.branch) {
info('Missing --branch for non-interactive action.');
process.exit(1);
}
if (!isValidAction(nonInteractive.action)) {
info('Invalid --action. Use one of: diff, try, merge, delete.');
process.exit(1);
}
const item = items.find((entry) => entry.info.branch === nonInteractive.branch);
if (!item) {
info(`Branch not found: ${nonInteractive.branch}`);
process.exit(1);
}
switch (nonInteractive.action) {
case 'diff':
showDiffStat(cwd, defaultBranch, item.info.branch);
return;
case 'try':
tryMergeBranch(cwd, item);
return;
case 'merge':
mergeBranch(cwd, item);
return;
case 'delete':
if (!nonInteractive.yes) {
info('Delete requires --yes in non-interactive mode.');
process.exit(1);
}
deleteBranch(cwd, item);
return;
}
return await selectOption<TaskAction>(
`Action for ${task.name}:`,
[{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }],
);
}
/**
@ -140,39 +71,52 @@ export async function listTasks(
options?: TaskExecutionOptions,
nonInteractive?: ListNonInteractiveOptions,
): Promise<void> {
log.info('Starting list-tasks');
if (nonInteractive?.enabled) {
await listTasksNonInteractive(cwd, options, nonInteractive);
await listTasksNonInteractive(cwd, nonInteractive);
return;
}
const defaultBranch = detectDefaultBranch(cwd);
let branches = listTaktBranches(cwd);
if (branches.length === 0) {
info('No tasks to list.');
return;
}
const runner = new TaskRunner(cwd);
// Interactive loop
while (branches.length > 0) {
while (true) {
const branches = listTaktBranches(cwd);
const items = buildListItems(cwd, branches, defaultBranch);
const pendingTasks = runner.listPendingTaskItems();
const failedTasks = runner.listFailedTasks();
const menuOptions = items.map((item, idx) => {
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
const description = item.originalInstruction
? `${filesSummary} | ${item.originalInstruction}`
: filesSummary;
return {
label: item.info.branch,
value: String(idx),
description,
};
});
if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) {
info('No tasks to list.');
return;
}
const menuOptions = [
...items.map((item, idx) => {
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
const description = item.originalInstruction
? `${filesSummary} | ${item.originalInstruction}`
: filesSummary;
return {
label: item.info.branch,
value: `branch:${idx}`,
description,
};
}),
...pendingTasks.map((task, idx) => ({
label: `[pending] ${task.name}`,
value: `pending:${idx}`,
description: task.content,
})),
...failedTasks.map((task, idx) => ({
label: `[failed] ${task.name}`,
value: `failed:${idx}`,
description: task.content,
})),
];
const selected = await selectOption<string>(
'List Tasks (Branches)',
'List Tasks',
menuOptions,
);
@ -180,47 +124,63 @@ export async function listTasks(
return;
}
const selectedIdx = parseInt(selected, 10);
const item = items[selectedIdx];
if (!item) continue;
const colonIdx = selected.indexOf(':');
if (colonIdx === -1) continue;
const type = selected.slice(0, colonIdx);
const idx = parseInt(selected.slice(colonIdx + 1), 10);
if (Number.isNaN(idx)) continue;
// Action loop: re-show menu after viewing diff
let action: ListAction | null;
do {
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
if (type === 'branch') {
const item = items[idx];
if (!item) continue;
if (action === 'diff') {
showFullDiff(cwd, defaultBranch, item.info.branch);
}
} while (action === 'diff');
// Action loop: re-show menu after viewing diff
let action: ListAction | null;
do {
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
if (action === null) continue;
switch (action) {
case 'instruct':
await instructBranch(cwd, item, options);
break;
case 'try':
tryMergeBranch(cwd, item);
break;
case 'merge':
mergeBranch(cwd, item);
break;
case 'delete': {
const confirmed = await confirm(
`Delete ${item.info.branch}? This will discard all changes.`,
false,
);
if (confirmed) {
deleteBranch(cwd, item);
if (action === 'diff') {
showFullDiff(cwd, defaultBranch, item.info.branch);
}
break;
} while (action === 'diff');
if (action === null) continue;
switch (action) {
case 'instruct':
await instructBranch(cwd, item, options);
break;
case 'try':
tryMergeBranch(cwd, item);
break;
case 'merge':
mergeBranch(cwd, item);
break;
case 'delete': {
const confirmed = await confirm(
`Delete ${item.info.branch}? This will discard all changes.`,
false,
);
if (confirmed) {
deleteBranch(cwd, item);
}
break;
}
}
} else if (type === 'pending') {
const task = pendingTasks[idx];
if (!task) continue;
const taskAction = await showTaskAndPromptAction(task);
if (taskAction === 'delete') {
await deletePendingTask(task);
}
} else if (type === 'failed') {
const task = failedTasks[idx];
if (!task) continue;
const taskAction = await showTaskAndPromptAction(task);
if (taskAction === 'delete') {
await deleteFailedTask(task);
}
}
// Refresh branch list after action
branches = listTaktBranches(cwd);
}
info('All tasks listed.');
}

View File

@ -0,0 +1,140 @@
/**
* Non-interactive list mode.
*
* Handles --non-interactive output (text/JSON) and
* non-interactive branch actions (--action, --branch).
*/
import { execFileSync } from 'node:child_process';
import type { TaskListItem, BranchListItem } from '../../../infra/task/index.js';
import {
detectDefaultBranch,
listTaktBranches,
buildListItems,
TaskRunner,
} from '../../../infra/task/index.js';
import { info } from '../../../shared/ui/index.js';
import {
type ListAction,
tryMergeBranch,
mergeBranch,
deleteBranch,
} from './taskActions.js';
export interface ListNonInteractiveOptions {
enabled: boolean;
action?: string;
branch?: string;
format?: string;
yes?: boolean;
}
function isValidAction(action: string): action is ListAction {
return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete';
}
function printNonInteractiveList(
items: BranchListItem[],
pendingTasks: TaskListItem[],
failedTasks: TaskListItem[],
format?: string,
): void {
const outputFormat = format ?? 'text';
if (outputFormat === 'json') {
// stdout に直接出力JSON パース用途のため UI ヘルパーを経由しない)
console.log(JSON.stringify({
branches: items,
pendingTasks,
failedTasks,
}, null, 2));
return;
}
for (const item of items) {
const worktreeLabel = item.info.worktreePath ? ' (worktree)' : '';
const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : '';
info(`${item.info.branch}${worktreeLabel} (${item.filesChanged} files)${instruction}`);
}
for (const task of pendingTasks) {
info(`[pending] ${task.name} - ${task.content}`);
}
for (const task of failedTasks) {
info(`[failed] ${task.name} - ${task.content}`);
}
}
function showDiffStat(projectDir: string, defaultBranch: string, branch: string): void {
try {
const stat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
info(stat);
} catch {
info('Could not generate diff stat');
}
}
/**
* Run list-tasks in non-interactive mode.
*/
export async function listTasksNonInteractive(
cwd: string,
nonInteractive: ListNonInteractiveOptions,
): Promise<void> {
const defaultBranch = detectDefaultBranch(cwd);
const branches = listTaktBranches(cwd);
const runner = new TaskRunner(cwd);
const pendingTasks = runner.listPendingTaskItems();
const failedTasks = runner.listFailedTasks();
const items = buildListItems(cwd, branches, defaultBranch);
if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) {
info('No tasks to list.');
return;
}
if (!nonInteractive.action) {
printNonInteractiveList(items, pendingTasks, failedTasks, nonInteractive.format);
return;
}
// Branch-targeted action (--branch)
if (!nonInteractive.branch) {
info('Missing --branch for non-interactive action.');
process.exit(1);
}
if (!isValidAction(nonInteractive.action)) {
info('Invalid --action. Use one of: diff, try, merge, delete.');
process.exit(1);
}
const item = items.find((entry) => entry.info.branch === nonInteractive.branch);
if (!item) {
info(`Branch not found: ${nonInteractive.branch}`);
process.exit(1);
}
switch (nonInteractive.action) {
case 'diff':
showDiffStat(cwd, defaultBranch, item.info.branch);
return;
case 'try':
tryMergeBranch(cwd, item);
return;
case 'merge':
mergeBranch(cwd, item);
return;
case 'delete':
if (!nonInteractive.yes) {
info('Delete requires --yes in non-interactive mode.');
process.exit(1);
}
deleteBranch(cwd, item);
return;
}
}

View File

@ -8,17 +8,16 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { rmSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import chalk from 'chalk';
import {
createTempCloneForBranch,
removeClone,
removeCloneMeta,
cleanupOrphanedClone,
} from '../../../infra/task/index.js';
import {
detectDefaultBranch,
type BranchListItem,
autoCommitAndPush,
type BranchListItem,
} from '../../../infra/task/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js';
@ -86,7 +85,7 @@ export async function showDiffAndPromptAction(
): Promise<ListAction | null> {
header(item.info.branch);
if (item.originalInstruction) {
console.log(chalk.dim(` ${item.originalInstruction}`));
info(chalk.dim(` ${item.originalInstruction}`));
}
blankLine();
@ -95,7 +94,7 @@ export async function showDiffAndPromptAction(
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
console.log(stat);
info(stat);
} catch {
warn('Could not generate diff stat');
}
@ -362,3 +361,4 @@ export async function instructBranch(
removeCloneMeta(projectDir, branch);
}
}

View File

@ -0,0 +1,54 @@
/**
* Delete actions for pending and failed tasks.
*
* Provides interactive deletion (with confirm prompt)
* for pending task files and failed task directories.
*/
import { rmSync, unlinkSync } from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js';
import { confirm } from '../../../shared/prompt/index.js';
import { success, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
const log = createLogger('list-tasks');
/**
* 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;
try {
unlinkSync(task.filePath);
} catch (err) {
const msg = getErrorMessage(err);
logError(`Failed to delete pending task "${task.name}": ${msg}`);
log.error('Failed to delete pending task', { name: task.name, filePath: task.filePath, error: msg });
return false;
}
success(`Deleted pending task: ${task.name}`);
log.info('Deleted pending task', { name: task.name, filePath: task.filePath });
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}" and its logs?`, false);
if (!confirmed) return false;
try {
rmSync(task.filePath, { recursive: true });
} catch (err) {
const msg = getErrorMessage(err);
logError(`Failed to delete failed task "${task.name}": ${msg}`);
log.error('Failed to delete failed task', { name: task.name, filePath: task.filePath, error: msg });
return false;
}
success(`Deleted failed task: ${task.name}`);
log.info('Deleted failed task', { name: task.name, filePath: task.filePath });
return true;
}

View File

@ -11,6 +11,7 @@ export type {
BranchInfo,
BranchListItem,
SummarizeOptions,
TaskListItem,
} from './types.js';
// Classes

View File

@ -16,9 +16,12 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js';
import type { TaskInfo, TaskResult } from './types.js';
import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
import { createLogger } from '../../shared/utils/index.js';
export type { TaskInfo, TaskResult };
export type { TaskInfo, TaskResult, TaskListItem };
const log = createLogger('task-runner');
/**
*
@ -129,6 +132,78 @@ export class TaskRunner {
return this.moveTask(result, this.failedDir);
}
/**
* pendingタスクを TaskListItem
*/
listPendingTaskItems(): TaskListItem[] {
return this.listTasks().map((task) => ({
kind: 'pending' as const,
name: task.name,
createdAt: task.createdAt,
filePath: task.filePath,
content: task.content.trim().split('\n')[0]?.slice(0, 80) ?? '',
}));
}
/**
* failedタスクの一覧を取得
* .takt/failed/ TaskListItem
*/
listFailedTasks(): TaskListItem[] {
this.ensureDirs();
const entries = fs.readdirSync(this.failedDir);
return entries
.filter((entry) => {
const entryPath = path.join(this.failedDir, entry);
return fs.statSync(entryPath).isDirectory() && entry.includes('_');
})
.map((entry) => {
const entryPath = path.join(this.failedDir, entry);
const underscoreIdx = entry.indexOf('_');
const timestampRaw = entry.slice(0, underscoreIdx);
const name = entry.slice(underscoreIdx + 1);
const createdAt = timestampRaw.replace(
/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})$/,
'$1:$2:$3',
);
const content = this.readFailedTaskContent(entryPath);
return { kind: 'failed' as const, name, createdAt, filePath: entryPath, content };
})
.filter((item) => item.name !== '');
}
/**
* failedタスクディレクトリ内のタスクファイルから先頭1行を読み取る
*/
private readFailedTaskContent(dirPath: string): string {
const taskExtensions = ['.md', '.yaml', '.yml'];
let files: string[];
try {
files = fs.readdirSync(dirPath);
} catch (err) {
log.error('Failed to read failed task directory', { dirPath, error: String(err) });
return '';
}
for (const file of files) {
const ext = path.extname(file);
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
try {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf-8');
return raw.trim().split('\n')[0]?.slice(0, 80) ?? '';
} catch (err) {
log.error('Failed to read failed task file', { file, dirPath, error: String(err) });
continue;
}
}
return '';
}
/**
*
*/

View File

@ -66,3 +66,12 @@ export interface SummarizeOptions {
/** Use LLM for summarization (default: true). If false, uses romanization. */
useLLM?: boolean;
}
/** pending/failedタスクのリストアイテム */
export interface TaskListItem {
kind: 'pending' | 'failed';
name: string;
createdAt: string;
filePath: string;
content: string;
}