From 899f890dbc71e4b380ad0b09e19324df1e2f7f92 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 11 Aug 2026 11:13:36 +0200 Subject: [PATCH 1/2] fix(cache): keep the local .cache root out of the user's git history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside production the cache root is `/.cache`, so every dev server run drops generated bundles into the user's project. `veryfront init` scaffolds a .gitignore that lists `.cache/`, but a project that adopted Veryfront into an existing tree keeps its own .gitignore and never gets the entry — the generated .mjs files then show up as untracked and `git add -A` commits them. Write a `.gitignore` containing `*` inside the cache root on server startup instead. It ignores the directory's contents and itself regardless of what the project's own .gitignore says, so it works on the scaffold path, the adopt path, and any custom VERYFRONT_CACHE_DIR. It is best-effort and never overwrites an existing file. Also relabel the scaffolded `.cache/` comment: the directory holds `veryfront-mdx-esm/` and `veryfront-http-bundle/`, not AI models. --- cli/utils/env-prompt.test.ts | 2 +- cli/utils/env-prompt.ts | 2 +- .../mdx/esm-module-loader/cache/index.test.ts | 30 +++++++++++++- .../mdx/esm-module-loader/cache/index.ts | 4 ++ src/utils/cache-dir.test.ts | 22 ++++++++++ src/utils/cache-dir.ts | 41 +++++++++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) diff --git a/cli/utils/env-prompt.test.ts b/cli/utils/env-prompt.test.ts index e484446f94..126456624d 100644 --- a/cli/utils/env-prompt.test.ts +++ b/cli/utils/env-prompt.test.ts @@ -26,7 +26,7 @@ describe("env-prompt", () => { assertStringIncludes(result, "# Dependencies"); assertStringIncludes(result, "# Environment files"); assertStringIncludes(result, "# Build output"); - assertStringIncludes(result, "# Local AI model cache"); + assertStringIncludes(result, "# Local build cache"); assertStringIncludes(result, ".cache/"); assertStringIncludes(result, "# IDE"); }); diff --git a/cli/utils/env-prompt.ts b/cli/utils/env-prompt.ts index 9d6c2bb1be..90cc47038b 100644 --- a/cli/utils/env-prompt.ts +++ b/cli/utils/env-prompt.ts @@ -178,7 +178,7 @@ export function generateGitignoreContent(existingContent?: string): string { "dist/", ".veryfront/", "", - "# Local AI model cache", + "# Local build cache", ".cache/", "", "# IDE", diff --git a/src/transforms/mdx/esm-module-loader/cache/index.test.ts b/src/transforms/mdx/esm-module-loader/cache/index.test.ts index b53facc986..d4360cea6c 100644 --- a/src/transforms/mdx/esm-module-loader/cache/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/cache/index.test.ts @@ -3,6 +3,7 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { join, toFileUrl } from "#veryfront/compat/path"; import { + clearAllLocalCaches, clearMdxEsmCacheNamespace, clearModulePathCache, getLocalFs, @@ -18,7 +19,7 @@ import { waitForDiskCleanup, } from "./index.ts"; import { makeTempDir } from "#veryfront/testing/deno-compat.ts"; -import { exists, remove, writeTextFile } from "#veryfront/compat/fs.ts"; +import { exists, readTextFile, remove, writeTextFile } from "#veryfront/compat/fs.ts"; import { runWithCacheDir } from "#veryfront/utils/cache-dir.ts"; import { cacheModule } from "../module-fetcher/module-cache.ts"; import { rendererLogger as log } from "#veryfront/utils"; @@ -1255,3 +1256,30 @@ describe("invalidateModulePaths — edge cases", () => { } }); }); + +describe("local cache root version-control hygiene", () => { + // Regression: `veryfront dev` writes its ESM/bundle caches into + // `/.cache`. Projects that adopted Veryfront without scaffolding + // (their .gitignore predates `veryfront init`) have no `.cache/` entry, so + // the generated .mjs bundles showed up as untracked files and a `git add -A` + // committed them. Server startup must leave the cache root ignoring itself. + it("marks the local cache root as ignored on startup", async () => { + const cacheBase = await makeTempDir({ prefix: "vf-cache-root-ignore-" }); + + try { + await runWithCacheDir(cacheBase, async () => { + await clearAllLocalCaches(); + }); + + const ignorePath = join(cacheBase, ".gitignore"); + assertEquals(await exists(ignorePath), true); + assertEquals( + (await readTextFile(ignorePath)).split(/\r?\n/).includes("*"), + true, + ); + } finally { + await remove(cacheBase, { recursive: true }); + clearModulePathCache(); + } + }); +}); diff --git a/src/transforms/mdx/esm-module-loader/cache/index.ts b/src/transforms/mdx/esm-module-loader/cache/index.ts index 18f8c48722..680568e669 100644 --- a/src/transforms/mdx/esm-module-loader/cache/index.ts +++ b/src/transforms/mdx/esm-module-loader/cache/index.ts @@ -9,6 +9,7 @@ import { fromFileUrl, join } from "#veryfront/compat/path"; import { rendererLogger as logger } from "#veryfront/utils"; import { + ensureCacheDirIgnored, getCacheBaseDir, getHttpBundleCacheDir, getMdxEsmCacheDir, @@ -615,6 +616,9 @@ export async function clearHttpBundleCache(): Promise { export async function clearAllLocalCaches(): Promise { clearModulePathCache(); await Promise.all([clearESMDiskCache(), clearHttpBundleCache()]); + // The cache root lives inside the user's project outside production, so keep + // the generated bundles out of their version control before writing more. + await ensureCacheDirIgnored(); logger.debug(`${LOG_PREFIX_MDX_LOADER} Cleared all local caches`); } diff --git a/src/utils/cache-dir.test.ts b/src/utils/cache-dir.test.ts index aa474e51ad..a8af44417c 100644 --- a/src/utils/cache-dir.test.ts +++ b/src/utils/cache-dir.test.ts @@ -22,6 +22,7 @@ import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { __cacheDirInternals, + ensureCacheDirIgnored, ensureCacheNodeModules, getCacheBaseDir, getCacheDirFromContext, @@ -262,6 +263,27 @@ describe("cache-dir", () => { }); }); + describe("ensureCacheDirIgnored", () => { + it("creates a self-ignoring .gitignore inside the cache root", async () => { + const cacheRoot = makeNodeCacheRoot(); + + await runWithCacheDir(cacheRoot, ensureCacheDirIgnored); + + const contents = readFileSync(join(cacheRoot, ".gitignore"), "utf8"); + assert(contents.split(/\r?\n/).includes("*")); + }); + + it("never overwrites a .gitignore the user already put there", async () => { + const cacheRoot = makeNodeCacheRoot(); + const ignorePath = join(cacheRoot, ".gitignore"); + writeFileSync(ignorePath, "!keep-me\n"); + + await runWithCacheDir(cacheRoot, ensureCacheDirIgnored); + + assertEquals(readFileSync(ignorePath, "utf8"), "!keep-me\n"); + }); + }); + describe({ name: "ensureCacheNodeModules on Node", ignore: !isNode }, () => { it("should link distinct cache roots independently", async () => { const firstRoot = makeNodeCacheRoot(); diff --git a/src/utils/cache-dir.ts b/src/utils/cache-dir.ts index 982da37452..d6ad6a2e54 100644 --- a/src/utils/cache-dir.ts +++ b/src/utils/cache-dir.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { join } from "#veryfront/compat/path/index.ts"; import { cwd, getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { isNode } from "#veryfront/platform/compat/runtime.ts"; import { hashString } from "#veryfront/cache/hash.ts"; import { serverLogger } from "./logger/index.ts"; @@ -105,6 +106,46 @@ export function getHttpBundleCacheDir(): string { return join(getCacheBaseDir(), "veryfront-http-bundle"); } +const CACHE_DIR_IGNORE_CONTENT = [ + "# Created by Veryfront. Holds generated bundles only — safe to delete.", + "*", + "", +].join("\n"); + +/** + * Mark the cache base directory as ignored by version control. + * + * Outside production the cache root is `/.cache`, so every dev server + * run drops generated `.mjs` bundles into the user's project. `veryfront init` + * scaffolds a `.gitignore` with a `.cache/` entry, but a project that adopted + * Veryfront into an existing tree keeps its own `.gitignore` and never gets + * one — the bundles then show up as untracked files and `git add -A` commits + * them. A `.gitignore` written *inside* the cache root ignores its contents + * (and itself) no matter what the project's own `.gitignore` says. + * + * Best-effort: an unwritable cache root must not fail server startup, and an + * existing `.gitignore` is never overwritten. + */ +export async function ensureCacheDirIgnored(): Promise { + const cacheBase = getCacheBaseDir(); + + try { + const fs = createFileSystem(); + const ignorePath = join(cacheBase, ".gitignore"); + if (await fs.exists(ignorePath)) return; + await fs.mkdir(cacheBase, { recursive: true }); + await fs.writeTextFile(ignorePath, CACHE_DIR_IGNORE_CONTENT); + } catch (error) { + logger.debug("Cache dir ignore marker not written", { + cacheRoot: describeCacheRoot(cacheBase), + reason: redactCachePathDetails( + error instanceof Error ? error.message : String(error), + cacheBase, + ), + }); + } +} + /** * Ensure cached ESM modules can resolve bare specifiers (e.g. `import 'react'`) * when running on Node.js. From 73d792047543f67f97509b5ab44f412711893b66 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 11 Aug 2026 12:18:39 +0200 Subject: [PATCH 2/2] fix(cache): create the cache ignore marker exclusively and drop the em dash Review follow-up: - The generated `.cache/.gitignore` comment lands in the user's project, so it is public copy and must not contain an em dash (AGENTS.md public copy rules). Replace it with a comma. - Write the marker through `createFileBytesExclusive` when the adapter exposes it, treating an already-exists error as success, so a `.gitignore` that appears between the `exists()` check and the write is not truncated. Adapters without the capability keep the plain write. --- src/utils/cache-dir.ts | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/utils/cache-dir.ts b/src/utils/cache-dir.ts index d6ad6a2e54..2613ff885a 100644 --- a/src/utils/cache-dir.ts +++ b/src/utils/cache-dir.ts @@ -1,7 +1,11 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { join } from "#veryfront/compat/path/index.ts"; import { cwd, getHostEnv } from "#veryfront/platform/compat/process.ts"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { + createFileSystem, + type FileSystem, + isAlreadyExistsError, +} from "#veryfront/platform/compat/fs.ts"; import { isNode } from "#veryfront/platform/compat/runtime.ts"; import { hashString } from "#veryfront/cache/hash.ts"; import { serverLogger } from "./logger/index.ts"; @@ -107,11 +111,41 @@ export function getHttpBundleCacheDir(): string { } const CACHE_DIR_IGNORE_CONTENT = [ - "# Created by Veryfront. Holds generated bundles only — safe to delete.", + "# Created by Veryfront. Holds generated bundles only, safe to delete.", "*", "", ].join("\n"); +/** + * Write the ignore marker without clobbering a file that already exists. + * + * Adapters that expose an exclusive create use it, so a `.gitignore` another + * process writes between the caller's `exists()` check and this write survives. + * Adapters without that capability fall back to a plain write. + */ +async function createIgnoreMarker( + fs: FileSystem, + ignorePath: string, +): Promise { + const createExclusive = fs.createFileBytesExclusive?.bind(fs); + if (createExclusive === undefined) { + await fs.writeTextFile(ignorePath, CACHE_DIR_IGNORE_CONTENT); + return; + } + + try { + // Exclusive create so a `.gitignore` that appears between the exists() + // check and this write is left intact rather than truncated. + await createExclusive( + ignorePath, + new TextEncoder().encode(CACHE_DIR_IGNORE_CONTENT), + ); + } catch (error) { + if (isAlreadyExistsError(error)) return; + throw error; + } +} + /** * Mark the cache base directory as ignored by version control. * @@ -119,7 +153,7 @@ const CACHE_DIR_IGNORE_CONTENT = [ * run drops generated `.mjs` bundles into the user's project. `veryfront init` * scaffolds a `.gitignore` with a `.cache/` entry, but a project that adopted * Veryfront into an existing tree keeps its own `.gitignore` and never gets - * one — the bundles then show up as untracked files and `git add -A` commits + * one, so the bundles show up as untracked files and `git add -A` commits * them. A `.gitignore` written *inside* the cache root ignores its contents * (and itself) no matter what the project's own `.gitignore` says. * @@ -134,7 +168,7 @@ export async function ensureCacheDirIgnored(): Promise { const ignorePath = join(cacheBase, ".gitignore"); if (await fs.exists(ignorePath)) return; await fs.mkdir(cacheBase, { recursive: true }); - await fs.writeTextFile(ignorePath, CACHE_DIR_IGNORE_CONTENT); + await createIgnoreMarker(fs, ignorePath); } catch (error) { logger.debug("Cache dir ignore marker not written", { cacheRoot: describeCacheRoot(cacheBase),