James Long před 3 měsíci
rodič
revize
33a78ead3d

+ 2 - 2
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -24,7 +24,6 @@ import { Command } from "@/command"
 import * as Observability from "@opencode-ai/core/effect/observability"
 import { File } from "@/file"
 import { FileWatcher } from "@/file/watcher"
-import { Git } from "@/git"
 import { Ripgrep } from "@/file/ripgrep"
 import { Format } from "@/format"
 import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -75,6 +74,7 @@ import { SimulationNetwork } from "@/testing/simulation/network"
 import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
 import { SimulationProvider } from "@/testing/simulation/provider"
 import { SimulationSpawner } from "@/testing/simulation/spawner"
+import { SimulationGit } from "@/testing/simulation/git"
 import { Simulation } from "@/testing/simulation/service"
 import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
 import { serveUIEffect } from "@/server/shared/ui"
@@ -376,7 +376,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
   )
 
   return withCoreAppServices.pipe(
-    Layer.provideMerge(Git.layer),
+    Layer.provideMerge(SimulationGit.layer),
     Layer.provideMerge(Npm.layer),
     Layer.provideMerge(Env.layer),
     Layer.provideMerge(Bus.layer),

+ 142 - 0
packages/opencode/src/testing/simulation/git.ts

@@ -0,0 +1,142 @@
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Effect, Layer } from "effect"
+import path from "path"
+import { Git } from "@/git"
+
+const emptyResult = {
+  exitCode: 0,
+  text: () => "",
+  stdout: Buffer.alloc(0),
+  stderr: Buffer.alloc(0),
+  truncated: false,
+} satisfies Git.Result
+
+const parsePathToken = (value: string) => {
+  if (!value.startsWith('"')) return value.split("\t")[0]
+  const match = /^"((?:\\.|[^"])*)"/.exec(value)
+  return match?.[1]?.replace(/\\(["\\tnr])/g, (_all, char: string) => {
+    if (char === "t") return "\t"
+    if (char === "n") return "\n"
+    if (char === "r") return "\r"
+    return char
+  })
+}
+
+const diffPath = (value: string | undefined) => {
+  if (!value || value === "/dev/null") return
+  const file = parsePathToken(value)
+  if (!file) return
+  if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
+  return file
+}
+
+const splitPatch = (text: string) => {
+  const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
+    match[0].startsWith("\n") ? match.index + 1 : match.index,
+  )
+  if (starts.length === 0) return text.trim() ? [text] : []
+  return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
+}
+
+const fileFromPatch = (patch: string) =>
+  diffPath(/^\+\+\+ (.+)$/m.exec(patch)?.[1]) ?? diffPath(/^--- (.+)$/m.exec(patch)?.[1])
+
+const statusFromPatch = (patch: string): Git.Kind => {
+  if (/^--- \/dev\/null$/m.test(patch)) return "added"
+  if (/^\+\+\+ \/dev\/null$/m.test(patch)) return "deleted"
+  return "modified"
+}
+
+const codeFromStatus = (status: Git.Kind) => {
+  if (status === "added") return "A"
+  if (status === "deleted") return "D"
+  return "M"
+}
+
+const statFromPatch = (file: string, patch: string) =>
+  patch
+    .split(/\r?\n/)
+    .filter((line) => line.startsWith("+") || line.startsWith("-"))
+    .filter((line) => !line.startsWith("+++") && !line.startsWith("---"))
+    .reduce(
+      (acc, line) => ({
+        file,
+        additions: acc.additions + (line.startsWith("+") ? 1 : 0),
+        deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
+      }),
+      { file, additions: 0, deletions: 0 } satisfies Git.Stat,
+    )
+
+const cap = (text: string, options?: Git.PatchOptions) => {
+  const truncated = options?.maxOutputBytes !== undefined && Buffer.byteLength(text) > options.maxOutputBytes
+  return { text: truncated ? text.slice(0, options.maxOutputBytes) : text, truncated } satisfies Git.Patch
+}
+
+export const layer = Layer.effect(
+  Git.Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+
+    const patches = Effect.fn("SimulationGit.patches")(function* (cwd: string) {
+      const directory = path.join(cwd, "_patches")
+      const files = yield* fs.readDirectory(directory, { recursive: true }).pipe(Effect.catch(() => Effect.succeed([])))
+      const text = yield* Effect.forEach(
+        files
+          .filter((file) => file.endsWith(".patch"))
+          .toSorted((a, b) => a.localeCompare(b)),
+        (file) => fs.readFileString(path.join(directory, file)).pipe(Effect.catch(() => Effect.succeed(""))),
+      )
+      return splitPatch(text.filter(Boolean).join("\n"))
+    })
+
+    const items = Effect.fn("SimulationGit.items")(function* (cwd: string) {
+      return (yield* patches(cwd)).flatMap((patch) => {
+        const file = fileFromPatch(patch)
+        if (!file) return []
+        const status = statusFromPatch(patch)
+        return [{ file, code: codeFromStatus(status), status } satisfies Git.Item]
+      })
+    })
+
+    const patchFor = Effect.fn("SimulationGit.patchFor")(function* (cwd: string, file: string, options?: Git.PatchOptions) {
+      return cap(
+        (yield* patches(cwd))
+          .filter((patch) => fileFromPatch(patch) === file)
+          .join(""),
+        options,
+      )
+    })
+
+    return Git.Service.of({
+      run: () => Effect.succeed(emptyResult),
+      branch: () => Effect.succeed("main"),
+      prefix: () => Effect.succeed(""),
+      defaultBranch: () => Effect.succeed({ name: "main", ref: "main" }),
+      hasHead: () => Effect.succeed(true),
+      mergeBase: () => Effect.succeed("HEAD"),
+      show: () => Effect.succeed(""),
+      status: items,
+      diff: items,
+      stats: Effect.fn("SimulationGit.stats")(function* (cwd: string) {
+        return (yield* patches(cwd)).flatMap((patch) => {
+          const file = fileFromPatch(patch)
+          if (!file) return []
+          return [statFromPatch(file, patch)]
+        })
+      }),
+      patch: (cwd, _ref, file, options) => patchFor(cwd, file, options),
+      patchAll: Effect.fn("SimulationGit.patchAll")(function* (cwd: string, _ref: string, options?: Git.PatchOptions) {
+        return cap((yield* patches(cwd)).join(""), options)
+      }),
+      patchUntracked: patchFor,
+      statUntracked: Effect.fn("SimulationGit.statUntracked")(function* (cwd: string, file: string) {
+        const patch = (yield* patches(cwd)).find((item) => fileFromPatch(item) === file)
+        if (!patch) return
+        return statFromPatch(file, patch)
+      }),
+      applyPatch: () => Effect.succeed(emptyResult),
+    })
+  }),
+)
+
+export * as SimulationGit from "./git"

