takt/src/commands/management/listTasks.ts
2026-02-02 06:55:56 +09:00

442 lines
12 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.

/**
* List tasks command
*
* Interactive UI for reviewing branch-based task results:
* try merge, merge & cleanup, or delete actions.
* Clones are ephemeral — only branches persist between sessions.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import chalk from 'chalk';
import {
createTempCloneForBranch,
removeClone,
removeCloneMeta,
cleanupOrphanedClone,
} from '../../task/clone.js';
import {
detectDefaultBranch,
listTaktBranches,
buildListItems,
type BranchListItem,
} from '../../task/branchList.js';
import { autoCommitAndPush } from '../../task/autoCommit.js';
import { selectOption, confirm, promptInput } from '../../prompt/index.js';
import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js';
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
import { listWorkflows } from '../../config/workflowLoader.js';
import { getCurrentWorkflow } from '../../config/paths.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.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', 'inherit', '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.
*/
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();
// Show diff stat
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');
}
// Prompt action
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.
* User can inspect staged changes and commit manually if satisfied.
*/
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.
* Otherwise merge first, then delete the branch.
* No worktree removal needed — clones are ephemeral.
*/
export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
const alreadyMerged = isBranchMerged(projectDir, branch);
try {
// Merge only if not already merged
if (alreadyMerged) {
info(`${branch} is already merged, skipping merge.`);
log.info('Branch already merged, cleanup only', { branch });
} else {
execFileSync('git', ['merge', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
}
// Delete the branch
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.`);
}
// Clean up orphaned clone directory if it still exists
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).
* No worktree removal needed — clones are ephemeral.
*/
export function deleteBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
try {
// Force-delete the branch
execFileSync('git', ['branch', '-D', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
// Clean up orphaned clone directory if it still exists
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 workflow to use for instruction.
* If multiple workflows available, prompt user to select.
*/
async function selectWorkflowForInstruction(projectDir: string): Promise<string | null> {
const availableWorkflows = listWorkflows(projectDir);
const currentWorkflow = getCurrentWorkflow(projectDir);
if (availableWorkflows.length === 0) {
return DEFAULT_WORKFLOW_NAME;
}
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
return availableWorkflows[0];
}
// Multiple workflows: let user select
const options = availableWorkflows.map((name) => ({
label: name === currentWorkflow ? `${name} (current)` : name,
value: name,
}));
return await selectOption('Select workflow:', 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[] = [];
// Get diff stat
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
}
// Get commit log
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;
// 1. Prompt for instruction
const instruction = await promptInput('Enter instruction');
if (!instruction) {
info('Cancelled');
return false;
}
// 2. Select workflow
const selectedWorkflow = await selectWorkflowForInstruction(projectDir);
if (!selectedWorkflow) {
info('Cancelled');
return false;
}
log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow });
info(`Running instruction on ${branch}...`);
// 3. Create temp clone for the branch
const clone = createTempCloneForBranch(projectDir, branch);
try {
// 4. Build instruction with branch context
const branchContext = getBranchContext(projectDir, branch);
const fullInstruction = branchContext
? `${branchContext}## 追加指示\n${instruction}`
: instruction;
// 5. Execute task on temp clone
const taskSuccess = await executeTask({
task: fullInstruction,
cwd: clone.path,
workflowIdentifier: selectedWorkflow,
projectCwd: projectDir,
agentOverrides: options,
});
// 6. Auto-commit+push if successful
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 {
// 7. Always remove temp clone and metadata
removeClone(clone.path);
removeCloneMeta(projectDir, branch);
}
}
/**
* Main entry point: list branch-based tasks interactively.
*/
export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
log.info('Starting list-tasks');
const defaultBranch = detectDefaultBranch(cwd);
let branches = listTaktBranches(cwd);
if (branches.length === 0) {
info('No tasks to list.');
return;
}
// Interactive loop
while (branches.length > 0) {
const items = buildListItems(cwd, branches, defaultBranch);
// Build selection options
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,
};
});
const selected = await selectOption<string>(
'List Tasks (Branches)',
menuOptions,
);
if (selected === null) {
return;
}
const selectedIdx = parseInt(selected, 10);
const item = items[selectedIdx];
if (!item) continue;
// Action loop: re-show menu after viewing diff
let action: ListAction | null;
do {
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
if (action === 'diff') {
showFullDiff(cwd, defaultBranch, item.info.branch);
}
} 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;
}
}
// Refresh branch list after action
branches = listTaktBranches(cwd);
}
info('All tasks listed.');
}