import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as plugins from './tsdocker.plugins.js'; import * as paths from './tsdocker.paths.js'; import { logger } from './tsdocker.logging.js'; import type { ICacheData, ICacheEntry } from './interfaces/index.js'; const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', }); /** * Manages content-hash-based build caching for Dockerfiles. * Cache is stored in .nogit/tsdocker_support.json. */ export class TsDockerCache { private cacheFilePath: string; private data: ICacheData; constructor() { this.cacheFilePath = path.join(paths.cwd, '.nogit', 'tsdocker_support.json'); this.data = { version: 1, entries: {} }; } /** * Loads cache data from disk. Falls back to empty cache on missing/corrupt file. */ public load(): void { try { const raw = fs.readFileSync(this.cacheFilePath, 'utf-8'); const parsed = JSON.parse(raw); if (parsed && parsed.version === 1 && parsed.entries) { this.data = parsed; } else { logger.log('warn', '[cache] Cache file has unexpected format, starting fresh'); this.data = { version: 1, entries: {} }; } } catch { // Missing or corrupt file — start fresh this.data = { version: 1, entries: {} }; } } /** * Saves cache data to disk. Creates .nogit directory if needed. */ public save(): void { const dir = path.dirname(this.cacheFilePath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(this.cacheFilePath, JSON.stringify(this.data, null, 2), 'utf-8'); } /** * Computes SHA-256 hash of Dockerfile content. */ public computeContentHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } /** * Checks whether a build can be skipped for the given Dockerfile. * Logs detailed diagnostics and returns true if the build should be skipped. */ public async shouldSkipBuild(cleanTag: string, content: string): Promise { const contentHash = this.computeContentHash(content); const entry = this.data.entries[cleanTag]; if (!entry) { console.log(`[cache] ${cleanTag}:`); console.log(`[cache] Content hash: ${contentHash}`); console.log(`[cache] Stored hash: (none)`); console.log(`[cache] Hash match: no`); console.log(`→ Building ${cleanTag}`); return false; } const hashMatch = entry.contentHash === contentHash; console.log(`[cache] ${cleanTag}:`); console.log(`[cache] Content hash: ${contentHash}`); console.log(`[cache] Stored hash: ${entry.contentHash}`); console.log(`[cache] Image ID: ${entry.imageId}`); console.log(`[cache] Hash match: ${hashMatch ? 'yes' : 'no'}`); if (!hashMatch) { console.log(`→ Building ${cleanTag}`); return false; } // Hash matches — verify the image still exists locally const inspectResult = await smartshellInstance.exec( `docker image inspect ${entry.imageId} > /dev/null 2>&1` ); const available = inspectResult.exitCode === 0; console.log(`[cache] Available: ${available ? 'yes' : 'no'}`); if (available) { console.log(`→ Skipping build for ${cleanTag} (cache hit)`); return true; } console.log(`→ Building ${cleanTag} (image no longer available)`); return false; } /** * Records a successful build in the cache. */ public recordBuild(cleanTag: string, content: string, imageId: string, buildTag: string): void { this.data.entries[cleanTag] = { contentHash: this.computeContentHash(content), imageId, buildTag, timestamp: Date.now(), }; } }