From c29f039bb24b6f5f52349a7d4d0cb01f0109d9c2 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 29 Jun 2026 11:29:09 -0700 Subject: [PATCH 1/4] test(build-infra): add gitignore utils --- .../build-infrastructure/package.json | 4 +- .../build-infrastructure/src/gitignore.ts | 302 ++++++++++++++++++ .../src/test/gitignore.test.ts | 129 ++++++++ .../build-infrastructure/tsconfig.cjs.json | 6 + build-tools/pnpm-lock.yaml | 9 +- 5 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 build-tools/packages/build-infrastructure/src/gitignore.ts create mode 100644 build-tools/packages/build-infrastructure/src/test/gitignore.test.ts create mode 100644 build-tools/packages/build-infrastructure/tsconfig.cjs.json diff --git a/build-tools/packages/build-infrastructure/package.json b/build-tools/packages/build-infrastructure/package.json index bb64345e08f..98996d53707 100644 --- a/build-tools/packages/build-infrastructure/package.json +++ b/build-tools/packages/build-infrastructure/package.json @@ -27,6 +27,7 @@ ], "scripts": { "build": "fluid-build --task build", + "build:commonjs": "fluid-tsc commonjs --project ./tsconfig.cjs.json", "build:compile": "fluid-build --task compile", "build:docs": "api-extractor run --local", "build:manifest": "oclif manifest", @@ -55,7 +56,7 @@ "detect-indent": "^6.1.0", "execa": "^5.1.1", "fs-extra": "^11.3.2", - "globby": "^11.1.0", + "ignore": "^7.0.5", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "oclif": "^4.23.0", @@ -64,6 +65,7 @@ "semver": "^7.7.3", "simple-git": "^3.32.3", "sort-package-json": "1.57.0", + "tinyglobby": "^0.2.15", "type-fest": "^2.19.0", "typescript": "~5.9.3" }, diff --git a/build-tools/packages/build-infrastructure/src/gitignore.ts b/build-tools/packages/build-infrastructure/src/gitignore.ts new file mode 100644 index 00000000000..0e7c47c8c2c --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/gitignore.ts @@ -0,0 +1,302 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import createIgnore from "ignore"; +import { glob as tinyglobbyGlob } from "tinyglobby"; + +/** + * Converts a path to use forward slashes (POSIX style). + */ +export function toPosixPath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +/** + * A gitignore rule set binds a directory to an `ignore` instance configured + * with the patterns from that directory's .gitignore file. + */ +interface GitignoreRuleSet { + dir: string; + ignorer: ReturnType; +} + +/** + * Cache for gitignore rule sets per directory path. + * + * This avoids re-reading .gitignore files for the same directory. + * Note: This cache is scoped to the module lifecycle. If .gitignore files + * are modified while a process is running, the cached patterns may become + * stale. Long-running processes that need to reflect .gitignore changes + * should call {@link clearGitignoreCache} when appropriate. + */ +const gitignoreRuleSetsCache = new Map(); +const pendingGitignoreRuleSetsCache = new Map>(); + +/** + * Clears the cached gitignore rule sets. + * + * This can be used by long-running processes (e.g. watch modes) that need to + * pick up changes to .gitignore files without restarting the process. + * + * @remarks + * The cache is process-wide module state, so calling this function affects + * every in-process consumer of this package — including other tools that + * share the same Node.js process (e.g. monorepo build scripts, test + * runners that reuse workers, or watch-mode tools). Use with care in shared + * processes. + */ +export function clearGitignoreCache(): void { + gitignoreRuleSetsCache.clear(); + pendingGitignoreRuleSetsCache.clear(); +} + +/** + * Returns true if the path is inside the provided directory. + */ +function isPathWithinDirectory(filePath: string, dir: string): boolean { + const relativePath = path.relative(dir, filePath); + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +} + +/** + * Returns true if the error represents a missing file. + */ +function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +/** + * Reads the .gitignore file in the provided directory, if present. + */ +async function readGitignoreRuleSet(dir: string): Promise { + const gitignorePath = path.join(dir, ".gitignore"); + + try { + const content = await readFile(gitignorePath, "utf8"); + return { dir, ignorer: createIgnore().add(content) }; + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + + throw error; + } +} + +/** + * Reads the .gitignore file in the provided directory synchronously, if present. + */ +function readGitignoreRuleSetSync(dir: string): GitignoreRuleSet | undefined { + const gitignorePath = path.join(dir, ".gitignore"); + + try { + const content = readFileSync(gitignorePath, "utf8"); + return { dir, ignorer: createIgnore().add(content) }; + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + + throw error; + } +} + +/** + * Reads gitignore patterns from .gitignore files in the given directory and its + * parents, returning a list of rule sets ordered from ancestor to descendant. + * Results are cached per directory path to avoid repeated filesystem reads. + */ +async function readGitignoreRuleSets(dir: string): Promise { + const normalizedDir = path.resolve(dir); + const cached = gitignoreRuleSetsCache.get(normalizedDir); + if (cached !== undefined) { + return cached; + } + + const pending = pendingGitignoreRuleSetsCache.get(normalizedDir); + if (pending !== undefined) { + return pending; + } + + const loadPromise = (async () => { + const parentDir = path.dirname(normalizedDir); + const inheritedRuleSets = + parentDir === normalizedDir ? [] : await readGitignoreRuleSets(parentDir); + const currentRuleSet = await readGitignoreRuleSet(normalizedDir); + const ruleSets = + currentRuleSet === undefined + ? inheritedRuleSets + : [...inheritedRuleSets, currentRuleSet]; + gitignoreRuleSetsCache.set(normalizedDir, ruleSets); + return ruleSets; + })(); + + pendingGitignoreRuleSetsCache.set(normalizedDir, loadPromise); + + try { + return await loadPromise; + } finally { + pendingGitignoreRuleSetsCache.delete(normalizedDir); + } +} + +/** + * Reads gitignore patterns from .gitignore files in the given directory and its + * parents synchronously, returning a list of rule sets ordered from ancestor to descendant. + * Results are cached per directory path to avoid repeated filesystem reads. + * + * Because of this caching, changes to `.gitignore` files made after the first read + * for a given directory will not be reflected until the process is restarted. + */ +function readGitignoreRuleSetsSync(dir: string): GitignoreRuleSet[] { + const normalizedDir = path.resolve(dir); + const cached = gitignoreRuleSetsCache.get(normalizedDir); + if (cached !== undefined) { + return cached; + } + + const parentDir = path.dirname(normalizedDir); + const inheritedRuleSets = + parentDir === normalizedDir ? [] : readGitignoreRuleSetsSync(parentDir); + const currentRuleSet = readGitignoreRuleSetSync(normalizedDir); + const ruleSets = + currentRuleSet === undefined ? inheritedRuleSets : [...inheritedRuleSets, currentRuleSet]; + + gitignoreRuleSetsCache.set(normalizedDir, ruleSets); + return ruleSets; +} + +/** + * Applies gitignore rules to a single file path. + */ +function shouldIncludeFile( + file: string, + cwd: string, + ruleSets: readonly GitignoreRuleSet[], +): boolean { + if (!isPathWithinDirectory(file, cwd)) { + return true; + } + + let isIgnored = false; + + for (const { dir, ignorer } of ruleSets) { + if (!isPathWithinDirectory(file, dir)) { + continue; + } + + const testResult = ignorer.test(toPosixPath(path.relative(dir, file))); + if (testResult.ignored) { + isIgnored = true; + } else if (testResult.unignored) { + isIgnored = false; + } + } + + return !isIgnored; +} + +/** + * Filters an array of file paths using gitignore rules. + * Reads .gitignore files from the filesystem hierarchy and applies them correctly + * relative to each .gitignore file's directory. + * + * @remarks + * Relative paths in `files` are resolved against `cwd` before gitignore + * rules are applied. The returned array preserves the original input + * strings (not the resolved absolute paths) for entries that pass the filter. + */ +export async function filterByGitignore(files: string[], cwd: string): Promise { + const normalizedCwd = path.resolve(cwd); + const included = await Promise.all( + files.map(async (file) => { + const resolvedFile = path.resolve(normalizedCwd, file); + if (!isPathWithinDirectory(resolvedFile, normalizedCwd)) { + return true; + } + + const ruleSets = await readGitignoreRuleSets(path.dirname(resolvedFile)); + return shouldIncludeFile(resolvedFile, normalizedCwd, ruleSets); + }), + ); + + return files.filter((_file, index) => included[index]); +} + +/** + * Filters an array of file paths using gitignore rules synchronously. + * Reads .gitignore files from the filesystem hierarchy and applies them correctly + * relative to each .gitignore file's directory. + * + * @remarks + * Relative paths in `files` are resolved against `cwd` before gitignore + * rules are applied. The returned array preserves the original input + * strings (not the resolved absolute paths) for entries that pass the filter. + */ +export function filterByGitignoreSync(files: string[], cwd: string): string[] { + const normalizedCwd = path.resolve(cwd); + return files.filter((file) => { + const resolvedFile = path.resolve(normalizedCwd, file); + if (!isPathWithinDirectory(resolvedFile, normalizedCwd)) { + return true; + } + + const ruleSets = readGitignoreRuleSetsSync(path.dirname(resolvedFile)); + return shouldIncludeFile(resolvedFile, normalizedCwd, ruleSets); + }); +} + +/** + * Options for {@link globWithGitignore}. + */ +export interface GlobWithGitignoreOptions { + /** + * The working directory to use for relative patterns. + */ + cwd: string; + + /** + * Whether to apply gitignore rules to exclude files. + * @defaultValue true + */ + gitignore?: boolean; +} + +/** + * Glob files with optional gitignore support. + * + * @param patterns - Glob patterns to match files. + * @param options - Options for the glob operation. + * @returns An array of absolute paths to all files that match the globs. + * + * @remarks + * This function uses tinyglobby for globbing and the `ignore` package for gitignore filtering. + * The gitignore patterns are read from .gitignore files in the file system hierarchy. + */ +export async function globWithGitignore( + patterns: readonly string[], + options: GlobWithGitignoreOptions, +): Promise { + const { cwd, gitignore: applyGitignore = true } = options; + + // Get all files matching the patterns + const files = await tinyglobbyGlob(patterns, { + cwd, + absolute: true, + }); + + const filtered = applyGitignore ? await filterByGitignore(files, cwd) : files; + + return filtered; +} diff --git a/build-tools/packages/build-infrastructure/src/test/gitignore.test.ts b/build-tools/packages/build-infrastructure/src/test/gitignore.test.ts new file mode 100644 index 00000000000..8820dc52b15 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/gitignore.test.ts @@ -0,0 +1,129 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, it } from "mocha"; +import { globSync } from "tinyglobby"; + +import { + clearGitignoreCache, + filterByGitignoreSync, + globWithGitignore, +} from "../gitignore.js"; + +describe("gitignore utilities", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "build-infra-gitignore-")); + await writeFile(path.join(tempDir, ".gitignore"), "root-ignored.ts\n"); + await writeFile(path.join(tempDir, "root-ignored.ts"), "export const ignored = true;\n"); + await writeFile(path.join(tempDir, "root-kept.ts"), "export const kept = true;\n"); + await writeFile(path.join(tempDir, "z-root.ts"), "export const root = true;\n"); + + const nestedDir = path.join(tempDir, "a-nested"); + await mkdir(nestedDir, { recursive: true }); + await writeFile(path.join(nestedDir, ".gitignore"), "nested-ignored.ts\n"); + await writeFile( + path.join(nestedDir, "nested-ignored.ts"), + "export const nestedIgnored = true;\n", + ); + await writeFile( + path.join(nestedDir, "nested-kept.ts"), + "export const nestedKept = true;\n", + ); + + clearGitignoreCache(); + }); + + afterEach(async () => { + clearGitignoreCache(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("applies descendant .gitignore files when globbing", async () => { + const results = await globWithGitignore(["**/*.ts"], { cwd: tempDir }); + const relativePaths = results.map((file) => path.relative(tempDir, file)); + + assert.deepEqual(relativePaths, ["root-kept.ts", "z-root.ts", "a-nested/nested-kept.ts"]); + }); + + it("applies descendant .gitignore files when filtering synchronously", () => { + const allFiles = globSync(["**/*.ts"], { + cwd: tempDir, + absolute: true, + onlyFiles: true, + }); + const filtered = filterByGitignoreSync(allFiles, tempDir); + const relativePaths = filtered.map((file) => path.relative(tempDir, file)).sort(); + + assert.deepEqual( + relativePaths, + ["root-kept.ts", "z-root.ts", "a-nested/nested-kept.ts"].sort(), + ); + }); + + it("preserves root-before-nested traversal order", async () => { + const results = await globWithGitignore(["**/*.ts"], { + cwd: tempDir, + gitignore: false, + }); + const relativePaths = results.map((file) => path.relative(tempDir, file)); + const lastRootIndex = relativePaths.lastIndexOf("z-root.ts"); + const firstNestedIndex = relativePaths.indexOf("a-nested/nested-ignored.ts"); + + assert(lastRootIndex >= 0, "Expected a root-level file in the results"); + assert(firstNestedIndex >= 0, "Expected a nested file in the results"); + assert( + lastRootIndex < firstNestedIndex, + `Expected root files to come before nested files, but got ${JSON.stringify(relativePaths)}`, + ); + }); + + it("re-includes files via nested negation patterns (unignored branch)", async () => { + // Root .gitignore ignores all .log files; nested .gitignore un-ignores + // `important.log` only. Verifies the `unignored` re-inclusion path in + // shouldIncludeFile. + await writeFile(path.join(tempDir, ".gitignore"), "*.log\n"); + + const nestedDir = path.join(tempDir, "negation"); + await mkdir(nestedDir, { recursive: true }); + await writeFile(path.join(nestedDir, ".gitignore"), "!important.log\n"); + await writeFile(path.join(nestedDir, "important.log"), "kept\n"); + await writeFile(path.join(nestedDir, "other.log"), "ignored\n"); + + clearGitignoreCache(); + + const allFiles = globSync(["negation/*.log"], { + cwd: tempDir, + absolute: true, + onlyFiles: true, + }); + const filtered = filterByGitignoreSync(allFiles, tempDir); + const relativePaths = filtered.map((file) => path.relative(tempDir, file)).sort(); + + assert.deepEqual(relativePaths, ["negation/important.log"]); + }); + + it("resolves relative file paths against cwd before filtering", () => { + // Pass relative paths (relative to tempDir) and verify gitignore rules + // still apply correctly. + const filtered = filterByGitignoreSync( + [ + "root-ignored.ts", + "root-kept.ts", + "a-nested/nested-ignored.ts", + "a-nested/nested-kept.ts", + ], + tempDir, + ); + + assert.deepEqual(filtered.sort(), ["a-nested/nested-kept.ts", "root-kept.ts"].sort()); + }); +}); diff --git a/build-tools/packages/build-infrastructure/tsconfig.cjs.json b/build-tools/packages/build-infrastructure/tsconfig.cjs.json new file mode 100644 index 00000000000..d7940f58602 --- /dev/null +++ b/build-tools/packages/build-infrastructure/tsconfig.cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + }, +} diff --git a/build-tools/pnpm-lock.yaml b/build-tools/pnpm-lock.yaml index 183014dc8c2..7dfbe595bab 100644 --- a/build-tools/pnpm-lock.yaml +++ b/build-tools/pnpm-lock.yaml @@ -425,9 +425,9 @@ importers: fs-extra: specifier: ^11.3.2 version: 11.3.2 - globby: - specifier: ^11.1.0 - version: 11.1.0 + ignore: + specifier: ^7.0.5 + version: 7.0.5 lilconfig: specifier: ^3.1.3 version: 3.1.3 @@ -452,6 +452,9 @@ importers: sort-package-json: specifier: 1.57.0 version: 1.57.0 + tinyglobby: + specifier: ^0.2.15 + version: 0.2.15 type-fest: specifier: ^2.19.0 version: 2.19.0 From 099ef02681ae675b3a376e9f6e26073bb7109790 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 29 Jun 2026 12:18:16 -0700 Subject: [PATCH 2/4] deps --- build-tools/packages/build-infrastructure/package.json | 1 + build-tools/packages/build-infrastructure/tsconfig.cjs.json | 6 ------ build-tools/pnpm-lock.yaml | 3 +++ 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 build-tools/packages/build-infrastructure/tsconfig.cjs.json diff --git a/build-tools/packages/build-infrastructure/package.json b/build-tools/packages/build-infrastructure/package.json index 98996d53707..a55f812e0d8 100644 --- a/build-tools/packages/build-infrastructure/package.json +++ b/build-tools/packages/build-infrastructure/package.json @@ -57,6 +57,7 @@ "execa": "^5.1.1", "fs-extra": "^11.3.2", "ignore": "^7.0.5", + "globby": "^11.1.0", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "oclif": "^4.23.0", diff --git a/build-tools/packages/build-infrastructure/tsconfig.cjs.json b/build-tools/packages/build-infrastructure/tsconfig.cjs.json deleted file mode 100644 index d7940f58602..00000000000 --- a/build-tools/packages/build-infrastructure/tsconfig.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - }, -} diff --git a/build-tools/pnpm-lock.yaml b/build-tools/pnpm-lock.yaml index 7dfbe595bab..fcd4439683d 100644 --- a/build-tools/pnpm-lock.yaml +++ b/build-tools/pnpm-lock.yaml @@ -428,6 +428,9 @@ importers: ignore: specifier: ^7.0.5 version: 7.0.5 + globby: + specifier: ^11.1.0 + version: 11.1.0 lilconfig: specifier: ^3.1.3 version: 3.1.3 From c05674b8d74523d626f3d603e555ca5a232f1134 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 29 Jun 2026 12:25:37 -0700 Subject: [PATCH 3/4] update --- build-tools/packages/build-infrastructure/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/build-tools/packages/build-infrastructure/package.json b/build-tools/packages/build-infrastructure/package.json index a55f812e0d8..9c19943f3da 100644 --- a/build-tools/packages/build-infrastructure/package.json +++ b/build-tools/packages/build-infrastructure/package.json @@ -27,7 +27,6 @@ ], "scripts": { "build": "fluid-build --task build", - "build:commonjs": "fluid-tsc commonjs --project ./tsconfig.cjs.json", "build:compile": "fluid-build --task compile", "build:docs": "api-extractor run --local", "build:manifest": "oclif manifest", From f92b724c0ae973f69e73d5387b100fe6c81f4d49 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 29 Jun 2026 12:45:56 -0700 Subject: [PATCH 4/4] sort --- build-tools/packages/build-infrastructure/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-tools/packages/build-infrastructure/package.json b/build-tools/packages/build-infrastructure/package.json index 9c19943f3da..da8829ef521 100644 --- a/build-tools/packages/build-infrastructure/package.json +++ b/build-tools/packages/build-infrastructure/package.json @@ -55,8 +55,8 @@ "detect-indent": "^6.1.0", "execa": "^5.1.1", "fs-extra": "^11.3.2", - "ignore": "^7.0.5", "globby": "^11.1.0", + "ignore": "^7.0.5", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "oclif": "^4.23.0",