UNPKG

git-bolt

Version:

Git utilities for faster development

592 lines (510 loc) 22.6 kB
import chalk from 'chalk'; import dotenv from 'dotenv'; import fs from 'fs'; import git from 'isomorphic-git'; import http from 'isomorphic-git/http/node/index.js'; import { getGitHubToken, saveGitHubToken } from '../utils/auth.js'; import { askQuestion, askQuestionWithDefault, closeReadlineInterface } from '../utils/input.js'; // Load environment variables right away dotenv.config(); // Function to get a value from environment variables function getEnvValue(key) { return process.env[key] || null; } // Function to save a value to the .env file async function saveToEnvFile(key, value) { try { if (!value) return; // Check if .env file exists const envFilePath = '.env'; let envContent = ''; if (fs.existsSync(envFilePath)) { envContent = fs.readFileSync(envFilePath, 'utf8'); } // Check if the key already exists in the file const keyRegex = new RegExp(`^${key}=.*`, 'm'); if (keyRegex.test(envContent)) { // Update existing key envContent = envContent.replace(keyRegex, `${key}=${value}`); } else { // Add new key envContent += `\n${key}=${value}`; } // Write back to .env file fs.writeFileSync(envFilePath, envContent.trim() + '\n'); // Reload environment variables dotenv.config(); return true; } catch (error) { console.log(chalk.yellow(`Warning: Could not save ${key} to .env file: ${error.message}`)); return false; } } async function isGitInitialized() { try { await git.resolveRef({ fs, dir: '.', ref: 'HEAD' }); return true; } catch (error) { return false; } } async function getCurrentBranch() { try { const currentBranch = await git.currentBranch({ fs, dir: '.', fullname: false }); return currentBranch || 'main'; } catch (error) { return 'main'; } } async function initializeRepository(repoUrl, token, authorName, authorEmail) { try { // Initialize git repository await git.init({ fs, dir: '.' }); console.log(chalk.green('✓ Git repository initialized')); // Save GitHub token await saveGitHubToken(token); console.log(chalk.green('✓ GitHub token saved')); // Save repository URL to .env file for future use await saveToEnvFile('GITHUB_URL', repoUrl); // Configure git await git.addRemote({ fs, dir: '.', remote: 'origin', url: repoUrl }); console.log(chalk.green('✓ Remote repository configured')); // Set author configuration await git.setConfig({ fs, dir: '.', path: 'user.name', value: authorName }); await git.setConfig({ fs, dir: '.', path: 'user.email', value: authorEmail }); // Save author information to .env file for future use await saveToEnvFile('GITHUB_AUTHOR', authorName); await saveToEnvFile('GITHUB_AUTHOR_EMAIL', authorEmail); console.log(chalk.green('✓ Author information configured')); // Create initial commit on main branch await git.setConfig({ fs, dir: '.', path: 'init.defaultBranch', value: 'main' }); // Create an initial empty commit const sha = await git.commit({ fs, dir: '.', message: 'Initial commit', author: { name: authorName, email: authorEmail }, ref: 'refs/heads/main' }); // Set HEAD to point to main branch await git.writeRef({ fs, dir: '.', ref: 'HEAD', value: 'refs/heads/main', force: true, symbolic: true }); console.log(chalk.green('✓ Main branch created with initial commit')); } catch (error) { console.error(chalk.red('Error during initialization:'), error.message); process.exit(1); } } async function commitChanges() { try { // Get status const status = await git.statusMatrix({ fs, dir: '.' }); if (status.length === 0) { console.log(chalk.blue('No changes to commit')); return false; } // Add all changes (including deletions) for (const [filepath, headStatus, workdirStatus] of status) { if (headStatus === 1 && workdirStatus === 0) { // File exists in HEAD but not in working directory (deleted) await git.remove({ fs, dir: '.', filepath }); console.log(chalk.green(`✓ Staged deletion: ${filepath}`)); } else if (workdirStatus === 2) { // File exists in working directory await git.add({ fs, dir: '.', filepath }); console.log(chalk.green(`✓ Staged: ${filepath}`)); } } console.log(chalk.green('✓ All changes staged')); // Get author info from git config const authorName = await git.getConfig({ fs, dir: '.', path: 'user.name' }); const authorEmail = await git.getConfig({ fs, dir: '.', path: 'user.email' }); // Commit await git.commit({ fs, dir: '.', author: { name: authorName?.value || 'Unknown', email: authorEmail?.value || 'unknown@example.com' }, message: 'Update files' }); console.log(chalk.green('✓ Changes committed')); return true; } catch (error) { console.error(chalk.red('Error during commit:'), error.message); process.exit(1); } } async function showMergeConflicts() { try { // Get the status matrix to find conflicted files const status = await git.statusMatrix({ fs, dir: '.' }); const conflicts = []; for (const [filepath, head, workdir, stage] of status) { // In git status matrix, stage === 3 indicates merge conflict if (stage === 3) { conflicts.push(filepath); } } if (conflicts.length > 0) { console.log(chalk.yellow('\n📁 Files with merge conflicts:')); for (const file of conflicts) { console.log(chalk.yellow(` - ${file}`)); // Read and show the conflict content try { const content = fs.readFileSync(file, 'utf8'); const lines = content.split('\n'); let inConflict = false; let conflictContent = []; console.log(chalk.yellow('\n Conflict details:')); for (const line of lines) { if (line.startsWith('<<<<<<<')) { inConflict = true; conflictContent = []; continue; } if (line.startsWith('>>>>>>>')) { inConflict = false; console.log(chalk.gray(' ----------------------------------------')); console.log(chalk.red(' Your changes:')); console.log(conflictContent.join('\n').split('=======')[0] .split('\n') .map(l => ' ' + l) .join('\n')); console.log(chalk.blue(' Incoming changes:')); console.log(conflictContent.join('\n').split('=======')[1] .split('\n') .map(l => ' ' + l) .join('\n')); console.log(chalk.gray(' ----------------------------------------')); continue; } if (inConflict) { conflictContent.push(line); } } } catch (err) { console.log(chalk.red(` Unable to read conflict details: ${err.message}`)); } } console.log(chalk.yellow('\n🔧 To resolve conflicts:')); console.log(chalk.yellow(' 1. Open each file and look for conflict markers (<<<<<<<, =======, >>>>>>>)')); console.log(chalk.yellow(' 2. Choose which changes to keep or combine them')); console.log(chalk.yellow(' 3. Remove the conflict markers')); console.log(chalk.yellow(' 4. Save the files')); console.log(chalk.yellow(' 5. Run git-bolt sync again\n')); } return conflicts.length > 0; } catch (error) { console.error(chalk.red('Error checking merge conflicts:'), error.message); return false; } } async function checkGitignore() { try { // Check if .gitignore exists if (!fs.existsSync('.gitignore')) { console.log(chalk.yellow('⚠️ Warning: No .gitignore file found. Creating one with recommended entries...')); fs.writeFileSync('.gitignore', '.env\nnode_modules/\n.DS_Store\n'); console.log(chalk.green('✓ Created .gitignore with recommended entries')); return; } // Read .gitignore content const gitignoreContent = fs.readFileSync('.gitignore', 'utf8'); const lines = gitignoreContent.split('\n').map(line => line.trim()); // Check for .env and add it if missing if (!lines.some(line => line === '.env')) { console.log(chalk.yellow('⚠️ Warning: Adding .env to .gitignore to protect sensitive data...')); fs.appendFileSync('.gitignore', '\n.env\n'); console.log(chalk.green('✓ Added .env to .gitignore')); } } catch (error) { // Don't fail the sync process if gitignore check fails console.log(chalk.yellow('⚠️ Warning: Error managing .gitignore file:', error.message)); } } async function getChangedFiles(beforeRef, afterRef) { try { const beforeFiles = new Set(); const afterFiles = new Set(); let changes = { newFiles: [], updatedFiles: [] }; // Get files from before ref if (beforeRef !== 'unknown') { const beforeCommit = await git.readCommit({ fs, dir: '.', oid: beforeRef }); const beforeTree = await git.readTree({ fs, dir: '.', oid: beforeCommit.commit.tree }); for (const entry of beforeTree.entries) { beforeFiles.add(entry.path); } } // Get files from after ref if (afterRef !== 'unknown') { const afterCommit = await git.readCommit({ fs, dir: '.', oid: afterRef }); const afterTree = await git.readTree({ fs, dir: '.', oid: afterCommit.commit.tree }); for (const entry of afterTree.entries) { afterFiles.add(entry.path); } } // Determine new and updated files changes.newFiles = [...afterFiles].filter(file => !beforeFiles.has(file)); changes.updatedFiles = [...afterFiles].filter(file => beforeFiles.has(file)); return changes; } catch (error) { console.log(chalk.yellow('Note: Unable to determine changed files')); return { newFiles: [], updatedFiles: [] }; } } async function pullChanges(token) { try { const currentBranch = await getCurrentBranch(); console.log(chalk.blue(`⬇️ Pulling changes from remote (branch: ${currentBranch})...`)); // Get current HEAD before fetch const beforeHead = await git.resolveRef({ fs, dir: '.', ref: 'HEAD' }).catch(() => 'unknown'); // First fetch from remote with all history await git.fetch({ fs, http, dir: '.', ref: currentBranch, remote: 'origin', depth: Infinity, tags: true, singleBranch: false, onAuth: () => ({ username: token }) }); // Check if remote branch exists try { const remoteRef = await git.resolveRef({ fs, dir: '.', ref: `remotes/origin/${currentBranch}` }); try { // Try to merge remote changes await git.merge({ fs, dir: '.', theirs: `remotes/origin/${currentBranch}`, author: { name: 'Git Bolt', email: 'git-bolt@example.com' }, fastForward: true }); // Get changes after merge const afterHead = await git.resolveRef({ fs, dir: '.', ref: 'HEAD' }).catch(() => 'unknown'); // Check for changes between before and after merge if (beforeHead !== afterHead && beforeHead !== 'unknown' && afterHead !== 'unknown') { const { newFiles, updatedFiles } = await getChangedFiles(beforeHead, afterHead); if (newFiles.length > 0 || updatedFiles.length > 0) { console.log(chalk.green('✓ Pull successful with changes:')); if (newFiles.length > 0) { console.log(chalk.blue('\n📄 New files:')); newFiles.forEach(file => console.log(chalk.blue(` ${file}`))); } if (updatedFiles.length > 0) { console.log(chalk.blue('\n📝 Updated files:')); updatedFiles.forEach(file => console.log(chalk.blue(` ${file}`))); } } else { console.log(chalk.green('✓ Pull successful (no changes)')); } } else { console.log(chalk.green('✓ Pull successful (no changes)')); } return false; // No merge conflicts } catch (mergeError) { if (mergeError.code === 'MergeNotSupportedError') { console.log(chalk.yellow('⚠️ Merge conflicts detected. Please resolve them manually.')); const hasConflicts = await showMergeConflicts(); if (hasConflicts) { process.exit(1); } return true; // We have merge conflicts } throw mergeError; } } catch (error) { // If remote branch doesn't exist, this is likely the first push console.log(chalk.yellow(`🌱 Remote branch '${currentBranch}' not found. This might be your first push.`)); return false; } } catch (error) { console.error(chalk.red('Error during pull:'), error.message); process.exit(1); } } async function pushChanges(token, hasMergeCommit = false) { try { const currentBranch = await getCurrentBranch(); console.log(chalk.blue(`⬆️ Pushing changes to remote (branch: ${currentBranch})...`)); try { // Never use force push, it's too dangerous and can destroy history await git.push({ fs, http, dir: '.', remote: 'origin', ref: currentBranch, force: false, // Never use force push onAuth: () => ({ username: token, password: token }) }); console.log(chalk.green('✓ Push complete')); } catch (error) { // If push fails - we should tell user what to do instead of using force push if (error.message.includes('fast-forward')) { console.log(chalk.yellow('\n⚠️ Push failed because your local history differs from remote')); console.log(chalk.yellow('This often happens when:')); console.log(chalk.yellow(' 1. Someone else pushed changes to the remote branch')); console.log(chalk.yellow(' 2. You have local commits that aren\'t on the remote branch')); console.log(chalk.blue('\nRecommended actions:')); console.log(chalk.blue(' 1. Pull the latest changes first: npx git-bolt pull')); console.log(chalk.blue(' 2. Resolve any conflicts if they occur')); console.log(chalk.blue(' 3. Try sync again: npx git-bolt sync')); console.log(chalk.yellow('\nWe do not use force push to avoid accidental data loss.')); throw new Error('Push failed - remote branch has diverged from local branch'); } throw error; } } catch (error) { console.error(chalk.red('Error during push:'), error.message); process.exit(1); } } async function isGitHubRepoEmpty(repoUrl, token) { try { console.log(chalk.blue('Checking if GitHub repository is empty...')); // Extract owner and repo from the URL // Handle both https://github.com/owner/repo.git and https://github.com/owner/repo formats const urlMatch = repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)(\.git)?/); if (!urlMatch) { console.error(chalk.red('Error: Invalid GitHub repository URL format')); return false; } const owner = urlMatch[1]; const repo = urlMatch[2].replace('.git', ''); // Use the GitHub API to check if the repository is empty // We'll make a request to list the branches const apiUrl = `https://api.github.com/repos/${owner}/${repo}/branches`; const response = await fetch(apiUrl, { headers: { 'Authorization': `token ${token}`, 'User-Agent': 'git-bolt' } }); if (!response.ok) { // If we can't access the repo, we'll assume it's not empty for safety console.error(chalk.red(`Error checking repository: ${response.statusText}`)); return false; } const branches = await response.json(); // If there are no branches, the repo is empty return branches.length === 0; } catch (error) { console.error(chalk.red('Error checking if repository is empty:'), error.message); return false; } } async function syncCommand() { console.log(chalk.blue('🚀 Starting Git Bolt sync...')); try { const isInitialized = await isGitInitialized(); let token = await getGitHubToken(); if (!isInitialized) { console.log(chalk.yellow('Repository not initialized. Let\'s set it up! 🛠️\n')); // Get default values from environment variables const defaultRepoUrl = getEnvValue('GITHUB_URL') || ''; const defaultAuthorName = getEnvValue('GITHUB_AUTHOR') || ''; const defaultAuthorEmail = getEnvValue('GITHUB_AUTHOR_EMAIL') || ''; // Ask for repository URL with default from .env if available const repoUrl = await askQuestionWithDefault( 'Enter your GitHub repository URL (e.g., https://github.com/username/repo.git)', defaultRepoUrl, false ); // If token exists, offer it as default token = await askQuestionWithDefault('Enter your GitHub personal access token', token, true); // Check if the repository is empty before proceeding const isEmpty = await isGitHubRepoEmpty(repoUrl, token); if (!isEmpty) { console.error(chalk.red('\n❌ Error: The GitHub repository is not empty.')); console.log(chalk.yellow('git-bolt sync only supports connecting to empty GitHub repositories.')); console.log(chalk.yellow('Please create a new empty repository on GitHub and try again.')); process.exit(1); } // Ask for author info with defaults from .env if available const authorName = await askQuestionWithDefault( 'Enter your name (for Git commits)', defaultAuthorName, false ); const authorEmail = await askQuestionWithDefault( 'Enter your email (for Git commits)', defaultAuthorEmail, false ); await initializeRepository(repoUrl, token, authorName, authorEmail); console.log(chalk.green('\n✨ Repository initialized successfully!\n')); } else if (!token) { token = await askQuestion('Enter your GitHub personal access token: '); await saveGitHubToken(token); } // Check .gitignore before proceeding with sync await checkGitignore(); // Commit changes const hasChanges = await commitChanges(); // Pull changes and check for merge conflicts const hasMergeCommit = await pullChanges(token); // Push if we had local changes, passing merge commit status if (hasChanges) { await pushChanges(token, hasMergeCommit); } console.log(chalk.green('\n✨ Sync completed successfully! Your code is up to date.\n')); } catch (error) { console.error(chalk.red('\n❌ Error during sync:'), error.message); process.exit(1); } finally { closeReadlineInterface(); } } export { syncCommand };