diff --git a/.gitignore b/.gitignore index b91580a..da8394e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,13 @@ node_modules dist -.docs .DS_Store *.tgz *.log pnpm-debug.log* npm-debug.log* yarn-debug.log* -.docs docs.config.json docs.lock coverage TODO.md +.docs/ diff --git a/AGENTS.md b/AGENTS.md index 8ffac5d..b89c1ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,11 @@ pnpm. - Test: `pnpm test` - Typecheck: `pnpm typecheck` +## Testing expectations + +- Add or update tests for behavior changes and bug fixes. +- Prefer extending existing test coverage in `tests/` before adding new files. + ## Cache layout - Materialized sources live at `.docs//`. diff --git a/src/add.ts b/src/add.ts index e5b0f80..5aac97b 100644 --- a/src/add.ts +++ b/src/add.ts @@ -1,7 +1,7 @@ import { access, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; - import { + DEFAULT_CACHE_DIR, DEFAULT_CONFIG, type DocsCacheConfig, resolveConfigPath, @@ -9,6 +9,7 @@ import { validateConfig, writeConfig, } from "./config"; +import { ensureGitignoreEntry } from "./gitignore"; import { resolveTargetDir } from "./paths"; import { resolveRepoInput } from "./resolve-repo"; import { assertSafeSourceId } from "./source-id"; @@ -68,16 +69,19 @@ export const addSources = async (params: { let config = DEFAULT_CONFIG; let rawConfig: DocsCacheConfig | null = null; let rawPackage: Record | null = null; + let hadDocsCacheConfig = false; if (await exists(resolvedPath)) { if (target.mode === "package") { const pkg = await loadPackageConfig(resolvedPath); rawPackage = pkg.parsed; rawConfig = pkg.config; config = rawConfig ?? DEFAULT_CONFIG; + hadDocsCacheConfig = Boolean(rawConfig); } else { const raw = await readFile(resolvedPath, "utf8"); rawConfig = JSON.parse(raw.toString()); config = validateConfig(rawConfig); + hadDocsCacheConfig = true; } } @@ -138,11 +142,19 @@ export const addSources = async (params: { } else { await writeConfig(resolvedPath, nextConfig); } + const gitignoreResult = !hadDocsCacheConfig + ? await ensureGitignoreEntry( + path.dirname(resolvedPath), + rawConfig?.cacheDir ?? DEFAULT_CACHE_DIR, + ) + : null; return { configPath: resolvedPath, sources: newSources, skipped, created: true, + gitignoreUpdated: gitignoreResult?.updated ?? false, + gitignorePath: gitignoreResult?.gitignorePath ?? null, }; }; diff --git a/src/cli/index.ts b/src/cli/index.ts index b466188..8a2d0a1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -148,6 +148,11 @@ const runCommand = async ( ui.line( `${symbols.info} Updated ${pc.gray(path.relative(process.cwd(), result.configPath) || "docs.config.json")}`, ); + if (result.gitignoreUpdated && result.gitignorePath) { + ui.line( + `${symbols.info} Updated ${pc.gray(ui.path(result.gitignorePath))}`, + ); + } } return; } @@ -297,6 +302,11 @@ const runCommand = async ( ui.line( `${symbols.success} Wrote ${pc.gray(ui.path(result.configPath))}`, ); + if (result.gitignoreUpdated && result.gitignorePath) { + ui.line( + `${symbols.info} Updated ${pc.gray(ui.path(result.gitignorePath))}`, + ); + } } return; } diff --git a/src/gitignore.ts b/src/gitignore.ts new file mode 100644 index 0000000..95b8489 --- /dev/null +++ b/src/gitignore.ts @@ -0,0 +1,92 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { toPosixPath } from "./paths"; + +const exists = async (target: string) => { + try { + await access(target); + return true; + } catch { + return false; + } +}; + +const normalizeEntry = (value: string) => { + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) { + return ""; + } + let normalized = trimmed.replace(/^\//, ""); + normalized = normalized.replace(/^\.\//, ""); + normalized = normalized.replace(/\/+$/, ""); + return toPosixPath(normalized); +}; + +const resolveGitignoreEntry = (rootDir: string, cacheDir: string) => { + const resolved = path.isAbsolute(cacheDir) + ? path.resolve(cacheDir) + : path.resolve(rootDir, cacheDir); + const relative = path.relative(rootDir, resolved); + const isOutside = + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + if (isOutside) { + return null; + } + return relative.length === 0 ? "." : relative; +}; + +export const getGitignoreStatus = async (rootDir: string, cacheDir: string) => { + const gitignorePath = path.resolve(rootDir, ".gitignore"); + const entry = resolveGitignoreEntry(rootDir, cacheDir); + if (!entry) { + return { gitignorePath, entry: null, hasEntry: false }; + } + const normalizedEntry = normalizeEntry(entry); + if (!normalizedEntry) { + return { gitignorePath, entry: null, hasEntry: false }; + } + let contents = ""; + if (await exists(gitignorePath)) { + contents = await readFile(gitignorePath, "utf8"); + } + const lines = contents.split(/\r?\n/); + const existing = new Set( + lines.map((line) => normalizeEntry(line)).filter(Boolean), + ); + return { + gitignorePath, + entry: `${normalizedEntry}/`, + hasEntry: existing.has(normalizedEntry), + }; +}; + +export const ensureGitignoreEntry = async ( + rootDir: string, + cacheDir: string, +) => { + const status = await getGitignoreStatus(rootDir, cacheDir); + if (!status.entry) { + return { updated: false, gitignorePath: status.gitignorePath, entry: null }; + } + if (status.hasEntry) { + return { + updated: false, + gitignorePath: status.gitignorePath, + entry: status.entry, + }; + } + let contents = ""; + if (await exists(status.gitignorePath)) { + contents = await readFile(status.gitignorePath, "utf8"); + } + const prefix = contents.length === 0 || contents.endsWith("\n") ? "" : "\n"; + const next = `${contents}${prefix}${status.entry}\n`; + await writeFile(status.gitignorePath, next, "utf8"); + return { + updated: true, + gitignorePath: status.gitignorePath, + entry: status.entry, + }; +}; diff --git a/src/init.ts b/src/init.ts index 093f38d..887ec74 100644 --- a/src/init.ts +++ b/src/init.ts @@ -13,6 +13,7 @@ import { stripDefaultConfigValues, writeConfig, } from "./config"; +import { ensureGitignoreEntry, getGitignoreStatus } from "./gitignore"; type InitOptions = { cacheDirOverride?: string; @@ -91,6 +92,7 @@ export const initConfig = async ( if (isCancel(cacheDirAnswer)) { throw new Error("Init cancelled."); } + const cacheDirValue = cacheDirAnswer || DEFAULT_CACHE_DIR; const indexAnswer = await confirm({ message: "Generate index.json (summary of cached sources + paths for tools)", @@ -99,15 +101,29 @@ export const initConfig = async ( if (isCancel(indexAnswer)) { throw new Error("Init cancelled."); } + const gitignoreStatus = await getGitignoreStatus(cwd, cacheDirValue); + let gitignoreAnswer = false; + if (gitignoreStatus.entry && !gitignoreStatus.hasEntry) { + const reply = await confirm({ + message: "Add cache directory to .gitignore", + initialValue: true, + }); + if (isCancel(reply)) { + throw new Error("Init cancelled."); + } + gitignoreAnswer = reply; + } const answers = { configPath, cacheDir: cacheDirAnswer, index: indexAnswer, + gitignore: gitignoreAnswer, } as { configPath: string; cacheDir: string; index: boolean; + gitignore: boolean; }; const resolvedConfigPath = path.resolve(cwd, answers.configPath); @@ -124,9 +140,9 @@ export const initConfig = async ( "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", sources: [], }; - const cacheDirValue = answers.cacheDir || DEFAULT_CACHE_DIR; - if (cacheDirValue !== DEFAULT_CACHE_DIR) { - baseConfig.cacheDir = cacheDirValue; + const resolvedCacheDir = answers.cacheDir || DEFAULT_CACHE_DIR; + if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { + baseConfig.cacheDir = resolvedCacheDir; } if (answers.index) { baseConfig.index = true; @@ -137,9 +153,17 @@ export const initConfig = async ( `${JSON.stringify(pkg, null, 2)}\n`, "utf8", ); + const gitignoreResult = answers.gitignore + ? await ensureGitignoreEntry( + path.dirname(resolvedConfigPath), + resolvedCacheDir, + ) + : null; return { configPath: resolvedConfigPath, created: true, + gitignoreUpdated: gitignoreResult?.updated ?? false, + gitignorePath: gitignoreResult?.gitignorePath ?? null, }; } if (await exists(resolvedConfigPath)) { @@ -150,17 +174,25 @@ export const initConfig = async ( "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", sources: [], }; - const cacheDirValue = answers.cacheDir || DEFAULT_CACHE_DIR; - if (cacheDirValue !== DEFAULT_CACHE_DIR) { - config.cacheDir = cacheDirValue; + const resolvedCacheDir = answers.cacheDir || DEFAULT_CACHE_DIR; + if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { + config.cacheDir = resolvedCacheDir; } if (answers.index) { config.index = true; } await writeConfig(resolvedConfigPath, config); + const gitignoreResult = answers.gitignore + ? await ensureGitignoreEntry( + path.dirname(resolvedConfigPath), + resolvedCacheDir, + ) + : null; return { configPath: resolvedConfigPath, created: true, + gitignoreUpdated: gitignoreResult?.updated ?? false, + gitignorePath: gitignoreResult?.gitignorePath ?? null, }; }; diff --git a/tests/cli-add.test.js b/tests/cli-add.test.js index 9e1b255..91da773 100644 --- a/tests/cli-add.test.js +++ b/tests/cli-add.test.js @@ -141,3 +141,21 @@ test("add writes package.json without default fields", async () => { assert.equal(pkg["docs-cache"].defaults, undefined); assert.equal(pkg["docs-cache"].targetMode, undefined); }); + +test("add writes .gitignore when initializing config", async () => { + const tmpRoot = path.join(tmpdir(), `docs-cache-add-gitignore-${Date.now()}`); + await mkdir(tmpRoot, { recursive: true }); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await execFileAsync("node", [ + "bin/docs-cache.mjs", + "add", + "--offline", + "https://github.com/fbosch/docs-cache.git", + "--config", + configPath, + ]); + + const raw = await readFile(path.join(tmpRoot, ".gitignore"), "utf8"); + assert.match(raw, /^\.docs\/$/m); +}); diff --git a/tests/init.test.js b/tests/init.test.js index 11f6d08..2c3da55 100644 --- a/tests/init.test.js +++ b/tests/init.test.js @@ -6,8 +6,19 @@ import { test } from "node:test"; import { initConfig } from "../dist/api.mjs"; -const stubPrompts = (answers) => ({ - confirm: async () => answers.index, +const stubPrompts = (answers, callbacks = {}) => ({ + confirm: async (options) => { + if (options.message?.startsWith("Generate index.json")) { + return answers.index; + } + if (options.message === "Add cache directory to .gitignore") { + if (callbacks.onGitignorePrompt) { + callbacks.onGitignorePrompt(); + } + return answers.gitignore ?? true; + } + return false; + }, isCancel: () => false, select: async () => answers.location, text: async (options) => { @@ -70,6 +81,7 @@ test("init writes docs.config.json when selected", async () => { location: "config", cacheDir: ".docs", index: true, + gitignore: true, }), ); @@ -95,6 +107,7 @@ test("init writes package.json docs-cache when selected", async () => { location: "package", cacheDir: ".docs", index: false, + gitignore: false, }), ); @@ -105,3 +118,60 @@ test("init writes package.json docs-cache when selected", async () => { assert.equal(pkg["docs-cache"].cacheDir, undefined); assert.equal(pkg["docs-cache"].defaults, undefined); }); + +test("init writes .gitignore entry when missing", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-init-gitignore-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + await writeFile( + path.join(tmpRoot, "package.json"), + JSON.stringify({ name: "x", version: "0.0.0" }), + ); + + await initConfig( + { json: false, cwd: tmpRoot }, + stubPrompts({ + location: "config", + cacheDir: ".docs", + index: false, + gitignore: true, + }), + ); + + const raw = await readFile(path.join(tmpRoot, ".gitignore"), "utf8"); + assert.match(raw, /^\.docs\/$/m); +}); + +test("init skips gitignore prompt when entry exists", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-init-gitignore-skip-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + await writeFile( + path.join(tmpRoot, "package.json"), + JSON.stringify({ name: "x", version: "0.0.0" }), + ); + await writeFile(path.join(tmpRoot, ".gitignore"), ".docs/\n", "utf8"); + + let prompted = false; + await initConfig( + { json: false, cwd: tmpRoot }, + stubPrompts( + { + location: "config", + cacheDir: ".docs", + index: false, + }, + { + onGitignorePrompt: () => { + prompted = true; + }, + }, + ), + ); + + assert.equal(prompted, false); +});