Skip to content

Commit 1a3c1fc

Browse files
committed
feat(fmt): respect gitignore files
1 parent 7d7983d commit 1a3c1fc

6 files changed

Lines changed: 278 additions & 31 deletions

File tree

‎packages/rstack/THIRD_PARTY_NOTICES.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,34 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
5858
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
5959
SOFTWARE.
6060

61+
## ignore
62+
63+
This package includes bundled code from [ignore](https://github.com/kaelzhang/node-ignore).
64+
65+
License: MIT
66+
67+
Copyright (c) 2013 Kael Zhang <i@kael.me>, contributors
68+
http://kael.me/
69+
70+
Permission is hereby granted, free of charge, to any person obtaining
71+
a copy of this software and associated documentation files (the
72+
"Software"), to deal in the Software without restriction, including
73+
without limitation the rights to use, copy, modify, merge, publish,
74+
distribute, sublicense, and/or sell copies of the Software, and to
75+
permit persons to whom the Software is furnished to do so, subject to
76+
the following conditions:
77+
78+
The above copyright notice and this permission notice shall be
79+
included in all copies or substantial portions of the Software.
80+
81+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
82+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
83+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
84+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
85+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
86+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
87+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
88+
6189
## is-binary-path
6290

6391
This package includes bundled code from [is-binary-path](https://github.com/sindresorhus/is-binary-path).

‎packages/rstack/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"@types/micromatch": "catalog:",
6969
"@types/node": "catalog:",
7070
"fast-ignore": "catalog:",
71+
"ignore": "catalog:",
7172
"is-binary-path": "catalog:",
7273
"lint-staged": "catalog:",
7374
"micromatch": "catalog:",

‎packages/rstack/src/fmt/discoverPaths.ts‎

Lines changed: 194 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { lstat } from 'node:fs/promises';
1+
import { lstat, readFile } from 'node:fs/promises';
22
import path from 'node:path';
3+
import ignore from 'ignore';
34
import isBinaryPath from 'is-binary-path';
45
import micromatch from 'micromatch';
56
import readdir, { type Dirent } from 'tiny-readdir';
@@ -50,7 +51,148 @@ const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean =>
5051
.split(path.sep)
5152
.some((segment) => alwaysIgnoredNames.has(segment));
5253

53-
const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => {
54+
const findGitRoot = async (cwd: string): Promise<string> => {
55+
let directoryPath = cwd;
56+
57+
while (true) {
58+
if (await lstatSafe(path.join(directoryPath, '.git'))) {
59+
return directoryPath;
60+
}
61+
62+
const parentPath = path.dirname(directoryPath);
63+
if (parentPath === directoryPath) {
64+
return cwd;
65+
}
66+
directoryPath = parentPath;
67+
}
68+
};
69+
70+
class GitIgnoreMatcher {
71+
readonly #rootPath: string;
72+
readonly #matchers = new Map<string, ReturnType<typeof ignore>>();
73+
readonly #loads = new Map<string, Promise<void>>();
74+
readonly #ignoredDirectories = new Map<string, boolean>();
75+
76+
private constructor(rootPath: string) {
77+
this.#rootPath = rootPath;
78+
}
79+
80+
static async create(cwd: string): Promise<GitIgnoreMatcher> {
81+
const matcher = new GitIgnoreMatcher(await findGitRoot(cwd));
82+
await matcher.loadThrough(cwd);
83+
return matcher;
84+
}
85+
86+
async loadThrough(directoryPath: string): Promise<void> {
87+
if (!isPathInside(this.#rootPath, directoryPath)) {
88+
return;
89+
}
90+
91+
const relativePath = path.relative(this.#rootPath, directoryPath);
92+
const segments = relativePath ? relativePath.split(path.sep) : [];
93+
const loads = [this.#load(this.#rootPath)];
94+
let currentPath = this.#rootPath;
95+
96+
for (const segment of segments) {
97+
currentPath = path.join(currentPath, segment);
98+
loads.push(this.#load(currentPath));
99+
}
100+
101+
await Promise.all(loads);
102+
}
103+
104+
async load(directoryPath: string): Promise<void> {
105+
if (isPathInside(this.#rootPath, directoryPath)) {
106+
await this.#load(directoryPath);
107+
}
108+
}
109+
110+
isIgnored(filePath: string, isDirectory: boolean): boolean {
111+
if (this.#matchers.size === 0) {
112+
return false;
113+
}
114+
115+
const relativePath = path.relative(this.#rootPath, filePath);
116+
if (relativePath === '' || !isRelativePathInside(relativePath)) {
117+
return false;
118+
}
119+
120+
if (isDirectory) {
121+
return this.#isDirectoryIgnored(filePath, relativePath);
122+
}
123+
124+
const parentPath = path.dirname(filePath);
125+
return (
126+
(parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) ||
127+
this.#matches(relativePath, false)
128+
);
129+
}
130+
131+
#load(directoryPath: string): Promise<void> {
132+
const cached = this.#loads.get(directoryPath);
133+
if (cached) {
134+
return cached;
135+
}
136+
137+
// Ignore files may disappear or become unreadable during traversal.
138+
const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8')
139+
.then((content) => {
140+
this.#matchers.set(directoryPath, ignore().add(content));
141+
})
142+
.catch(() => undefined);
143+
144+
this.#loads.set(directoryPath, loading);
145+
return loading;
146+
}
147+
148+
#isDirectoryIgnored(
149+
directoryPath: string,
150+
relativePath = path.relative(this.#rootPath, directoryPath),
151+
): boolean {
152+
const cached = this.#ignoredDirectories.get(directoryPath);
153+
if (cached !== undefined) {
154+
return cached;
155+
}
156+
157+
// Git cannot re-include a path below an ignored directory.
158+
const parentPath = path.dirname(directoryPath);
159+
const ignored =
160+
(parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) ||
161+
this.#matches(relativePath, true);
162+
this.#ignoredDirectories.set(directoryPath, ignored);
163+
return ignored;
164+
}
165+
166+
#matches(relativePath: string, isDirectory: boolean): boolean {
167+
const segments = relativePath.split(path.sep);
168+
let directoryPath = this.#rootPath;
169+
let pathFromMatcher = segments.join('/');
170+
let ignored = false;
171+
172+
for (const segment of segments) {
173+
const matcher = this.#matchers.get(directoryPath);
174+
if (matcher) {
175+
const result = matcher.test(isDirectory ? `${pathFromMatcher}/` : pathFromMatcher);
176+
177+
if (result.ignored) {
178+
ignored = true;
179+
} else if (result.unignored) {
180+
ignored = false;
181+
}
182+
}
183+
184+
directoryPath = path.join(directoryPath, segment);
185+
pathFromMatcher = pathFromMatcher.slice(segment.length + 1);
186+
}
187+
188+
return ignored;
189+
}
190+
}
191+
192+
const createTraversalOptions = (
193+
gitIgnore: GitIgnoreMatcher,
194+
isIncluded?: (filePath: string) => boolean,
195+
) => {
54196
// tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly.
55197
const directories = new Set<string>();
56198

@@ -62,18 +204,31 @@ const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => {
62204
return true;
63205
}
64206

207+
if (isDirectory) {
208+
return gitIgnore.isIgnored(targetPath, true);
209+
}
210+
65211
return (
66-
!isDirectory &&
67-
(isBinaryPath(targetPath) || (isIncluded !== undefined && !isIncluded(targetPath)))
212+
isBinaryPath(targetPath) ||
213+
(isIncluded !== undefined && !isIncluded(targetPath)) ||
214+
gitIgnore.isIgnored(targetPath, false)
68215
);
69216
},
70-
onDirents: (dirents: Dirent[]) => {
217+
onDirents: async (dirents: Dirent[]) => {
71218
const parentPath = getDirentParentPath(dirents[0]);
219+
let hasGitIgnore = false;
72220

73221
for (const dirent of dirents) {
74222
if (dirent.isDirectory()) {
75223
directories.add(getDirentPath(dirent, parentPath));
76224
}
225+
if (dirent.name === '.gitignore') {
226+
hasGitIgnore = true;
227+
}
228+
}
229+
230+
if (hasGitIgnore) {
231+
await gitIgnore.load(parentPath);
77232
}
78233

79234
return undefined;
@@ -192,34 +347,42 @@ const discoverFmtPaths = async ({
192347
const candidates = new Set(explicitFiles);
193348
const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs);
194349

195-
const results = await Promise.all(
196-
traversalRoots.map(async (rootPath) => {
197-
const stats = await lstatSafe(rootPath);
198-
if (!stats?.isDirectory()) {
199-
return [];
200-
}
350+
if (traversalRoots.length) {
351+
const gitIgnore = await GitIgnoreMatcher.create(cwd);
352+
const results = await Promise.all(
353+
traversalRoots.map(async (rootPath) => {
354+
const stats = await lstatSafe(rootPath);
355+
if (!stats?.isDirectory()) {
356+
return [];
357+
}
201358

202-
const includesAll = directoryRoots.some((directoryPath) =>
203-
isPathInside(directoryPath, rootPath),
204-
);
205-
const isIncluded = includesAll
206-
? undefined
207-
: (filePath: string): boolean => {
208-
if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) {
209-
return true;
210-
}
211-
212-
const relativePath = toPosixPath(path.relative(cwd, filePath));
213-
return globMatchers.some((matches) => matches(relativePath));
214-
};
215-
216-
return (await readdir(rootPath, createTraversalOptions(isIncluded))).files;
217-
}),
218-
);
359+
await gitIgnore.loadThrough(rootPath);
360+
if (gitIgnore.isIgnored(rootPath, true)) {
361+
return [];
362+
}
219363

220-
for (const files of results) {
221-
for (const filePath of files) {
222-
candidates.add(filePath);
364+
const includesAll = directoryRoots.some((directoryPath) =>
365+
isPathInside(directoryPath, rootPath),
366+
);
367+
const isIncluded = includesAll
368+
? undefined
369+
: (filePath: string): boolean => {
370+
if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) {
371+
return true;
372+
}
373+
374+
const relativePath = toPosixPath(path.relative(cwd, filePath));
375+
return globMatchers.some((matches) => matches(relativePath));
376+
};
377+
378+
return (await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded))).files;
379+
}),
380+
);
381+
382+
for (const files of results) {
383+
for (const filePath of files) {
384+
candidates.add(filePath);
385+
}
223386
}
224387
}
225388

‎packages/rstack/tests/fmt/discoverPaths.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,48 @@ test('combines files, directories, and globs without duplicates', async () => {
8989
});
9090
});
9191

92+
test('applies nested gitignore rules with child negation', async () => {
93+
await withProject(async (rootPath) => {
94+
mkdirSync(path.join(rootPath, '.git'));
95+
writeProjectFile(rootPath, '.gitignore', '*.js\ndist/\n');
96+
writeProjectFile(rootPath, 'src/.gitignore', '!keep.js\n');
97+
writeProjectFile(rootPath, 'dist/.gitignore', '!keep.js\n');
98+
writeProjectFile(rootPath, 'dist/nested/.gitignore', '!keep.js\n');
99+
writeProjectFile(rootPath, 'src/keep.js');
100+
writeProjectFile(rootPath, 'src/drop.js');
101+
writeProjectFile(rootPath, 'dist/keep.js');
102+
writeProjectFile(rootPath, 'dist/nested/keep.js');
103+
writeProjectFile(rootPath, 'visible.ts');
104+
105+
const files = await discoverFmtPaths({ cwd: rootPath, patterns: ['**/*.{js,ts}'] });
106+
const ignoredNestedDirectory = await discoverFmtPaths({
107+
cwd: rootPath,
108+
patterns: ['dist/nested'],
109+
});
110+
111+
expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'keep.js'), 'visible.ts']);
112+
expect(ignoredNestedDirectory).toEqual([]);
113+
});
114+
});
115+
116+
test('lets explicit files bypass gitignore', async () => {
117+
await withProject(async (rootPath) => {
118+
mkdirSync(path.join(rootPath, '.git'));
119+
writeProjectFile(rootPath, '.gitignore', '/generated/\n');
120+
const keepPath = writeProjectFile(rootPath, 'generated/keep.ts');
121+
writeProjectFile(rootPath, 'src/index.ts');
122+
123+
const discoveredFiles = await discoverFmtPaths({
124+
cwd: rootPath,
125+
patterns: ['**/*.ts'],
126+
});
127+
const explicitFiles = await discoverFmtPaths({ cwd: rootPath, patterns: [keepPath] });
128+
129+
expect(relativePaths(rootPath, discoveredFiles)).toEqual([path.join('src', 'index.ts')]);
130+
expect(relativePaths(rootPath, explicitFiles)).toEqual([path.join('generated', 'keep.ts')]);
131+
});
132+
});
133+
92134
test.runIf(process.platform !== 'win32')('does not follow file or directory symlinks', async () => {
93135
await withProject(async (rootPath) => {
94136
const targetPath = writeProjectFile(rootPath, 'target/index.ts');

‎pnpm-lock.yaml‎

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)