fix(coding-agent): scope nested .gitignore rules to their subtree in find
The find tool previously collected every .gitignore under the search path and passed them to fd via --ignore-file. fd treats --ignore-file entries as a single global ignore source, so rules from a/.gitignore also filtered files under sibling b/. Drop the manual collection and pass --no-require-git instead, which makes fd apply hierarchical .gitignore semantics whether or not a git repo is present. closes #3303
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- Fixed `--verbose` startup output to begin with expanded startup help and loaded resource listings after the compact startup header change ([#3147](https://github.com/badlogic/pi-mono/issues/3147))
|
- Fixed `--verbose` startup output to begin with expanded startup help and loaded resource listings after the compact startup header change ([#3147](https://github.com/badlogic/pi-mono/issues/3147))
|
||||||
- Fixed `find` tool returning no results for path-based glob patterns such as `src/**/*.spec.ts` or `some/parent/child/**` by switching fd into full-path mode and normalizing the pattern when it contains a `/` ([#3302](https://github.com/badlogic/pi-mono/issues/3302))
|
- Fixed `find` tool returning no results for path-based glob patterns such as `src/**/*.spec.ts` or `some/parent/child/**` by switching fd into full-path mode and normalizing the pattern when it contains a `/` ([#3302](https://github.com/badlogic/pi-mono/issues/3302))
|
||||||
|
- Fixed `find` tool applying nested `.gitignore` rules across sibling directories (e.g. rules from `a/.gitignore` hiding matching files under `b/`) by dropping the manual `--ignore-file` collection and delegating to fd's hierarchical `.gitignore` handling via `--no-require-git` ([#3303](https://github.com/badlogic/pi-mono/issues/3303))
|
||||||
|
|
||||||
## [0.67.5] - 2026-04-16
|
## [0.67.5] - 2026-04-16
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Text } from "@mariozechner/pi-tui";
|
|||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { glob } from "glob";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||||
import { ensureTool } from "../../utils/tools-manager.js";
|
import { ensureTool } from "../../utils/tools-manager.js";
|
||||||
@@ -225,39 +224,17 @@ export function createFindToolDefinition(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fd arguments.
|
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
|
||||||
|
// semantics whether or not the search path is inside a git repository, without
|
||||||
|
// leaking sibling-directory rules the way --ignore-file (a global source) would.
|
||||||
const args: string[] = [
|
const args: string[] = [
|
||||||
"--glob",
|
"--glob",
|
||||||
"--color=never",
|
"--color=never",
|
||||||
"--hidden",
|
"--hidden",
|
||||||
|
"--no-require-git",
|
||||||
"--max-results",
|
"--max-results",
|
||||||
String(effectiveLimit),
|
String(effectiveLimit),
|
||||||
];
|
];
|
||||||
// Include .gitignore files from the search tree.
|
|
||||||
const gitignoreFiles = new Set<string>();
|
|
||||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
|
||||||
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
|
|
||||||
try {
|
|
||||||
const nestedGitignores = await glob("**/.gitignore", {
|
|
||||||
cwd: searchPath,
|
|
||||||
dot: true,
|
|
||||||
absolute: true,
|
|
||||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
for (const file of nestedGitignores) gitignoreFiles.add(file);
|
|
||||||
} catch {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
settle(() => reject(new Error("Operation aborted")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (signal?.aborted) {
|
|
||||||
settle(() => reject(new Error("Operation aborted")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
|
||||||
|
|
||||||
// fd --glob matches against the basename unless --full-path is set; in --full-path
|
// fd --glob matches against the basename unless --full-path is set; in --full-path
|
||||||
// mode it matches against the absolute candidate path, so a path-containing
|
// mode it matches against the absolute candidate path, so a path-containing
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
|
||||||
|
*
|
||||||
|
* The `find` tool previously collected every `.gitignore` under the search
|
||||||
|
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
|
||||||
|
* entries as a single global ignore source, so rules from `a/.gitignore`
|
||||||
|
* also filtered files under sibling `b/`. The fix switches to fd's
|
||||||
|
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
|
||||||
|
* manual collection.
|
||||||
|
*/
|
||||||
|
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
|
||||||
|
async function runFind(pattern: string): Promise<string[]> {
|
||||||
|
const def = createFindToolDefinition(tempRoot);
|
||||||
|
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||||
|
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||||
|
content: Array<{ type: string; text?: string }>;
|
||||||
|
};
|
||||||
|
const text = result.content[0]?.text ?? "";
|
||||||
|
if (text === "No files found matching pattern") return [];
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.length > 0 && !l.startsWith("["))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flat sibling case", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
|
||||||
|
mkdirSync(join(tempRoot, "a"), { recursive: true });
|
||||||
|
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||||
|
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||||
|
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
|
||||||
|
const files = await runFind("**/*.txt");
|
||||||
|
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deeply nested case", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
|
||||||
|
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
|
||||||
|
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||||
|
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||||
|
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
|
||||||
|
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||||
|
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes each .gitignore to its own subtree", async () => {
|
||||||
|
const files = await runFind("**/*.txt");
|
||||||
|
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
|
||||||
|
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
|
||||||
|
// b/ is untouched by either.
|
||||||
|
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user