Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 44 additions & 31 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof x> => 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
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions packages/opencode/test/file/symlink.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})
28 changes: 28 additions & 0 deletions packages/opencode/test/util/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 })
})
})