diff --git a/.gitignore b/.gitignore index 92dd408..ffd43c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ logs node_modules temp !temp/.gitkeep + +test/.out +!test/.in +test/dir/file1 +!test/dir/file2 +**/test/dir/file3 diff --git a/package.json b/package.json index 07f8efd..1f20511 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "prepare": "simple-git-hooks" }, "dependencies": { - "find-up-simple": "^1.0.0", - "parse-gitignore": "^2.0.0" + "@eslint/compat": "^1.1.1", + "find-up-simple": "^1.0.0" }, "devDependencies": { "@antfu/eslint-config": "^2.22.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b61091a..7cc1949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,12 @@ importers: .: dependencies: + '@eslint/compat': + specifier: ^1.1.1 + version: 1.1.1 find-up-simple: specifier: ^1.0.0 version: 1.0.0 - parse-gitignore: - specifier: ^2.0.0 - version: 2.0.0 devDependencies: '@antfu/eslint-config': specifier: ^2.22.3 @@ -649,6 +649,10 @@ packages: resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@1.1.1': + resolution: {integrity: sha512-lpHyRyplhGPL5mGEh6M9O5nnKk0Gz4bFI+Zu6tKlPpDUN7XshWvH9C/px4UVm87IAANE0W81CEsNGbS1KlzXpA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.17.0': resolution: {integrity: sha512-A68TBu6/1mHHuc5YJL0U0VVeGNiklLAL6rRmhTCP2B5XjWLMnrX+HkO+IAXyHvks5cyyY1jjK5ITPQ1HGS2EVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3105,6 +3109,8 @@ snapshots: '@eslint-community/regexpp@4.11.0': {} + '@eslint/compat@1.1.1': {} + '@eslint/config-array@0.17.0': dependencies: '@eslint/object-schema': 2.1.4 diff --git a/src/index.ts b/src/index.ts index 0759fe7..0619066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import fs from 'node:fs' +import process from 'node:process' +import path from 'node:path' import { findUpSync } from 'find-up-simple' - -// @ts-expect-error missing types -import parse from 'parse-gitignore' +import { convertIgnorePatternToMinimatch } from '@eslint/compat' export interface FlatGitignoreOptions { /** @@ -12,10 +12,12 @@ export interface FlatGitignoreOptions { name?: string /** * Path to `.gitignore` files, or files with compatible formats like `.eslintignore`. + * @default ['.gitignore'] // or findUpSync('.gitignore') */ files?: string | string[] /** * Throw an error if gitignore file not found. + * @default true */ strict?: boolean /** @@ -26,6 +28,13 @@ export interface FlatGitignoreOptions { * @default false */ root?: boolean + + /** + * Current working directory. + * Used to resolve relative paths. + * @default process.cwd() + */ + cwd?: string } export interface FlatConfigItem { @@ -42,6 +51,7 @@ export default function ignore(options: FlatGitignoreOptions = {}): FlatConfigIt root = false, files: _files = root ? GITIGNORE : findUpSync(GITIGNORE) || [], strict = true, + cwd = process.cwd(), } = options const files = Array.isArray(_files) ? _files : [_files] @@ -56,14 +66,14 @@ export default function ignore(options: FlatGitignoreOptions = {}): FlatConfigIt throw error continue } - const parsed = parse(`${content}\n`) - const globs = parsed.globs() - for (const glob of globs) { - if (glob.type === 'ignore') - ignores.push(...glob.patterns) - else if (glob.type === 'unignore') - ignores.push(...glob.patterns.map((pattern: string) => `!${pattern}`)) - } + const relativePath = path.relative(cwd, path.dirname(file)).replaceAll('\\', '/') + const globs = content.split(/\r?\n/u) + .filter(line => line && !line.startsWith('#')) + .map(line => convertIgnorePatternToMinimatch(line)) + .map(glob => relativyMinimatch(glob, relativePath, cwd)) + .filter(glob => glob !== null) + + ignores.push(...globs) } if (strict && files.length === 0) @@ -75,3 +85,47 @@ export default function ignore(options: FlatGitignoreOptions = {}): FlatConfigIt ignores, } } + +function relativyMinimatch(pattern: string, relativePath: string, cwd: string) { + // if gitignore is in the current directory leave it as is + if (['', '.', '/'].includes(relativePath)) + return pattern + + const negated = pattern.startsWith('!') ? '!' : '' + let cleanPattern = negated ? pattern.slice(1) : pattern + + if (!relativePath.endsWith('/')) + relativePath = `${relativePath}/` + + const isParent = relativePath.startsWith('..') + // child directories need to just add path in start + if (!isParent) + return `${negated}${relativePath}${cleanPattern}` + + // uncle directories don't make sence + if (!relativePath.match(/^(\.\.\/)+$/)) + throw new Error('The ignore file location should be either a parent or child directory') + + // if it has ** depth it may be left as is + if (cleanPattern.startsWith('**')) + return pattern + + // if glob doesn't match the parent dirs it should be ignored + const parents = path.relative(path.resolve(cwd, relativePath), cwd).split(/[/\\]/) + + while (parents.length && cleanPattern.startsWith(`${parents[0]}/`)) { + cleanPattern = cleanPattern.slice(parents[0].length + 1) + parents.shift() + } + + // if it has ** depth it may be left as is + if (cleanPattern.startsWith('**')) + return `${negated}${cleanPattern}` + + // if all parents are out, it's clean + if (parents.length === 0) + return `${negated}${cleanPattern}` + + // otherwise it doesn't matches the current folder + return null +} diff --git a/test/index.test.ts b/test/index.test.ts index bf44118..db6b3d7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -7,30 +7,23 @@ describe('should execute tests in root folder', () => { .toMatchInlineSnapshot(` { "ignores": [ - ".cache", - "**/.cache/**", - ".DS_Store", - "**/.DS_Store/**", - ".idea", - "**/.idea/**", - "*.log", - "**/*.log/**", - "*.tgz", - "**/*.tgz/**", - "coverage", - "**/coverage/**", - "dist", - "**/dist/**", - "lib-cov", - "**/lib-cov/**", - "logs", - "**/logs/**", - "node_modules", - "**/node_modules/**", - "temp", - "**/temp/**", + "**/.cache", + "**/.DS_Store", + "**/.idea", + "**/*.log", + "**/*.tgz", + "**/coverage", + "**/dist", + "**/lib-cov", + "**/logs", + "**/node_modules", + "**/temp", "!temp/.gitkeep", - "!temp/.gitkeep/**", + "test/.out", + "!test/.in", + "test/dir/file1", + "!test/dir/file2", + "**/test/dir/file3", ], } `) diff --git a/test/parent.test.ts b/test/parent.test.ts new file mode 100644 index 0000000..ceffde0 --- /dev/null +++ b/test/parent.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import ignore from '../src/index' + +describe('should execute tests in subfolder', () => { + process.chdir('test') + it('should properly work with parent dirs', () => { + expect(ignore({ root: false })) + .toMatchInlineSnapshot(` + { + "ignores": [ + "**/.cache", + "**/.DS_Store", + "**/.idea", + "**/*.log", + "**/*.tgz", + "**/coverage", + "**/dist", + "**/lib-cov", + "**/logs", + "**/node_modules", + "**/temp", + ".out", + "!.in", + "dir/file1", + "!dir/file2", + "**/test/dir/file3", + ], + } + `) + }) +}) diff --git a/test/workspace-with-gitignore.test.ts b/test/workspace-with-gitignore.test.ts index d51d58f..89c0f65 100644 --- a/test/workspace-with-gitignore.test.ts +++ b/test/workspace-with-gitignore.test.ts @@ -9,8 +9,12 @@ describe('should execute tests in test/workspace-with-gitignore', () => { .toMatchInlineSnapshot(` { "ignores": [ - "gitignoretest", - "**/gitignoretest/**", + "rootfile", + "rootdir/", + "**/rootpath", + "rootfolder/file", + "rootfolder/dir/", + "rootfolder/path", ], } `) @@ -35,8 +39,12 @@ describe('should execute tests in test/workspace-with-gitignore', () => { .toMatchInlineSnapshot(` { "ignores": [ - "gitignoretest", - "**/gitignoretest/**", + "rootfile", + "rootdir/", + "**/rootpath", + "rootfolder/file", + "rootfolder/dir/", + "rootfolder/path", ], } `) @@ -47,8 +55,40 @@ describe('should execute tests in test/workspace-with-gitignore', () => { .toMatchInlineSnapshot(` { "ignores": [ - "gitignoretest", - "**/gitignoretest/**", + "rootfile", + "rootdir/", + "**/rootpath", + "rootfolder/file", + "rootfolder/dir/", + "rootfolder/path", + ], + } + `) + }) + + it('should work properly with nested gitignore', () => { + /** + * https://git-scm.com/docs/gitignore#_pattern_format + * If there is a separator at the beginning or middle (or both) of the pattern, + * then the pattern is relative to the directory level of the particular .gitignore file itself. + * Otherwise the pattern may also match at any level below the .gitignore level. + * + * If there is a separator at the end of the pattern then the pattern will only match directories, + * otherwise the pattern can match both files and directories. + */ + expect(ignore({ files: ['.gitignore', 'folder/.gitignore'] })) + .toMatchInlineSnapshot(` + { + "ignores": [ + "rootfile", + "rootdir/", + "**/rootpath", + "rootfolder/file", + "rootfolder/dir/", + "rootfolder/path", + "folder/file", + "folder/dir/", + "folder/**/path", ], } `) diff --git a/test/workspace-with-gitignore/.gitignore b/test/workspace-with-gitignore/.gitignore index ee0c43f..37a41b5 100644 --- a/test/workspace-with-gitignore/.gitignore +++ b/test/workspace-with-gitignore/.gitignore @@ -1 +1,7 @@ -gitignoretest \ No newline at end of file +/rootfile +/rootdir/ +rootpath + +/rootfolder/file +/rootfolder/dir/ +/rootfolder/path diff --git a/test/workspace-with-gitignore/folder/.gitignore b/test/workspace-with-gitignore/folder/.gitignore new file mode 100644 index 0000000..9af61c2 --- /dev/null +++ b/test/workspace-with-gitignore/folder/.gitignore @@ -0,0 +1,3 @@ +/file +/dir/ +path diff --git a/test/workspace-without-gitignore.test.ts b/test/workspace-without-gitignore.test.ts index 57eea04..b8f0d8a 100644 --- a/test/workspace-without-gitignore.test.ts +++ b/test/workspace-without-gitignore.test.ts @@ -9,30 +9,18 @@ describe('should execute tests in test/workspace-without-gitignore', () => { .toMatchInlineSnapshot(` { "ignores": [ - ".cache", - "**/.cache/**", - ".DS_Store", - "**/.DS_Store/**", - ".idea", - "**/.idea/**", - "*.log", - "**/*.log/**", - "*.tgz", - "**/*.tgz/**", - "coverage", - "**/coverage/**", - "dist", - "**/dist/**", - "lib-cov", - "**/lib-cov/**", - "logs", - "**/logs/**", - "node_modules", - "**/node_modules/**", - "temp", - "**/temp/**", - "!temp/.gitkeep", - "!temp/.gitkeep/**", + "**/.cache", + "**/.DS_Store", + "**/.idea", + "**/*.log", + "**/*.tgz", + "**/coverage", + "**/dist", + "**/lib-cov", + "**/logs", + "**/node_modules", + "**/temp", + "**/test/dir/file3", ], } `)