Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface DocsCacheLockSource {
bytes: number;
fileCount: number;
manifestSha256: string;
rulesSha256?: string;
updatedAt: string;
}

Expand Down Expand Up @@ -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`),
};
}
Expand Down
50 changes: 49 additions & 1 deletion src/sync.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
Comment thread
fbosch marked this conversation as resolved.
bytes: lockEntry?.bytes,
fileCount: lockEntry?.fileCount,
manifestSha256: lockEntry?.manifestSha256,
rulesSha256,
};
}
const resolved = await resolveCommit({
Expand All @@ -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"
Expand All @@ -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,
};
}),
);
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions tests/fixtures/docs.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
1 change: 1 addition & 0 deletions tests/lock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
78 changes: 78 additions & 0 deletions tests/sync-include-exclude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
14 changes: 13 additions & 1 deletion tests/sync-output.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
};

Expand All @@ -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"));
});
Loading