mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
simulation git
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user