mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 16:02:24 +00:00
fix(coding-agent): respect .gitignore/.ignore/.fdignore when scanning package resources
fixes #1072
This commit is contained in:
parent
678e3e28bd
commit
098f396cf3
5 changed files with 140 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue