Skip to content

Commit 3c6c744

Browse files
committed
sync
1 parent fc6e793 commit 3c6c744

4 files changed

Lines changed: 294 additions & 72 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Bun shell migration plan
2+
3+
Practical phased replacement of Bun `$` calls.
4+
5+
## Goal
6+
7+
Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
8+
9+
Keep behavior stable while improving safety, testability, and observability.
10+
11+
Current baseline from audit:
12+
13+
- 143 runtime command invocations across 17 files
14+
- 84 are git commands
15+
- Largest hotspots:
16+
- `src/cli/cmd/github.ts` (33)
17+
- `src/worktree/index.ts` (22)
18+
- `src/lsp/server.ts` (21)
19+
- `src/installation/index.ts` (20)
20+
- `src/snapshot/index.ts` (18)
21+
22+
## Decisions
23+
24+
- Extend `src/util/process.ts` (do not create a separate exec module).
25+
- Proceed with phased migration for both git and non-git paths.
26+
- Keep plugin `$` compatibility in 1.x and remove in 2.0.
27+
28+
## Non-goals
29+
30+
- Do not remove plugin `$` compatibility in this effort.
31+
- Do not redesign command semantics beyond what is needed to preserve behavior.
32+
33+
## Constraints
34+
35+
- Keep migration phased, not big-bang.
36+
- Minimize behavioral drift.
37+
- Keep these explicit shell-only exceptions:
38+
- `src/session/prompt.ts` raw command execution
39+
- worktree start scripts in `src/worktree/index.ts`
40+
41+
## Process API proposal (`src/util/process.ts`)
42+
43+
Add higher-level wrappers on top of current spawn support.
44+
45+
Core methods:
46+
47+
- `Process.run(cmd, opts)`
48+
- `Process.text(cmd, opts)`
49+
- `Process.lines(cmd, opts)`
50+
- `Process.status(cmd, opts)`
51+
- `Process.shell(command, opts)` for intentional shell execution
52+
53+
Git helpers:
54+
55+
- `Process.git(args, opts)`
56+
- `Process.gitText(args, opts)`
57+
58+
Shared options:
59+
60+
- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
61+
- `allowFailure` / non-throw mode
62+
- optional redaction + trace metadata
63+
64+
Standard result shape:
65+
66+
- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
67+
- helpers like `text()` and `arrayBuffer()` where useful
68+
69+
## Phased rollout
70+
71+
### Phase 0: Foundation
72+
73+
- Implement Process wrappers in `src/util/process.ts`.
74+
- Refactor `src/util/git.ts` to use Process only.
75+
- Add tests for exit handling, timeout, abort, and output capture.
76+
77+
### Phase 1: High-impact hotspots
78+
79+
Migrate these first:
80+
81+
- `src/cli/cmd/github.ts`
82+
- `src/worktree/index.ts`
83+
- `src/lsp/server.ts`
84+
- `src/installation/index.ts`
85+
- `src/snapshot/index.ts`
86+
87+
Within each file, migrate git paths first where applicable.
88+
89+
### Phase 2: Remaining git-heavy files
90+
91+
Migrate git-centric call sites to `Process.git*` helpers:
92+
93+
- `src/file/index.ts`
94+
- `src/project/vcs.ts`
95+
- `src/file/watcher.ts`
96+
- `src/storage/storage.ts`
97+
- `src/cli/cmd/pr.ts`
98+
99+
### Phase 3: Remaining non-git files
100+
101+
Migrate residual non-git usages:
102+
103+
- `src/cli/cmd/tui/util/clipboard.ts`
104+
- `src/util/archive.ts`
105+
- `src/file/ripgrep.ts`
106+
- `src/tool/bash.ts`
107+
- `src/cli/cmd/uninstall.ts`
108+
109+
### Phase 4: Stabilize
110+
111+
- Remove dead wrappers and one-off patterns.
112+
- Keep plugin `$` compatibility isolated and documented as temporary.
113+
- Create linked 2.0 task for plugin `$` removal.
114+
115+
## Validation strategy
116+
117+
- Unit tests for new `Process` methods and options.
118+
- Integration tests on hotspot modules.
119+
- Smoke tests for install, snapshot, worktree, and GitHub flows.
120+
- Regression checks for output parsing behavior.
121+
122+
## Risk mitigation
123+
124+
- File-by-file PRs with small diffs.
125+
- Preserve behavior first, simplify second.
126+
- Keep shell-only exceptions explicit and documented.
127+
- Add consistent error shaping and logging at Process layer.
128+
129+
## Definition of done
130+
131+
- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
132+
- approved shell-only exceptions
133+
- temporary plugin compatibility path (1.x)
134+
- Git paths use `Process.git*` consistently.
135+
- CI and targeted smoke tests pass.
136+
- 2.0 issue exists for plugin `$` removal.

