diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 4897ebd..5431566 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -58,6 +58,34 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## ignore + +This package includes bundled code from [ignore](https://github.com/kaelzhang/node-ignore). + +License: MIT + +Copyright (c) 2013 Kael Zhang , contributors +http://kael.me/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ## is-binary-path This package includes bundled code from [is-binary-path](https://github.com/sindresorhus/is-binary-path). diff --git a/packages/rstack/package.json b/packages/rstack/package.json index a33ec46..3a9cfd0 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -68,6 +68,7 @@ "@types/micromatch": "catalog:", "@types/node": "catalog:", "fast-ignore": "catalog:", + "ignore": "catalog:", "is-binary-path": "catalog:", "lint-staged": "catalog:", "micromatch": "catalog:", diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 205e3b4..9f9fe7f 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -1,5 +1,6 @@ -import { lstat } from 'node:fs/promises'; +import { lstat, readFile } from 'node:fs/promises'; import path from 'node:path'; +import ignore from 'ignore'; import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; import readdir, { type Dirent } from 'tiny-readdir'; @@ -50,7 +51,148 @@ const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean => .split(path.sep) .some((segment) => alwaysIgnoredNames.has(segment)); -const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => { +const findGitRoot = async (cwd: string): Promise => { + let directoryPath = cwd; + + while (true) { + if (await lstatSafe(path.join(directoryPath, '.git'))) { + return directoryPath; + } + + const parentPath = path.dirname(directoryPath); + if (parentPath === directoryPath) { + return cwd; + } + directoryPath = parentPath; + } +}; + +class GitIgnoreMatcher { + readonly #rootPath: string; + readonly #matchers = new Map>(); + readonly #loads = new Map>(); + readonly #ignoredDirectories = new Map(); + + private constructor(rootPath: string) { + this.#rootPath = rootPath; + } + + static async create(cwd: string): Promise { + const matcher = new GitIgnoreMatcher(await findGitRoot(cwd)); + await matcher.loadThrough(cwd); + return matcher; + } + + async loadThrough(directoryPath: string): Promise { + if (!isPathInside(this.#rootPath, directoryPath)) { + return; + } + + const relativePath = path.relative(this.#rootPath, directoryPath); + const segments = relativePath ? relativePath.split(path.sep) : []; + const loads = [this.#load(this.#rootPath)]; + let currentPath = this.#rootPath; + + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + loads.push(this.#load(currentPath)); + } + + await Promise.all(loads); + } + + async load(directoryPath: string): Promise { + if (isPathInside(this.#rootPath, directoryPath)) { + await this.#load(directoryPath); + } + } + + isIgnored(filePath: string, isDirectory: boolean): boolean { + if (this.#matchers.size === 0) { + return false; + } + + const relativePath = path.relative(this.#rootPath, filePath); + if (relativePath === '' || !isRelativePathInside(relativePath)) { + return false; + } + + if (isDirectory) { + return this.#isDirectoryIgnored(filePath, relativePath); + } + + const parentPath = path.dirname(filePath); + return ( + (parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) || + this.#matches(relativePath, false) + ); + } + + #load(directoryPath: string): Promise { + const cached = this.#loads.get(directoryPath); + if (cached) { + return cached; + } + + // Ignore files may disappear or become unreadable during traversal. + const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8') + .then((content) => { + this.#matchers.set(directoryPath, ignore().add(content)); + }) + .catch(() => undefined); + + this.#loads.set(directoryPath, loading); + return loading; + } + + #isDirectoryIgnored( + directoryPath: string, + relativePath = path.relative(this.#rootPath, directoryPath), + ): boolean { + const cached = this.#ignoredDirectories.get(directoryPath); + if (cached !== undefined) { + return cached; + } + + // Git cannot re-include a path below an ignored directory. + const parentPath = path.dirname(directoryPath); + const ignored = + (parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) || + this.#matches(relativePath, true); + this.#ignoredDirectories.set(directoryPath, ignored); + return ignored; + } + + #matches(relativePath: string, isDirectory: boolean): boolean { + const segments = relativePath.split(path.sep); + let directoryPath = this.#rootPath; + let pathFromMatcher = segments.join('/'); + let ignored = false; + + for (const segment of segments) { + const matcher = this.#matchers.get(directoryPath); + if (matcher) { + const result = matcher.test(isDirectory ? `${pathFromMatcher}/` : pathFromMatcher); + + if (result.ignored) { + ignored = true; + } else if (result.unignored) { + ignored = false; + } + } + + directoryPath = path.join(directoryPath, segment); + pathFromMatcher = pathFromMatcher.slice(segment.length + 1); + } + + return ignored; + } +} + +const createTraversalOptions = ( + gitIgnore: GitIgnoreMatcher, + isIncluded?: (filePath: string) => boolean, +) => { // tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly. const directories = new Set(); @@ -62,18 +204,31 @@ const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => { return true; } + if (isDirectory) { + return gitIgnore.isIgnored(targetPath, true); + } + return ( - !isDirectory && - (isBinaryPath(targetPath) || (isIncluded !== undefined && !isIncluded(targetPath))) + isBinaryPath(targetPath) || + (isIncluded !== undefined && !isIncluded(targetPath)) || + gitIgnore.isIgnored(targetPath, false) ); }, - onDirents: (dirents: Dirent[]) => { + onDirents: async (dirents: Dirent[]) => { const parentPath = getDirentParentPath(dirents[0]); + let hasGitIgnore = false; for (const dirent of dirents) { if (dirent.isDirectory()) { directories.add(getDirentPath(dirent, parentPath)); } + if (dirent.name === '.gitignore') { + hasGitIgnore = true; + } + } + + if (hasGitIgnore) { + await gitIgnore.load(parentPath); } return undefined; @@ -192,34 +347,42 @@ const discoverFmtPaths = async ({ const candidates = new Set(explicitFiles); const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs); - const results = await Promise.all( - traversalRoots.map(async (rootPath) => { - const stats = await lstatSafe(rootPath); - if (!stats?.isDirectory()) { - return []; - } + if (traversalRoots.length) { + const gitIgnore = await GitIgnoreMatcher.create(cwd); + const results = await Promise.all( + traversalRoots.map(async (rootPath) => { + const stats = await lstatSafe(rootPath); + if (!stats?.isDirectory()) { + return []; + } - const includesAll = directoryRoots.some((directoryPath) => - isPathInside(directoryPath, rootPath), - ); - const isIncluded = includesAll - ? undefined - : (filePath: string): boolean => { - if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) { - return true; - } - - const relativePath = toPosixPath(path.relative(cwd, filePath)); - return globMatchers.some((matches) => matches(relativePath)); - }; - - return (await readdir(rootPath, createTraversalOptions(isIncluded))).files; - }), - ); + await gitIgnore.loadThrough(rootPath); + if (gitIgnore.isIgnored(rootPath, true)) { + return []; + } - for (const files of results) { - for (const filePath of files) { - candidates.add(filePath); + const includesAll = directoryRoots.some((directoryPath) => + isPathInside(directoryPath, rootPath), + ); + const isIncluded = includesAll + ? undefined + : (filePath: string): boolean => { + if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) { + return true; + } + + const relativePath = toPosixPath(path.relative(cwd, filePath)); + return globMatchers.some((matches) => matches(relativePath)); + }; + + return (await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded))).files; + }), + ); + + for (const files of results) { + for (const filePath of files) { + candidates.add(filePath); + } } } diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index 16a699a..64f2add 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -89,6 +89,48 @@ test('combines files, directories, and globs without duplicates', async () => { }); }); +test('applies nested gitignore rules with child negation', async () => { + await withProject(async (rootPath) => { + mkdirSync(path.join(rootPath, '.git')); + writeProjectFile(rootPath, '.gitignore', '*.js\ndist/\n'); + writeProjectFile(rootPath, 'src/.gitignore', '!keep.js\n'); + writeProjectFile(rootPath, 'dist/.gitignore', '!keep.js\n'); + writeProjectFile(rootPath, 'dist/nested/.gitignore', '!keep.js\n'); + writeProjectFile(rootPath, 'src/keep.js'); + writeProjectFile(rootPath, 'src/drop.js'); + writeProjectFile(rootPath, 'dist/keep.js'); + writeProjectFile(rootPath, 'dist/nested/keep.js'); + writeProjectFile(rootPath, 'visible.ts'); + + const files = await discoverFmtPaths({ cwd: rootPath, patterns: ['**/*.{js,ts}'] }); + const ignoredNestedDirectory = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['dist/nested'], + }); + + expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'keep.js'), 'visible.ts']); + expect(ignoredNestedDirectory).toEqual([]); + }); +}); + +test('lets explicit files bypass gitignore', async () => { + await withProject(async (rootPath) => { + mkdirSync(path.join(rootPath, '.git')); + writeProjectFile(rootPath, '.gitignore', '/generated/\n'); + const keepPath = writeProjectFile(rootPath, 'generated/keep.ts'); + writeProjectFile(rootPath, 'src/index.ts'); + + const discoveredFiles = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['**/*.ts'], + }); + const explicitFiles = await discoverFmtPaths({ cwd: rootPath, patterns: [keepPath] }); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual([path.join('src', 'index.ts')]); + expect(relativePaths(rootPath, explicitFiles)).toEqual([path.join('generated', 'keep.ts')]); + }); +}); + test.runIf(process.platform !== 'win32')('does not follow file or directory symlinks', async () => { await withProject(async (rootPath) => { const targetPath = writeProjectFile(rootPath, 'target/index.ts'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d433f69..e31f88a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ catalogs: heading-case: specifier: ^1.1.4 version: 1.1.4 + ignore: + specifier: 7.0.5 + version: 7.0.5 is-binary-path: specifier: 3.0.0 version: 3.0.0 @@ -355,6 +358,9 @@ importers: fast-ignore: specifier: 'catalog:' version: 2.0.0 + ignore: + specifier: 'catalog:' + version: 7.0.5 is-binary-path: specifier: 'catalog:' version: 3.0.0 @@ -1408,6 +1414,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -3192,6 +3202,8 @@ snapshots: html-void-elements@3.0.0: {} + ignore@7.0.5: {} + immutable@5.1.9: {} indent-string@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d28d3e5..3b657e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,7 @@ catalog: 'fast-ignore': '2.0.0' 'happy-dom': '^20.11.1' 'heading-case': '^1.1.4' + ignore: 7.0.5 is-binary-path: 3.0.0 'lint-staged': '^17.2.0' 'micromatch': '4.0.8'