diff --git a/docs/content/agent-notes.mdx b/docs/content/agent-notes.mdx index 09e1379..4b02854 100644 --- a/docs/content/agent-notes.mdx +++ b/docs/content/agent-notes.mdx @@ -76,6 +76,28 @@ Without `XDG_CACHE_HOME`, macOS uses and range targets each get a distinct keyed file. Pass `--summaries FILE` to use another path. +## Cache control + +Saved notes use a short-lived lease while an agent writes them. A reader can +still open the last complete notes. If a writer stops, its lease expires and a +later run can take over. A writer that loses its lease cannot publish or remove +the newer writer's lease. + +See cache location, total size, oldest entry, and active targets: + +```sh +npx diffsplain cache status +``` + +Prune inactive notes by age in days or by total size in bytes. `clear --yes` +removes inactive notes only; it keeps notes under an active lease. + +```sh +npx diffsplain cache prune --age 30 +npx diffsplain cache prune --size 104857600 +npx diffsplain cache clear --yes +``` + The file has one change note and notes keyed by file path: ```json diff --git a/package.json b/package.json index 7b8dd20..fb7e244 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist", "scripts/build-diff-data.mjs", "scripts/cli-args.mjs", + "scripts/cache.mjs", "scripts/coding-agents.mjs", "scripts/doctor.mjs", "scripts/generate-summaries.mjs", diff --git a/scripts/build-diff-data.mjs b/scripts/build-diff-data.mjs index f59d97e..43d8531 100644 --- a/scripts/build-diff-data.mjs +++ b/scripts/build-diff-data.mjs @@ -119,9 +119,11 @@ const repoPath = (file) => { const path = relative(repo, file).replaceAll('\\', '/'); return path && path !== '..' && !path.startsWith('../') ? path : undefined; }; +const summariesRepoPath = repoPath(summariesPath); const excludedPaths = new Set( [ - repoPath(summariesPath), + summariesRepoPath, + summariesRepoPath ? `${summariesRepoPath}.lock` : undefined, repoPath(output), excludedOutput ? repoPath(resolve(excludedOutput)) : undefined, ].filter(Boolean), diff --git a/scripts/cache.mjs b/scripts/cache.mjs new file mode 100644 index 0000000..f9c69c3 --- /dev/null +++ b/scripts/cache.mjs @@ -0,0 +1,320 @@ +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + unlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { hostname } from 'node:os'; +import { dirname, join } from 'node:path'; +import { defaultCacheRoot } from './summary-path.mjs'; + +export const leaseDurationMs = 5 * 60_000; + +function files(root) { + try { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + return entry.isDirectory() ? files(path) : [path]; + }); + } catch { + return []; + } +} + +function leaseRecord(path) { + try { + const value = JSON.parse(readFileSync(path, 'utf8')); + return typeof value?.token === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +function leaseOwnerIsActive(record) { + return ( + record?.hostname === hostname() && + Number.isSafeInteger(record.pid) && + processIsAlive(record.pid) + ); +} + +function leaseIsActive(path, now = Date.now(), duration = leaseDurationMs) { + try { + if (leaseOwnerIsActive(leaseRecord(path))) return true; + return now - statSync(path).mtimeMs < duration; + } catch { + return false; + } +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function createLease(path, record, duration) { + const descriptor = openSync(path, 'wx', 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`); + closeSync(descriptor); + return { path, token: record.token, duration }; +} + +function renameLease(path, stalePath) { + try { + renameSync(path, stalePath); + return true; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + return false; + } +} + +function restoreLease(path, stalePath) { + try { + linkSync(stalePath, path); + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + } + rmSync(stalePath, { force: true }); +} + +export function removeStaleLease(path, observedToken) { + const stalePath = `${path}.stale-${randomUUID()}`; + if (!renameLease(path, stalePath)) return false; + if (leaseRecord(stalePath)?.token === observedToken) { + rmSync(stalePath, { force: true }); + return true; + } + restoreLease(path, stalePath); + return false; +} + +function rejectNonConflict(error) { + if (error?.code !== 'EEXIST') throw error; +} + +function rejectActiveLease(active, path) { + if (active) { + throw new Error(`Notes for this target are already being generated: ${path}`); + } +} + +function handleLeaseConflict(error, path, now, duration) { + rejectNonConflict(error); + const observed = leaseRecord(path); + rejectActiveLease(leaseOwnerIsActive(observed), path); + rejectActiveLease(leaseIsActive(path, now, duration), path); + removeStaleLease(path, observed?.token); +} + +export function acquireLease(path, { + token = randomUUID(), + now = Date.now(), + duration = leaseDurationMs, + pid = process.pid, + leaseHostname = hostname(), +} = {}) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + for (;;) { + try { + return createLease( + path, + { token, startedAt: now, pid, hostname: leaseHostname }, + duration, + ); + } catch (error) { + handleLeaseConflict(error, path, now, duration); + } + } +} + +function assertLease(lease) { + if (leaseRecord(lease.path)?.token !== lease.token) { + throw new Error('This process no longer owns the note cache'); + } +} + +export function refreshLease(lease, now = Date.now()) { + assertLease(lease); + utimesSync(lease.path, new Date(now), new Date(now)); + assertLease(lease); +} + +export function releaseLease(lease) { + assertLease(lease); + unlinkSync(lease.path); +} + +export function writePrivateFile(path, value) { + writeFileAtomic(path, value, 0o600); +} + +function existingFileMode(path) { + try { + return statSync(path).mode & 0o777; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + return undefined; + } +} + +function chmodIfSet(path, mode) { + if (mode !== undefined) chmodSync(path, mode); +} + +function writeFileAtomic(path, value, mode) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + const outputMode = mode === undefined ? existingFileMode(path) : mode; + writeFileSync( + temporary, + value, + outputMode === undefined ? undefined : { mode: outputMode }, + ); + chmodIfSet(temporary, outputMode); + renameSync(temporary, path); + chmodIfSet(path, outputMode); +} + +export function publishLeaseFile( + lease, + path, + value, + { privateFile = true } = {}, +) { + assertLease(lease); + refreshLease(lease); + if (privateFile) writePrivateFile(path, value); + else writeFileAtomic(path, value); + assertLease(lease); +} + +function active(path, now) { + return leaseIsActive(`${path}.lock`, now); +} + +function sameEntry(left, right) { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs + ); +} + +function fileSize(path) { + try { + return statSync(path).size; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + return undefined; + } +} + +function removedCacheEntry() { + return { removed: true, retainedActive: false, size: 0 }; +} + +function cacheEntryState(entry, removedPath, now) { + return { + changed: !sameEntry(entry, statSync(removedPath)), + retainedActive: active(entry.path, now), + replaced: typeof fileSize(entry.path) === 'number', + }; +} + +function restoreCacheEntry(entry, removedPath, retainedActive) { + restoreLease(entry.path, removedPath); + return { + removed: false, + retainedActive, + size: fileSize(entry.path) ?? 0, + }; +} + +export function removeCacheEntry(entry, now = Date.now()) { + const removedPath = `${entry.path}.remove-${randomUUID()}`; + if (!renameLease(entry.path, removedPath)) return removedCacheEntry(); + const state = cacheEntryState(entry, removedPath, now); + if ([state.changed, state.retainedActive, state.replaced].includes(true)) { + return restoreCacheEntry(entry, removedPath, state.retainedActive); + } + rmSync(removedPath, { force: true }); + return removedCacheEntry(); +} + +function entries(cacheRoot) { + return files(join(cacheRoot, 'summaries')) + .filter((path) => path.endsWith('.json')) + .map((path) => ({ path, ...statSync(path) })); +} + +function activeLeases(cacheRoot, now) { + return files(join(cacheRoot, 'summaries')).filter( + (path) => path.endsWith('.json.lock') && leaseIsActive(path, now), + ); +} + +export function cacheStatus({ cacheRoot = defaultCacheRoot(), now = Date.now() } = {}) { + const cached = entries(cacheRoot); + const bytes = cached.reduce((sum, entry) => sum + entry.size, 0); + const ages = cached.map((entry) => now - entry.mtimeMs); + return { + location: cacheRoot, + entries: cached.length, + bytes, + ageMs: ages.length ? Math.max(...ages) : 0, + active: activeLeases(cacheRoot, now).length, + }; +} + +function shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes }) { + const overAge = + maxAgeMs !== undefined && now - entry.mtimeMs > maxAgeMs; + const overSize = maxBytes !== undefined && bytes > maxBytes; + return overAge || overSize; +} + +function applyPruneResult(entry, result, removed, retainedActive) { + if (result.removed) { + removed.push(entry.path); + return -entry.size; + } + if (result.retainedActive) retainedActive.push(entry.path); + return result.size - entry.size; +} + +export function pruneCache({ cacheRoot = defaultCacheRoot(), maxAgeMs, maxBytes, now = Date.now() } = {}) { + const cached = entries(cacheRoot).sort((a, b) => a.mtimeMs - b.mtimeMs); + let bytes = cached.reduce((sum, entry) => sum + entry.size, 0); + const removed = []; + const retainedActive = []; + for (const entry of cached) { + if (!shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes })) continue; + const result = removeCacheEntry(entry, now); + bytes += applyPruneResult(entry, result, removed, retainedActive); + } + return { removed, retainedActive, bytes }; +} + +export function clearCache(options) { + return pruneCache({ ...options, maxAgeMs: 0 }); +} + +export function formatCacheStatus(status) { + return `Location: ${status.location}\nSize: ${status.bytes} bytes\nOldest entry: ${Math.floor(status.ageMs / 1000)} seconds\nActive use: ${status.active} target${status.active === 1 ? '' : 's'}`; +} diff --git a/scripts/check.mjs b/scripts/check.mjs index 055f0a9..c61767b 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -63,6 +63,7 @@ const requiredPackageFiles = [ 'package.json', 'dist/index.html', 'scripts/build-diff-data.mjs', + 'scripts/cache.mjs', 'scripts/cli-args.mjs', 'scripts/coding-agents.mjs', 'scripts/doctor.mjs', @@ -71,7 +72,7 @@ const requiredPackageFiles = [ 'scripts/serve-built.mjs', 'scripts/summary-path.mjs', ]; -const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/; +const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cache|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/; const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i; export function validatePackageManifest(pack) { diff --git a/scripts/cli-args.mjs b/scripts/cli-args.mjs index a4f565d..68d70f1 100644 --- a/scripts/cli-args.mjs +++ b/scripts/cli-args.mjs @@ -72,6 +72,7 @@ Show the current checkout against its default branch: Commands: doctor [--json] [--deep] Check review, agent, and pull request capabilities + cache Show or prune saved agent notes Targets: --branch NAME Show a remote branch against its default branch @@ -112,6 +113,7 @@ Examples: diffsplain diffsplain doctor diffsplain doctor --json + diffsplain cache status diffsplain --repo owner/project --pr 42 diffsplain owner/project --branch feature/search diffsplain --agent claude`; diff --git a/scripts/generate-summaries.mjs b/scripts/generate-summaries.mjs index f6582fd..dd2a2af 100644 --- a/scripts/generate-summaries.mjs +++ b/scripts/generate-summaries.mjs @@ -4,10 +4,8 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, - mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, writeFileSync, } from 'node:fs'; @@ -23,6 +21,12 @@ import { selectCodingAgent, } from './coding-agents.mjs'; import { summaryPath } from './summary-path.mjs'; +import { + acquireLease, + publishLeaseFile, + refreshLease, + releaseLease, +} from './cache.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const callerDirectory = process.cwd(); @@ -200,6 +204,16 @@ const summariesPath = summaryPath({ head: selectedHead, remote, }); +const ownershipPath = `${summariesPath}.lock`; +let ownership; +let ownershipHeartbeat; +function acquireOwnership() { + ownership = acquireLease(ownershipPath); + ownershipHeartbeat = setInterval(() => { + try { refreshLease(ownership); } catch { clearInterval(ownershipHeartbeat); } + }, 30_000); + ownershipHeartbeat.unref(); +} if (selectedBase) targetArgs.push('--base', selectedBase); if (selectedHead) targetArgs.push('--head', selectedHead); const cacheDirectory = option('--cache-dir'); @@ -241,8 +255,9 @@ function pathInsideRepo(file) { } function cleanSnapshot(snapshot) { + const summaryFile = pathInsideRepo(summariesPath); const excluded = new Set( - [pathInsideRepo(summariesPath), pathInsideRepo(outputPath)].filter(Boolean), + [summaryFile, summaryFile && `${summaryFile}.lock`, pathInsideRepo(outputPath)].filter(Boolean), ); const files = snapshot.files .filter((file) => !excluded.has(file.path)) @@ -614,11 +629,13 @@ function normalizeFileResponse(value, paths) { return { files, failedFiles: indexed.failedFiles, errors }; } -function writeJsonAtomic(file, value) { - mkdirSync(dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); - renameSync(temporary, file); +function writeJsonAtomic(file, value, options) { + publishLeaseFile( + ownership, + file, + `${JSON.stringify(value, null, 2)}\n`, + options, + ); } function metadataList(value) { @@ -678,11 +695,16 @@ function publishSnapshot(snapshot, summaries) { throw new Error('The diff changed while agent notes were being written'); } - const state = summaryFailureState(snapshot, summaries); + const lockPath = pathInsideRepo(summariesPath); + const publishedFiles = snapshot.files.filter( + (file) => file.path !== `${lockPath}.lock`, + ); + const publishedSnapshot = { ...snapshot, files: publishedFiles }; + const state = summaryFailureState(publishedSnapshot, summaries); const failureByPath = new Map( state.failedFiles.map((failure) => [failure.path, failure.reason]), ); - const files = snapshot.files.map((file) => + const files = publishedFiles.map((file) => snapshotFileWithNote(file, summaries, failureByPath), ); const content = { @@ -716,11 +738,15 @@ function publishSnapshot(snapshot, summaries) { .update(JSON.stringify(content)) .digest('hex') .slice(0, 12); - writeJsonAtomic(outputPath, { - version, - generatedAt: new Date().toISOString(), - ...content, - }); + writeJsonAtomic( + outputPath, + { + version, + generatedAt: new Date().toISOString(), + ...content, + }, + { privateFile: false }, + ); } function publish(snapshot, summaries) { @@ -930,6 +956,7 @@ let workingSummaries; let workingSnapshot; try { + acquireOwnership(); const rawSnapshotPath = resolve(temporaryDirectory, 'diff-data.json'); if (!snapshotPath) runBuilder(rawSnapshotPath, true); const rawSnapshot = JSON.parse( @@ -1284,5 +1311,7 @@ try { error instanceof Error && error.exitCode === 2 ? 2 : 1; } } finally { + if (ownershipHeartbeat) clearInterval(ownershipHeartbeat); + if (ownership) { try { releaseLease(ownership); } catch {} } rmSync(temporaryDirectory, { recursive: true, force: true }); } diff --git a/scripts/present.mjs b/scripts/present.mjs index 192624c..0217ef7 100755 --- a/scripts/present.mjs +++ b/scripts/present.mjs @@ -20,9 +20,64 @@ import { selectCodingAgent, } from './coding-agents.mjs'; import { doctorReport } from './doctor.mjs'; +import { cacheStatus, clearCache, formatCacheStatus, pruneCache } from './cache.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const callerDirectory = process.cwd(); +const cacheArgs = process.argv.slice(2); + +function cacheUsage() { + console.error( + 'Use: diffsplain cache [status|prune --age DAYS|prune --size BYTES|clear --yes]', + ); + return 2; +} + +function printCacheChange(result) { + console.log( + `Removed ${result.removed.length} inactive cache entries; kept ${result.retainedActive.length} active.`, + ); + return 0; +} + +function statusCommand(args) { + if (args.length !== 1 && args.length !== 2) return cacheUsage(); + console.log(formatCacheStatus(cacheStatus())); + return 0; +} + +function clearCommand(args) { + if (args.length !== 3 || args[2] !== '--yes') return cacheUsage(); + return printCacheChange(clearCache()); +} + +function pruneOptions(flag, rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) return undefined; + if (flag === '--age') return { maxAgeMs: value * 86_400_000 }; + if (flag === '--size') return { maxBytes: value }; + return undefined; +} + +function pruneCommand(args) { + if (args.length !== 4) return cacheUsage(); + const options = pruneOptions(args[2], args[3]); + return options ? printCacheChange(pruneCache(options)) : cacheUsage(); +} + +function runCacheCommand(args) { + const commands = { + status: statusCommand, + clear: clearCommand, + prune: pruneCommand, + }; + const command = args[1] || 'status'; + return commands[command]?.(args) ?? cacheUsage(); +} + +if (cacheArgs[0] === 'cache') { + process.exit(runCacheCommand(cacheArgs)); +} let cli; try { cli = parseCliArgs(process.argv.slice(2), { callerDirectory }); diff --git a/tests/cache.test.mjs b/tests/cache.test.mjs new file mode 100644 index 0000000..493d167 --- /dev/null +++ b/tests/cache.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { + mkdtemp, + readFile, + rm, + stat, + utimes, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + acquireLease, + cacheStatus, + clearCache, + leaseDurationMs, + publishLeaseFile, + pruneCache, + releaseLease, + removeCacheEntry, + removeStaleLease, + writePrivateFile, +} from '../scripts/cache.mjs'; + +test('fences a delayed writer after stale-lease recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + const note = join(root, 'summaries', 'target.json'); + const lock = `${note}.lock`; + const now = Date.now(); + try { + const older = acquireLease(lock, { + token: 'older', + now, + pid: 999_999, + }); + publishLeaseFile(older, note, 'older result'); + await utimes(lock, new Date(now - leaseDurationMs - 1), new Date(now - leaseDurationMs - 1)); + const newer = acquireLease(lock, { token: 'newer', now }); + publishLeaseFile(newer, note, 'newer result'); + + assert.throws(() => publishLeaseFile(older, note, 'late older result'), /no longer owns/i); + assert.equal(await readFile(note, 'utf8'), 'newer result'); + assert.throws(() => releaseLease(older), /no longer owns/i); + assert.equal(await readFile(lock, 'utf8').then(JSON.parse).then((lease) => lease.token), 'newer'); + releaseLease(newer); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('does not remove a new lease found during stale recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + const lock = join(root, 'summaries', 'target.json.lock'); + try { + acquireLease(lock, { + token: 'observed-stale', + now: 0, + pid: 999_999, + }); + await writeFile( + lock, + `${JSON.stringify({ + token: 'new-owner', + startedAt: Date.now(), + pid: process.pid, + hostname: 'test-host', + })}\n`, + ); + + assert.equal(removeStaleLease(lock, 'observed-stale'), false); + assert.equal( + JSON.parse(await readFile(lock, 'utf8')).token, + 'new-owner', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('pruning and explicit clearing keep entries with an active lease', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + const old = join(root, 'summaries', 'old.json'); + const active = join(root, 'summaries', 'active.json'); + let lease; + try { + writePrivateFile(old, 'old'); + writePrivateFile(active, 'active'); + await utimes(old, new Date(0), new Date(0)); + await utimes(active, new Date(0), new Date(0)); + lease = acquireLease(`${active}.lock`); + + const pruned = pruneCache({ cacheRoot: root, maxAgeMs: 1 }); + assert.deepEqual(pruned.removed, [old]); + assert.deepEqual(pruned.retainedActive, [active]); + assert.equal(cacheStatus({ cacheRoot: root }).active, 1); + assert.deepEqual(clearCache({ cacheRoot: root }).removed, []); + assert.deepEqual(clearCache({ cacheRoot: root }).retainedActive, [active]); + } finally { + if (lease) releaseLease(lease); + await rm(root, { recursive: true, force: true }); + } +}); + +test('does not remove an entry replaced after the prune snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + const note = join(root, 'summaries', 'target.json'); + const lock = `${note}.lock`; + try { + writePrivateFile(note, 'observed result'); + const observed = { path: note, ...(await stat(note)) }; + const lease = acquireLease(lock); + publishLeaseFile(lease, note, 'new result'); + releaseLease(lease); + + const result = removeCacheEntry(observed); + + assert.equal(result.removed, false); + assert.equal(result.retainedActive, false); + assert.equal(await readFile(note, 'utf8'), 'new result'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('reports a first-run lease before its cache entry exists', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + let lease; + + try { + const lock = join(root, 'summaries', 'first-run.json.lock'); + lease = acquireLease(lock); + assert.equal(cacheStatus({ cacheRoot: root }).entries, 0); + assert.equal(cacheStatus({ cacheRoot: root }).active, 1); + } finally { + if (lease) releaseLease(lease); + await rm(root, { recursive: true, force: true }); + } +}); + +test('writes private cache files', async () => { + const root = await mkdtemp(join(tmpdir(), 'diffsplain-cache-')); + const file = join(root, 'summaries', 'private.json'); + try { + writePrivateFile(file, 'private notes'); + assert.equal((await stat(file)).mode & 0o077, 0); + assert.equal(await readFile(file, 'utf8'), 'private notes'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/generate-summaries.test.mjs b/tests/generate-summaries.test.mjs index 8e93d05..13ba668 100644 --- a/tests/generate-summaries.test.mjs +++ b/tests/generate-summaries.test.mjs @@ -395,7 +395,7 @@ test("generates notes with Codex and rebuilds a selected range", async () => { }), ); - const result = run(repo, [ + const commandArgs = [ "--range", "HEAD~1..HEAD", "--codex-bin", @@ -408,7 +408,8 @@ test("generates notes with Codex and rebuilds a selected range", async () => { summaries, "--output", output, - ]); + ]; + const result = run(repo, commandArgs); assert.equal(result.status, 0, result.stderr); const args = JSON.parse(await readFile(codex.argsFile, "utf8")); @@ -448,6 +449,17 @@ test("generates notes with Codex and rebuilds a selected range", async () => { ); assert.equal(snapshot.notes.fresh, true); assert.equal(snapshot.notes.complete, true); + + await chmod(output, 0o640); + const snapshotResult = run(repo, [ + ...commandArgs, + "--snapshot", + output, + "--force", + ]); + assert.equal(snapshotResult.status, 0, snapshotResult.stderr); + assert.equal((await stat(output)).mode & 0o777, 0o640); + assert.equal((await stat(summaries)).mode & 0o077, 0); } finally { await rm(repo, { recursive: true, force: true }); } diff --git a/tests/package-manifest.test.mjs b/tests/package-manifest.test.mjs index e474678..486ac02 100644 --- a/tests/package-manifest.test.mjs +++ b/tests/package-manifest.test.mjs @@ -7,6 +7,7 @@ const requiredFiles = [ 'package.json', 'dist/index.html', 'scripts/build-diff-data.mjs', + 'scripts/cache.mjs', 'scripts/cli-args.mjs', 'scripts/coding-agents.mjs', 'scripts/doctor.mjs', diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index 660036a..aa485e0 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -182,6 +182,7 @@ test("builds live data for tracked and untracked workspace files", async () => { `${"other before\n".repeat(10)}renamed after\n`, ); await writeFile(join(repo, "new.txt"), "new line\n"); + await writeFile(join(repo, "undefined.lock"), "review this file\n"); await writeFile( summariesPath, JSON.stringify({ @@ -228,7 +229,7 @@ test("builds live data for tracked and untracked workspace files", async () => { const first = JSON.parse(await readFile(output, "utf8")); assert.deepEqual( first.files.map((file) => file.path), - ["new.txt", "renamed file.txt", "tracked.txt"], + ["new.txt", "renamed file.txt", "tracked.txt", "undefined.lock"], ); assert.equal(first.files[0].status, "added"); assert.match(first.files[0].patch, /new line/);