git-bolt
Version:
Git utilities for faster development
592 lines (510 loc) • 22.6 kB
JavaScript
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 };