-
Notifications
You must be signed in to change notification settings - Fork 18
feat(core): support nested .gitignore files in filtering #2156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2da7488
3a4b652
5368626
88d6b3a
6cbe010
dafe61f
d286b34
e425c9d
571dc60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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); | ||
| }); | ||
| }); |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both of them are no longer maintained. The logic also isn’t particularly complex — for example,
100 percent agree.
I didn't try them
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leading spaces matter in gitignore: This is not a comment, this is an ignored file with name Also trailing spaces should be trimmed only if they don't have preceeding
https://git-scm.com/docs/gitignore#_pattern_format I wonder: do we even need to filter out comments and empty lines? It looks like |
||
| .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)); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
APFS is case insensitive by default! #1246 (comment)
(why...)
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:
normalizePath- it normalizes slashes and casing (lowercase for case-insensitive file systems). This normalization must be idempodent.core), such asLinterHostmethods, must normalize paths before using them (even if someone passes an already normalized path, we're guarded against other cases).LinterHostmethods), we don't need to normalize them again.So, instead of
const prefix = path.relative(cwd, dir);, we should writeconst prefix = path.relative(normalizePath(cwd, host.caseSensitiveFS()), dir);.(We should probably also use
path.posix.relativeinstead ofpath.relativeto preserve forward slashes on win32?)