Skip to content

refactor(opencode): replace Bun shell in core flows#16286

Open
thdxr wants to merge 6 commits intodevfrom
chore/remove-bun-shell-opencode
Open

refactor(opencode): replace Bun shell in core flows#16286
thdxr wants to merge 6 commits intodevfrom
chore/remove-bun-shell-opencode

Conversation

@thdxr
Copy link
Member

@thdxr thdxr commented Mar 6, 2026

Summary

  • add Process.text() and Process.lines() so argv-based process execution can replace Bun shell text and line helpers
  • migrate worktree, snapshot, file, installation, clipboard, and related git/process flows in packages/opencode/src away from Bun $
  • replace shell one-offs with Node APIs where possible, including fs.realpath() and fs.rm()

Testing

  • bun run typecheck

@thdxr
Copy link
Member Author

thdxr commented Mar 6, 2026

@greptile review

…-opencode

# Conflicts:
#	packages/opencode/src/file/index.ts
#	packages/opencode/src/worktree/index.ts
@greptile-apps
Copy link

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR is a broad, mechanical refactoring that removes Bun's $ shell from the core packages/opencode/src module, replacing it with a portable Process.run/Process.text/Process.lines argv-based API and the existing git() helper. Node.js built-in APIs (fs.realpath, fs.chmod, fs.rm, fetch) replace a number of single-use shell one-offs, and two new helpers (Process.text and Process.lines) are added to util/process.ts to cover the common "capture stdout as string/lines" pattern.

Key points:

  • Process.text() / Process.lines(): Clean additions to util/process.ts; the TextResult interface with a text: string property is consistent within the new API surface (though it remains inconsistent with GitResult.text() being a method — previously noted in review threads).
  • ripgrep.ts: Removing shell quoting around --glob=!.git/* is correct — quotes were stripped by the shell anyway, and the argv-based approach also fixes potential path-with-spaces bugs from the old args.join(" ") approach.
  • snapshot.ts fileDiff: The for await...of streaming loop is replaced with buffered Process.lines(), meaning the entire git numstat output is held in memory before iteration starts.
  • upgradeCurl: The curl | bash pattern is now fetch() → pipe to bash stdin; functionally equivalent but the env passed to Process.spawn double-spreads process.env (redundant but harmless).
  • lsp/server.ts: Two local run/output helpers are defined with nothrow: true, but some call sites bypass them and call Process.run directly, creating a minor inconsistency in error-handling intent.
  • archive.ts: extractZip throws on non-zero exit; callers that wrap it in .catch() are safe, but bare call sites should be verified.

Confidence Score: 4/5

  • This PR is safe to merge; it is a straightforward API migration with no introduced logic regressions — only minor style concerns remain.
  • The changes are largely mechanical (Bun $ → argv array), and equivalence with the original shell commands has been preserved throughout. No critical logic bugs were introduced. Minor issues identified: buffered vs. streaming output in snapshot.ts, redundant env double-spread in upgradeCurl, inconsistent use of nothrow helpers in lsp/server.ts, and throwing behavior in archive.ts that callers must handle.
  • Pay close attention to packages/opencode/src/snapshot/index.ts (buffered diff processing) and packages/opencode/src/installation/index.ts (upgradeCurl env handling).

Important Files Changed

Filename Overview
packages/opencode/src/util/process.ts Adds TextResult interface and text()/lines() helpers to Process namespace; adds a .catch() guard inside run() when nothrow is set so spawn errors (e.g. ENOENT) are returned as { code: 1 } rather than thrown. API is clean and the additions are low-risk.
packages/opencode/src/snapshot/index.ts Heavy Bun $ removal; all git commands migrated to Process.run/Process.text/Process.lines. The for await...of .lines() streaming loop is replaced with buffered Process.lines() — a minor memory behavioural change for large diffs but no functional regression. Logic in restore and revert is semantically equivalent.
packages/opencode/src/installation/index.ts Replaces `curl
packages/opencode/src/lsp/server.ts Removes Bun $ across many LSP server install helpers. chmod calls replaced with fs.chmod, curl replaced with fetch, and ls glob replaced with fs.readdir().find(). Local run/output helpers with nothrow: true are defined but some call sites use Process.run directly without nothrow, which is intentional but inconsistent.
packages/opencode/src/worktree/index.ts All git operations migrated to git() helper and Process.run. The inline fallback { exitCode: 1, stdout: undefined, stderr: undefined } for the no-remote case is handled correctly since outputText accepts `Uint8Array
packages/opencode/src/cli/cmd/github.ts Introduces local gitText/gitRun/gitStatus/commitChanges helpers that wrap git(). Multi-line commit message with co-author is now correctly expressed as two separate -m flags (git treats each as a paragraph). Error handling switches from $.ShellError to Process.RunFailedError.
packages/opencode/src/file/ripgrep.ts Ripgrep invocation switched from shell-joined raw string to argv array. The --glob='!.git/*' shell-quoting removed correctly — quotes were only needed to prevent shell expansion; ripgrep receives --glob=!.git/* in both cases. This also fixes potential path-with-spaces issues from the old args.join(" ") approach.
packages/opencode/src/util/archive.ts PowerShell and unzip calls replaced with Process.run. Early return added after Windows path makes the if/else structure cleaner. No nothrow — functions will throw on failure, consistent with original.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Process
    participant NodeChildProcess
    participant git helper

    Note over Caller,git helper: New Process.text() / Process.lines() flow

    Caller->>Process: text(cmd, opts)
    Process->>Process: run(cmd, opts)
    Process->>NodeChildProcess: spawn(cmd[0], cmd[1..], {stdout:pipe, stderr:pipe})
    NodeChildProcess-->>Process: ChildProcess
    Process->>NodeChildProcess: buffer(stdout), buffer(stderr), exited
    NodeChildProcess-->>Process: [code, stdout, stderr]
    alt nothrow=true and code≠0
        Process-->>Caller: {code, stdout, stderr, text}
    else code=0
        Process-->>Caller: {code, stdout, stderr, text}
    else nothrow=false and code≠0
        Process-->>Caller: throws RunFailedError
    end

    Note over Caller,git helper: git() helper (always nothrow)

    Caller->>git helper: git(args, {cwd})
    git helper->>Process: run(["git",...args], {stdin:ignore, nothrow:true})
    Process->>NodeChildProcess: spawn("git", args, {stdin:ignore})
    NodeChildProcess-->>Process: {code, stdout, stderr}
    git helper-->>Caller: {exitCode, text(), stdout, stderr}

    Note over Caller,git helper: upgradeCurl — fetch+bash stdin pipe

    Caller->>Process: spawn(["bash"], {stdin:pipe, stdout:pipe, stderr:pipe})
    Process->>NodeChildProcess: launch("bash", [], {stdio:[pipe,pipe,pipe]})
    Caller->>NodeChildProcess: stdin.end(install_script_body)
    NodeChildProcess-->>Caller: {code, stdout, stderr}
Loading

Comments Outside Diff (3)

  1. packages/opencode/src/snapshot/index.ts, line 296-299 (link)

    Streaming replaced with full buffer before processing

    The original for await...of over Bun's .lines() streamed git diff output lazily, so only one line was held in memory at a time. The new Process.lines() buffers the entire diff output into a string[] before the loop begins. For repositories with a very large number of changed files, this means the entire numstat output must fit in memory before processing begins.

    Inside the loop, each file additionally triggers two sequential Process.text calls (show from:file and show to:file), so the in-memory footprint includes both the full numstat output and the most-recently-fetched file contents. This is unlikely to be a problem in practice, but it's a behavioural change worth being aware of.

  2. packages/opencode/src/installation/index.ts, line 684-701 (link)

    Redundant double-spread of process.env in upgradeCurl

    upgradeCurl constructs env: { ...process.env, VERSION: target } and passes it to Process.spawn. Inside spawn, the env option is already merged: { ...process.env, ...opts.env }, so process.env ends up spread twice. The result is still correct (VERSION wins), but it's unnecessarily redundant.

    Since Process.spawn (and Process.run) already perform the merge with process.env, callers only need to supply the overrides:

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  3. packages/opencode/src/lsp/server.ts, line 886-887 (link)

    Local helpers defined but bypassed by direct Process.run calls

    run and output are defined as module-level helpers that always set nothrow: true, but several calls in the same namespace use Process.run directly without nothrow. For example:

    await Process.run([npmCmd, "install"], { cwd: finalPath })
    await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })

    These will throw on failure (the original Bun calls also lacked .nothrow(), so behaviour is preserved), but the inconsistency makes it hard to reason about which calls are fire-and-forget vs. which are expected to fail gracefully. Using the helpers or explicitly passing nothrow at every call site would make intent clearer.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Last reviewed commit: add16af

@thdxr
Copy link
Member Author

thdxr commented Mar 6, 2026

@greptile

Comment on lines +28 to +30
export interface TextResult extends Result {
text: string
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TextResult.text (property) vs GitResult.text() (method) — inconsistent API surface across the codebase

Process.text() returns TextResult where text is a plain string property, while util/git.ts returns GitResult where text is a zero-arg method (text()). This means callers accessing stdout text must choose between result.text and result.text() depending on which helper they used. The mixed surface makes it easy to accidentally call result.text as a property when the caller is holding a GitResult (getting the function reference rather than the string), or vice-versa.

Consider aligning the two shapes — either both expose text as a property, or both expose it as a method — to make the API uniform across the codebase. Since GitResult appears in the older helper and the majority of call sites, making TextResult.text a method (or aliasing both) would be the least disruptive change.

@thdxr
Copy link
Member Author

thdxr commented Mar 6, 2026

@greptile what up

@thdxr thdxr requested a review from Copilot March 6, 2026 06:35
@greptile-apps
Copy link

greptile-apps bot commented Mar 6, 2026

Hey! Nothing much, just here to help with the review. Did you have a question about the refactoring or anything specific you wanted to discuss?

@thdxr
Copy link
Member Author

thdxr commented Mar 6, 2026

@greptile

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors core packages/opencode/src flows to remove Bun $ shell usage in favor of argv-based process execution (Process) and a dedicated git() helper, improving portability and reducing reliance on shell parsing.

Changes:

  • Added Process.text() and Process.lines() helpers to complement Process.run().
  • Migrated many git/process operations (worktree, snapshot, file, installation, clipboard, LSP installs, CLI commands) off Bun $ to Process/git() and Node FS APIs.
  • Replaced a few shell one-offs with Node equivalents (fs.realpath, fs.rm, fs.chmod, directory scanning via fs.readdir, downloads via fetch + Filesystem.writeStream).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/opencode/src/worktree/index.ts Migrates git commands and start-command execution to git()/Process.run().
packages/opencode/src/util/process.ts Adds text() and lines() helpers and improves nothrow handling in run().
packages/opencode/src/util/archive.ts Replaces unzip/PowerShell extraction shell usage with Process.run().
packages/opencode/src/tool/bash.ts Replaces realpath shell call with fs.realpath().
packages/opencode/src/storage/storage.ts Migrates git usage to git() (introduces a critical destructuring bug).
packages/opencode/src/snapshot/index.ts Replaces Bun $ git flows with Process.run/text/lines.
packages/opencode/src/project/vcs.ts Uses git() to read current branch instead of Bun shell.
packages/opencode/src/lsp/server.ts Replaces multiple install/build shell invocations with Process + Node FS + fetch.
packages/opencode/src/installation/index.ts Reworks upgrade/check flows to Process and implements `curl
packages/opencode/src/file/watcher.ts Uses git() to locate .git directory.
packages/opencode/src/file/ripgrep.ts Switches from raw shell command to argv execution via Process.text().
packages/opencode/src/file/index.ts Migrates git diff/list/show calls to git().
packages/opencode/src/cli/cmd/uninstall.ts Replaces uninstall shell execution with Process.run().
packages/opencode/src/cli/cmd/tui/util/clipboard.ts Migrates clipboard shell calls to Process + Node FS.
packages/opencode/src/cli/cmd/pr.ts Migrates gh/git usage to Process + git().
packages/opencode/src/cli/cmd/github.ts Migrates git shell usage to git() and updates error handling to Process.RunFailedError.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +139 to +149
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}

export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Process.text() / Process.lines() are newly added but there are no unit tests covering them (including behavior when nothrow is set and the command fails). Since packages/opencode/test/util/process.test.ts already exists for Process.run(), please add coverage for these helpers as well to lock in their expected semantics (newline splitting, encoding, and error behavior).

Copilot uses AI. Check for mistakes.
Comment on lines +300 to +317
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change switches from streaming line iteration (previously for await (...) over Bun’s .lines()) to await Process.lines(...), which buffers the entire command output into memory before processing. For large diffs, this can increase peak memory and delay first-item processing. Consider using a streaming approach (e.g., spawn + readline) or adding a Process.iterLines() async iterator helper to preserve the previous behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +60
const result = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result is a GitResult object and isn’t iterable. const [id] = result will throw (or fail typecheck). This should destructure from the output string, e.g. const [id] = result.text().split(...) (or assign const ids = ...).

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +47
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upgradeCurl() can throw (e.g. network failure, bash missing/spawn error, stream read error). Unlike the other branches (which use Process.run(..., { nothrow: true })), these throws bypass the result.code handling and won’t be wrapped into UpgradeFailedError. Consider catching inside upgradeCurl (or around the call site) and returning a { code: 1, stdout, stderr }-shaped result (or rethrowing as UpgradeFailedError) to keep error handling consistent across methods.

Suggested change
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
try {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
code: 1,
stdout: Buffer.from(""),
stderr: Buffer.from(message),
}

Copilot uses AI. Check for mistakes.
Comment on lines 5 to 16
@@ -8,9 +8,10 @@ export namespace Archive {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
} else {
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}

await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No error handling / nothrow on extractZip

Both Process.run calls use default options (no nothrow: true), so a non-zero exit from powershell or unzip will throw a RunFailedError. The original Bun $ calls also lacked .nothrow(), so the throwing behaviour is unchanged — however, one call site (lsp/server.ts) catches the error via .catch() while other call sites (also in lsp/server.ts) do not. Worth verifying all callers intentionally tolerate exceptions from this function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants