From 33a78ead3dd35a222e0e7336f4fc738602c0a2b1 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 15 May 2026 19:23:20 -0400 Subject: [PATCH] simulation git --- .../server/routes/instance/httpapi/server.ts | 4 +- .../opencode/src/testing/simulation/git.ts | 142 ++++++++++++++++++ .../test/testing/simulation/git.test.ts | 87 +++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/testing/simulation/git.ts create mode 100644 packages/opencode/test/testing/simulation/git.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 1d9cc41b81e..0e9fec03264 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/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 "", + 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" diff --git a/packages/opencode/test/testing/simulation/git.test.ts b/packages/opencode/test/testing/simulation/git.test.ts new file mode 100644 index 00000000000..0ab2a1a6a3a --- /dev/null +++ b/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(body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { + 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) + }) + }) +})