Skip to content

Commit 1b499c4

Browse files
committed
feat: add hierarchical .roo configuration resolution for mono-repos
- Implement hierarchical directory walking in getRooDirectoriesForCwd - Update CustomModesManager to support hierarchical .roomodes files - Add comprehensive tests for hierarchical resolution - Maintain backward compatibility with legacy mode (non-hierarchical) Fixes #8825
1 parent 98b8d5b commit 1b499c4

File tree

4 files changed

+364
-70
lines changed

4 files changed

+364
-70
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 113 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,61 @@ export class CustomModesManager {
102102
return exists ? roomodesPath : undefined
103103
}
104104

105+
/**
106+
* Get all .roomodes files in the hierarchy from workspace root up to parent directories
107+
* @returns Array of .roomodes file paths, ordered from most general (parent) to most specific (workspace)
108+
*/
109+
private async getHierarchicalRoomodes(): Promise<string[]> {
110+
const workspaceFolders = vscode.workspace.workspaceFolders
111+
112+
if (!workspaceFolders || workspaceFolders.length === 0) {
113+
return []
114+
}
115+
116+
const workspaceRoot = getWorkspacePath()
117+
const roomodesFiles: string[] = []
118+
const visitedPaths = new Set<string>()
119+
const homeDir = os.homedir()
120+
let currentPath = path.resolve(workspaceRoot)
121+
122+
// Walk up the directory tree from workspace root
123+
while (currentPath && currentPath !== path.dirname(currentPath)) {
124+
// Avoid infinite loops
125+
if (visitedPaths.has(currentPath)) {
126+
break
127+
}
128+
visitedPaths.add(currentPath)
129+
130+
// Don't look for .roomodes in the home directory
131+
if (currentPath === homeDir) {
132+
break
133+
}
134+
135+
// Check if .roomodes exists at this level
136+
const roomodesPath = path.join(currentPath, ROOMODES_FILENAME)
137+
if (await fileExistsAtPath(roomodesPath)) {
138+
roomodesFiles.push(roomodesPath)
139+
}
140+
141+
// Move to parent directory
142+
const parentPath = path.dirname(currentPath)
143+
144+
// Stop if we've reached the root or if parent is the same as current
145+
if (
146+
parentPath === currentPath ||
147+
parentPath === "/" ||
148+
(process.platform === "win32" && parentPath === path.parse(currentPath).root)
149+
) {
150+
break
151+
}
152+
153+
currentPath = parentPath
154+
}
155+
156+
// Return in order from most general (parent) to most specific (workspace)
157+
return roomodesFiles.reverse()
158+
}
159+
105160
/**
106161
* Regex pattern for problematic characters that need to be cleaned from YAML content
107162
* Includes:
@@ -293,12 +348,17 @@ export class CustomModesManager {
293348
return
294349
}
295350

296-
// Get modes from .roomodes if it exists (takes precedence)
297-
const roomodesPath = await this.getWorkspaceRoomodes()
298-
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
351+
// Get modes from hierarchical .roomodes
352+
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
353+
const allRoomodesModes: ModeConfig[] = []
354+
355+
for (const roomodesPath of hierarchicalRoomodes) {
356+
const modes = await this.loadModesFromFile(roomodesPath)
357+
allRoomodesModes.push(...modes)
358+
}
299359

300-
// Merge modes from both sources (.roomodes takes precedence)
301-
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
360+
// Merge modes from all sources
361+
const mergedModes = await this.mergeCustomModes(allRoomodesModes, result.data.customModes)
302362
await this.context.globalState.update("customModes", mergedModes)
303363
this.clearCache()
304364
await this.onUpdate()
@@ -312,19 +372,28 @@ export class CustomModesManager {
312372
this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange))
313373
this.disposables.push(settingsWatcher)
314374

315-
// Watch .roomodes file - watch the path even if it doesn't exist yet
375+
// Watch .roomodes files in hierarchy
316376
const workspaceFolders = vscode.workspace.workspaceFolders
317377
if (workspaceFolders && workspaceFolders.length > 0) {
318-
const workspaceRoot = getWorkspacePath()
319-
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
320-
const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
378+
// Create a generic pattern to watch all .roomodes files in the workspace tree
379+
const roomodesPattern = new vscode.RelativePattern(workspaceFolders[0], "**/.roomodes")
380+
const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPattern)
321381

