diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index 39ba5dc13..d2904bc72 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -1,15 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { mkdirSync, realpathSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { - findProjectAgentsSkillDirs, - findProjectClaudeSkillDirs, - findProjectOpencodeCommandDirs, - findProjectOpencodeSkillDirs, -} from "./project-discovery-dirs" const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`) +let worktreeSpawnCount = 0 function canonicalPath(path: string): string { return realpathSync(path) @@ -24,7 +19,35 @@ describe("project-discovery-dirs", () => { rmSync(TEST_DIR, { recursive: true, force: true }) }) - it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", () => { + 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 { clearWorktreeCache, detectWorktreePath } = await import("./project-discovery-dirs") + + clearWorktreeCache() + + // when + const firstPath = detectWorktreePath("/some/dir") + const secondPath = detectWorktreePath("/some/dir") + clearWorktreeCache() + const thirdPath = detectWorktreePath("/some/dir") + + // then + expect(firstPath).toBe(TEST_DIR) + expect(secondPath).toBe(TEST_DIR) + expect(thirdPath).toBe(TEST_DIR) + expect(worktreeSpawnCount).toBe(2) + }) + + it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", async () => { // given const projectDir = join(TEST_DIR, "project") const childDir = join(projectDir, "apps", "cli") @@ -32,6 +55,8 @@ describe("project-discovery-dirs", () => { mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true }) mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true }) + const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs") + // when const directories = findProjectOpencodeSkillDirs(childDir) @@ -43,13 +68,15 @@ describe("project-discovery-dirs", () => { ]) }) - it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", () => { + it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", async () => { // given const projectDir = join(TEST_DIR, "project") const childDir = join(projectDir, "packages", "tool") mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true }) mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true }) + const { findProjectOpencodeCommandDirs } = await import("./project-discovery-dirs") + // when const directories = findProjectOpencodeCommandDirs(childDir) @@ -60,13 +87,15 @@ describe("project-discovery-dirs", () => { ]) }) - it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", () => { + it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", async () => { // given const projectDir = join(TEST_DIR, "project") const childDir = join(projectDir, "src", "nested") mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true }) mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true }) + const { findProjectAgentsSkillDirs, findProjectClaudeSkillDirs } = await import("./project-discovery-dirs") + // when const claudeDirectories = findProjectClaudeSkillDirs(childDir) const agentsDirectories = findProjectAgentsSkillDirs(childDir) @@ -76,17 +105,20 @@ describe("project-discovery-dirs", () => { expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))]) }) - it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => { + it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", async () => { // given const projectDir = join(TEST_DIR, "project") const childDir = join(projectDir, "apps", "cli") mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true }) mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true }) + const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs") + // when const directories = findProjectOpencodeSkillDirs(childDir, projectDir) // then expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))]) }) + }) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index 4e22b66f6..5e243df5a 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process" import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" +const worktreePathCache = new Map() + function normalizePath(path: string): string { const resolvedPath = resolve(path) if (!existsSync(resolvedPath)) { @@ -49,15 +51,28 @@ function findAncestorDirectories( } } -function detectWorktreePath(directory: string): string | undefined { +export function clearWorktreeCache(): void { + worktreePathCache.clear() +} + +export function detectWorktreePath(directory: string): string | undefined { + const resolvedDirectory = resolve(directory) + if (worktreePathCache.has(resolvedDirectory)) { + return worktreePathCache.get(resolvedDirectory) + } + try { - return execFileSync("git", ["rev-parse", "--show-toplevel"], { - cwd: directory, + const worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: resolvedDirectory, encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim() + + worktreePathCache.set(resolvedDirectory, worktreePath) + return worktreePath } catch { + worktreePathCache.set(resolvedDirectory, undefined) return undefined } }