takt 対話モードの save_task を takt add と同じ worktree 設定フローに統一

takt 対話モードで Save Task を選択した際に worktree/branch/auto_pr の
設定プロンプトがスキップされ、takt run で clone なしに実行されて成果物が
消失するバグを修正。promptWorktreeSettings() を共通関数として抽出し、
saveTaskFromInteractive() と addTask() の両方から使用するようにした。
This commit is contained in:
nrslib 2026-02-08 20:26:26 +09:00
parent 8d760c1fc7
commit c0d48df33a
2 changed files with 137 additions and 28 deletions

View File

@ -17,6 +17,11 @@ vi.mock('../shared/ui/index.js', () => ({
blankLine: vi.fn(), blankLine: vi.fn(),
})); }));
vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(),
promptInput: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({ createLogger: () => ({
@ -28,11 +33,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { summarizeTaskName } from '../infra/task/summarize.js'; import { summarizeTaskName } from '../infra/task/summarize.js';
import { success, info } from '../shared/ui/index.js'; import { success, info } from '../shared/ui/index.js';
import { confirm, promptInput } from '../shared/prompt/index.js';
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockSuccess = vi.mocked(success); const mockSuccess = vi.mocked(success);
const mockInfo = vi.mocked(info); const mockInfo = vi.mocked(info);
const mockConfirm = vi.mocked(confirm);
const mockPromptInput = vi.mocked(promptInput);
let testDir: string; let testDir: string;
@ -163,16 +171,82 @@ describe('saveTaskFile', () => {
}); });
describe('saveTaskFromInteractive', () => { describe('saveTaskFromInteractive', () => {
it('should save task and display success message', async () => { it('should save task with worktree settings when user confirms worktree', async () => {
// Given: user confirms worktree, accepts defaults, confirms auto-PR
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When // When
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
// Then // Then
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:'));
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('worktree: true');
expect(content).toContain('auto_pr: true');
});
it('should save task without worktree settings when user declines worktree', async () => {
// Given: user declines worktree
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).not.toContain('worktree:');
expect(content).not.toContain('branch:');
expect(content).not.toContain('auto_pr:');
});
it('should save custom worktree path and branch when specified', async () => {
// Given: user specifies custom path and branch
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce('/custom/path'); // Worktree path
mockPromptInput.mockResolvedValueOnce('feat/branch'); // Branch name
mockConfirm.mockResolvedValueOnce(false); // Auto-create PR? → No
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('worktree: /custom/path');
expect(content).toContain('branch: feat/branch');
expect(content).toContain('auto_pr: false');
});
it('should display worktree/branch/auto-PR info when settings are provided', async () => {
// Given
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce('/my/path'); // Worktree path
mockPromptInput.mockResolvedValueOnce('my-branch'); // Branch name
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Worktree: /my/path');
expect(mockInfo).toHaveBeenCalledWith(' Branch: my-branch');
expect(mockInfo).toHaveBeenCalledWith(' Auto-PR: yes');
}); });
it('should display piece info when specified', async () => { it('should display piece info when specified', async () => {
// Given
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When // When
await saveTaskFromInteractive(testDir, 'Task content', 'review'); await saveTaskFromInteractive(testDir, 'Task content', 'review');
@ -181,6 +255,9 @@ describe('saveTaskFromInteractive', () => {
}); });
it('should include piece in saved YAML', async () => { it('should include piece in saved YAML', async () => {
// Given
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When // When
await saveTaskFromInteractive(testDir, 'Task content', 'custom'); await saveTaskFromInteractive(testDir, 'Task content', 'custom');
@ -193,6 +270,9 @@ describe('saveTaskFromInteractive', () => {
}); });
it('should not display piece info when not specified', async () => { it('should not display piece info when not specified', async () => {
// Given
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When // When
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
@ -202,4 +282,18 @@ describe('saveTaskFromInteractive', () => {
); );
expect(pieceInfoCalls.length).toBe(0); expect(pieceInfoCalls.length).toBe(0);
}); });
it('should display auto worktree info when no custom path', async () => {
// Given
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Worktree: auto');
});
}); });

View File

@ -87,19 +87,52 @@ export function createIssueFromTask(task: string): void {
} }
} }
interface WorktreeSettings {
worktree?: boolean | string;
branch?: string;
autoPr?: boolean;
}
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const useWorktree = await confirm('Create worktree?', true);
if (!useWorktree) {
return {};
}
const customPath = await promptInput('Worktree path (Enter for auto)');
const worktree: boolean | string = customPath || true;
const customBranch = await promptInput('Branch name (Enter for auto)');
const branch = customBranch || undefined;
const autoPr = await confirm('Auto-create PR?', true);
return { worktree, branch, autoPr };
}
/** /**
* Save a task from interactive mode result. * Save a task from interactive mode result.
* Does not prompt for worktree/branch settings. * Prompts for worktree/branch/auto_pr settings before saving.
*/ */
export async function saveTaskFromInteractive( export async function saveTaskFromInteractive(
cwd: string, cwd: string,
task: string, task: string,
piece?: string, piece?: string,
): Promise<void> { ): Promise<void> {
const filePath = await saveTaskFile(cwd, task, { piece }); const settings = await promptWorktreeSettings();
const filePath = await saveTaskFile(cwd, task, { piece, ...settings });
const filename = path.basename(filePath); const filename = path.basename(filePath);
success(`Task created: ${filename}`); success(`Task created: ${filename}`);
info(` Path: ${filePath}`); info(` Path: ${filePath}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (piece) info(` Piece: ${piece}`); if (piece) info(` Piece: ${piece}`);
} }
@ -173,43 +206,25 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
} }
// 3. ワークツリー/ブランチ/PR設定 // 3. ワークツリー/ブランチ/PR設定
let worktree: boolean | string | undefined; const settings = await promptWorktreeSettings();
let branch: string | undefined;
let autoPr: boolean | undefined;
const useWorktree = await confirm('Create worktree?', true);
if (useWorktree) {
const customPath = await promptInput('Worktree path (Enter for auto)');
worktree = customPath || true;
const customBranch = await promptInput('Branch name (Enter for auto)');
if (customBranch) {
branch = customBranch;
}
// PR確認worktreeが有効な場合のみ
autoPr = await confirm('Auto-create PR?', true);
}
// YAMLファイル作成 // YAMLファイル作成
const filePath = await saveTaskFile(cwd, taskContent, { const filePath = await saveTaskFile(cwd, taskContent, {
piece, piece,
issue: issueNumber, issue: issueNumber,
worktree, ...settings,
branch,
autoPr,
}); });
const filename = path.basename(filePath); const filename = path.basename(filePath);
success(`Task created: ${filename}`); success(`Task created: ${filename}`);
info(` Path: ${filePath}`); info(` Path: ${filePath}`);
if (worktree) { if (settings.worktree) {
info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`); info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
} }
if (branch) { if (settings.branch) {
info(` Branch: ${branch}`); info(` Branch: ${settings.branch}`);
} }
if (autoPr) { if (settings.autoPr) {
info(` Auto-PR: yes`); info(` Auto-PR: yes`);
} }
if (piece) { if (piece) {