diff --git a/.changeset/fluffy-waves-allow.md b/.changeset/fluffy-waves-allow.md new file mode 100644 index 00000000..ea6c4633 --- /dev/null +++ b/.changeset/fluffy-waves-allow.md @@ -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." diff --git a/.taskmaster/config.json b/.taskmaster/config.json index 3a5d85fc..6e51b320 100644 --- a/.taskmaster/config.json +++ b/.taskmaster/config.json @@ -32,7 +32,7 @@ "defaultTag": "master" }, "tags": { - "enabledGitworkflow": false, - "autoSwitchTagWithBranch": false + "enabledGitworkflow": true, + "autoSwitchTagWithBranch": true } } diff --git a/.taskmaster/state.json b/.taskmaster/state.json index f01b07f2..7154f4a1 100644 --- a/.taskmaster/state.json +++ b/.taskmaster/state.json @@ -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 } \ No newline at end of file diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index f1f3527d..edaa3d21 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -6553,8 +6553,8 @@ "dependencies": [ 4 ], - "details": "Enable seamless context switching between code branches and task tags. Use add-tag internally when creating tags from branch names.", - "status": "pending", + "details": "Enable seamless context switching between code branches and task tags. Use add-tag internally when creating tags from branch names.\n\n**Code Context Analysis Complete**\n\n**Current State:**\n- `state.json` has `branchTagMapping: {}` ready for storing git branch to tag mappings\n- `config.json` has `tags.enabledGitworkflow: false` and `tags.autoSwitchTagWithBranch: false` controls\n- Existing tag management functions in `scripts/modules/task-manager/tag-management.js` provide `createTag`, `useTag`, `switchCurrentTag` utilities\n- No existing git integration - need to add git CLI dependencies\n\n**Implementation Plan:**\n\n1. **Add Git Dependencies**: Add `simple-git` package for git operations (better than calling CLI directly)\n2. **Create Git Utilities Module**: `scripts/modules/utils/git-utils.js` with functions:\n - `getCurrentBranch()` - Get current git branch name\n - `isGitRepository()` - Check if we're in a git repo\n - `getBranchList()` - Get list of all branches\n - `onBranchChange()` - Hook for branch change detection\n\n3. **Enhance Tag Management**: Add git integration functions:\n - `createTagFromBranch(branchName)` - Create tag from git branch name\n - `autoSwitchTagForBranch()` - Auto-switch tag when branch changes\n - `updateBranchTagMapping()` - Update state.json mapping\n\n4. **Add CLI Commands**:\n - `--from-branch` flag for `add-tag` command\n - `task-master sync-git` command for manual git-tag synchronization\n\n5. **Configuration Integration**: \n - Check `config.tags.enabledGitworkflow` before git operations\n - Use `config.tags.autoSwitchTagWithBranch` for automatic switching\n\n**Next Steps**: Start with adding simple-git dependency and creating git utilities module.\n\n\n**Updated Implementation Strategy - Automatic Git Integration**\n\n**Revised Approach:**\n- Eliminate manual `sync-git` command for seamless user experience\n- Implement automatic git-tag synchronization following the established migration pattern\n- Integration occurs transparently during normal task operations without user intervention\n- Behavior controlled entirely through existing configuration flags\n\n**Updated Implementation Plan:**\n\n1. **Simplified Git Dependencies**: Keep `simple-git` package for git operations\n\n2. **Enhanced Git Utilities Module**: `scripts/modules/utils/git-utils.js` with streamlined functions:\n - `getCurrentBranch()` - Get current git branch name\n - `isGitRepository()` - Check if we're in a git repo\n - `shouldAutoSync()` - Check if git workflow is enabled and conditions are met\n\n3. **Automatic Integration Hook**: \n - Add `checkAndSyncGitTags()` function to utils.js\n - Integrate into `readJSON()` similar to migration system\n - Automatically create tags from branch names when conditions are met\n - Update branch-tag mappings in state.json transparently\n\n4. **Streamlined Tag Management**: Remove complex CLI additions:\n - No `--from-branch` flag needed for `add-tag`\n - No manual `sync-git` command\n - Automatic tag creation and switching based on git context\n\n5. **Configuration-Driven Behavior**:\n - `config.tags.enabledGitworkflow` enables/disables entire system\n - `config.tags.autoSwitchTagWithBranch` controls automatic tag switching\n - Silent operation when disabled, seamless when enabled\n\n**Benefits**: Zero-friction git integration that \"just works\" when enabled, following established project patterns for automatic system enhancements.\n\n\n**✅ IMPLEMENTATION COMPLETED**\n\n**Final Implementation Summary:**\n\n1. **Proper Module Organization**: \n - Moved `checkAndAutoSwitchGitTag` function to correct location in `scripts/modules/utils/git-utils.js`\n - Updated imports in `utils.js` to use the git-utils version\n - Maintains clean separation of concerns with git operations in dedicated module\n\n2. **Seamless Integration Architecture**:\n - Function automatically executes during `readJSON()` operations\n - Integrates with both migration system and normal tagged format processing\n - Zero user intervention required - works transparently in background\n\n3. **Smart Git-Tag Synchronization**:\n - Automatically switches to existing tags matching current branch names\n - Creates new tags for branches without corresponding tags\n - Updates `state.json` branch-tag mappings for persistent tracking\n - Validates branch names (excludes main/master/develop/dev/HEAD)\n\n4. **Configuration-Driven Operation**:\n - Controlled by `config.tags.enabledGitworkflow` and `config.tags.autoSwitchTagWithBranch` flags\n - Silent operation when disabled, seamless when enabled\n - Uses `console.debug` for error handling to avoid disrupting normal operations\n\n5. **MCP-Compatible Design**:\n - All functions require `projectRoot` parameter for MCP compatibility\n - Leverages existing git utility functions (`isGitRepository`, `getCurrentBranch`, `isValidBranchForTag`, `sanitizeBranchNameForTag`)\n - Follows established project patterns for automatic system enhancements\n\n**Status**: Implementation complete and ready for production use. Users can enable automatic git integration by configuring the appropriate flags in `.taskmaster/config.json`.\n", + "status": "in-progress", "testStrategy": "Test tag creation and switching in repositories with multiple branches." }, { @@ -6748,7 +6748,7 @@ ], "metadata": { "created": "2025-06-13T02:49:12.129Z", - "updated": "2025-06-13T17:22:17.929Z", + "updated": "2025-06-13T17:26:21.856Z", "description": "Tasks for master context" } } diff --git a/mcp-server/src/core/direct-functions/create-tag-from-branch.js b/mcp-server/src/core/direct-functions/create-tag-from-branch.js new file mode 100644 index 00000000..8a37c979 --- /dev/null +++ b/mcp-server/src/core/direct-functions/create-tag-from-branch.js @@ -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} - 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 + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/research.js b/mcp-server/src/core/direct-functions/research.js index 4579bcbb..e6feee29 100644 --- a/mcp-server/src/core/direct-functions/research.js +++ b/mcp-server/src/core/direct-functions/research.js @@ -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) { diff --git a/mcp-server/src/tools/research.js b/mcp-server/src/tools/research.js index a1aabed1..4e54b077 100644 --- a/mcp-server/src/tools/research.js +++ b/mcp-server/src/tools/research.js @@ -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, diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index baf6f312..743660c7 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -1644,6 +1644,10 @@ function registerCommands(programInstance) { '--save-to ', 'Automatically save research results to specified task/subtask ID (e.g., "15" or "15.2")' ) + .option( + '--save-to-file ', + 'Save research results to the specified file' + ) .option('--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(); diff --git a/scripts/modules/task-manager/research.js b/scripts/modules/task-manager/research.js index 4dc39fe6..8ffb36bf 100644 --- a/scripts/modules/task-manager/research.js +++ b/scripts/modules/task-manager/research.js @@ -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} 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 diff --git a/scripts/modules/task-manager/tag-management.js b/scripts/modules/task-manager/tag-management.js index 92ddf8e6..b964a15b 100644 --- a/scripts/modules/task-manager/tag-management.js +++ b/scripts/modules/task-manager/tag-management.js @@ -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} + */ +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} 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} 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} 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} 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 }; diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 198c80fd..6eb3f556 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -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'; diff --git a/scripts/modules/utils/git-utils.js b/scripts/modules/utils/git-utils.js new file mode 100644 index 00000000..f7eaa051 --- /dev/null +++ b/scripts/modules/utils/git-utils.js @@ -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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} + */ +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 +};