diff --git a/packages/tm-core/src/services/preflight-checker.service.ts b/packages/tm-core/src/services/preflight-checker.service.ts index 62e9b074..25b622de 100644 --- a/packages/tm-core/src/services/preflight-checker.service.ts +++ b/packages/tm-core/src/services/preflight-checker.service.ts @@ -7,19 +7,11 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { execSync } from 'child_process'; import { getLogger } from '../logger/factory.js'; - -// Import git utilities (JS module without type definitions) -// eslint-disable-next-line @typescript-eslint/no-var-requires -const gitUtils = require('../../../../scripts/modules/utils/git-utils.js'); -const isGitRepository = gitUtils.isGitRepository as ( - projectRoot: string -) => Promise; -const isGhCliAvailable = gitUtils.isGhCliAvailable as ( - projectRoot: string -) => Promise; -const getDefaultBranch = gitUtils.getDefaultBranch as ( - projectRoot: string -) => Promise; +import { + isGitRepository, + isGhCliAvailable, + getDefaultBranch +} from '../utils/git-utils.js'; const logger = getLogger('PreflightChecker'); diff --git a/packages/tm-core/src/utils/git-utils.ts b/packages/tm-core/src/utils/git-utils.ts new file mode 100644 index 00000000..b5753be9 --- /dev/null +++ b/packages/tm-core/src/utils/git-utils.ts @@ -0,0 +1,304 @@ +/** + * @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 } + ); + 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 } + ); + 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 + */ +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 + 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 + */ +export async function isOnDefaultBranch(projectRoot: string): Promise { + if (!projectRoot) { + throw new Error('projectRoot is required for isOnDefaultBranch'); + } + + try { + const currentBranch = await getCurrentBranch(projectRoot); + const defaultBranch = await 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 + .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'; +} diff --git a/packages/tm-core/src/utils/index.ts b/packages/tm-core/src/utils/index.ts index 61969f78..f587dab0 100644 --- a/packages/tm-core/src/utils/index.ts +++ b/packages/tm-core/src/utils/index.ts @@ -13,6 +13,25 @@ export { getParentTaskId } from './id-generator.js'; +// Export git utilities +export { + isGitRepository, + isGitRepositorySync, + getCurrentBranch, + getCurrentBranchSync, + getLocalBranches, + getRemoteBranches, + isGhCliAvailable, + getGitHubRepoInfo, + getGitRepositoryRoot, + getDefaultBranch, + isOnDefaultBranch, + insideGitWorkTree, + sanitizeBranchNameForTag, + isValidBranchForTag, + type GitHubRepoInfo +} from './git-utils.js'; + // Additional utility exports /**