From a1ee46cf8295c9eb69c18cc77457efd51cca2911 Mon Sep 17 00:00:00 2001 From: dmargol1 Date: Wed, 2 Sep 2026 20:05:23 -0500 Subject: [PATCH 1/5] Filter the stateless-install project copy using .gitignore Fixes #2269. When dataformCoreVersion is set in workflow_settings.yaml, compile() copies the whole project directory to a temporary directory before running `npm i` there. That copy had no filter, so its cost scaled with everything under the project root -- most commonly a large .venv or build-output directory sitting next to definitions/ -- rather than with the Dataform project itself. Adds buildProjectCopyFilter(), an fs-extra copySync filter built from the project's own root .gitignore (already written by `dataform init`, but not previously consulted anywhere in the compile pipeline), plus a small always-ignored floor of .git and node_modules that a .gitignore negation pattern cannot override. Using the project's .gitignore rather than a hardcoded list of directory names avoids having to enumerate every ecosystem's junk directories (.venv, target/, __pycache__/, vendor/, coverage/, ...), since a project's .gitignore already states what that project treats as disposable. Behavior change worth calling out in review: a gitignored file is no longer copied, so it is also no longer compiled. A project that generates definitions into a gitignored path now has to unignore that path. Verbose mode prints which exclusions are applied. Projects with no .gitignore are unaffected beyond the .git/node_modules floor. No lockfile regeneration is needed: `ignore` is already resolved in yarn.lock at 5.3.2 as an existing transitive dependency, and the declared ^5.2.0 range matches that entry. --- cli/api/BUILD | 2 + cli/api/commands/compile.ts | 6 +- cli/api/commands/compile_copy_filter.ts | 74 ++++++++++++ cli/api/commands/compile_copy_filter_test.ts | 121 +++++++++++++++++++ package.json | 1 + packages/@dataform/cli/BUILD | 1 + 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 cli/api/commands/compile_copy_filter.ts create mode 100644 cli/api/commands/compile_copy_filter_test.ts diff --git a/cli/api/BUILD b/cli/api/BUILD index 9b817c962..fa015c14e 100644 --- a/cli/api/BUILD +++ b/cli/api/BUILD @@ -41,6 +41,7 @@ ts_library( "@npm//fs-extra", "@npm//glob", "@npm//google-sql-syntax-ts", + "@npm//ignore", "@npm//js-beautify", "@npm//js-yaml", "@npm//promise-pool-executor", @@ -62,6 +63,7 @@ ts_test_suite( srcs = [ "tasks_test.ts", "utils_test.ts", + "commands/compile_copy_filter_test.ts", "commands/jit/rpc_test.ts", "commands/prune_test.ts", "dbadapters/bigquery_test.ts", diff --git a/cli/api/commands/compile.ts b/cli/api/commands/compile.ts index 8ef7c78c8..3d81a3b21 100644 --- a/cli/api/commands/compile.ts +++ b/cli/api/commands/compile.ts @@ -5,6 +5,7 @@ import * as tmp from "tmp"; import { promisify } from "util"; import { BaseWorker } from "df/cli/api/commands/base_worker"; +import { buildProjectCopyFilter } from "df/cli/api/commands/compile_copy_filter"; import { MISSING_CORE_VERSION_ERROR } from "df/cli/api/commands/install"; import { readConfigFromWorkflowSettings } from "df/cli/api/utils"; import { DEFAULT_COMPILATION_TIMEOUT_MILLIS } from "df/cli/api/utils/constants"; @@ -54,9 +55,12 @@ export async function compile( `Using isolated environment for @dataform/core@${workflowSettingsDataformCoreVersion}\n`, ); print(`Copying project to temporary directory: ${temporaryProjectPath}\n`); + print(`Excluding .git, node_modules, and paths matched by the project's .gitignore\n`); } const copyStartTime = performance.now(); - fs.copySync(resolvedProjectPath, temporaryProjectPath); + fs.copySync(resolvedProjectPath, temporaryProjectPath, { + filter: buildProjectCopyFilter(resolvedProjectPath) + }); if (compileConfig.verbose) { print(`Project copy completed in ${performance.now() - copyStartTime}ms\n`); } diff --git a/cli/api/commands/compile_copy_filter.ts b/cli/api/commands/compile_copy_filter.ts new file mode 100644 index 000000000..a03099cef --- /dev/null +++ b/cli/api/commands/compile_copy_filter.ts @@ -0,0 +1,74 @@ +import * as fs from "fs-extra"; +import ignore from "ignore"; +import * as path from "path"; + +// Excluded whatever the project's .gitignore says. `.git` holds no Dataform project +// files. A top-level `node_modules` can't be present at all here -- `compile()` rejects +// the project before copying if it finds one -- so that entry covers nested ones, which +// are likewise never part of a Dataform project. +// +// Checked independently of the `ignore` instance below, rather than seeded into it, so +// that a project's .gitignore cannot override this floor: `ignore` lets later patterns +// override earlier ones by design, so a `!node_modules` negation would otherwise +// un-ignore it. +const ALWAYS_IGNORED_NAMES = new Set([".git", "node_modules"]); + +/** + * Builds a filter for fs-extra's `copySync`, so the stateless-install copy in `compile()` + * skips files that can't be part of the Dataform project -- most commonly a large + * `.venv`, build-output or cache directory sitting alongside `definitions/`, whose size + * the copy would otherwise pay for. + * + * Exclusions come from the project's own `.gitignore` rather than from a hardcoded list + * of directory names: no fixed list covers every ecosystem's junk directories (`.venv`, + * `target/`, `__pycache__/`, `vendor/`, `coverage/`, ...), whereas a project's + * `.gitignore` already states exactly what that project treats as disposable, and + * `dataform init` writes one. + * + * Only the project root's `.gitignore` is read. Nested `.gitignore` files, + * `.git/info/exclude` and the user's global excludes file are not consulted, so a + * project relying on those has more copied than `git status` would suggest. A project + * with no `.gitignore` at all gets only the ALWAYS_IGNORED_NAMES floor. + * + * Note that a gitignored file is never copied, so it is also never compiled: a project + * that generates definitions into a gitignored path needs that path unignored. + */ +export function buildProjectCopyFilter(resolvedProjectPath: string): (src: string) => boolean { + const ig = ignore(); + const gitignorePath = path.join(resolvedProjectPath, ".gitignore"); + if (fs.existsSync(gitignorePath)) { + ig.add(fs.readFileSync(gitignorePath, "utf8")); + } + + return (src: string) => { + const relative = path.relative(resolvedProjectPath, src); + // The project root itself (relative === ""), or something outside the project + // root (shouldn't happen in practice for a copySync(resolvedProjectPath, ...) + // call, but not this function's place to decide) is always copied/recursed into. + if ( + !relative || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return true; + } + + const relativeSegments = relative.split(path.sep); + if (relativeSegments.some(segment => ALWAYS_IGNORED_NAMES.has(segment))) { + return false; + } + + // `ignore` needs to know whether a path is a directory to correctly match + // patterns like `.venv/` (trailing slash = directories only), and fs-extra's + // copySync filter callback isn't given that -- only `src`. Use lstatSync so + // dangling symlinks remain copyable, matching copySync's default behavior of + // copying links rather than dereferencing them. + let posixRelative = relativeSegments.join("/"); + if (fs.lstatSync(src).isDirectory()) { + posixRelative += "/"; + } + + return !ig.ignores(posixRelative); + }; +} diff --git a/cli/api/commands/compile_copy_filter_test.ts b/cli/api/commands/compile_copy_filter_test.ts new file mode 100644 index 000000000..98eb82e3a --- /dev/null +++ b/cli/api/commands/compile_copy_filter_test.ts @@ -0,0 +1,121 @@ +import { expect } from "chai"; +import * as fs from "fs-extra"; +import * as path from "path"; + +import { buildProjectCopyFilter } from "df/cli/api/commands/compile_copy_filter"; +import { suite, test } from "df/testing"; +import { TmpDirFixture } from "df/testing/fixtures"; + +suite("buildProjectCopyFilter", ({ afterEach }) => { + const tmpDirFixture = new TmpDirFixture(afterEach); + + test("with no .gitignore, only .git and node_modules are excluded", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, "definitions")); + fs.writeFileSync(path.join(projectDir, "definitions", "foo.sqlx"), "SELECT 1"); + fs.ensureDirSync(path.join(projectDir, ".venv")); + fs.ensureDirSync(path.join(projectDir, ".git")); + fs.ensureDirSync(path.join(projectDir, "node_modules")); + fs.ensureDirSync(path.join(projectDir, "definitions", "nested", "node_modules")); + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(projectDir)).to.equal(true); + expect(filter(path.join(projectDir, "definitions"))).to.equal(true); + expect(filter(path.join(projectDir, "definitions", "foo.sqlx"))).to.equal(true); + expect(filter(path.join(projectDir, ".venv"))).to.equal(true); + + expect(filter(path.join(projectDir, ".git"))).to.equal(false); + expect(filter(path.join(projectDir, "node_modules"))).to.equal(false); + // Excluded at any depth, not just at the project root. + expect(filter(path.join(projectDir, "definitions", "nested", "node_modules"))).to.equal(false); + }); + + test("does not dereference symlinks while filtering", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const danglingSymlink = path.join(projectDir, "dangling-link"); + fs.symlinkSync(path.join(projectDir, "missing-target"), danglingSymlink); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(danglingSymlink)).to.equal(true); + }); + + test("applies ignore rules to in-project paths beginning with two dots", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const ignoredDir = path.join(projectDir, "..cache"); + fs.ensureDirSync(ignoredDir); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "..cache/\n"); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(ignoredDir)).to.equal(false); + }); + + test("the always-ignored floor cannot be overridden by a negation pattern", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, "node_modules")); + // A project .gitignore is user-controlled and could (unusually, but validly) + // contain a negation pattern for something we always want to exclude. + fs.writeFileSync(path.join(projectDir, ".gitignore"), "!node_modules\n"); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(path.join(projectDir, "node_modules"))).to.equal(false); + }); + + test("filters an actual project copy", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const destinationDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, "definitions")); + fs.writeFileSync(path.join(projectDir, "definitions", "foo.sqlx"), "SELECT 1"); + fs.ensureDirSync(path.join(projectDir, ".venv")); + fs.writeFileSync(path.join(projectDir, ".venv", "ignored"), "junk"); + fs.ensureDirSync(path.join(projectDir, "node_modules")); + fs.writeFileSync(path.join(projectDir, "node_modules", "ignored"), "junk"); + fs.writeFileSync(path.join(projectDir, ".gitignore"), ".venv/\n"); + + fs.copySync(projectDir, destinationDir, { + filter: buildProjectCopyFilter(projectDir) + }); + + expect(fs.readFileSync(path.join(destinationDir, "definitions", "foo.sqlx"), "utf8")).to.equal( + "SELECT 1" + ); + expect(fs.existsSync(path.join(destinationDir, ".venv"))).to.equal(false); + expect(fs.existsSync(path.join(destinationDir, "node_modules"))).to.equal(false); + }); + + test("respects a project .gitignore, in addition to the always-ignored floor", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync( + path.join(projectDir, ".gitignore"), + [".venv/", "__pycache__/", "*.pyc"].join("\n") + ); + fs.ensureDirSync(path.join(projectDir, ".venv", "lib")); + fs.writeFileSync(path.join(projectDir, ".venv", "lib", "mod.py"), "# stub"); + fs.ensureDirSync(path.join(projectDir, "definitions")); + fs.writeFileSync(path.join(projectDir, "definitions", "foo.sqlx"), "SELECT 1"); + fs.writeFileSync(path.join(projectDir, "foo.pyc"), "junk"); + fs.ensureDirSync(path.join(projectDir, ".git")); + fs.ensureDirSync(path.join(projectDir, "node_modules")); + + const filter = buildProjectCopyFilter(projectDir); + + // Dataform-relevant paths are still copied. + expect(filter(projectDir)).to.equal(true); + expect(filter(path.join(projectDir, "definitions"))).to.equal(true); + expect(filter(path.join(projectDir, "definitions", "foo.sqlx"))).to.equal(true); + expect(filter(path.join(projectDir, ".gitignore"))).to.equal(true); + + // gitignore'd paths are excluded -- including the bare directory itself (not + // just its contents), which requires correctly detecting it as a directory to + // match a trailing-slash-only pattern like `.venv/`. + expect(filter(path.join(projectDir, ".venv"))).to.equal(false); + expect(filter(path.join(projectDir, ".venv", "lib", "mod.py"))).to.equal(false); + expect(filter(path.join(projectDir, "foo.pyc"))).to.equal(false); + + // The always-ignored floor still applies even when a .gitignore is present. + expect(filter(path.join(projectDir, ".git"))).to.equal(false); + expect(filter(path.join(projectDir, "node_modules"))).to.equal(false); + }); +}); diff --git a/package.json b/package.json index 1000b614b..aba30509c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "fs-extra": "^9.0.0", "glob": "13.0.6", "google-sql-syntax-ts": "^1.0.3", + "ignore": "^5.2.0", "js-beautify": "1.15.4", "js-yaml": "^4.3.2", "long": "^4.0.0", diff --git a/packages/@dataform/cli/BUILD b/packages/@dataform/cli/BUILD index 31309c581..b625504f0 100644 --- a/packages/@dataform/cli/BUILD +++ b/packages/@dataform/cli/BUILD @@ -38,6 +38,7 @@ externals = [ "fs-extra", "glob", "google-sql-syntax-ts", + "ignore", "js-beautify", "js-yaml", "moo", From f7f3208d9ba085eaef72efcf35c85774416a09a6 Mon Sep 17 00:00:00 2001 From: dmargol1 Date: Thu, 24 Sep 2026 18:19:39 -0500 Subject: [PATCH 2/5] Address review: .dataformignore support and accurate verbose log - Read an optional .dataformignore from the project root after the .gitignore, into the same ignore instance. It can exclude further paths, or un-ignore gitignored ones with `!pattern` (for example, definitions generated into a gitignored directory). The .git and node_modules floor still cannot be overridden by either file. - The verbose log now names the ignore files actually found, and no longer mentions a .gitignore when the project has none. - Reformat the new files with the repo's Prettier 3 config. --- cli/api/commands/compile.ts | 14 +++- cli/api/commands/compile_copy_filter.ts | 39 +++++++--- cli/api/commands/compile_copy_filter_test.ts | 76 ++++++++++++++++++-- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/cli/api/commands/compile.ts b/cli/api/commands/compile.ts index 3d81a3b21..5a193afd2 100644 --- a/cli/api/commands/compile.ts +++ b/cli/api/commands/compile.ts @@ -5,7 +5,10 @@ import * as tmp from "tmp"; import { promisify } from "util"; import { BaseWorker } from "df/cli/api/commands/base_worker"; -import { buildProjectCopyFilter } from "df/cli/api/commands/compile_copy_filter"; +import { + buildProjectCopyFilter, + findProjectIgnoreFiles, +} from "df/cli/api/commands/compile_copy_filter"; import { MISSING_CORE_VERSION_ERROR } from "df/cli/api/commands/install"; import { readConfigFromWorkflowSettings } from "df/cli/api/utils"; import { DEFAULT_COMPILATION_TIMEOUT_MILLIS } from "df/cli/api/utils/constants"; @@ -55,11 +58,16 @@ export async function compile( `Using isolated environment for @dataform/core@${workflowSettingsDataformCoreVersion}\n`, ); print(`Copying project to temporary directory: ${temporaryProjectPath}\n`); - print(`Excluding .git, node_modules, and paths matched by the project's .gitignore\n`); + const ignoreFiles = findProjectIgnoreFiles(resolvedProjectPath); + print( + ignoreFiles.length > 0 + ? `Excluding .git, node_modules, and paths matched by: ${ignoreFiles.join(", ")}\n` + : `Excluding .git and node_modules (no .gitignore or .dataformignore in project root)\n`, + ); } const copyStartTime = performance.now(); fs.copySync(resolvedProjectPath, temporaryProjectPath, { - filter: buildProjectCopyFilter(resolvedProjectPath) + filter: buildProjectCopyFilter(resolvedProjectPath), }); if (compileConfig.verbose) { print(`Project copy completed in ${performance.now() - copyStartTime}ms\n`); diff --git a/cli/api/commands/compile_copy_filter.ts b/cli/api/commands/compile_copy_filter.ts index a03099cef..447b0be1a 100644 --- a/cli/api/commands/compile_copy_filter.ts +++ b/cli/api/commands/compile_copy_filter.ts @@ -2,17 +2,32 @@ import * as fs from "fs-extra"; import ignore from "ignore"; import * as path from "path"; -// Excluded whatever the project's .gitignore says. `.git` holds no Dataform project +// Excluded whatever the project's ignore files say. `.git` holds no Dataform project // files. A top-level `node_modules` can't be present at all here -- `compile()` rejects // the project before copying if it finds one -- so that entry covers nested ones, which // are likewise never part of a Dataform project. // // Checked independently of the `ignore` instance below, rather than seeded into it, so -// that a project's .gitignore cannot override this floor: `ignore` lets later patterns +// that a project's ignore files cannot override this floor: `ignore` lets later patterns // override earlier ones by design, so a `!node_modules` negation would otherwise // un-ignore it. const ALWAYS_IGNORED_NAMES = new Set([".git", "node_modules"]); +// Ignore files read from the project root, in this order. Later patterns override earlier +// ones, so a `!pattern` in `.dataformignore` can un-ignore a path the `.gitignore` excludes +// (for example, definitions generated into a gitignored directory). +export const PROJECT_IGNORE_FILE_NAMES = [".gitignore", ".dataformignore"]; + +/** + * Returns the names of the PROJECT_IGNORE_FILE_NAMES present in the project root, in the + * order they are applied. + */ +export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { + return PROJECT_IGNORE_FILE_NAMES.filter((name) => + fs.existsSync(path.join(resolvedProjectPath, name)), + ); +} + /** * Builds a filter for fs-extra's `copySync`, so the stateless-install copy in `compile()` * skips files that can't be part of the Dataform project -- most commonly a large @@ -23,21 +38,23 @@ const ALWAYS_IGNORED_NAMES = new Set([".git", "node_modules"]); * of directory names: no fixed list covers every ecosystem's junk directories (`.venv`, * `target/`, `__pycache__/`, `vendor/`, `coverage/`, ...), whereas a project's * `.gitignore` already states exactly what that project treats as disposable, and - * `dataform init` writes one. + * `dataform init` writes one. An optional `.dataformignore`, in the same syntax, is + * applied on top of it: it can exclude further paths, or un-ignore gitignored ones with + * `!pattern`. * - * Only the project root's `.gitignore` is read. Nested `.gitignore` files, + * Only ignore files in the project root are read. Nested `.gitignore` files, * `.git/info/exclude` and the user's global excludes file are not consulted, so a * project relying on those has more copied than `git status` would suggest. A project - * with no `.gitignore` at all gets only the ALWAYS_IGNORED_NAMES floor. + * with neither file gets only the ALWAYS_IGNORED_NAMES floor. * - * Note that a gitignored file is never copied, so it is also never compiled: a project - * that generates definitions into a gitignored path needs that path unignored. + * Note that an ignored file is never copied, so it is also never compiled: a project + * that generates definitions into a gitignored path needs that path unignored, in + * either file. */ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: string) => boolean { const ig = ignore(); - const gitignorePath = path.join(resolvedProjectPath, ".gitignore"); - if (fs.existsSync(gitignorePath)) { - ig.add(fs.readFileSync(gitignorePath, "utf8")); + for (const name of findProjectIgnoreFiles(resolvedProjectPath)) { + ig.add(fs.readFileSync(path.join(resolvedProjectPath, name), "utf8")); } return (src: string) => { @@ -55,7 +72,7 @@ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: strin } const relativeSegments = relative.split(path.sep); - if (relativeSegments.some(segment => ALWAYS_IGNORED_NAMES.has(segment))) { + if (relativeSegments.some((segment) => ALWAYS_IGNORED_NAMES.has(segment))) { return false; } diff --git a/cli/api/commands/compile_copy_filter_test.ts b/cli/api/commands/compile_copy_filter_test.ts index 98eb82e3a..edb3090f9 100644 --- a/cli/api/commands/compile_copy_filter_test.ts +++ b/cli/api/commands/compile_copy_filter_test.ts @@ -2,7 +2,10 @@ import { expect } from "chai"; import * as fs from "fs-extra"; import * as path from "path"; -import { buildProjectCopyFilter } from "df/cli/api/commands/compile_copy_filter"; +import { + buildProjectCopyFilter, + findProjectIgnoreFiles, +} from "df/cli/api/commands/compile_copy_filter"; import { suite, test } from "df/testing"; import { TmpDirFixture } from "df/testing/fixtures"; @@ -75,11 +78,11 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { fs.writeFileSync(path.join(projectDir, ".gitignore"), ".venv/\n"); fs.copySync(projectDir, destinationDir, { - filter: buildProjectCopyFilter(projectDir) + filter: buildProjectCopyFilter(projectDir), }); expect(fs.readFileSync(path.join(destinationDir, "definitions", "foo.sqlx"), "utf8")).to.equal( - "SELECT 1" + "SELECT 1", ); expect(fs.existsSync(path.join(destinationDir, ".venv"))).to.equal(false); expect(fs.existsSync(path.join(destinationDir, "node_modules"))).to.equal(false); @@ -89,7 +92,7 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { const projectDir = tmpDirFixture.createNewTmpDir(); fs.writeFileSync( path.join(projectDir, ".gitignore"), - [".venv/", "__pycache__/", "*.pyc"].join("\n") + [".venv/", "__pycache__/", "*.pyc"].join("\n"), ); fs.ensureDirSync(path.join(projectDir, ".venv", "lib")); fs.writeFileSync(path.join(projectDir, ".venv", "lib", "mod.py"), "# stub"); @@ -118,4 +121,69 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { expect(filter(path.join(projectDir, ".git"))).to.equal(false); expect(filter(path.join(projectDir, "node_modules"))).to.equal(false); }); + + test("a .dataformignore excludes paths on its own, with no .gitignore", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".dataformignore"), "scratch/\n"); + fs.ensureDirSync(path.join(projectDir, "scratch")); + fs.ensureDirSync(path.join(projectDir, "definitions")); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(path.join(projectDir, "scratch"))).to.equal(false); + expect(filter(path.join(projectDir, "definitions"))).to.equal(true); + }); + + test("a .dataformignore supplements the .gitignore and can un-ignore its paths", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const destinationDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync( + path.join(projectDir, ".gitignore"), + [".venv/", "definitions/generated/"].join("\n"), + ); + fs.writeFileSync( + path.join(projectDir, ".dataformignore"), + ["docs/", "!definitions/generated/"].join("\n"), + ); + fs.ensureDirSync(path.join(projectDir, ".venv")); + fs.writeFileSync(path.join(projectDir, ".venv", "ignored"), "junk"); + fs.ensureDirSync(path.join(projectDir, "docs")); + fs.writeFileSync(path.join(projectDir, "docs", "ignored.md"), "junk"); + fs.ensureDirSync(path.join(projectDir, "definitions", "generated")); + fs.writeFileSync(path.join(projectDir, "definitions", "generated", "gen.sqlx"), "SELECT 1"); + + fs.copySync(projectDir, destinationDir, { + filter: buildProjectCopyFilter(projectDir), + }); + + // Still excluded by the .gitignore. + expect(fs.existsSync(path.join(destinationDir, ".venv"))).to.equal(false); + // Excluded by the .dataformignore. + expect(fs.existsSync(path.join(destinationDir, "docs"))).to.equal(false); + // Gitignored, but un-ignored by the .dataformignore. + expect( + fs.readFileSync(path.join(destinationDir, "definitions", "generated", "gen.sqlx"), "utf8"), + ).to.equal("SELECT 1"); + }); + + test("a .dataformignore cannot override the always-ignored floor", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, "node_modules")); + fs.writeFileSync(path.join(projectDir, ".dataformignore"), "!node_modules\n"); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(path.join(projectDir, "node_modules"))).to.equal(false); + }); + + test("findProjectIgnoreFiles lists the ignore files present, in application order", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([]); + + fs.writeFileSync(path.join(projectDir, ".dataformignore"), ""); + expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([".dataformignore"]); + + fs.writeFileSync(path.join(projectDir, ".gitignore"), ""); + expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([".gitignore", ".dataformignore"]); + }); }); From 3df06a6651f2420fc71ecfeebfbd91901a36de22 Mon Sep 17 00:00:00 2001 From: dmargol1 Date: Thu, 24 Sep 2026 18:49:48 -0500 Subject: [PATCH 3/5] Match ignore patterns case-sensitively; document negation limits - ignore() matches case-insensitively by default, so on a case-sensitive filesystem a `definitions/staging/` pattern also dropped `definitions/Staging/table.sqlx`, which git keeps. Always match case-sensitively: at worst this copies a little extra on case-insensitive filesystems, and never drops a definition. - Document that, as in git, a file can't be re-included while an ancestor directory is still excluded, with a concrete example, and test the child-only negation case. --- cli/api/commands/compile_copy_filter.ts | 13 ++++++-- cli/api/commands/compile_copy_filter_test.ts | 32 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/cli/api/commands/compile_copy_filter.ts b/cli/api/commands/compile_copy_filter.ts index 447b0be1a..6748b84c1 100644 --- a/cli/api/commands/compile_copy_filter.ts +++ b/cli/api/commands/compile_copy_filter.ts @@ -49,10 +49,19 @@ export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { * * Note that an ignored file is never copied, so it is also never compiled: a project * that generates definitions into a gitignored path needs that path unignored, in - * either file. + * either file. As in git, a file can't be re-included while any ancestor directory is + * still excluded -- the copy never descends into that directory -- so each excluded + * ancestor must be un-ignored too. With `definitions/generated/` in `.gitignore`, + * `!definitions/generated/gen.sqlx` alone has no effect; `!definitions/generated/` + * restores the directory. + * + * Matching is always case-sensitive, even on case-insensitive filesystems where git's + * `core.ignorecase` would be set. That can only under-exclude: a pattern like + * `definitions/staging/` must never drop `definitions/Staging/table.sqlx`, which git on + * a case-sensitive filesystem treats as tracked. */ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: string) => boolean { - const ig = ignore(); + const ig = ignore({ ignorecase: false }); for (const name of findProjectIgnoreFiles(resolvedProjectPath)) { ig.add(fs.readFileSync(path.join(resolvedProjectPath, name), "utf8")); } diff --git a/cli/api/commands/compile_copy_filter_test.ts b/cli/api/commands/compile_copy_filter_test.ts index edb3090f9..2f29b5a78 100644 --- a/cli/api/commands/compile_copy_filter_test.ts +++ b/cli/api/commands/compile_copy_filter_test.ts @@ -186,4 +186,36 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { fs.writeFileSync(path.join(projectDir, ".gitignore"), ""); expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([".gitignore", ".dataformignore"]); }); + + test("matches ignore patterns case-sensitively", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const destinationDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "definitions/staging/\n"); + fs.ensureDirSync(path.join(projectDir, "definitions", "Staging")); + fs.writeFileSync(path.join(projectDir, "definitions", "Staging", "table.sqlx"), "SELECT 1"); + + fs.copySync(projectDir, destinationDir, { + filter: buildProjectCopyFilter(projectDir), + }); + + expect( + fs.readFileSync(path.join(destinationDir, "definitions", "Staging", "table.sqlx"), "utf8"), + ).to.equal("SELECT 1"); + }); + + test("un-ignoring only a file does not re-include it while its directory is ignored", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const destinationDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "definitions/generated/\n"); + fs.writeFileSync(path.join(projectDir, ".dataformignore"), "!definitions/generated/gen.sqlx\n"); + fs.ensureDirSync(path.join(projectDir, "definitions", "generated")); + fs.writeFileSync(path.join(projectDir, "definitions", "generated", "gen.sqlx"), "SELECT 1"); + + fs.copySync(projectDir, destinationDir, { + filter: buildProjectCopyFilter(projectDir), + }); + + // Matches git: the excluded parent directory must itself be un-ignored. + expect(fs.existsSync(path.join(destinationDir, "definitions", "generated"))).to.equal(false); + }); }); From e970ba4cb2234571ad5201c1ef1462fdd44d723b Mon Sep 17 00:00:00 2001 From: dmargol1 Date: Thu, 24 Sep 2026 18:51:26 -0500 Subject: [PATCH 4/5] Harden the copy filter's fixed rules and ignore-file loading - Compare the always-ignored .git/node_modules names case-insensitively, so .GIT or NODE_MODULES on a case-insensitive filesystem is excluded. - Always copy the root workflow_settings.yaml. compile() has already read it from the original project, and compilation can't proceed without it, so a broad pattern like `*.yaml` must not drop it. - Skip a .gitignore/.dataformignore that isn't a regular file, instead of failing the compile with EISDIR. - Document that patterns are evaluated without consulting git's index, so force-added tracked files matching a pattern are still excluded. --- cli/api/commands/compile_copy_filter.ts | 31 ++++++++++++--- cli/api/commands/compile_copy_filter_test.ts | 40 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/cli/api/commands/compile_copy_filter.ts b/cli/api/commands/compile_copy_filter.ts index 6748b84c1..38c9263bf 100644 --- a/cli/api/commands/compile_copy_filter.ts +++ b/cli/api/commands/compile_copy_filter.ts @@ -11,21 +11,32 @@ import * as path from "path"; // that a project's ignore files cannot override this floor: `ignore` lets later patterns // override earlier ones by design, so a `!node_modules` negation would otherwise // un-ignore it. +// +// Compared case-insensitively: on a case-insensitive filesystem `.GIT` or `NODE_MODULES` +// is the same directory, and on a case-sensitive one excluding them anyway costs nothing. const ALWAYS_IGNORED_NAMES = new Set([".git", "node_modules"]); +// Project-root files that are always copied, whatever the project's ignore files say. +// `compile()` has already read `workflow_settings.yaml` from the original project to +// decide on a stateless install, and compilation in the copy can't proceed without it, +// so a broad pattern like `*.yaml` must not drop it. +const ALWAYS_COPIED_ROOT_FILES = new Set(["workflow_settings.yaml"]); + // Ignore files read from the project root, in this order. Later patterns override earlier // ones, so a `!pattern` in `.dataformignore` can un-ignore a path the `.gitignore` excludes // (for example, definitions generated into a gitignored directory). export const PROJECT_IGNORE_FILE_NAMES = [".gitignore", ".dataformignore"]; /** - * Returns the names of the PROJECT_IGNORE_FILE_NAMES present in the project root, in the - * order they are applied. + * Returns the names of the PROJECT_IGNORE_FILE_NAMES present in the project root as + * files, in the order they are applied. Anything else by that name, such as a directory, + * is not an ignore file and is skipped rather than failing the compile. */ export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { - return PROJECT_IGNORE_FILE_NAMES.filter((name) => - fs.existsSync(path.join(resolvedProjectPath, name)), - ); + return PROJECT_IGNORE_FILE_NAMES.filter((name) => { + const ignoreFilePath = path.join(resolvedProjectPath, name); + return fs.existsSync(ignoreFilePath) && fs.statSync(ignoreFilePath).isFile(); + }); } /** @@ -59,6 +70,10 @@ export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { * `core.ignorecase` would be set. That can only under-exclude: a pattern like * `definitions/staging/` must never drop `definitions/Staging/table.sqlx`, which git on * a case-sensitive filesystem treats as tracked. + * + * Patterns are evaluated on their own, without consulting git's index, so a file git + * still tracks despite matching a pattern (for example, one force-added with + * `git add -f`) is excluded all the same. The exception is ALWAYS_COPIED_ROOT_FILES. */ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: string) => boolean { const ig = ignore({ ignorecase: false }); @@ -80,8 +95,12 @@ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: strin return true; } + if (ALWAYS_COPIED_ROOT_FILES.has(relative)) { + return true; + } + const relativeSegments = relative.split(path.sep); - if (relativeSegments.some((segment) => ALWAYS_IGNORED_NAMES.has(segment))) { + if (relativeSegments.some((segment) => ALWAYS_IGNORED_NAMES.has(segment.toLowerCase()))) { return false; } diff --git a/cli/api/commands/compile_copy_filter_test.ts b/cli/api/commands/compile_copy_filter_test.ts index 2f29b5a78..5ecc1d3f8 100644 --- a/cli/api/commands/compile_copy_filter_test.ts +++ b/cli/api/commands/compile_copy_filter_test.ts @@ -218,4 +218,44 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { // Matches git: the excluded parent directory must itself be un-ignored. expect(fs.existsSync(path.join(destinationDir, "definitions", "generated"))).to.equal(false); }); + + test("the always-ignored floor matches names case-insensitively", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, ".GIT")); + fs.ensureDirSync(path.join(projectDir, "definitions", "NODE_MODULES")); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "!.GIT\n!NODE_MODULES\n"); + + const filter = buildProjectCopyFilter(projectDir); + + expect(filter(path.join(projectDir, ".GIT"))).to.equal(false); + expect(filter(path.join(projectDir, "definitions", "NODE_MODULES"))).to.equal(false); + }); + + test("workflow_settings.yaml is always copied, even when an ignore file matches it", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + const destinationDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "*.yaml\n"); + fs.writeFileSync(path.join(projectDir, "workflow_settings.yaml"), "defaultProject: p\n"); + fs.writeFileSync(path.join(projectDir, "other.yaml"), "junk"); + + fs.copySync(projectDir, destinationDir, { + filter: buildProjectCopyFilter(projectDir), + }); + + expect(fs.readFileSync(path.join(destinationDir, "workflow_settings.yaml"), "utf8")).to.equal( + "defaultProject: p\n", + ); + expect(fs.existsSync(path.join(destinationDir, "other.yaml"))).to.equal(false); + }); + + test("an ignore file name that is a directory is skipped, not read", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.ensureDirSync(path.join(projectDir, ".dataformignore")); + fs.writeFileSync(path.join(projectDir, ".gitignore"), ".venv/\n"); + fs.ensureDirSync(path.join(projectDir, ".venv")); + + expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([".gitignore"]); + const filter = buildProjectCopyFilter(projectDir); + expect(filter(path.join(projectDir, ".venv"))).to.equal(false); + }); }); From 2a61411f8957e03958aaf339d72dc0c138da7056 Mon Sep 17 00:00:00 2001 From: dmargol1 Date: Thu, 24 Sep 2026 18:55:29 -0500 Subject: [PATCH 5/5] Follow the filesystem's case sensitivity in the copy filter A fixed case policy was wrong in both directions: always-sensitive matching let a mixed-case negation miss its file on a case-insensitive filesystem, and always-folding the .git/node_modules floor excluded a legitimate NODE_MODULES directory on Linux. Detect once whether the project directory is case-insensitive (probing an existing entry under its case-swapped name, falling back to the platform default), and apply that to the ignore matcher, the always-ignored floor and the workflow_settings.yaml allowlist, as git does with core.ignorecase. Tests pin each mode explicitly so they behave the same on every platform. --- cli/api/commands/compile_copy_filter.ts | 49 ++++++++++--- cli/api/commands/compile_copy_filter_test.ts | 76 ++++++++++++++++++-- 2 files changed, 108 insertions(+), 17 deletions(-) diff --git a/cli/api/commands/compile_copy_filter.ts b/cli/api/commands/compile_copy_filter.ts index 38c9263bf..e31825ac4 100644 --- a/cli/api/commands/compile_copy_filter.ts +++ b/cli/api/commands/compile_copy_filter.ts @@ -11,9 +11,6 @@ import * as path from "path"; // that a project's ignore files cannot override this floor: `ignore` lets later patterns // override earlier ones by design, so a `!node_modules` negation would otherwise // un-ignore it. -// -// Compared case-insensitively: on a case-insensitive filesystem `.GIT` or `NODE_MODULES` -// is the same directory, and on a case-sensitive one excluding them anyway costs nothing. const ALWAYS_IGNORED_NAMES = new Set([".git", "node_modules"]); // Project-root files that are always copied, whatever the project's ignore files say. @@ -39,6 +36,30 @@ export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { }); } +function swapCase(name: string): string { + return name + .split("") + .map((c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())) + .join(""); +} + +/** + * Returns whether names in `directory` are matched case-insensitively by its filesystem, + * as on default Windows and macOS volumes. It probes an existing entry under its + * case-swapped name: if that resolves but isn't itself listed, it's the same entry. + * Falls back to the platform default when no entry has a name with letters. + */ +export function isCaseInsensitiveDirectory(directory: string): boolean { + const entries = fs.readdirSync(directory); + for (const entry of entries) { + const swapped = swapCase(entry); + if (swapped !== entry) { + return !entries.includes(swapped) && fs.existsSync(path.join(directory, swapped)); + } + } + return process.platform === "win32" || process.platform === "darwin"; +} + /** * Builds a filter for fs-extra's `copySync`, so the stateless-install copy in `compile()` * skips files that can't be part of the Dataform project -- most commonly a large @@ -66,17 +87,23 @@ export function findProjectIgnoreFiles(resolvedProjectPath: string): string[] { * `!definitions/generated/gen.sqlx` alone has no effect; `!definitions/generated/` * restores the directory. * - * Matching is always case-sensitive, even on case-insensitive filesystems where git's - * `core.ignorecase` would be set. That can only under-exclude: a pattern like - * `definitions/staging/` must never drop `definitions/Staging/table.sqlx`, which git on - * a case-sensitive filesystem treats as tracked. + * Names are matched with the case sensitivity of the project's filesystem, as git does + * with `core.ignorecase`: on a case-sensitive one, `definitions/staging/` doesn't match + * `definitions/Staging/`, and `NODE_MODULES` is an ordinary directory; on a + * case-insensitive one, both patterns and negations match regardless of case. That + * applies to the ALWAYS_IGNORED_NAMES and ALWAYS_COPIED_ROOT_FILES checks too. + * `caseInsensitive` is detected from the project directory unless given. * * Patterns are evaluated on their own, without consulting git's index, so a file git * still tracks despite matching a pattern (for example, one force-added with * `git add -f`) is excluded all the same. The exception is ALWAYS_COPIED_ROOT_FILES. */ -export function buildProjectCopyFilter(resolvedProjectPath: string): (src: string) => boolean { - const ig = ignore({ ignorecase: false }); +export function buildProjectCopyFilter( + resolvedProjectPath: string, + caseInsensitive = isCaseInsensitiveDirectory(resolvedProjectPath), +): (src: string) => boolean { + const normalizeCase = (name: string) => (caseInsensitive ? name.toLowerCase() : name); + const ig = ignore({ ignorecase: caseInsensitive }); for (const name of findProjectIgnoreFiles(resolvedProjectPath)) { ig.add(fs.readFileSync(path.join(resolvedProjectPath, name), "utf8")); } @@ -95,12 +122,12 @@ export function buildProjectCopyFilter(resolvedProjectPath: string): (src: strin return true; } - if (ALWAYS_COPIED_ROOT_FILES.has(relative)) { + if (ALWAYS_COPIED_ROOT_FILES.has(normalizeCase(relative))) { return true; } const relativeSegments = relative.split(path.sep); - if (relativeSegments.some((segment) => ALWAYS_IGNORED_NAMES.has(segment.toLowerCase()))) { + if (relativeSegments.some((segment) => ALWAYS_IGNORED_NAMES.has(normalizeCase(segment)))) { return false; } diff --git a/cli/api/commands/compile_copy_filter_test.ts b/cli/api/commands/compile_copy_filter_test.ts index 5ecc1d3f8..3dafcacb5 100644 --- a/cli/api/commands/compile_copy_filter_test.ts +++ b/cli/api/commands/compile_copy_filter_test.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { buildProjectCopyFilter, findProjectIgnoreFiles, + isCaseInsensitiveDirectory, } from "df/cli/api/commands/compile_copy_filter"; import { suite, test } from "df/testing"; import { TmpDirFixture } from "df/testing/fixtures"; @@ -187,7 +188,7 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { expect(findProjectIgnoreFiles(projectDir)).to.deep.equal([".gitignore", ".dataformignore"]); }); - test("matches ignore patterns case-sensitively", () => { + test("on a case-sensitive filesystem, patterns only match their exact case", () => { const projectDir = tmpDirFixture.createNewTmpDir(); const destinationDir = tmpDirFixture.createNewTmpDir(); fs.writeFileSync(path.join(projectDir, ".gitignore"), "definitions/staging/\n"); @@ -195,7 +196,7 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { fs.writeFileSync(path.join(projectDir, "definitions", "Staging", "table.sqlx"), "SELECT 1"); fs.copySync(projectDir, destinationDir, { - filter: buildProjectCopyFilter(projectDir), + filter: buildProjectCopyFilter(projectDir, false), }); expect( @@ -219,16 +220,52 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { expect(fs.existsSync(path.join(destinationDir, "definitions", "generated"))).to.equal(false); }); - test("the always-ignored floor matches names case-insensitively", () => { + test("on a case-insensitive filesystem, patterns and negations ignore case", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "definitions/staging/\n*.sqlx\n"); + fs.writeFileSync(path.join(projectDir, ".dataformignore"), "!definitions/Keep.sqlx\n"); + fs.ensureDirSync(path.join(projectDir, "definitions", "Staging")); + fs.writeFileSync(path.join(projectDir, "definitions", "keep.sqlx"), "SELECT 1"); + fs.writeFileSync(path.join(projectDir, "definitions", "drop.sqlx"), "SELECT 1"); + + const filter = buildProjectCopyFilter(projectDir, true); + + expect(filter(path.join(projectDir, "definitions", "Staging"))).to.equal(false); + expect(filter(path.join(projectDir, "definitions", "keep.sqlx"))).to.equal(true); + expect(filter(path.join(projectDir, "definitions", "drop.sqlx"))).to.equal(false); + }); + + test("on a case-sensitive filesystem, negations only match their exact case", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "*.sqlx\n!definitions/Keep.sqlx\n"); + fs.ensureDirSync(path.join(projectDir, "definitions")); + fs.writeFileSync(path.join(projectDir, "definitions", "keep.sqlx"), "SELECT 1"); + fs.writeFileSync(path.join(projectDir, "definitions", "Keep.sqlx"), "SELECT 1"); + + const filter = buildProjectCopyFilter(projectDir, false); + + expect(filter(path.join(projectDir, "definitions", "Keep.sqlx"))).to.equal(true); + expect(filter(path.join(projectDir, "definitions", "keep.sqlx"))).to.equal(false); + }); + + test("the always-ignored floor follows the filesystem's case sensitivity", () => { const projectDir = tmpDirFixture.createNewTmpDir(); fs.ensureDirSync(path.join(projectDir, ".GIT")); fs.ensureDirSync(path.join(projectDir, "definitions", "NODE_MODULES")); + fs.writeFileSync(path.join(projectDir, "definitions", "NODE_MODULES", "table.sqlx"), ""); fs.writeFileSync(path.join(projectDir, ".gitignore"), "!.GIT\n!NODE_MODULES\n"); - const filter = buildProjectCopyFilter(projectDir); + // Case-insensitive: these are the excluded directories, negations notwithstanding. + const insensitiveFilter = buildProjectCopyFilter(projectDir, true); + expect(insensitiveFilter(path.join(projectDir, ".GIT"))).to.equal(false); + expect(insensitiveFilter(path.join(projectDir, "definitions", "NODE_MODULES"))).to.equal(false); - expect(filter(path.join(projectDir, ".GIT"))).to.equal(false); - expect(filter(path.join(projectDir, "definitions", "NODE_MODULES"))).to.equal(false); + // Case-sensitive: these are ordinary directories. + const sensitiveFilter = buildProjectCopyFilter(projectDir, false); + expect(sensitiveFilter(path.join(projectDir, ".GIT"))).to.equal(true); + expect( + sensitiveFilter(path.join(projectDir, "definitions", "NODE_MODULES", "table.sqlx")), + ).to.equal(true); }); test("workflow_settings.yaml is always copied, even when an ignore file matches it", () => { @@ -258,4 +295,31 @@ suite("buildProjectCopyFilter", ({ afterEach }) => { const filter = buildProjectCopyFilter(projectDir); expect(filter(path.join(projectDir, ".venv"))).to.equal(false); }); + + test("on a case-insensitive filesystem, workflow_settings.yaml is kept in any case", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, ".gitignore"), "*.yaml\n"); + fs.writeFileSync(path.join(projectDir, "Workflow_Settings.yaml"), "defaultProject: p\n"); + + expect( + buildProjectCopyFilter(projectDir, true)(path.join(projectDir, "Workflow_Settings.yaml")), + ).to.equal(true); + expect( + buildProjectCopyFilter(projectDir, false)(path.join(projectDir, "Workflow_Settings.yaml")), + ).to.equal(false); + }); + + test("isCaseInsensitiveDirectory agrees with how the filesystem resolves names", () => { + const projectDir = tmpDirFixture.createNewTmpDir(); + fs.writeFileSync(path.join(projectDir, "probe"), ""); + const expected = fs.existsSync(path.join(projectDir, "PROBE")); + + expect(isCaseInsensitiveDirectory(projectDir)).to.equal(expected); + + // With both case variants present as distinct entries, it must be case-sensitive. + if (!expected) { + fs.writeFileSync(path.join(projectDir, "PROBE"), ""); + expect(isCaseInsensitiveDirectory(projectDir)).to.equal(false); + } + }); });