fix(tags): Resolve tag deletion bug in remove-task command

Refactored the core 'removeTask' function to be fully tag-aware, preventing data corruption.

- The function now correctly reads the full tagged data structure by prioritizing '_rawTaggedData' instead of operating on a resolved single-tag view.

- All subsequent operations (task removal, dependency cleanup, file writing) now correctly reference the full multi-tag data object, preserving the integrity of 'tasks.json'.

- This resolves the critical bug where removing a task would delete all other tags.
This commit is contained in:
Eyal Toledano
2025-06-13 00:18:53 -04:00
parent 4585a6bbc7
commit b205e52d08
7 changed files with 208 additions and 191 deletions

View File

@@ -2525,7 +2525,6 @@ ${result.result}
programInstance
.command('remove-task')
.description('Remove one or more tasks or subtasks permanently')
.description('Remove one or more tasks or subtasks permanently')
.option(
'-i, --id <ids>',
'ID(s) of the task(s) or subtask(s) to remove (e.g., "5", "5.2", or "5,6.1,7")'
@@ -2536,9 +2535,17 @@ ${result.result}
TASKMASTER_TASKS_FILE
)
.option('-y, --yes', 'Skip confirmation prompt', false)
.option('--tag <tag>', 'Specify tag context for task operations')
.action(async (options) => {
const tasksPath = options.file || TASKMASTER_TASKS_FILE;
const taskIdsString = options.id;
const tag = options.tag;
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error(chalk.red('Error: Could not find project root.'));
process.exit(1);
}
if (!taskIdsString) {
console.error(chalk.red('Error: Task ID(s) are required'));
@@ -2562,7 +2569,7 @@ ${result.result}
try {
// Read data once for checks and confirmation
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks) {
console.error(
chalk.red(`Error: No valid tasks found in ${tasksPath}`)
@@ -2702,7 +2709,10 @@ ${result.result}
const existingIdsString = existingTasksToRemove
.map(({ id }) => id)
.join(',');
const result = await removeTask(tasksPath, existingIdsString);
const result = await removeTask(tasksPath, existingIdsString, {
projectRoot,
tag
});
stopLoadingIndicator(indicator);

View File

@@ -149,7 +149,7 @@ async function addDependency(tasksPath, taskId, dependencyId) {
}
// Check for circular dependencies
let dependencyChain = [formattedTaskId];
const dependencyChain = [formattedTaskId];
if (
!isCircularDependency(data.tasks, formattedDependencyId, dependencyChain)
) {

View File

@@ -16,108 +16,111 @@ import { getDebugFlag } from '../config-manager.js';
*/
function generateTaskFiles(tasksPath, outputDir, options = {}) {
try {
// Determine if we're in MCP mode by checking for mcpLog
const isMcpMode = !!options?.mcpLog;
const data = readJSON(tasksPath, options.projectRoot, options.tag);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
// 1. Read the raw data structure, ensuring we have all tags.
// We call readJSON without a specific tag to get the resolved default view,
// which correctly contains the full structure in `_rawTaggedData`.
const resolvedData = readJSON(tasksPath, options.projectRoot);
if (!resolvedData) {
throw new Error(`Could not read or parse tasks file: ${tasksPath}`);
}
// Prioritize the _rawTaggedData if it exists, otherwise use the data as is.
const rawData = resolvedData._rawTaggedData || resolvedData;
// 2. Determine the target tag we need to generate files for.
const targetTag = options.tag || resolvedData.tag || 'master';
const tagData = rawData[targetTag];
if (!tagData || !tagData.tasks) {
throw new Error(
`Tag '${targetTag}' not found or has no tasks in the data.`
);
}
const tasksForGeneration = tagData.tasks;
// Create the output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
log('info', `Preparing to regenerate ${data.tasks.length} task files`);
// Validate and fix dependencies before generating files
log('info', `Validating and fixing dependencies`);
validateAndFixDependencies(
data,
tasksPath,
options.projectRoot,
options.tag
log(
'info',
`Preparing to regenerate ${tasksForGeneration.length} task files for tag '${targetTag}'`
);
// Get valid task IDs from tasks.json
const validTaskIds = data.tasks.map((task) => task.id);
// 3. Validate dependencies using the FULL, raw data structure to prevent data loss.
validateAndFixDependencies(
rawData, // Pass the entire object with all tags
tasksPath,
options.projectRoot,
targetTag // Provide the current tag context for the operation
);
const allTasksInTag = tagData.tasks;
const validTaskIds = allTasksInTag.map((task) => task.id);
// Cleanup orphaned task files
log('info', 'Checking for orphaned task files to clean up...');
try {
// Get all task files in the output directory
const files = fs.readdirSync(outputDir);
const taskFilePattern = /^task_(\d+)\.txt$/;
// Filter for task files and check if they match a valid task ID
const orphanedFiles = files.filter((file) => {
const match = file.match(taskFilePattern);
if (match) {
const fileTaskId = parseInt(match[1], 10);
// Important: Only clean up files for tasks that *should* be in the current tag.
// This prevents deleting files from other tags.
// A more robust cleanup might need to check across all tags.
// For now, this is safer than the previous implementation.
return !validTaskIds.includes(fileTaskId);
}
return false;
});
// Delete orphaned files
if (orphanedFiles.length > 0) {
log(
'info',
`Found ${orphanedFiles.length} orphaned task files to remove`
`Found ${orphanedFiles.length} orphaned task files to remove for tag '${targetTag}'`
);
orphanedFiles.forEach((file) => {
const filePath = path.join(outputDir, file);
try {
fs.unlinkSync(filePath);
log('info', `Removed orphaned task file: ${file}`);
} catch (err) {
log(
'warn',
`Failed to remove orphaned task file ${file}: ${err.message}`
);
}
fs.unlinkSync(filePath);
});
} else {
log('info', 'No orphaned task files found');
log('info', 'No orphaned task files found.');
}
} catch (err) {
log('warn', `Error cleaning up orphaned task files: ${err.message}`);
// Continue with file generation even if cleanup fails
}
// Generate task files
log('info', 'Generating individual task files...');
data.tasks.forEach((task) => {
// Generate task files for the target tag
log('info', `Generating individual task files for tag '${targetTag}'...`);
tasksForGeneration.forEach((task) => {
const taskPath = path.join(
outputDir,
`task_${task.id.toString().padStart(3, '0')}.txt`
);
// Format the content
let content = `# Task ID: ${task.id}\n`;
content += `# Title: ${task.title}\n`;
content += `# Status: ${task.status || 'pending'}\n`;
// Format dependencies with their status
if (task.dependencies && task.dependencies.length > 0) {
content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, data.tasks, false)}\n`;
content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, allTasksInTag, false)}\n`;
} else {
content += '# Dependencies: None\n';
}
content += `# Priority: ${task.priority || 'medium'}\n`;
content += `# Description: ${task.description || ''}\n`;
// Add more detailed sections
content += '# Details:\n';
content += (task.details || '')
.split('\n')
.map((line) => line)
.join('\n');
content += '\n\n';
content += '# Test Strategy:\n';
content += (task.testStrategy || '')
.split('\n')
@@ -125,36 +128,22 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) {
.join('\n');
content += '\n';
// Add subtasks if they exist
if (task.subtasks && task.subtasks.length > 0) {
content += '\n# Subtasks:\n';
task.subtasks.forEach((subtask) => {
content += `## ${subtask.id}. ${subtask.title} [${subtask.status || 'pending'}]\n`;
if (subtask.dependencies && subtask.dependencies.length > 0) {
// Format subtask dependencies
let subtaskDeps = subtask.dependencies
.map((depId) => {
if (typeof depId === 'number') {
// Handle numeric dependencies to other subtasks
const foundSubtask = task.subtasks.find(
(st) => st.id === depId
);
if (foundSubtask) {
// Just return the plain ID format without any color formatting
return `${task.id}.${depId}`;
}
}
return depId.toString();
})
const subtaskDeps = subtask.dependencies
.map((depId) =>
typeof depId === 'number'
? `${task.id}.${depId}`
: depId.toString()
)
.join(', ');
content += `### Dependencies: ${subtaskDeps}\n`;
} else {
content += '### Dependencies: None\n';
}
content += `### Description: ${subtask.description || ''}\n`;
content += '### Details:\n';
content += (subtask.details || '')
@@ -165,39 +154,30 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) {
});
}
// Write the file
fs.writeFileSync(taskPath, content);
// log('info', `Generated: task_${task.id.toString().padStart(3, '0')}.txt`); // Pollutes the CLI output
});
log(
'success',
`All ${data.tasks.length} tasks have been generated into '${outputDir}'.`
`All ${tasksForGeneration.length} tasks for tag '${targetTag}' have been generated into '${outputDir}'.`
);
// Return success data in MCP mode
if (isMcpMode) {
return {
success: true,
count: data.tasks.length,
count: tasksForGeneration.length,
directory: outputDir
};
}
} catch (error) {
log('error', `Error generating task files: ${error.message}`);
// Only show error UI in CLI mode
if (!options?.mcpLog) {
console.error(chalk.red(`Error generating task files: ${error.message}`));
if (getDebugFlag()) {
// Use getter
console.error(error);
}
process.exit(1);
} else {
// In MCP mode, throw the error for the caller to handle
throw error;
}
}

View File

@@ -9,9 +9,11 @@ import taskExists from './task-exists.js';
* Removes one or more tasks or subtasks from the tasks file
* @param {string} tasksPath - Path to the tasks file
* @param {string} taskIds - Comma-separated string of task/subtask IDs to remove (e.g., '5,6.1,7')
* @param {Object} context - Context object containing projectRoot and tag information
* @returns {Object} Result object with success status, messages, and removed task info
*/
async function removeTask(tasksPath, taskIds) {
async function removeTask(tasksPath, taskIds, context = {}) {
const { projectRoot, tag } = context;
const results = {
success: true,
messages: [],
@@ -30,18 +32,28 @@ async function removeTask(tasksPath, taskIds) {
}
try {
// Read the tasks file ONCE before the loop
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
// Read the tasks file ONCE before the loop, preserving the full tagged structure
const rawData = readJSON(tasksPath, projectRoot); // Read raw data
if (!rawData) {
throw new Error(`Could not read tasks file at ${tasksPath}`);
}
// Use the full tagged data if available, otherwise use the data as is
const fullTaggedData = rawData._rawTaggedData || rawData;
const currentTag = tag || rawData.tag || 'master';
if (!fullTaggedData[currentTag] || !fullTaggedData[currentTag].tasks) {
throw new Error(`Tag '${currentTag}' not found or has no tasks.`);
}
const tasks = fullTaggedData[currentTag].tasks; // Work with tasks from the correct tag
const tasksToDeleteFiles = []; // Collect IDs of main tasks whose files should be deleted
for (const taskId of taskIdsToRemove) {
// Check if the task ID exists *before* attempting removal
if (!taskExists(data.tasks, taskId)) {
const errorMsg = `Task with ID ${taskId} not found or already removed.`;
if (!taskExists(tasks, taskId)) {
const errorMsg = `Task with ID ${taskId} in tag '${currentTag}' not found or already removed.`;
results.errors.push(errorMsg);
results.success = false; // Mark overall success as false if any error occurs
continue; // Skip to the next ID
@@ -55,7 +67,7 @@ async function removeTask(tasksPath, taskIds) {
.map((id) => parseInt(id, 10));
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentTaskId);
const parentTask = tasks.find((t) => t.id === parentTaskId);
if (!parentTask || !parentTask.subtasks) {
throw new Error(
`Parent task ${parentTaskId} or its subtasks not found for subtask ${taskId}`
@@ -82,27 +94,31 @@ async function removeTask(tasksPath, taskIds) {
// Remove the subtask from the parent
parentTask.subtasks.splice(subtaskIndex, 1);
results.messages.push(`Successfully removed subtask ${taskId}`);
results.messages.push(
`Successfully removed subtask ${taskId} from tag '${currentTag}'`
);
}
// Handle main task removal
else {
const taskIdNum = parseInt(taskId, 10);
const taskIndex = data.tasks.findIndex((t) => t.id === taskIdNum);
const taskIndex = tasks.findIndex((t) => t.id === taskIdNum);
if (taskIndex === -1) {
// This case should theoretically be caught by the taskExists check above,
// but keep it as a safeguard.
throw new Error(`Task with ID ${taskId} not found`);
throw new Error(
`Task with ID ${taskId} not found in tag '${currentTag}'`
);
}
// Store the task info before removal
const removedTask = data.tasks[taskIndex];
const removedTask = tasks[taskIndex];
results.removedTasks.push(removedTask);
tasksToDeleteFiles.push(taskIdNum); // Add to list for file deletion
// Remove the task from the main array
data.tasks.splice(taskIndex, 1);
tasks.splice(taskIndex, 1);
results.messages.push(`Successfully removed task ${taskId}`);
results.messages.push(
`Successfully removed task ${taskId} from tag '${currentTag}'`
);
}
} catch (innerError) {
// Catch errors specific to processing *this* ID
@@ -117,36 +133,46 @@ async function removeTask(tasksPath, taskIds) {
// Only proceed with cleanup and saving if at least one task was potentially removed
if (results.removedTasks.length > 0) {
// Remove all references AFTER all tasks/subtasks are removed
const allRemovedIds = new Set(
taskIdsToRemove.map((id) =>
typeof id === 'string' && id.includes('.') ? id : parseInt(id, 10)
)
);
data.tasks.forEach((task) => {
// Clean dependencies in main tasks
if (task.dependencies) {
task.dependencies = task.dependencies.filter(
(depId) => !allRemovedIds.has(depId)
);
}
// Clean dependencies in remaining subtasks
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (subtask.dependencies) {
subtask.dependencies = subtask.dependencies.filter(
(depId) =>
!allRemovedIds.has(`${task.id}.${depId}`) &&
!allRemovedIds.has(depId) // check both subtask and main task refs
// Update the tasks in the current tag of the full data structure
fullTaggedData[currentTag].tasks = tasks;
// Remove dependencies from all tags
for (const tagName in fullTaggedData) {
if (
Object.prototype.hasOwnProperty.call(fullTaggedData, tagName) &&
fullTaggedData[tagName] &&
fullTaggedData[tagName].tasks
) {
const currentTagTasks = fullTaggedData[tagName].tasks;
currentTagTasks.forEach((task) => {
if (task.dependencies) {
task.dependencies = task.dependencies.filter(
(depId) => !allRemovedIds.has(depId)
);
}
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (subtask.dependencies) {
subtask.dependencies = subtask.dependencies.filter(
(depId) =>
!allRemovedIds.has(`${task.id}.${depId}`) &&
!allRemovedIds.has(depId)
);
}
});
}
});
}
});
}
// Save the updated tasks file ONCE
writeJSON(tasksPath, data);
// Save the updated raw data structure
writeJSON(tasksPath, fullTaggedData);
// Delete task files AFTER saving tasks.json
for (const taskIdNum of tasksToDeleteFiles) {
@@ -167,9 +193,12 @@ async function removeTask(tasksPath, taskIds) {
}
}
// Generate updated task files ONCE
// Generate updated task files ONCE, with context
try {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
projectRoot,
tag: currentTag
});
results.messages.push('Task files regenerated successfully.');
} catch (genError) {
const genErrMsg = `Failed to regenerate task files: ${genError.message}`;
@@ -178,7 +207,6 @@ async function removeTask(tasksPath, taskIds) {
log('warn', genErrMsg);
}
} else if (results.errors.length === 0) {
// Case where valid IDs were provided but none existed
results.messages.push('No tasks found matching the provided IDs.');
}