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 (