diff --git a/src/lock.ts b/src/lock.ts index 070446b..bfc09e5 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -8,6 +8,7 @@ export interface DocsCacheLockSource { bytes: number; fileCount: number; manifestSha256: string; + rulesSha256?: string; updatedAt: string; } @@ -79,6 +80,10 @@ export const validateLock = (input: unknown): DocsCacheLock => { value.manifestSha256, `sources.${key}.manifestSha256`, ), + rulesSha256: + value.rulesSha256 === undefined + ? undefined + : assertString(value.rulesSha256, `sources.${key}.rulesSha256`), updatedAt: assertString(value.updatedAt, `sources.${key}.updatedAt`), }; } diff --git a/src/sync.ts b/src/sync.ts index ee040df..5406d47 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { access, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import pc from "picocolors"; @@ -42,10 +43,12 @@ type SyncResult = { ref: string; resolvedCommit: string; lockCommit: string | null; + lockRulesSha256?: string; status: "up-to-date" | "changed" | "missing"; bytes?: number; fileCount?: number; manifestSha256?: string; + rulesSha256?: string; }; const formatBytes = (value: number) => { @@ -79,6 +82,29 @@ const hasDocs = async (cacheDir: string, sourceId: string) => { return await exists(path.join(sourceDir, MANIFEST_FILENAME)); }; +const normalizePatterns = (patterns?: string[]) => { + if (!patterns || patterns.length === 0) { + return []; + } + const normalized = patterns + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + return Array.from(new Set(normalized)).sort(); +}; + +const computeRulesHash = (params: { + include: string[]; + exclude?: string[]; +}) => { + const payload = { + include: normalizePatterns(params.include), + exclude: normalizePatterns(params.exclude), + }; + const hash = createHash("sha256"); + hash.update(JSON.stringify(payload)); + return hash.digest("hex"); +}; + export const getSyncPlan = async ( options: SyncOptions, deps: SyncDeps = {}, @@ -108,6 +134,9 @@ export const getSyncPlan = async ( const results: SyncResult[] = await Promise.all( filteredSources.map(async (source) => { const lockEntry = lockData?.sources?.[source.id]; + const include = source.include ?? defaults.include; + const exclude = source.exclude; + const rulesSha256 = computeRulesHash({ include, exclude }); if (options.offline) { const docsPresent = await hasDocs(resolvedCacheDir, source.id); return { @@ -116,10 +145,12 @@ export const getSyncPlan = async ( ref: lockEntry?.ref ?? source.ref ?? defaults.ref, resolvedCommit: lockEntry?.resolvedCommit ?? "offline", lockCommit: lockEntry?.resolvedCommit ?? null, + lockRulesSha256: lockEntry?.rulesSha256, status: lockEntry && docsPresent ? "up-to-date" : "missing", bytes: lockEntry?.bytes, fileCount: lockEntry?.fileCount, manifestSha256: lockEntry?.manifestSha256, + rulesSha256, }; } const resolved = await resolveCommit({ @@ -128,7 +159,9 @@ export const getSyncPlan = async ( allowHosts: defaults.allowHosts, timeoutMs: options.timeoutMs, }); - const upToDate = lockEntry?.resolvedCommit === resolved.resolvedCommit; + const upToDate = + lockEntry?.resolvedCommit === resolved.resolvedCommit && + lockEntry?.rulesSha256 === rulesSha256; const status = lockEntry ? upToDate ? "up-to-date" @@ -140,10 +173,12 @@ export const getSyncPlan = async ( ref: resolved.ref, resolvedCommit: resolved.resolvedCommit, lockCommit: lockEntry?.resolvedCommit ?? null, + lockRulesSha256: lockEntry?.rulesSha256, status, bytes: lockEntry?.bytes, fileCount: lockEntry?.fileCount, manifestSha256: lockEntry?.manifestSha256, + rulesSha256, }; }), ); @@ -199,6 +234,7 @@ const buildLock = async ( fileCount: result.fileCount ?? prior?.fileCount ?? 0, manifestSha256: result.manifestSha256 ?? prior?.manifestSha256 ?? result.resolvedCommit, + rulesSha256: result.rulesSha256 ?? prior?.rulesSha256, updatedAt: now, }; } @@ -467,6 +503,10 @@ export const printSyncPlan = ( for (const result of plan.results) { const shortResolved = ui.hash(result.resolvedCommit); const shortLock = ui.hash(result.lockCommit); + const rulesChanged = + Boolean(result.lockRulesSha256) && + Boolean(result.rulesSha256) && + result.lockRulesSha256 !== result.rulesSha256; if (result.status === "up-to-date") { ui.item( @@ -477,6 +517,14 @@ export const printSyncPlan = ( continue; } if (result.status === "changed") { + if (result.lockCommit === result.resolvedCommit && rulesChanged) { + ui.item( + symbols.warn, + result.id, + `${pc.dim("rules changed")} ${pc.gray(shortResolved)}`, + ); + continue; + } ui.item( symbols.warn, result.id, diff --git a/tests/fixtures/docs.lock b/tests/fixtures/docs.lock index 848e787..450f907 100644 --- a/tests/fixtures/docs.lock +++ b/tests/fixtures/docs.lock @@ -6,11 +6,12 @@ "vitest": { "repo": "https://github.com/vitest-dev/vitest.git", "ref": "main", - "resolvedCommit": "0123456789abcdef0123456789abcdef01234567", - "bytes": 123456, - "fileCount": 512, - "manifestSha256": "abcd", - "updatedAt": "2026-01-30T12:00:00+01:00" + "resolvedCommit": "0123456789abcdef0123456789abcdef01234567", + "bytes": 123456, + "fileCount": 512, + "manifestSha256": "abcd", + "rulesSha256": "efgh", + "updatedAt": "2026-01-30T12:00:00+01:00" } } } diff --git a/tests/lock.test.js b/tests/lock.test.js index e72a127..b30dc50 100644 --- a/tests/lock.test.js +++ b/tests/lock.test.js @@ -27,6 +27,7 @@ test("lock fixture is valid", async (t) => { const lock = module.validateLock(parsed); assert.equal(lock.version, 1); assert.ok(lock.sources.vitest); + assert.equal(lock.sources.vitest.rulesSha256, "efgh"); }); test("writeLock produces readable JSON", async (t) => { diff --git a/tests/sync-include-exclude.test.js b/tests/sync-include-exclude.test.js index bf3e073..c4fa4f9 100644 --- a/tests/sync-include-exclude.test.js +++ b/tests/sync-include-exclude.test.js @@ -131,3 +131,81 @@ test("exclude overrides include on overlap", async () => { const docsRoot = path.join(cacheDir, "local"); assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), false); }); + +test("sync re-materializes when include rules change", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-rules-${Date.now().toString(36)}`, + ); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(path.join(repoDir, "docs"), { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "readme", "utf8"); + await writeFile(path.join(repoDir, "docs", "guide.md"), "guide", "utf8"); + + const baseConfig = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + include: ["docs/**"], + }, + ], + }; + await writeFile( + configPath, + `${JSON.stringify(baseConfig, null, 2)}\n`, + "utf8", + ); + + const syncOptions = { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }; + const deps = { + resolveRemoteCommit: async () => ({ + repo: "https://example.com/repo.git", + ref: "HEAD", + resolvedCommit: "abc123", + }), + fetchSource: async () => ({ + repoDir, + cleanup: async () => undefined, + }), + }; + + await runSync(syncOptions, deps); + + const docsRoot = path.join(cacheDir, "local"); + assert.equal(await exists(path.join(docsRoot, "README.md")), false); + assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true); + + const updatedConfig = { + ...baseConfig, + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + include: ["README.md"], + }, + ], + }; + await writeFile( + configPath, + `${JSON.stringify(updatedConfig, null, 2)}\n`, + "utf8", + ); + + await runSync(syncOptions, deps); + + assert.equal(await exists(path.join(docsRoot, "README.md")), true); + assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), false); +}); diff --git a/tests/sync-output.test.js b/tests/sync-output.test.js index a5bba7d..b3886e3 100644 --- a/tests/sync-output.test.js +++ b/tests/sync-output.test.js @@ -28,6 +28,16 @@ test("printSyncPlan outputs summary and short hashes", () => { lockCommit: "cccccccccccccccccccccccccccccccccccccccc", status: "changed", }, + { + id: "gamma", + repo: "https://example.com/gamma.git", + ref: "main", + resolvedCommit: "dddddddddddddddddddddddddddddddddddddddd", + lockCommit: "dddddddddddddddddddddddddddddddddddddddd", + lockRulesSha256: "111111", + rulesSha256: "222222", + status: "changed", + }, ], }; @@ -44,9 +54,11 @@ test("printSyncPlan outputs summary and short hashes", () => { process.stdout.write = originalWrite; } - assert.ok(output.includes("2 sources")); + assert.ok(output.includes("3 sources")); assert.ok(output.includes("alpha")); assert.ok(output.includes("beta")); + assert.ok(output.includes("gamma")); assert.ok(output.includes("aaaaaaa")); assert.ok(output.includes("bbbbbbb")); + assert.ok(output.includes("rules changed")); });