diff --git a/package.json b/package.json index fbd0340..ce8e8e8 100644 --- a/package.json +++ b/package.json @@ -94,11 +94,15 @@ "devDependencies": { "@goodbyenjn/configs": "^6.2.0", "@types/cross-spawn": "^6.0.6", + "@types/micromatch": "^4.0.10", "@types/node": "^25.6.0", "args-tokenizer": "^0.3.0", "cross-spawn": "^7.0.6", "eslint": "^10.2.1", + "ignore": "^7.0.5", "memfs": "^4.57.2", + "micromatch": "^4.0.8", + "minimatch": "^10.2.5", "prettier": "^3.8.3", "rolldown": "1.0.0-rc.18", "rolldown-plugin-dts": "^0.23.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e41cdab..4be6760 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/cross-spawn': specifier: ^6.0.6 version: 6.0.6 + '@types/micromatch': + specifier: ^4.0.10 + version: 4.0.10 '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -36,9 +39,18 @@ importers: eslint: specifier: ^10.2.1 version: 10.2.1 + ignore: + specifier: ^7.0.5 + version: 7.0.5 memfs: specifier: ^4.57.2 version: 4.57.2(tslib@2.8.1) + micromatch: + specifier: ^4.0.8 + version: 4.0.8 + minimatch: + specifier: ^10.2.5 + version: 10.2.5 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -696,6 +708,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/braces@3.0.5': + resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -717,6 +732,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/micromatch@4.0.10': + resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} + '@types/node@25.3.3': resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} @@ -876,6 +894,10 @@ packages: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -991,6 +1013,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1031,6 +1057,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1043,6 +1073,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1079,6 +1113,10 @@ packages: peerDependencies: tslib: '2' + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1123,6 +1161,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -1229,6 +1271,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -1856,6 +1902,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/braces@3.0.5': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -1874,6 +1922,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/micromatch@4.0.10': + dependencies: + '@types/braces': 3.0.5 + '@types/node@25.3.3': dependencies: undici-types: 7.18.2 @@ -2005,6 +2057,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + chai@6.2.2: {} convert-source-map@2.0.0: {} @@ -2140,6 +2196,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2173,6 +2233,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + imurmurhash@0.1.4: {} is-extglob@2.1.1: {} @@ -2181,6 +2243,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-number@7.0.0: {} + isexe@2.0.0: {} jsesc@3.1.0: {} @@ -2225,6 +2289,11 @@ snapshots: tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -2262,6 +2331,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} postcss@8.5.6: @@ -2383,6 +2454,10 @@ snapshots: tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 diff --git a/src/tools/gitignore.ts b/src/tools/gitignore.ts new file mode 100644 index 0000000..3fa90af --- /dev/null +++ b/src/tools/gitignore.ts @@ -0,0 +1,55 @@ +/* #__NO_SIDE_EFFECTS__ */ +export const convertGitignorePatternToMinimatch = (pattern: string): string | null => { + // Trim trailing unescaped whitespace char by char (git spec: trailing spaces can be marked by + // backslash). Each trailing space/tab preceded by an odd number of backslashes is "escaped" + // (preserved); all others are stripped. Processing right-to-left stops at the first escaped + // whitespace character so that double-backslash (\\) correctly un-escapes itself. + let p = pattern; + while (p.length > 0) { + const lastChar = p[p.length - 1]; + if (lastChar !== " " && lastChar !== "\t") break; + let numBackslashes = 0; + let j = p.length - 2; + while (j >= 0 && p[j] === "\\") { + numBackslashes++; + j--; + } + if (numBackslashes % 2 === 1) break; + p = p.slice(0, -1); + } + + // A blank line matches no files + if (!p) return null; + + // A line starting with # serves as a comment + if (p.startsWith("#")) return null; + + // An optional prefix ! which negates the pattern + const isNegated = p.startsWith("!"); + if (isNegated) p = p.slice(1); + + // Put a backslash in front of the first # or ! for patterns that begin with those characters + if (p.startsWith("\\#") || p.startsWith("\\!")) { + p = p.slice(1); + } + + // If there is a separator at the end of the pattern then the pattern will only match directories + const isDirectoryOnly = p.endsWith("/"); + if (isDirectoryOnly) p = p.slice(0, -1); + + // If there is a separator at the beginning or middle (or both) of the pattern, then the pattern + // is relative to the level of the particular .gitignore file itself (anchored). + // Otherwise the pattern may also match at any level below the .gitignore level. + const isAnchored = p.includes("/"); + + // Remove leading slash so the pattern is relative to root + if (p.startsWith("/")) p = p.slice(1); + + // If not anchored, add **/ prefix to allow matching at any level + if (!isAnchored) p = `**/${p}`; + + // If directory-only, add /** suffix to match all contents of the directory + if (isDirectoryOnly) p = `${p}/**`; + + return isNegated ? `!${p}` : p; +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index dadb861..1a3461b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,7 @@ export { getErrorMessage, normalizeError } from "./error"; +export { convertGitignorePatternToMinimatch } from "./gitignore"; + export { linear, scale } from "./math"; export { parseKeyValuePairs, parseValueToBoolean } from "./parse"; diff --git a/tests/tools/gitignore.test.ts b/tests/tools/gitignore.test.ts new file mode 100644 index 0000000..4ad239d --- /dev/null +++ b/tests/tools/gitignore.test.ts @@ -0,0 +1,292 @@ +/** + * E2E tests for convertGitignorePatternToMinimatch. + * + * Strategy: For each test case (pattern + set of paths), node-ignore is used as the source of + * truth to determine whether each path should be ignored. The gitignore pattern is then converted + * via convertGitignorePatternToMinimatch and the resulting minimatch/micromatch pattern is + * verified to produce the same match result on the same paths. + * + * Test cases are derived from the node-ignore project's own test fixture: + * https://github.com/kaelzhang/node-ignore/blob/master/test/fixtures/cases.js + * + * Note: Paths that are implicitly ignored in git because they reside inside a matched directory + * (but are not themselves directly matched by the pattern) are intentionally excluded from the + * path sets. Minimatch and micromatch are path-matching utilities; replicating git's + * "ignore directory contents transitively" behaviour is out of scope for this conversion utility. + */ + +import ignore from "ignore"; +import { minimatch } from "minimatch"; +import micromatch from "micromatch"; +import { describe, expect, test } from "vitest"; + +import { convertGitignorePatternToMinimatch } from "@/tools/gitignore"; + +interface TestCase { + description: string; + /** A single gitignore pattern string. */ + pattern: string; + /** + * Map from path to expected "should this path be ignored" boolean. + * These are verified against node-ignore first, then used to assert that minimatch + * and micromatch return the same result after pattern conversion. + */ + paths: Record; +} + +const TEST_CASES: TestCase[] = [ + // ------------------------------------------------------------------------- + // Blank lines and comments (git spec §gitignore) + // ------------------------------------------------------------------------- + { + description: "a blank line matches no files", + pattern: "", + paths: { a: false, "a/b/c": false }, + }, + { + description: "a line starting with # is a comment and matches nothing", + pattern: "#abc", + paths: { "#abc": false }, + }, + { + description: "backslash before # escapes the hash, making it a literal pattern", + pattern: "\\#abc", + paths: { "#abc": true, abc: false }, + }, + { + description: "backslash before ! escapes the exclamation mark, making it a literal pattern", + pattern: "\\!abc", + paths: { "!abc": true, abc: false, "b/!abc": true }, + }, + { + description: "backslash before ! with exclamation in filename also matches via wildcard", + pattern: "\\!important!.txt", + paths: { "!important!.txt": true, "b/!important!.txt": true, "important!.txt": false }, + }, + + // ------------------------------------------------------------------------- + // Trailing whitespace (git spec §gitignore) + // ------------------------------------------------------------------------- + { + description: "unescaped trailing spaces are stripped", + pattern: "bcd ", + paths: { bcd: true, "bcd ": false, "bcd ": false, "dir/bcd": true }, + }, + { + description: "trailing space quoted with backslash is preserved (one space result)", + // JS string "abc\\ " = gitignore pattern: abc\ (abc + backslash + 2 spaces) + // First space is escaped → kept, second trailing space is stripped → abc + pattern: "abc\\ ", + paths: { "abc ": true, abc: false, "abc ": false }, + }, + + // ------------------------------------------------------------------------- + // Patterns without a slash — match at any directory level + // ------------------------------------------------------------------------- + { + description: "pattern with no slash matches a filename at any level", + pattern: "a.js", + paths: { "a.js": true, "b/a/a.js": true, "a/a.js": true, "b/a.jsa": false }, + }, + { + description: "dot-prefixed file matched at any level", + pattern: ".d", + paths: { ".d": true, ".dd": false, "d.d": false, "d/.d": true }, + }, + { + description: "wildcard * pattern matches any filename at any level", + pattern: "*.log", + paths: { "test.log": true, "dir/test.log": true, "test.txt": false }, + }, + { + description: "single character wildcard ? matches exactly one non-slash character", + pattern: "foo?bar", + paths: { fooxbar: true, "foo/bar": false, fooxxbar: false, "sub/fooxbar": true }, + }, + { + description: "? at the end of an extension pattern", + pattern: "*.web?", + paths: { "a.webp": true, "a.webm": true, "a.webam": false, "dir/a.webp": true }, + }, + { + description: "character set [oa] matches one character", + pattern: "*.[oa]", + paths: { + "a.js": false, + "a.a": true, + "a.aa": false, + "a.o": true, + "a.0": false, + "sub/a.o": true, + }, + }, + { + description: "multiple character sets *.[ab][cd][ef]", + pattern: "*.[ab][cd][ef]", + paths: { "a.ace": true, "a.bdf": true, "a.bce": true, "a.abc": false, "a.aceg": false }, + }, + { + description: "character range [a-z]", + pattern: "*.pn[a-z]", + paths: { "a.png": true, "a.pna": true, "a.pn1": false }, + }, + { + description: "character range [0-9]", + pattern: "*.pn[0-9]", + paths: { "a.pn1": true, "a.pn2": true, "a.png": false, "a.pna": false }, + }, + + // ------------------------------------------------------------------------- + // Patterns containing a slash — anchored to the root + // ------------------------------------------------------------------------- + { + description: + "pattern with a slash in the middle is anchored (FNM_PATHNAME): wildcards do not cross /", + pattern: "a/a.js", + paths: { "a/a.js": true, "b/a/a.js": false, "a/a.jsa": false }, + }, + { + description: "wildcards in an anchored pattern do not match / in the pathname", + pattern: "Documentation/*.html", + paths: { + "Documentation/git.html": true, + "Documentation/ppc/ppc.html": false, + "tools/perf/Documentation/perf.html": false, + }, + }, + { + description: "leading slash anchors the pattern to the repository root", + pattern: "/*.c", + paths: { "cat-file.c": true, "mozilla-sha1/sha1.c": false }, + }, + + // ------------------------------------------------------------------------- + // Trailing slash — directory-only patterns + // ------------------------------------------------------------------------- + { + description: + "trailing slash means the pattern only matches a directory and its contents", + pattern: "abc/", + paths: { abc: false, "abc/": true, "bcd/abc/": true, "abc/file": true }, + }, + { + description: "trailing slash with middle slash is anchored at root", + pattern: "doc/frotz/", + // Has a slash in the middle → anchored; trailing slash → directory only + paths: { "doc/frotz/": true, "doc/frotz/file": true, "a/doc/frotz/": false }, + }, + { + description: "unanchored directory pattern matches at any depth", + pattern: "node_modules/", + paths: { + "node_modules/": true, + "node_modules/abc.md": true, + "node_modules/gulp/abc.md": true, + }, + }, + { + description: "file pattern 'a.js' and directory pattern 'f/' handled independently", + pattern: "f/", + paths: { "f/": true, "g/f/": true, f: false }, + }, + + // ------------------------------------------------------------------------- + // Double-star (globstar) patterns + // ------------------------------------------------------------------------- + { + description: "leading **/ matches in all directories (direct matches only)", + pattern: "**/foo", + paths: { + foo: true, + "a/foo": true, + "a/b/c/foo": true, + "a/b": false, + }, + }, + { + description: "**/foo/bar matches bar directly under any foo directory", + pattern: "**/foo/bar", + paths: { "foo/bar": true, "abc/foo/bar": true }, + }, + { + description: "trailing /** matches everything inside the named directory", + pattern: "abc/**", + paths: { + "abc/a/": true, + "abc/b": true, + "abc/d/e/f/g": true, + "bcd/abc/a": false, + abc: false, + }, + }, + { + description: "/**/ in the middle matches zero or more intermediate directories", + pattern: "a/**/b", + paths: { "a/b": true, "a/x/b": true, "a/x/y/b": true, "b/a/b": false }, + }, + + // ------------------------------------------------------------------------- + // Negation patterns — conversion only (semantics differ in multi-pattern context) + // These cases only assert the converted form starts with "!" to confirm the prefix + // is correctly preserved. Full negation behaviour requires multiple patterns working + // together and is outside the scope of this per-pattern conversion utility. + // ------------------------------------------------------------------------- + { + description: "! prefix is preserved in the converted pattern", + pattern: "!foo", + paths: {}, + }, + { + description: "! prefix with directory pattern is preserved", + pattern: "!build/", + paths: {}, + }, +]; + +describe("convertGitignorePatternToMinimatch (e2e)", () => { + for (const { description, pattern, paths } of TEST_CASES) { + test(description, () => { + // Derive the expected ignore results from node-ignore (source of truth) + const ig = ignore(); + ig.add(pattern); + + const converted = convertGitignorePatternToMinimatch(pattern); + + if (converted === null) { + // Pattern converts to null → it is a comment or blank line. + // node-ignore should not ignore any of the listed paths. + for (const [path, shouldIgnore] of Object.entries(paths)) { + expect(ig.ignores(path), `node-ignore baseline for "${path}"`).toBe( + shouldIgnore, + ); + expect(shouldIgnore, `null pattern should never ignore "${path}"`).toBe(false); + } + return; + } + + if (converted.startsWith("!")) { + // Negation pattern — only assert the converted form is correctly negated. + expect(converted).toMatch(/^!/); + return; + } + + for (const [path, shouldIgnore] of Object.entries(paths)) { + // 1. Verify node-ignore agrees with our stated expectation (guards test setup). + expect(ig.ignores(path), `node-ignore baseline for "${path}"`).toBe(shouldIgnore); + + // 2. minimatch should match iff node-ignore says ignored. + expect( + minimatch(path, converted, { dot: true }), + `minimatch("${path}", "${converted}")`, + ).toBe(shouldIgnore); + + // 3. micromatch should match iff node-ignore says ignored. + // strictSlashes: true ensures patterns like abc/** do not match "abc" itself. + expect( + micromatch.isMatch(path, converted, { dot: true, strictSlashes: true }), + `micromatch.isMatch("${path}", "${converted}")`, + ).toBe(shouldIgnore); + } + }); + } +});