fix: correctly identify .roo folder in multi-root workspaces

- Add findWorkspaceWithRoo() utility to detect when .roo is a workspace folder
- Update getProjectRooDirectoryForCwd() to return .roo workspace path directly
- Update all direct .roo path constructions to use centralized utilities
- Add comprehensive tests for multi-root workspace scenarios

Fixes #6720
This commit is contained in:
Roo Code 2025-08-05 11:39:22 +00:00
parent d90bab71ff
commit f921cdb72c
6 changed files with 171 additions and 19 deletions

View file

@ -10,7 +10,7 @@ import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeC
import { fileExistsAtPath } from "../../utils/fs"
import { getWorkspacePath } from "../../utils/path"
import { getGlobalRooDirectory } from "../../services/roo-config"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../../services/roo-config"
import { logger } from "../../utils/logging"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
@ -566,14 +566,15 @@ export class CustomModesManager {
if (scope === "project") {
const workspacePath = getWorkspacePath()
if (workspacePath) {
rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`)
const rooDir = await getProjectRooDirectoryForCwd(workspacePath)
rulesFolderPath = path.join(rooDir, `rules-${slug}`)
} else {
return // No workspace, can't delete project rules
}
} else {
// Global scope - use OS home directory
const homeDir = os.homedir()
rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
// Global scope - use global .roo directory
const globalRooDir = getGlobalRooDirectory()
rulesFolderPath = path.join(globalRooDir, `rules-${slug}`)
}
// Check if the rules folder exists and delete it
@ -665,7 +666,8 @@ export class CustomModesManager {
if (!workspacePath) {
return false
}
modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
const rooDir = await getProjectRooDirectoryForCwd(workspacePath)
modeRulesDir = path.join(rooDir, `rules-${slug}`)
}
try {
@ -769,9 +771,13 @@ export class CustomModesManager {
}
// Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
const modeRulesDir = isGlobalMode
? path.join(baseDir, `rules-${slug}`)
: path.join(baseDir, ".roo", `rules-${slug}`)
let modeRulesDir: string
if (isGlobalMode) {
modeRulesDir = path.join(baseDir, `rules-${slug}`)
} else {
const rooDir = await getProjectRooDirectoryForCwd(baseDir)
modeRulesDir = path.join(rooDir, `rules-${slug}`)
}
let rulesFiles: RuleFile[] = []
try {
@ -855,7 +861,7 @@ export class CustomModesManager {
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
} else {
const workspacePath = getWorkspacePath()
baseDir = path.join(workspacePath, ".roo")
baseDir = await getProjectRooDirectoryForCwd(workspacePath)
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
}

View file

@ -50,6 +50,7 @@ import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"
import { generateSystemPrompt } from "./generateSystemPrompt"
import { getCommand } from "../../utils/commands"
import { getProjectRooDirectoryForCwd } from "../../services/roo-config"
const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"])
@ -801,7 +802,7 @@ export const webviewMessageHandler = async (
}
const workspaceFolder = vscode.workspace.workspaceFolders[0]
const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo")
const rooDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath)
const mcpPath = path.join(rooDir, "mcp.json")
try {
@ -2483,7 +2484,8 @@ export const webviewMessageHandler = async (
vscode.window.showErrorMessage(t("common:errors.no_workspace_for_project_command"))
break
}
commandsDir = path.join(workspaceRoot, ".roo", "commands")
const rooDir = await getProjectRooDirectoryForCwd(workspaceRoot)
commandsDir = path.join(rooDir, "commands")
}
// Ensure the commands directory exists

View file

@ -6,6 +6,7 @@ import type { MarketplaceItem, MarketplaceItemType, InstallMarketplaceItemOption
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import type { CustomModesManager } from "../../core/config/CustomModesManager"
import { getProjectRooDirectoryForCwd } from "../../services/roo-config"
export interface InstallOptions extends InstallMarketplaceItemOptions {
target: "project" | "global"
@ -375,7 +376,8 @@ export class SimpleInstaller {
if (!workspaceFolder) {
throw new Error("No workspace folder found")
}
return path.join(workspaceFolder.uri.fsPath, ".roo", "mcp.json")
const rooDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath)
return path.join(rooDir, "mcp.json")
} else {
const globalSettingsPath = await ensureSettingsDirectoryExists(this.context)
return path.join(globalSettingsPath, GlobalFileNames.mcpSettings)

View file

@ -32,6 +32,7 @@ import {
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { injectVariables } from "../../utils/config"
import { getProjectRooDirectoryForCwd } from "../../services/roo-config"
export type McpConnection = {
server: McpServer
@ -536,7 +537,7 @@ export class McpHub {
}
const workspaceFolder = vscode.workspace.workspaceFolders[0]
const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo")
const projectMcpDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath)
const projectMcpPath = path.join(projectMcpDir, "mcp.json")
try {

View file

@ -1,11 +1,13 @@
import * as path from "path"
import * as vscode from "vscode"
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
// Use vi.hoisted to ensure mocks are available during hoisting
const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
const { mockStat, mockReadFile, mockHomedir, mockWorkspaceFolders } = vi.hoisted(() => ({
mockStat: vi.fn(),
mockReadFile: vi.fn(),
mockHomedir: vi.fn(),
mockWorkspaceFolders: vi.fn(),
}))
// Mock fs/promises module
@ -21,6 +23,18 @@ vi.mock("os", () => ({
homedir: mockHomedir,
}))
// Mock vscode module
vi.mock("vscode", () => ({
workspace: {
get workspaceFolders() {
return mockWorkspaceFolders()
},
},
Uri: {
file: (path: string) => ({ fsPath: path }),
},
}))
import {
getGlobalRooDirectory,
getProjectRooDirectoryForCwd,
@ -29,12 +43,14 @@ import {
readFileIfExists,
getRooDirectoriesForCwd,
loadConfiguration,
findWorkspaceWithRoo,
} from "../index"
describe("RooConfigService", () => {
beforeEach(() => {
vi.clearAllMocks()
mockHomedir.mockReturnValue("/mock/home")
mockWorkspaceFolders.mockReturnValue(undefined)
})
afterEach(() => {
@ -54,10 +70,81 @@ describe("RooConfigService", () => {
})
})
describe("findWorkspaceWithRoo", () => {
it("should return undefined when no workspace folders exist", () => {
mockWorkspaceFolders.mockReturnValue(undefined)
const result = findWorkspaceWithRoo()
expect(result).toBeUndefined()
})
it("should return undefined when workspace folders array is empty", () => {
mockWorkspaceFolders.mockReturnValue([])
const result = findWorkspaceWithRoo()
expect(result).toBeUndefined()
})
it("should return the workspace folder named .roo", () => {
const workspaceFolders = [
{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 },
{ uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 },
{ uri: { fsPath: "/workspace/project2" }, name: "project2", index: 2 },
]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const result = findWorkspaceWithRoo()
expect(result).toBe(workspaceFolders[1])
})
it("should return undefined when no workspace folder is named .roo", () => {
const workspaceFolders = [
{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 },
{ uri: { fsPath: "/workspace/project2" }, name: "project2", index: 1 },
]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const result = findWorkspaceWithRoo()
expect(result).toBeUndefined()
})
it("should handle workspace folders with .roo in path but not as basename", () => {
const workspaceFolders = [
{ uri: { fsPath: "/workspace/.roo/subproject" }, name: "subproject", index: 0 },
{ uri: { fsPath: "/workspace/project.roo" }, name: "project.roo", index: 1 },
]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const result = findWorkspaceWithRoo()
expect(result).toBeUndefined()
})
})
describe("getProjectRooDirectoryForCwd", () => {
it("should return correct path for given cwd", () => {
it("should return .roo workspace folder path when it exists", async () => {
const workspaceFolders = [
{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 },
{ uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 },
]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const cwd = "/workspace/project1"
const result = await getProjectRooDirectoryForCwd(cwd)
expect(result).toBe("/workspace/.roo")
})
it("should return cwd/.roo when no .roo workspace folder exists", async () => {
const workspaceFolders = [{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const cwd = "/custom/project/path"
const result = getProjectRooDirectoryForCwd(cwd)
const result = await getProjectRooDirectoryForCwd(cwd)
expect(result).toBe(path.join(cwd, ".roo"))
})
it("should return cwd/.roo when no workspace folders exist", async () => {
mockWorkspaceFolders.mockReturnValue(undefined)
const cwd = "/custom/project/path"
const result = await getProjectRooDirectoryForCwd(cwd)
expect(result).toBe(path.join(cwd, ".roo"))
})
})
@ -206,13 +293,26 @@ describe("RooConfigService", () => {
})
describe("getRooDirectoriesForCwd", () => {
it("should return directories for given cwd", () => {
it("should return directories for given cwd", async () => {
const cwd = "/custom/project/path"
const result = getRooDirectoriesForCwd(cwd)
const result = await getRooDirectoriesForCwd(cwd)
expect(result).toEqual([path.join("/mock/home", ".roo"), path.join(cwd, ".roo")])
})
it("should use .roo workspace folder when it exists", async () => {
const workspaceFolders = [
{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 },
{ uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 },
]
mockWorkspaceFolders.mockReturnValue(workspaceFolders)
const cwd = "/workspace/project1"
const result = await getRooDirectoriesForCwd(cwd)
expect(result).toEqual([path.join("/mock/home", ".roo"), "/workspace/.roo"])
})
})
describe("loadConfiguration", () => {

View file

@ -1,6 +1,36 @@
import * as path from "path"
import * as os from "os"
import fs from "fs/promises"
import * as vscode from "vscode"
/**
* Finds the workspace folder that contains a .roo directory
*
* @returns The workspace folder containing .roo, or undefined if not found
*
* @example
* ```typescript
* const workspaceWithRoo = findWorkspaceWithRoo()
* if (workspaceWithRoo) {
* // .roo folder exists as one of the workspace folders
* const rooPath = workspaceWithRoo.uri.fsPath
* }
* ```
*/
export function findWorkspaceWithRoo(): vscode.WorkspaceFolder | undefined {
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
return undefined
}
// Check if any workspace folder is named .roo
for (const folder of vscode.workspace.workspaceFolders) {
if (path.basename(folder.uri.fsPath) === ".roo") {
return folder
}
}
return undefined
}
/**
* Gets the global .roo directory path based on the current platform
@ -56,8 +86,19 @@ export function getGlobalRooDirectory(): string {
* index.ts
* package.json
* ```
*
* @note In multi-root workspaces, if .roo is one of the workspace folders,
* this function will return that folder's path instead of creating a .roo
* subdirectory in the first workspace folder.
*/
export function getProjectRooDirectoryForCwd(cwd: string): string {
// Check if .roo is one of the workspace folders in a multi-root workspace
const workspaceWithRoo = findWorkspaceWithRoo()
if (workspaceWithRoo) {
return workspaceWithRoo.uri.fsPath
}
// Default behavior: create .roo as a subdirectory
return path.join(cwd, ".roo")
}