feat(research): Enhance research command with follow-up menu, save functionality, and fix ContextGatherer token counting

This commit is contained in:
Eyal Toledano
2025-06-13 15:54:21 -04:00
parent 92234323d7
commit a047886910
12 changed files with 1383 additions and 19 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Adds ability to automatically create/switch tags to match the current git branch. The configuration to enable the git workflow and then use the auto switching is in config.json."

View File

@@ -32,7 +32,7 @@
"defaultTag": "master"
},
"tags": {
"enabledGitworkflow": false,
"autoSwitchTagWithBranch": false
"enabledGitworkflow": true,
"autoSwitchTagWithBranch": true
}
}

View File

@@ -1,6 +1,8 @@
{
"currentTag": "master",
"lastSwitched": "2025-06-13T15:17:22.946Z",
"branchTagMapping": {},
"currentTag": "v017-adds",
"lastSwitched": "2025-06-13T17:56:13.380Z",
"branchTagMapping": {
"v017-adds": "v017-adds"
},
"migrationNoticeShown": true
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,159 @@
/**
* create-tag-from-branch.js
* Direct function implementation for creating tags from git branches
*/
import { createTagFromBranch } from '../../../../scripts/modules/task-manager/tag-management.js';
import {
getCurrentBranch,
isGitRepository
} from '../../../../scripts/modules/utils/git-utils.js';
import {
enableSilentMode,
disableSilentMode
} from '../../../../scripts/modules/utils.js';
import { createLogWrapper } from '../../tools/utils.js';
/**
* Direct function wrapper for creating tags from git branches with error handling.
*
* @param {Object} args - Command arguments
* @param {string} args.tasksJsonPath - Path to the tasks.json file (resolved by tool)
* @param {string} [args.branchName] - Git branch name (optional, uses current branch if not provided)
* @param {boolean} [args.copyFromCurrent] - Copy tasks from current tag
* @param {string} [args.copyFromTag] - Copy tasks from specific tag
* @param {string} [args.description] - Custom description for the tag
* @param {boolean} [args.autoSwitch] - Automatically switch to the new tag
* @param {string} [args.projectRoot] - Project root path
* @param {Object} log - Logger object
* @param {Object} context - Additional context (session)
* @returns {Promise<Object>} - Result object { success: boolean, data?: any, error?: { code: string, message: string } }
*/
export async function createTagFromBranchDirect(args, log, context = {}) {
// Destructure expected args
const {
tasksJsonPath,
branchName,
copyFromCurrent,
copyFromTag,
description,
autoSwitch,
projectRoot
} = args;
const { session } = context;
// Enable silent mode to prevent console logs from interfering with JSON response
enableSilentMode();
// Create logger wrapper using the utility
const mcpLog = createLogWrapper(log);
try {
// Check if tasksJsonPath was provided
if (!tasksJsonPath) {
log.error('createTagFromBranchDirect called without tasksJsonPath');
disableSilentMode();
return {
success: false,
error: {
code: 'MISSING_ARGUMENT',
message: 'tasksJsonPath is required'
}
};
}
// Check if projectRoot was provided
if (!projectRoot) {
log.error('createTagFromBranchDirect called without projectRoot');
disableSilentMode();
return {
success: false,
error: {
code: 'MISSING_ARGUMENT',
message: 'projectRoot is required'
}
};
}
// Check if we're in a git repository
if (!(await isGitRepository(projectRoot))) {
log.error('Not in a git repository');
disableSilentMode();
return {
success: false,
error: {
code: 'NOT_GIT_REPOSITORY',
message: 'Not in a git repository. Cannot create tag from branch.'
}
};
}
// Determine branch name
let targetBranch = branchName;
if (!targetBranch) {
targetBranch = await getCurrentBranch(projectRoot);
if (!targetBranch) {
log.error('Could not determine current git branch');
disableSilentMode();
return {
success: false,
error: {
code: 'NO_CURRENT_BRANCH',
message: 'Could not determine current git branch'
}
};
}
}
log.info(`Creating tag from git branch: ${targetBranch}`);
// Prepare options
const options = {
copyFromCurrent: copyFromCurrent || false,
copyFromTag,
description:
description || `Tag created from git branch "${targetBranch}"`,
autoSwitch: autoSwitch || false
};
// Call the createTagFromBranch function
const result = await createTagFromBranch(
tasksJsonPath,
targetBranch,
options,
{
session,
mcpLog,
projectRoot
},
'json' // outputFormat - use 'json' to suppress CLI UI
);
// Restore normal logging
disableSilentMode();
return {
success: true,
data: {
branchName: result.branchName,
tagName: result.tagName,
created: result.created,
mappingUpdated: result.mappingUpdated,
autoSwitched: result.autoSwitched,
message: `Successfully created tag "${result.tagName}" from branch "${result.branchName}"`
}
};
} catch (error) {
// Make sure to restore normal logging even if there's an error
disableSilentMode();
log.error(`Error in createTagFromBranchDirect: ${error.message}`);
return {
success: false,
error: {
code: error.code || 'CREATE_TAG_FROM_BRANCH_ERROR',
message: error.message
}
};
}
}

View File

@@ -22,6 +22,7 @@ import { createLogWrapper } from '../../tools/utils.js';
* @param {boolean} [args.includeProjectTree=false] - Include project file tree in context
* @param {string} [args.detailLevel='medium'] - Detail level: 'low', 'medium', 'high'
* @param {string} [args.saveTo] - Automatically save to task/subtask ID (e.g., "15" or "15.2")
* @param {boolean} [args.saveToFile=false] - Save research results to .taskmaster/docs/research/ directory
* @param {string} [args.projectRoot] - Project root path
* @param {Object} log - Logger object
* @param {Object} context - Additional context (session)
@@ -37,6 +38,7 @@ export async function researchDirect(args, log, context = {}) {
includeProjectTree = false,
detailLevel = 'medium',
saveTo,
saveToFile = false,
projectRoot
} = args;
const { session } = context; // Destructure session from context
@@ -108,7 +110,8 @@ export async function researchDirect(args, log, context = {}) {
customContext: customContext || '',
includeProjectTree,
detailLevel,
projectRoot
projectRoot,
saveToFile
};
// Prepare context for the research function
@@ -226,7 +229,8 @@ ${result.result}`;
totalInputTokens: result.totalInputTokens,
detailLevel: result.detailLevel,
telemetryData: result.telemetryData,
tagInfo: result.tagInfo
tagInfo: result.tagInfo,
savedFilePath: result.savedFilePath
}
};
} catch (error) {

View File

@@ -53,6 +53,12 @@ export function registerResearchTool(server) {
.describe(
'Automatically save research results to specified task/subtask ID (e.g., "15" or "15.2")'
),
saveToFile: z
.boolean()
.optional()
.describe(
'Save research results to .taskmaster/docs/research/ directory (default: false)'
),
projectRoot: z
.string()
.describe('The directory of the project. Must be an absolute path.')
@@ -73,6 +79,7 @@ export function registerResearchTool(server) {
includeProjectTree: args.includeProjectTree || false,
detailLevel: args.detailLevel || 'medium',
saveTo: args.saveTo,
saveToFile: args.saveToFile || false,
projectRoot: args.projectRoot
},
log,

View File

@@ -1644,6 +1644,10 @@ function registerCommands(programInstance) {
'--save-to <id>',
'Automatically save research results to specified task/subtask ID (e.g., "15" or "15.2")'
)
.option(
'--save-to-file <file>',
'Save research results to the specified file'
)
.option('--tag <tag>', 'Specify tag context for task operations')
.action(async (prompt, options) => {
// Parameter validation
@@ -1842,6 +1846,7 @@ function registerCommands(programInstance) {
includeProjectTree: validatedParams.includeProjectTree,
detailLevel: validatedParams.detailLevel,
projectRoot: validatedParams.projectRoot,
saveToFile: validatedParams.saveFile,
tag: tag
};
@@ -3734,19 +3739,26 @@ Examples:
process.exit(1);
}
const createOptions = {
copyFromCurrent: options.copyFromCurrent || false,
copyFromTag: options.copyFrom,
description: options.description
};
const context = {
projectRoot,
commandName: 'add-tag',
outputType: 'cli'
};
// Regular tag creation
const createOptions = {
copyFromCurrent: options.copyFromCurrent || false,
copyFromTag: options.copyFrom,
description: options.description
};
await createTag(tasksPath, tagName, createOptions, context, 'text');
// Handle auto-switch if requested
if (options.autoSwitch) {
const { useTag } = await import('./task-manager/tag-management.js');
await useTag(tasksPath, tagName, {}, context, 'text');
}
} catch (error) {
console.error(chalk.red(`Error creating tag: ${error.message}`));
showAddTagHelp();

View File

@@ -34,6 +34,7 @@ import {
* @param {boolean} [options.includeProjectTree] - Include project file tree
* @param {string} [options.detailLevel] - Detail level: 'low', 'medium', 'high'
* @param {string} [options.projectRoot] - Project root directory
* @param {boolean} [options.saveToFile] - Whether to save results to file (MCP mode)
* @param {Object} [context] - Execution context
* @param {Object} [context.session] - MCP session object
* @param {Object} [context.mcpLog] - MCP logger object
@@ -56,7 +57,8 @@ async function performResearch(
customContext = '',
includeProjectTree = false,
detailLevel = 'medium',
projectRoot: providedProjectRoot
projectRoot: providedProjectRoot,
saveToFile = false
} = options;
const {
@@ -275,6 +277,41 @@ async function performResearch(
}
}
// Handle MCP save-to-file request
if (saveToFile && isMCP) {
const conversationHistory = [
{
question: query,
answer: researchResult,
type: 'initial',
timestamp: new Date().toISOString()
}
];
const savedFilePath = await handleSaveToFile(
conversationHistory,
projectRoot,
context,
logFn
);
// Add saved file path to return data
return {
query,
result: researchResult,
contextSize: gatheredContext.length,
contextTokens: tokenBreakdown.total,
tokenBreakdown,
systemPromptTokens,
userPromptTokens,
totalInputTokens,
detailLevel,
telemetryData,
tagInfo,
savedFilePath
};
}
logFn.success('Research query completed successfully');
return {
@@ -631,10 +668,11 @@ async function handleFollowUpQuestions(
message: 'What would you like to do next?',
choices: [
{ name: 'Ask a follow-up question', value: 'followup' },
{ name: 'Save to file', value: 'savefile' },
{ name: 'Save to task/subtask', value: 'save' },
{ name: 'Quit', value: 'quit' }
],
pageSize: 3
pageSize: 4
}
]);
@@ -642,6 +680,17 @@ async function handleFollowUpQuestions(
break;
}
if (action === 'savefile') {
// Handle save to file functionality
await handleSaveToFile(
conversationHistory,
projectRoot,
context,
logFn
);
continue;
}
if (action === 'save') {
// Handle save functionality
await handleSaveToTask(
@@ -856,6 +905,122 @@ async function handleSaveToTask(
}
}
/**
* Handle saving conversation to a file in .taskmaster/docs/research/
* @param {Array} conversationHistory - Array of conversation exchanges
* @param {string} projectRoot - Project root directory
* @param {Object} context - Execution context
* @param {Object} logFn - Logger function
* @returns {Promise<string>} Path to saved file
*/
async function handleSaveToFile(
conversationHistory,
projectRoot,
context,
logFn
) {
try {
// Create research directory if it doesn't exist
const researchDir = path.join(
projectRoot,
'.taskmaster',
'docs',
'research'
);
if (!fs.existsSync(researchDir)) {
fs.mkdirSync(researchDir, { recursive: true });
}
// Generate filename from first query and timestamp
const firstQuery = conversationHistory[0]?.question || 'research-query';
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
// Create a slug from the query (remove special chars, limit length)
const querySlug = firstQuery
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.substring(0, 50) // Limit length
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
const filename = `${timestamp}_${querySlug}.md`;
const filePath = path.join(researchDir, filename);
// Format conversation for file
const fileContent = formatConversationForFile(
conversationHistory,
firstQuery
);
// Write file
fs.writeFileSync(filePath, fileContent, 'utf8');
const relativePath = path.relative(projectRoot, filePath);
console.log(
chalk.green(`✅ Research saved to: ${chalk.cyan(relativePath)}`)
);
logFn.success(`Research conversation saved to ${relativePath}`);
return filePath;
} catch (error) {
console.log(chalk.red(`❌ Error saving research file: ${error.message}`));
logFn.error(`Error saving research file: ${error.message}`);
throw error;
}
}
/**
* Format conversation history for saving to a file
* @param {Array} conversationHistory - Array of conversation exchanges
* @param {string} initialQuery - The initial query for metadata
* @returns {string} Formatted file content
*/
function formatConversationForFile(conversationHistory, initialQuery) {
const timestamp = new Date().toISOString();
const date = new Date().toLocaleDateString();
const time = new Date().toLocaleTimeString();
// Create metadata header
let content = `---
title: Research Session
query: "${initialQuery}"
date: ${date}
time: ${time}
timestamp: ${timestamp}
exchanges: ${conversationHistory.length}
---
# Research Session
**Query:** ${initialQuery}
**Date:** ${date} ${time}
**Exchanges:** ${conversationHistory.length}
---
`;
// Add each conversation exchange
conversationHistory.forEach((exchange, index) => {
if (exchange.type === 'initial') {
content += `## Initial Query\n\n**Question:** ${exchange.question}\n\n**Response:**\n\n${exchange.answer}\n\n`;
} else {
content += `## Follow-up ${index}\n\n**Question:** ${exchange.question}\n\n**Response:**\n\n${exchange.answer}\n\n`;
}
if (index < conversationHistory.length - 1) {
content += '---\n\n';
}
});
// Add footer
content += `\n---\n\n*Generated by Task Master Research Command* \n*Timestamp: ${timestamp}*\n`;
return content;
}
/**
* Format conversation history for saving to a task/subtask
* @param {Array} conversationHistory - Array of conversation exchanges

View File

@@ -1151,6 +1151,351 @@ async function switchCurrentTag(projectRoot, tagName) {
}
}
/**
* Update branch-tag mapping in state.json
* @param {string} projectRoot - Project root directory
* @param {string} branchName - Git branch name
* @param {string} tagName - Tag name to map to
* @returns {Promise<void>}
*/
async function updateBranchTagMapping(projectRoot, branchName, tagName) {
try {
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
// Read current state or create default
let state = {};
if (fs.existsSync(statePath)) {
const rawState = fs.readFileSync(statePath, 'utf8');
state = JSON.parse(rawState);
}
// Ensure branchTagMapping exists
if (!state.branchTagMapping) {
state.branchTagMapping = {};
}
// Update the mapping
state.branchTagMapping[branchName] = tagName;
// Write updated state
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
} catch (error) {
log('warn', `Could not update branch-tag mapping: ${error.message}`);
// Don't throw - this is not critical for tag operations
}
}
/**
* Get tag name for a git branch from state.json mapping
* @param {string} projectRoot - Project root directory
* @param {string} branchName - Git branch name
* @returns {Promise<string|null>} Mapped tag name or null if not found
*/
async function getTagForBranch(projectRoot, branchName) {
try {
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
if (!fs.existsSync(statePath)) {
return null;
}
const rawState = fs.readFileSync(statePath, 'utf8');
const state = JSON.parse(rawState);
return state.branchTagMapping?.[branchName] || null;
} catch (error) {
return null;
}
}
/**
* Create a tag from a git branch name
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} branchName - Git branch name to create tag from
* @param {Object} options - Options object
* @param {boolean} [options.copyFromCurrent] - Copy tasks from current tag
* @param {string} [options.copyFromTag] - Copy tasks from specific tag
* @param {string} [options.description] - Custom description for the tag
* @param {boolean} [options.autoSwitch] - Automatically switch to the new tag
* @param {Object} context - Context object containing session and projectRoot
* @param {string} [context.projectRoot] - Project root path
* @param {Object} [context.mcpLog] - MCP logger object (optional)
* @param {string} outputFormat - Output format (text or json)
* @returns {Promise<Object>} Result object with creation details
*/
async function createTagFromBranch(
tasksPath,
branchName,
options = {},
context = {},
outputFormat = 'text'
) {
const { mcpLog, projectRoot } = context;
const { copyFromCurrent, copyFromTag, description, autoSwitch } = options;
// Import git utilities
const { sanitizeBranchNameForTag, isValidBranchForTag } = await import(
'../../utils/git-utils.js'
);
// Create a consistent logFn object regardless of context
const logFn = mcpLog || {
info: (...args) => log('info', ...args),
warn: (...args) => log('warn', ...args),
error: (...args) => log('error', ...args),
debug: (...args) => log('debug', ...args),
success: (...args) => log('success', ...args)
};
try {
// Validate branch name
if (!branchName || typeof branchName !== 'string') {
throw new Error('Branch name is required and must be a string');
}
// Check if branch name is valid for tag creation
if (!isValidBranchForTag(branchName)) {
throw new Error(
`Branch "${branchName}" cannot be converted to a valid tag name`
);
}
// Sanitize branch name to create tag name
const tagName = sanitizeBranchNameForTag(branchName);
logFn.info(`Creating tag "${tagName}" from git branch "${branchName}"`);
// Create the tag using existing createTag function
const createResult = await createTag(
tasksPath,
tagName,
{
copyFromCurrent,
copyFromTag,
description:
description || `Tag created from git branch "${branchName}"`
},
context,
outputFormat
);
// Update branch-tag mapping
await updateBranchTagMapping(projectRoot, branchName, tagName);
logFn.info(`Updated branch-tag mapping: ${branchName} -> ${tagName}`);
// Auto-switch to the new tag if requested
if (autoSwitch) {
await switchCurrentTag(projectRoot, tagName);
logFn.info(`Automatically switched to tag "${tagName}"`);
}
// For JSON output, return structured data
if (outputFormat === 'json') {
return {
...createResult,
branchName,
tagName,
mappingUpdated: true,
autoSwitched: autoSwitch || false
};
}
// For text output, the createTag function already handles display
return {
branchName,
tagName,
created: true,
mappingUpdated: true,
autoSwitched: autoSwitch || false
};
} catch (error) {
logFn.error(`Error creating tag from branch: ${error.message}`);
throw error;
}
}
/**
* Automatically switch tag based on current git branch
* @param {string} tasksPath - Path to the tasks.json file
* @param {Object} options - Options object
* @param {boolean} [options.createIfMissing] - Create tag if it doesn't exist
* @param {boolean} [options.copyFromCurrent] - Copy tasks when creating new tag
* @param {Object} context - Context object containing session and projectRoot
* @param {string} [context.projectRoot] - Project root path
* @param {Object} [context.mcpLog] - MCP logger object (optional)
* @param {string} outputFormat - Output format (text or json)
* @returns {Promise<Object>} Result object with switch details
*/
async function autoSwitchTagForBranch(
tasksPath,
options = {},
context = {},
outputFormat = 'text'
) {
const { mcpLog, projectRoot } = context;
const { createIfMissing, copyFromCurrent } = options;
// Import git utilities
const {
getCurrentBranch,
isGitRepository,
sanitizeBranchNameForTag,
isValidBranchForTag
} = await import('../../utils/git-utils.js');
// Create a consistent logFn object regardless of context
const logFn = mcpLog || {
info: (...args) => log('info', ...args),
warn: (...args) => log('warn', ...args),
error: (...args) => log('error', ...args),
debug: (...args) => log('debug', ...args),
success: (...args) => log('success', ...args)
};
try {
// Check if we're in a git repository
if (!(await isGitRepository(projectRoot))) {
logFn.warn('Not in a git repository, cannot auto-switch tags');
return { switched: false, reason: 'not_git_repo' };
}
// Get current git branch
const currentBranch = await getCurrentBranch(projectRoot);
if (!currentBranch) {
logFn.warn('Could not determine current git branch');
return { switched: false, reason: 'no_current_branch' };
}
logFn.info(`Current git branch: ${currentBranch}`);
// Check if branch is valid for tag creation
if (!isValidBranchForTag(currentBranch)) {
logFn.info(`Branch "${currentBranch}" is not suitable for tag creation`);
return {
switched: false,
reason: 'invalid_branch_for_tag',
branchName: currentBranch
};
}
// Check if there's already a mapping for this branch
let tagName = await getTagForBranch(projectRoot, currentBranch);
if (!tagName) {
// No mapping exists, create tag name from branch
tagName = sanitizeBranchNameForTag(currentBranch);
}
// Check if tag exists
const data = readJSON(tasksPath, projectRoot);
const rawData = data._rawTaggedData || data;
const tagExists = rawData[tagName];
if (!tagExists && createIfMissing) {
// Create the tag from branch
logFn.info(`Creating new tag "${tagName}" for branch "${currentBranch}"`);
const createResult = await createTagFromBranch(
tasksPath,
currentBranch,
{
copyFromCurrent,
autoSwitch: true
},
context,
outputFormat
);
return {
switched: true,
created: true,
branchName: currentBranch,
tagName,
...createResult
};
} else if (tagExists) {
// Tag exists, switch to it
logFn.info(
`Switching to existing tag "${tagName}" for branch "${currentBranch}"`
);
const switchResult = await useTag(
tasksPath,
tagName,
{},
context,
outputFormat
);
// Update mapping if it didn't exist
if (!(await getTagForBranch(projectRoot, currentBranch))) {
await updateBranchTagMapping(projectRoot, currentBranch, tagName);
}
return {
switched: true,
created: false,
branchName: currentBranch,
tagName,
...switchResult
};
} else {
// Tag doesn't exist and createIfMissing is false
logFn.warn(
`Tag "${tagName}" for branch "${currentBranch}" does not exist`
);
return {
switched: false,
reason: 'tag_not_found',
branchName: currentBranch,
tagName
};
}
} catch (error) {
logFn.error(`Error in auto-switch tag for branch: ${error.message}`);
throw error;
}
}
/**
* Check git workflow configuration and perform auto-switch if enabled
* @param {string} projectRoot - Project root directory
* @param {string} tasksPath - Path to the tasks.json file
* @param {Object} context - Context object
* @returns {Promise<Object|null>} Switch result or null if not enabled
*/
async function checkAndAutoSwitchTag(projectRoot, tasksPath, context = {}) {
try {
// Read configuration
const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
if (!fs.existsSync(configPath)) {
return null;
}
const rawConfig = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(rawConfig);
// Check if git workflow is enabled
const gitWorkflowEnabled = config.tags?.enabledGitworkflow || false;
const autoSwitchEnabled = config.tags?.autoSwitchTagWithBranch || false;
if (!gitWorkflowEnabled || !autoSwitchEnabled) {
return null;
}
// Perform auto-switch
return await autoSwitchTagForBranch(
tasksPath,
{ createIfMissing: true, copyFromCurrent: false },
context,
'json'
);
} catch (error) {
// Silently fail - this is not critical
return null;
}
}
// Export all tag management functions
export {
createTag,
@@ -1159,5 +1504,10 @@ export {
useTag,
renameTag,
copyTag,
switchCurrentTag
switchCurrentTag,
updateBranchTagMapping,
getTagForBranch,
createTagFromBranch,
autoSwitchTagForBranch,
checkAndAutoSwitchTag
};

View File

@@ -298,6 +298,19 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// Perform complete migration (config.json, state.json)
performCompleteTagMigration(filepath);
// Check and auto-switch git tags if enabled (after migration)
// This needs to run synchronously BEFORE tag resolution
if (projectRoot) {
try {
// Import git utilities synchronously
const gitUtils = require('./utils/git-utils.js');
// Run git integration synchronously
gitUtils.checkAndAutoSwitchGitTagSync(projectRoot, filepath);
} catch (error) {
// Silent fail - don't break normal operations
}
}
// Mark for migration notice
markMigrationForNotice(filepath);
} catch (writeError) {
@@ -344,6 +357,19 @@ function readJSON(filepath, projectRoot = null, tag = null) {
// Store reference to the raw tagged data for functions that need it
const originalTaggedData = JSON.parse(JSON.stringify(data));
// Check and auto-switch git tags if enabled (for existing tagged format)
// This needs to run synchronously BEFORE tag resolution
if (projectRoot) {
try {
// Import git utilities synchronously
const gitUtils = require('./utils/git-utils.js');
// Run git integration synchronously
gitUtils.checkAndAutoSwitchGitTagSync(projectRoot, filepath);
} catch (error) {
// Silent fail - don't break normal operations
}
}
try {
// Default to master tag if anything goes wrong
let resolvedTag = 'master';

View File

@@ -0,0 +1,634 @@
/**
* git-utils.js
* Git integration utilities for Task Master
* Uses raw git commands and gh CLI for operations
* MCP-friendly: All functions require projectRoot parameter
*/
const { exec, execSync } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs');
const execAsync = promisify(exec);
/**
* Check if the specified directory is inside a git repository
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<boolean>} True if inside a git repository
*/
async function isGitRepository(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for isGitRepository');
}
try {
await execAsync('git rev-parse --git-dir', { cwd: projectRoot });
return true;
} catch (error) {
return false;
}
}
/**
* Get the current git branch name
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<string|null>} Current branch name or null if not in git repo
*/
async function getCurrentBranch(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getCurrentBranch');
}
try {
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectRoot
});
return stdout.trim();
} catch (error) {
return null;
}
}
/**
* Get list of all local git branches
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<string[]>} Array of branch names
*/
async function getLocalBranches(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getLocalBranches');
}
try {
const { stdout } = await execAsync(
'git branch --format="%(refname:short)"',
{ cwd: projectRoot }
);
return stdout
.trim()
.split('\n')
.filter((branch) => branch.length > 0)
.map((branch) => branch.trim());
} catch (error) {
return [];
}
}
/**
* Get list of all remote branches
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<string[]>} Array of remote branch names (without remote prefix)
*/
async function getRemoteBranches(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getRemoteBranches');
}
try {
const { stdout } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: projectRoot }
);
return stdout
.trim()
.split('\n')
.filter((branch) => branch.length > 0 && !branch.includes('HEAD'))
.map((branch) => branch.replace(/^origin\//, '').trim());
} catch (error) {
return [];
}
}
/**
* Check if gh CLI is available and authenticated
* @param {string} [projectRoot] - Directory context (optional for this check)
* @returns {Promise<boolean>} True if gh CLI is available and authenticated
*/
async function isGhCliAvailable(projectRoot = null) {
try {
const options = projectRoot ? { cwd: projectRoot } : {};
await execAsync('gh auth status', options);
return true;
} catch (error) {
return false;
}
}
/**
* Get GitHub repository information using gh CLI
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<Object|null>} Repository info or null if not available
*/
async function getGitHubRepoInfo(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getGitHubRepoInfo');
}
try {
const { stdout } = await execAsync(
'gh repo view --json name,owner,defaultBranchRef',
{ cwd: projectRoot }
);
return JSON.parse(stdout);
} catch (error) {
return null;
}
}
/**
* Sanitize branch name to be a valid tag name
* @param {string} branchName - Git branch name
* @returns {string} Sanitized tag name
*/
function sanitizeBranchNameForTag(branchName) {
if (!branchName || typeof branchName !== 'string') {
return 'unknown-branch';
}
// Replace invalid characters with hyphens and clean up
return branchName
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace invalid chars with hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.toLowerCase() // Convert to lowercase
.substring(0, 50); // Limit length
}
/**
* Check if a branch name would create a valid tag name
* @param {string} branchName - Git branch name
* @returns {boolean} True if branch name can be converted to valid tag
*/
function isValidBranchForTag(branchName) {
if (!branchName || typeof branchName !== 'string') {
return false;
}
// Check if it's a reserved branch name that shouldn't become tags
const reservedBranches = ['main', 'master', 'develop', 'dev', 'HEAD'];
if (reservedBranches.includes(branchName.toLowerCase())) {
return false;
}
// Check if sanitized name would be meaningful
const sanitized = sanitizeBranchNameForTag(branchName);
return sanitized.length > 0 && sanitized !== 'unknown-branch';
}
/**
* Get git repository root directory
* @param {string} projectRoot - Directory to start search from (required)
* @returns {Promise<string|null>} Git repository root path or null
*/
async function getGitRepositoryRoot(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getGitRepositoryRoot');
}
try {
const { stdout } = await execAsync('git rev-parse --show-toplevel', {
cwd: projectRoot
});
return stdout.trim();
} catch (error) {
return null;
}
}
/**
* Check if specified directory is the git repository root
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<boolean>} True if directory is git root
*/
async function isGitRepositoryRoot(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for isGitRepositoryRoot');
}
try {
const gitRoot = await getGitRepositoryRoot(projectRoot);
return gitRoot && path.resolve(gitRoot) === path.resolve(projectRoot);
} catch (error) {
return false;
}
}
/**
* Get the default branch name for the repository
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<string|null>} Default branch name or null
*/
async function getDefaultBranch(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for getDefaultBranch');
}
try {
// Try to get from GitHub first (if gh CLI is available)
if (await isGhCliAvailable(projectRoot)) {
const repoInfo = await getGitHubRepoInfo(projectRoot);
if (repoInfo && repoInfo.defaultBranchRef) {
return repoInfo.defaultBranchRef.name;
}
}
// Fallback to git remote info
const { stdout } = await execAsync(
'git symbolic-ref refs/remotes/origin/HEAD',
{ cwd: projectRoot }
);
return stdout.replace('refs/remotes/origin/', '').trim();
} catch (error) {
// Final fallback - common default branch names
const commonDefaults = ['main', 'master'];
const branches = await getLocalBranches(projectRoot);
for (const defaultName of commonDefaults) {
if (branches.includes(defaultName)) {
return defaultName;
}
}
return null;
}
}
/**
* Check if we're currently on the default branch
* @param {string} projectRoot - Directory to check (required)
* @returns {Promise<boolean>} True if on default branch
*/
async function isOnDefaultBranch(projectRoot) {
if (!projectRoot) {
throw new Error('projectRoot is required for isOnDefaultBranch');
}
try {
const currentBranch = await getCurrentBranch(projectRoot);
const defaultBranch = await getDefaultBranch(projectRoot);
return currentBranch && defaultBranch && currentBranch === defaultBranch;
} catch (error) {
return false;
}
}
/**
* Check and automatically switch tags based on git branch if enabled
* This runs automatically during task operations, similar to migration
* @param {string} projectRoot - Project root directory (required)
* @param {string} tasksPath - Path to tasks.json file
* @returns {Promise<void>}
*/
async function checkAndAutoSwitchGitTag(projectRoot, tasksPath) {
if (!projectRoot) {
throw new Error('projectRoot is required for checkAndAutoSwitchGitTag');
}
try {
// Only proceed if we have a valid project root
if (!fs.existsSync(projectRoot)) {
return;
}
// Read configuration to check if git workflow is enabled
const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
if (!fs.existsSync(configPath)) {
return; // No config, git workflow disabled
}
const rawConfig = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(rawConfig);
// Check if git workflow features are enabled
const gitWorkflowEnabled = config.tags?.enabledGitworkflow || false;
const autoSwitchEnabled = config.tags?.autoSwitchTagWithBranch || false;
if (!gitWorkflowEnabled || !autoSwitchEnabled) {
return; // Git integration disabled
}
// Check if we're in a git repository
if (!(await isGitRepository(projectRoot))) {
return; // Not a git repo
}
// Get current git branch
const currentBranch = await getCurrentBranch(projectRoot);
if (!currentBranch || !isValidBranchForTag(currentBranch)) {
return; // No valid branch for tag creation
}
// Determine expected tag name from branch
const expectedTag = sanitizeBranchNameForTag(currentBranch);
// Get current tag from state.json
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
let currentTag = 'master'; // Default fallback
if (fs.existsSync(statePath)) {
try {
const rawState = fs.readFileSync(statePath, 'utf8');
const state = JSON.parse(rawState);
currentTag = state.currentTag || 'master';
} catch (error) {
// Use default if state reading fails
}
}
// If we're already on the correct tag, nothing to do
if (currentTag === expectedTag) {
return;
}
// Check if the expected tag exists by reading tasks.json
if (!fs.existsSync(tasksPath)) {
return; // No tasks file to work with
}
const rawTasksData = fs.readFileSync(tasksPath, 'utf8');
const tasksData = JSON.parse(rawTasksData);
const tagExists = tasksData[expectedTag];
if (tagExists) {
// Tag exists, switch to it
console.log(
`Auto-switching to tag "${expectedTag}" for branch "${currentBranch}"`
);
// Update current tag in state.json
let state = {};
if (fs.existsSync(statePath)) {
const rawState = fs.readFileSync(statePath, 'utf8');
state = JSON.parse(rawState);
}
state.currentTag = expectedTag;
state.lastSwitched = new Date().toISOString();
// Ensure branchTagMapping exists and update it
if (!state.branchTagMapping) {
state.branchTagMapping = {};
}
state.branchTagMapping[currentBranch] = expectedTag;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
} else {
// Tag doesn't exist, create it automatically
console.log(
`Auto-creating tag "${expectedTag}" for branch "${currentBranch}"`
);
// Create the tag with a descriptive name
const newTagData = {
tasks: [],
metadata: {
created: new Date().toISOString(),
updated: new Date().toISOString(),
description: `Automatically created from git branch "${currentBranch}"`
}
};
// Add the new tag to the tasks data
tasksData[expectedTag] = newTagData;
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2), 'utf8');
// Update branch-tag mapping
let state = {};
if (fs.existsSync(statePath)) {
const rawState = fs.readFileSync(statePath, 'utf8');
state = JSON.parse(rawState);
}
state.currentTag = expectedTag;
state.lastSwitched = new Date().toISOString();
if (!state.branchTagMapping) {
state.branchTagMapping = {};
}
state.branchTagMapping[currentBranch] = expectedTag;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
}
} catch (error) {
// Silently fail - don't break normal operations
console.debug(`Git auto-switch failed: ${error.message}`);
}
}
/**
* Synchronous version of git tag checking and switching
* This runs during readJSON to ensure git integration happens BEFORE tag resolution
* @param {string} projectRoot - Project root directory (required)
* @param {string} tasksPath - Path to tasks.json file
* @returns {void}
*/
function checkAndAutoSwitchGitTagSync(projectRoot, tasksPath) {
if (!projectRoot) {
return; // Can't proceed without project root
}
try {
// Only proceed if we have a valid project root
if (!fs.existsSync(projectRoot)) {
return;
}
// Read configuration to check if git workflow is enabled
const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
if (!fs.existsSync(configPath)) {
return; // No config, git workflow disabled
}
const rawConfig = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(rawConfig);
// Check if git workflow features are enabled
const gitWorkflowEnabled = config.tags?.enabledGitworkflow || false;
const autoSwitchEnabled = config.tags?.autoSwitchTagWithBranch || false;
if (!gitWorkflowEnabled || !autoSwitchEnabled) {
return; // Git integration disabled
}
// Check if we're in a git repository (synchronously)
if (!isGitRepositorySync(projectRoot)) {
return; // Not a git repo
}
// Get current git branch (synchronously)
const currentBranch = getCurrentBranchSync(projectRoot);
if (!currentBranch || !isValidBranchForTag(currentBranch)) {
return; // No valid branch for tag creation
}
// Determine expected tag name from branch
const expectedTag = sanitizeBranchNameForTag(currentBranch);
// Get current tag from state.json
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
let currentTag = 'master'; // default
if (fs.existsSync(statePath)) {
try {
const rawState = fs.readFileSync(statePath, 'utf8');
const state = JSON.parse(rawState);
currentTag = state.currentTag || 'master';
} catch (error) {
// Use default if state.json is corrupted
}
}
// If we're already on the correct tag, nothing to do
if (currentTag === expectedTag) {
return;
}
// Check if the expected tag exists in tasks.json
let tasksData = {};
if (fs.existsSync(tasksPath)) {
try {
const rawTasks = fs.readFileSync(tasksPath, 'utf8');
tasksData = JSON.parse(rawTasks);
} catch (error) {
return; // Can't read tasks file
}
}
const tagExists = tasksData[expectedTag];
if (tagExists) {
// Tag exists, switch to it
console.log(
`Auto-switching to tag "${expectedTag}" for branch "${currentBranch}"`
);
// Update current tag in state.json
let state = {};
if (fs.existsSync(statePath)) {
try {
const rawState = fs.readFileSync(statePath, 'utf8');
state = JSON.parse(rawState);
} catch (error) {
state = {}; // Start fresh if corrupted
}
}
state.currentTag = expectedTag;
state.lastSwitched = new Date().toISOString();
// Ensure branchTagMapping exists and update it
if (!state.branchTagMapping) {
state.branchTagMapping = {};
}
state.branchTagMapping[currentBranch] = expectedTag;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
} else {
// Tag doesn't exist, create it automatically
console.log(
`Auto-creating tag "${expectedTag}" for branch "${currentBranch}"`
);
// Create the tag with a descriptive name
const newTagData = {
tasks: [],
metadata: {
created: new Date().toISOString(),
updated: new Date().toISOString(),
description: `Automatically created from git branch "${currentBranch}"`
}
};
// Add the new tag to the tasks data
tasksData[expectedTag] = newTagData;
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2), 'utf8');
// Update branch-tag mapping and current tag
let state = {};
if (fs.existsSync(statePath)) {
try {
const rawState = fs.readFileSync(statePath, 'utf8');
state = JSON.parse(rawState);
} catch (error) {
state = {}; // Start fresh if corrupted
}
}
state.currentTag = expectedTag;
state.lastSwitched = new Date().toISOString();
if (!state.branchTagMapping) {
state.branchTagMapping = {};
}
state.branchTagMapping[currentBranch] = expectedTag;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
}
} catch (error) {
// Silently fail - don't break normal operations
if (process.env.TASKMASTER_DEBUG === 'true') {
console.debug(`Git auto-switch failed: ${error.message}`);
}
}
}
/**
* Synchronous check if directory is in a git repository
* @param {string} projectRoot - Directory to check (required)
* @returns {boolean} True if inside a git repository
*/
function isGitRepositorySync(projectRoot) {
if (!projectRoot) {
return false;
}
try {
execSync('git rev-parse --git-dir', {
cwd: projectRoot,
stdio: 'ignore' // Suppress output
});
return true;
} catch (error) {
return false;
}
}
/**
* Synchronous get current git branch name
* @param {string} projectRoot - Directory to check (required)
* @returns {string|null} Current branch name or null if not in git repo
*/
function getCurrentBranchSync(projectRoot) {
if (!projectRoot) {
return null;
}
try {
const stdout = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: projectRoot,
encoding: 'utf8'
});
return stdout.trim();
} catch (error) {
return null;
}
}
// Export all functions
module.exports = {
isGitRepository,
getCurrentBranch,
getLocalBranches,
getRemoteBranches,
isGhCliAvailable,
getGitHubRepoInfo,
sanitizeBranchNameForTag,
isValidBranchForTag,
getGitRepositoryRoot,
isGitRepositoryRoot,
getDefaultBranch,
isOnDefaultBranch,
checkAndAutoSwitchGitTag,
checkAndAutoSwitchGitTagSync,
isGitRepositorySync,
getCurrentBranchSync
};