Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: lockEntry?.rulesSha256,
Comment thread
fbosch marked this conversation as resolved.
Outdated
};
}
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