From 51fb66e369eef73f641e7cc6236d89713f0d7fe6 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:44:52 +0100 Subject: [PATCH 01/12] fix(sparse): support brace patterns in includes --- src/git/fetch-source.ts | 24 ++- tests/integration-real-repos.test.js | 60 ++++++ tests/sparse-brace-expansion.test.js | 263 +++++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 tests/sparse-brace-expansion.test.js diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index 0060ca2..ef868fa 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -286,8 +286,28 @@ type CloneResult = { const patternHasGlob = (pattern: string) => pattern.includes("*") || pattern.includes("?") || pattern.includes("["); -const normalizeSparsePatterns = (include?: string[]) => - (include ?? []).map((pattern) => pattern.replace(/\\/g, "/")).filter(Boolean); +const expandBracePattern = (pattern: string): string[] => { + // Match patterns like **/*.{md,mdx,txt} + const braceMatch = pattern.match(/^(.*)\.{([^}]+)}(.*)$/); + if (!braceMatch) { + return [pattern]; + } + const [, prefix, extensions, suffix] = braceMatch; + const extList = extensions.split(",").map((ext) => ext.trim()); + return extList.map((ext) => `${prefix}.${ext}${suffix}`); +}; + +const normalizeSparsePatterns = (include?: string[]) => { + const patterns = include ?? []; + const expanded: string[] = []; + for (const pattern of patterns) { + const normalized = pattern.replace(/\\/g, "/"); + if (!normalized) continue; + // Expand brace patterns for git sparse-checkout compatibility + expanded.push(...expandBracePattern(normalized)); + } + return expanded; +}; const isDirectoryLiteral = (pattern: string) => pattern.endsWith("/"); diff --git a/tests/integration-real-repos.test.js b/tests/integration-real-repos.test.js index 1a1633a..e51fb5f 100644 --- a/tests/integration-real-repos.test.js +++ b/tests/integration-real-repos.test.js @@ -123,3 +123,63 @@ test("integration clears partial clone cache before sync", async (t) => { await rm(tmpRoot, { recursive: true, force: true }); } }); + +test("integration uses default include pattern without explicit config", async (t) => { + if (!shouldRun()) { + t.skip("Set DOCS_CACHE_INTEGRATION=1 to run integration tests"); + return; + } + const tmpRoot = path.join( + tmpdir(), + `docs-cache-defaults-${Date.now().toString(36)}`, + ); + const cacheDir = path.join(tmpRoot, ".docs"); + const configPath = path.join(tmpRoot, "docs.config.json"); + const repo = "https://github.com/glanceapp/glance.git"; + + await mkdir(tmpRoot, { recursive: true }); + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + sources: [ + { + id: "glance", + repo, + // No include pattern specified - should use defaults + }, + ], + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + try { + await runSync({ + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }); + const lockRaw = await readFile( + path.join(tmpRoot, DEFAULT_LOCK_FILENAME), + "utf8", + ); + const lock = JSON.parse(lockRaw); + assert.ok(lock.sources.glance); + // The default pattern includes **/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc} + // glanceapp/glance has markdown files, so fileCount should be > 0 + assert.ok( + lock.sources.glance.fileCount > 0, + `Expected files to be synced with default include pattern, got ${lock.sources.glance.fileCount}`, + ); + // Verify that actual .md files were synced + const readmePath = path.join(cacheDir, "glance", "README.md"); + const readmeContent = await readFile(readmePath, "utf8"); + assert.ok( + readmeContent.length > 0, + "Expected README.md to be synced and have content", + ); + } finally { + await rm(tmpRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/sparse-brace-expansion.test.js b/tests/sparse-brace-expansion.test.js new file mode 100644 index 0000000..644e504 --- /dev/null +++ b/tests/sparse-brace-expansion.test.js @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { runSync } from "../dist/api.mjs"; + +const hashRepoUrl = (repo) => + createHash("sha256").update(repo).digest("hex").substring(0, 16); + +const writeGitShim = async (binDir, logPath) => { + const scriptPath = path.join( + binDir, + process.platform === "win32" ? "git.js" : "git", + ); + const payload = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const logPath = ${JSON.stringify(logPath)}; +fs.appendFileSync(logPath, + JSON.stringify(process.argv.slice(2)) + "\\n", + "utf8", +); + +const args = process.argv.slice(2); +const isWin = process.platform === "win32"; +const normalize = (value) => (isWin ? value.toLowerCase() : value); + +if (args.map(normalize).includes("ls-remote")) { + // Return a fake commit SHA + console.log("abc123def456789012345678901234567890abcd\\tHEAD"); + process.exit(0); +} + +if (args.map(normalize).includes("clone")) { + const outDir = args[args.length - 1]; + fs.mkdirSync(outDir, { recursive: true }); +} + +if (args.map(normalize).includes("checkout")) { + process.exit(0); +} + +process.exit(0); +`; + await writeFile(scriptPath, payload, "utf8"); + if (process.platform !== "win32") { + await chmod(scriptPath, 0o755); + return; + } + const cmdPath = path.join(binDir, "git.cmd"); + const cmdPayload = `@echo off +"${process.execPath}" "${scriptPath}" %* +`; + await writeFile(cmdPath, cmdPayload, "utf8"); +}; + +test("sync expands brace patterns for git sparse-checkout", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-brace-${Date.now().toString(36)}`, + ); + const binDir = path.join(tmpRoot, "bin"); + const logPath = path.join(tmpRoot, "git.log"); + const cacheDir = path.join(tmpRoot, ".docs"); + const configPath = path.join(tmpRoot, "docs.config.json"); + const gitCacheRoot = path.join(tmpRoot, "git-cache"); + const repo = "https://example.com/repo.git"; + const repoHash = hashRepoUrl(repo); + const cachePath = path.join(gitCacheRoot, repoHash); + + await mkdir(binDir, { recursive: true }); + await mkdir(cachePath, { recursive: true }); + await writeGitShim(binDir, logPath); + await writeFile(logPath, "", "utf8"); + + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + defaults: { + allowHosts: ["example.com"], + }, + sources: [ + { + id: "test", + repo, + include: ["**/*.{md,mdx,txt}"], + }, + ], + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + const previousPath = process.env.PATH ?? process.env.Path; + const previousPathExt = process.env.PATHEXT; + const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; + const nextPath = + process.platform === "win32" ? binDir : `${binDir}:${previousPath ?? ""}`; + const nextPathExt = + process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; + + process.env.PATH = nextPath; + process.env.Path = nextPath; + process.env.PATHEXT = nextPathExt; + process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; + + try { + await runSync({ + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }); + + const logRaw = await readFile(logPath, "utf8"); + const lines = logRaw.trim().split("\n").filter(Boolean); + const sparseCheckoutCalls = lines.filter((line) => { + try { + const args = JSON.parse(line); + return args.includes("sparse-checkout"); + } catch { + return false; + } + }); + + assert.ok( + sparseCheckoutCalls.length > 0, + "Expected sparse-checkout to be called", + ); + + // Check that brace pattern was expanded into separate patterns + const sparseArgs = sparseCheckoutCalls.map((call) => JSON.parse(call)); + const hasExpandedPatterns = sparseArgs.some((args) => { + // Should have expanded **/*.{md,mdx,txt} into: + // **/*.md, **/*.mdx, **/*.txt + const patternIndex = args.indexOf("set"); + if (patternIndex === -1) return false; + const patterns = args.slice(patternIndex + 2); // skip "set" and "--no-cone" + return ( + patterns.includes("**/*.md") && + patterns.includes("**/*.mdx") && + patterns.includes("**/*.txt") + ); + }); + + assert.ok( + hasExpandedPatterns, + `Expected brace patterns to be expanded. Got: ${JSON.stringify(sparseArgs, null, 2)}`, + ); + } finally { + process.env.PATH = previousPath; + process.env.Path = previousPath; + process.env.PATHEXT = previousPathExt; + process.env.DOCS_CACHE_GIT_DIR = previousGitDir; + await rm(tmpRoot, { recursive: true, force: true }); + } +}); + +test("sync expands default brace pattern when no include specified", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-default-brace-${Date.now().toString(36)}`, + ); + const binDir = path.join(tmpRoot, "bin"); + const logPath = path.join(tmpRoot, "git.log"); + const cacheDir = path.join(tmpRoot, ".docs"); + const configPath = path.join(tmpRoot, "docs.config.json"); + const gitCacheRoot = path.join(tmpRoot, "git-cache"); + const repo = "https://example.com/repo.git"; + const repoHash = hashRepoUrl(repo); + const cachePath = path.join(gitCacheRoot, repoHash); + + await mkdir(binDir, { recursive: true }); + await mkdir(cachePath, { recursive: true }); + await writeGitShim(binDir, logPath); + await writeFile(logPath, "", "utf8"); + + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + defaults: { + allowHosts: ["example.com"], + }, + sources: [ + { + id: "test", + repo, + // No include - should use default pattern with brace expansion + }, + ], + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + const previousPath = process.env.PATH ?? process.env.Path; + const previousPathExt = process.env.PATHEXT; + const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; + const nextPath = + process.platform === "win32" ? binDir : `${binDir}:${previousPath ?? ""}`; + const nextPathExt = + process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; + + process.env.PATH = nextPath; + process.env.Path = nextPath; + process.env.PATHEXT = nextPathExt; + process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; + + try { + await runSync({ + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }); + + const logRaw = await readFile(logPath, "utf8"); + const lines = logRaw.trim().split("\n").filter(Boolean); + const sparseCheckoutCalls = lines.filter((line) => { + try { + const args = JSON.parse(line); + return args.includes("sparse-checkout"); + } catch { + return false; + } + }); + + assert.ok( + sparseCheckoutCalls.length > 0, + "Expected sparse-checkout to be called with default patterns", + ); + + // Check that default brace pattern was expanded + const sparseArgs = sparseCheckoutCalls.map((call) => JSON.parse(call)); + const hasExpandedDefaults = sparseArgs.some((args) => { + const patternIndex = args.indexOf("set"); + if (patternIndex === -1) return false; + const patterns = args.slice(patternIndex + 2); + // Default is **/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc} + return ( + patterns.includes("**/*.md") && + patterns.includes("**/*.mdx") && + patterns.includes("**/*.markdown") && + patterns.includes("**/*.txt") + ); + }); + + assert.ok( + hasExpandedDefaults, + `Expected default brace patterns to be expanded. Got: ${JSON.stringify(sparseArgs, null, 2)}`, + ); + } finally { + process.env.PATH = previousPath; + process.env.Path = previousPath; + process.env.PATHEXT = previousPathExt; + process.env.DOCS_CACHE_GIT_DIR = previousGitDir; + await rm(tmpRoot, { recursive: true, force: true }); + } +}); From 21656fa3d0da6a311f2268fd7b81ba734bf3cdc5 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:53:34 +0100 Subject: [PATCH 02/12] fix: windows build --- tests/sparse-brace-expansion.test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/sparse-brace-expansion.test.js b/tests/sparse-brace-expansion.test.js index 644e504..45ea8b0 100644 --- a/tests/sparse-brace-expansion.test.js +++ b/tests/sparse-brace-expansion.test.js @@ -97,7 +97,9 @@ test("sync expands brace patterns for git sparse-checkout", async () => { const previousPathExt = process.env.PATHEXT; const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; const nextPath = - process.platform === "win32" ? binDir : `${binDir}:${previousPath ?? ""}`; + process.platform === "win32" + ? `${binDir};${previousPath ?? ""}` + : `${binDir}:${previousPath ?? ""}`; const nextPathExt = process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; @@ -199,7 +201,9 @@ test("sync expands default brace pattern when no include specified", async () => const previousPathExt = process.env.PATHEXT; const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; const nextPath = - process.platform === "win32" ? binDir : `${binDir}:${previousPath ?? ""}`; + process.platform === "win32" + ? `${binDir};${previousPath ?? ""}` + : `${binDir}:${previousPath ?? ""}`; const nextPathExt = process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; From ef50cdf9e688a855b08f3780118664adbdfc16d6 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:17:00 +0100 Subject: [PATCH 03/12] fix(default-includes): add brace expansion limit --- .gitignore | 4 + README.md | 4 +- src/git/fetch-source.ts | 73 ++++++------ src/git/git-env.ts | 30 +++++ src/git/resolve-remote.ts | 3 +- tests/sparse-brace-expansion.test.js | 167 ++++++++++++++------------- 6 files changed, 160 insertions(+), 121 deletions(-) create mode 100644 src/git/git-env.ts diff --git a/.gitignore b/.gitignore index 4b2795d..1833e21 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ coverage TODO.md .docs/ benchmarks/ + +*.md +!AGENTS.md +!README.md diff --git a/README.md b/README.md index 64259b7..7ce756b 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,12 @@ These fields can be set in `defaults` and are inherited by every source unless o | `maxBytes` | Maximum total bytes to materialize. Default: `200000000` (200 MB). | | `maxFiles` | Maximum total files to materialize. | | `ignoreHidden` | Skip hidden files and directories (dotfiles). Default: `false`. | -| `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com", "visualstudio.com"]`. | +| `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com", "visualstudio.com"]`. | | `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format: `"tree"` (human readable), `"compressed"` | | `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `true`. | +> Brace expansion in `include` supports comma-separated lists (including multiple groups) like `**/*.{md,mdx}` and is capped at 500 expanded patterns per include entry. It does not support nested braces or numeric ranges. + ### Source options #### Required diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index ef868fa..fababb9 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -9,40 +9,13 @@ import { execa } from "execa"; import { getErrnoCode } from "#core/errors"; import { assertSafeSourceId } from "#core/source-id"; import { exists, resolveGitCacheDir } from "#git/cache-dir"; +import { buildGitEnv } from "#git/git-env"; const DEFAULT_TIMEOUT_MS = 120000; // 120 seconds (2 minutes) const DEFAULT_GIT_DEPTH = 1; const DEFAULT_RM_RETRIES = 3; const DEFAULT_RM_BACKOFF_MS = 100; - -const buildGitEnv = () => { - const pathValue = process.env.PATH ?? process.env.Path; - const pathExtValue = - process.env.PATHEXT ?? - (process.platform === "win32" ? ".COM;.EXE;.BAT;.CMD" : undefined); - return { - ...process.env, - ...(pathValue ? { PATH: pathValue, Path: pathValue } : {}), - ...(pathExtValue ? { PATHEXT: pathExtValue } : {}), - HOME: process.env.HOME, - USER: process.env.USER, - USERPROFILE: process.env.USERPROFILE, - TMPDIR: process.env.TMPDIR, - TMP: process.env.TMP, - TEMP: process.env.TEMP, - SYSTEMROOT: process.env.SYSTEMROOT, - WINDIR: process.env.WINDIR, - SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK, - SSH_AGENT_PID: process.env.SSH_AGENT_PID, - HTTP_PROXY: process.env.HTTP_PROXY, - HTTPS_PROXY: process.env.HTTPS_PROXY, - NO_PROXY: process.env.NO_PROXY, - GIT_TERMINAL_PROMPT: "0", - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_NOGLOBAL: "1", - ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), - }; -}; +const MAX_BRACE_EXPANSIONS = 500; const buildGitConfigs = (allowFileProtocol?: boolean) => [ "-c", @@ -287,14 +260,40 @@ const patternHasGlob = (pattern: string) => pattern.includes("*") || pattern.includes("?") || pattern.includes("["); const expandBracePattern = (pattern: string): string[] => { - // Match patterns like **/*.{md,mdx,txt} - const braceMatch = pattern.match(/^(.*)\.{([^}]+)}(.*)$/); - if (!braceMatch) { - return [pattern]; - } - const [, prefix, extensions, suffix] = braceMatch; - const extList = extensions.split(",").map((ext) => ext.trim()); - return extList.map((ext) => `${prefix}.${ext}${suffix}`); + const results: string[] = []; + const expand = (value: string) => { + const braceMatch = value.match(/^(.*?){([^}]+)}(.*)$/); + if (!braceMatch) { + results.push(value); + if (results.length > MAX_BRACE_EXPANSIONS) { + throw new Error( + `Brace expansion exceeded ${MAX_BRACE_EXPANSIONS} patterns for '${pattern}'.`, + ); + } + return; + } + const [, prefix, values, suffix] = braceMatch; + const valueList = values + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (valueList.length === 0) { + results.push(value); + if (results.length > MAX_BRACE_EXPANSIONS) { + throw new Error( + `Brace expansion exceeded ${MAX_BRACE_EXPANSIONS} patterns for '${pattern}'.`, + ); + } + return; + } + for (const entry of valueList) { + const expandedPattern = `${prefix}${entry}${suffix}`; + expand(expandedPattern); + } + }; + + expand(pattern); + return results; }; const normalizeSparsePatterns = (include?: string[]) => { diff --git a/src/git/git-env.ts b/src/git/git-env.ts new file mode 100644 index 0000000..ea3dc8a --- /dev/null +++ b/src/git/git-env.ts @@ -0,0 +1,30 @@ +const buildGitEnv = (): NodeJS.ProcessEnv => { + const pathValue = process.env.PATH ?? process.env.Path; + const pathExtValue = + process.env.PATHEXT ?? + (process.platform === "win32" ? ".COM;.EXE;.BAT;.CMD" : undefined); + return { + ...process.env, + ...(pathValue ? { PATH: pathValue, Path: pathValue } : {}), + ...(pathExtValue ? { PATHEXT: pathExtValue } : {}), + HOME: process.env.HOME, + USER: process.env.USER, + USERPROFILE: process.env.USERPROFILE, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK, + SSH_AGENT_PID: process.env.SSH_AGENT_PID, + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + NO_PROXY: process.env.NO_PROXY, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_NOGLOBAL: "1", + ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), + }; +}; + +export { buildGitEnv }; diff --git a/src/git/resolve-remote.ts b/src/git/resolve-remote.ts index 0d46544..b61431a 100644 --- a/src/git/resolve-remote.ts +++ b/src/git/resolve-remote.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; - +import { buildGitEnv } from "#git/git-env"; import { redactRepoUrl } from "#git/redact"; const execFileAsync = promisify(execFile); @@ -90,6 +90,7 @@ export const resolveRemoteCommit = async (params: ResolveRemoteParams) => { { timeout: params.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxBuffer: 1024 * 1024, + env: buildGitEnv(), }, ); diff --git a/tests/sparse-brace-expansion.test.js b/tests/sparse-brace-expansion.test.js index 45ea8b0..ea7ab7f 100644 --- a/tests/sparse-brace-expansion.test.js +++ b/tests/sparse-brace-expansion.test.js @@ -58,11 +58,8 @@ process.exit(0); await writeFile(cmdPath, cmdPayload, "utf8"); }; -test("sync expands brace patterns for git sparse-checkout", async () => { - const tmpRoot = path.join( - tmpdir(), - `docs-cache-brace-${Date.now().toString(36)}`, - ); +const createTestContext = async (label) => { + const tmpRoot = path.join(tmpdir(), `${label}-${Date.now().toString(36)}`); const binDir = path.join(tmpRoot, "bin"); const logPath = path.join(tmpRoot, "git.log"); const cacheDir = path.join(tmpRoot, ".docs"); @@ -77,6 +74,65 @@ test("sync expands brace patterns for git sparse-checkout", async () => { await writeGitShim(binDir, logPath); await writeFile(logPath, "", "utf8"); + const cleanup = async () => { + await rm(tmpRoot, { recursive: true, force: true }); + }; + + return { + binDir, + logPath, + cacheDir, + configPath, + gitCacheRoot, + repo, + cleanup, + }; +}; + +const withModifiedPath = async (binDir, gitCacheRoot, fn) => { + const saved = { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + DOCS_CACHE_GIT_DIR: process.env.DOCS_CACHE_GIT_DIR, + }; + const previousPath = process.env.PATH ?? process.env.Path; + const nextPath = previousPath + ? `${binDir}${path.delimiter}${previousPath}` + : binDir; + + process.env.PATH = nextPath; + process.env.Path = nextPath; + if (process.platform === "win32") { + process.env.PATHEXT = ".CMD;.BAT;.EXE;.COM"; + } + process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; + + try { + return await fn(); + } finally { + process.env.PATH = saved.PATH; + process.env.Path = saved.Path; + process.env.PATHEXT = saved.PATHEXT; + process.env.DOCS_CACHE_GIT_DIR = saved.DOCS_CACHE_GIT_DIR; + } +}; + +const getSparsePatterns = (args) => { + const patternIndex = args.indexOf("set"); + if (patternIndex === -1) return []; + const noConeIndex = args.indexOf("--no-cone"); + const patternsStart = + noConeIndex !== -1 && noConeIndex > patternIndex + ? noConeIndex + 1 + : patternIndex + 1; + return args.slice(patternsStart).filter((arg) => !arg.startsWith("--")); +}; + +test("sync expands brace patterns for git sparse-checkout", async () => { + const { binDir, logPath, cacheDir, configPath, gitCacheRoot, repo, cleanup } = + await createTestContext("docs-cache-brace"); + const config = { $schema: "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", @@ -93,29 +149,16 @@ test("sync expands brace patterns for git sparse-checkout", async () => { }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - const previousPath = process.env.PATH ?? process.env.Path; - const previousPathExt = process.env.PATHEXT; - const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; - const nextPath = - process.platform === "win32" - ? `${binDir};${previousPath ?? ""}` - : `${binDir}:${previousPath ?? ""}`; - const nextPathExt = - process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; - - process.env.PATH = nextPath; - process.env.Path = nextPath; - process.env.PATHEXT = nextPathExt; - process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; - try { - await runSync({ - configPath, - cacheDirOverride: cacheDir, - json: false, - lockOnly: false, - offline: false, - failOnMiss: false, + await withModifiedPath(binDir, gitCacheRoot, async () => { + await runSync({ + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }); }); const logRaw = await readFile(logPath, "utf8"); @@ -139,9 +182,7 @@ test("sync expands brace patterns for git sparse-checkout", async () => { const hasExpandedPatterns = sparseArgs.some((args) => { // Should have expanded **/*.{md,mdx,txt} into: // **/*.md, **/*.mdx, **/*.txt - const patternIndex = args.indexOf("set"); - if (patternIndex === -1) return false; - const patterns = args.slice(patternIndex + 2); // skip "set" and "--no-cone" + const patterns = getSparsePatterns(args); return ( patterns.includes("**/*.md") && patterns.includes("**/*.mdx") && @@ -154,32 +195,13 @@ test("sync expands brace patterns for git sparse-checkout", async () => { `Expected brace patterns to be expanded. Got: ${JSON.stringify(sparseArgs, null, 2)}`, ); } finally { - process.env.PATH = previousPath; - process.env.Path = previousPath; - process.env.PATHEXT = previousPathExt; - process.env.DOCS_CACHE_GIT_DIR = previousGitDir; - await rm(tmpRoot, { recursive: true, force: true }); + await cleanup(); } }); test("sync expands default brace pattern when no include specified", async () => { - const tmpRoot = path.join( - tmpdir(), - `docs-cache-default-brace-${Date.now().toString(36)}`, - ); - const binDir = path.join(tmpRoot, "bin"); - const logPath = path.join(tmpRoot, "git.log"); - const cacheDir = path.join(tmpRoot, ".docs"); - const configPath = path.join(tmpRoot, "docs.config.json"); - const gitCacheRoot = path.join(tmpRoot, "git-cache"); - const repo = "https://example.com/repo.git"; - const repoHash = hashRepoUrl(repo); - const cachePath = path.join(gitCacheRoot, repoHash); - - await mkdir(binDir, { recursive: true }); - await mkdir(cachePath, { recursive: true }); - await writeGitShim(binDir, logPath); - await writeFile(logPath, "", "utf8"); + const { binDir, logPath, cacheDir, configPath, gitCacheRoot, repo, cleanup } = + await createTestContext("docs-cache-default-brace"); const config = { $schema: @@ -197,29 +219,16 @@ test("sync expands default brace pattern when no include specified", async () => }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - const previousPath = process.env.PATH ?? process.env.Path; - const previousPathExt = process.env.PATHEXT; - const previousGitDir = process.env.DOCS_CACHE_GIT_DIR; - const nextPath = - process.platform === "win32" - ? `${binDir};${previousPath ?? ""}` - : `${binDir}:${previousPath ?? ""}`; - const nextPathExt = - process.platform === "win32" ? ".CMD;.BAT;.EXE;.COM" : previousPathExt; - - process.env.PATH = nextPath; - process.env.Path = nextPath; - process.env.PATHEXT = nextPathExt; - process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; - try { - await runSync({ - configPath, - cacheDirOverride: cacheDir, - json: false, - lockOnly: false, - offline: false, - failOnMiss: false, + await withModifiedPath(binDir, gitCacheRoot, async () => { + await runSync({ + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: false, + failOnMiss: false, + }); }); const logRaw = await readFile(logPath, "utf8"); @@ -241,9 +250,7 @@ test("sync expands default brace pattern when no include specified", async () => // Check that default brace pattern was expanded const sparseArgs = sparseCheckoutCalls.map((call) => JSON.parse(call)); const hasExpandedDefaults = sparseArgs.some((args) => { - const patternIndex = args.indexOf("set"); - if (patternIndex === -1) return false; - const patterns = args.slice(patternIndex + 2); + const patterns = getSparsePatterns(args); // Default is **/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc} return ( patterns.includes("**/*.md") && @@ -258,10 +265,6 @@ test("sync expands default brace pattern when no include specified", async () => `Expected default brace patterns to be expanded. Got: ${JSON.stringify(sparseArgs, null, 2)}`, ); } finally { - process.env.PATH = previousPath; - process.env.Path = previousPath; - process.env.PATHEXT = previousPathExt; - process.env.DOCS_CACHE_GIT_DIR = previousGitDir; - await rm(tmpRoot, { recursive: true, force: true }); + await cleanup(); } }); From 6c774824f6c827f3774c08070fb52a08a6806973 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:28:18 +0100 Subject: [PATCH 04/12] fix: use absolute git command path in Windows tests Windows child_process.execFile() doesn't always respect PATH when resolving bare command names like 'git'. This caused tests to invoke the real git.exe instead of the git.cmd shim. Solution: - Add resolveGitCommand() helper that checks DOCS_CACHE_GIT_COMMAND env var - Export and use resolveGitCommand() in fetch-source.ts and resolve-remote.ts - Set DOCS_CACHE_GIT_COMMAND to absolute path in test's withModifiedPath() - Ensures Windows CI uses git.cmd shim instead of system git Fixes sparse-brace-expansion.test.js on Windows CI. --- src/git/fetch-source.ts | 4 ++-- src/git/git-env.ts | 11 ++++++++++- src/git/resolve-remote.ts | 4 ++-- tests/sparse-brace-expansion.test.js | 9 +++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index fababb9..d900973 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -9,7 +9,7 @@ import { execa } from "execa"; import { getErrnoCode } from "#core/errors"; import { assertSafeSourceId } from "#core/source-id"; import { exists, resolveGitCacheDir } from "#git/cache-dir"; -import { buildGitEnv } from "#git/git-env"; +import { buildGitEnv, resolveGitCommand } from "#git/git-env"; const DEFAULT_TIMEOUT_MS = 120000; // 120 seconds (2 minutes) const DEFAULT_GIT_DEPTH = 1; @@ -113,7 +113,7 @@ const git = async ( ); const commandLabel = `git ${commandArgs.join(" ")}`; options?.logger?.(commandLabel); - const subprocess = execa("git", commandArgs, { + const subprocess = execa(resolveGitCommand(), commandArgs, { cwd: options?.cwd, timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, diff --git a/src/git/git-env.ts b/src/git/git-env.ts index ea3dc8a..13db5ec 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -1,3 +1,12 @@ +const resolveGitCommand = (): string => { + // Allow tests to override git command path + const override = process.env.DOCS_CACHE_GIT_COMMAND; + if (override) { + return override; + } + return "git"; +}; + const buildGitEnv = (): NodeJS.ProcessEnv => { const pathValue = process.env.PATH ?? process.env.Path; const pathExtValue = @@ -27,4 +36,4 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { }; }; -export { buildGitEnv }; +export { buildGitEnv, resolveGitCommand }; diff --git a/src/git/resolve-remote.ts b/src/git/resolve-remote.ts index b61431a..16c4379 100644 --- a/src/git/resolve-remote.ts +++ b/src/git/resolve-remote.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { buildGitEnv } from "#git/git-env"; +import { buildGitEnv, resolveGitCommand } from "#git/git-env"; import { redactRepoUrl } from "#git/redact"; const execFileAsync = promisify(execFile); @@ -85,7 +85,7 @@ export const resolveRemoteCommit = async (params: ResolveRemoteParams) => { const repoLabel = redactRepoUrl(params.repo); params.logger?.(`git ls-remote ${repoLabel} ${params.ref}`); const { stdout } = await execFileAsync( - "git", + resolveGitCommand(), ["ls-remote", params.repo, params.ref], { timeout: params.timeoutMs ?? DEFAULT_TIMEOUT_MS, diff --git a/tests/sparse-brace-expansion.test.js b/tests/sparse-brace-expansion.test.js index ea7ab7f..0b54897 100644 --- a/tests/sparse-brace-expansion.test.js +++ b/tests/sparse-brace-expansion.test.js @@ -95,18 +95,26 @@ const withModifiedPath = async (binDir, gitCacheRoot, fn) => { Path: process.env.Path, PATHEXT: process.env.PATHEXT, DOCS_CACHE_GIT_DIR: process.env.DOCS_CACHE_GIT_DIR, + DOCS_CACHE_GIT_COMMAND: process.env.DOCS_CACHE_GIT_COMMAND, }; const previousPath = process.env.PATH ?? process.env.Path; const nextPath = previousPath ? `${binDir}${path.delimiter}${previousPath}` : binDir; + // On Windows, use absolute path to git.cmd to avoid resolution issues + const gitCommand = + process.platform === "win32" + ? path.join(binDir, "git.cmd") + : path.join(binDir, "git"); + process.env.PATH = nextPath; process.env.Path = nextPath; if (process.platform === "win32") { process.env.PATHEXT = ".CMD;.BAT;.EXE;.COM"; } process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot; + process.env.DOCS_CACHE_GIT_COMMAND = gitCommand; try { return await fn(); @@ -115,6 +123,7 @@ const withModifiedPath = async (binDir, gitCacheRoot, fn) => { process.env.Path = saved.Path; process.env.PATHEXT = saved.PATHEXT; process.env.DOCS_CACHE_GIT_DIR = saved.DOCS_CACHE_GIT_DIR; + process.env.DOCS_CACHE_GIT_COMMAND = saved.DOCS_CACHE_GIT_COMMAND; } }; From a5db44268e4c6cf57bcdbfc1b9b9733f24c00356 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:28:55 +0100 Subject: [PATCH 05/12] docs: add review guidelines to AGENTS.md Add comprehensive review guidelines for implementing PR feedback: - Cross-platform testing requirements - Code quality expectations - Testing best practices (DOCS_CACHE_GIT_COMMAND override) - Documentation requirements These guidelines capture lessons learned from PR #25 feedback implementation to help maintain code quality and consistency. --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 447048a..689a64d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,3 +49,18 @@ pnpm. - Files use kebab-case (e.g. `parse-args.ts`). - Types/interfaces use PascalCase; functions/variables use camelCase. - Use `index.ts` barrels for public entrypoints. + +## Review guidelines + +When implementing PR feedback or making changes for review: + +- **Run all checks before pushing**: `pnpm build && pnpm test && pnpm typecheck && pnpm lint` +- **Test cross-platform compatibility**: Windows CI often catches issues Linux doesn't +- **Keep commits focused**: One logical change per commit with clear messages +- **Avoid premature extraction**: Only extract shared code when you have 2+ actual uses +- **Document edge cases**: Add comments explaining non-obvious behavior or workarounds +- **Test git operations carefully**: Use `DOCS_CACHE_GIT_COMMAND` env var to override git path in tests +- **Preserve existing test patterns**: Follow the style and structure of existing test files +- **Check for regex gotchas**: Use non-greedy `.*?` instead of greedy `.*` when appropriate +- **Validate input limits**: Add safety checks (like `MAX_BRACE_EXPANSIONS`) for user-controlled expansion +- **Update documentation**: Ensure README reflects new features and limitations From a4e06c6b67e7622e647f3d619f409293e8ac0bf0 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:30:53 +0100 Subject: [PATCH 06/12] chore: AGENTS.md --- AGENTS.md | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 689a64d..9ae1dec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,18 @@ pnpm. - Test: `pnpm test` - Typecheck: `pnpm typecheck` +## Git workflow + +**IMPORTANT**: AI agents should NEVER commit or push changes without explicit user permission. + +- **DO NOT** run `git commit` or `git push` automatically +- **DO NOT** create commits as part of completing a task +- **ALWAYS** ask the user before committing or pushing +- **ONLY** commit when the user explicitly requests it (e.g., "commit these changes", "push this to git") +- After making changes, inform the user what was changed and let them decide when to commit + +The user maintains full control over git operations and commit history. + ## Testing expectations - Add or update tests for behavior changes and bug fixes. @@ -49,18 +61,3 @@ pnpm. - Files use kebab-case (e.g. `parse-args.ts`). - Types/interfaces use PascalCase; functions/variables use camelCase. - Use `index.ts` barrels for public entrypoints. - -## Review guidelines - -When implementing PR feedback or making changes for review: - -- **Run all checks before pushing**: `pnpm build && pnpm test && pnpm typecheck && pnpm lint` -- **Test cross-platform compatibility**: Windows CI often catches issues Linux doesn't -- **Keep commits focused**: One logical change per commit with clear messages -- **Avoid premature extraction**: Only extract shared code when you have 2+ actual uses -- **Document edge cases**: Add comments explaining non-obvious behavior or workarounds -- **Test git operations carefully**: Use `DOCS_CACHE_GIT_COMMAND` env var to override git path in tests -- **Preserve existing test patterns**: Follow the style and structure of existing test files -- **Check for regex gotchas**: Use non-greedy `.*?` instead of greedy `.*` when appropriate -- **Validate input limits**: Add safety checks (like `MAX_BRACE_EXPANSIONS`) for user-controlled expansion -- **Update documentation**: Ensure README reflects new features and limitations From 20242d4869879389df0feb13b1cc51cecef42872 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:33:29 +0100 Subject: [PATCH 07/12] fix(git): prevent brace overflow and update config --- src/git/fetch-source.ts | 8 ++++---- src/git/git-env.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index d900973..483290d 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -264,12 +264,12 @@ const expandBracePattern = (pattern: string): string[] => { const expand = (value: string) => { const braceMatch = value.match(/^(.*?){([^}]+)}(.*)$/); if (!braceMatch) { - results.push(value); - if (results.length > MAX_BRACE_EXPANSIONS) { + if (results.length >= MAX_BRACE_EXPANSIONS) { throw new Error( `Brace expansion exceeded ${MAX_BRACE_EXPANSIONS} patterns for '${pattern}'.`, ); } + results.push(value); return; } const [, prefix, values, suffix] = braceMatch; @@ -278,12 +278,12 @@ const expandBracePattern = (pattern: string): string[] => { .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); if (valueList.length === 0) { - results.push(value); - if (results.length > MAX_BRACE_EXPANSIONS) { + if (results.length >= MAX_BRACE_EXPANSIONS) { throw new Error( `Brace expansion exceeded ${MAX_BRACE_EXPANSIONS} patterns for '${pattern}'.`, ); } + results.push(value); return; } for (const entry of valueList) { diff --git a/src/git/git-env.ts b/src/git/git-env.ts index 13db5ec..47096b7 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -31,7 +31,7 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { NO_PROXY: process.env.NO_PROXY, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_NOGLOBAL: "1", + GIT_CONFIG_GLOBAL: "/dev/null", ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), }; }; From b26284bec858210195593bb0fbbe59048c889c63 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:37:21 +0100 Subject: [PATCH 08/12] fix(build): windows --- src/git/git-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git/git-env.ts b/src/git/git-env.ts index 47096b7..02c51f1 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -31,7 +31,7 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { NO_PROXY: process.env.NO_PROXY, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), }; }; From a0a1c5d272d03560f9bf742d4cbe29ece9c16b7d Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:41:48 +0100 Subject: [PATCH 09/12] fix: windows --- src/git/git-env.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/git/git-env.ts b/src/git/git-env.ts index 02c51f1..709d2e7 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -12,6 +12,12 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { const pathExtValue = process.env.PATHEXT ?? (process.platform === "win32" ? ".COM;.EXE;.BAT;.CMD" : undefined); + const gitConfigGlobal = + process.platform === "win32" + ? process.env.SYSTEMROOT + ? `${process.env.SYSTEMROOT}\\nul` + : "C:\\nul" + : "/dev/null"; return { ...process.env, ...(pathValue ? { PATH: pathValue, Path: pathValue } : {}), @@ -31,7 +37,7 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { NO_PROXY: process.env.NO_PROXY, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_GLOBAL: gitConfigGlobal, ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), }; }; From eeab34d58455f7406bab224c8d6c98a0bd6475e3 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:45:23 +0100 Subject: [PATCH 10/12] chore: adjust workflow --- .github/workflows/ci.yml | 50 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288205b..3f8fc03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,15 +46,59 @@ jobs: - name: Size limit run: pnpm size - build: + build-node-18: needs: precheck if: needs.precheck.result == 'success' runs-on: ${{ matrix.os }} strategy: - fail-fast: false + fail-fast: true matrix: os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest"]') }} - node-version: [18, 20, 22] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Audit dependencies + run: pnpm audit --audit-level=high + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Size limit + run: pnpm size + + build-node-20-and-22: + needs: build-node-18 + if: needs.build-node-18.result == 'success' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest"]') }} + node-version: [20, 22] steps: - name: Checkout uses: actions/checkout@v4 From 815dc6e32e06d4053fa9da9397e9160430c2ce75 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:48:33 +0100 Subject: [PATCH 11/12] fix: windows --- src/git/git-env.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/git/git-env.ts b/src/git/git-env.ts index 709d2e7..47096b7 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -12,12 +12,6 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { const pathExtValue = process.env.PATHEXT ?? (process.platform === "win32" ? ".COM;.EXE;.BAT;.CMD" : undefined); - const gitConfigGlobal = - process.platform === "win32" - ? process.env.SYSTEMROOT - ? `${process.env.SYSTEMROOT}\\nul` - : "C:\\nul" - : "/dev/null"; return { ...process.env, ...(pathValue ? { PATH: pathValue, Path: pathValue } : {}), @@ -37,7 +31,7 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { NO_PROXY: process.env.NO_PROXY, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: gitConfigGlobal, + GIT_CONFIG_GLOBAL: "/dev/null", ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), }; }; From 79929a6132ba121dab98c2564e5341c0378bbec1 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:57:25 +0100 Subject: [PATCH 12/12] refactor(git): replace execFile with execa --- src/git/git-env.ts | 1 - src/git/resolve-remote.ts | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/git/git-env.ts b/src/git/git-env.ts index 47096b7..3d6a7c4 100644 --- a/src/git/git-env.ts +++ b/src/git/git-env.ts @@ -31,7 +31,6 @@ const buildGitEnv = (): NodeJS.ProcessEnv => { NO_PROXY: process.env.NO_PROXY, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", ...(process.platform === "win32" ? {} : { GIT_ASKPASS: "/bin/false" }), }; }; diff --git a/src/git/resolve-remote.ts b/src/git/resolve-remote.ts index 16c4379..05c32cb 100644 --- a/src/git/resolve-remote.ts +++ b/src/git/resolve-remote.ts @@ -1,10 +1,7 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { execa } from "execa"; import { buildGitEnv, resolveGitCommand } from "#git/git-env"; import { redactRepoUrl } from "#git/redact"; -const execFileAsync = promisify(execFile); - const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds type ResolveRemoteParams = { @@ -84,7 +81,7 @@ export const resolveRemoteCommit = async (params: ResolveRemoteParams) => { const repoLabel = redactRepoUrl(params.repo); params.logger?.(`git ls-remote ${repoLabel} ${params.ref}`); - const { stdout } = await execFileAsync( + const { stdout } = await execa( resolveGitCommand(), ["ls-remote", params.repo, params.ref], {