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:
Christoph Leiter
2026-08-08 20:47:28 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 0906d1d7bd
commit 2740c8206f
3 changed files with 112 additions and 1 deletions
@@ -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`,
@@ -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 });
@@ -119,6 +119,11 @@ const IGNORED_DIRECTORY_NAMES = new Set([
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
const gitIgnoredPathsCache = new Map<string, GitIgnoredPathsCacheEntry>();
// 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<DirectorySuggestionEntry[]> {
@@ -188,7 +193,8 @@ async function findExactEntry(input: SearchInput): Promise<DirectorySuggestionEn
const visiblePath = path.resolve(input.root, input.plan.normalizedQuery);
const resolvedPath = await realpath(visiblePath).catch(() => 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 (