fake-it-til-you-git
Version:
A modern CLI tool to generate fake Git commit history for your GitHub/GitLab profile
624 lines ⢠28.2 kB
JavaScript
import chalk from 'chalk';
import ora from 'ora';
import inquirer from 'inquirer';
import { writeFileSync, appendFileSync, existsSync } from 'fs';
import { join } from 'path';
import { loadConfig } from './config.js';
import { GitOperations } from './git.js';
import { validateCommitPlan, generateCommitPlan, populateCommitMessages, calculateCommitStats } from './commits.js';
// Global state for cleanup
let globalGitOps = null;
let isOperationInProgress = false;
let currentBackup = null;
/**
* Enhanced error handler with better context and cleanup
*/
export class FakeGitError extends Error {
constructor(message, code, recoverable = false, context) {
super(message);
this.code = code;
this.recoverable = recoverable;
this.context = context;
this.name = 'FakeGitError';
}
}
/**
* Setup cleanup handlers for graceful interruption
*/
function setupCleanupHandlers() {
const cleanup = async (signal) => {
if (!isOperationInProgress) {
console.log(chalk.yellow(`\nš Received ${signal}, exiting cleanly...`));
process.exit(0);
}
console.log(chalk.yellow(`\nš Received ${signal} during operation. Cleaning up...`));
try {
if (globalGitOps && currentBackup) {
console.log(chalk.cyan('š Restoring repository state from backup...'));
await globalGitOps.restoreFromBackup(currentBackup);
console.log(chalk.green('ā
Repository state restored successfully'));
// Clean up the backup we just used
await globalGitOps.cleanupBackup(currentBackup);
}
}
catch (error) {
console.error(chalk.red(`ā Failed to restore repository state: ${error instanceof Error ? error.message : 'Unknown error'}`));
console.error(chalk.red('ā ļø Repository might be in an inconsistent state. Manual cleanup may be required.'));
}
finally {
console.log(chalk.yellow('š Cleanup completed. Exiting...'));
process.exit(1);
}
};
// Handle different termination signals
process.on('SIGINT', () => cleanup('SIGINT'));
process.on('SIGTERM', () => cleanup('SIGTERM'));
// Handle uncaught exceptions
process.on('uncaughtException', async (error) => {
console.error(chalk.red('\nš„ Uncaught Exception:'), error);
await cleanup('UNCAUGHT_EXCEPTION');
});
// Handle unhandled promise rejections
process.on('unhandledRejection', async (reason, promise) => {
console.error(chalk.red('\nš„ Unhandled Rejection at:'), promise, 'reason:', reason);
await cleanup('UNHANDLED_REJECTION');
});
}
export async function main(options) {
// Setup cleanup handlers early
setupCleanupHandlers();
const spinner = ora('Initializing fake-it-til-you-git...').start();
try {
isOperationInProgress = true;
// Load and validate configuration
spinner.text = 'Loading configuration...';
const config = await loadConfig(options);
// Generate random seed if no seed was specified in CLI
if (!options.seed) {
config.seed = Math.random().toString(36).substring(2, 15);
}
// Generate commit plan
const rawPlans = generateCommitPlan(config);
const populatedPlans = populateCommitMessages(rawPlans, config);
// Validate the plan (after messages are populated)
const validation = validateCommitPlan(populatedPlans, config);
if (!validation.valid) {
throw new FakeGitError(`Invalid commit plan: ${validation.errors.join(', ')}`, 'INVALID_PLAN', false, { validation });
}
// Initialize Git operations (for both preview and actual execution)
spinner.text = 'Initializing Git operations...';
const isDevelopmentMode = config.options.dev || process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
// Determine repository path: dev mode > repositoryPath > current directory
let targetRepoPath;
if (isDevelopmentMode) {
targetRepoPath = join(process.cwd(), 'test-repo');
}
else if (config.options.repositoryPath !== '.') {
targetRepoPath = config.options.repositoryPath;
}
const gitOps = new GitOperations(targetRepoPath);
globalGitOps = gitOps; // Store for cleanup
const repoPath = gitOps.getRepositoryPath();
// Check if it's a Git repository
const isRepo = await gitOps.isGitRepository();
if (!isRepo && !config.options.preview) {
spinner.text = 'Initializing Git repository...';
await gitOps.initRepository();
}
spinner.succeed();
// DISPLAY INFORMATION (for both preview and actual execution)
const stats = calculateCommitStats(populatedPlans);
const totalDays = populatedPlans.length;
const realismScore = calculateRealismScore(populatedPlans, config);
const realismLevel = getRealismLevel(realismScore);
if (config.options.preview) {
console.log(chalk.cyan('\nš ') + chalk.bold.cyan('HISTORY PREVIEW'));
}
else {
console.log(chalk.cyan('\nš ') + chalk.bold.cyan('EXECUTION PLAN'));
}
console.log(chalk.cyan('\nš OVERVIEW:'));
console.log(chalk.white(` š
${config.dateRange.startDate} ā ${config.dateRange.endDate} (${totalDays} days)`));
console.log(chalk.white(` š ${stats.totalCommits} commits across ${stats.activeDays} active days (${((stats.activeDays / totalDays) * 100).toFixed(1)}% activity)`));
console.log(chalk.white(` š ${stats.minPerDay}-${stats.maxPerDay} commits/day (avg: ${stats.averagePerDay.toFixed(1)})`));
console.log(chalk.white(` š¤ ${config.author.name} <${config.author.email}>`));
console.log(chalk.white(` š¬ ${config.commits.messageStyle} messages`));
console.log(chalk.white(` šÆ ${realismLevel.label} (${realismScore % 1 === 0 ? realismScore.toString() : realismScore.toFixed(1)}/10)`));
if (config.seed) {
console.log(chalk.white(` š± Seed: ${config.seed}`));
}
// Repository information (for non-preview mode)
if (!config.options.preview) {
const repoInfo = await gitOps.getRepositoryInfo();
console.log(chalk.white(` š Repository: ${repoInfo.path}`));
console.log(chalk.white(` šæ Branch: ${repoInfo.branch}${repoInfo.totalCommits > 0 ? ` (${repoInfo.totalCommits} existing commits)` : ''}`));
if (config.options.push && repoInfo.remote) {
console.log(chalk.white(` š Will push to: ${repoInfo.remote}`));
}
}
// GitHub contribution graph
console.log(chalk.cyan('\nš GITHUB CONTRIBUTION GRAPH:'));
displayGitHubStyleGraph(populatedPlans);
// Realism warnings if needed
if (realismScore < 6) {
console.log(chalk.yellow('\nā ļø REALISM SUGGESTIONS:'));
if (stats.averagePerDay > 8) {
console.log(chalk.yellow(` ⢠Consider reducing commits/day (currently ${stats.averagePerDay.toFixed(1)})`));
}
if (stats.activeDays / totalDays > 0.8) {
console.log(chalk.yellow(` ⢠Add more rest days (currently ${((stats.activeDays / totalDays) * 100).toFixed(1)}% active)`));
}
if (stats.maxPerDay > 15) {
console.log(chalk.yellow(` ⢠Reduce maximum daily commits (currently ${stats.maxPerDay})`));
}
}
// PREVIEW MODE - Just show the plan and exit
if (config.options.preview) {
console.log(chalk.yellow('\nš” This is a preview only. Remove --preview to create actual commits.'));
isOperationInProgress = false;
return;
}
// CONFIRMATION for actual execution
let shouldProceed = true;
if (!config.options.yes) {
shouldProceed = await showConfirmationDialog(populatedPlans, config, gitOps);
if (!shouldProceed) {
console.log(chalk.yellow('\nš« Operation cancelled by user'));
console.log(chalk.dim('š” Use --preview to see what would be created without making changes'));
isOperationInProgress = false;
return;
}
}
else {
// Auto-accept with yes flag
console.log(chalk.cyan(`\nā
Auto-confirming: Proceeding with ${calculateCommitStats(populatedPlans).totalCommits} commits (--yes flag)`));
}
// Prepare history.txt file (only when actually creating commits)
const historyFilePath = join(repoPath, 'history.txt');
if (!existsSync(historyFilePath)) {
const header = '# Fake Git History Log\n# Format: DATE | AUTHOR | EMAIL | MESSAGE\n\n';
writeFileSync(historyFilePath, header, 'utf-8');
}
// Execute commit creation with elegant progress tracking
const result = await createCommitsWithProgress(populatedPlans, config, gitOps, historyFilePath);
// Show results
if (result.success) {
console.log(chalk.green('\nš Fake Git history created successfully!'));
// Show final repository stats
const finalRepoInfo = await gitOps.getRepositoryInfo();
console.log(chalk.cyan('\nš FINAL STATUS:'));
console.log(chalk.white(` š Created: ${result.successfulCommits} commits`));
console.log(chalk.white(` ā±ļø Duration: ${(result.duration / 1000).toFixed(1)}s`));
console.log(chalk.white(` šæ ${finalRepoInfo.branch} (${finalRepoInfo.totalCommits} total commits)`));
if (finalRepoInfo.lastCommit) {
console.log(chalk.white(` š Latest: "${finalRepoInfo.lastCommit.message}"`));
}
// Enhanced push functionality
if (config.options.push && finalRepoInfo.remote) {
await handlePushToRemote(gitOps, finalRepoInfo, config.options.verbose);
}
// Clean up old backups on success
try {
await gitOps.cleanupOldBackups();
}
catch (error) {
// Don't fail the whole operation for backup cleanup issues
if (config.options.verbose) {
console.log(chalk.yellow(`ā ļø Failed to cleanup old backups: ${error instanceof Error ? error.message : 'Unknown error'}`));
}
}
}
else {
throw new FakeGitError('Failed to create fake Git history', 'COMMIT_CREATION_FAILED', true, { result });
}
isOperationInProgress = false;
}
catch (error) {
isOperationInProgress = false;
spinner.fail(chalk.red('ā Execution failed'));
if (error instanceof FakeGitError) {
console.error(chalk.red(`\nš„ ${error.message}`));
if (error.code === 'COMMIT_CREATION_FAILED' && error.recoverable) {
console.log(chalk.yellow('\nš Attempting to recover...'));
if (globalGitOps && currentBackup) {
try {
await globalGitOps.restoreFromBackup(currentBackup);
console.log(chalk.green('ā
Repository restored to previous state'));
}
catch (restoreError) {
console.error(chalk.red(`ā Failed to restore: ${restoreError instanceof Error ? restoreError.message : 'Unknown error'}`));
}
}
}
if (options.verbose && error.context) {
console.error(chalk.gray('\nš Error context:'), JSON.stringify(error.context, null, 2));
}
}
else {
console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : 'Unknown error'}`));
if (options.verbose && error instanceof Error && error.stack) {
console.error(chalk.gray('\nš Stack trace:'), error.stack);
}
}
console.error(chalk.red('\nš” Tips:'));
console.error(chalk.red(' - Use --preview to see what would be created without making changes'));
console.error(chalk.red(' - Use --verbose for more detailed error information'));
console.error(chalk.red(' - Check that your Git repository is in a clean state'));
process.exit(1);
}
}
/**
* Handle pushing to remote with proper error handling and progress
*/
async function handlePushToRemote(gitOps, repoInfo, verbose) {
const pushSpinner = ora('Pushing commits to remote...').start();
try {
// Check if we have commits to push
const status = await gitOps.getRepositoryStatus();
if (status.ahead === 0) {
pushSpinner.info(chalk.yellow('š” No commits to push - repository is up to date'));
return;
}
pushSpinner.text = `Pushing ${status.ahead} commits to ${repoInfo.remote}...`;
// Perform the push
const pushResult = await gitOps.push();
if (pushResult.success) {
pushSpinner.succeed(chalk.green(`š Successfully pushed ${status.ahead} commits to ${repoInfo.remote}`));
if (verbose && pushResult.details) {
console.log(chalk.gray(` Push details: ${pushResult.details}`));
}
}
else {
throw new Error(pushResult.error || 'Push failed for unknown reason');
}
}
catch (error) {
pushSpinner.fail(chalk.red('ā Failed to push to remote'));
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(chalk.red(` Error: ${errorMessage}`));
// Provide helpful suggestions based on common push errors
if (errorMessage.includes('rejected')) {
console.log(chalk.yellow('\nš” Push was rejected. This might be because:'));
console.log(chalk.yellow(' - The remote has commits that you don\'t have locally'));
console.log(chalk.yellow(' - You need to pull first: git pull origin main'));
console.log(chalk.yellow(' - Force push might be needed (use with caution): git push --force'));
}
else if (errorMessage.includes('permission') || errorMessage.includes('authentication')) {
console.log(chalk.yellow('\nš” Authentication failed. Make sure:'));
console.log(chalk.yellow(' - Your SSH key is properly configured'));
console.log(chalk.yellow(' - You have push access to the repository'));
console.log(chalk.yellow(' - Your credentials are up to date'));
}
else if (errorMessage.includes('network') || errorMessage.includes('connection')) {
console.log(chalk.yellow('\nš” Network error. Check your internet connection and try again.'));
}
// Don't exit on push failure - the commits were created successfully
console.log(chalk.dim('\nš Note: Commits were created successfully locally. Push can be done manually later.'));
}
}
/**
* Display a GitHub-style contribution graph using the improved algorithm
* Based on the GitHub activity visualization algorithm provided by user
*/
function displayGitHubStyleGraph(plans) {
if (plans.length === 0) {
console.log(' No commits to display');
return;
}
// Extract commit dates list and date range
const commitDateList = [];
plans.forEach(plan => {
for (let i = 0; i < plan.count; i++) {
commitDateList.push(plan.date);
}
});
const startDate = plans[0].date;
const endDate = plans[plans.length - 1].date;
// Count commits by day
const commitsByDay = {};
commitDateList.forEach(date => {
const dateKey = formatDateKey(date);
if (!commitsByDay[dateKey]) {
commitsByDay[dateKey] = 0;
}
commitsByDay[dateKey]++;
});
// Get the max number of commits in a day
let maxCommitsInDay = 0;
Object.values(commitsByDay).forEach(count => {
if (count > maxCommitsInDay) {
maxCommitsInDay = count;
}
});
// Generate a list of all days between start and end date
const totalDays = differenceInDays(endDate, startDate) + 1;
const days = [];
for (let i = 0; i < totalDays; i++) {
days.push(addDays(startDate, i));
}
// Calculate the number of weeks
const totalWeeks = Math.ceil(totalDays / 7);
// Track month positions for labels
const monthLabelPositions = [];
let currentMonth = null;
days.forEach((day, index) => {
const month = formatMonth(day);
const week = Math.floor(index / 7);
if (month !== currentMonth) {
monthLabelPositions.push({ month, week });
currentMonth = month;
}
});
// Build the visualization
const result = [];
// Create month labels row
let monthRow = " "; // Space for day labels
for (let i = 0; i < monthLabelPositions.length; i++) {
const { month, week } = monthLabelPositions[i];
// Add the month label
monthRow += month;
if (i < monthLabelPositions.length - 1) {
// Add spaces to align with the next month or fill to the end
const nextMonthWeek = monthLabelPositions.find(m => m.week > week)?.week || totalWeeks;
const spacesToAdd = (nextMonthWeek - week - 1) * 1.7;
monthRow += " ".repeat(Math.floor(spacesToAdd));
}
}
result.push(monthRow);
// Create day rows with contribution cells
const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// GitHub-like intensity blocks using Unicode characters
const intensityBlocks = [
chalk.hex("#fdfdfd")("ā "), // Empty/no commits (white square)
chalk.hex("#7feebb")("ā "), // Few commits (light green)
chalk.hex("#4ac26b")("ā "), // Some commits (medium green)
chalk.hex("#2da44e")("ā "), // Many commits (darker green)
chalk.hex("#116329")("ā ") // Most commits (darkest green)
];
// Organize days by day of week and week number
const calendar = Array(7)
.fill(null)
.map(() => Array(totalWeeks).fill(null));
days.forEach((day, index) => {
const dayOfWeek = getDay(day); // 0 = Sunday, 1 = Monday, etc.
const week = Math.floor(index / 7);
calendar[dayOfWeek][week] = day;
});
// Generate rows for each day of the week
for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
let row = chalk.bold(dayLabels[dayOfWeek]) + " ";
for (let week = 0; week < totalWeeks; week++) {
const day = calendar[dayOfWeek][week];
if (day) {
const dateKey = formatDateKey(day);
// If there are no commits on this day
if (!commitsByDay[dateKey]) {
row += intensityBlocks[0] + " "; // No activity square
}
else {
// There are commits on this day
const commitCount = commitsByDay[dateKey];
// Calculate intensity level (0-4)
const intensity = Math.min(Math.ceil((commitCount / maxCommitsInDay) * 4), 4);
row += intensityBlocks[intensity] + " ";
}
}
else {
row += " "; // No day (outside the date range)
}
}
result.push(row);
}
result.push("");
// Add a legend
result.push(`Legend: ${intensityBlocks[0]} No commits ${intensityBlocks[1]} Few ${intensityBlocks[2]} Some ${intensityBlocks[3]} Many ${intensityBlocks[4]} Most`);
result.push("");
// Print all the results
console.log(result.join('\n'));
}
// Helper functions to match the original algorithm
function formatDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatMonth(date) {
return date.toLocaleDateString('en', { month: 'short' });
}
function getDay(date) {
return date.getDay(); // 0 = Sunday, 1 = Monday, etc.
}
function differenceInDays(endDate, startDate) {
const timeDiff = endDate.getTime() - startDate.getTime();
return Math.floor(timeDiff / (1000 * 60 * 60 * 24));
}
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Calculate a realism score based on commit patterns
*/
function calculateRealismScore(plans, config) {
const stats = calculateCommitStats(plans);
let score = 10;
// Penalize unrealistic averages
if (stats.averagePerDay > 10)
score -= 2;
else if (stats.averagePerDay > 6)
score -= 1;
// Penalize too many active days
const activityRate = stats.activeDays / stats.totalDays;
if (activityRate > 0.9)
score -= 2;
else if (activityRate > 0.7)
score -= 1;
// Penalize extreme days
if (stats.maxPerDay > 20)
score -= 3;
else if (stats.maxPerDay > 15)
score -= 2;
else if (stats.maxPerDay > 10)
score -= 1;
// Bonus for realistic patterns
if (stats.averagePerDay >= 2 && stats.averagePerDay <= 5)
score += 1;
if (activityRate >= 0.3 && activityRate <= 0.6)
score += 1;
return Math.max(0, Math.min(10, score));
}
/**
* Get realism level description
*/
function getRealismLevel(score) {
if (score >= 8) {
return {
label: 'Very Realistic',
icon: 'šÆ',
description: 'This pattern looks very natural and realistic'
};
}
else if (score >= 6) {
return {
label: 'Realistic',
icon: 'ā
',
description: 'This pattern looks believable'
};
}
else if (score >= 4) {
return {
label: 'Somewhat Artificial',
icon: 'ā ļø',
description: 'This pattern might look artificial to careful observers'
};
}
else {
return {
label: 'Very Artificial',
icon: 'šØ',
description: 'This pattern will likely be detected as fake'
};
}
}
/**
* Show interactive confirmation dialog for non-preview operations
*/
async function showConfirmationDialog(plans, config, gitOps) {
const stats = calculateCommitStats(plans);
const repoInfo = await gitOps.getRepositoryInfo();
const message = `\nProceed with creating ${stats.totalCommits} commits?`;
const answer = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message,
default: false,
},
]);
return answer.proceed;
}
/**
* Create commits with elegant progress tracking
* This function creates commits with a clean progress indicator
*/
async function createCommitsWithProgress(plans, config, gitOps, historyFilePath) {
const startTime = Date.now();
const result = {
success: false,
totalCommits: 0,
successfulCommits: 0,
failedCommits: 0,
errors: [],
commitHashes: [],
duration: 0,
};
// Calculate total commits to create
result.totalCommits = plans.reduce((sum, plan) => sum + plan.count, 0);
if (result.totalCommits === 0) {
result.success = true;
result.duration = Date.now() - startTime;
return result;
}
const progressSpinner = ora({
text: `Creating commits... 0/${result.totalCommits}`,
spinner: 'dots'
}).start();
try {
// Check if working directory is clean, but handle newly initialized repos
const status = await gitOps.getRepositoryStatus();
// If there are modified or staged files, the directory is not clean
// For newly initialized repos, untracked files are expected and acceptable
const hasModifiedFiles = status.modified.length > 0 || status.staged.length > 0 || status.deleted.length > 0;
if (hasModifiedFiles) {
throw new Error('Working directory is not clean. Please commit or stash your changes before proceeding.');
}
// Create a backup before starting
let backup;
try {
backup = await gitOps.createBackup();
currentBackup = backup; // Store globally for cleanup
}
catch (error) {
// Continue without backup if it fails
}
// Process each day's commits
for (const plan of plans) {
if (plan.count === 0 || plan.messages.length === 0) {
continue;
}
// Create commits for this day
for (let i = 0; i < plan.count && i < plan.messages.length; i++) {
try {
const commitMessage = plan.messages[i];
const commitDate = new Date(plan.date);
// Add slight time variation to avoid conflicts (spread commits across the day)
const hoursOffset = Math.floor((i * 24) / plan.count);
const minutesOffset = Math.floor(Math.random() * 60);
commitDate.setHours(hoursOffset, minutesOffset, 0, 0);
const author = {
name: config.author.name,
email: config.author.email,
};
// Append to history.txt with commit information
const historyLine = `${commitDate.toISOString()} | ${author.name} | ${author.email} | ${commitMessage}\n`;
appendFileSync(historyFilePath, historyLine, 'utf-8');
// Add the history file to git
await gitOps.addAll();
// Create the commit
const commitResult = await gitOps.createCommit(commitMessage, commitDate, author);
result.commitHashes.push(commitResult.commit);
result.successfulCommits++;
// Update progress
progressSpinner.text = `Creating commits... ${result.successfulCommits}/${result.totalCommits}`;
// Optional: Add a small delay to avoid overwhelming the system
if (result.totalCommits > 100 && result.successfulCommits % 50 === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
catch (error) {
const errorMessage = `Failed to create commit on ${plan.date.toISOString()}: ${error instanceof Error ? error.message : 'Unknown error'}`;
result.errors.push(errorMessage);
result.failedCommits++;
// If too many commits are failing, abort the process
if (result.failedCommits > 10) {
throw new Error(`Too many commit failures (${result.failedCommits}). Aborting to prevent further issues.`);
}
}
}
}
// Check final success status
result.success = result.failedCommits === 0 || result.successfulCommits > 0;
progressSpinner.succeed(chalk.green(`ā
Created ${result.successfulCommits} commits`));
}
catch (error) {
const errorMessage = `Critical error during commit creation: ${error instanceof Error ? error.message : 'Unknown error'}`;
result.errors.push(errorMessage);
result.success = false;
progressSpinner.fail(chalk.red('ā Failed to create commits'));
}
result.duration = Date.now() - startTime;
return result;
}
//# sourceMappingURL=main.js.map