From 2740c8206fe2f9000c62fd02d87c71a4c08dc534 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Sat, 8 Aug 2026 20:47:28 +0200 Subject: [PATCH] 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 --- .../src/server/daemon-client.e2e.test.ts | 36 ++++++++++ .../src/utils/directory-suggestions.test.ts | 69 +++++++++++++++++++ .../server/src/utils/directory-suggestions.ts | 8 ++- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index d36589915..1c65316bd 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1188,6 +1188,42 @@ test("finds workspace files inside the OpenCode directory", async () => { } }, 30000); +test("opens an exact gitignored workspace path without offering it as a suggestion", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-gitignored-suggestion-")); + const target = path.join(cwd, "generated", "notes.md"); + + try { + execSync("git init -q", { cwd }); + writeFileSync(path.join(cwd, ".gitignore"), "generated/\n"); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, "generated notes\n"); + writeFileSync(path.join(cwd, "notes.md"), "tracked notes\n"); + + const exactResult = await ctx.client.getDirectorySuggestions({ + cwd, + query: "generated/notes.md", + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + limit: 1, + }); + const discoveryResult = await ctx.client.getDirectorySuggestions({ + cwd, + query: "notes", + includeFiles: true, + includeDirectories: false, + limit: 20, + }); + + expect(exactResult.error).toBeNull(); + expect(exactResult.entries).toEqual([{ path: "generated/notes.md", kind: "file" }]); + expect(discoveryResult.error).toBeNull(); + expect(discoveryResult.entries).toEqual([{ path: "notes.md", kind: "file" }]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("receives server_info on websocket connect", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 932e1b005..0297d8ac4 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -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 }); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index c5daa09cf..03031fbeb 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -119,6 +119,11 @@ const IGNORED_DIRECTORY_NAMES = new Set([ const directoryListCache = new Map(); const gitIgnoredPathsCache = new Map(); +// Discovery and retrieval filter differently, on purpose. Discovery — anything that ranks or +// browses candidates the caller has not named — drops gitignored and hidden entries, so pickers +// do not offer build output. Retrieval of a path the caller named exactly applies no ignore or +// hidden filtering; the only question is whether the path stays inside the root. Clicking a file +// reference an agent wrote must open it whether or not Git tracks it. export async function searchDirectoryEntries( options: SearchDirectoryEntriesOptions, ): Promise { @@ -188,7 +193,8 @@ async function findExactEntry(input: SearchInput): Promise null); if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; - if (isGitIgnoredPath(resolvedPath, input)) return null; + // No ignore filtering here: the caller named this exact path, so containment above is the + // only question left to answer. Filtering belongs to discovery, not retrieval. const info = await stat(resolvedPath).catch(() => null); const kind = getEntryKind(info); if (