/** * @fileoverview Git utilities for Task Master * Git integration utilities using raw git commands and gh CLI */ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * GitHub repository information */ export interface GitHubRepoInfo { name: string; owner: { login: string }; defaultBranchRef: { name: string }; } /** * Check if the specified directory is inside a git repository */ export async function isGitRepository(projectRoot: string): Promise { 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; } } /** * Synchronous check if directory is in a git repository */ export function isGitRepositorySync(projectRoot: string): boolean { if (!projectRoot) { return false; } try { execSync('git rev-parse --git-dir', { cwd: projectRoot, stdio: 'ignore' }); return true; } catch (error) { return false; } } /** * Get the current git branch name */ export async function getCurrentBranch( projectRoot: string ): Promise { 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; } } /** * Synchronous get current git branch name */ export function getCurrentBranchSync(projectRoot: string): string | null { 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; } } /** * Get list of all local git branches */ export async function getLocalBranches(projectRoot: string): Promise { if (!projectRoot) { throw new Error('projectRoot is required for getLocalBranches'); } try { const { stdout } = await execAsync( 'git branch --format="%(refname:short)"', { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 } ); return stdout .trim() .split('\n') .filter((branch) => branch.length > 0) .map((branch) => branch.trim()); } catch (error) { return []; } } /** * Get list of all remote branches */ export async function getRemoteBranches( projectRoot: string ): Promise { if (!projectRoot) { throw new Error('projectRoot is required for getRemoteBranches'); } try { const { stdout } = await execAsync( 'git branch -r --format="%(refname:short)"', { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 } ); const names = stdout .trim() .split('\n') .filter((branch) => branch.length > 0 && !branch.includes('HEAD')) .map((branch) => branch.replace(/^[^/]+\//, '').trim()); return Array.from(new Set(names)); } catch (error) { return []; } } /** * Check if gh CLI is available and authenticated */ export async function isGhCliAvailable(projectRoot?: string): Promise { 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 */ export async function getGitHubRepoInfo( projectRoot: string ): Promise { 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) as GitHubRepoInfo; } catch (error) { return null; } } /** * Get git repository root directory */ export async function getGitRepositoryRoot( projectRoot: string ): Promise { 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; } } /** * Get the default branch name for the repository */ export async function getDefaultBranch( projectRoot: string ): Promise { 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 (support non-origin remotes) const remotesRaw = await execAsync('git remote', { cwd: projectRoot }); const remotes = remotesRaw.stdout.trim().split('\n').filter(Boolean); if (remotes.length > 0) { const primary = remotes.includes('origin') ? 'origin' : remotes[0]; // Parse `git remote show` (preferred) try { const { stdout } = await execAsync(`git remote show ${primary}`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 }); const m = stdout.match(/HEAD branch:\s+([^\s]+)/); if (m) return m[1].trim(); } catch {} // Fallback to symbolic-ref of remote HEAD try { const { stdout } = await execAsync( `git symbolic-ref refs/remotes/${primary}/HEAD`, { cwd: projectRoot } ); return stdout.replace(`refs/remotes/${primary}/`, '').trim(); } catch {} } // If we couldn't determine, throw to trigger final fallbacks throw new Error('default-branch-not-found'); } catch (error) { // Final fallback - common default branch names const commonDefaults = ['main', 'master']; const branches = await getLocalBranches(projectRoot); const remoteBranches = await getRemoteBranches(projectRoot); for (const defaultName of commonDefaults) { if ( branches.includes(defaultName) || remoteBranches.includes(defaultName) ) { return defaultName; } } return null; } } /** * Check if we're currently on the default branch */ export async function isOnDefaultBranch(projectRoot: string): Promise { if (!projectRoot) { throw new Error('projectRoot is required for isOnDefaultBranch'); } try { const [currentBranch, defaultBranch] = await Promise.all([ getCurrentBranch(projectRoot), getDefaultBranch(projectRoot) ]); return ( currentBranch !== null && defaultBranch !== null && currentBranch === defaultBranch ); } catch (error) { return false; } } /** * Check if the current working directory is inside a Git work-tree */ export function insideGitWorkTree(): boolean { try { execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore', cwd: process.cwd() }); return true; } catch { return false; } } /** * Sanitize branch name to be a valid tag name */ export function sanitizeBranchNameForTag(branchName: string): string { 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 (allow dots) .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 */ export function isValidBranchForTag(branchName: string): boolean { 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'; } /** * Git worktree information */ export interface GitWorktree { path: string; branch: string | null; head: string; } /** * Get list of all git worktrees */ export async function getWorktrees( projectRoot: string ): Promise { if (!projectRoot) { throw new Error('projectRoot is required for getWorktrees'); } try { const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectRoot }); const worktrees: GitWorktree[] = []; const lines = stdout.trim().split('\n'); let current: Partial = {}; for (const line of lines) { if (line.startsWith('worktree ')) { // flush previous entry if present if (current.path) { worktrees.push({ path: current.path, branch: current.branch || null, head: current.head || '' }); current = {}; } current.path = line.substring(9); } else if (line.startsWith('HEAD ')) { current.head = line.substring(5); } else if (line.startsWith('branch ')) { current.branch = line.substring(7).replace('refs/heads/', ''); } else if (line === '' && current.path) { worktrees.push({ path: current.path, branch: current.branch || null, head: current.head || '' }); current = {}; } } // Handle last entry if no trailing newline if (current.path) { worktrees.push({ path: current.path, branch: current.branch || null, head: current.head || '' }); } return worktrees; } catch (error) { return []; } } /** * Check if a branch is checked out in any worktree * Returns the worktree path if found, null otherwise */ export async function isBranchCheckedOut( projectRoot: string, branchName: string ): Promise { if (!projectRoot) { throw new Error('projectRoot is required for isBranchCheckedOut'); } if (!branchName) { throw new Error('branchName is required for isBranchCheckedOut'); } const worktrees = await getWorktrees(projectRoot); const worktree = worktrees.find((wt) => wt.branch === branchName); return worktree ? worktree.path : null; }