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

@ -23,6 +23,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t
- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing. - After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing.
- NEVER run: `npm run dev`, `npm run build`, `npm test` - NEVER run: `npm run dev`, `npm run build`, `npm test`
- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` - Only run specific tests if user instructs: `npm test -- test/specific.test.ts`
- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed.
- NEVER commit unless user asks - NEVER commit unless user asks
## GitHub Issues ## GitHub Issues

View file

@ -5,6 +5,7 @@
### Fixed ### Fixed
- Read tool now handles macOS filenames with curly quotes (U+2019) and NFD Unicode normalization ([#1078](https://github.com/badlogic/pi-mono/issues/1078)) - Read tool now handles macOS filenames with curly quotes (U+2019) and NFD Unicode normalization ([#1078](https://github.com/badlogic/pi-mono/issues/1078))
- Respect .gitignore, .ignore, and .fdignore files when scanning package resources for skills, prompts, themes, and extensions ([#1072](https://github.com/badlogic/pi-mono/issues/1072))
## [0.50.3] - 2026-01-29 ## [0.50.3] - 2026-01-29

View file

@ -49,6 +49,7 @@
"diff": "^8.0.2", "diff": "^8.0.2",
"file-type": "^21.1.1", "file-type": "^21.1.1",
"glob": "^11.0.3", "glob": "^11.0.3",
"ignore": "^7.0.5",
"marked": "^15.0.12", "marked": "^15.0.12",
"minimatch": "^10.1.1", "minimatch": "^10.1.1",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",

View file

@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; 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 { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js"; import { CONFIG_DIR_NAME } from "../config.js";
import { looksLikeGitUrl } from "../utils/git.js"; import { looksLikeGitUrl } from "../utils/git.js";
@ -115,6 +116,57 @@ const FILE_PATTERNS: Record<ResourceType, RegExp> = {
themes: /\.json$/, 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 { function isPattern(s: string): boolean {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?"); 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 }; 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[] = []; const files: string[] = [];
if (!existsSync(dir)) return files; if (!existsSync(dir)) return files;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try { try {
const entries = readdirSync(dir, { withFileTypes: true }); const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { 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) { if (isDir) {
files.push(...collectFiles(fullPath, filePattern, skipNodeModules)); files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root));
} else if (isFile && filePattern.test(entry.name)) { } else if (isFile && filePattern.test(entry.name)) {
files.push(fullPath); files.push(fullPath);
} }
@ -169,10 +235,19 @@ function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true):
return files; return files;
} }
function collectSkillEntries(dir: string, includeRootFiles = true): string[] { function collectSkillEntries(
dir: string,
includeRootFiles = true,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): string[] {
const entries: string[] = []; const entries: string[] = [];
if (!existsSync(dir)) return entries; if (!existsSync(dir)) return entries;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try { try {
const dirEntries = readdirSync(dir, { withFileTypes: true }); const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) { 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) { if (isDir) {
entries.push(...collectSkillEntries(fullPath, false)); entries.push(...collectSkillEntries(fullPath, false, ig, root));
} else if (isFile) { } else if (isFile) {
const isRootMd = includeRootFiles && entry.name.endsWith(".md"); const isRootMd = includeRootFiles && entry.name.endsWith(".md");
const isSkillMd = !includeRootFiles && entry.name === "SKILL.md"; const isSkillMd = !includeRootFiles && entry.name === "SKILL.md";
@ -218,6 +297,9 @@ function collectAutoPromptEntries(dir: string): string[] {
const entries: string[] = []; const entries: string[] = [];
if (!existsSync(dir)) return entries; if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try { try {
const dirEntries = readdirSync(dir, { withFileTypes: true }); const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) { 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")) { if (isFile && entry.name.endsWith(".md")) {
entries.push(fullPath); entries.push(fullPath);
} }
@ -249,6 +334,9 @@ function collectAutoThemeEntries(dir: string): string[] {
const entries: string[] = []; const entries: string[] = [];
if (!existsSync(dir)) return entries; if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try { try {
const dirEntries = readdirSync(dir, { withFileTypes: true }); const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) { 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")) { if (isFile && entry.name.endsWith(".json")) {
entries.push(fullPath); entries.push(fullPath);
} }
@ -320,6 +411,9 @@ function collectAutoExtensionEntries(dir: string): string[] {
const entries: string[] = []; const entries: string[] = [];
if (!existsSync(dir)) return entries; if (!existsSync(dir)) return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try { try {
const dirEntries = readdirSync(dir, { withFileTypes: true }); const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) { 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"))) { if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
entries.push(fullPath); entries.push(fullPath);
} else if (isDir) { } else if (isDir) {

View file

@ -113,6 +113,40 @@ Content`,
}); });
}); });
describe("ignore files", () => {
it("should respect .gitignore in skill directories", async () => {
const skillsDir = join(agentDir, "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, ".gitignore"), "venv\n__pycache__\n");
const goodSkillDir = join(skillsDir, "good-skill");
mkdirSync(goodSkillDir, { recursive: true });
writeFileSync(join(goodSkillDir, "SKILL.md"), "---\nname: good-skill\ndescription: Good\n---\nContent");
const ignoredSkillDir = join(skillsDir, "venv", "bad-skill");
mkdirSync(ignoredSkillDir, { recursive: true });
writeFileSync(join(ignoredSkillDir, "SKILL.md"), "---\nname: bad-skill\ndescription: Bad\n---\nContent");
settingsManager.setSkillPaths(["skills"]);
const result = await packageManager.resolve();
expect(result.skills.some((r) => r.path.includes("good-skill") && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path.includes("venv") && r.enabled)).toBe(false);
});
it("should not apply parent .gitignore to .pi auto-discovery", async () => {
writeFileSync(join(tempDir, ".gitignore"), ".pi\n");
const skillDir = join(tempDir, ".pi", "skills", "auto-skill");
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, "---\nname: auto-skill\ndescription: Auto\n---\nContent");
const result = await packageManager.resolve();
expect(result.skills.some((r) => r.path === skillPath && r.enabled)).toBe(true);
});
});
describe("resolveExtensionSources", () => { describe("resolveExtensionSources", () => {
it("should resolve local paths", async () => { it("should resolve local paths", async () => {
const extPath = join(tempDir, "ext.ts"); const extPath = join(tempDir, "ext.ts");