From fc5a995c83baad21e4d6954546a1242ad1f46118 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 23:52:16 +0900 Subject: [PATCH] fix(git): use stable executable for worktree discovery --- .../claude-code-command-loader/loader.test.ts | 19 ++++-- .../project-skill-discovery.test.ts | 55 +++++++++++++-- .../team-mode/team-worktree/cleanup.ts | 5 +- .../team-mode/team-worktree/manager.test.ts | 20 +++--- .../team-mode/team-worktree/manager.ts | 3 +- src/shared/git-executable.ts | 21 ++++++ src/shared/index.ts | 1 + src/shared/project-discovery-dirs.test.ts | 68 ++++++++++++++----- src/shared/project-discovery-dirs.ts | 33 ++++++--- 9 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 src/shared/git-executable.ts diff --git a/src/features/claude-code-command-loader/loader.test.ts b/src/features/claude-code-command-loader/loader.test.ts index b674f8ff9..6f80fcad0 100644 --- a/src/features/claude-code-command-loader/loader.test.ts +++ b/src/features/claude-code-command-loader/loader.test.ts @@ -1,9 +1,10 @@ -import { execFileSync } from "node:child_process" import { promises as fs } from "node:fs" import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { spawnSync } from "../../shared/bun-spawn-shim" +import { resolveGitExecutable } from "../../shared/git-executable" import * as loader from "./loader" const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`) @@ -16,6 +17,17 @@ function writeCommand(directory: string, name: string, description: string): voi ) } +function runGit(args: string[], cwd: string): void { + const result = spawnSync([resolveGitExecutable(), ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)) + } +} + describe("claude-code command loader", () => { let originalClaudeConfigDir: string | undefined let originalOpencodeConfigDir: string | undefined @@ -128,10 +140,7 @@ describe("claude-code command loader", () => { const repositoryDir = join(TEST_DIR, "repo") const nestedDirectory = join(repositoryDir, "packages", "app", "src") mkdirSync(nestedDirectory, { recursive: true }) - execFileSync("git", ["init"], { - cwd: repositoryDir, - stdio: ["ignore", "ignore", "ignore"], - }) + runGit(["init"], repositoryDir) writeCommand(join(repositoryDir, ".opencode", "commands", "deploy"), "staging", "Deploy staging") writeCommand(join(repositoryDir, ".opencode", "command"), "release", "Release command") writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command") diff --git a/src/features/opencode-skill-loader/project-skill-discovery.test.ts b/src/features/opencode-skill-loader/project-skill-discovery.test.ts index 0d34da8ea..de05af137 100644 --- a/src/features/opencode-skill-loader/project-skill-discovery.test.ts +++ b/src/features/opencode-skill-loader/project-skill-discovery.test.ts @@ -1,8 +1,10 @@ -import { execFileSync } from "node:child_process" import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { spawnSync } from "../../shared/bun-spawn-shim" +import { resolveGitExecutable } from "../../shared/git-executable" +import { detectWorktreePath } from "../../shared/project-discovery-dirs" import { discoverOpencodeProjectSkills, discoverProjectAgentsSkills, @@ -17,6 +19,21 @@ function writeSkill(directory: string, name: string, description: string): void ) } +function runGit(args: string[], cwd: string): void { + const result = spawnSync([resolveGitExecutable(), ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)) + } +} + +function canonicalPath(path: string): string { + return realpathSync(path) +} + describe("project skill discovery", () => { let tempDir = "" @@ -34,10 +51,38 @@ describe("project skill discovery", () => { const nestedDirectory = join(repositoryDir, "packages", "app", "src") mkdirSync(nestedDirectory, { recursive: true }) - execFileSync("git", ["init"], { - cwd: repositoryDir, - stdio: ["ignore", "ignore", "ignore"], + runGit(["init"], repositoryDir) + expect(existsSync(join(repositoryDir, ".git"))).toBe(true) + const gitExecutable = resolveGitExecutable() + const shellProbe = spawnSync(["/bin/sh", "-c", "printf '%s' probe"], { + stdout: "pipe", + stderr: "pipe", }) + const gitVersionProbe = spawnSync([gitExecutable, "--version"], { + stdout: "pipe", + stderr: "pipe", + }) + const directRevParse = spawnSync([gitExecutable, "rev-parse", "--show-toplevel"], { + cwd: nestedDirectory, + stdout: "pipe", + stderr: "pipe", + }) + expect({ + gitExecutable, + exitCode: directRevParse.exitCode, + gitVersionOutput: new TextDecoder().decode(gitVersionProbe.stdout).trim().startsWith("git version"), + shellOutput: new TextDecoder().decode(shellProbe.stdout), + stderr: new TextDecoder().decode(directRevParse.stderr), + stdout: new TextDecoder().decode(directRevParse.stdout).trim(), + }).toEqual({ + gitExecutable, + exitCode: 0, + gitVersionOutput: true, + shellOutput: "probe", + stderr: "", + stdout: canonicalPath(repositoryDir), + }) + expect(detectWorktreePath(nestedDirectory)).toBe(canonicalPath(repositoryDir)) writeSkill( join(repositoryDir, ".claude", "skills", "repo-claude"), diff --git a/src/features/team-mode/team-worktree/cleanup.ts b/src/features/team-mode/team-worktree/cleanup.ts index 72649cc31..928b05e73 100644 --- a/src/features/team-mode/team-worktree/cleanup.ts +++ b/src/features/team-mode/team-worktree/cleanup.ts @@ -2,10 +2,11 @@ import fs from "node:fs/promises" import path from "node:path" import type { TeamModeConfig } from "./manager" +import { resolveGitExecutable } from "../../../shared" import { spawn as bunSpawn } from "../../../shared/bun-spawn-shim" async function runGit(args: string[]): Promise<{ code: number; stderr: string }> { - const process = bunSpawn({ cmd: ["git", ...args], stdout: "pipe", stderr: "pipe" }) + const process = bunSpawn({ cmd: [resolveGitExecutable(), ...args], stdout: "pipe", stderr: "pipe" }) const [exitCode, stderrText] = await Promise.all([process.exited, new Response(process.stderr).text()]) return { code: exitCode, stderr: stderrText } } @@ -14,7 +15,7 @@ export async function removeWorktree(worktreePath: string): Promise { await fs.rm(worktreePath, { recursive: true, force: true }) const rootLookup = bunSpawn({ - cmd: ["git", "-C", worktreePath, "rev-parse", "--show-superproject-working-tree"], + cmd: [resolveGitExecutable(), "-C", worktreePath, "rev-parse", "--show-superproject-working-tree"], stdout: "pipe", stderr: "pipe", }) diff --git a/src/features/team-mode/team-worktree/manager.test.ts b/src/features/team-mode/team-worktree/manager.test.ts index 0f21fc2c1..31d1285b0 100644 --- a/src/features/team-mode/team-worktree/manager.test.ts +++ b/src/features/team-mode/team-worktree/manager.test.ts @@ -14,18 +14,21 @@ import { validateWorktreeSpec, } from "./manager" import { removeWorktree } from "./cleanup" +import { spawnSync } from "../../../shared/bun-spawn-shim" +import { resolveGitExecutable } from "../../../shared" const temporaryDirectories: string[] = [] async function initGitRepo(): Promise { const repositoryRoot = await fs.mkdtemp(path.join(tmpdir(), "team-worktree-")) temporaryDirectories.push(repositoryRoot) - Bun.spawnSync(["git", "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + const git = resolveGitExecutable() + spawnSync([git, "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) await fs.writeFile(path.join(repositoryRoot, "README.md"), "hello\n") - Bun.spawnSync(["git", "add", "README.md"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) - Bun.spawnSync(["git", "config", "user.email", "test@example.com"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) - Bun.spawnSync(["git", "config", "user.name", "Test User"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) - Bun.spawnSync(["git", "commit", "-m", "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + spawnSync([git, "add", "README.md"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + spawnSync([git, "config", "user.email", "test@example.com"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + spawnSync([git, "config", "user.name", "Test User"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + spawnSync([git, "commit", "-m", "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) return repositoryRoot } @@ -57,10 +60,11 @@ describe("team-worktree manager", () => { // then expect(resultPath).toBe(worktreeDirectory) await expect(fs.stat(worktreeDirectory)).resolves.toBeDefined() - const listResult = Bun.spawnSync(["git", "worktree", "list"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) + const git = resolveGitExecutable() + const listResult = spawnSync([git, "worktree", "list"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" }) expect(new TextDecoder().decode(listResult.stdout)).toContain(worktreeDirectory) - const headResult = Bun.spawnSync(["git", "-C", worktreeDirectory, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }) - const repoHeadResult = Bun.spawnSync(["git", "-C", repositoryRoot, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }) + const headResult = spawnSync([git, "-C", worktreeDirectory, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }) + const repoHeadResult = spawnSync([git, "-C", repositoryRoot, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }) expect(new TextDecoder().decode(headResult.stdout).trim()).toBe(new TextDecoder().decode(repoHeadResult.stdout).trim()) }) diff --git a/src/features/team-mode/team-worktree/manager.ts b/src/features/team-mode/team-worktree/manager.ts index fe07c3fc7..b19937c6b 100644 --- a/src/features/team-mode/team-worktree/manager.ts +++ b/src/features/team-mode/team-worktree/manager.ts @@ -1,4 +1,5 @@ import path from "node:path" +import { resolveGitExecutable } from "../../../shared" import { spawn as bunSpawn } from "../../../shared/bun-spawn-shim" export type TeamModeConfig = { @@ -17,7 +18,7 @@ function countParentSegments(spec: string): number { } async function runGit(args: string[], cwd?: string): Promise<{ code: number; stderr: string }> { - const process = bunSpawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" }) + const process = bunSpawn({ cmd: [resolveGitExecutable(), ...args], cwd, stdout: "pipe", stderr: "pipe" }) const [exitCode, stderrBytes] = await Promise.all([process.exited, new Response(process.stderr).text()]) return { code: exitCode, stderr: stderrBytes } } diff --git a/src/shared/git-executable.ts b/src/shared/git-executable.ts new file mode 100644 index 000000000..f5ad478f4 --- /dev/null +++ b/src/shared/git-executable.ts @@ -0,0 +1,21 @@ +import { existsSync } from "node:fs" + +const GIT_EXECUTABLE_CANDIDATES = [ + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", +] as const + +export function resolveGitExecutable(): string { + for (const candidate of GIT_EXECUTABLE_CANDIDATES) { + if (existsSync(candidate)) { + return candidate + } + } + + if (typeof Bun !== "undefined") { + return Bun.which("git") ?? "git" + } + + return "git" +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 9f39f9bb3..5d0d4d717 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -61,6 +61,7 @@ export * from "./opencode-provider-auth" export * from "./opencode-http-api" export * from "./port-utils" export * from "./git-worktree" +export * from "./git-executable" export * from "./safe-create-hook" export * from "./truncate-description" export * from "./opencode-storage-paths" diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index 040d5e3e7..d6312000c 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -1,51 +1,83 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +/// + +import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { spawnSync } from "./bun-spawn-shim" +import { resolveGitExecutable } from "./git-executable" + const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`) -let worktreeSpawnCount = 0 +type ProjectDiscoveryDirsModule = typeof import("./project-discovery-dirs") + +async function importFreshProjectDiscoveryDirs(): Promise { + return import(`./project-discovery-dirs?test=${Date.now()}-${Math.random()}`) +} function canonicalPath(path: string): string { return realpathSync(path) } +function runGit(args: string[], cwd: string): void { + const result = spawnSync([resolveGitExecutable(), ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)) + } +} + describe("project-discovery-dirs", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) }) afterEach(() => { - mock.restore() rmSync(TEST_DIR, { recursive: true, force: true }) }) it("#given repeated worktree detection #when detecting twice #then reuses the cached result", async () => { // given - worktreeSpawnCount = 0 - - mock.module("node:child_process", () => ({ - execFileSync: () => { - worktreeSpawnCount += 1 - return TEST_DIR - }, - })) + const repositoryDir = join(TEST_DIR, "repo") + const nestedDirectory = join(repositoryDir, "packages", "app") + mkdirSync(nestedDirectory, { recursive: true }) + runGit(["init"], repositoryDir) const { clearWorktreeCache, detectWorktreePath } = await import("./project-discovery-dirs") clearWorktreeCache() // when - const firstPath = detectWorktreePath("/some/dir") - const secondPath = detectWorktreePath("/some/dir") + const firstPath = detectWorktreePath(nestedDirectory) + rmSync(join(repositoryDir, ".git"), { recursive: true, force: true }) + const secondPath = detectWorktreePath(nestedDirectory) clearWorktreeCache() - const thirdPath = detectWorktreePath("/some/dir") + const thirdPath = detectWorktreePath(nestedDirectory) // then - expect(firstPath).toBe(TEST_DIR) - expect(secondPath).toBe(TEST_DIR) - expect(thirdPath).toBe(TEST_DIR) - expect(worktreeSpawnCount).toBe(2) + expect(firstPath).toBe(canonicalPath(repositoryDir)) + expect(secondPath).toBe(firstPath) + expect(thirdPath).toBeUndefined() + }) + + it("#given a fresh module and real git repo #when detecting a worktree #then resolves the repository root", async () => { + // given + const repositoryDir = join(TEST_DIR, "fresh-repo") + const nestedDirectory = join(repositoryDir, "packages", "app") + mkdirSync(nestedDirectory, { recursive: true }) + runGit(["init"], repositoryDir) + + const { clearWorktreeCache, detectWorktreePath } = await importFreshProjectDiscoveryDirs() + clearWorktreeCache() + + // when + const worktreePath = detectWorktreePath(nestedDirectory) + + // then + expect(worktreePath).toBe(canonicalPath(repositoryDir)) }) it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", async () => { diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index cbe49a771..1f7cb7ca9 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -1,7 +1,8 @@ -import { execFileSync } from "node:child_process" import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" +import { spawnSync } from "./bun-spawn-shim" +import { resolveGitExecutable } from "./git-executable" import { detectPluginConfigFile } from "./jsonc-parser" import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" @@ -64,20 +65,34 @@ export function detectWorktreePath(directory: string): string | undefined { return worktreePathCache.get(resolvedDirectory) } + let result: ReturnType try { - const worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], { + result = spawnSync([resolveGitExecutable(), "rev-parse", "--show-toplevel"], { cwd: resolvedDirectory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() + stdout: "pipe", + stderr: "pipe", + }) + } catch (error) { + if (error instanceof Error) { + worktreePathCache.set(resolvedDirectory, undefined) + return undefined + } + throw error + } - worktreePathCache.set(resolvedDirectory, worktreePath) - return worktreePath - } catch { + if (result.exitCode !== 0 || result.stdout === undefined) { worktreePathCache.set(resolvedDirectory, undefined) return undefined } + + const worktreePath = result.stdout.toString("utf8").trim() + if (worktreePath === "") { + worktreePathCache.set(resolvedDirectory, undefined) + return undefined + } + + worktreePathCache.set(resolvedDirectory, worktreePath) + return worktreePath } export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {