From b8c84f4f8c552ec14f548d443112d05372229b4f Mon Sep 17 00:00:00 2001 From: Jakub Wolniewicz Date: Sat, 24 Jan 2026 10:58:47 +0100 Subject: [PATCH] fix(opencode): resolve symlinks to directories in project picker --- packages/opencode/src/file/index.ts | 75 ++++++----- packages/opencode/test/file/symlink.test.ts | 116 ++++++++++++++++++ .../opencode/test/util/filesystem.test.ts | 28 +++++ 3 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/test/file/symlink.test.ts diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 76b7be4b72b..636a79c7d28 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -146,19 +146,32 @@ export namespace File { .readdir(Instance.directory, { withFileTypes: true }) .catch(() => [] as fs.Dirent[]) - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnore(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(Instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } + // Process top-level entries in parallel, checking symlinks + const topDirs = await Promise.all( + top.map(async (entry) => { + if (shouldIgnore(entry.name)) return null + const full = path.join(Instance.directory, entry.name) + const dir = entry.isDirectory() || (entry.isSymbolicLink() && (await Filesystem.isDir(full))) + return dir ? { name: entry.name, path: full } : null + }), + ).then((results) => results.filter((x): x is NonNullable => x !== null)) + + // Scan subdirectories in parallel + await Promise.all( + topDirs.map(async ({ name, path: base }) => { + dirs.add(name + "/") + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + + await Promise.all( + children.map(async (child) => { + if (shouldIgnoreNested(child.name)) return + const nested = path.join(base, child.name) + const dir = child.isDirectory() || (child.isSymbolicLink() && (await Filesystem.isDir(nested))) + if (dir) dirs.add(name + "/" + child.name + "/") + }), + ) + }), + ) result.dirs = Array.from(dirs).toSorted() cache = result @@ -343,24 +356,24 @@ export namespace File { throw new Error(`Access denied: path escapes project directory`) } - const nodes: Node[] = [] - for (const entry of await fs.promises - .readdir(resolved, { - withFileTypes: true, - }) - .catch(() => [])) { - if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(Instance.directory, fullPath) - const type = entry.isDirectory() ? "directory" : "file" - nodes.push({ - name: entry.name, - path: relativePath, - absolute: fullPath, - type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), - }) - } + const entries = await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => []) + const nodes = await Promise.all( + entries + .filter((entry) => !exclude.includes(entry.name)) + .map(async (entry) => { + const fullPath = path.join(resolved, entry.name) + const relativePath = path.relative(Instance.directory, fullPath) + const dir = entry.isDirectory() || (entry.isSymbolicLink() && (await Filesystem.isDir(fullPath))) + const type = dir ? "directory" : "file" + return { + name: entry.name, + path: relativePath, + absolute: fullPath, + type, + ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + } as Node + }), + ) return nodes.sort((a, b) => { if (a.type !== b.type) { return a.type === "directory" ? -1 : 1 diff --git a/packages/opencode/test/file/symlink.test.ts b/packages/opencode/test/file/symlink.test.ts new file mode 100644 index 00000000000..217481e55d8 --- /dev/null +++ b/packages/opencode/test/file/symlink.test.ts @@ -0,0 +1,116 @@ +import { test, expect, describe } from "bun:test" +import { $ } from "bun" +import path from "path" +import { File } from "../../src/file" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("File.list symlink handling", () => { + test("lists symlinks to directories as directories", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await $`mkdir -p ${dir}/real-dir`.quiet() + await Bun.write(path.join(dir, "real-dir", "file.txt"), "content") + await $`ln -s ${dir}/real-dir ${dir}/symlink-dir`.quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list() + + const realDir = result.find((n) => n.name === "real-dir") + const symlinkDir = result.find((n) => n.name === "symlink-dir") + + expect(realDir?.type).toBe("directory") + expect(symlinkDir?.type).toBe("directory") + }, + }) + }) + + test("lists symlinks to files as files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "real-file.txt"), "content") + await $`ln -s ${dir}/real-file.txt ${dir}/symlink-file.txt`.quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list() + + const realFile = result.find((n) => n.name === "real-file.txt") + const symlinkFile = result.find((n) => n.name === "symlink-file.txt") + + expect(realFile?.type).toBe("file") + expect(symlinkFile?.type).toBe("file") + }, + }) + }) + + test("handles broken symlinks gracefully", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await $`ln -s ${dir}/nonexistent ${dir}/broken-link`.quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Should not throw, broken symlink treated as file + const result = await File.list() + const brokenLink = result.find((n) => n.name === "broken-link") + expect(brokenLink?.type).toBe("file") + }, + }) + }) + + test("can list contents of symlinked directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await $`mkdir -p ${dir}/real-dir`.quiet() + await Bun.write(path.join(dir, "real-dir", "nested.txt"), "nested content") + await $`ln -s ${dir}/real-dir ${dir}/symlink-dir`.quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list("symlink-dir") + expect(result.some((n) => n.name === "nested.txt")).toBe(true) + }, + }) + }) + + test("symlinked directories are sorted with other directories", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await $`mkdir -p ${dir}/aaa-real-dir`.quiet() + await $`mkdir -p ${dir}/zzz-real-dir`.quiet() + await Bun.write(path.join(dir, "file.txt"), "content") + await $`ln -s ${dir}/aaa-real-dir ${dir}/mmm-symlink-dir`.quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list() + const dirs = result.filter((n) => n.type === "directory") + const files = result.filter((n) => n.type === "file") + + // All directories should come before files + expect(dirs.length).toBe(3) + expect(files.length).toBe(1) + + // Symlink dir should be included in directories + expect(dirs.some((d) => d.name === "mmm-symlink-dir")).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 0e5f0ba381d..9a7f9a1fb4f 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { $ } from "bun" import os from "node:os" import path from "node:path" import { mkdtemp, mkdir, rm } from "node:fs/promises" @@ -36,4 +37,31 @@ describe("util.filesystem", () => { await rm(tmp, { recursive: true, force: true }) }) + + test("isDir() follows symlinks to directories", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-")) + const realDir = path.join(tmp, "real-dir") + const symlinkDir = path.join(tmp, "symlink-dir") + const realFile = path.join(tmp, "real-file.txt") + const symlinkFile = path.join(tmp, "symlink-file.txt") + const brokenLink = path.join(tmp, "broken-link") + + await mkdir(realDir, { recursive: true }) + await Bun.write(realFile, "content") + await $`ln -s ${realDir} ${symlinkDir}`.quiet() + await $`ln -s ${realFile} ${symlinkFile}`.quiet() + await $`ln -s ${tmp}/nonexistent ${brokenLink}`.quiet() + + const cases = await Promise.all([ + Filesystem.isDir(realDir), + Filesystem.isDir(symlinkDir), + Filesystem.isDir(realFile), + Filesystem.isDir(symlinkFile), + Filesystem.isDir(brokenLink), + ]) + + expect(cases).toEqual([true, true, false, false, false]) + + await rm(tmp, { recursive: true, force: true }) + }) })