UNPKG

prprompts-flutter-generator

Version:

AI-powered Flutter development with full automation + official extension support - Generate 32 security-audited guides & auto-implement in 2-3 hours. NEW v5.1: Official Claude Code plugin with hooks, Gemini TOML commands, Qwen MCP settings. Features: Comp

482 lines (419 loc) 12 kB
/** * GitIntegration - Git integration utilities for safe refactoring * * Features: * - Working tree validation * - Automatic branch creation * - Patch generation and application * - Conflict detection and resolution * - 3-way merge support * - Commit creation with attribution */ const { execSync } = require('child_process'); const fs = require('fs-extra'); const path = require('path'); const chalk = require('chalk'); class GitIntegration { constructor(config = {}) { this.config = { repoPath: config.repoPath || process.cwd(), verbose: config.verbose || false, autoCommit: config.autoCommit || false, branchPrefix: config.branchPrefix || 'prprompts/', requireCleanTree: config.requireCleanTree !== false, }; } /** * Check if directory is a Git repository * @returns {boolean} Is Git repo */ isGitRepo() { try { const gitDir = path.join(this.config.repoPath, '.git'); return fs.existsSync(gitDir); } catch { return false; } } /** * Ensure Git repository exists */ ensureGitRepo() { if (!this.isGitRepo()) { if (this.config.verbose) { console.log(chalk.yellow('Not a Git repository, initializing...')); } this.exec('git init'); } } /** * Check if working tree is clean * @returns {boolean} Is clean */ isWorkingTreeClean() { try { const status = this.exec('git status --porcelain', { silent: true }); return status.trim() === ''; } catch { return false; } } /** * Validate working tree before operations * @throws {Error} If tree is not clean and required */ validateWorkingTree() { if (!this.isGitRepo()) { throw new Error('Not a Git repository'); } if (this.config.requireCleanTree && !this.isWorkingTreeClean()) { throw new Error( 'Working tree has uncommitted changes. ' + 'Please commit or stash changes before proceeding.' ); } } /** * Get current branch name * @returns {string} Branch name */ getCurrentBranch() { try { return this.exec('git branch --show-current', { silent: true }).trim(); } catch { return 'master'; } } /** * Create a new branch * @param {string} name - Branch name (will be prefixed) * @param {string} baseBranch - Base branch to create from * @returns {string} Full branch name */ createBranch(name, baseBranch = null) { const branchName = `${this.config.branchPrefix}${name}`; // Check if branch already exists const branchExists = this.branchExists(branchName); if (branchExists) { throw new Error(`Branch ${branchName} already exists`); } // Create branch if (baseBranch) { this.exec(`git checkout -b ${branchName} ${baseBranch}`); } else { this.exec(`git checkout -b ${branchName}`); } if (this.config.verbose) { console.log(chalk.green(`✓ Created branch: ${branchName}`)); } return branchName; } /** * Check if branch exists * @param {string} branchName - Branch name * @returns {boolean} Exists */ branchExists(branchName) { try { this.exec(`git rev-parse --verify ${branchName}`, { silent: true }); return true; } catch { return false; } } /** * Switch to branch * @param {string} branchName - Branch name */ checkoutBranch(branchName) { this.exec(`git checkout ${branchName}`); if (this.config.verbose) { console.log(chalk.green(`✓ Switched to branch: ${branchName}`)); } } /** * Stage files for commit * @param {Array<string>} files - Files to stage (or '.' for all) */ stageFiles(files = ['.']) { const fileList = Array.isArray(files) ? files.join(' ') : files; this.exec(`git add ${fileList}`); if (this.config.verbose) { console.log(chalk.green(`✓ Staged files: ${fileList}`)); } } /** * Create a commit * @param {string} message - Commit message * @param {Object} options - Commit options */ createCommit(message, options = {}) { const { author = null, coAuthors = [], allowEmpty = false, } = options; // Build commit message let fullMessage = message; // Add co-authors if (coAuthors.length > 0) { fullMessage += '\n\n'; for (const coAuthor of coAuthors) { fullMessage += `Co-Authored-By: ${coAuthor}\n`; } } // Prepare commit command let cmd = 'git commit -m'; if (allowEmpty) { cmd += ' --allow-empty'; } if (author) { cmd += ` --author="${author}"`; } // Execute commit const escapedMessage = fullMessage.replace(/"/g, '\\"').replace(/\n/g, '\\n'); this.exec(`${cmd} "${escapedMessage}"`); if (this.config.verbose) { console.log(chalk.green('✓ Created commit')); } } /** * Generate patch file * @param {Array<string>} files - Files to include in patch * @param {string} outputPath - Output patch file path */ generatePatch(files, outputPath) { const fileList = files.join(' '); const patch = this.exec(`git diff HEAD ${fileList}`, { silent: true }); fs.writeFileSync(outputPath, patch, 'utf8'); if (this.config.verbose) { console.log(chalk.green(`✓ Generated patch: ${outputPath}`)); } return outputPath; } /** * Apply patch file * @param {string} patchPath - Patch file path * @param {Object} options - Apply options * @returns {Object} Apply result */ applyPatch(patchPath, options = {}) { const { threeWay = false, check = false, reverse = false, } = options; if (!fs.existsSync(patchPath)) { throw new Error(`Patch file not found: ${patchPath}`); } let cmd = 'git apply'; if (threeWay) cmd += ' --3way'; if (check) cmd += ' --check'; if (reverse) cmd += ' --reverse'; cmd += ` ${patchPath}`; try { const output = this.exec(cmd, { silent: !this.config.verbose }); return { success: true, conflicts: [], output, }; } catch (error) { // Check for conflicts const conflicts = this.getConflictedFiles(); return { success: false, conflicts, error: error.message, }; } } /** * Get list of conflicted files * @returns {Array<string>} Conflicted files */ getConflictedFiles() { try { const status = this.exec('git diff --name-only --diff-filter=U', { silent: true }); return status.trim().split('\n').filter(f => f); } catch { return []; } } /** * Resolve conflicts by choosing a strategy * @param {string} file - File with conflict * @param {string} strategy - Resolution strategy (ours, theirs, merge) */ resolveConflict(file, strategy = 'ours') { switch (strategy) { case 'ours': this.exec(`git checkout --ours ${file}`); break; case 'theirs': this.exec(`git checkout --theirs ${file}`); break; case 'merge': // Keep conflict markers for manual resolution break; default: throw new Error(`Unknown resolution strategy: ${strategy}`); } if (strategy !== 'merge') { this.exec(`git add ${file}`); } } /** * Create a stash * @param {string} message - Stash message * @returns {string} Stash reference */ createStash(message = 'PRPROMPTS auto-stash') { const output = this.exec(`git stash push -m "${message}"`); if (this.config.verbose) { console.log(chalk.green('✓ Created stash')); } return output.trim(); } /** * Pop most recent stash */ popStash() { this.exec('git stash pop'); if (this.config.verbose) { console.log(chalk.green('✓ Applied stash')); } } /** * Get diff between two commits/branches * @param {string} base - Base reference * @param {string} target - Target reference * @param {Object} options - Diff options * @returns {string} Diff output */ getDiff(base, target = 'HEAD', options = {}) { const { nameOnly = false, stat = false, unified = 3, } = options; let cmd = 'git diff'; if (nameOnly) cmd += ' --name-only'; if (stat) cmd += ' --stat'; cmd += ` --unified=${unified}`; cmd += ` ${base}..${target}`; return this.exec(cmd, { silent: true }); } /** * Get commit history * @param {number} count - Number of commits * @param {string} format - Format string * @returns {Array<Object>} Commits */ getCommitHistory(count = 10, format = '%H|%an|%ae|%at|%s') { const output = this.exec(`git log -n ${count} --format="${format}"`, { silent: true }); const lines = output.trim().split('\n'); return lines.map(line => { const [hash, authorName, authorEmail, timestamp, subject] = line.split('|'); return { hash, authorName, authorEmail, timestamp: parseInt(timestamp) * 1000, subject, }; }); } /** * Get file blame information * @param {string} file - File path * @returns {Array<Object>} Blame lines */ getBlame(file) { const output = this.exec(`git blame --line-porcelain ${file}`, { silent: true }); const lines = output.split('\n'); const blame = []; let current = {}; for (const line of lines) { if (line.match(/^[a-f0-9]{40}/)) { if (Object.keys(current).length > 0) { blame.push(current); } current = { hash: line.split(' ')[0] }; } else if (line.startsWith('author ')) { current.author = line.substring(7); } else if (line.startsWith('author-time ')) { current.timestamp = parseInt(line.substring(12)) * 1000; } else if (line.startsWith('\t')) { current.content = line.substring(1); } } if (Object.keys(current).length > 0) { blame.push(current); } return blame; } /** * Execute Git command * @param {string} cmd - Git command * @param {Object} options - Execution options * @returns {string} Command output */ exec(cmd, options = {}) { const { silent = false } = options; try { const output = execSync(cmd, { cwd: this.config.repoPath, encoding: 'utf8', stdio: silent ? 'pipe' : 'inherit', }); return output || ''; } catch (error) { if (this.config.verbose && !silent) { console.error(chalk.red(`Git command failed: ${cmd}`)); console.error(chalk.gray(error.message)); } throw error; } } /** * Get repository information * @returns {Object} Repository info */ getRepoInfo() { if (!this.isGitRepo()) { return null; } return { isGitRepo: true, currentBranch: this.getCurrentBranch(), isClean: this.isWorkingTreeClean(), remoteUrl: this.getRemoteUrl(), lastCommit: this.getCommitHistory(1)[0], }; } /** * Get remote URL * @returns {string|null} Remote URL */ getRemoteUrl() { try { return this.exec('git config --get remote.origin.url', { silent: true }).trim(); } catch { return null; } } /** * Check if Git is installed * @returns {boolean} Git is available */ static isGitAvailable() { try { execSync('git --version', { stdio: 'ignore' }); return true; } catch { return false; } } } module.exports = GitIntegration;