Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/spicy-otters-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@flint.fyi/core": patch
---

feat(core): support nested .gitignore files in filtering
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@flint.fyi/utils": "workspace:^",
"cached-factory": "^0.1.0",
"debug-for-file": "^0.2.0",
"ignore": "^7.0.5",
"omit-empty": "^1.0.0",
"zod": "^4.2.1"
},
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/running/collectFilesAndOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export async function collectFilesAndOptions(
ignoreCache: boolean | undefined,
): Promise<CollectedFilesAndOptions> {
// 1. Collect all file paths to lint and the 'use' rule configuration groups
const { allFilePaths, useDefinitions } =
await computeUseDefinitions(configDefinition);
const { allFilePaths, useDefinitions } = await computeUseDefinitions(
configDefinition,
host,
);

// 2. Retrieve any past cached results from those files
const cached = ignoreCache
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/running/computeUseDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import type {
ConfigUseDefinition,
ProcessedConfigDefinition,
} from "../types/configs.ts";
import type { LinterHost } from "../types/host.ts";
import { flatten } from "../utils/arrays.ts";
import { readGitignore } from "./readGitignore.ts";
import { createGitignoreFilter } from "./createGitignoreFilter.ts";
import { resolveUseFilesGlobs } from "./resolveUseFilesGlobs.ts";

const log = debugForFile(import.meta.filename);
Expand All @@ -26,31 +27,35 @@ export interface ConfigUseDefinitionWithFiles extends ConfigUseDefinition {

export async function computeUseDefinitions(
configDefinition: ProcessedConfigDefinition,
host: LinterHost,
): Promise<ComputedUseDefinitions> {
log("Collecting files from %d use pattern(s)", configDefinition.use.length);

const allFilePaths = new Set<string>();
const gitignore = await readGitignore();

log("Excluding based on .gitignore: %s", gitignore);
const gitignoreFilter = createGitignoreFilter(process.cwd(), host);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const gitignoreFilter = createGitignoreFilter(process.cwd(), host);
const gitignoreFilter = createGitignoreFilter(host.getCurrentWorkingDirectory(), host);

Copy link
Member Author

Choose a reason for hiding this comment

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

process.cwd() -> /User/xxx
linterhost.getCurrentDirectory() -> /user/xxx

when i use path.relative(), it throw an error.

Copy link
Member

Choose a reason for hiding this comment

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

Well, that's a bug... uh... can you take a look why? It definitely shouldn't be lowercase 🤔

CC @auvred in case I'm wrong

Copy link
Member

Choose a reason for hiding this comment

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

Well, that's a bug... uh... can you take a look why? It definitely shouldn't be lowercase 🤔

CC @auvred in case I'm wrong

APFS is case insensitive by default! #1246 (comment)

(why...)

when i use path.relative(), it throw an error.

It's better not to mix normalized Flint paths (the ones that come out of normalizePath) with other paths. We probably should establish this as a guideline, because working with cross-platform paths is always a pain.

In general, the smoothest approach for me (I implemented this in Flint's LinterHost) is:

  • Introduce normalizePath - it normalizes slashes and casing (lowercase for case-insensitive file systems). This normalization must be idempodent.
  • All functions that take paths from the outside world (non-core), such as LinterHost methods, must normalize paths before using them (even if someone passes an already normalized path, we're guarded against other cases).
  • All paths returned to the outside world are normalized (unless it's explicitly required otherwise, e.g. we might need an API that preserves both raw and normalized paths).
  • If, in some internal function that doesn't accept raw paths from the outside world, we work with paths that are known ot be normalized (e.g. paths returned by LinterHost methods), we don't need to normalize them again.

So, instead of const prefix = path.relative(cwd, dir);, we should write const prefix = path.relative(normalizePath(cwd, host.caseSensitiveFS()), dir);.

(We should probably also use path.posix.relative instead of path.relative to preserve forward slashes on win32?)


const useDefinitions = await Promise.all(
configDefinition.use.map(async (use) => {
const globs = resolveUseFilesGlobs(use.files, configDefinition);
const foundFilePaths = (
await Array.fromAsync(
fs.glob([globs.include].flat(), {
exclude: [...gitignore, ...globs.exclude],
exclude: globs.exclude,
withFileTypes: true,
}),
)
)
.filter((entry) => entry.isFile())
.map((entry) =>
path.relative(
process.cwd(),
makeAbsolute(path.join(entry.parentPath, entry.name)),
),
entry.isFile()
? path.relative(
process.cwd(),
makeAbsolute(path.join(entry.parentPath, entry.name)),
)
: null,
)
.filter(
(filePath): filePath is string =>
filePath !== null && gitignoreFilter(filePath),
);

for (const foundFilePath of foundFilePaths) {
Expand Down
235 changes: 235 additions & 0 deletions packages/core/src/running/createGitignoreFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { createDiskBackedLinterHost } from "../host/createDiskBackedLinterHost.ts";
import { createGitignoreFilter } from "./createGitignoreFilter.ts";

const INTEGRATION_DIR_NAME = ".flint-gitignore-filter-integration-tests";

function findUpNodeModules(startDir: string): string {
let current = startDir;
while (true) {
const candidate = path.join(current, "node_modules");
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(current);
if (parent === current) {
throw new Error("Could not find node_modules directory.");
}
current = parent;
}
}

describe("createGitignoreFilter", () => {
const integrationRoot = path.join(
findUpNodeModules(import.meta.dirname),
INTEGRATION_DIR_NAME,
);

beforeEach(() => {
fs.rmSync(integrationRoot, { force: true, recursive: true });
fs.mkdirSync(integrationRoot, { recursive: true });
});

afterEach(() => {
fs.rmSync(integrationRoot, { force: true, recursive: true });
});

// root/
// └── src/
// └── file.ts
it("returns true for files when no .gitignore exists", () => {
const filePath = path.join(integrationRoot, "src", "file.ts");
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "content");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(filePath)).toBe(true);
});

// root/
// ├── .gitignore (*.log, dist/)
// ├── debug.log ❌ ignored
// ├── dist/
// │ └── bundle.js ❌ ignored
// └── src/
// └── index.ts ✓ not ignored
it("filters files matching root .gitignore patterns", () => {
fs.writeFileSync(path.join(integrationRoot, ".gitignore"), "*.log\ndist/");
const logFile = path.join(integrationRoot, "debug.log");
const distFile = path.join(integrationRoot, "dist", "bundle.js");
const srcFile = path.join(integrationRoot, "src", "index.ts");

fs.writeFileSync(logFile, "log content");
fs.mkdirSync(path.dirname(distFile), { recursive: true });
fs.writeFileSync(distFile, "bundle content");
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
fs.writeFileSync(srcFile, "source content");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(logFile)).toBe(false);
expect(gitignoreFilter(distFile)).toBe(false);
expect(gitignoreFilter(srcFile)).toBe(true);
});

// root/
// ├── .gitignore (*.log, !important.log)
// ├── debug.log ❌ ignored
// └── important.log ✓ not ignored (negated)
it("handles negation patterns", () => {
fs.writeFileSync(
path.join(integrationRoot, ".gitignore"),
"*.log\n!important.log",
);
const debugLog = path.join(integrationRoot, "debug.log");
const importantLog = path.join(integrationRoot, "important.log");

fs.writeFileSync(debugLog, "debug");
fs.writeFileSync(importantLog, "important");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(debugLog)).toBe(false);
expect(gitignoreFilter(importantLog)).toBe(true);
});

// root/
// └── src/
// ├── .gitignore (dist)
// ├── dist/
// │ └── bundle.js ❌ ignored
// └── nested/
// └── dist/
// └── bundle.js ❌ ignored
it("handles unanchored pattern in nested .gitignore (should match any depth)", () => {
const srcDir = path.join(integrationRoot, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, ".gitignore"), "dist");

const srcDist = path.join(srcDir, "dist", "bundle.js");
const nestedDist = path.join(srcDir, "nested", "dist", "bundle.js");

fs.mkdirSync(path.dirname(srcDist), { recursive: true });
fs.writeFileSync(srcDist, "bundle");
fs.mkdirSync(path.dirname(nestedDist), { recursive: true });
fs.writeFileSync(nestedDist, "nested bundle");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(srcDist)).toBe(false);
expect(gitignoreFilter(nestedDist)).toBe(false);
});

// root/
// ├── .gitignore (/build)
// ├── build/
// │ └── output.js ❌ ignored (root /build)
// └── src/
// ├── build
// │ └── output.js ✓ not ignored
// └── index.ts ✓ not ignored
it("handles absolute path patterns with leading slash", () => {
fs.writeFileSync(path.join(integrationRoot, ".gitignore"), "/build");
const rootBuild = path.join(integrationRoot, "build", "output.js");
const srcFile = path.join(integrationRoot, "src", "index.ts");
const srcBuild = path.join(integrationRoot, "src", "build", "output.js");

fs.mkdirSync(path.dirname(rootBuild), { recursive: true });
fs.writeFileSync(rootBuild, "root build");
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
fs.writeFileSync(srcFile, "source");

fs.mkdirSync(path.dirname(srcBuild), { recursive: true });
fs.writeFileSync(srcBuild, "src build");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(rootBuild)).toBe(false);
expect(gitignoreFilter(srcFile)).toBe(true);
expect(gitignoreFilter(srcBuild)).toBe(true);
});

// root/
// ├── .gitignore (*.log)
// ├── root.log ❌ ignored
// └── src/
// ├── .gitignore (temp/)
// ├── src.log ❌ ignored (from root)
// ├── index.ts ✓ not ignored
// └── temp/
// └── cache.txt ❌ ignored (from src/.gitignore)
it("handles nested .gitignore files", () => {
fs.writeFileSync(path.join(integrationRoot, ".gitignore"), "*.log");
const srcDir = path.join(integrationRoot, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, ".gitignore"), "temp/");

const rootLog = path.join(integrationRoot, "root.log");
const srcLog = path.join(srcDir, "src.log");
const srcTemp = path.join(srcDir, "temp", "cache.txt");
const srcFile = path.join(srcDir, "index.ts");

fs.writeFileSync(rootLog, "root log");
fs.writeFileSync(srcLog, "src log");
fs.mkdirSync(path.dirname(srcTemp), { recursive: true });
fs.writeFileSync(srcTemp, "cache");
fs.writeFileSync(srcFile, "source");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(rootLog)).toBe(false);
expect(gitignoreFilter(srcLog)).toBe(false);
expect(gitignoreFilter(srcTemp)).toBe(false);
expect(gitignoreFilter(srcFile)).toBe(true);
});

// root/
// └── src/
// ├── .gitignore (*.generated.ts, !/keep.generated.ts)
// ├── api.generated.ts ❌ ignored
// └── keep.generated.ts ✓ not ignored (negated)
it("handles negation with leading slash in subdirectory", () => {
const srcDir = path.join(integrationRoot, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, ".gitignore"),
"*.generated.ts\n!/keep.generated.ts",
);

const ignoredFile = path.join(srcDir, "api.generated.ts");
const keptFile = path.join(srcDir, "keep.generated.ts");

fs.writeFileSync(ignoredFile, "generated");
fs.writeFileSync(keptFile, "keep");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(ignoredFile)).toBe(false);
expect(gitignoreFilter(keptFile)).toBe(true);
});

// root/
// ├── .gitignore (# comment, *.log, # comment)
// ├── debug.log ❌ ignored
// └── index.ts ✓ not ignored
it("ignores comments and empty lines", () => {
fs.writeFileSync(
path.join(integrationRoot, ".gitignore"),
"# This is a comment\n\n*.log\n \n# Another comment",
);
const logFile = path.join(integrationRoot, "debug.log");
const tsFile = path.join(integrationRoot, "index.ts");

fs.writeFileSync(logFile, "log");
fs.writeFileSync(tsFile, "source");

const host = createDiskBackedLinterHost(integrationRoot);
const gitignoreFilter = createGitignoreFilter(integrationRoot, host);
expect(gitignoreFilter(logFile)).toBe(false);
expect(gitignoreFilter(tsFile)).toBe(true);
});
});
62 changes: 62 additions & 0 deletions packages/core/src/running/createGitignoreFilter.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

