浏览代码

fix(core): stop repository discovery at nearest marker (#39714)

Kit Langton 2 周之前
父节点
当前提交
9d55b223bb

+ 1 - 1
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)),
       )

+ 2 - 2
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)),
       )

+ 28 - 1
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", () => {

+ 17 - 17
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<boolean>
     readonly isFile: (path: string) => Effect.Effect<boolean>
@@ -40,7 +47,7 @@ export namespace FSUtil {
     readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
     readonly resolve: (path: string) => Effect.Effect<string>
     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 up: (options: UpOptions) => Effect.Effect<string[], Error>
     readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
     readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
     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