+ 87 - 0
packages/opencode/test/testing/simulation/git.test.ts

@@ -0,0 +1,87 @@
+import { describe, expect, test } from "bun:test"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { ManagedRuntime, Layer } from "effect"
+import { InMemoryFs } from "just-bash"
+import { Git } from "../../../src/git"
+import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
+import { SimulationGit } from "../../../src/testing/simulation/git"
+
+const patch = `diff --git a/src/app.ts b/src/app.ts
+index 1111111..2222222 100644
+--- a/src/app.ts
++++ b/src/app.ts
+@@ -1,2 +1,2 @@
+-export const name = "old"
++export const name = "new"
+ export const stable = true
+diff --git a/docs/readme.md b/docs/readme.md
+new file mode 100644
+index 0000000..3333333
+--- /dev/null
++++ b/docs/readme.md
+@@ -0,0 +1,2 @@
++# Demo
++Notes
+`
+
+async function withFakeGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service | AppFileSystem.Service, never>) => Promise<T>) {
+  const fs = new InMemoryFs()
+  const fsLayer = SimulationFileSystem.layer({ root: "/opencode", fs })
+  const rt = ManagedRuntime.make(
+    Layer.mergeAll(fsLayer, SimulationGit.layer.pipe(Layer.provide(fsLayer))),
+  )
+  try {
+    return await body(rt)
+  } finally {
+    await rt.dispose()
+  }
+}
+
+describe("SimulationGit", () => {
+  test("loads diff data from _patches patch files", async () => {
+    await withFakeGit(async (rt) => {
+      await rt.runPromise(
+        AppFileSystem.Service.use((fs) => fs.writeWithDirs("/opencode/_patches/changes.patch", patch)),
+      )
+
+      const [status, diff, stats, all] = await Promise.all([
+        rt.runPromise(Git.Service.use((git) => git.status("/opencode"))),
+        rt.runPromise(Git.Service.use((git) => git.diff("/opencode", "HEAD"))),
+        rt.runPromise(Git.Service.use((git) => git.stats("/opencode", "HEAD"))),
+        rt.runPromise(Git.Service.use((git) => git.patchAll("/opencode", "HEAD"))),
+      ])
+
+      expect(status).toEqual([
+        { file: "src/app.ts", code: "M", status: "modified" },
+        { file: "docs/readme.md", code: "A", status: "added" },
+      ])
+      expect(diff).toEqual(status)
+      expect(stats).toEqual([
+        { file: "src/app.ts", additions: 1, deletions: 1 },
+        { file: "docs/readme.md", additions: 2, deletions: 0 },
+      ])
+      expect(all).toEqual({ text: patch, truncated: false })
+    })
+  })
+
+  test("filters individual patches and keeps inert methods successful", async () => {
+    await withFakeGit(async (rt) => {
+      await rt.runPromise(
+        AppFileSystem.Service.use((fs) => fs.writeWithDirs("/opencode/_patches/changes.patch", patch)),
+      )
+
+      const [filePatch, missingPatch, branch, applied] = await Promise.all([
+        rt.runPromise(Git.Service.use((git) => git.patch("/opencode", "HEAD", "src/app.ts"))),
+        rt.runPromise(Git.Service.use((git) => git.patch("/opencode", "HEAD", "missing.ts"))),
+        rt.runPromise(Git.Service.use((git) => git.branch("/opencode"))),
+        rt.runPromise(Git.Service.use((git) => git.applyPatch("/opencode", "not applied"))),
+      ])
+
+      expect(filePatch.text).toContain("diff --git a/src/app.ts b/src/app.ts")
+      expect(filePatch.text).not.toContain("docs/readme.md")
+      expect(missingPatch).toEqual({ text: "", truncated: false })
+      expect(branch).toBe("main")
+      expect(applied.exitCode).toBe(0)
+    })
+  })
+})