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
2 changes: 1 addition & 1 deletion cli/utils/env-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
2 changes: 1 addition & 1 deletion cli/utils/env-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export function generateGitignoreContent(existingContent?: string): string {
"dist/",
".veryfront/",
"",
"# Local AI model cache",
"# Local build cache",
".cache/",
"",
"# IDE",
Expand Down
30 changes: 29 additions & 1 deletion src/transforms/mdx/esm-module-loader/cache/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -1255,3 +1256,30 @@ describe("invalidateModulePaths — edge cases", () => {
}
});
});

describe("local cache root version-control hygiene", () => {
// Regression: `veryfront dev` writes its ESM/bundle caches into
// `<project>/.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();
}
});
});
4 changes: 4 additions & 0 deletions src/transforms/mdx/esm-module-loader/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { fromFileUrl, join } from "#veryfront/compat/path";
import { rendererLogger as logger } from "#veryfront/utils";
import {
ensureCacheDirIgnored,
getCacheBaseDir,
getHttpBundleCacheDir,
getMdxEsmCacheDir,
Expand Down Expand Up @@ -615,6 +616,9 @@ export async function clearHttpBundleCache(): Promise<void> {
export async function clearAllLocalCaches(): Promise<void> {
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`);
}

Expand Down
22 changes: 22 additions & 0 deletions src/utils/cache-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
75 changes: 75 additions & 0 deletions src/utils/cache-dir.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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,
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";
Expand Down Expand Up @@ -105,6 +110,76 @@ 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");

/**
* 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<void> {
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.
*
* Outside production the cache root is `<project>/.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, 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.
*
* Best-effort: an unwritable cache root must not fail server startup, and an
* existing `.gitignore` is never overwritten.
*/
export async function ensureCacheDirIgnored(): Promise<void> {
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 createIgnoreMarker(fs, ignorePath);
} 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.
Expand Down