refactor(core): consolidate filesystem services (#30447)

This commit is contained in:
Dax 2026-06-02 16:09:26 -04:00 committed by GitHub
commit 604a5f781f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
153 changed files with 2542 additions and 4301 deletions

View file

@ -5,7 +5,7 @@ import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
@ -131,7 +131,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const fsys = yield* FSUtil.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
@ -334,7 +334,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)

View file

@ -3,7 +3,7 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionV2 } from "./permission"
@ -127,7 +127,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service

View file

@ -1,247 +1,269 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export * as FileSystem from "./filesystem"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
import path from "path"
import { pathToFileURL } from "url"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
export type Error = PlatformError | FileSystemError
export const ReadInput = Schema.Struct({
path: RelativePath,
reference: Schema.String.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
export interface DirEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
mime: Schema.String,
}) {}
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
export type Content = typeof Content.Type
export const use = serviceUse(Service)
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type FindInput = typeof FindInput.Type
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}) {}
export const Event = {
Edited: EventV2.define({
type: "file.edited",
schema: {
file: Schema.String,
},
}),
}
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
const gitignore = yield* fs
.readFileString(path.join(location.project.directory, ".gitignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (gitignore) ignored.add(gitignore)
const ignorefile = yield* fs
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const select = Effect.fnUntraced(function* (reference?: string) {
if (!reference) return { directory: location.directory, root }
const resolved = yield* references.get(reference)
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, ...selected }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!FSUtil.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
})
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
})
const scan = Effect.fnUntraced(function* () {
if (location.directory === Global.Path.home && location.project.id === "global") {
const protectedNames = Protected.names()
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
return (yield* Effect.forEach(
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
(item) =>
Effect.gen(function* () {
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
const directory = path.join(location.directory, item.name)
return [
item.name + "/",
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
? [`${item.name}/${child.name}/`]
: [],
),
]
}),
)).flat()
}
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
})
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
const dirs = new Set<string>()
for (const file of files) {
let current = file
while (true) {
const directory = path.dirname(current)
if (directory === "." || directory === current) break
current = directory
dirs.add(directory + "/")
}
}
return [...files, ...dirs]
})
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "File"
})
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
return entries.map(
(e): DirEntry => ({
name: e.name,
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
}),
)
},
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const file = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(file.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
})
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
yield* fs.makeDirectory(path, { recursive: true })
})
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
path: string,
content: string | Uint8Array,
mode?: number,
) {
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
yield* write.pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
yield* fs.makeDirectory(dirname(path), { recursive: true })
yield* write
}),
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
if (mode) yield* fs.chmod(path, mode)
})
}),
find: Effect.fn("FileSystem.find")(function* (input) {
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
const sorted = input.query.trim()
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
: filtered.slice(0, input.limit)
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
)
}),
grep: Effect.fn("FileSystem.grep")(function* (input) {
return (yield* ripgrep
.search({
cwd: location.directory,
pattern: input.pattern,
glob: input.include ? [input.include] : undefined,
limit: input.limit,
})
.pipe(Effect.orDie)).items.map(
(item) =>
new GrepMatch({
path: RelativePath.make(item.path.text),
lines: item.lines.text,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
}),
)
}),
isIgnored: (input, type) =>
ignored.ignores(
path.relative(location.project.directory, path.join(location.directory, input)) +
(type === "directory" ? "/" : ""),
),
})
}),
)
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
})
})
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
return Service.of({
...fs,
existsSafe,
readFileStringSafe,
isDir,
isFile,
readDirectoryEntries,
readJson,
writeJson,
ensureDir,
writeWithDirs,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT") return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
}
export const locationLayer = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(ProjectReference.locationLayer),
)

View file

@ -0,0 +1,67 @@
import { Glob } from "../util/glob"
const FOLDERS = new Set([
"node_modules",
"bower_components",
".pnpm-store",
"vendor",
".npm",
"dist",
"build",
"out",
".next",
"target",
"bin",
"obj",
".git",
".svn",
".hg",
".vscode",
".idea",
".turbo",
".output",
"desktop",
".sst",
".cache",
".webkit-cache",
"__pycache__",
".pytest_cache",
"mypy_cache",
".history",
".gradle",
])
const FILES = [
"**/*.swp",
"**/*.swo",
"**/*.pyc",
"**/.DS_Store",
"**/Thumbs.db",
"**/logs/**",
"**/tmp/**",
"**/temp/**",
"**/*.log",
"**/coverage/**",
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(/[/\\]/)
for (const part of parts) {
if (FOLDERS.has(part)) return true
}
for (const pattern of [...FILES, ...(opts?.extra || [])]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
export * as Ignore from "./ignore"

View file

@ -0,0 +1,53 @@
import os from "os"
import path from "path"
const home = os.homedir()
const DARWIN_HOME = [
"Music",
"Pictures",
"Movies",
"Downloads",
"Desktop",
"Documents",
"Public",
"Applications",
"Library",
]
const DARWIN_LIBRARY = [
"Application Support/AddressBook",
"Calendars",
"Mail",
"Messages",
"Safari",
"Cookies",
"Application Support/com.apple.TCC",
"PersonalizationPortrait",
"Metadata/CoreSpotlight",
"Suggestions",
]
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((name) => path.join(home, name)),
...DARWIN_LIBRARY.map((name) => path.join(home, "Library", name)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((name) => path.join(home, name))
return []
}
export * as Protected from "./protected"

View file

@ -0,0 +1,484 @@
import path from "path"
import { serviceUse } from "../effect/service-use"
import { FSUtil } from "../fs-util"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import * as Log from "../util/log"
import { sanitizedProcessEnv } from "../util/opencode-process"
import { which } from "../util/which"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const TimeStats = Schema.Struct({
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
})
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats,
}),
})
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = Schema.Union([Begin, Match, End, Summary])
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
items: Item[]
partial: boolean
}
export interface FilesInput {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}
export interface SearchInput {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
signal?: AbortSignal
}
export interface TreeInput {
cwd: string
limit?: number
signal?: AbortSignal
}
export interface Interface {
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const use = serviceUse(Service)
function env() {
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
out.name = "AbortError"
return out
}
function waitForAbort(signal?: AbortSignal) {
if (!signal) return Effect.never
if (signal.aborted) return Effect.fail(aborted(signal))
return Effect.callback<never, Error>((resume) => {
const onabort = () => resume(Effect.fail(aborted(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
}
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
return err
}
function clean(file: string) {
return path.normalize(file.replace(/^\.[\\/]/, ""))
}
function row(data: Row): Row {
return {
...data,
path: {
...data.path,
text: clean(data.path.text),
},
}
}
function parse(line: string) {
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
}
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) {
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
}
yield* fs.copyFile(extracted, target)
if (process.platform === "win32") return
yield* fs.chmod(target, 0o755)
}, Effect.scoped)
const filepath = yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) {
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
}
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) {
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
)
const check = Effect.fnUntraced(function* (cwd: string) {
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
return yield* Effect.fail(
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
code: "ENOENT",
errno: -2,
path: cwd,
}),
)
})
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
const binary = yield* filepath
return ChildProcess.make(binary, args, {
cwd,
env: env(),
extendEnv: true,
stdin: "ignore",
})
})
const files: Interface["files"] = (input) =>
Stream.callback<string, PlatformError | Error>((queue) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Effect.gen(function* () {
yield* check(input.cwd)
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
Effect.forkScoped,
)
const code = yield* raceAbort(handle.exitCode, input.signal)
yield* Fiber.join(stdout)
if (code === 0 || code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(yield* Fiber.join(stderr), code))
}).pipe(
Effect.catch((err) =>
Effect.sync(() => {
fail(queue, err)
}),
),
),
)
}),
)
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
yield* check(input.cwd)
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(parse),
Stream.filter((item): item is Match => item.type === "match"),
Stream.map((item) => row(item.data)),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(error(stderr, code))
}
return {
items: code === 1 ? [] : items,
partial: code === 2,
}
}),
)
return yield* raceAbort(program, input.signal)
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
name: string
children: Map<string, Node>
}
function child(node: Node, name: string) {
const item = node.children.get(name)
if (item) return item
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
function count(node: Node): number {
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = child(node, part)
}
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: node.name }))
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const item = queue[i]
lines.push(item.path)
used++
queue.push(
...Array.from(item.node.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
)
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
})
return Service.of({ files, tree, search })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Ripgrep from "./ripgrep"

View file

@ -0,0 +1,140 @@
export * as Watcher from "./watcher"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import * as Log from "../util/log"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: EventV2.define({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
}),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
const backend = getBackend()
const location = yield* Location.Service
if (!backend) {
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
return Service.of({})
}
const w = watcher()
if (!w) return Service.of({})
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
}
const subscribe = (directory: string, ignore: string[]) => {
const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const config = (yield* (yield* Config.Service).get()).flatMap((item) => item.info.watcher?.ignore ?? [])
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
}
if (location.vcs?.type === "git") {
const resolved = yield* git.dir(location.directory)
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
}),
),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))

View file

@ -0,0 +1,247 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type Error = PlatformError | FileSystemError
export interface DirEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
})
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "File"
})
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
return entries.map(
(e): DirEntry => ({
name: e.name,
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
}),
)
},
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
})
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
})
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
yield* fs.makeDirectory(path, { recursive: true })
})
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
path: string,
content: string | Uint8Array,
mode?: number,
) {
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
yield* write.pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
yield* fs.makeDirectory(dirname(path), { recursive: true })
yield* write
}),
),
)
if (mode) yield* fs.chmod(path, mode)
})
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
})
})
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
return Service.of({
...fs,
existsSafe,
readFileStringSafe,
isDir,
isFile,
readDirectoryEntries,
readJson,
writeJson,
ensureDir,
writeWithDirs,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT") return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
}

View file

@ -4,7 +4,7 @@ import path from "path"
import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { AppProcess } from "./process"
export interface Repo {
@ -32,6 +32,7 @@ export interface Interface {
readonly roots: (repo: Repo) => Effect.Effect<string[]>
readonly origin: (directory: string) => Effect.Effect<string | undefined>
readonly head: (directory: string) => Effect.Effect<string | undefined>
readonly dir: (directory: string) => Effect.Effect<string | undefined>
readonly branch: (directory: string) => Effect.Effect<string | undefined>
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
readonly clone: (input: {
@ -51,7 +52,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Gi
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const proc = yield* AppProcess.Service
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
@ -101,6 +102,12 @@ export const layer = Layer.effect(
return result.text.trim() || undefined
})
const dir = Effect.fn("Git.dir")(function* (directory: string) {
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
if (result.exitCode !== 0) return undefined
return AbsolutePath.make(resolvePath(directory, result.text))
})
const branch = Effect.fn("Git.branch")(function* (directory: string) {
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
if (result.exitCode !== 0) return undefined
@ -148,6 +155,7 @@ export const layer = Layer.effect(
roots,
origin,
head,
dir,
branch,
remoteHead,
clone,
@ -159,10 +167,7 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export interface Result {
readonly exitCode: number
@ -200,7 +205,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
const normalized = FSUtil.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
}

View file

@ -1,194 +0,0 @@
export * as LocationFileSystem from "./location-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { Location } from "./location"
import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
export const ReadInput = Schema.Struct({
path: RelativePath,
reference: Schema.String.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type FindInput = typeof FindInput.Type
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}) {}
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const location = yield* Location.Service
const references = yield* ProjectReference.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const select = Effect.fnUntraced(function* (reference?: string) {
if (!reference) return { directory: location.directory, root }
const resolved = yield* references.get(reference)
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!AppFileSystem.contains(selected.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!AppFileSystem.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, ...selected }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected: { directory: string; root: string }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!AppFileSystem.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
})
})
const entries = [
new Entry({
path: RelativePath.make("README.md"),
uri: pathToFileURL(path.join(location.directory, "README.md")).href,
type: "file",
mime: "text/markdown",
}),
new Entry({
path: RelativePath.make("src"),
uri: pathToFileURL(path.join(location.directory, "src")).href,
type: "directory",
mime: "application/x-directory",
}),
]
return Service.of({
read: Effect.fn("LocationFileSystem.read")(function* (input) {
const file = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
const mime = AppFileSystem.mimeType(file.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
}),
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
find: Effect.fn("LocationFileSystem.find")(function* (input) {
return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit)
}),
grep: Effect.fn("LocationFileSystem.grep")(function* (input) {
return [
new GrepMatch({
path: RelativePath.make("README.md"),
lines: "# opencode",
line: 1,
offset: 0,
submatches: [{ text: input.pattern, start: 0, end: input.pattern.length }],
}),
].slice(0, input.limit)
}),
})
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(ProjectReference.locationLayer))

View file

@ -11,13 +11,14 @@ import { EventV2 } from "./event"
import { Auth } from "./auth"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { SessionV2 } from "./session"
import { LocationFileSystem } from "./location-filesystem"
import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher"
import { ProjectReference } from "./project-reference"
import { RepositoryCache } from "./repository-cache"
@ -34,7 +35,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
AgentV2.locationLayer,
PluginBoot.locationLayer,
PermissionV2.locationLayer,
LocationFileSystem.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
@ -44,7 +46,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Auth.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Global.defaultLayer,
Database.defaultLayer,
SessionV2.defaultLayer,

View file

@ -5,7 +5,7 @@ import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
@ -125,7 +125,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const events = yield* EventV2.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
@ -227,7 +227,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)

View file

@ -4,7 +4,7 @@ import path from "path"
import npa from "npm-package-arg"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"
import { makeRuntime } from "./effect/runtime"
@ -70,7 +70,7 @@ interface ArboristTree {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
@ -246,7 +246,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(FSUtil.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)

View file

@ -4,7 +4,7 @@ import path from "path"
import { Context, Effect, Layer } from "effect"
import { Config } from "./config"
import { ConfigReference } from "./config/reference"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
@ -65,7 +65,7 @@ export const layer = Layer.effect(
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const cache = yield* RepositoryCache.Service
@ -137,7 +137,7 @@ export const layer = Layer.effect(
if (!target) return { name, kind: "reference", reference, path: reference.path }
const resolved = path.resolve(reference.path, target)
if (!AppFileSystem.contains(reference.path, resolved))
if (!FSUtil.contains(reference.path, resolved))
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
if (!(yield* fs.existsSafe(resolved)))
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
@ -228,9 +228,9 @@ function uniqueGitReferences(references: Resolved[]) {
function normalizePath(target?: string) {
if (!target) return
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function contains(parent: string, child: string) {
return AppFileSystem.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
}

View file

@ -4,7 +4,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath, withStatics } from "./schema"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Hash } from "./util/hash"
@ -55,7 +55,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const cached = Effect.fnUntraced(function* (dir: string) {
@ -126,4 +126,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))

View file

@ -1,6 +1,6 @@
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
@ -119,139 +119,142 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
})
})
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Git.Service | EffectFlock.Service | Global.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const flock = yield* EffectFlock.Service
const global = yield* Global.Service
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const flock = yield* EffectFlock.Service
const global = yield* Global.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
if (input.branch) yield* validateBranch(input.branch)
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference)
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference)
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
return yield* flock
.withLock(
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
return yield* flock
.withLock(
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })))
if (result.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: resultMessage(result, `Failed to clone ${repository}`),
})
}
}
if (status === "refreshed") {
const fetch = yield* git
.fetch(localPath)
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })))
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
if (result.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: resultMessage(result, `Failed to clone ${repository}`),
})
}
}
if (status === "refreshed") {
const fetch = yield* git
.fetch(localPath)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
}
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: errorMessage(error),
}),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
})
}
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: errorMessage(error),
}),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
}
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
)
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
message: resultMessage(reset, `Failed to reset ${repository}`),
})
}
}
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })))
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
message: resultMessage(reset, `Failed to reset ${repository}`),
})
}
}
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
} satisfies Result
}),
`repository-cache:${localPath}`,
)
.pipe(
Effect.mapError((error) =>
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
),
)
}),
})
}),
)
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
} satisfies Result
}),
`repository-cache:${localPath}`,
)
.pipe(
Effect.mapError((error) =>
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
),
)
}),
})
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Global.defaultLayer),
)

View file

@ -4,7 +4,7 @@ import { randomUUID } from "crypto"
import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect"
import type { FileSystem, Scope } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { AppFileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Hash } from "./hash"
@ -93,11 +93,11 @@ export namespace EffectFlock {
const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Global.Service | FSUtil.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const lockRoot = path.join(global.state, "locks")
const hostname = os.hostname()
const ensuredDirs = new Set<string>()
@ -279,5 +279,5 @@ export namespace EffectFlock {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
}

View file

@ -0,0 +1,14 @@
import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null
}