Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton ed580ec051 effect(core): add AppProcess.runWithStdin; migrate snapshot+clipboard
Adds a runWithStdin method to AppProcess that accepts string,
Uint8Array, or Stream<Uint8Array> and feeds it to the child process
stdin while preserving the rest of the Command options. Internally
the method delegates to the same collect-and-exit body as run.

Replaces the bespoke gitWithStdin spawn helper in snapshot (ignore,
drop, stage, cat-file --batch) with calls to runWithStdin, and
migrates the Linux/Windows clipboard write paths (wl-copy, xclip,
xsel, powershell) off util/process.ts.
2026-05-12 22:38:11 -04:00
Kit Langton a0f3716e18 effect(snapshot): migrate to AppProcess.run
Route non-stdin git calls through AppProcess.run. Keep stdin-feed
calls (check-ignore --stdin, rm --pathspec-from-file=-, add
--pathspec-from-file=-, cat-file --batch) on raw spawn — AppProcess.run
does not yet accept a stdin Stream.
2026-05-12 21:20:41 -04:00
Kit Langton bf596d5b22 effect(core): add AppProcess service 2026-05-12 21:15:01 -04:00
4 changed files with 621 additions and 81 deletions
+239
View File
@@ -0,0 +1,239 @@
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
exitCode: Schema.optional(Schema.Number),
stderr: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {}
export interface RunOptions {
readonly maxOutputBytes?: number
readonly maxErrorBytes?: number
readonly signal?: AbortSignal
readonly timeout?: Duration.Input
}
export interface RunStreamOptions {
readonly signal?: AbortSignal
readonly includeStderr?: boolean
readonly okExitCodes?: ReadonlyArray<number>
readonly maxErrorBytes?: number
}
export interface RunResult {
readonly command: string
readonly exitCode: number
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
}
export type Interface = ChildProcessSpawner["Service"] & {
readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
readonly runStream: (
command: ChildProcess.Command,
options?: RunStreamOptions,
) => Stream.Stream<string, AppProcessError>
readonly runWithStdin: (
command: ChildProcess.Command,
stdin: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
options?: RunOptions,
) => Effect.Effect<RunResult, AppProcessError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
result.exitCode === 0
? Effect.succeed(result)
: Effect.fail(
new AppProcessError({
command: result.command,
exitCode: result.exitCode,
stderr: result.stderr.toString("utf8"),
}),
)
export const requireExitIn =
(codes: ReadonlyArray<number>) =>
(result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
codes.includes(result.exitCode)
? Effect.succeed(result)
: Effect.fail(
new AppProcessError({
command: result.command,
exitCode: result.exitCode,
stderr: result.stderr.toString("utf8"),
}),
)
const describeCommand = (command: ChildProcess.Command): string => {
if (command._tag === "StandardCommand") {
return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
}
return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
}
const wrapError = (description: string, cause: unknown): AppProcessError =>
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
const abortError = (signal: AbortSignal): Error => {
const reason = signal.reason
if (reason instanceof Error) return reason
const err = new Error("Aborted")
err.name = "AbortError"
return err
}
const waitForAbort = (signal: AbortSignal) =>
Effect.callback<never, Error>((resume) => {
if (signal.aborted) {
resume(Effect.fail(abortError(signal)))
return
}
const onabort = () => resume(Effect.fail(abortError(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
Stream.runFold(
stream,
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
(acc, chunk) => {
if (maxOutputBytes === undefined) {
acc.chunks.push(chunk)
acc.bytes += chunk.length
return acc
}
const remaining = maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
return acc
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
const description = describeCommand(command)
const collect = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, options?.maxOutputBytes),
collectStream(handle.stderr, options?.maxErrorBytes),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated,
} satisfies RunResult
}),
)
const timed = options?.timeout
? Effect.timeoutOrElse(collect, {
duration: options.timeout,
orElse: () =>
Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
})
: collect
const aborted = options?.signal
? timed.pipe(
Effect.raceFirst(
waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
),
)
: timed
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
}
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
return yield* runCommand(command, options)
})
const runWithStdin = Effect.fn("AppProcess.runWithStdin")(function* (
command: ChildProcess.Command,
stdin: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
options?: RunOptions,
) {
if (command._tag !== "StandardCommand") {
return yield* Effect.fail(
new AppProcessError({
command: describeCommand(command),
cause: new Error("runWithStdin only supports StandardCommand; received PipedCommand"),
}),
)
}
const stream =
typeof stdin === "string"
? Stream.make(new TextEncoder().encode(stdin))
: stdin instanceof Uint8Array
? Stream.make(stdin)
: stdin
const next = ChildProcess.make(command.command, command.args, { ...command.options, stdin: stream })
return yield* runCommand(next, options)
})
const runStream = (command: ChildProcess.Command, options?: RunStreamOptions): Stream.Stream<string, AppProcessError> => {
const description = describeCommand(command)
const okExitCodes = options?.okExitCodes
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const stderrFiber = yield* Effect.forkScoped(
collectStream(handle.stderr, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
)
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),
)
const tail = Stream.unwrap(
Effect.gen(function* () {
const code = yield* handle.exitCode
if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
const stderr = yield* Fiber.join(stderrFiber)
return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
}
return Stream.empty
}),
)
return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
}),
)
const mapped = built.pipe(
Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
)
if (!options?.signal) return mapped
const signal = options.signal
return mapped.pipe(
Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
)
}
return Service.of({ ...spawner, run, runStream, runWithStdin })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export * as AppProcess from "./process"
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect } from "bun:test"
import { realpathSync } from "node:fs"
import { tmpdir } from "node:os"
import { Effect, Exit, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { testEffect } from "../lib/effect"
const it = testEffect(AppProcess.defaultLayer)
const NODE = process.execPath
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
describe("AppProcess", () => {
describe("run", () => {
it.effect(
"captures stdout and exit code zero",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hi\n")
expect(result.truncated).toBe(false)
}),
)
it.effect(
"non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.exit(1)"))
expect(result.exitCode).toBe(1)
}),
)
it.effect(
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
} else {
throw new Error("expected fail reason")
}
}
}),
)
it.effect(
"requireSuccess succeeds on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
expect(result.exitCode).toBe(0)
}),
)
it.effect(
"requireExitIn allowlists multiple exit codes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
}),
)
it.effect(
"truncates output when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.truncated).toBe(true)
expect(result.stdout.length).toBe(5)
expect(result.stdout.toString("utf8")).toBe("01234")
}),
)
it.effect(
"result includes command description",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
}),
)
})
describe("inherited platform methods", () => {
it.effect(
"string returns stdout as string",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
expect(out).toBe("hi\n")
}),
)
it.effect(
"lines returns the platform's array of lines",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
expect(Array.from(out)).toEqual(["a", "b"])
}),
)
})
describe("runWithStdin", () => {
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
it.effect(
"feeds a string to stdin and returns it on stdout",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.runWithStdin(cmd("-e", echoStdin), "hello")
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hello")
}),
)
it.effect(
"feeds a Uint8Array to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const bytes = new TextEncoder().encode("bytes")
const result = yield* svc.runWithStdin(cmd("-e", echoStdin), bytes)
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("bytes")
}),
)
it.effect(
"feeds a Stream of Uint8Array chunks to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const enc = new TextEncoder()
const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
const result = yield* svc.runWithStdin(cmd("-e", echoStdin), stream)
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("one-two-three")
}),
)
it.effect(
"completes correctly with empty input",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.runWithStdin(cmd("-e", echoStdin), "")
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("")
}),
)
it.effect(
"carries existing Command options like env",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script = "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
const result = yield* svc.runWithStdin(command, "payload")
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("envset:payload")
}),
)
it.effect(
"carries existing Command options like cwd",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const dir = realpathSync(tmpdir())
const script =
"process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
const result = yield* svc.runWithStdin(command, "ok")
expect(result.exitCode).toBe(0)
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
expect(realpathSync(cwd)).toBe(dir)
expect(stdin).toBe("ok")
}),
)
it.effect(
"interacts with requireSuccess just like run",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const ok = yield* svc.runWithStdin(cmd("-e", echoStdin), "x").pipe(Effect.flatMap(AppProcess.requireSuccess))
expect(ok.exitCode).toBe(0)
const exit = yield* Effect.exit(
svc
.runWithStdin(cmd("-e", "process.stdin.on('data', () => {}); process.stdin.on('end', () => process.exit(2))"), "x")
.pipe(Effect.flatMap(AppProcess.requireSuccess)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
}),
)
})
describe("runStream", () => {
it.live(
"emits lines incrementally and ends cleanly on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["one", "two", "three"])
}),
)
it.live(
"fails with AppProcessError when exit not in okExitCodes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
}),
)
it.live(
"okExitCodes allowlist treats non-zero as success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["only"])
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(7)"))
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["only"])
}),
)
it.live(
"AbortSignal interrupts the stream",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const controller = new AbortController()
setTimeout(() => controller.abort(), 250)
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 50); setTimeout(() => {}, 60_000)"), {
signal: controller.signal,
})
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
describe("spawn (inherited)", () => {
it.live(
"returns the platform ChildProcessHandle for advanced use",
Effect.scoped(
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
expect(yield* handle.isRunning).toBe(true)
yield* handle.kill()
}),
),
)
})
})
@@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import * as Filesystem from "../../../../util/filesystem"
import * as Process from "../../../../util/process"
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
Effect.runPromise(
AppProcess.Service.use((svc) => svc.runWithStdin(ChildProcess.make(cmd[0]!, cmd.slice(1)), text)).pipe(
Effect.provide(AppProcess.defaultLayer),
Effect.catch(() => Effect.void),
Effect.asVoid,
),
).catch(() => undefined)
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("../../../../util/which")
@@ -125,49 +137,23 @@ const getCopyMethod = lazy(async () => {
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["wl-copy"], text)
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
return (text: string) =>
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
writeWithStdin(
[
"powershell.exe",
"-NonInteractive",
@@ -175,18 +161,8 @@ const getCopyMethod = lazy(async () => {
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
text,
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
+55 -39
View File
@@ -1,8 +1,8 @@
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, Stream } from "effect"
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -58,12 +58,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
AppFileSystem.Service | AppProcess.Service | Config.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const appProcess = yield* AppProcess.Service
const config = yield* Config.Service
const locks = new Map<string, Semaphore.Semaphore>()
@@ -87,29 +87,48 @@ export const layer: Layer.Layer<
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const enc = new TextEncoder()
const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
const feed = (list: string[]) => list.join("\0") + "\0"
const gitWithStdin = Effect.fnUntraced(
function* (cmd: string[], opts: { cwd?: string; env?: Record<string, string>; stdin: string }) {
const result = yield* appProcess.runWithStdin(
ChildProcess.make("git", cmd, {
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
}),
opts.stdin,
)
return {
code: ChildProcessSpawner.ExitCode(result.exitCode),
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
} satisfies GitResult
},
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: err instanceof Error ? err.message : String(err),
}),
),
)
const git = Effect.fnUntraced(
function* (
cmd: string[],
opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
stdin: opts?.stdin,
})
const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const result = yield* appProcess.run(
ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
}),
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
return {
code: ChildProcessSpawner.ExitCode(result.exitCode),
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
} satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
@@ -121,7 +140,7 @@ export const layer: Layer.Layer<
const ignore = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return new Set<string>()
const check = yield* git(
const check = yield* gitWithStdin(
[
...quote,
"--git-dir",
@@ -144,7 +163,7 @@ export const layer: Layer.Layer<
const drop = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
yield* git(
yield* gitWithStdin(
[
...cfg,
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
@@ -158,7 +177,7 @@ export const layer: Layer.Layer<
const stage = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
const result = yield* git(
const result = yield* gitWithStdin(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.directory,
@@ -565,24 +584,21 @@ export const layer: Layer.Layer<
})
if (!refs.length) return new Map<string, { before: string; after: string }>()
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
})
const handle = yield* spawner.spawn(proc)
const [out, err] = yield* Effect.all(
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
const batch = yield* appProcess.runWithStdin(
ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
}),
refs.map((item) => item.ref).join("\n") + "\n",
)
const code = yield* handle.exitCode
if (code !== 0) {
if (batch.exitCode !== 0) {
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
stderr: err,
stderr: batch.stderr.toString("utf8"),
refs: refs.length,
})
return
}
const out = batch.stdout
const fail = (msg: string, extra?: Record<string, string>) => {
log.info(msg, { ...extra, refs: refs.length })
@@ -767,7 +783,7 @@ export const layer: Layer.Layer<
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Config.defaultLayer),
)