Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 113 additions & 51 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,61 @@ export class CustomModesManager {
return exists ? roomodesPath : undefined
}

/**
* Get all .roomodes files in the hierarchy from workspace root up to parent directories
* @returns Array of .roomodes file paths, ordered from most general (parent) to most specific (workspace)
*/
private async getHierarchicalRoomodes(): Promise<string[]> {
const workspaceFolders = vscode.workspace.workspaceFolders

if (!workspaceFolders || workspaceFolders.length === 0) {
return []
}

const workspaceRoot = getWorkspacePath()
const roomodesFiles: string[] = []
const visitedPaths = new Set<string>()
const homeDir = os.homedir()
let currentPath = path.resolve(workspaceRoot)

// Walk up the directory tree from workspace root
while (currentPath && currentPath !== path.dirname(currentPath)) {
// Avoid infinite loops
if (visitedPaths.has(currentPath)) {
break
}
visitedPaths.add(currentPath)

// Don't look for .roomodes in the home directory
if (currentPath === homeDir) {
break
}

// Check if .roomodes exists at this level
const roomodesPath = path.join(currentPath, ROOMODES_FILENAME)
if (await fileExistsAtPath(roomodesPath)) {
roomodesFiles.push(roomodesPath)
}

// Move to parent directory
const parentPath = path.dirname(currentPath)

// Stop if we've reached the root or if parent is the same as current
if (
parentPath === currentPath ||
parentPath === "/" ||
(process.platform === "win32" && parentPath === path.parse(currentPath).root)
) {
break
}

currentPath = parentPath
}

// Return in order from most general (parent) to most specific (workspace)
return roomodesFiles.reverse()
}

/**
* Regex pattern for problematic characters that need to be cleaned from YAML content
* Includes:
Expand Down Expand Up @@ -293,12 +348,17 @@ export class CustomModesManager {
return
}

// Get modes from .roomodes if it exists (takes precedence)
const roomodesPath = await this.getWorkspaceRoomodes()
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
// Get modes from hierarchical .roomodes
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
const allRoomodesModes: ModeConfig[] = []

for (const roomodesPath of hierarchicalRoomodes) {
const modes = await this.loadModesFromFile(roomodesPath)
allRoomodesModes.push(...modes)
}

// Merge modes from both sources (.roomodes takes precedence)
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
// Merge modes from all sources
const mergedModes = await this.mergeCustomModes(allRoomodesModes, result.data.customModes)
await this.context.globalState.update("customModes", mergedModes)
this.clearCache()
await this.onUpdate()
Expand All @@ -312,19 +372,28 @@ export class CustomModesManager {
this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange))
this.disposables.push(settingsWatcher)

// Watch .roomodes file - watch the path even if it doesn't exist yet
// Watch .roomodes files in hierarchy
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders && workspaceFolders.length > 0) {
const workspaceRoot = getWorkspacePath()
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
// Create a generic pattern to watch all .roomodes files in the workspace tree
const roomodesPattern = new vscode.RelativePattern(workspaceFolders[0], "**/.roomodes")
const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPattern)
Comment on lines +378 to +380
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file watcher pattern **/.roomodes only watches files within the workspace directory, but getHierarchicalRoomodes() walks up to parent directories outside the workspace. This means changes to .roomodes files in parent directories won't trigger updates, requiring users to restart VSCode. For example, if the workspace is /home/user/mono-repo/packages/frontend and there's a .roomodes at /home/user/mono-repo/.roomodes, changes to the parent file won't be detected since it's outside the workspace tree being watched.


