Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/rstack/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <i@kael.me>, 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).
Expand Down
1 change: 1 addition & 0 deletions packages/rstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@types/micromatch": "catalog:",
"@types/node": "catalog:",
"fast-ignore": "catalog:",
"ignore": "catalog:",
"is-binary-path": "catalog:",
"lint-staged": "catalog:",
"micromatch": "catalog:",
Expand Down
225 changes: 194 additions & 31 deletions packages/rstack/src/fmt/discoverPaths.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string> => {
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<string, ReturnType<typeof ignore>>();
readonly #loads = new Map<string, Promise<void>>();
readonly #ignoredDirectories = new Map<string, boolean>();

private constructor(rootPath: string) {
this.#rootPath = rootPath;
}

static async create(cwd: string): Promise<GitIgnoreMatcher> {
const matcher = new GitIgnoreMatcher(await findGitRoot(cwd));
await matcher.loadThrough(cwd);
return matcher;
}

async loadThrough(directoryPath: string): Promise<void> {
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<void> {
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<void> {
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor Git's case sensitivity in ignore matching

In a case-sensitive repository (core.ignoreCase=false, the usual Linux setting), a .gitignore entry such as generated.ts does not ignore Generated.ts, but ignore() defaults to case-insensitive matching. Directory and glob formatting therefore silently omit differently-cased files that Git considers unignored; configure the matcher using the repository's core.ignoreCase behavior instead of accepting the package default.

Useful? React with 👍 / 👎.

})
.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<string>();

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
42 changes: 42 additions & 0 deletions packages/rstack/tests/fmt/discoverPaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading