feat(research): Enhance research command with follow-up menu, save functionality, and fix ContextGatherer token counting
This commit is contained in:
5
.changeset/fluffy-waves-allow.md
Normal file
5
.changeset/fluffy-waves-allow.md
Normal 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."
|
||||
@@ -32,7 +32,7 @@
|
||||
"defaultTag": "master"
|
||||
},
|
||||
"tags": {
|
||||
"enabledGitworkflow": false,
|
||||
"autoSwitchTagWithBranch": false
|
||||
"enabledGitworkflow": true,
|
||||
"autoSwitchTagWithBranch": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
159
mcp-server/src/core/direct-functions/create-tag-from-branch.js
Normal file
159
mcp-server/src/core/direct-functions/create-tag-from-branch.js
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
634
scripts/modules/utils/git-utils.js
Normal file
634
scripts/modules/utils/git-utils.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user