ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
698 lines (589 loc) • 22 kB
JavaScript
// GitHub Cache Utility
// Shared functions for managing cached data in GitHub branches
const path = require('path');
const jetpack = require('fs-jetpack');
const crypto = require('crypto');
const { Octokit } = require('@octokit/rest');
const AdmZip = require('adm-zip');
class GitHubCache {
constructor(options = {}) {
this.branchName = options.branchName || 'cache-branch';
this.cacheDir = options.cacheDir || '.temp/cache';
this.logger = options.logger || console;
this.octokit = null;
this.owner = null;
this.repo = null;
this.cacheType = options.cacheType || 'Cache'; // For README generation
this.description = options.description || 'cached files for faster builds';
}
// Initialize GitHub API client
async init() {
if (!process.env.GH_TOKEN) {
throw new Error('GH_TOKEN environment variable not set');
}
if (!process.env.GITHUB_REPOSITORY) {
throw new Error('GITHUB_REPOSITORY environment variable not set');
}
[this.owner, this.repo] = process.env.GITHUB_REPOSITORY.split('/');
if (!this.octokit) {
this.octokit = new Octokit({
auth: process.env.GH_TOKEN,
});
}
return true;
}
// Check if credentials are available
hasCredentials() {
return !!(process.env.GH_TOKEN && process.env.GITHUB_REPOSITORY);
}
// Fetch cache branch from GitHub
async fetchBranch() {
await this.init();
this.logger.log(`📥 Fetching cache from branch '${this.branchName}'`);
// Check if the branch exists
let branchExists = false;
try {
await this.octokit.repos.getBranch({
owner: this.owner,
repo: this.repo,
branch: this.branchName
});
branchExists = true;
} catch (e) {
if (e.status !== 404) throw e;
}
if (!branchExists) {
this.logger.warn(`⚠️ Cache branch '${this.branchName}' does not exist. Will create on first push.`);
return false;
}
// Download branch as ZIP archive
const zipBallArchive = await this.octokit.repos.downloadZipballArchive({
owner: this.owner,
repo: this.repo,
ref: this.branchName,
});
// Save and extract ZIP
const zipPath = path.join(path.dirname(this.cacheDir), `${this.repo}-${this.branchName}.zip`);
const extractDir = path.dirname(this.cacheDir);
jetpack.write(zipPath, Buffer.from(zipBallArchive.data));
const zip = new AdmZip(zipPath);
zip.extractAllTo(extractDir, true);
// Find extracted root folder
const extractedRoot = jetpack.list(extractDir).find(name =>
name.startsWith(`${this.owner}-${this.repo}-`)
);
if (!extractedRoot) {
throw new Error('Could not find extracted archive root folder');
}
const extractedFullPath = path.join(extractDir, extractedRoot);
const targetPath = this.cacheDir;
// Move to target location
if (jetpack.exists(targetPath)) {
jetpack.remove(targetPath);
}
jetpack.move(extractedFullPath, targetPath);
// Clean up
jetpack.remove(zipPath);
// Log what was fetched
const fetchedFiles = jetpack.find(targetPath, { matching: '**/*', files: true, directories: false });
this.logger.log(`✅ Fetched cache from branch '${this.branchName}' (${fetchedFiles.length} files total)`);
return true;
}
// Push files to cache branch with automatic orphan detection
async pushBranch(updatedFiles, options = {}) {
await this.init();
// Git is required
this.requireGitCommands();
// Convert Set to array if needed
let files = Array.isArray(updatedFiles) ? updatedFiles : [...updatedFiles];
// Auto-add metadata file if it exists and not already included
const metaPath = path.join(this.cacheDir, 'meta.json');
if (jetpack.exists(metaPath) && !files.includes(metaPath)) {
files.push(metaPath);
}
// Handle orphan detection if validFiles provided
let forceRecreate = options.forceRecreate || false;
if (options.validFiles) {
const orphanCheck = await this.checkForOrphans(options.validFiles);
if (orphanCheck.hasOrphans) {
this.logger.log(`🗑️ Found ${orphanCheck.orphanedCount} orphaned files in cache`);
forceRecreate = true;
files = orphanCheck.validFiles;
// Re-add metadata after orphan check
if (jetpack.exists(metaPath) && !files.includes(metaPath)) {
files.push(metaPath);
}
}
}
this.logger.log(`📤 Pushing ${files.length} file(s) to cache branch '${this.branchName}'`);
// Generate README if stats provided
const readme = options.stats ? this.generateReadme(options.stats) :
options.branchReadme || this.generateDefaultReadme();
// If forceRecreate is true, we'll handle it in uploadFilesViaGit
if (forceRecreate) {
this.logger.log(`🔄 Force recreating cache branch with clean files...`);
const uploadedCount = await this.uploadFilesViaGit(files, true, readme);
this.logger.log(`🎉 Recreated cache branch with ${uploadedCount} file(s)`);
return uploadedCount;
} else {
// Normal update
await this.ensureBranchExists(readme);
const uploadedCount = await this.uploadFilesViaGit(files, false, readme);
if (uploadedCount > 0) {
this.logger.log(`🎉 Pushed ${uploadedCount} file(s) to cache branch`);
}
return uploadedCount;
}
}
// Delete a branch
async deleteBranch(branchName = null) {
const branch = branchName || this.branchName;
try {
this.logger.log(`🗑️ Deleting branch '${branch}'...`);
await this.octokit.git.deleteRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${branch}`
});
this.logger.log(`✅ Deleted branch '${branch}'`);
return true;
} catch (e) {
if (e.status === 404) {
this.logger.log(`⚠️ Branch '${branch}' doesn't exist, nothing to delete`);
return false;
}
throw e;
}
}
// Replace one branch with another (safe atomic operation)
async replaceBranch(sourceBranch, targetBranch) {
try {
// Get source branch SHA with retries (in case it was just created via git push)
let source;
let retries = 5;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
while (retries > 0) {
try {
const result = await this.octokit.git.getRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${sourceBranch}`
});
source = result.data;
break;
} catch (e) {
if (e.status === 404 && retries > 1) {
// Branch might not be synced yet, wait and retry
this.logger.log(`⏳ Waiting for branch '${sourceBranch}' to sync (${retries - 1} retries left)...`);
await delay(2000);
retries--;
} else {
throw e;
}
}
}
// Try to update target branch to source SHA
try {
await this.octokit.git.updateRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${targetBranch}`,
sha: source.object.sha,
force: true
});
this.logger.log(`✅ Replaced '${targetBranch}' with '${sourceBranch}'`);
} catch (e) {
if (e.status === 422) {
// Target doesn't exist, create it
await this.octokit.git.createRef({
owner: this.owner,
repo: this.repo,
ref: `refs/heads/${targetBranch}`,
sha: source.object.sha
});
this.logger.log(`✅ Created '${targetBranch}' from '${sourceBranch}'`);
} else {
throw e;
}
}
// Delete source branch
await this.deleteBranch(sourceBranch);
return true;
} catch (error) {
this.logger.error(`❌ Failed to replace branch: ${error.message}`);
throw error;
}
}
// Ensure branch exists, create if needed
async ensureBranchExists(readmeContent) {
let branchExists = false;
try {
await this.octokit.repos.getBranch({
owner: this.owner,
repo: this.repo,
branch: this.branchName
});
branchExists = true;
} catch (e) {
if (e.status !== 404) throw e;
}
if (!branchExists) {
this.logger.log(`📝 Creating new cache branch '${this.branchName}'`);
// Default README content
const content = readmeContent || `This branch stores cached data for faster builds\n`;
// Create README blob
const { data: blob } = await this.octokit.git.createBlob({
owner: this.owner,
repo: this.repo,
content: content,
encoding: 'utf-8'
});
// Create tree
const { data: tree } = await this.octokit.git.createTree({
owner: this.owner,
repo: this.repo,
tree: [{
path: 'README.md',
mode: '100644',
type: 'blob',
sha: blob.sha
}]
});
// Create commit
const { data: commit } = await this.octokit.git.createCommit({
owner: this.owner,
repo: this.repo,
message: `Initial cache branch: ${this.branchName}`,
tree: tree.sha,
parents: []
});
// Create branch
await this.octokit.git.createRef({
owner: this.owner,
repo: this.repo,
ref: `refs/heads/${this.branchName}`,
sha: commit.sha
});
this.logger.log(`✅ Created cache branch '${this.branchName}'`);
}
return branchExists;
}
// Load metadata file
loadMetadata(metaPath) {
let meta = {};
if (jetpack.exists(metaPath)) {
try {
meta = jetpack.read(metaPath, 'json');
} catch (e) {
this.logger.warn('⚠️ Metadata file corrupted - starting fresh');
}
}
return meta;
}
// Save metadata file
saveMetadata(metaPath, meta) {
jetpack.write(metaPath, meta);
}
// Clean deleted files from metadata
cleanDeletedFromMetadata(meta, currentFiles, rootPath) {
const currentFilesSet = new Set(currentFiles.map(f =>
path.relative(rootPath, f)
));
let removedCount = 0;
Object.keys(meta).forEach(key => {
if (!currentFilesSet.has(key)) {
delete meta[key];
this.logger.log(`🗑️ Removed deleted file from metadata: ${key}`);
removedCount++;
}
});
return removedCount;
}
// Check if git commands are available (required)
requireGitCommands() {
const { execSync } = require('child_process');
try {
execSync('git --version', { stdio: 'ignore' });
return true;
} catch (e) {
throw new Error('Git is required but not available. Please ensure git is installed and in PATH.');
}
}
// Upload files using git commands (much faster for multiple files)
async uploadFilesViaGit(files, forceRecreate = false, readme = null) {
const { execSync } = require('child_process');
this.logger.log(`🚀 Using fast git upload for ${files.length} files`);
try {
// Work directly in the cache directory
const gitDir = path.join(this.cacheDir, '.git');
if (forceRecreate) {
// For force recreate, remove git dir and init fresh
this.logger.log(`🆕 Initializing fresh repository in ${this.cacheDir}...`);
jetpack.remove(gitDir);
execSync('git init', { cwd: this.cacheDir, stdio: 'ignore' });
execSync(`git remote add origin https://${process.env.GH_TOKEN}@github.com/${this.owner}/${this.repo}.git`, { cwd: this.cacheDir, stdio: 'ignore' });
execSync(`git checkout -b ${this.branchName}`, { cwd: this.cacheDir, stdio: 'ignore' });
} else if (!jetpack.exists(gitDir)) {
// If no git dir exists, clone the branch
this.logger.log(`📥 Initializing git in cache directory...`);
// Save current files temporarily
const tempBackup = path.join(path.dirname(this.cacheDir), `${path.basename(this.cacheDir)}-backup-${Date.now()}`);
if (jetpack.exists(this.cacheDir)) {
jetpack.move(this.cacheDir, tempBackup);
}
// Clone the branch
execSync(
`git clone --depth 1 --branch ${this.branchName} https://${process.env.GH_TOKEN}@github.com/${this.owner}/${this.repo}.git "${this.cacheDir}"`,
{ stdio: 'ignore' }
);
// Restore backed up files (overwriting cloned files)
if (jetpack.exists(tempBackup)) {
jetpack.copy(tempBackup, this.cacheDir, { overwrite: true });
jetpack.remove(tempBackup);
}
} else {
// Git dir exists, just pull latest
this.logger.log(`📥 Pulling latest changes...`);
try {
execSync('git fetch origin ' + this.branchName, { cwd: this.cacheDir, stdio: 'ignore' });
execSync('git reset --hard origin/' + this.branchName, { cwd: this.cacheDir, stdio: 'ignore' });
} catch (e) {
// If pull fails, continue anyway - we'll force push if needed
this.logger.warn('⚠️ Pull failed, will force push if needed');
}
}
// Add README if provided
let readmeChanged = false;
if (readme) {
const readmePath = path.join(this.cacheDir, 'README.md');
const existingReadme = jetpack.exists(readmePath) ? jetpack.read(readmePath) : '';
if (existingReadme !== readme) {
this.logger.log('📝 README content has changed, updating...');
readmeChanged = true;
}
jetpack.write(readmePath, readme);
}
// Check if there are changes
const status = execSync('git status --porcelain', { cwd: this.cacheDir }).toString();
if (!status.trim()) {
this.logger.log('⏭️ No changes to commit (including README)');
return 0;
}
// Log what changed
const changedFiles = status.trim().split('\n').length;
if (readmeChanged && changedFiles === 1) {
this.logger.log('📄 Only README.md has changed, committing update...');
} else if (readmeChanged) {
this.logger.log(`📄 README.md and ${changedFiles - 1} cache files have changed`);
} else {
this.logger.log(`📝 ${changedFiles} files have changed`);
}
// Add all changes
this.logger.log(`📝 Staging changes...`);
execSync('git add -A', { cwd: this.cacheDir, stdio: 'ignore' });
// Create commit message based on what changed
let commitMessage;
if (readmeChanged && changedFiles === 1) {
commitMessage = '📊 Update cache statistics in README';
} else if (readmeChanged) {
commitMessage = `📦 Update cache: ${changedFiles - 1} files + README stats`;
} else {
commitMessage = `📦 Update cache: ${changedFiles} files`;
}
// Commit
execSync(
`git -c user.name="GitHub Actions" -c user.email="actions@github.com" commit -m "${commitMessage}"`,
{ cwd: this.cacheDir, stdio: 'ignore' }
);
// Push
this.logger.log(`📤 Pushing to GitHub...`);
try {
execSync('git push origin ' + this.branchName, { cwd: this.cacheDir, stdio: 'ignore' });
} catch (e) {
// If normal push fails, try force push
this.logger.warn('⚠️ Normal push failed, attempting force push...');
execSync('git push --force origin ' + this.branchName, { cwd: this.cacheDir, stdio: 'ignore' });
}
return changedFiles;
} catch (error) {
this.logger.error(`❌ Git command failed: ${error.message}`);
throw error; // No fallback - git is required
}
}
// Check for orphaned files in cache
async checkForOrphans(validFiles) {
const validSet = new Set(validFiles);
const cacheFiles = jetpack.find(this.cacheDir, {
matching: '**/*',
files: true,
directories: false
});
const orphanedFiles = [];
const validCacheFiles = [];
cacheFiles.forEach(file => {
const relativePath = path.relative(this.cacheDir, file);
if (validSet.has(relativePath) || relativePath === 'meta.json') {
validCacheFiles.push(file);
} else {
orphanedFiles.push(relativePath);
if (process.env.UJ_LOUD_LOGS === 'true') {
this.logger.log(` - Orphaned: ${relativePath}`);
}
}
});
return {
hasOrphans: orphanedFiles.length > 0,
orphanedCount: orphanedFiles.length,
validFiles: validCacheFiles,
orphanedFiles
};
}
// Generate default README
generateDefaultReadme() {
return `# ${this.cacheType} Cache Branch
This branch stores ${this.description}.
---
*Generated automatically by build process*
`;
}
// Generate README with stats
generateReadme(stats = {}) {
const date = new Date(stats.timestamp || Date.now());
const formattedDate = date.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
let readme = `# ${this.cacheType} Cache Branch
This branch stores ${this.description}.
## Cache Information
- **Last Updated:** ${formattedDate}
`;
// Add custom stats
if (stats.sourceCount !== undefined) {
readme += `- **Source Files:** ${stats.sourceCount}\n`;
}
if (stats.cachedCount !== undefined) {
readme += `- **Cached Files:** ${stats.cachedCount}\n`;
}
// Add timing information if provided
if (stats.timing) {
const { startTime, endTime, elapsedMs } = stats.timing;
const elapsedFormatted = this.formatElapsedTime(elapsedMs);
readme += `
## Timing Information
- **Start Time:** ${new Date(startTime).toLocaleTimeString()}
- **End Time:** ${new Date(endTime).toLocaleTimeString()}
- **Total Elapsed:** ${elapsedFormatted}
`;
}
// Add last run stats if provided
if (stats.processedNow !== undefined || stats.fromCache !== undefined || stats.newlyProcessed !== undefined) {
readme += `
## Last Run Statistics
- **Total Files Processed:** ${stats.processedNow || 0}
- **Files From Cache:** ${stats.fromCache || 0}
- **Newly Processed:** ${stats.newlyProcessed || 0}
`;
// Add percentage if both values exist
if (stats.processedNow && stats.fromCache !== undefined) {
const cacheRate = ((stats.fromCache / stats.processedNow) * 100).toFixed(1);
readme += `- **Cache Hit Rate:** ${cacheRate}%\n`;
}
}
// Add language breakdown for translation
if (stats.languageBreakdown && stats.languageBreakdown.length > 0) {
readme += `
## Language Breakdown
`;
stats.languageBreakdown.forEach(lang => {
const cacheRate = lang.total > 0 ? ((lang.fromCache / lang.total) * 100).toFixed(1) : 0;
readme += `### ${lang.language.toUpperCase()}\n`;
readme += `- **Total Files:** ${Math.round(lang.total)}\n`;
readme += `- **From Cache:** ${lang.fromCache} (${cacheRate}%)\n`;
readme += `- **Newly Translated:** ${lang.newlyTranslated}\n`;
if (lang.failed > 0) {
readme += `- **Failed:** ${lang.failed}\n`;
}
readme += '\n';
});
}
// Add token usage and costs for translation
if (stats.tokenUsage) {
const { inputTokens, outputTokens, totalTokens, inputCost, outputCost, totalCost } = stats.tokenUsage;
readme += `
## Token Usage & Costs
- **Input Tokens:** ${(inputTokens || 0).toLocaleString()}
- **Output Tokens:** ${(outputTokens || 0).toLocaleString()}
- **Total Tokens:** ${(totalTokens || 0).toLocaleString()}
### Cost Breakdown
- **Input Cost:** $${(inputCost || 0).toFixed(4)}
- **Output Cost:** $${(outputCost || 0).toFixed(4)}
- **Total Cost:** $${(totalCost || 0).toFixed(4)}
`;
}
// Add image optimization stats if provided
if (stats.imageStats) {
const { totalImages, optimized, skipped, totalSizeBefore, totalSizeAfter, totalSaved } = stats.imageStats;
readme += `
## Image Optimization Statistics
- **Total Images:** ${totalImages || 0}
- **Optimized:** ${optimized || 0}
- **Skipped (from cache):** ${skipped || 0}
`;
if (totalSizeBefore && totalSizeAfter) {
const savedPercent = ((totalSaved / totalSizeBefore) * 100).toFixed(1);
readme += `
### Size Reduction
- **Original Size:** ${this.formatBytes(totalSizeBefore)}
- **Optimized Size:** ${this.formatBytes(totalSizeAfter)}
- **Total Saved:** ${this.formatBytes(totalSaved)} (${savedPercent}%)
`;
}
}
// Add custom details section if provided
if (stats.details) {
readme += `
## Details
${stats.details}
`;
}
readme += `
---
*Generated automatically by build process*
`;
return readme;
}
// Helper to format elapsed time
formatElapsedTime(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
// Helper to format bytes
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Calculate file hash
calculateHash(filePath) {
const content = jetpack.read(filePath, 'buffer');
return crypto.createHash('sha256').update(content).digest('hex');
}
}
module.exports = GitHubCache;