packages/opencode/src/util/git.ts

Lines changed: 23 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,35 @@
1-
import { $ } from "bun"
2-
import { buffer } from "node:stream/consumers"
3-
import { Flag } from "../flag/flag"
41
import { Process } from "./process"
52

63
export interface GitResult {
74
exitCode: number
8-
text(): string | Promise<string>
9-
stdout: Buffer | ReadableStream<Uint8Array>
10-
stderr: Buffer | ReadableStream<Uint8Array>
5+
text(): string
6+
stdout: Buffer
7+
stderr: Buffer
118
}
129

1310
/**
1411
* Run a git command.
1512
*
16-
* Uses Bun's lightweight `$` shell by default. When the process is running
17-
* as an ACP client, child processes inherit the parent's stdin pipe which
18-
* carries protocol data – on Windows this causes git to deadlock. In that
19-
* case we fall back to `Process.spawn` with `stdin: "ignore"`.
13+
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
14+
* issues in embedded/client environments.
2015
*/
2116
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
22-
if (Flag.OPENCODE_CLIENT === "acp") {
23-
try {
24-
const proc = Process.spawn(["git", ...args], {
25-
stdin: "ignore",
26-
stdout: "pipe",
27-
stderr: "pipe",
28-
cwd: opts.cwd,
29-
env: opts.env ? { ...process.env, ...opts.env } : process.env,
30-
})
31-
// Read output concurrently with exit to avoid pipe buffer deadlock
32-
if (!proc.stdout || !proc.stderr) {
33-
throw new Error("Process output not available")
34-
}
35-
const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
36-
return {
37-
exitCode,
38-
text: () => out.toString(),
39-
stdout: out,
40-
stderr: err,
41-
}
42-
} catch (error) {
43-
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
44-
return {
45-
exitCode: 1,
46-
text: () => "",
47-
stdout: Buffer.alloc(0),
48-
stderr,
49-
}
50-
}
51-
}
52-
53-
const env = opts.env ? { ...process.env, ...opts.env } : undefined
54-
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
55-
if (env) cmd = cmd.env(env)
56-
const result = await cmd
57-
return {
58-
exitCode: result.exitCode,
59-
text: () => result.text(),
60-
stdout: result.stdout,
61-
stderr: result.stderr,
62-
}
17+
return Process.run(["git", ...args], {
18+
cwd: opts.cwd,
19+
env: opts.env,
20+
stdin: "ignore",
21+
nothrow: true,
22+
})
23+
.then((result) => ({
24+
exitCode: result.code,
25+
text: () => result.stdout.toString(),
26+
stdout: result.stdout,
27+
stderr: result.stderr,
28+
}))
29+
.catch((error) => ({
30+
exitCode: 1,
31+
text: () => "",
32+
stdout: Buffer.alloc(0),
33+
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
34+
}))
6335
}
Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn as launch, type ChildProcess } from "child_process"
2+
import { buffer } from "node:stream/consumers"
23

34
export namespace Process {
45
export type Stdio = "inherit" | "pipe" | "ignore"
@@ -14,58 +15,112 @@ export namespace Process {
1415
timeout?: number
1516
}
1617

18+
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
19+
nothrow?: boolean
20+
}
21+
22+
export interface Result {
23+
code: number
24+
stdout: Buffer
25+
stderr: Buffer
26+
}
27+
28+
export class RunFailedError extends Error {
29+
readonly cmd: string[]
30+
readonly code: number
31+
readonly stdout: Buffer
32+
readonly stderr: Buffer
33+
34+
constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) {
35+
const text = stderr.toString().trim()
36+
super(
37+
text
38+
? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}`
39+
: `Command failed with code ${code}: ${cmd.join(" ")}`,
40+
)
41+
this.name = "ProcessRunFailedError"
42+
this.cmd = [...cmd]
43+
this.code = code
44+
this.stdout = stdout
45+
this.stderr = stderr
46+
}
47+
}
48+
1749
export type Child = ChildProcess & { exited: Promise<number> }
1850

19-
export function spawn(cmd: string[], options: Options = {}): Child {
51+
export function spawn(cmd: string[], opts: Options = {}): Child {
2052
if (cmd.length === 0) throw new Error("Command is required")
21-
options.abort?.throwIfAborted()
53+
opts.abort?.throwIfAborted()
2254

2355
const proc = launch(cmd[0], cmd.slice(1), {
24-
cwd: options.cwd,
25-
env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined,
26-
stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"],
56+
cwd: opts.cwd,
57+
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
58+
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
2759
})
2860

29-
let aborted = false
61+
let closed = false
3062
let timer: ReturnType<typeof setTimeout> | undefined
3163

3264
const abort = () => {
33-
if (aborted) return
65+
if (closed) return
3466
if (proc.exitCode !== null || proc.signalCode !== null) return
35-
aborted = true
36-
37-
proc.kill(options.kill ?? "SIGTERM")
67+
closed = true
3868

39-
const timeout = options.timeout ?? 5_000
40-
if (timeout <= 0) return
69+
proc.kill(opts.kill ?? "SIGTERM")
4170

42-
timer = setTimeout(() => {
43-
proc.kill("SIGKILL")
44-
}, timeout)
71+
const ms = opts.timeout ?? 5_000
72+
if (ms <= 0) return
73+
timer = setTimeout(() => proc.kill("SIGKILL"), ms)
4574
}
4675

4776
const exited = new Promise<number>((resolve, reject) => {
4877
const done = () => {
49-
options.abort?.removeEventListener("abort", abort)
78+
opts.abort?.removeEventListener("abort", abort)
5079
if (timer) clearTimeout(timer)
5180
}
52-
proc.once("exit", (exitCode, signal) => {
81+
82+
proc.once("exit", (code, signal) => {
5383
done()
54-
resolve(exitCode ?? (signal ? 1 : 0))
84+
resolve(code ?? (signal ? 1 : 0))
5585
})
86+
5687
proc.once("error", (error) => {
5788
done()
5889
reject(error)
5990
})
6091
})
6192

62-
if (options.abort) {
63-
options.abort.addEventListener("abort", abort, { once: true })
64-
if (options.abort.aborted) abort()
93+
if (opts.abort) {
94+
opts.abort.addEventListener("abort", abort, { once: true })
95+
if (opts.abort.aborted) abort()
6596
}
6697

6798
const child = proc as Child
6899
child.exited = exited
69100
return child
70101
}
102+
103+
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
104+
const proc = spawn(cmd, {
105+
cwd: opts.cwd,
106+
env: opts.env,
107+
stdin: opts.stdin,
108+
abort: opts.abort,
109+
kill: opts.kill,
110+
timeout: opts.timeout,
111+
stdout: "pipe",
112+
stderr: "pipe",
113+
})
114+
115+
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
116+
117+
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
118+
const out = {
119+
code,
120+
stdout,
121+
stderr,
122+
}
123+
if (out.code === 0 || opts.nothrow) return out
124+
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
125+
}
71126
}

0 commit comments

Comments
 (0)