takt/src/features/tasks/list/taskActions.ts
nrslib ee0cb8e13a E2Eテスト基盤の追加・レビューエージェント改善・lint修正
- E2Eテストのフィクスチャ、ヘルパー、スペックを追加
- mock/provider別のvitest設定を追加
- レビューエージェントのプロンプト改善
- TTY判定の共通化、list/confirmのnon-interactive対応
- eslint no-non-null-assertion を off に変更、未使用インポート削除
2026-02-05 16:59:32 +09:00

365 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Individual actions for branch-based tasks.
*
* Provides merge, delete, try-merge, instruct, and diff operations
* for branches listed by the listTasks command.
*/
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,
} 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';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executeTask } from '../execute/taskExecution.js';
import type { TaskExecutionOptions } from '../execute/types.js';
import { listPieces, getCurrentPiece } from '../../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js';
const log = createLogger('list-tasks');
/** Actions available for a listed branch */
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
/**
* Check if a branch has already been merged into HEAD.
*/
export function isBranchMerged(projectDir: string, branch: string): boolean {
try {
execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
/**
* Show full diff in an interactive pager (less).
* Falls back to direct output if pager is unavailable.
*/
export function showFullDiff(
cwd: string,
defaultBranch: string,
branch: string,
): void {
try {
const result = spawnSync(
'git', ['diff', '--color=always', `${defaultBranch}...${branch}`],
{
cwd,
stdio: 'inherit',
env: { ...process.env, GIT_PAGER: 'less -R' },
},
);
if (result.status !== 0) {
warn('Could not display diff');
}
} catch {
warn('Could not display diff');
}
}
/**
* Show diff stat for a branch and prompt for an action.
*/
export async function showDiffAndPromptAction(
cwd: string,
defaultBranch: string,
item: BranchListItem,
): Promise<ListAction | null> {
header(item.info.branch);
if (item.originalInstruction) {
console.log(chalk.dim(` ${item.originalInstruction}`));
}
blankLine();
try {
const stat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
console.log(stat);
} catch {
warn('Could not generate diff stat');
}
const action = await selectOption<ListAction>(
`Action for ${item.info.branch}:`,
[
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
],
);
return action;
}
/**
* Try-merge (squash): stage changes from branch without committing.
*/
export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
try {
execFileSync('git', ['merge', '--squash', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
success(`Squash-merged ${branch} (changes staged, not committed)`);
info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.');
log.info('Try-merge (squash) completed', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Squash merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Try-merge (squash) failed', { branch, error: msg });
return false;
}
}
/**
* Merge & cleanup: if already merged, skip merge and just delete the branch.
*/
export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
const alreadyMerged = isBranchMerged(projectDir, branch);
try {
if (alreadyMerged) {
info(`${branch} is already merged, skipping merge.`);
log.info('Branch already merged, cleanup only', { branch });
} else {
execFileSync('git', ['merge', '--no-edit', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
env: {
...process.env,
GIT_MERGE_AUTOEDIT: 'no',
},
});
}
try {
execFileSync('git', ['branch', '-d', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
} catch {
warn(`Could not delete branch ${branch}. You may delete it manually.`);
}
cleanupOrphanedClone(projectDir, branch);
success(`Merged & cleaned up ${branch}`);
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Merge & cleanup failed', { branch, error: msg });
return false;
}
}
/**
* Delete a branch (discard changes).
* For worktree branches, removes the worktree directory and session file.
*/
export function deleteBranch(projectDir: string, item: BranchListItem): boolean {
const { branch, worktreePath } = item.info;
try {
// If this is a worktree branch, remove the worktree directory and session file
if (worktreePath) {
// Remove worktree directory if it exists
if (existsSync(worktreePath)) {
rmSync(worktreePath, { recursive: true, force: true });
log.info('Removed worktree directory', { worktreePath });
}
// Remove worktree-session file
const encodedPath = encodeWorktreePath(worktreePath);
const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`);
if (existsSync(sessionFile)) {
unlinkSync(sessionFile);
log.info('Removed worktree-session file', { sessionFile });
}
success(`Deleted worktree ${branch}`);
log.info('Worktree branch deleted', { branch, worktreePath });
return true;
}
// For regular branches, use git branch -D
execFileSync('git', ['branch', '-D', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
cleanupOrphanedClone(projectDir, branch);
success(`Deleted ${branch}`);
log.info('Branch deleted', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Delete failed: ${msg}`);
log.error('Delete failed', { branch, error: msg });
return false;
}
}
/**
* Get the piece to use for instruction.
*/
async function selectPieceForInstruction(projectDir: string): Promise<string | null> {
const availablePieces = listPieces(projectDir);
const currentPiece = getCurrentPiece(projectDir);
if (availablePieces.length === 0) {
return DEFAULT_PIECE_NAME;
}
if (availablePieces.length === 1 && availablePieces[0]) {
return availablePieces[0];
}
const options = availablePieces.map((name) => ({
label: name === currentPiece ? `${name} (current)` : name,
value: name,
}));
return await selectOption('Select piece:', options);
}
/**
* Get branch context: diff stat and commit log from main branch.
*/
function getBranchContext(projectDir: string, branch: string): string {
const defaultBranch = detectDefaultBranch(projectDir);
const lines: string[] = [];
try {
const diffStat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (diffStat) {
lines.push('## 現在の変更内容mainからの差分');
lines.push('```');
lines.push(diffStat);
lines.push('```');
}
} catch {
// Ignore errors
}
try {
const commitLog = execFileSync(
'git', ['log', '--oneline', `${defaultBranch}..${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (commitLog) {
lines.push('');
lines.push('## コミット履歴');
lines.push('```');
lines.push(commitLog);
lines.push('```');
}
} catch {
// Ignore errors
}
return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
}
/**
* Instruct branch: create a temp clone, give additional instructions,
* auto-commit+push, then remove clone.
*/
export async function instructBranch(
projectDir: string,
item: BranchListItem,
options?: TaskExecutionOptions,
): Promise<boolean> {
const { branch } = item.info;
const instruction = await promptInput('Enter instruction');
if (!instruction) {
info('Cancelled');
return false;
}
const selectedPiece = await selectPieceForInstruction(projectDir);
if (!selectedPiece) {
info('Cancelled');
return false;
}
log.info('Instructing branch via temp clone', { branch, piece: selectedPiece });
info(`Running instruction on ${branch}...`);
const clone = createTempCloneForBranch(projectDir, branch);
try {
const branchContext = getBranchContext(projectDir, branch);
const fullInstruction = branchContext
? `${branchContext}## 追加指示\n${instruction}`
: instruction;
const taskSuccess = await executeTask({
task: fullInstruction,
cwd: clone.path,
pieceIdentifier: selectedPiece,
projectCwd: projectDir,
agentOverrides: options,
});
if (taskSuccess) {
const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir);
if (commitResult.success && commitResult.commitHash) {
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
warn(`Auto-commit skipped: ${commitResult.message}`);
}
success(`Instruction completed on ${branch}`);
log.info('Instruction completed', { branch });
} else {
logError(`Instruction failed on ${branch}`);
log.error('Instruction failed', { branch });
}
return taskSuccess;
} finally {
removeClone(clone.path);
removeCloneMeta(projectDir, branch);
}
}