[Question] This is a lot of added logic. Back in #1856 (comment) I'd found a couple of other packages: gitignore-to-glob, globify-gitignore. Regardless of what package we use, I think it would be nice to have to manage this logic ourselves. Did you try out those packages and/or others? Is there some reason they aren't workable?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd found a couple of other packages: gitignore-to-glob, globify-gitignore.

Both of them are no longer maintained. The logic also isn’t particularly complex — for example, gitignore-to-glob is only around 80 lines of code.

Regardless of what package we use, I think it would be nice to have to manage this logic ourselves.

100 percent agree.

Did you try out those packages and/or others? Is there some reason they aren't

I didn't try them

Copy link
Collaborator

Choose a reason for hiding this comment

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

globify-gitignore doesn't have any open issues or PRs, so I think it might just be "done" (working as expected) rather than not maintained? 3 years doesn't seem too long to me.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { makeAbsolute } from "@flint.fyi/utils";
import ignore from "ignore";
import path from "path";

import type { LinterHost } from "../types/host.ts";

export function createGitignoreFilter(cwd: string, host: LinterHost) {
const ig = ignore();
const visited = new Set();

function loadDir(dir: string): void {
if (visited.has(dir) || !dir.startsWith(cwd)) {
return;
}

const parent = path.dirname(dir);
if (parent !== dir) {
loadDir(parent);
}
visited.add(dir);

const gitignorePath = path.join(dir, ".gitignore");
if (host.stat(gitignorePath) !== "file") {
return;
}

const content = host.readFile(gitignorePath);
if (content === undefined) {
return;
}

const prefix = path.relative(cwd, dir);

const rules = content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
Comment on lines +36 to +37
Copy link
Member

Choose a reason for hiding this comment

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

Leading spaces matter in gitignore:

 #foo

This is not a comment, this is an ignored file with name #foo.

Also trailing spaces should be trimmed only if they don't have preceeding \:

Trailing spaces are ignored unless they are quoted with backslash ("").

https://git-scm.com/docs/gitignore#_pattern_format


I wonder: do we even need to filter out comments and empty lines? It looks like ignore supports them: https://www.npmjs.com/package/ignore#methods

.map((rule) => {
const negated = rule.startsWith("!");
const [negatePrefix, pattern] = negated
? ["!", rule.slice(1)]
: ["", rule];

if (pattern.startsWith("/")) {
return `${negatePrefix}${prefix}${pattern}`;
}

if (prefix) {
return `${negatePrefix}${prefix}/**/${pattern}`;
}

return rule;
});

ig.add(rules);
}

return (filePath: string) => {
loadDir(path.dirname(makeAbsolute(filePath)));
return !ig.ignores(path.relative(cwd, filePath));
};
}
Loading