wip
This commit is contained in:
parent
8e0676ebea
commit
27de1ee982
9 changed files with 571 additions and 23 deletions
251
packages/opencode/src/cli/cmd/tui/routes/diff.tsx
Normal file
251
packages/opencode/src/cli/cmd/tui/routes/diff.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { parsePatch } from "diff"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
type DiffFile = {
|
||||
readonly file: string
|
||||
readonly patch: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const stripPrefix = (file: string | undefined) => {
|
||||
if (!file || file === "/dev/null") return undefined
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const splitRawDiff = (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 parseRawDiff = (text: string): DiffFile[] => {
|
||||
const chunks = splitRawDiff(text)
|
||||
return chunks.flatMap((chunk) => {
|
||||
const parsed = parsePatch(chunk)[0]
|
||||
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
|
||||
if (!parsed || !file) return []
|
||||
|
||||
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
|
||||
(acc, line) => ({
|
||||
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
patch: chunk,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
|
||||
} satisfies DiffFile,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const lineKind = (line: string) => {
|
||||
if (line.startsWith("+")) return "added"
|
||||
if (line.startsWith("-")) return "deleted"
|
||||
if (line.startsWith("@@")) return "hunk"
|
||||
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
|
||||
return "context"
|
||||
}
|
||||
|
||||
export function DiffViewer(props: { onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [raw] = createResource(async () => {
|
||||
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
|
||||
return result.data ?? ""
|
||||
})
|
||||
const files = createMemo(() => parseRawDiff(raw() ?? ""))
|
||||
const current = createMemo(() => files()[selected()])
|
||||
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
|
||||
|
||||
const move = (delta: number) => {
|
||||
const total = files().length
|
||||
if (total === 0) return
|
||||
setSelected((selected() + delta + total) % total)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
priority: 2000,
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "j",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
{
|
||||
key: "q",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>Diff</text>
|
||||
<text fg={theme.textMuted}>working tree</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>j/k select · q/esc close</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
|
||||
<box
|
||||
width={32}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>Files</text>
|
||||
<Switch>
|
||||
<Match when={raw.loading}>
|
||||
<text fg={theme.textMuted}>Loading diff...</text>
|
||||
</Match>
|
||||
<Match when={raw.error}>
|
||||
<text fg={theme.error}>Failed to load diff</text>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<text fg={theme.text}>No changes</text>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
|
||||
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "›" : " "}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{file.file}
|
||||
</text>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file.deletions}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.borderActive}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<Show
|
||||
when={current()}
|
||||
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
|
||||
>
|
||||
{(file) => (
|
||||
<>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={theme.text}>{file().file}</text>
|
||||
<text fg={theme.textMuted}>{file().status}</text>
|
||||
<text fg={theme.diffAdded}>+{file().additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file().deletions}</text>
|
||||
</box>
|
||||
<scrollbox flexGrow={1} minHeight={0}>
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
const kind = lineKind(line)
|
||||
return (
|
||||
<text
|
||||
fg={
|
||||
kind === "added"
|
||||
? theme.diffAdded
|
||||
: kind === "deleted"
|
||||
? theme.diffRemoved
|
||||
: kind === "hunk"
|
||||
? theme.accent
|
||||
: kind === "meta"
|
||||
? theme.textMuted
|
||||
: theme.text
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
{line || " "}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
|
|
@ -19,6 +20,15 @@ import fs from "fs/promises"
|
|||
ensureProcessMetadata("worker")
|
||||
if (process.env.OPENCODE_SIMULATION_CWD) {
|
||||
process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
|
||||
process.env.OPENCODE_TEST_HOME = process.env.OPENCODE_SIMULATION_CWD
|
||||
Global.Path.data = `${process.env.OPENCODE_SIMULATION_CWD}/.local/share/opencode`
|
||||
Global.Path.cache = `${process.env.OPENCODE_SIMULATION_CWD}/.cache/opencode`
|
||||
Global.Path.config = `${process.env.OPENCODE_SIMULATION_CWD}/.config/opencode`
|
||||
Global.Path.state = `${process.env.OPENCODE_SIMULATION_CWD}/.local/state/opencode`
|
||||
Global.Path.tmp = `${process.env.OPENCODE_SIMULATION_CWD}/tmp/opencode`
|
||||
Global.Path.bin = `${Global.Path.cache}/bin`
|
||||
Global.Path.log = `${Global.Path.data}/log`
|
||||
Global.Path.repos = `${Global.Path.data}/repos`
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => process.env.OPENCODE_SIMULATION_CWD!,
|
||||
configurable: true,
|
||||
|
|
|
|||
|
|
@ -199,8 +199,10 @@ export const layer: Layer.Layer<
|
|||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
log.info("fromDirectory dotgit discovery", { directory, dotgit, matches: dotgitMatches })
|
||||
|
||||
if (!dotgit) {
|
||||
log.info("fromDirectory no dotgit", { directory, fakeVcs })
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
|
|
@ -212,8 +214,10 @@ export const layer: Layer.Layer<
|
|||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
log.info("fromDirectory dotgit found", { directory, dotgit, sandbox, gitBinary, cachedProjectId: id })
|
||||
|
||||
if (!gitBinary) {
|
||||
log.info("fromDirectory no git binary", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
|
|
@ -223,7 +227,9 @@ export const layer: Layer.Layer<
|
|||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
log.info("fromDirectory git common-dir", { directory, sandbox, code: commonDir.code, text: commonDir.text, stderr: commonDir.stderr })
|
||||
if (commonDir.code !== 0) {
|
||||
log.info("fromDirectory git common-dir failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
|
|
@ -235,13 +241,24 @@ export const layer: Layer.Layer<
|
|||
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
|
||||
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
|
||||
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
|
||||
log.info("fromDirectory git repository metadata", {
|
||||
directory,
|
||||
sandbox,
|
||||
common,
|
||||
bareCheckCode: bareCheck.code,
|
||||
bareCheckText: bareCheck.text,
|
||||
isBareRepo,
|
||||
worktree,
|
||||
})
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(common)
|
||||
log.info("fromDirectory common cached project id", { directory, common, cachedProjectId: id })
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
log.info("fromDirectory git rev-list roots", { directory, sandbox, code: revList.code, text: revList.text, stderr: revList.stderr })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
|
|
@ -255,11 +272,14 @@ export const layer: Layer.Layer<
|
|||
}
|
||||
|
||||
if (!id) {
|
||||
log.info("fromDirectory no project id", { directory, sandbox, worktree })
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
log.info("fromDirectory git top-level", { directory, sandbox, code: topLevel.code, text: topLevel.text, stderr: topLevel.stderr })
|
||||
if (topLevel.code !== 0) {
|
||||
log.info("fromDirectory git top-level failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
|
|
@ -269,8 +289,10 @@ export const layer: Layer.Layer<
|
|||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
log.info("fromDirectory discovered git project", { directory, id, sandbox, worktree })
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
log.info("fromDirectory discovery result", data)
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
|
|
@ -292,6 +314,7 @@ export const layer: Layer.Layer<
|
|||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
log.info("fromDirectory existing project row", { directory, hasExisting: Boolean(row), existing })
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ export function make(options: Options) {
|
|||
return Effect.fail(normalized)
|
||||
}
|
||||
|
||||
const normalizeSearchStart = (file: string) => {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
|
||||
}
|
||||
|
||||
const normalizePair = (method: string, fromPath: string, toPath: string) =>
|
||||
Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
|
||||
|
||||
|
|
@ -68,7 +73,10 @@ export function make(options: Options) {
|
|||
fs.mkdirSync(root, { recursive: true })
|
||||
for (const [file, content] of Object.entries(options.files ?? {})) {
|
||||
const normalized = normalize("seed", file)
|
||||
if (typeof normalized === "string") fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||
if (typeof normalized === "string") {
|
||||
fs.mkdirSync(path.dirname(normalized), { recursive: true })
|
||||
fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||
}
|
||||
}
|
||||
|
||||
const base = FileSystem.make({
|
||||
|
|
@ -272,8 +280,9 @@ export function make(options: Options) {
|
|||
up: (methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = yield* normalizeEffect("up", methodOptions.start)
|
||||
const normalizedStop = methodOptions.stop ? yield* normalizeEffect("up", methodOptions.stop) : undefined
|
||||
let current = normalizeSearchStart(methodOptions.start)
|
||||
if (!current) return result
|
||||
const normalizedStop = methodOptions.stop ? normalizeSearchStart(methodOptions.stop) : undefined
|
||||
while (true) {
|
||||
for (const target of methodOptions.targets) {
|
||||
const file = path.join(current, target)
|
||||
|
|
@ -289,8 +298,9 @@ export function make(options: Options) {
|
|||
globUp: (pattern, start, stop) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = yield* normalizeEffect("globUp", start)
|
||||
const normalizedStop = stop ? yield* normalizeEffect("globUp", stop) : undefined
|
||||
let current = normalizeSearchStart(start)
|
||||
if (!current) return result
|
||||
const normalizedStop = stop ? normalizeSearchStart(stop) : undefined
|
||||
while (true) {
|
||||
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
||||
if (normalizedStop === current) break
|
||||
|
|
|
|||
|
|
@ -46,6 +46,24 @@ function output(value: string) {
|
|||
return Stream.make(encoder.encode(value))
|
||||
}
|
||||
|
||||
function handle(result: { stdout?: string; stderr?: string; exitCode?: number }) {
|
||||
const stdoutText = result.stdout ?? ""
|
||||
const stderrText = result.stderr ?? ""
|
||||
return makeHandle({
|
||||
pid: ProcessId(0),
|
||||
stdin: Sink.drain,
|
||||
stdout: output(stdoutText),
|
||||
stderr: output(stderrText),
|
||||
all: output(stdoutText + stderrText),
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
isRunning: Effect.succeed(false),
|
||||
exitCode: Effect.succeed(ExitCode(result.exitCode ?? 0)),
|
||||
kill: () => Effect.void,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
}
|
||||
|
||||
function cwd(options: Options, command: ChildProcess.StandardCommand) {
|
||||
const root = path.resolve(options.root)
|
||||
const resolved = path.resolve(root, command.options.cwd ?? root)
|
||||
|
|
@ -56,33 +74,25 @@ function cwd(options: Options, command: ChildProcess.StandardCommand) {
|
|||
export function make(options: Options) {
|
||||
const spawn = Effect.fn("SimulationSpawner.spawn")(function* (command: ChildProcess.Command) {
|
||||
if (command._tag !== "StandardCommand") return yield* error("spawn", command, "Piped commands are not supported")
|
||||
const workingDirectory = yield* cwd(options, command)
|
||||
if (Shell.name(command.command) === "git") {
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--git-common-dir")) return handle({ stdout: ".git\n" })
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--show-toplevel")) return handle({ stdout: `${workingDirectory}\n` })
|
||||
if (command.args[0] === "rev-parse") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "rev-list") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "config" && command.args.includes("core.bare")) return handle({ stdout: "false\n" })
|
||||
}
|
||||
if (!isShell(command)) return yield* error("spawn", command, "Only shell commands are supported in simulation")
|
||||
|
||||
const text = commandText(command)
|
||||
if (!text) return yield* error("spawn", command, "Shell command did not include command text")
|
||||
|
||||
const workingDirectory = yield* cwd(options, command)
|
||||
const result = yield* Effect.promise(() =>
|
||||
new Bash({ fs: options.fs, cwd: workingDirectory }).exec(text, {
|
||||
env: Object.fromEntries(Object.entries(command.options.env ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
|
||||
}),
|
||||
)
|
||||
const stdout = output(result.stdout)
|
||||
const stderr = output(result.stderr)
|
||||
|
||||
return makeHandle({
|
||||
pid: ProcessId(0),
|
||||
stdin: Sink.drain,
|
||||
stdout,
|
||||
stderr,
|
||||
all: output(result.stdout + result.stderr),
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
isRunning: Effect.succeed(false),
|
||||
exitCode: Effect.succeed(ExitCode(result.exitCode)),
|
||||
kill: () => Effect.void,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
return handle(result)
|
||||
})
|
||||
|
||||
return makeSpawner(spawn)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ describe("SimulationFileSystem", () => {
|
|||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "README.md"))).toBe("hello")
|
||||
expect(yield* fs.readFileString(path.join(root, "src/index.ts"))).toBe("export const value = 1\n")
|
||||
expect(yield* fs.isDir(path.join(root, "src"))).toBe(true)
|
||||
yield* fs.writeWithDirs(path.join(root, "tmp", "result.txt"), "done")
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "tmp", "result.txt"))).toBe("done")
|
||||
|
|
@ -55,6 +57,15 @@ describe("SimulationFileSystem", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("returns no upward matches when search starts outside the simulated root", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.up({ targets: [".opencode"], start: "/Users/james", stop: "/Users/james" })).toEqual([])
|
||||
expect(yield* fs.globUp("*.json", "/Users/james", "/Users/james")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
const shared = new InMemoryFs()
|
||||
const sharedIt = testEffect(SimulationFileSystem.layer({ root, fs: shared }))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"actions": [
|
||||
{ "type": "writeFile", "path": ".git/HEAD", "content": "ref: refs/heads/main\n" },
|
||||
{ "type": "writeFile", "path": ".git/config", "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch \"main\"]\n" },
|
||||
{ "type": "writeFile", "path": "src/delta-23.txt", "content": "delta_23 note 1: alpha-41 glade-90.\ndelta_23 note 2: iris-73 juniper-33.\ndelta_23 note 3: ember-41 cedar-12." },
|
||||
{ "type": "writeFile", "path": "src/cedar-20.txt", "content": "cedar_20 note 1: cedar-94 field-8.\ncedar_20 note 2: delta-21 juniper-19.\ncedar_20 note 3: bravo-42 juniper-35.\ncedar_20 note 4: cedar-25 delta-87." },
|
||||
{ "type": "writeFile", "path": "src/bravo-86.txt", "content": "bravo_86 note 1: bravo-42 harbor-20.\nbravo_86 note 2: delta-92 cedar-97.\nbravo_86 note 3: juniper-72 juniper-90.\nbravo_86 note 4: cedar-15 field-91." },
|
||||
{ "type": "writeFile", "path": "src/bravo-11.ts", "content": "const bravo_11Defaults = {\n retries: 1,\n timeout: 220,\n label: \"iris-21\",\n} as const\n\nexport async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {}) {\n const config = { ...bravo_11Defaults, ...input }\n await Promise.resolve()\n return {\n ...config,\n ready: config.retries > 0 && config.timeout > 0,\n }\n}\n" },
|
||||
{ "type": "writeFile", "path": "src/iris-53.ts", "content": "export class iris_53Store {\n #items = new Map<string, number>()\n\n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n return this\n }\n\n snapshot() {\n return Object.fromEntries(this.#items.entries())\n }\n}\n\nexport const iris_53StoreInstance = new iris_53Store().add(\"harbor-38\", 4)\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-2/delta-75.ts", "content": "export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/harbor-79.ts", "content": "export type harbor_79Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/alpha-56.txt", "content": "alpha_56 note 1: iris-27 ember-92.\nalpha_56 note 2: iris-35 cedar-50.\nalpha_56 note 3: iris-71 glade-35.\nalpha_56 note 4: field-83 harbor-24.\nalpha_56 note 5: field-9 cedar-86.\nalpha_56 note 6: harbor-11 juniper-49." },
|
||||
{ "type": "writeFile", "path": "_patches/001-delta-75.ts.patch", "content": "diff --git a/src/dir-2/delta-75.ts b/src/dir-2/delta-75.ts\nindex 9ed8735..df7ce67 100644\n--- a/src/dir-2/delta-75.ts\n+++ b/src/dir-2/delta-75.ts\n@@ -1,12 +1,8 @@\n export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n- if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n-export const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/002-harbor-79.ts.patch", "content": "diff --git a/src/dir-4/harbor-79.ts b/src/dir-4/harbor-79.ts\nindex d7ebf78..5e2b8bf 100644\n--- a/src/dir-4/harbor-79.ts\n+++ b/src/dir-4/harbor-79.ts\n@@ -1,12 +1,9 @@\n+\n export type harbor_79Event =\n- | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n export const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "_patches/003-bravo-11.ts.patch", "content": "diff --git a/src/bravo-11.ts b/src/bravo-11.ts\nindex eaf26e4..58519e4 100644\n--- a/src/bravo-11.ts\n+++ b/src/bravo-11.ts\n@@ -1,5 +1,4 @@\n const bravo_11Defaults = {\n- retries: 1,\n timeout: 220,\n label: \"iris-21\",\n } as const\n@@ -12,3 +11,20 @@ export async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {})\n ready: config.retries > 0 && config.timeout > 0,\n }\n }\n+\n+export function generated370Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\"|\")\n+}\n+\n+export const generated160Config = {\n+ id: \"alpha-85\",\n+ retries: 5,\n+ flags: [\"iris-67\", \"ember-55\"],\n+} as const\n+\n+export const generated595Config = {\n+ id: \"alpha-55\",\n+ retries: 3,\n+ flags: [\"harbor-26\", \"harbor-77\"],\n+} as const\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/004-iris-53.ts.patch", "content": "diff --git a/src/iris-53.ts b/src/iris-53.ts\nindex 9497460..b8281a7 100644\n--- a/src/iris-53.ts\n+++ b/src/iris-53.ts\n@@ -1,9 +1,39 @@\n+\n+export function generated284Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\",\")\n+}\n+\n+export type generated458State =\n+ | { readonly ok: true; readonly value: \"harbor-83\" }\n+ | { readonly ok: false; readonly reason: \"iris-97\" }\n+\n+export type generated838State =\n+ | { readonly ok: true; readonly value: \"harbor-34\" }\n+ | { readonly ok: false; readonly reason: \"delta-29\" }\n+\n+export const generated467Items = [\n+ { key: \"glade-76\", value: 11 },\n+ { key: \"alpha-80\", value: 9 },\n+ { key: \"field-83\", value: 16 },\n+] as const\n+\n+export const generated560Config = {\n+ id: \"alpha-44\",\n+ retries: 1,\n+ flags: [\"harbor-11\", \"field-37\"],\n+} as const\n+\n+export const generated713Items = [\n+ { key: \"juniper-98\", value: 2 },\n+ { key: \"juniper-83\", value: 47 },\n+ { key: \"bravo-78\", value: 33 },\n+] as const\n+\n export class iris_53Store {\n #items = new Map<string, number>()\n \n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n- return this\n }\n \n snapshot() {\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Read the fake git diff from _patches and summarize the generated changes." }, { "type": "text", "content": "The generated project contains eight seeded files under src and four fake git patches under _patches. The patches remove several event variants from delta_75 and harbor_79, remove retries from bravo_11 while adding generated config helpers, and expand iris_53 with generated normalize/state/item exports while changing add() to stop returning this. Main risks: type errors from removed union variants, bravo_11 still referencing config.retries after deletion, and fluent chaining breakage in iris_53StoreInstance." }]], "usage": { "inputTokens": 520, "outputTokens": 96, "totalTokens": 616 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the generated project changes. Use the git diff from _patches and summarize the changed files, likely risks, and a recommended next step." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 }
|
||||
]
|
||||
}
|
||||
|
|
@ -37,7 +37,25 @@ describe("SimulationSpawner", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-shell commands", () =>
|
||||
it.effect("fakes git discovery commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const [common, topLevel, revision] = yield* Effect.all(
|
||||
[
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--git-common-dir"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: root })).pipe(Effect.scoped),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
|
||||
expect(yield* Stream.mkString(Stream.decodeText(common.stdout))).toBe(".git\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(topLevel.stdout))).toBe("/opencode\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(revision.stdout))).toBe("0000000000000000000000000000000000000000\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported non-shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const exit = yield* spawner.spawn(ChildProcess.make("git", ["status"], { cwd: root })).pipe(Effect.scoped, Effect.exit)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue