|
| 1 | +/** |
| 2 | + * Project file inventory for graph indexing. |
| 3 | + * |
| 4 | + * Git repositories use Git's own exclude engine so .gitignore, info/exclude, |
| 5 | + * and global excludes are interpreted exactly as the working tree sees them. |
| 6 | + * Non-Git fixtures fall back to a deterministic walker with common generated |
| 7 | + * directories and root .gitignore rules. |
| 8 | + */ |
| 9 | + |
| 10 | +import { execFileSync } from "node:child_process"; |
| 11 | +import { existsSync, readFileSync, readdirSync } from "node:fs"; |
| 12 | +import { join, relative, resolve } from "node:path"; |
| 13 | +import picomatch from "picomatch"; |
| 14 | +import { CODEGRAPH_DIR } from "./store.mjs"; |
| 15 | + |
| 16 | +const WALK_IGNORE_DIRS = new Set([ |
| 17 | + "node_modules", ".git", "dist", "build", "out", ".next", |
| 18 | + "__pycache__", ".venv", "venv", "vendor", "target", |
| 19 | + CODEGRAPH_DIR, ".vs", "bin", "obj", |
| 20 | +]); |
| 21 | + |
| 22 | +function normalizeRelPath(path) { |
| 23 | + return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); |
| 24 | +} |
| 25 | + |
| 26 | +function isInsideProject(relPath) { |
| 27 | + return relPath && relPath !== "." && !relPath.startsWith("../") && !relPath.startsWith("..\\"); |
| 28 | +} |
| 29 | + |
| 30 | +function gitInventory(projectPath) { |
| 31 | + try { |
| 32 | + const output = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "--", "."], { |
| 33 | + cwd: projectPath, |
| 34 | + encoding: "utf8", |
| 35 | + stdio: ["ignore", "pipe", "ignore"], |
| 36 | + timeout: 10_000, |
| 37 | + maxBuffer: 50 * 1024 * 1024, |
| 38 | + }); |
| 39 | + return output |
| 40 | + .split(/\r?\n/) |
| 41 | + .map(normalizeRelPath) |
| 42 | + .filter(isInsideProject) |
| 43 | + .filter(relPath => existsSync(join(projectPath, relPath))); |
| 44 | + } catch { |
| 45 | + return null; |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +function gitignorePatternVariants(pattern, { anchored, directoryOnly }) { |
| 50 | + const variants = []; |
| 51 | + const pathPattern = pattern.replace(/^\/+/, "").replace(/\/+$/, ""); |
| 52 | + if (!pathPattern) return variants; |
| 53 | + |
| 54 | + const hasSlash = pathPattern.includes("/"); |
| 55 | + const bases = anchored || hasSlash |
| 56 | + ? [pathPattern] |
| 57 | + : [pathPattern, `**/${pathPattern}`]; |
| 58 | + |
| 59 | + for (const base of bases) { |
| 60 | + variants.push(base); |
| 61 | + if (directoryOnly) variants.push(`${base}/**`); |
| 62 | + } |
| 63 | + return variants; |
| 64 | +} |
| 65 | + |
| 66 | +function loadRootGitignoreRules(projectPath) { |
| 67 | + const gitignorePath = join(projectPath, ".gitignore"); |
| 68 | + if (!existsSync(gitignorePath)) return []; |
| 69 | + |
| 70 | + let lines; |
| 71 | + try { |
| 72 | + lines = readFileSync(gitignorePath, "utf8").split(/\r?\n/); |
| 73 | + } catch { |
| 74 | + return []; |
| 75 | + } |
| 76 | + |
| 77 | + const rules = []; |
| 78 | + for (let raw of lines) { |
| 79 | + raw = raw.trimEnd(); |
| 80 | + if (!raw || raw.startsWith("#")) continue; |
| 81 | + |
| 82 | + const negated = raw.startsWith("!"); |
| 83 | + if (negated) raw = raw.slice(1); |
| 84 | + if (!raw || raw.startsWith("#")) continue; |
| 85 | + |
| 86 | + const anchored = raw.startsWith("/"); |
| 87 | + const directoryOnly = raw.endsWith("/"); |
| 88 | + const variants = gitignorePatternVariants(raw, { anchored, directoryOnly }); |
| 89 | + if (variants.length === 0) continue; |
| 90 | + |
| 91 | + rules.push({ |
| 92 | + negated, |
| 93 | + isMatch: picomatch(variants, { dot: true }), |
| 94 | + }); |
| 95 | + } |
| 96 | + return rules; |
| 97 | +} |
| 98 | + |
| 99 | +function isIgnoredByRules(relPath, rules) { |
| 100 | + let ignored = false; |
| 101 | + const normalized = normalizeRelPath(relPath); |
| 102 | + for (const rule of rules) { |
| 103 | + if (rule.isMatch(normalized)) ignored = !rule.negated; |
| 104 | + } |
| 105 | + return ignored; |
| 106 | +} |
| 107 | + |
| 108 | +function fallbackInventory(projectPath) { |
| 109 | + const results = []; |
| 110 | + const rules = loadRootGitignoreRules(projectPath); |
| 111 | + |
| 112 | + function walk(dir, depth = 0) { |
| 113 | + if (depth > 12) return; |
| 114 | + let entries; |
| 115 | + try { |
| 116 | + entries = readdirSync(dir, { withFileTypes: true }); |
| 117 | + } catch { |
| 118 | + return; |
| 119 | + } |
| 120 | + |
| 121 | + for (const entry of entries) { |
| 122 | + const fullPath = resolve(dir, entry.name); |
| 123 | + const relPath = normalizeRelPath(relative(projectPath, fullPath)); |
| 124 | + if (!isInsideProject(relPath)) continue; |
| 125 | + |
| 126 | + if (entry.isDirectory()) { |
| 127 | + if (WALK_IGNORE_DIRS.has(entry.name) || entry.name.startsWith(".")) continue; |
| 128 | + if (isIgnoredByRules(relPath, rules)) continue; |
| 129 | + walk(fullPath, depth + 1); |
| 130 | + continue; |
| 131 | + } |
| 132 | + |
| 133 | + if (!entry.isFile()) continue; |
| 134 | + if (isIgnoredByRules(relPath, rules)) continue; |
| 135 | + results.push(relPath); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + walk(projectPath); |
| 140 | + return results; |
| 141 | +} |
| 142 | + |
| 143 | +export function listProjectFiles(projectPath) { |
| 144 | + const absPath = resolve(projectPath); |
| 145 | + return gitInventory(absPath) ?? fallbackInventory(absPath); |
| 146 | +} |
0 commit comments