Skip to content

Commit 2f2856e

Browse files
authored
refactor(opencode): replace Bun shell in core flows (anomalyco#16286)
1 parent 831eb68 commit 2f2856e

File tree

18 files changed

+681
-364
lines changed

18 files changed

+681
-364
lines changed

packages/opencode/src/cli/cmd/github.ts

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
2727
import { Bus } from "../../bus"
2828
import { MessageV2 } from "../../session/message-v2"
2929
import { SessionPrompt } from "@/session/prompt"
30-
import { $ } from "bun"
3130
import { setTimeout as sleep } from "node:timers/promises"
31+
import { Process } from "@/util/process"
32+
import { git } from "@/util/git"
3233

3334
type GitHubAuthor = {
3435
login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
255256
}
256257

257258
// Get repo info
258-
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
259+
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
259260
const parsed = parseGitHubRemote(info)
260261
if (!parsed) {
261262
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
493494
? "pr_review"
494495
: "issue"
495496
: undefined
497+
const gitText = async (args: string[]) => {
498+
const result = await git(args, { cwd: Instance.worktree })
499+
if (result.exitCode !== 0) {
500+
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
501+
}
502+
return result.text().trim()
503+
}
504+
const gitRun = async (args: string[]) => {
505+
const result = await git(args, { cwd: Instance.worktree })
506+
if (result.exitCode !== 0) {
507+
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
508+
}
509+
return result
510+
}
511+
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
512+
const commitChanges = async (summary: string, actor?: string) => {
513+
const args = ["commit", "-m", summary]
514+
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
515+
await gitRun(args)
516+
}
496517

497518
try {
498519
if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
553574
}
554575
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
555576
const branch = await checkoutNewBranch(branchPrefix)
556-
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
577+
const head = await gitText(["rev-parse", "HEAD"])
557578
const response = await chat(userPrompt, promptFiles)
558579
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
559580
if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
587608
// Local PR
588609
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
589610
await checkoutLocalBranch(prData)
590-
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
611+
const head = await gitText(["rev-parse", "HEAD"])
591612
const dataPrompt = buildPromptDataForPR(prData)
592613
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
593614
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
605626
// Fork PR
606627
else {
607628
const forkBranch = await checkoutForkBranch(prData)
608-
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
629+
const head = await gitText(["rev-parse", "HEAD"])
609630
const dataPrompt = buildPromptDataForPR(prData)
610631
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
611632
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
624645
// Issue
625646
else {
626647
const branch = await checkoutNewBranch("issue")
627-
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
648+
const head = await gitText(["rev-parse", "HEAD"])
628649
const issueData = await fetchIssue()
629650
const dataPrompt = buildPromptDataForIssue(issueData)
630651
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
658679
exitCode = 1
659680
console.error(e instanceof Error ? e.message : String(e))
660681
let msg = e
661-
if (e instanceof $.ShellError) {
682+
if (e instanceof Process.RunFailedError) {
662683
msg = e.stderr.toString()
663684
} else if (e instanceof Error) {
664685
msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
10491070
const config = "http.https://github.com/.extraheader"
10501071
// actions/checkout@v6 no longer stores credentials in .git/config,
10511072
// so this may not exist - use nothrow() to handle gracefully
1052-
const ret = await $`git config --local --get ${config}`.nothrow()
1073+
const ret = await gitStatus(["config", "--local", "--get", config])
10531074
if (ret.exitCode === 0) {
10541075
gitConfig = ret.stdout.toString().trim()
1055-
await $`git config --local --unset-all ${config}`
1076+
await gitRun(["config", "--local", "--unset-all", config])
10561077
}
10571078

10581079
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
10591080

1060-
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
1061-
await $`git config --global user.name "${AGENT_USERNAME}"`
1062-
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
1081+
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
1082+
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
1083+
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
10631084
}
10641085

10651086
async function restoreGitConfig() {
10661087
if (gitConfig === undefined) return
10671088
const config = "http.https://github.com/.extraheader"
1068-
await $`git config --local ${config} "${gitConfig}"`
1089+
await gitRun(["config", "--local", config, gitConfig])
10691090
}
10701091

10711092
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
10721093
console.log("Checking out new branch...")
10731094
const branch = generateBranchName(type)
1074-
await $`git checkout -b ${branch}`
1095+
await gitRun(["checkout", "-b", branch])
10751096
return branch
10761097
}
10771098

@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
10811102
const branch = pr.headRefName
10821103
const depth = Math.max(pr.commits.totalCount, 20)
10831104

1084-
await $`git fetch origin --depth=${depth} ${branch}`
1085-
await $`git checkout ${branch}`
1105+
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
1106+
await gitRun(["checkout", branch])
10861107
}
10871108

10881109
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
10921113
const localBranch = generateBranchName("pr")
10931114
const depth = Math.max(pr.commits.totalCount, 20)
10941115

1095-
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
1096-
await $`git fetch fork --depth=${depth} ${remoteBranch}`
1097-
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
1116+
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
1117+
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
1118+
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
10981119
return localBranch
10991120
}
11001121

@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
11151136
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
11161137
console.log("Pushing to new branch...")
11171138
if (commit) {
1118-
await $`git add .`
1139+
await gitRun(["add", "."])
11191140
if (isSchedule) {
1120-
// No co-author for scheduled events - the schedule is operating as the repo
1121-
await $`git commit -m "${summary}"`
1141+
await commitChanges(summary)
11221142
} else {
1123-
await $`git commit -m "${summary}
1124-
1125-
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
1143+
await commitChanges(summary, actor)
11261144
}
11271145
}
1128-
await $`git push -u origin ${branch}`
1146+
await gitRun(["push", "-u", "origin", branch])
11291147
}
11301148

11311149
async function pushToLocalBranch(summary: string, commit: boolean) {
11321150
console.log("Pushing to local branch...")
11331151
if (commit) {
1134-
await $`git add .`
1135-
await $`git commit -m "${summary}
1136-
1137-
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
1152+
await gitRun(["add", "."])
1153+
await commitChanges(summary, actor)
11381154
}
1139-
await $`git push`
1155+
await gitRun(["push"])
11401156
}
11411157

11421158
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
11451161
const remoteBranch = pr.headRefName
11461162

11471163
if (commit) {
1148-
await $`git add .`
1149-
await $`git commit -m "${summary}
1150-
1151-
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
1164+
await gitRun(["add", "."])
1165+
await commitChanges(summary, actor)
11521166
}
1153-
await $`git push fork HEAD:${remoteBranch}`
1167+
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
11541168
}
11551169

11561170
async function branchIsDirty(originalHead: string, expectedBranch: string) {
11571171
console.log("Checking if branch is dirty...")
11581172
// Detect if the agent switched branches during chat (e.g. created
11591173
// its own branch, committed, and possibly pushed/created a PR).
1160-
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
1174+
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
11611175
if (current !== expectedBranch) {
11621176
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
11631177
return { dirty: true, uncommittedChanges: false, switched: true }
11641178
}
11651179

1166-
const ret = await $`git status --porcelain`
1180+
const ret = await gitStatus(["status", "--porcelain"])
11671181
const status = ret.stdout.toString().trim()
11681182
if (status.length > 0) {
11691183
return { dirty: true, uncommittedChanges: true, switched: false }
11701184
}
1171-
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
1185+
const head = await gitText(["rev-parse", "HEAD"])
11721186
return {
11731187
dirty: head !== originalHead,
11741188
uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
11801194
// Falls back to fetching from origin when local refs are missing
11811195
// (common in shallow clones from actions/checkout).
11821196
async function hasNewCommits(base: string, head: string) {
1183-
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
1197+
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
11841198
if (result.exitCode !== 0) {
11851199
console.log(`rev-list failed, fetching origin/${base}...`)
1186-
await $`git fetch origin ${base} --depth=1`.nothrow()
1187-
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
1200+
await gitStatus(["fetch", "origin", base, "--depth=1"])
1201+
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
11881202
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
11891203
return parseInt(retry.stdout.toString().trim()) > 0
11901204
}

packages/opencode/src/cli/cmd/pr.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { UI } from "../ui"
22
import { cmd } from "./cmd"
33
import { Instance } from "@/project/instance"
4-
import { $ } from "bun"
4+
import { Process } from "@/util/process"
5+
import { git } from "@/util/git"
56

67
export const PrCommand = cmd({
78
command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
2728
UI.println(`Fetching and checking out PR #${prNumber}...`)
2829

2930
// Use gh pr checkout with custom branch name
30-
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
31+
const result = await Process.run(
32+
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
33+
{
34+
nothrow: true,
35+
},
36+
)
3137

32-
if (result.exitCode !== 0) {
38+
if (result.code !== 0) {
3339
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
3440
process.exit(1)
3541
}
3642

3743
// Fetch PR info for fork handling and session link detection
38-
const prInfoResult =
39-
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
44+
const prInfoResult = await Process.text(
45+
[
46+
"gh",
47+
"pr",
48+
"view",
49+
`${prNumber}`,
50+
"--json",
51+
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
52+
],
53+
{ nothrow: true },
54+
)
4055

4156
let sessionId: string | undefined
4257

43-
if (prInfoResult.exitCode === 0) {
44-
const prInfoText = prInfoResult.text()
58+
if (prInfoResult.code === 0) {
59+
const prInfoText = prInfoResult.text
4560
if (prInfoText.trim()) {
4661
const prInfo = JSON.parse(prInfoText)
4762

@@ -52,15 +67,19 @@ export const PrCommand = cmd({
5267
const remoteName = forkOwner
5368

5469
// Check if remote already exists
55-
const remotes = (await $`git remote`.nothrow().text()).trim()
70+
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
5671
if (!remotes.split("\n").includes(remoteName)) {
57-
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
72+
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
73+
cwd: Instance.worktree,
74+
})
5875
UI.println(`Added fork remote: ${remoteName}`)
5976
}
6077

6178
// Set upstream to the fork so pushes go there
6279
const headRefName = prInfo.headRefName
63-
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
80+
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
81+
cwd: Instance.worktree,
82+
})
6483
}
6584

6685
// Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
7190
UI.println(`Found opencode session: ${sessionUrl}`)
7291
UI.println(`Importing session...`)
7392

74-
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
75-
if (importResult.exitCode === 0) {
76-
const importOutput = importResult.text().trim()
93+
const importResult = await Process.text(["opencode", "import", sessionUrl], {
94+
nothrow: true,
95+
})
96+
if (importResult.code === 0) {
97+
const importOutput = importResult.text.trim()
7798
// Extract session ID from the output (format: "Imported session: <session-id>")
7899
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
79100
if (sessionIdMatch) {

0 commit comments

Comments
 (0)