diff --git a/changelog.md b/changelog.md index 1f5ff74..acb8d59 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-06 - 1.8.0 - feat(build) +add optional content-hash based build cache to skip rebuilding unchanged Dockerfiles + +- Introduce TsDockerCache to compute SHA-256 of Dockerfile content and persist cache to .nogit/tsdocker_support.json +- Add ICacheEntry and ICacheData interfaces and a cached flag to IBuildCommandOptions +- Integrate cached mode in TsDockerManager: skip builds on cache hits, verify image presence, record builds on misses, and still perform dependency tagging +- Expose --cached option in CLI to enable the cached build flow +- Cache records store contentHash, imageId, buildTag and timestamp + ## 2026-02-06 - 1.7.0 - feat(cli) add CLI version display using commitinfo diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 56120b1..0bc6db8 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsdocker', - version: '1.7.0', + version: '1.8.0', description: 'develop npm modules cross platform with docker' } diff --git a/ts/classes.tsdockercache.ts b/ts/classes.tsdockercache.ts new file mode 100644 index 0000000..a6be1fb --- /dev/null +++ b/ts/classes.tsdockercache.ts @@ -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 { + 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(), + }; + } +} diff --git a/ts/classes.tsdockermanager.ts b/ts/classes.tsdockermanager.ts index 3da51f4..ff19481 100644 --- a/ts/classes.tsdockermanager.ts +++ b/ts/classes.tsdockermanager.ts @@ -4,6 +4,7 @@ import { logger } from './tsdocker.logging.js'; import { Dockerfile } from './classes.dockerfile.js'; import { DockerRegistry } from './classes.dockerregistry.js'; import { RegistryStorage } from './classes.registrystorage.js'; +import { TsDockerCache } from './classes.tsdockercache.js'; import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js'; const smartshellInstance = new plugins.smartshell.Smartshell({ @@ -136,11 +137,54 @@ export class TsDockerManager { } logger.log('info', `Building ${toBuild.length} Dockerfiles...`); - await Dockerfile.buildDockerfiles(toBuild, { - platform: options?.platform, - timeout: options?.timeout, - noCache: options?.noCache, - }); + + if (options?.cached) { + // === CACHED MODE: skip builds for unchanged Dockerfiles === + logger.log('info', '=== CACHED MODE ACTIVE ==='); + const cache = new TsDockerCache(); + cache.load(); + + for (const dockerfileArg of toBuild) { + const skip = await cache.shouldSkipBuild(dockerfileArg.cleanTag, dockerfileArg.content); + if (skip) { + continue; + } + + // Cache miss — build this Dockerfile + await dockerfileArg.build({ + platform: options?.platform, + timeout: options?.timeout, + noCache: options?.noCache, + }); + + const imageId = await dockerfileArg.getId(); + cache.recordBuild(dockerfileArg.cleanTag, dockerfileArg.content, imageId, dockerfileArg.buildTag); + } + + // Perform dependency tagging for all Dockerfiles (even cache hits, since tags may be stale) + for (const dockerfileArg of toBuild) { + const dependentBaseImages = new Set(); + for (const other of toBuild) { + if (other.localBaseDockerfile === dockerfileArg && other.baseImage !== dockerfileArg.buildTag) { + dependentBaseImages.add(other.baseImage); + } + } + for (const fullTag of dependentBaseImages) { + logger.log('info', `Tagging ${dockerfileArg.buildTag} as ${fullTag} for local dependency resolution`); + await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`); + } + } + + cache.save(); + } else { + // === STANDARD MODE: build all via static helper === + await Dockerfile.buildDockerfiles(toBuild, { + platform: options?.platform, + timeout: options?.timeout, + noCache: options?.noCache, + }); + } + logger.log('success', 'All Dockerfiles built successfully'); return toBuild; diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts index 226a1f4..9269bb6 100644 --- a/ts/interfaces/index.ts +++ b/ts/interfaces/index.ts @@ -77,4 +77,17 @@ export interface IBuildCommandOptions { platform?: string; // Single platform override (e.g., 'linux/arm64') timeout?: number; // Build timeout in seconds noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache) + cached?: boolean; // Skip builds when Dockerfile content hasn't changed +} + +export interface ICacheEntry { + contentHash: string; // SHA-256 hex of Dockerfile content + imageId: string; // Docker image ID (sha256:...) + buildTag: string; + timestamp: number; // Unix ms +} + +export interface ICacheData { + version: 1; + entries: { [cleanTag: string]: ICacheEntry }; } diff --git a/ts/tsdocker.cli.ts b/ts/tsdocker.cli.ts index 8708d18..16f724d 100644 --- a/ts/tsdocker.cli.ts +++ b/ts/tsdocker.cli.ts @@ -49,6 +49,9 @@ export let run = () => { if (argvArg.cache === false) { buildOptions.noCache = true; } + if (argvArg.cached) { + buildOptions.cached = true; + } await manager.build(buildOptions); logger.log('success', 'Build completed successfully'); @@ -142,6 +145,9 @@ export let run = () => { if (argvArg.cache === false) { buildOptions.noCache = true; } + if (argvArg.cached) { + buildOptions.cached = true; + } await manager.build(buildOptions); // Run tests