fix(git): use stable executable for worktree discovery

This commit is contained in:
YeonGyu-Kim
2026-05-30 23:52:16 +09:00
parent f50cfb8984
commit fc5a995c83
9 changed files with 177 additions and 48 deletions
@@ -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")
@@ -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"),
@@ -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<void> {
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",
})
@@ -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<string> {
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())
})
@@ -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 }
}
+21
View File
@@ -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"
}
+1
View File
@@ -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"
+50 -18
View File
@@ -1,51 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
/// <reference types="bun-types" />
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<ProjectDiscoveryDirsModule> {
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 () => {
+24 -9
View File
@@ -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<typeof spawnSync>
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[] {