const handleRoomodesChange = async () => {
try {
const settingsModes = await this.loadModesFromFile(settingsPath)
const roomodesModes = await this.loadModesFromFile(roomodesPath)
// .roomodes takes precedence
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)

// Get modes from hierarchical .roomodes
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
const allRoomodesModes: ModeConfig[] = []

for (const roomodesPath of hierarchicalRoomodes) {
const modes = await this.loadModesFromFile(roomodesPath)
allRoomodesModes.push(...modes)
}

// Merge modes from all sources
const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes)
await this.context.globalState.update("customModes", mergedModes)
this.clearCache()
await this.onUpdate()
Expand All @@ -335,19 +404,7 @@ export class CustomModesManager {

this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange))
this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange))
this.disposables.push(
roomodesWatcher.onDidDelete(async () => {
// When .roomodes is deleted, refresh with only settings modes
try {
const settingsModes = await this.loadModesFromFile(settingsPath)
await this.context.globalState.update("customModes", settingsModes)
this.clearCache()
await this.onUpdate()
} catch (error) {
console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error)
}
}),
)
this.disposables.push(roomodesWatcher.onDidDelete(handleRoomodesChange))
this.disposables.push(roomodesWatcher)
}
}
Expand All @@ -360,37 +417,35 @@ export class CustomModesManager {
return this.cachedModes
}

// Get modes from settings file.
// Get modes from settings file (global)
const settingsPath = await this.getCustomModesFilePath()
const settingsModes = await this.loadModesFromFile(settingsPath)

// Get modes from .roomodes if it exists.
const roomodesPath = await this.getWorkspaceRoomodes()
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
// Get modes from hierarchical .roomodes files
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
const allRoomodesModes: ModeConfig[] = []

// Create maps to store modes by source.
const projectModes = new Map<string, ModeConfig>()
const globalModes = new Map<string, ModeConfig>()

// Add project modes (they take precedence).
for (const mode of roomodesModes) {
projectModes.set(mode.slug, { ...mode, source: "project" as const })
// Load modes from each .roomodes file in hierarchy
for (const roomodesPath of hierarchicalRoomodes) {
const modes = await this.loadModesFromFile(roomodesPath)
allRoomodesModes.push(...modes)
}

// Add global modes.
// Create a map to handle mode precedence (more specific overrides more general)
const modesMap = new Map<string, ModeConfig>()

// Add global modes first
for (const mode of settingsModes) {
if (!projectModes.has(mode.slug)) {
globalModes.set(mode.slug, { ...mode, source: "global" as const })
}
modesMap.set(mode.slug, { ...mode, source: "global" as const })
}

// Add hierarchical .roomodes modes (will override global and parent modes)
for (const mode of allRoomodesModes) {
modesMap.set(mode.slug, { ...mode, source: "project" as const })
}

// Combine modes in the correct order: project modes first, then global modes.
const mergedModes = [
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
...settingsModes
.filter((mode) => !projectModes.has(mode.slug))
.map((mode) => ({ ...mode, source: "global" as const })),
]
// Convert map to array
const mergedModes = Array.from(modesMap.values())

await this.context.globalState.update("customModes", mergedModes)

Expand Down Expand Up @@ -493,11 +548,18 @@ export class CustomModesManager {

private async refreshMergedState(): Promise<void> {
const settingsPath = await this.getCustomModesFilePath()
const roomodesPath = await this.getWorkspaceRoomodes()

const settingsModes = await this.loadModesFromFile(settingsPath)
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)

// Get modes from hierarchical .roomodes
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
const allRoomodesModes: ModeConfig[] = []

for (const roomodesPath of hierarchicalRoomodes) {
const modes = await this.loadModesFromFile(roomodesPath)
allRoomodesModes.push(...modes)
}

const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes)

await this.context.globalState.update("customModes", mergedModes)

Expand Down
158 changes: 158 additions & 0 deletions src/services/roo-config/__tests__/hierarchical-resolution.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as path from "path"
import * as os from "os"
import { getRooDirectoriesForCwd } from "../index"

vi.mock("os", () => ({
homedir: vi.fn(),
}))

describe("Hierarchical .roo directory resolution", () => {
const mockHomedir = vi.mocked(os.homedir)

beforeEach(() => {
mockHomedir.mockReturnValue("/home/user")
})

afterEach(() => {
vi.clearAllMocks()
})

describe("getRooDirectoriesForCwd with hierarchical resolution", () => {
it("should return only global and project-local when hierarchical is disabled", () => {
const cwd = "/home/user/projects/myapp"
const directories = getRooDirectoriesForCwd(cwd, false)

expect(directories).toEqual([
"/home/user/.roo", // Global
"/home/user/projects/myapp/.roo", // Project-local
])
})

it("should return hierarchical directories for simple project", () => {
const cwd = "/home/user/projects/myapp"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"/home/user/.roo", // Global
"/home/user/projects/.roo", // Parent directory
"/home/user/projects/myapp/.roo", // Project-local
])
})

it("should handle deeply nested mono-repo structure", () => {
const cwd = "/home/user/work/company/mono-repo/packages/frontend/src"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"/home/user/.roo", // Global
"/home/user/work/.roo",
"/home/user/work/company/.roo",
"/home/user/work/company/mono-repo/.roo", // Repository root
"/home/user/work/company/mono-repo/packages/.roo", // Packages folder
"/home/user/work/company/mono-repo/packages/frontend/.roo", // Frontend package
"/home/user/work/company/mono-repo/packages/frontend/src/.roo", // Source folder
])
})

it.skipIf(process.platform !== "win32")("should handle Windows paths correctly", () => {
mockHomedir.mockReturnValue("C:\\Users\\john")

const cwd = "C:\\Users\\john\\projects\\myapp"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"C:\\Users\\john\\.roo", // Global
"C:\\Users\\john\\projects\\.roo", // Parent directory
"C:\\Users\\john\\projects\\myapp\\.roo", // Project-local
])
})

