diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index c874ead415..bc6cd871ca 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -201,7 +201,7 @@ const layer = Layer.effect( locks.withLock(repository.gitDirectory)(effect) const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) { - const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe( + const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index de55eccb1e..f63ccb2569 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* ( fs: FSUtil.Interface, input: AbsolutePath, ) { - return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe( + return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined), Effect.catch(() => Effect.succeed(undefined)), ) @@ -224,7 +224,7 @@ const layer = Layer.effect( }) const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) { - const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe( + const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), ) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index f4203cfde7..0d4cbdcab6 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { Effect, FileSystem } from "effect" +import { Effect, FileSystem, Layer } from "effect" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -267,6 +267,33 @@ describe("FSUtil", () => { expect(result).toContain(path.join(tmp, "b.txt")) }), ) + + it( + "stops at the first match when requested", + Effect.gen(function* () { + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "sub") + yield* filesys.makeDirectory(child) + yield* filesys.writeFileString(path.join(child, "marker"), "child") + const checked: string[] = [] + const instrumented = FileSystem.FileSystem.of({ + ...filesys, + exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))), + }) + const search = yield* FSUtil.Service.pipe( + Effect.provide( + FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))), + ), + ) + + expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([ + path.join(child, "marker"), + ]) + expect(checked).toEqual([path.join(child, "marker")]) + }), + ) }) describe("glob", () => { diff --git a/packages/util/src/fs-util.ts b/packages/util/src/fs-util.ts index affeedebe3..58d8048a72 100644 --- a/packages/util/src/fs-util.ts +++ b/packages/util/src/fs-util.ts @@ -28,6 +28,13 @@ export namespace FSUtil { readonly type: "file" | "directory" | "symlink" | "other" } + export interface UpOptions { + readonly targets: string[] + readonly start: string + readonly stop?: string + readonly mode?: "all" | "first" + } + export interface Interface extends FileSystem.FileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect @@ -40,7 +47,7 @@ export namespace FSUtil { readonly readDirectoryEntries: (path: string) => Effect.Effect readonly resolve: (path: string) => Effect.Effect readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect - readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect + readonly up: (options: UpOptions) => Effect.Effect readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect readonly globMatch: (pattern: string, filepath: string) => boolean @@ -153,27 +160,16 @@ export namespace FSUtil { }) }) - 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 up = Effect.fn("FileSystem.up")(function* (options: UpOptions) { 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 (yield* fs.exists(search)) { + result.push(search) + if (options.mode === "first") return result + } } if (options.stop === current) break const parent = dirname(current) @@ -183,6 +179,10 @@ export namespace FSUtil { return result }) + const findUp = Effect.fn("FileSystem.findUp")((target: string, start: string, stop?: string) => + up({ targets: [target], start, stop }), + ) + const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { const result: string[] = [] let current = start