feat: add ignore patterns for collections

Add an optional 'ignore' field to collection config that accepts an array
of glob patterns to exclude from indexing. This allows collections to skip
specific subdirectories without needing separate collections.

Example YAML config:
  personal:
    path: ~/personal_synced
    pattern: '**/*.md'
    ignore:
      - 'Sessions/**'
      - 'archive/**'

The ignore patterns are passed to fast-glob's ignore option alongside the
existing hardcoded excludes (node_modules, .git, etc). Already-indexed
files matching new ignore patterns are deactivated on the next update.

Changes:
- Add ignore?: string[] to Collection interface
- Pass ignore patterns through to fast-glob in indexFiles()
- Show ignore patterns in collection list/status output
- 5 new CLI integration tests covering the feature
This commit is contained in:
Sebastian Kouba 2026-03-05 19:17:44 +01:00
parent 40610c3aa6
commit fde542cd0d
3 changed files with 126 additions and 4 deletions

View File

@ -27,6 +27,7 @@ export type ContextMap = Record<string, string>;
export interface Collection {
path: string; // Absolute path to index
pattern: string; // Glob pattern (e.g., "**/*.md")
ignore?: string[]; // Glob patterns to exclude (e.g., ["Sessions/**"])
context?: ContextMap; // Optional context definitions
update?: string; // Optional bash command to run during qmd update
includeByDefault?: boolean; // Include in queries by default (default: true)

View File

@ -522,7 +522,7 @@ async function updateCollections(): Promise<void> {
}
}
await indexFiles(col.pwd, col.glob_pattern, col.name, true);
await indexFiles(col.pwd, col.glob_pattern, col.name, true, yamlCol?.ignore);
console.log("");
}
@ -1306,6 +1306,9 @@ function collectionList(): void {
console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}${excludeTag}`);
console.log(` ${c.dim}Pattern:${c.reset} ${coll.glob_pattern}`);
if (yamlColl?.ignore?.length) {
console.log(` ${c.dim}Ignore:${c.reset} ${yamlColl.ignore.join(', ')}`);
}
console.log(` ${c.dim}Files:${c.reset} ${coll.active_count}`);
console.log(` ${c.dim}Updated:${c.reset} ${timeAgo}`);
console.log();
@ -1348,7 +1351,8 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P
// Create the collection and index files
console.log(`Creating collection '${collName}'...`);
await indexFiles(pwd, globPattern, collName);
const newColl = getCollectionFromYaml(collName);
await indexFiles(pwd, globPattern, collName, false, newColl?.ignore);
console.log(`${c.green}${c.reset} Collection '${collName}' created successfully`);
}
@ -1397,7 +1401,7 @@ function collectionRename(oldName: string, newName: string): void {
console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset}${c.cyan}qmd://${newName}/${c.reset}`);
}
async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false): Promise<void> {
async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false, ignorePatterns?: string[]): Promise<void> {
const db = getDb();
const resolvedPwd = pwd || getPwd();
const now = new Date().toISOString();
@ -1414,12 +1418,16 @@ async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, coll
console.log(`Collection: ${resolvedPwd} (${globPattern})`);
progress.indeterminate();
const allIgnore = [
...excludeDirs.map(d => `**/${d}/**`),
...(ignorePatterns || []),
];
const allFiles: string[] = await fastGlob(globPattern, {
cwd: resolvedPwd,
onlyFiles: true,
followSymbolicLinks: false,
dot: false,
ignore: excludeDirs.map(d => `**/${d}/**`),
ignore: allIgnore,
});
// Filter hidden files/folders (dot: false handles top-level but not nested)
const files = allFiles.filter(file => {

View File

@ -782,6 +782,119 @@ describe("CLI Collection Commands", () => {
});
});
// =============================================================================
// Collection Ignore Patterns
// =============================================================================
describe("collection ignore patterns", () => {
let localDbPath: string;
let localConfigDir: string;
let ignoreTestDir: string;
beforeAll(async () => {
const env = await createIsolatedTestEnv("ignore-patterns");
localDbPath = env.dbPath;
localConfigDir = env.configDir;
// Create directory structure with subdirectories to ignore
ignoreTestDir = join(testDir, "ignore-fixtures");
await mkdir(join(ignoreTestDir, "notes"), { recursive: true });
await mkdir(join(ignoreTestDir, "sessions"), { recursive: true });
await mkdir(join(ignoreTestDir, "sessions", "2026-03"), { recursive: true });
await mkdir(join(ignoreTestDir, "archive"), { recursive: true });
// Files that should be indexed
await writeFile(join(ignoreTestDir, "readme.md"), "# Main readme\nThis should be indexed.");
await writeFile(join(ignoreTestDir, "notes", "note1.md"), "# Note 1\nThis is a personal note.");
// Files that should be ignored
await writeFile(join(ignoreTestDir, "sessions", "session1.md"), "# Session 1\nThis session should be ignored.");
await writeFile(join(ignoreTestDir, "sessions", "2026-03", "session2.md"), "# Session 2\nNested session should also be ignored.");
await writeFile(join(ignoreTestDir, "archive", "old.md"), "# Old stuff\nThis archive file should be ignored.");
});
test("ignore patterns exclude matching files from indexing", async () => {
// Write YAML config with ignore patterns
await writeFile(
join(localConfigDir, "index.yml"),
`collections:
ignoretst:
path: ${ignoreTestDir}
pattern: "**/*.md"
ignore:
- "sessions/**"
- "archive/**"
`
);
const { stdout, exitCode } = await runQmd(["update"], {
cwd: ignoreTestDir,
dbPath: localDbPath,
configDir: localConfigDir,
});
expect(exitCode).toBe(0);
// Should index 2 files (readme.md + notes/note1.md), not 5
expect(stdout).toContain("2 new");
});
test("ignored files are not searchable", async () => {
const { stdout, exitCode } = await runQmd(["search", "session", "-n", "10"], {
cwd: ignoreTestDir,
dbPath: localDbPath,
configDir: localConfigDir,
});
// Should find no results since sessions/ was ignored
if (exitCode === 0) {
expect(stdout).not.toContain("session1");
expect(stdout).not.toContain("session2");
}
});
test("non-ignored files are searchable", async () => {
const { stdout, exitCode } = await runQmd(["search", "personal note", "-n", "10"], {
cwd: ignoreTestDir,
dbPath: localDbPath,
configDir: localConfigDir,
});
expect(exitCode).toBe(0);
expect(stdout).toContain("note1");
});
test("status shows ignore patterns", async () => {
const { stdout, exitCode } = await runQmd(["collection", "list"], {
cwd: ignoreTestDir,
dbPath: localDbPath,
configDir: localConfigDir,
});
expect(exitCode).toBe(0);
expect(stdout).toContain("Ignore:");
expect(stdout).toContain("sessions/**");
expect(stdout).toContain("archive/**");
});
test("collection without ignore indexes all files", async () => {
// Create a second collection without ignore
const env2 = await createIsolatedTestEnv("no-ignore");
await writeFile(
join(env2.configDir, "index.yml"),
`collections:
allfiles:
path: ${ignoreTestDir}
pattern: "**/*.md"
`
);
const { stdout, exitCode } = await runQmd(["update"], {
cwd: ignoreTestDir,
dbPath: env2.dbPath,
configDir: env2.configDir,
});
expect(exitCode).toBe(0);
// Should index all 5 files
expect(stdout).toContain("5 new");
});
});
// =============================================================================
// Output Format Tests - qmd:// URIs, context, and docid
// =============================================================================