Skip to content

Commit 89bbda9

Browse files
committed
fix: support parent directory glob patterns in imports (closes #13)
The ignore package doesn't accept paths starting with "../". When glob patterns like @../../**/*.rs were used, relative paths were calculated from the agent file's directory, resulting in paths like "../../target/..." which the ignore package rejected. Fix: - Add extractGlobBaseDir() to determine actual search directory from glob - Calculate relative paths from glob's base directory, not agent file's - Load .gitignore from the correct directory Behavioral change: glob XML path attributes now show paths relative to the glob's base directory (e.g., "a.ts" instead of "imports/glob-test/a.ts")
1 parent 41a4dfb commit 89bbda9

File tree

3 files changed

+95
-12
lines changed

3 files changed

+95
-12
lines changed

src/__snapshots__/snapshot.test.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ Use these to implement the feature."
6767
exports[`Snapshot Tests Glob imports expands glob patterns to XML format 1`] = `
6868
"Here are all TypeScript files in the glob-test directory:
6969
70-
<a path="imports/glob-test/a.ts">
70+
<a path="a.ts">
7171
// File a.ts
7272
export const A = "alpha";
7373
7474
</a>
7575
76-
<b path="imports/glob-test/b.ts">
76+
<b path="b.ts">
7777
// File b.ts
7878
export const B = "beta";
7979

src/imports.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,54 @@ test("expandImports handles glob patterns", async () => {
271271
expect(result).toContain("<b path=");
272272
});
273273

274+
test("expandImports handles parent directory glob patterns (issue #13)", async () => {
275+
// Create a nested subdirectory structure to test parent directory globs
276+
// Structure: testDir/parent-glob-test/subdir/agent.md
277+
// testDir/parent-glob-test/*.rs (files to find)
278+
const parentDir = join(testDir, "parent-glob-test");
279+
const subDir = join(parentDir, "subdir");
280+
281+
// Create the Rust files in the parent directory
282+
await Bun.write(join(parentDir, "main.rs"), "fn main() {}");
283+
await Bun.write(join(parentDir, "lib.rs"), "pub mod lib;");
284+
285+
// Create dummy file in subdir to ensure it exists
286+
await Bun.write(join(subDir, "dummy.md"), "");
287+
288+
// Run glob from subdir looking at parent with ../*.rs
289+
const content = "@../*.rs";
290+
const result = await expandImports(content, subDir);
291+
292+
// Should include both .rs files
293+
expect(result).toContain("fn main() {}");
294+
expect(result).toContain("pub mod lib;");
295+
// Should be formatted as XML
296+
expect(result).toContain("main.rs");
297+
expect(result).toContain("lib.rs");
298+
});
299+
300+
test("expandImports handles deep parent directory glob patterns", async () => {
301+
// Test ../../**/*.rs pattern (2 levels up)
302+
const deepParent = join(testDir, "deep-parent");
303+
const level1 = join(deepParent, "level1");
304+
const level2 = join(level1, "level2");
305+
306+
// Create files at the top level
307+
await Bun.write(join(deepParent, "top.rs"), "// top level");
308+
await Bun.write(join(deepParent, "nested/inner.rs"), "// nested");
309+
310+
// Create dummy to ensure level2 exists
311+
await Bun.write(join(level2, "dummy.md"), "");
312+
313+
// Run glob from level2 looking 2 levels up with **/*.rs
314+
const content = "@../../**/*.rs";
315+
const result = await expandImports(content, level2);
316+
317+
// Should include both .rs files
318+
expect(result).toContain("// top level");
319+
expect(result).toContain("// nested");
320+
});
321+
274322
// Canonical path tests
275323
test("toCanonicalPath resolves symlinks to real path", async () => {
276324
// Create a real file

src/imports.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,32 @@ function formatFilesAsXml(files: Array<{ path: string; content: string }>): stri
652652
}).join("\n\n");
653653
}
654654

655+
/**
656+
* Extract the static prefix from a glob pattern (everything before the first wildcard)
657+
* Returns the directory portion that can be resolved as a path.
658+
*/
659+
function extractGlobBaseDir(pattern: string, currentFileDir: string): string {
660+
// Find the first glob character
661+
const firstWildcard = Math.min(
662+
...[pattern.indexOf('*'), pattern.indexOf('?'), pattern.indexOf('[')]
663+
.filter(i => i !== -1)
664+
.concat([pattern.length]) // fallback if no wildcards
665+
);
666+
667+
// Get everything before the wildcard
668+
const staticPrefix = pattern.slice(0, firstWildcard);
669+
670+
// Find the last directory separator in the static prefix
671+
const lastSlash = staticPrefix.lastIndexOf('/');
672+
const dirPart = lastSlash === -1 ? '' : staticPrefix.slice(0, lastSlash);
673+
674+
// Resolve the directory part
675+
if (pattern.startsWith('/')) {
676+
return dirPart || '/';
677+
}
678+
return resolve(currentFileDir, dirPart || '.');
679+
}
680+
655681
/**
656682
* Process a glob import pattern
657683
*/
@@ -661,30 +687,39 @@ async function processGlobImport(
661687
verbose: boolean
662688
): Promise<string> {
663689
const resolvedPattern = expandTilde(pattern);
664-
const baseDir = resolvedPattern.startsWith("/") ? "/" : currentFileDir;
665690

666-
// For relative patterns, we need to resolve from the current directory
667-
const globPattern = resolvedPattern.startsWith("/")
668-
? resolvedPattern
669-
: resolve(currentFileDir, resolvedPattern).replace(currentFileDir + "/", "");
691+
// Calculate the actual base directory for the glob
692+
// This handles patterns like "../../**/*.rs" by resolving the static prefix
693+
const globBaseDir = extractGlobBaseDir(resolvedPattern, currentFileDir);
670694

671695
if (verbose) {
672-
console.error(`[imports] Glob pattern: ${globPattern} in ${currentFileDir}`);
696+
console.error(`[imports] Glob pattern: ${resolvedPattern} (base: ${globBaseDir})`);
673697
}
674698

675-
// Load gitignore
676-
const ig = await loadGitignore(currentFileDir);
699+
// Load gitignore from the glob's base directory, not the agent file's directory
700+
const ig = await loadGitignore(globBaseDir);
677701

678702
// Collect matching files
703+
// Use the resolved base directory as cwd for proper pattern matching
679704
const glob = new Glob(resolvedPattern.startsWith("/") ? resolvedPattern : pattern.replace(/^\.\//, ""));
680705
const files: Array<{ path: string; content: string }> = [];
681706
let totalChars = 0;
682707

683708
const skippedBinaryFiles: string[] = [];
684709

685710
for await (const file of glob.scan({ cwd: currentFileDir, absolute: true, onlyFiles: true })) {
686-
// Check gitignore
687-
const relativePath = relative(currentFileDir, file);
711+
// Check gitignore - calculate relative path from the glob's base directory
712+
// This ensures paths don't start with "../" which the ignore package can't handle
713+
const relativePath = relative(globBaseDir, file);
714+
715+
// Skip paths that are still outside the glob base (shouldn't happen, but safety check)
716+
if (relativePath.startsWith('..')) {
717+
if (verbose) {
718+
console.error(`[imports] Skipping file outside glob base: ${file}`);
719+
}
720+
continue;
721+
}
722+
688723
if (ig.ignores(relativePath)) {
689724
continue;
690725
}

0 commit comments

Comments
 (0)