Skip to content

Commit a161ea0

Browse files
CopilotfboschCopilot
authored
Clean up stale lock entries when sources removed from config (#27)
* Initial plan * Implement automatic cleanup of stale lock entries during sync Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Update src/commands/sync.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/sync-lock-cleanup.test.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: lint --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 7311d26 commit a161ea0

2 files changed

Lines changed: 316 additions & 2 deletions

File tree

src/commands/sync.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
22
import { access, mkdir, readFile } from "node:fs/promises";
33
import path from "node:path";
44
import pc from "picocolors";
5-
import type { DocsCacheLock } from "#cache/lock";
5+
import type { DocsCacheLock, DocsCacheLockSource } from "#cache/lock";
66
import { readLock, resolveLockPath, writeLock } from "#cache/lock";
77
import { MANIFEST_FILENAME } from "#cache/manifest";
88
import { computeManifestHash, materializeSource } from "#cache/materialize";
@@ -240,7 +240,17 @@ const buildLock = async (
240240
) => {
241241
const toolVersion = await loadToolVersion();
242242
const now = new Date().toISOString();
243-
const sources = { ...(previous?.sources ?? {}) };
243+
const configSourceIds = new Set(
244+
plan.config.sources.map((source) => source.id),
245+
);
246+
const sources: Record<string, DocsCacheLockSource> = {};
247+
if (previous?.sources) {
248+
for (const [id, source] of Object.entries(previous.sources)) {
249+
if (configSourceIds.has(id)) {
250+
sources[id] = source;
251+
}
252+
}
253+
}
244254
for (const result of plan.results) {
245255
const prior = sources[result.id];
246256
sources[result.id] = buildLockSource(result, prior, now);

tests/sync-lock-cleanup.test.js

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import assert from "node:assert/strict";
2+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
import { test } from "node:test";
6+
7+
import { runSync } from "../dist/api.mjs";
8+
9+
const exists = async (target) => {
10+
try {
11+
await access(target);
12+
return true;
13+
} catch {
14+
return false;
15+
}
16+
};
17+
18+
test("sync removes lock entries for sources removed from config", async () => {
19+
const tmpRoot = path.join(
20+
tmpdir(),
21+
`docs-cache-lock-cleanup-${Date.now().toString(36)}`,
22+
);
23+
await mkdir(tmpRoot, { recursive: true });
24+
const cacheDir = path.join(tmpRoot, ".docs");
25+
const repoDir = path.join(tmpRoot, "repo");
26+
const configPath = path.join(tmpRoot, "docs.config.json");
27+
const lockPath = path.join(tmpRoot, "docs-lock.json");
28+
29+
await mkdir(repoDir, { recursive: true });
30+
await writeFile(path.join(repoDir, "a.md"), "alpha", "utf8");
31+
32+
// Initial config with two sources
33+
const config = {
34+
$schema:
35+
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
36+
sources: [
37+
{
38+
id: "source-one",
39+
repo: "https://example.com/repo.git",
40+
include: ["**/*.md"],
41+
},
42+
{
43+
id: "source-two",
44+
repo: "https://example.com/repo.git",
45+
include: ["**/*.md"],
46+
},
47+
],
48+
};
49+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
50+
51+
let resolveCallCount = 0;
52+
const resolveRemoteCommit = async ({ repo }) => {
53+
resolveCallCount += 1;
54+
return {
55+
repo,
56+
ref: "HEAD",
57+
resolvedCommit: `commit-${resolveCallCount}`,
58+
};
59+
};
60+
61+
const fetchSource = async () => ({
62+
repoDir,
63+
cleanup: async () => undefined,
64+
fromCache: false,
65+
});
66+
67+
// First sync with both sources
68+
await runSync(
69+
{
70+
configPath,
71+
cacheDirOverride: cacheDir,
72+
json: false,
73+
lockOnly: false,
74+
offline: false,
75+
failOnMiss: false,
76+
},
77+
{
78+
resolveRemoteCommit,
79+
fetchSource,
80+
},
81+
);
82+
83+
// Verify lock contains both sources
84+
assert.equal(await exists(lockPath), true);
85+
const lockContent1 = await readFile(lockPath, "utf8");
86+
const lock1 = JSON.parse(lockContent1);
87+
assert.ok(lock1.sources["source-one"]);
88+
assert.ok(lock1.sources["source-two"]);
89+
90+
// Update config to remove source-two
91+
config.sources = [
92+
{
93+
id: "source-one",
94+
repo: "https://example.com/repo.git",
95+
include: ["**/*.md"],
96+
},
97+
];
98+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
99+
100+
// Second sync with only source-one
101+
await runSync(
102+
{
103+
configPath,
104+
cacheDirOverride: cacheDir,
105+
json: false,
106+
lockOnly: false,
107+
offline: false,
108+
failOnMiss: false,
109+
},
110+
{
111+
resolveRemoteCommit,
112+
fetchSource,
113+
},
114+
);
115+
116+
// Verify lock only contains source-one
117+
const lockContent2 = await readFile(lockPath, "utf8");
118+
const lock2 = JSON.parse(lockContent2);
119+
assert.ok(lock2.sources["source-one"], "source-one should still be in lock");
120+
assert.equal(
121+
lock2.sources["source-two"],
122+
undefined,
123+
"source-two should be removed from lock",
124+
);
125+
assert.equal(
126+
Object.keys(lock2.sources).length,
127+
1,
128+
"lock should only have one source",
129+
);
130+
});
131+
132+
test("sync preserves lock entries for sources still in config", async () => {
133+
const tmpRoot = path.join(
134+
tmpdir(),
135+
`docs-cache-lock-preserve-${Date.now().toString(36)}`,
136+
);
137+
await mkdir(tmpRoot, { recursive: true });
138+
const cacheDir = path.join(tmpRoot, ".docs");
139+
const repoDir = path.join(tmpRoot, "repo");
140+
const configPath = path.join(tmpRoot, "docs.config.json");
141+
const lockPath = path.join(tmpRoot, "docs-lock.json");
142+
143+
await mkdir(repoDir, { recursive: true });
144+
await writeFile(path.join(repoDir, "a.md"), "alpha", "utf8");
145+
146+
const config = {
147+
$schema:
148+
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
149+
sources: [
150+
{
151+
id: "source-one",
152+
repo: "https://example.com/repo.git",
153+
include: ["**/*.md"],
154+
},
155+
],
156+
};
157+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
158+
159+
const resolveRemoteCommit = async ({ repo }) => ({
160+
repo,
161+
ref: "HEAD",
162+
resolvedCommit: "fixed-commit",
163+
});
164+
165+
const fetchSource = async () => ({
166+
repoDir,
167+
cleanup: async () => undefined,
168+
fromCache: false,
169+
});
170+
171+
// First sync
172+
await runSync(
173+
{
174+
configPath,
175+
cacheDirOverride: cacheDir,
176+
json: false,
177+
lockOnly: false,
178+
offline: false,
179+
failOnMiss: false,
180+
},
181+
{
182+
resolveRemoteCommit,
183+
fetchSource,
184+
},
185+
);
186+
187+
// Second sync with same config
188+
await runSync(
189+
{
190+
configPath,
191+
cacheDirOverride: cacheDir,
192+
json: false,
193+
lockOnly: false,
194+
offline: false,
195+
failOnMiss: false,
196+
},
197+
{
198+
resolveRemoteCommit,
199+
fetchSource,
200+
},
201+
);
202+
203+
// Verify source is still in lock
204+
const lockContent2 = await readFile(lockPath, "utf8");
205+
const lock2 = JSON.parse(lockContent2);
206+
assert.ok(lock2.sources["source-one"], "source-one should still be in lock");
207+
assert.equal(lock2.sources["source-one"].resolvedCommit, "fixed-commit");
208+
});
209+
210+
test("sync preserves all lock entries when using sourceFilter for a subset of sources", async () => {
211+
const tmpRoot = path.join(
212+
tmpdir(),
213+
`docs-cache-lock-preserve-source-filter-${Date.now().toString(36)}`,
214+
);
215+
await mkdir(tmpRoot, { recursive: true });
216+
const cacheDir = path.join(tmpRoot, ".docs");
217+
const repoDir = path.join(tmpRoot, "repo");
218+
const configPath = path.join(tmpRoot, "docs.config.json");
219+
const lockPath = path.join(tmpRoot, "docs-lock.json");
220+
221+
await mkdir(repoDir, { recursive: true });
222+
await writeFile(path.join(repoDir, "a.md"), "alpha", "utf8");
223+
224+
const config = {
225+
$schema:
226+
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
227+
sources: [
228+
{
229+
id: "source-one",
230+
repo: "https://example.com/source-one.git",
231+
include: ["**/*.md"],
232+
},
233+
{
234+
id: "source-two",
235+
repo: "https://example.com/source-two.git",
236+
include: ["**/*.md"],
237+
},
238+
],
239+
};
240+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
241+
242+
const resolveRemoteCommit = async ({ repo }) => ({
243+
repo,
244+
ref: "HEAD",
245+
resolvedCommit: repo.endsWith("source-one.git")
246+
? "commit-source-one"
247+
: "commit-source-two",
248+
});
249+
250+
const fetchSource = async () => ({
251+
repoDir,
252+
cleanup: async () => undefined,
253+
fromCache: false,
254+
});
255+
256+
// Initial sync to populate lock for both sources
257+
await runSync(
258+
{
259+
configPath,
260+
cacheDirOverride: cacheDir,
261+
json: false,
262+
lockOnly: false,
263+
offline: false,
264+
failOnMiss: false,
265+
},
266+
{
267+
resolveRemoteCommit,
268+
fetchSource,
269+
},
270+
);
271+
272+
// Sync again, but only for source-one using sourceFilter
273+
await runSync(
274+
{
275+
configPath,
276+
cacheDirOverride: cacheDir,
277+
json: false,
278+
lockOnly: false,
279+
offline: false,
280+
failOnMiss: false,
281+
sourceFilter: ["source-one"],
282+
},
283+
{
284+
resolveRemoteCommit,
285+
fetchSource,
286+
},
287+
);
288+
289+
// Verify both sources are still in lock with their respective commits
290+
const lockContent = await readFile(lockPath, "utf8");
291+
const lock = JSON.parse(lockContent);
292+
293+
assert.ok(
294+
lock.sources["source-one"],
295+
"source-one should still be in lock after filtered sync",
296+
);
297+
assert.ok(
298+
lock.sources["source-two"],
299+
"source-two should still be in lock after filtered sync",
300+
);
301+
302+
assert.equal(lock.sources["source-one"].resolvedCommit, "commit-source-one");
303+
assert.equal(lock.sources["source-two"].resolvedCommit, "commit-source-two");
304+
});

0 commit comments

Comments
 (0)