322382
const handleRoomodesChange = async () => {
323383
try {
324384
const settingsModes = await this.loadModesFromFile(settingsPath)
325-
const roomodesModes = await this.loadModesFromFile(roomodesPath)
326-
// .roomodes takes precedence
327-
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
385+
386+
// Get modes from hierarchical .roomodes
387+
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
388+
const allRoomodesModes: ModeConfig[] = []
389+
390+
for (const roomodesPath of hierarchicalRoomodes) {
391+
const modes = await this.loadModesFromFile(roomodesPath)
392+
allRoomodesModes.push(...modes)
393+
}
394+
395+
// Merge modes from all sources
396+
const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes)
328397
await this.context.globalState.update("customModes", mergedModes)
329398
this.clearCache()
330399
await this.onUpdate()
@@ -335,19 +404,7 @@ export class CustomModesManager {
335404

336405
this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange))
337406
this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange))
338-
this.disposables.push(
339-
roomodesWatcher.onDidDelete(async () => {
340-
// When .roomodes is deleted, refresh with only settings modes
341-
try {
342-
const settingsModes = await this.loadModesFromFile(settingsPath)
343-
await this.context.globalState.update("customModes", settingsModes)
344-
this.clearCache()
345-
await this.onUpdate()
346-
} catch (error) {
347-
console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error)
348-
}
349-
}),
350-
)
407+
this.disposables.push(roomodesWatcher.onDidDelete(handleRoomodesChange))
351408
this.disposables.push(roomodesWatcher)
352409
}
353410
}
@@ -360,37 +417,35 @@ export class CustomModesManager {
360417
return this.cachedModes
361418
}
362419

363-
// Get modes from settings file.
420+
// Get modes from settings file (global)
364421
const settingsPath = await this.getCustomModesFilePath()
365422
const settingsModes = await this.loadModesFromFile(settingsPath)
366423

367-
// Get modes from .roomodes if it exists.
368-
const roomodesPath = await this.getWorkspaceRoomodes()
369-
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
424+
// Get modes from hierarchical .roomodes files
425+
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
426+
const allRoomodesModes: ModeConfig[] = []
370427

