From 83819a15d3035eb3345165b6594b32359eb919ca Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 12:15:47 +0900 Subject: [PATCH] fix(shared): stop ancestor discovery at worktree root Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/project-discovery-dirs.test.ts | 34 +++++++--- src/shared/project-discovery-dirs.ts | 83 ++++++++++++++++++----- 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index 13dcc8a71..39ba5dc13 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdirSync, rmSync } from "node:fs" +import { mkdirSync, realpathSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -11,6 +11,10 @@ import { const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`) +function canonicalPath(path: string): string { + return realpathSync(path) +} + describe("project-discovery-dirs", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) @@ -33,9 +37,9 @@ describe("project-discovery-dirs", () => { // then expect(directories).toEqual([ - join(projectDir, ".opencode", "skills"), - join(projectDir, ".opencode", "skill"), - join(TEST_DIR, ".opencode", "skills"), + canonicalPath(join(projectDir, ".opencode", "skills")), + canonicalPath(join(projectDir, ".opencode", "skill")), + canonicalPath(join(TEST_DIR, ".opencode", "skills")), ]) }) @@ -51,8 +55,8 @@ describe("project-discovery-dirs", () => { // then expect(directories).toEqual([ - join(projectDir, ".opencode", "commands"), - join(TEST_DIR, ".opencode", "command"), + canonicalPath(join(projectDir, ".opencode", "commands")), + canonicalPath(join(TEST_DIR, ".opencode", "command")), ]) }) @@ -68,7 +72,21 @@ describe("project-discovery-dirs", () => { const agentsDirectories = findProjectAgentsSkillDirs(childDir) // then - expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")]) - expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")]) + expect(claudeDirectories).toEqual([canonicalPath(join(projectDir, ".claude", "skills"))]) + 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", () => { + // 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 }) + + // 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 007c3c16b..4e22b66f6 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -1,13 +1,29 @@ -import { existsSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" +function normalizePath(path: string): string { + const resolvedPath = resolve(path) + if (!existsSync(resolvedPath)) { + return resolvedPath + } + + try { + return realpathSync(resolvedPath) + } catch { + return resolvedPath + } +} + function findAncestorDirectories( startDirectory: string, targetPaths: ReadonlyArray>, + stopDirectory?: string, ): string[] { const directories: string[] = [] const seen = new Set() - let currentDirectory = resolve(startDirectory) + let currentDirectory = normalizePath(startDirectory) + const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined while (true) { for (const targetPath of targetPaths) { @@ -20,33 +36,66 @@ function findAncestorDirectories( directories.push(candidateDirectory) } + if (resolvedStopDirectory === currentDirectory) { + return directories + } + const parentDirectory = dirname(currentDirectory) if (parentDirectory === currentDirectory) { return directories } - currentDirectory = parentDirectory + currentDirectory = normalizePath(parentDirectory) } } -export function findProjectClaudeSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [[".claude", "skills"]]) +function detectWorktreePath(directory: string): string | undefined { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + } catch { + return undefined + } } -export function findProjectAgentsSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [[".agents", "skills"]]) +export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [[".claude", "skills"]], + stopDirectory ?? detectWorktreePath(startDirectory), + ) } -export function findProjectOpencodeSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [ - [".opencode", "skills"], - [".opencode", "skill"], - ]) +export function findProjectAgentsSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [[".agents", "skills"]], + stopDirectory ?? detectWorktreePath(startDirectory), + ) } -export function findProjectOpencodeCommandDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [ - [".opencode", "commands"], - [".opencode", "command"], - ]) +export function findProjectOpencodeSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [ + [".opencode", "skills"], + [".opencode", "skill"], + ], + stopDirectory ?? detectWorktreePath(startDirectory), + ) +} + +export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [ + [".opencode", "commands"], + [".opencode", "command"], + ], + stopDirectory ?? detectWorktreePath(startDirectory), + ) }