From 324a39833d57ec87cd186c8e86c219acaa37b3ed Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 24 Mar 2026 13:43:40 +0530 Subject: [PATCH] core: refactor git utilities from util/git.ts to dedicated Git service module with proper Effect patterns --- packages/opencode/src/cli/cmd/github.ts | 10 +- packages/opencode/src/cli/cmd/pr.ts | 8 +- packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/git/index.ts | 317 ++++++++++++++++++ packages/opencode/src/project/project.ts | 10 +- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/util/git.ts | 35 -- packages/opencode/src/worktree/index.ts | 44 +-- .../opencode/test/project/project.test.ts | 73 ++-- 10 files changed, 402 insertions(+), 117 deletions(-) create mode 100644 packages/opencode/src/git/index.ts delete mode 100644 packages/opencode/src/util/git.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index edd9d75610..31ad65c07b 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" +import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" -import { git } from "@/util/git" type GitHubAuthor = { login: string @@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() + const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await git(args, { cwd: Instance.worktree }) + const result = await Git.run(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await git(args, { cwd: Instance.worktree }) + const result = await Git.run(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) + const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree }) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 8826fe343e..58d42c6ef0 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,8 +1,8 @@ import { UI } from "../ui" import { cmd } from "./cmd" +import { Git } from "@/git" import { Instance } from "@/project/instance" import { Process } from "@/util/process" -import { git } from "@/util/git" export const PrCommand = cmd({ command: "pr ", @@ -67,9 +67,9 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() + const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: Instance.worktree, }) UI.println(`Added fork remote: ${remoteName}`) @@ -77,7 +77,7 @@ export const PrCommand = cmd({ // Set upstream to the fork so pushes go there const headRefName = prInfo.headRefName - await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { cwd: Instance.worktree, }) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 23c77e7bf7..7dc36e9c3d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" -import { git } from "@/util/git" +import { Git } from "@/git" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" import fs from "fs" @@ -432,7 +432,7 @@ export namespace File { return yield* Effect.promise(async () => { const diffOutput = ( - await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { cwd: Instance.directory, }) ).text() @@ -452,7 +452,7 @@ export namespace File { } const untrackedOutput = ( - await git( + await Git.run( [ "-c", "core.fsmonitor=false", @@ -485,7 +485,7 @@ export namespace File { } const deletedOutput = ( - await git( + await Git.run( [ "-c", "core.fsmonitor=false", @@ -576,17 +576,17 @@ export namespace File { if (Instance.project.vcs === "git") { let diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) + await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) ).text() if (!diff.trim()) { diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory, }) ).text() } if (diff.trim()) { - const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() + const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 1b3fc8ab4f..ba70791433 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" import { Flag } from "@/flag/flag" +import { Git } from "@/git" import { Instance } from "@/project/instance" -import { git } from "@/util/git" import { lazy } from "@/util/lazy" import { Config } from "../config/config" import { FileIgnore } from "./ignore" @@ -130,7 +130,7 @@ export namespace FileWatcher { if (Instance.project.vcs === "git") { const result = yield* Effect.promise(() => - git(["rev-parse", "--git-dir"], { + Git.run(["rev-parse", "--git-dir"], { cwd: Instance.project.worktree, }), ) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts new file mode 100644 index 0000000000..d7e6d3ac14 --- /dev/null +++ b/packages/opencode/src/git/index.ts @@ -0,0 +1,317 @@ +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, Layer, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { makeRunPromise } from "@/effect/run-service" + +export namespace Git { + const cfg = [ + "--no-optional-locks", + "-c", + "core.autocrlf=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", + ] as const + + const out = (result: { text(): string }) => result.text().trim() + const split = (text: string) => text.split("\0").filter(Boolean) + const fail = (err: unknown) => + ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + }) satisfies Result + + export type Kind = "added" | "deleted" | "modified" + + export type Base = { + readonly name: string + readonly ref: string + } + + export type Item = { + readonly file: string + readonly code: string + readonly status: Kind + } + + export type Stat = { + readonly file: string + readonly additions: number + readonly deletions: number + } + + export interface Result { + readonly exitCode: number + readonly text: () => string + readonly stdout: Buffer + readonly stderr: Buffer + } + + export interface Options { + readonly cwd: string + readonly env?: Record + } + + export interface Interface { + readonly run: (args: string[], opts: Options) => Effect.Effect + readonly branch: (cwd: string) => Effect.Effect + readonly prefix: (cwd: string) => Effect.Effect + readonly defaultBranch: (cwd: string) => Effect.Effect + readonly hasHead: (cwd: string) => Effect.Effect + readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect + readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect + readonly status: (cwd: string) => Effect.Effect + readonly diff: (cwd: string, ref: string) => Effect.Effect + readonly stats: (cwd: string, ref: string) => Effect.Effect + } + + const kind = (code?: string): Kind => { + if (code === "??") return "added" + if (code?.includes("U")) return "modified" + if (code?.includes("A") && !code.includes("D")) return "added" + if (code?.includes("D") && !code.includes("A")) return "deleted" + return "modified" + } + + const parseStatus = (text: string) => + split(text).flatMap((item) => { + const file = item.slice(3) + if (!file) return [] + const code = item.slice(0, 2) + return [{ file, code, status: kind(code) } satisfies Item] + }) + + const parseNames = (text: string) => { + const list = split(text) + return list.flatMap((code, idx) => { + if (idx % 2 !== 0) return [] + const file = list[idx + 1] + if (!code || !file) return [] + return [{ file, code, status: kind(code) } satisfies Item] + }) + } + + const parseStats = (text: string) => + split(text).flatMap((item) => { + const a = item.indexOf("\t") + const b = item.indexOf("\t", a + 1) + if (a === -1 || b === -1) return [] + const file = item.slice(b + 1) + if (!file) return [] + const adds = item.slice(0, a) + const dels = item.slice(a + 1, b) + const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) + const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) + return [ + { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat, + ] + }) + + export class Service extends ServiceMap.Service()("@opencode/Git") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const run = Effect.fn("Git.run")( + function* (args: string[], opts: Options) { + const proc = ChildProcess.make("git", [...cfg, ...args], { + cwd: opts.cwd, + env: opts.env, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + return { + exitCode: yield* handle.exitCode, + text: () => stdout, + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), + } satisfies Result + }, + Effect.scoped, + Effect.catch((err) => Effect.succeed(fail(err))), + ) + + const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { + return (yield* run(args, opts)).text() + }) + + const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { + return (yield* text(args, opts)) + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + }) + + const refs = Effect.fnUntraced(function* (cwd: string) { + return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) + }) + + const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { + const result = yield* run(["config", "init.defaultBranch"], { cwd }) + const name = out(result) + if (!name || !list.includes(name)) return + return { name, ref: name } satisfies Base + }) + + const primary = Effect.fnUntraced(function* (cwd: string) { + const list = yield* lines(["remote"], { cwd }) + if (list.includes("origin")) return "origin" + if (list.length === 1) return list[0] + if (list.includes("upstream")) return "upstream" + return list[0] + }) + + const branch = Effect.fn("Git.branch")(function* (cwd: string) { + const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) + if (result.exitCode !== 0) return "" + return out(result) + }) + + const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { + const remote = yield* primary(cwd) + if (remote) { + const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) + if (head.exitCode === 0) { + const ref = out(head).replace(/^refs\/remotes\//, "") + const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" + if (name) return { name, ref } satisfies Base + } + } + + const list = yield* refs(cwd) + const next = yield* configured(cwd, list) + if (next) return next + if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base + if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base + }) + + const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) + return result.exitCode === 0 + }) + + const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { + const result = yield* run(["merge-base", base, head], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { + const target = prefix ? `${prefix}${file}` : file + const result = yield* run(["show", `${ref}:${target}`], { cwd }) + if (result.exitCode !== 0) return "" + if (result.stdout.includes(0)) return "" + return result.text() + }) + + const status = Effect.fn("Git.status")(function* (cwd: string) { + return parseStatus( + yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { + cwd, + }), + ) + }) + + const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { + return parseNames( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), + ) + }) + + const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { + return parseStats( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), + ) + }) + + return Service.of({ + run, + branch, + prefix, + defaultBranch, + hasHead, + mergeBase, + show, + status, + diff, + stats, + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) + + const runPromise = makeRunPromise(Service, defaultLayer) + + export function run(args: string[], opts: Options) { + return runPromise((git) => git.run(args, opts)) + } + + export function branch(cwd: string) { + return runPromise((git) => git.branch(cwd)) + } + + export function prefix(cwd: string) { + return runPromise((git) => git.prefix(cwd)) + } + + export function defaultBranch(cwd: string) { + return runPromise((git) => git.defaultBranch(cwd)) + } + + export function hasHead(cwd: string) { + return runPromise((git) => git.hasHead(cwd)) + } + + export function mergeBase(cwd: string, base: string, head?: string) { + return runPromise((git) => git.mergeBase(cwd, base, head)) + } + + export function show(cwd: string, ref: string, file: string, prefix?: string) { + return runPromise((git) => git.show(cwd, ref, file, prefix)) + } + + export function status(cwd: string) { + return runPromise((git) => git.status(cwd)) + } + + export function diff(cwd: string, ref: string) { + return runPromise((git) => git.diff(cwd, ref)) + } + + export function stats(cwd: string, ref: string) { + return runPromise((git) => git.stats(cwd, ref)) + } +} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c..4894fea1b9 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,8 +10,8 @@ import { fn } from "@opencode-ai/util/fn" import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" +import { Git } from "@/git" import { existsSync } from "fs" -import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" @@ -119,7 +119,7 @@ export namespace Project { } } - const worktree = await git(["rev-parse", "--git-common-dir"], { + const worktree = await Git.run(["rev-parse", "--git-common-dir"], { cwd: sandbox, }) .then(async (result) => { @@ -147,7 +147,7 @@ export namespace Project { // generate id from root commit if (!id) { - const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { + const roots = await Git.run(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox, }) .then(async (result) => @@ -184,7 +184,7 @@ export namespace Project { } } - const top = await git(["rev-parse", "--show-toplevel"], { + const top = await Git.run(["rev-parse", "--show-toplevel"], { cwd: sandbox, }) .then(async (result) => gitpath(sandbox, await result.text())) @@ -349,7 +349,7 @@ export namespace Project { if (input.project.vcs === "git") return input.project if (!which("git")) throw new Error("Git is not installed") - const result = await git(["init", "--quiet"], { + const result = await Git.run(["init", "--quiet"], { cwd: input.directory, }) if (result.exitCode !== 0) { diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index a78607cdfd..e48bfc1b67 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -7,8 +7,8 @@ import { lazy } from "../util/lazy" import { Lock } from "../util/lock" import { NamedError } from "@opencode-ai/util/error" import z from "zod" +import { Git } from "@/git" import { Glob } from "../util/glob" -import { git } from "@/util/git" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -49,7 +49,7 @@ export namespace Storage { } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue - const result = await git(["rev-list", "--max-parents=0", "--all"], { + const result = await Git.run(["rev-list", "--max-parents=0", "--all"], { cwd: worktree, }) const [id] = result diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts deleted file mode 100644 index 731131357f..0000000000 --- a/packages/opencode/src/util/git.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Process } from "./process" - -export interface GitResult { - exitCode: number - text(): string - stdout: Buffer - stderr: Buffer -} - -/** - * Run a git command. - * - * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance - * issues in embedded/client environments. - */ -export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { - return Process.run(["git", ...args], { - cwd: opts.cwd, - env: opts.env, - stdin: "ignore", - nothrow: true, - }) - .then((result) => ({ - exitCode: result.code, - text: () => result.stdout.toString(), - stdout: result.stdout, - stderr: result.stderr, - })) - .catch((error) => ({ - exitCode: 1, - text: () => "", - stdout: Buffer.alloc(0), - stderr: Buffer.from(error instanceof Error ? error.message : String(error)), - })) -} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6ed0e48202..10b455c48a 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,9 +12,9 @@ import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" -import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { Git } from "@/git" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -250,14 +250,14 @@ export namespace Worktree { } async function sweep(root: string) { - const first = await git(["clean", "-ffdx"], { cwd: root }) + const first = await Git.run(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first const entries = failed(first) if (!entries.length) return first await prune(root, entries) - return git(["clean", "-ffdx"], { cwd: root }) + return Git.run(["clean", "-ffdx"], { cwd: root }) } async function canonical(input: string) { @@ -276,7 +276,7 @@ export namespace Worktree { if (await exists(directory)) continue const ref = `refs/heads/${branch}` - const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { + const branchCheck = await Git.run(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree, }) if (branchCheck.exitCode === 0) continue @@ -348,7 +348,7 @@ export namespace Worktree { } export async function createFromInfo(info: Info, startCommand?: string) { - const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + const created = await Git.run(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: Instance.worktree, }) if (created.exitCode !== 0) { @@ -362,7 +362,7 @@ export namespace Worktree { return () => { const start = async () => { - const populated = await git(["reset", "--hard"], { cwd: info.directory }) + const populated = await Git.run(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "Failed to populate worktree" log.error("worktree checkout failed", { directory: info.directory, message }) @@ -479,10 +479,10 @@ export namespace Worktree { const stop = async (target: string) => { if (!(await exists(target))) return - await git(["fsmonitor--daemon", "stop"], { cwd: target }) + await Git.run(["fsmonitor--daemon", "stop"], { cwd: target }) } - const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -499,11 +499,11 @@ export namespace Worktree { } await stop(entry.path) - const removed = await git(["worktree", "remove", "--force", entry.path], { + const removed = await Git.run(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree, }) if (removed.exitCode !== 0) { - const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const next = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", @@ -520,7 +520,7 @@ export namespace Worktree { const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree }) + const deleted = await Git.run(["branch", "-D", branch], { cwd: Instance.worktree }) if (deleted.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) } @@ -540,7 +540,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -573,7 +573,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Worktree not found" }) } - const remoteList = await git(["remote"], { cwd: Instance.worktree }) + const remoteList = await Git.run(["remote"], { cwd: Instance.worktree }) if (remoteList.exitCode !== 0) { throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) } @@ -592,17 +592,17 @@ export namespace Worktree { : "" const remoteHead = remote - ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) + ? await Git.run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) : { exitCode: 1, stdout: undefined, stderr: undefined } const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" - const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { + const mainCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree, }) - const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { + const masterCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree, }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" @@ -613,7 +613,7 @@ export namespace Worktree { } if (remoteBranch) { - const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) + const fetch = await Git.run(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } @@ -625,7 +625,7 @@ export namespace Worktree { const worktreePath = entry.path - const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) + const resetToTarget = await Git.run(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } @@ -635,26 +635,26 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } - const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) + const update = await Git.run(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } - const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { + const subReset = await Git.run(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { cwd: worktreePath, }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } - const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { + const subClean = await Git.run(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { cwd: worktreePath, }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + const status = await Git.run(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f..d13f424a8f 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,45 +10,48 @@ import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) -const gitModule = await import("../../src/util/git") -const originalGit = gitModule.git +const gitModule = await import("../../src/git") +const originalRun = gitModule.Git.run type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" let mode: Mode = "none" -mock.module("../../src/util/git", () => ({ - git: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - 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 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 Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) +mock.module("../../src/git", () => ({ + Git: { + ...gitModule.Git, + run: (args: string[], opts: { cwd: string; env?: Record }) => { + const cmd = ["git", ...args].join(" ") + if ( + mode === "rev-list-fail" && + cmd.includes("git rev-list") && + cmd.includes("--max-parents=0") && + cmd.includes("HEAD") + ) { + return Promise.resolve({ + exitCode: 128, + text: () => "", + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { + return Promise.resolve({ + exitCode: 128, + text: () => "", + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { + return Promise.resolve({ + exitCode: 128, + text: () => "", + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + return originalRun(args, opts) + }, }, }))