371-
// Create maps to store modes by source.
372-
const projectModes = new Map<string, ModeConfig>()
373-
const globalModes = new Map<string, ModeConfig>()
374-
375-
// Add project modes (they take precedence).
376-
for (const mode of roomodesModes) {
377-
projectModes.set(mode.slug, { ...mode, source: "project" as const })
428+
// Load modes from each .roomodes file in hierarchy
429+
for (const roomodesPath of hierarchicalRoomodes) {
430+
const modes = await this.loadModesFromFile(roomodesPath)
431+
allRoomodesModes.push(...modes)
378432
}
379433

380-
// Add global modes.
434+
// Create a map to handle mode precedence (more specific overrides more general)
435+
const modesMap = new Map<string, ModeConfig>()
436+
437+
// Add global modes first
381438
for (const mode of settingsModes) {
382-
if (!projectModes.has(mode.slug)) {
383-
globalModes.set(mode.slug, { ...mode, source: "global" as const })
384-
}
439+
modesMap.set(mode.slug, { ...mode, source: "global" as const })
440+
}
441+
442+
// Add hierarchical .roomodes modes (will override global and parent modes)
443+
for (const mode of allRoomodesModes) {
444+
modesMap.set(mode.slug, { ...mode, source: "project" as const })
385445
}
386446

387-
// Combine modes in the correct order: project modes first, then global modes.
388-
const mergedModes = [
389-
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
390-
...settingsModes
391-
.filter((mode) => !projectModes.has(mode.slug))
392-
.map((mode) => ({ ...mode, source: "global" as const })),
393-
]
447+
// Convert map to array
448+
const mergedModes = Array.from(modesMap.values())
394449

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

@@ -493,11 +548,18 @@ export class CustomModesManager {
493548

494549
private async refreshMergedState(): Promise<void> {
495550
const settingsPath = await this.getCustomModesFilePath()
496-
const roomodesPath = await this.getWorkspaceRoomodes()
497-
498551
const settingsModes = await this.loadModesFromFile(settingsPath)
499-
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
500-
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
552+
553+
// Get modes from hierarchical .roomodes
554+
const hierarchicalRoomodes = await this.getHierarchicalRoomodes()
555+
const allRoomodesModes: ModeConfig[] = []
556+
557+
for (const roomodesPath of hierarchicalRoomodes) {
558+
const modes = await this.loadModesFromFile(roomodesPath)
559+
allRoomodesModes.push(...modes)
560+
}
561+
562+
const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes)
501563

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

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import * as path from "path"
3+
import * as os from "os"
4+
import { getRooDirectoriesForCwd } from "../index"
5+
6+
vi.mock("os", () => ({
7+
homedir: vi.fn(),
8+
}))
9+
10+
describe("Hierarchical .roo directory resolution", () => {
11+
const mockHomedir = vi.mocked(os.homedir)
12+
13+
beforeEach(() => {
14+
mockHomedir.mockReturnValue("/home/user")
15+
})
16+
17+
afterEach(() => {
18+
vi.clearAllMocks()
19+
})
20+
21+
describe("getRooDirectoriesForCwd with hierarchical resolution", () => {
22+
it("should return only global and project-local when hierarchical is disabled", () => {
23+
const cwd = "/home/user/projects/myapp"
24+
const directories = getRooDirectoriesForCwd(cwd, false)
25+
26+
expect(directories).toEqual([
27+
"/home/user/.roo", // Global
28+
"/home/user/projects/myapp/.roo", // Project-local
29+
])
30+
})
31+
32+
it("should return hierarchical directories for simple project", () => {
33+
const cwd = "/home/user/projects/myapp"
34+
const directories = getRooDirectoriesForCwd(cwd, true)
35+
36+
expect(directories).toEqual([
37+
"/home/user/.roo", // Global
38+
"/home/user/projects/.roo", // Parent directory
39+
"/home/user/projects/myapp/.roo", // Project-local
40+
])
41+
})
42+
43+
it("should handle deeply nested mono-repo structure", () => {
44+
const cwd = "/home/user/work/company/mono-repo/packages/frontend/src"
45+
const directories = getRooDirectoriesForCwd(cwd, true)
46+
47+
expect(directories).toEqual([
48+
"/home/user/.roo", // Global
49+
"/home/user/work/.roo",
50+
"/home/user/work/company/.roo",
51+
"/home/user/work/company/mono-repo/.roo", // Repository root
52+
"/home/user/work/company/mono-repo/packages/.roo", // Packages folder
53+
"/home/user/work/company/mono-repo/packages/frontend/.roo", // Frontend package
54+
"/home/user/work/company/mono-repo/packages/frontend/src/.roo", // Source folder
55+
])
56+
})
57+
58+
it.skipIf(process.platform !== "win32")("should handle Windows paths correctly", () => {
59+
mockHomedir.mockReturnValue("C:\\Users\\john")
60+
61+
const cwd = "C:\\Users\\john\\projects\\myapp"
62+
const directories = getRooDirectoriesForCwd(cwd, true)
63+
64+
expect(directories).toEqual([
65+
"C:\\Users\\john\\.roo", // Global
66+
"C:\\Users\\john\\projects\\.roo", // Parent directory
67+
"C:\\Users\\john\\projects\\myapp\\.roo", // Project-local
68+
])
69+
})
70+
71+
it("should stop at home directory and not include it twice", () => {
72+
const cwd = "/home/user/myproject"
73+
const directories = getRooDirectoriesForCwd(cwd, true)
74+
75+
expect(directories).toEqual([
76+
"/home/user/.roo", // Global (home directory)
77+
"/home/user/myproject/.roo", // Project-local
78+
])
79+
80+
// Should not have duplicate /home/user/.roo entries
81+
const homeDirRooCount = directories.filter((d) => d === "/home/user/.roo").length
82+
expect(homeDirRooCount).toBe(1)
83+
})
84+
85+
it("should handle root directory edge case", () => {
86+
const cwd = "/project"
87+
const directories = getRooDirectoriesForCwd(cwd, true)
88+
89+
expect(directories).toEqual([
90+
"/home/user/.roo", // Global
91+
"/project/.roo", // Project at root level
92+
])
93+
})
94+
95+
it("should handle relative paths by resolving them", () => {
96+
const cwd = "./myproject"
97+
const directories = getRooDirectoriesForCwd(cwd, true)
98+
99+
// Should resolve relative path and return proper hierarchy
100+
expect(directories[0]).toBe("/home/user/.roo") // Global directory
101+
expect(directories[directories.length - 1]).toMatch(/myproject[\/\\]\.roo$/)
102+
})
103+
104+
it("should use hierarchical resolution by default when not specified", () => {
105+
const cwd = "/home/user/projects/myapp"
106+
const directories = getRooDirectoriesForCwd(cwd) // No second parameter
107+
108+
expect(directories).toEqual([
109+
"/home/user/.roo", // Global
110+
"/home/user/projects/.roo", // Parent directory
111+
"/home/user/projects/myapp/.roo", // Project-local
112+
])
113+
})
114+
115+
it("should handle edge case of cwd being exactly home directory", () => {
116+
const cwd = "/home/user"
117+
const directories = getRooDirectoriesForCwd(cwd, true)
118+
119+
expect(directories).toEqual([
120+
"/home/user/.roo", // Global (and also the cwd)
121+
])
122+
123+
// Should not have duplicate entries
124+
expect(directories.length).toBe(1)
125+
})
126+
127+
it("should handle symbolic links and circular references gracefully", () => {
128+
// This tests that the visitedPaths Set prevents infinite loops
129+
const cwd = "/home/user/projects/link/to/self"
130+
const directories = getRooDirectoriesForCwd(cwd, true)
131+
132+
// Should not throw or hang, should return valid hierarchy
133+
expect(directories).toBeDefined()
134+
expect(directories[0]).toBe("/home/user/.roo")
135+
expect(directories.length).toBeGreaterThan(0)
136+
})
137+
})
138+
139+
describe("Integration with configuration loading", () => {
140+
it("should provide directories in correct order for override behavior", () => {
141+
// More specific configurations should override more general ones
142+
const cwd = "/home/user/company/project/submodule"
143+
const directories = getRooDirectoriesForCwd(cwd, true)
144+
145+
// Verify order: global first (least specific), then progressively more specific
146+
expect(directories[0]).toBe("/home/user/.roo") // Least specific
147+
expect(directories[directories.length - 1]).toBe("/home/user/company/project/submodule/.roo") // Most specific
148+
149+
// This order ensures that when configs are merged, more specific ones override general ones
150+
for (let i = 1; i < directories.length; i++) {
151+
// Each directory should be a child of the previous (when excluding global)
152+
if (i > 1) {
153+
expect(directories[i].startsWith(directories[i - 1].replace(/[\/\\]\.roo$/, ""))).toBe(true)
154+
}
155+
}
156+
})
157+
})
158+
})

0 commit comments

Comments
 (0)