fix(coding-agent): respect .gitignore/.ignore/.fdignore when scanning package resources

fixes #1072
This commit is contained in:
Mario Zechner 2026-01-30 01:02:45 +01:00
parent 678e3e28bd
commit 098f396cf3
5 changed files with 140 additions and 5 deletions

View file

@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { looksLikeGitUrl } from "../utils/git.js";
@ -115,6 +116,57 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
themes: /\.json$/,
};
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
type IgnoreMatcher = ReturnType<typeof ignore>;
function toPosixPath(p: string): string {
return p.split(sep).join("/");
}
function prefixIgnorePattern(line: string, prefix: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;
let pattern = line;
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = pattern.slice(1);
} else if (pattern.startsWith("\\!")) {
pattern = pattern.slice(1);
}
if (pattern.startsWith("/")) {
pattern = pattern.slice(1);
}
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
return negated ? `!${prefixed}` : prefixed;
}
function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {
const relativeDir = relative(rootDir, dir);
const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = join(dir, filename);
if (!existsSync(ignorePath)) continue;
try {
const content = readFileSync(ignorePath, "utf-8");
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) {
ig.add(patterns);
}
} catch {}
}
}
function isPattern(s: string): boolean {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
}
@ -132,10 +184,20 @@ function splitPatterns(entries: string[]): { plain: string[]; patterns: string[]
return { plain, patterns };
}
function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true): string[] {
function collectFiles(
dir: string,
filePattern: RegExp,
skipNodeModules = true,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): string[] {
const files: string[] = [];
if (!existsSync(dir)) return files;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
@ -156,8 +218,12 @@ function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true):
}
}
const relPath = toPosixPath(relative(root, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath)) continue;
if (isDir) {
files.push(...collectFiles(fullPath, filePattern, skipNodeModules));
files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root));
} else if (isFile && filePattern.test(entry.name)) {
files.push(fullPath);
}
@ -169,10 +235,19 @@ function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true):
return files;
}
function collectSkillEntries(dir: string, includeRootFiles = true): string[] {
function collectSkillEntries(
dir: string,
includeRootFiles = true,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
@ -193,8 +268,12 @@ function collectSkillEntries(dir: string, includeRootFiles = true): string[] {
}
}
const relPath = toPosixPath(relative(root, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath)) continue;
if (isDir) {
entries.push(...collectSkillEntries(fullPath, false));
entries.push(...collectSkillEntries(fullPath, false, ig, root));
} else if (isFile) {
const isRootMd = includeRootFiles && entry.name.endsWith(".md");
const isSkillMd = !includeRootFiles && entry.name === "SKILL.md";
@ -218,6 +297,9 @@ function collectAutoPromptEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
@ -234,6 +316,9 @@ function collectAutoPromptEntries(dir: string): string[] {
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath)) continue;
if (isFile && entry.name.endsWith(".md")) {
entries.push(fullPath);
}
@ -249,6 +334,9 @@ function collectAutoThemeEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
@ -265,6 +353,9 @@ function collectAutoThemeEntries(dir: string): string[] {
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath)) continue;
if (isFile && entry.name.endsWith(".json")) {
entries.push(fullPath);
}
@ -320,6 +411,9 @@ function collectAutoExtensionEntries(dir: string): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
@ -340,6 +434,10 @@ function collectAutoExtensionEntries(dir: string): string[] {
}
}
const relPath = toPosixPath(relative(dir, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath)) continue;
if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
entries.push(fullPath);
} else if (isDir) {