Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/core/src/environment/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,11 @@ export const typeFollowing = (files: Files, path: string) =>
),
)

/** Follows a final symlink, so a dangling symlink does not exist. */
export const exists = (files: Files, path: string) =>
typeFollowing(files, path).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)

export * as EnvironmentFiles from "./files.js"
1 change: 1 addition & 0 deletions packages/core/src/environment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as Environment from "./index.js"
export { type Driver } from "./driver.js"
export {
type DirEntry,
exists,
Failed,
type FileInfo,
type Files,
Expand Down
28 changes: 16 additions & 12 deletions packages/core/src/file-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Array, Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { Environment } from "./environment/index.js"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
Expand Down Expand Up @@ -46,7 +47,7 @@ export interface ReadOptions {

export interface Interface {
/** Resolve a lexical path and its permission resources, without requesting approval. */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, Environment.Failed>
/** Approve external directories in one batch, preserving first-seen resource order. */
readonly authorizeExternal: (
targets: readonly Target[],
Expand All @@ -58,7 +59,7 @@ export interface Interface {
file: string,
context: Invocation,
options?: ReadOptions,
) => Effect.Effect<Target, FSUtil.Error | Permission.Error | SessionErrors.NotFoundError>
) => Effect.Effect<Target, Environment.Failed | Permission.Error | SessionErrors.NotFoundError>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
Expand Down Expand Up @@ -86,7 +87,8 @@ const invocation = (context: Invocation) => ({
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const { files } = yield* Environment.Service
const exists = (file: string) => Environment.exists(files, file)
const location = yield* Location.Service
const permission = yield* Permission.Service

Expand All @@ -103,21 +105,19 @@ const layer = Layer.effect(
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
input.kind ??
(yield* Environment.typeFollowing(files, absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
))
const directory = AbsolutePath.make(type === "directory" ? absolute : path.dirname(absolute))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory,
resource: slash(path.join(directory, "*")),
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
save: slash(path.join((yield* Project.root({ exists }, directory)) ?? directory, "*")),
},
} satisfies Target
})
Expand Down Expand Up @@ -164,4 +164,8 @@ const layer = Layer.effect(
}),
)

export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Environment.node, Location.node, Permission.node],
})
25 changes: 15 additions & 10 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,21 @@ export interface Resolved {
}

// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
markers: readonly string[] = [".git", ".hg"],
) {
return yield* fs.up({ targets: [...markers], start: input, mode: "first" }).pipe(
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.orElseSucceed(() => undefined),
)
})
export const root = Effect.fn("Project.root")(
function* (
fs: { readonly exists: (path: string) => Effect.Effect<boolean, unknown> },
input: AbsolutePath,
markers: readonly string[] = [".git", ".hg"],
) {
for (let current: string = input; ; current = path.dirname(current)) {
for (const marker of markers) {
if (yield* fs.exists(path.join(current, marker))) return AbsolutePath.make(current)
}
if (path.dirname(current) === current) return undefined
}
},
Effect.orElseSucceed(() => undefined),
)

const ACTIVATE_INTERVAL = 60_000
const SWEEP_INTERVAL = Duration.hours(1)
Expand Down
60 changes: 59 additions & 1 deletion packages/core/test/file-access-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Environment } from "@opencode/core/environment/index"
import { Location } from "@opencode/core/location"
import { FileAccess } from "@opencode/core/file-access"
import { Permission } from "@opencode/core/permission"
Expand All @@ -13,10 +14,20 @@ import { location } from "./fixture/location"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"

function provide(directory: string, projectDirectory = directory) {
function provide(directory: string, projectDirectory = directory, environment?: Environment.Driver) {
return Effect.provide(
LayerNode.compile(FileAccess.node, {
replacements: [
...(environment
? [
Environment.node.replace(
Layer.succeed(
Environment.Service,
Environment.Service.of({ files: Environment.makeFiles(environment), spawner: environment.spawner }),
),
),
]
: []),
Permission.node.replace(permissionLayer()),
Location.node.replace(
Layer.succeed(
Expand Down Expand Up @@ -228,6 +239,53 @@ describe("FileAccess.resolve", () => {
),
)

it.live("saves external approval at the enclosing repository on the host filesystem", () =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const src = path.join(outside, "src")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(outside, ".git"))
await fs.mkdir(src)
})
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: src })
expect(target.externalDirectory).toMatchObject({
directory: src,
save: path.join(outside, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
),
)

it.live("stats and finds the repository through the Location's Environment driver", () => {
if (process.platform === "win32") return Effect.void
const driver = Environment.makeMemoryDriver()
const files = Environment.makeFiles(driver)
return Effect.gen(function* () {
yield* files.mkdir("/project")
yield* files.mkdir("/remote/repo/.git")
yield* files.mkdir("/remote/repo/src")
yield* driver.symlink("/remote/repo/src", "/remote/repo/linked")
const access = yield* FileAccess.Service

// These paths exist only in the memory driver, so the host filesystem would see neither the directory nor the repository.
expect((yield* access.resolve({ path: "/remote/repo/src" })).externalDirectory).toMatchObject({
directory: "/remote/repo/src",
save: "/remote/repo/*",
})
expect((yield* access.resolve({ path: "/remote/repo/linked" })).externalDirectory).toMatchObject({
directory: "/remote/repo/linked",
save: "/remote/repo/*",
})
expect((yield* access.resolve({ path: "/remote/repo/src/new.txt" })).externalDirectory).toMatchObject({
directory: "/remote/repo/src",
save: "/remote/repo/*",
})
}).pipe(provide("/project", "/project", driver))
})

test("ignores unknown path input fields", () => {
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
Expand Down
Loading