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) { logger.log('info', `[cache] ${cleanTag}: no cached entry, will build`); return false; } const hashMatch = entry.contentHash === contentHash; logger.log('info', `[cache] ${cleanTag}: hash ${hashMatch ? 'matches' : 'changed'}`); if (!hashMatch) { logger.log('info', `[cache] ${cleanTag}: content changed, will build`); 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; if (available) { logger.log('info', `[cache] ${cleanTag}: cache hit, skipping build`); return true; } logger.log('info', `[cache] ${cleanTag}: image no longer available, will build`); 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(), }; } }