summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorprojectArtur <[email protected]>2026-02-13 01:20:00 +0100
committerGitHub <[email protected]>2026-02-12 18:20:00 -0600
commit991496a753545f2705072d4da537c175dca357e6 (patch)
tree5ecb656712a70f2bab26a850ac4c0c263b08423d
parent76db218674496f9ca9e91b49e5718eabf6df7cc0 (diff)
downloadopencode-991496a753545f2705072d4da537c175dca357e6.tar.gz
opencode-991496a753545f2705072d4da537c175dca357e6.zip
fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222)
Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: LukeParkerDev <[email protected]> Co-authored-by: Aiden Cline <[email protected]> Co-authored-by: Aiden Cline <[email protected]>
-rw-r--r--packages/opencode/src/project/project.ts46
-rw-r--r--packages/opencode/src/snapshot/index.ts5
-rw-r--r--packages/opencode/src/util/git.ts64
-rw-r--r--packages/opencode/test/project/project.test.ts59
4 files changed, 112 insertions, 62 deletions
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f6902de4e..c79a62c6c 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -2,7 +2,6 @@ import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
-import { $ } from "bun"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
+import { git } from "../util/git"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -55,15 +55,15 @@ export namespace Project {
const { id, sandbox, worktree, vcs } = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
- const git = await matches.next().then((x) => x.value)
+ const dotgit = await matches.next().then((x) => x.value)
await matches.return()
- if (git) {
- let sandbox = path.dirname(git)
+ if (dotgit) {
+ let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
// cached id calculation
- let id = await Bun.file(path.join(git, "opencode"))
+ let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)
@@ -79,13 +79,11 @@ export namespace Project {
// generate id from root commit
if (!id) {
- const roots = await $`git rev-list --max-parents=0 --all`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) =>
- x
+ const roots = await git(["rev-list", "--max-parents=0", "--all"], {
+ cwd: sandbox,
+ })
+ .then(async (result) =>
+ (await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
@@ -104,7 +102,7 @@ export namespace Project {
id = roots[0]
if (id) {
- void Bun.file(path.join(git, "opencode"))
+ void Bun.file(path.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
}
@@ -119,12 +117,10 @@ export namespace Project {
}
}
- const top = await $`git rev-parse --show-toplevel`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => path.resolve(sandbox, x.trim()))
+ const top = await git(["rev-parse", "--show-toplevel"], {
+ cwd: sandbox,
+ })
+ .then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
.catch(() => undefined)
if (!top) {
@@ -138,13 +134,11 @@ export namespace Project {
sandbox = top
- const worktree = await $`git rev-parse --git-common-dir`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => {
- const dirname = path.dirname(x.trim())
+ const worktree = await git(["rev-parse", "--git-common-dir"], {
+ cwd: sandbox,
+ })
+ .then(async (result) => {
+ const dirname = path.dirname((await result.text()).trim())
if (dirname === ".") return sandbox
return dirname
})
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index b3c8a905c..a1c2b5781 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -2,6 +2,7 @@ import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Log } from "../util/log"
+import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
@@ -23,7 +24,7 @@ export namespace Snapshot {
}
export async function cleanup() {
- if (Instance.project.vcs !== "git") return
+ if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
@@ -48,7 +49,7 @@ export namespace Snapshot {
}
export async function track() {
- if (Instance.project.vcs !== "git") return
+ if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts
new file mode 100644
index 000000000..201def36a
--- /dev/null
+++ b/packages/opencode/src/util/git.ts
@@ -0,0 +1,64 @@
+import { $ } from "bun"
+import { Flag } from "../flag/flag"
+
+export interface GitResult {
+ exitCode: number
+ text(): string | Promise<string>
+ stdout: Buffer | ReadableStream<Uint8Array>
+ stderr: Buffer | ReadableStream<Uint8Array>
+}
+
+/**
+ * Run a git command.
+ *
+ * Uses Bun's lightweight `$` shell by default. When the process is running
+ * as an ACP client, child processes inherit the parent's stdin pipe which
+ * carries protocol data – on Windows this causes git to deadlock. In that
+ * case we fall back to `Bun.spawn` with `stdin: "ignore"`.
+ */
+export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
+ if (Flag.OPENCODE_CLIENT === "acp") {
+ try {
+ const proc = Bun.spawn(["git", ...args], {
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ cwd: opts.cwd,
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
+ })
+ // Read output concurrently with exit to avoid pipe buffer deadlock
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).arrayBuffer(),
+ new Response(proc.stderr).arrayBuffer(),
+ ])
+ const stdoutBuf = Buffer.from(stdout)
+ const stderrBuf = Buffer.from(stderr)
+ return {
+ exitCode,
+ text: () => stdoutBuf.toString(),
+ stdout: stdoutBuf,
+ stderr: stderrBuf,
+ }
+ } catch (error) {
+ const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
+ return {
+ exitCode: 1,
+ text: () => "",
+ stdout: Buffer.alloc(0),
+ stderr,
+ }
+ }
+ }
+
+ const env = opts.env ? { ...process.env, ...opts.env } : undefined
+ let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
+ if (env) cmd = cmd.env(env)
+ const result = await cmd
+ return {
+ exitCode: result.exitCode,
+ text: () => result.text(),
+ stdout: result.stdout,
+ stderr: result.stderr,
+ }
+}
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 0e99c5648..581c63b56 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
-const bunModule = await import("bun")
+const gitModule = await import("../../src/util/git")
+const originalGit = gitModule.git
+
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
-function render(parts: TemplateStringsArray, vals: unknown[]) {
- return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
-}
-
-function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
- const result = {
- exitCode: output.exitCode,
- stdout: Buffer.from(output.stdout),
- stderr: Buffer.from(output.stderr),
- text: async () => output.stdout,
- }
- const shell = {
- quiet: () => shell,
- nothrow: () => shell,
- cwd: () => shell,
- env: () => shell,
- text: async () => output.stdout,
- then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
- Promise.resolve(result).then(onfulfilled, onrejected),
- catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
- finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
- }
- return shell
-}
-
-mock.module("bun", () => ({
- ...bunModule,
- $: (parts: TemplateStringsArray, ...vals: unknown[]) => {
- const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
+mock.module("../../src/util/git", () => ({
+ git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
+ const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("--all")
) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
- return (bunModule.$ as any)(parts, ...vals)
+ return originalGit(args, opts)
},
}))