simulation git

This commit is contained in:
James Long
2026-05-15 19:23:20 -04:00
parent 3739eee8cf
commit 33a78ead3d
3 changed files with 231 additions and 2 deletions
@@ -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),
@@ -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"
@@ -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)
})
})
})