it("should stop at home directory and not include it twice", () => {
const cwd = "/home/user/myproject"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"/home/user/.roo", // Global (home directory)
"/home/user/myproject/.roo", // Project-local
])

// Should not have duplicate /home/user/.roo entries
const homeDirRooCount = directories.filter((d) => d === "/home/user/.roo").length
expect(homeDirRooCount).toBe(1)
})

it("should handle root directory edge case", () => {
const cwd = "/project"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"/home/user/.roo", // Global
"/project/.roo", // Project at root level
])
})

it("should handle relative paths by resolving them", () => {
const cwd = "./myproject"
const directories = getRooDirectoriesForCwd(cwd, true)

// Should resolve relative path and return proper hierarchy
expect(directories[0]).toBe("/home/user/.roo") // Global directory
expect(directories[directories.length - 1]).toMatch(/myproject[\/\\]\.roo$/)
})

it("should use hierarchical resolution by default when not specified", () => {
const cwd = "/home/user/projects/myapp"
const directories = getRooDirectoriesForCwd(cwd) // No second parameter

expect(directories).toEqual([
"/home/user/.roo", // Global
"/home/user/projects/.roo", // Parent directory
"/home/user/projects/myapp/.roo", // Project-local
])
})

it("should handle edge case of cwd being exactly home directory", () => {
const cwd = "/home/user"
const directories = getRooDirectoriesForCwd(cwd, true)

expect(directories).toEqual([
"/home/user/.roo", // Global (and also the cwd)
])

// Should not have duplicate entries
expect(directories.length).toBe(1)
})

it("should handle symbolic links and circular references gracefully", () => {
// This tests that the visitedPaths Set prevents infinite loops
const cwd = "/home/user/projects/link/to/self"
const directories = getRooDirectoriesForCwd(cwd, true)

// Should not throw or hang, should return valid hierarchy
expect(directories).toBeDefined()
expect(directories[0]).toBe("/home/user/.roo")
expect(directories.length).toBeGreaterThan(0)
})
})

describe("Integration with configuration loading", () => {
it("should provide directories in correct order for override behavior", () => {
// More specific configurations should override more general ones
const cwd = "/home/user/company/project/submodule"
const directories = getRooDirectoriesForCwd(cwd, true)

// Verify order: global first (least specific), then progressively more specific
expect(directories[0]).toBe("/home/user/.roo") // Least specific
expect(directories[directories.length - 1]).toBe("/home/user/company/project/submodule/.roo") // Most specific

// This order ensures that when configs are merged, more specific ones override general ones
for (let i = 1; i < directories.length; i++) {
// Each directory should be a child of the previous (when excluding global)
if (i > 1) {
expect(directories[i].startsWith(directories[i - 1].replace(/[\/\\]\.roo$/, ""))).toBe(true)
}
}
})
})
})
Loading
Loading