fix(git): use stable executable for worktree discovery
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user