feat: implement submodule modes inheritance

This adds support for inheriting custom modes from git submodules:

- Add "submodule" source type to mode schema in packages/types/src/mode.ts
- Add `submodulePath` field to track which submodule a mode comes from
- Add `includeSubmoduleModes` setting in global-settings.ts (default: false)
- Create git-submodules.ts utility for recursive submodule detection
- Modify CustomModesManager to load modes from submodules with proper
  precedence (project > submodule > global)
- Add file watchers for submodule .roomodes files
- Update UI to show submodule indicator in mode selector and make
  submodule modes read-only (disable rename/delete/edit buttons)

Addresses Issue #10382
This commit is contained in:
Roo Code 2025-12-29 22:14:38 +00:00
parent e851b9355e
commit 2fa52b92fd
5 changed files with 467 additions and 65 deletions

View file

@ -179,6 +179,13 @@ export const globalSettingsSchema = z.object({
mode: z.string().optional(),
modeApiConfigs: z.record(z.string(), z.string()).optional(),
customModes: z.array(modeConfigSchema).optional(),
/**
* Whether to include custom modes from git submodules.
* When enabled, Roo will recursively search for .roomodes files in git submodules
* and make those modes available in the parent workspace (as read-only).
* @default false
*/
includeSubmoduleModes: z.boolean().optional(),
customModePrompts: customModePromptsSchema.optional(),
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),

View file

@ -69,7 +69,12 @@ export const modeConfigSchema = z.object({
description: z.string().optional(),
customInstructions: z.string().optional(),
groups: groupEntryArraySchema,
source: z.enum(["global", "project"]).optional(),
source: z.enum(["global", "project", "submodule"]).optional(),
/**
* Relative path to the submodule from the workspace root.
* Only set when source is "submodule".
*/
submodulePath: z.string().optional(),
})
export type ModeConfig = z.infer<typeof modeConfigSchema>

View file

@ -15,6 +15,7 @@ import { logger } from "../../utils/logging"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { t } from "../../i18n"
import { getGitSubmodules, type SubmoduleInfo } from "../../utils/git-submodules"
const ROOMODES_FILENAME = ".roomodes"
@ -48,10 +49,12 @@ export class CustomModesManager {
private static readonly cacheTTL = 10_000
private disposables: vscode.Disposable[] = []
private submoduleWatchers: vscode.Disposable[] = []
private isWriting = false
private writeQueue: Array<() => Promise<void>> = []
private cachedModes: ModeConfig[] | null = null
private cachedAt: number = 0
private cachedSubmodules: SubmoduleInfo[] | null = null
constructor(
private readonly context: vscode.ExtensionContext,
@ -62,6 +65,73 @@ export class CustomModesManager {
})
}
/**
* Checks if the includeSubmoduleModes setting is enabled
*/
private isSubmoduleModesEnabled(): boolean {
const includeSubmoduleModes = this.context.globalState.get<boolean>("includeSubmoduleModes")
return includeSubmoduleModes === true
}
/**
* Loads modes from a submodule's .roomodes file
* @param submodule - The submodule info containing paths
* @returns Array of ModeConfig with source set to "submodule" and submodulePath set
*/
private async loadModesFromSubmodule(submodule: SubmoduleInfo): Promise<ModeConfig[]> {
const roomodesPath = path.join(submodule.absolutePath, ROOMODES_FILENAME)
if (!(await fileExistsAtPath(roomodesPath))) {
return []
}
try {
const modes = await this.loadModesFromFile(roomodesPath)
// Mark each mode as coming from a submodule
return modes.map((mode) => ({
...mode,
source: "submodule" as const,
submodulePath: submodule.fullRelativePath,
}))
} catch (error) {
console.error(
`[CustomModesManager] Error loading modes from submodule ${submodule.fullRelativePath}:`,
error,
)
return []
}
}
/**
* Gets all modes from git submodules (recursively)
* @returns Array of ModeConfig from all submodules
*/
private async getSubmoduleModes(): Promise<ModeConfig[]> {
if (!this.isSubmoduleModesEnabled()) {
return []
}
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return []
}
const workspaceRoot = getWorkspacePath()
const submodules = await getGitSubmodules(workspaceRoot, true)
// Cache submodules for file watcher setup
this.cachedSubmodules = submodules
const allSubmoduleModes: ModeConfig[] = []
for (const submodule of submodules) {
const modes = await this.loadModesFromSubmodule(submodule)
allSubmoduleModes.push(...modes)
}
return allSubmoduleModes
}
private async queueWrite(operation: () => Promise<void>): Promise<void> {
this.writeQueue.push(operation)
@ -350,9 +420,76 @@ export class CustomModesManager {
}),
)
this.disposables.push(roomodesWatcher)
// Setup submodule watchers if enabled
await this.setupSubmoduleWatchers()
}
}
/**
* Sets up file watchers for .roomodes files in git submodules
* This is called during initialization and when the includeSubmoduleModes setting changes
*/
private async setupSubmoduleWatchers(): Promise<void> {
// First, dispose of any existing submodule watchers
for (const watcher of this.submoduleWatchers) {
watcher.dispose()
}
this.submoduleWatchers = []
if (!this.isSubmoduleModesEnabled()) {
return
}
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return
}
const workspaceRoot = getWorkspacePath()
try {
const submodules = await getGitSubmodules(workspaceRoot, true)
for (const submodule of submodules) {
const roomodesPath = path.join(submodule.absolutePath, ROOMODES_FILENAME)
const watcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
const handleSubmoduleChange = async () => {
try {
logger.info(`Submodule .roomodes changed: ${submodule.fullRelativePath}`)
this.clearCache()
await this.getCustomModes() // This will refresh with submodule modes
await this.onUpdate()
} catch (error) {
console.error(
`[CustomModesManager] Error handling submodule .roomodes change in ${submodule.fullRelativePath}:`,
error,
)
}
}
this.submoduleWatchers.push(watcher.onDidChange(handleSubmoduleChange))
this.submoduleWatchers.push(watcher.onDidCreate(handleSubmoduleChange))
this.submoduleWatchers.push(watcher.onDidDelete(handleSubmoduleChange))
this.submoduleWatchers.push(watcher)
}
logger.info(`Set up ${submodules.length} submodule .roomodes watchers`)
} catch (error) {
console.error("[CustomModesManager] Failed to setup submodule watchers:", error)
}
}
/**
* Re-initializes submodule watchers. Call this when the includeSubmoduleModes setting changes.
*/
public async refreshSubmoduleWatchers(): Promise<void> {
await this.setupSubmoduleWatchers()
this.clearCache()
await this.onUpdate()
}
public async getCustomModes(): Promise<ModeConfig[]> {
// Check if we have a valid cached result.
const now = Date.now()
@ -369,29 +506,44 @@ export class CustomModesManager {
const roomodesPath = await this.getWorkspaceRoomodes()
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
// Create maps to store modes by source.
const projectModes = new Map<string, ModeConfig>()
const globalModes = new Map<string, ModeConfig>()
// Get modes from submodules if enabled.
const submoduleModes = await this.getSubmoduleModes()
// Add project modes (they take precedence).
// Create a set to track which slugs have been added (for deduplication).
const addedSlugs = new Set<string>()
// Precedence order (highest to lowest):
// 1. Project modes (.roomodes in workspace root)
// 2. Submodule modes (.roomodes in submodules)
// 3. Global modes (custom-modes.yaml)
const mergedModes: ModeConfig[] = []
// Add project modes first (highest precedence).
for (const mode of roomodesModes) {
projectModes.set(mode.slug, { ...mode, source: "project" as const })
}
// Add global modes.
for (const mode of settingsModes) {
if (!projectModes.has(mode.slug)) {
globalModes.set(mode.slug, { ...mode, source: "global" as const })
if (!addedSlugs.has(mode.slug)) {
addedSlugs.add(mode.slug)
mergedModes.push({ ...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 })),
]
// Add submodule modes (middle precedence).
// Only add if not already defined in project modes.
for (const mode of submoduleModes) {
if (!addedSlugs.has(mode.slug)) {
addedSlugs.add(mode.slug)
mergedModes.push(mode) // Already has source: "submodule" and submodulePath set
}
}
// Add global modes (lowest precedence).
// Only add if not already defined in project or submodule modes.
for (const mode of settingsModes) {
if (!addedSlugs.has(mode.slug)) {
addedSlugs.add(mode.slug)
mergedModes.push({ ...mode, source: "global" as const })
}
}
await this.context.globalState.update("customModes", mergedModes)
@ -1010,6 +1162,11 @@ export class CustomModesManager {
disposable.dispose()
}
for (const disposable of this.submoduleWatchers) {
disposable.dispose()
}
this.disposables = []
this.submoduleWatchers = []
}
}

198
src/utils/git-submodules.ts Normal file
View file

@ -0,0 +1,198 @@
import * as fs from "fs/promises"
import * as path from "path"
import { fileExistsAtPath } from "./fs"
/**
* Information about a git submodule
*/
export interface SubmoduleInfo {
/**
* The name of the submodule (from the [submodule "name"] section)
*/
name: string
/**
* The relative path to the submodule from the parent repository root
*/
relativePath: string
/**
* The absolute path to the submodule
*/
absolutePath: string
/**
* For nested submodules, this is the full relative path from the workspace root
* through all parent submodules (e.g., "parent-submodule/nested-submodule")
*/
fullRelativePath: string
}
/**
* Parses a .gitmodules file and extracts submodule information
*
* @param gitmodulesPath - Path to the .gitmodules file
* @param repoRoot - The root directory of the repository containing the .gitmodules file
* @param pathPrefix - Prefix to add to relative paths (for nested submodules)
* @returns Array of SubmoduleInfo objects
*/
async function parseGitmodules(
gitmodulesPath: string,
repoRoot: string,
pathPrefix: string = "",
): Promise<SubmoduleInfo[]> {
const submodules: SubmoduleInfo[] = []
try {
const content = await fs.readFile(gitmodulesPath, "utf-8")
const lines = content.split("\n")
let currentSubmodule: { name?: string; path?: string } = {}
for (const line of lines) {
const trimmedLine = line.trim()
// Match [submodule "name"] sections
const submoduleMatch = trimmedLine.match(/^\[submodule\s+"([^"]+)"\]$/)
if (submoduleMatch) {
// Save previous submodule if it has all required fields
if (currentSubmodule.name && currentSubmodule.path) {
const relativePath = currentSubmodule.path
const absolutePath = path.join(repoRoot, relativePath)
const fullRelativePath = pathPrefix ? path.join(pathPrefix, relativePath) : relativePath
submodules.push({
name: currentSubmodule.name,
relativePath,
absolutePath,
fullRelativePath,
})
}
// Start a new submodule
currentSubmodule = { name: submoduleMatch[1] }
continue
}
// Match path = value lines
const pathMatch = trimmedLine.match(/^path\s*=\s*(.+)$/)
if (pathMatch && currentSubmodule.name) {
currentSubmodule.path = pathMatch[1].trim()
}
}
// Don't forget the last submodule
if (currentSubmodule.name && currentSubmodule.path) {
const relativePath = currentSubmodule.path
const absolutePath = path.join(repoRoot, relativePath)
const fullRelativePath = pathPrefix ? path.join(pathPrefix, relativePath) : relativePath
submodules.push({
name: currentSubmodule.name,
relativePath,
absolutePath,
fullRelativePath,
})
}
} catch (error) {
// File doesn't exist or can't be read - return empty array
console.error(`[git-submodules] Failed to parse ${gitmodulesPath}:`, error)
}
return submodules
}
/**
* Gets all git submodules from a repository root, including nested submodules.
*
* This function recursively searches for .gitmodules files in the repository
* and all its submodules to find nested submodules as well.
*
* @param workspaceRoot - The root directory of the workspace
* @param recursive - Whether to recursively search for nested submodules (default: true)
* @param maxDepth - Maximum depth for recursive search (default: 10)
* @returns Array of SubmoduleInfo objects for all found submodules
*/
export async function getGitSubmodules(
workspaceRoot: string,
recursive: boolean = true,
maxDepth: number = 10,
): Promise<SubmoduleInfo[]> {
const allSubmodules: SubmoduleInfo[] = []
const visited = new Set<string>()
async function searchSubmodules(repoRoot: string, pathPrefix: string, depth: number): Promise<void> {
if (depth > maxDepth) {
console.warn(`[git-submodules] Max depth (${maxDepth}) reached, stopping recursive search`)
return
}
// Avoid infinite loops with circular submodule references
const normalizedRoot = path.normalize(repoRoot)
if (visited.has(normalizedRoot)) {
return
}
visited.add(normalizedRoot)
const gitmodulesPath = path.join(repoRoot, ".gitmodules")
// Check if .gitmodules exists
if (!(await fileExistsAtPath(gitmodulesPath))) {
return
}
const submodules = await parseGitmodules(gitmodulesPath, repoRoot, pathPrefix)
allSubmodules.push(...submodules)
// Recursively search nested submodules
if (recursive) {
for (const submodule of submodules) {
// Verify the submodule directory exists before searching
if (await fileExistsAtPath(submodule.absolutePath)) {
await searchSubmodules(submodule.absolutePath, submodule.fullRelativePath, depth + 1)
}
}
}
}
await searchSubmodules(workspaceRoot, "", 0)
return allSubmodules
}
/**
* Checks if a given path is inside a git submodule
*
* @param filePath - The file path to check
* @param workspaceRoot - The root directory of the workspace
* @returns The SubmoduleInfo if the path is inside a submodule, undefined otherwise
*/
export async function getSubmoduleForPath(
filePath: string,
workspaceRoot: string,
): Promise<SubmoduleInfo | undefined> {
const submodules = await getGitSubmodules(workspaceRoot)
// Normalize the file path for comparison
const normalizedFilePath = path.normalize(filePath)
// Find the most specific submodule (longest matching path)
let matchingSubmodule: SubmoduleInfo | undefined
for (const submodule of submodules) {
const normalizedSubmodulePath = path.normalize(submodule.absolutePath)
if (
normalizedFilePath.startsWith(normalizedSubmodulePath + path.sep) ||
normalizedFilePath === normalizedSubmodulePath
) {
// Choose the more specific (longer path) submodule
if (!matchingSubmodule || submodule.fullRelativePath.length > matchingSubmodule.fullRelativePath.length) {
matchingSubmodule = submodule
}
}
}
return matchingSubmodule
}

View file

@ -55,7 +55,7 @@ import { useEscapeKey } from "@src/hooks/useEscapeKey"
// Get all available groups that should show in prompts view
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
type ModeSource = "global" | "project"
type ModeSource = "global" | "project" | "submodule"
type ImportModeResult = { type: "importModeResult"; success: boolean; slug?: string; error?: string }
@ -793,43 +793,53 @@ const ModesView = () => {
.includes(searchValue.toLowerCase())
: true,
)
.map((modeConfig) => (
<CommandItem
key={modeConfig.slug}
value={`${modeConfig.name} ${modeConfig.slug}`}
onSelect={() => {
handleModeSwitch(modeConfig)
setOpen(false)
}}
data-testid={`mode-option-${modeConfig.slug}`}>
<div className="flex items-center justify-between w-full">
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
flex: 2,
minWidth: 0,
}}>
{modeConfig.name}
</span>
<span
className="text-foreground"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
direction: "rtl",
textAlign: "right",
flex: 1,
minWidth: 0,
marginLeft: "0.5em",
}}>
{modeConfig.slug}
</span>
</div>
</CommandItem>
))}
.map((modeConfig) => {
const isSubmoduleMode = modeConfig.source === "submodule"
return (
<CommandItem
key={modeConfig.slug}
value={`${modeConfig.name} ${modeConfig.slug}`}
onSelect={() => {
handleModeSwitch(modeConfig)
setOpen(false)
}}
data-testid={`mode-option-${modeConfig.slug}`}>
<div className="flex items-center justify-between w-full">
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
flex: 2,
minWidth: 0,
}}>
{modeConfig.name}
{isSubmoduleMode && (
<span
className="ml-2 text-xs text-vscode-descriptionForeground"
title={`From submodule: ${modeConfig.submodulePath}`}>
📦 {modeConfig.submodulePath}
</span>
)}
</span>
<span
className="text-foreground"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
direction: "rtl",
textAlign: "right",
flex: 1,
minWidth: 0,
marginLeft: "0.5em",
}}>
{modeConfig.slug}
</span>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
@ -847,26 +857,38 @@ const ModesView = () => {
</Button>
</StandardTooltip>
{/* Edit (rename) mode - only enabled for custom modes */}
<StandardTooltip content={t("settings:providers.renameProfile")}>
{/* Edit (rename) mode - only enabled for editable custom modes (not submodule) */}
<StandardTooltip content={(() => {
const customMode = findModeBySlug(visualMode, customModes)
if (customMode?.source === "submodule") {
return t("prompts:modes.readOnlySubmodule") || "Read-only (from submodule)"
}
return t("settings:providers.renameProfile")
})()}>
<Button
variant="ghost"
size="icon"
onClick={handleStartRenameMode}
data-testid="rename-mode-button"
disabled={!findModeBySlug(visualMode, customModes)}>
disabled={!findModeBySlug(visualMode, customModes) || findModeBySlug(visualMode, customModes)?.source === "submodule"}>
<span className="codicon codicon-edit" />
</Button>
</StandardTooltip>
{/* Delete mode - disabled for built-in modes */}
<StandardTooltip content={t("prompts:createModeDialog.deleteMode")}>
{/* Delete mode - disabled for built-in modes and submodule modes */}
<StandardTooltip content={(() => {
const customMode = findModeBySlug(visualMode, customModes)
if (customMode?.source === "submodule") {
return t("prompts:modes.readOnlySubmodule") || "Read-only (from submodule)"
}
return t("prompts:createModeDialog.deleteMode")
})()}>
<Button
variant="ghost"
size="icon"
onClick={() => {
const customMode = findModeBySlug(visualMode, customModes)
if (customMode) {
if (customMode && customMode.source !== "submodule") {
setModeToDelete({
slug: customMode.slug,
name: customMode.name,
@ -880,7 +902,7 @@ const ModesView = () => {
}
}}
data-testid="delete-mode-button"
disabled={!findModeBySlug(visualMode, customModes)}>
disabled={!findModeBySlug(visualMode, customModes) || findModeBySlug(visualMode, customModes)?.source === "submodule"}>
<span className="codicon codicon-trash" />
</Button>
</StandardTooltip>
@ -1106,13 +1128,26 @@ const ModesView = () => {
/>
</div>
{/* Submodule mode read-only indicator */}
{getCurrentMode()?.source === "submodule" && (
<div className="mb-4 p-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
<div className="flex items-center gap-2 text-sm">
<span>📦</span>
<span>
{t("prompts:modes.submoduleModeReadOnly") ||
`This mode is from submodule "${getCurrentMode()?.submodulePath}" and is read-only.`}
</span>
</div>
</div>
)}
{/* Mode settings */}
<>
{/* Show tools for all modes */}
<div className="mb-4">
<div className="flex justify-between items-center mb-1">
<div className="font-bold">{t("prompts:tools.title")}</div>
{findModeBySlug(visualMode, customModes) && (
{findModeBySlug(visualMode, customModes) && findModeBySlug(visualMode, customModes)?.source !== "submodule" && (
<StandardTooltip
content={
isToolsEditMode ? t("prompts:tools.doneEditing") : t("prompts:tools.editTools")