fix(server): resolve exact file paths that git ignores (#3042)
Clicking a file path an agent mentioned failed with "No file found" whenever git ignored the file, which covers anything written under a scratch directory such as .dev/. Backticked paths do not open directly. They resolve through the directory-suggestions RPC, which the daemon runs with gitignore filtering on for every workspace search. findExactEntry resolved the path, confirmed it stayed inside the workspace root, and then discarded it on the ignore check. Ignore filtering belongs to discovery. It keeps build output out of the composer's @-mention picker and out of the directory chooser, and both of those keep it. It does not belong to retrieval of a path the caller named exactly, where containment inside the root is the only question left. That check is now dropped from the exact-path branch alone. This does not widen what is readable. The file-read path applies no ignore filtering at all; resolveScopedPath checks only root containment, before and after resolving symlinks. Every file this surfaces was already readable through the file explorer. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0906d1d7bd
commit
2740c8206f
@@ -53,6 +53,7 @@ async function searchRelativeDirectoryEntries(options: {
|
||||
matchMode?: "fuzzy" | "suffix";
|
||||
maxDepth?: number;
|
||||
maxEntriesScanned?: number;
|
||||
respectGitIgnore?: boolean;
|
||||
}) {
|
||||
return searchDirectoryEntries({
|
||||
root: options.cwd,
|
||||
@@ -67,9 +68,15 @@ async function searchRelativeDirectoryEntries(options: {
|
||||
limit: options.limit,
|
||||
maxDepth: options.maxDepth,
|
||||
maxEntriesScanned: options.maxEntriesScanned,
|
||||
respectGitIgnore: options.respectGitIgnore,
|
||||
});
|
||||
}
|
||||
|
||||
function initGitRepo(directory: string, ignorePatterns: string): void {
|
||||
execFileSync("git", ["init", "-q"], { cwd: directory });
|
||||
writeFileSync(path.join(directory, ".gitignore"), ignorePatterns);
|
||||
}
|
||||
|
||||
describe("searchDirectoryEntries", () => {
|
||||
let configuredSearchRoot: string;
|
||||
let searchRoot: string;
|
||||
@@ -791,6 +798,68 @@ describe("relative typed-entry configuration", () => {
|
||||
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("resolves an exact gitignored path while keeping it out of discovery results", async () => {
|
||||
initGitRepo(workspaceDir, "generated/\n");
|
||||
mkdirSync(path.join(workspaceDir, "generated"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "generated", "notes.md"), "generated notes\n");
|
||||
|
||||
const exactResults = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "generated/notes.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
respectGitIgnore: true,
|
||||
});
|
||||
const fuzzyResults = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "notes",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
respectGitIgnore: true,
|
||||
});
|
||||
|
||||
expect({ exactResults, fuzzyResults }).toEqual({
|
||||
exactResults: [{ path: "generated/notes.md", kind: "file" }],
|
||||
fuzzyResults: [{ path: "docs/notes.md", kind: "file" }],
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(isWindows)(
|
||||
"refuses an exact path that escapes the root through a symlink",
|
||||
async () => {
|
||||
const outsideDir = path.join(tempRoot, "outside-workspace");
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
writeFileSync(path.join(outsideDir, "secret.md"), "outside\n");
|
||||
symlinkSync(outsideDir, path.join(workspaceDir, "escape-link"));
|
||||
symlinkSync(path.join(workspaceDir, "docs"), path.join(workspaceDir, "inside-link"));
|
||||
|
||||
const escaping = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "escape-link/secret.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
const staysInside = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "inside-link/notes.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect({ escaping, staysInside }).toEqual({
|
||||
escaping: [],
|
||||
staysInside: [{ path: "inside-link/notes.md", kind: "file" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("traverses only allowlisted hidden directories without suggesting the directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user