feat(build): add optional content-hash based build cache to skip rebuilding unchanged Dockerfiles

This commit is contained in:
2026-02-06 14:18:06 +00:00
parent 7131c16f80
commit cc83743f9a
6 changed files with 195 additions and 6 deletions

117
ts/classes.tsdockercache.ts Normal file
View File

@@ -0,0 +1,117 @@
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<boolean> {
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(),
};
}
}