feat(desktop): configure repository discovery (supersedes #67630) (#68642)

* feat(desktop): configure repository discovery

* fix(config): preserve additive default migration

* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe

projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).

Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.

Supersedes #67630; incorporates review feedback from that PR.

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>

---------

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-07-21 10:09:31 -04:00 committed by GitHub
parent d3b0e61429
commit d355e0e71d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 957 additions and 109 deletions

View file

@ -0,0 +1,76 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizeRepoScanPath, repoScanPathIsWithin, scanGitRepos } from './git-repo-scan'
const tempDirs: string[] = []
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-repo-scan-'))
tempDirs.push(dir)
return dir
}
function makeRepo(root: string, valid = true): void {
fs.mkdirSync(path.join(root, '.git'), { recursive: true })
if (valid) {
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
}
}
afterEach(() => {
vi.restoreAllMocks()
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { force: true, recursive: true })
}
})
describe('scanGitRepos', () => {
it('does not read the filesystem when discovery is disabled', async () => {
const read = vi.spyOn(fs.promises, 'readdir')
await expect(scanGitRepos([], { enabled: false })).resolves.toEqual([])
expect(read).not.toHaveBeenCalled()
})
it('scans only configured roots and excludes complete subtrees', async () => {
const root = tempDir()
const included = path.join(root, 'included')
const excluded = path.join(root, 'excluded')
const invalid = path.join(root, 'invalid')
makeRepo(included)
makeRepo(excluded)
makeRepo(invalid, false)
await expect(scanGitRepos([root], { enabled: true, excludePaths: [excluded], maxDepth: 2 })).resolves.toEqual([
{ label: 'included', root: included }
])
})
it('deduplicates overlapping roots', async () => {
const root = tempDir()
const repo = path.join(root, 'repo')
makeRepo(repo)
const result = await scanGitRepos([root, repo], { enabled: true })
expect(result).toEqual([{ label: 'repo', root: repo }])
})
})
describe('repository scan path normalization', () => {
it('expands tilde and resolves relative paths from home', () => {
expect(normalizeRepoScanPath('~/src', { homeDir: '/Users/rudi', platform: 'darwin' })?.value).toBe(
'/Users/rudi/src'
)
expect(normalizeRepoScanPath('src', { homeDir: '/Users/rudi', platform: 'linux' })?.value).toBe('/Users/rudi/src')
})
it('uses segment-aware, case-insensitive containment on Windows', () => {
const options = { homeDir: 'C:\\Users\\Rudi', platform: 'win32' as const }
expect(repoScanPathIsWithin('c:\\SRC\\Fever\\repo', 'C:\\src\\fever', options)).toBe(true)
expect(repoScanPathIsWithin('C:\\src\\feverish', 'C:\\src\\fever', options)).toBe(false)
})
})

View file

@ -1,32 +1,76 @@
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
// Repo-first discovery: walk bounded roots for Git repositories using only
// Node's fs APIs. Electron owns this machine-local capability; the renderer
// supplies the profile-scoped policy from Hermes config.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
async function mapLimit(items, limit, fn) {
export interface RepoScanOptions {
maxDepth?: number
enabled?: boolean
excludePaths?: string[]
}
export interface RepoScanPathOptions {
homeDir?: string
platform?: NodeJS.Platform
}
interface NormalizedScanPath {
key: string
value: string
}
function pathApiFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix
}
export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOptions = {}): NormalizedScanPath | null {
const platform = options.platform ?? process.platform
const homeDir = options.homeDir ?? os.homedir()
const pathApi = pathApiFor(platform)
const raw = String(rawPath ?? '').trim()
if (!raw) {
return null
}
let expanded = raw
if (raw === '~') {
expanded = homeDir
} else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
expanded = pathApi.join(homeDir, raw.slice(2))
}
const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded)
const value = pathApi.normalize(absolute)
const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value
return { key, value }
}
export function repoScanPathIsWithin(candidate: string, parent: string, options: RepoScanPathOptions = {}): boolean {
const platform = options.platform ?? process.platform
const pathApi = pathApiFor(platform)
const candidatePath = normalizeRepoScanPath(candidate, options)
const parentPath = normalizeRepoScanPath(parent, options)
if (!candidatePath || !parentPath) {
return false
}
const relative = pathApi.relative(parentPath.key, candidatePath.key)
return (
relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative))
)
}
async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let cursor = 0
async function worker() {
async function worker(): Promise<void> {
while (cursor < items.length) {
const index = cursor
cursor += 1
@ -34,63 +78,75 @@ async function mapLimit(items, limit, fn) {
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
* Scan roots for Git repositories. An empty root list preserves the historical
* home-directory scan. Disabled discovery returns before resolving home or
* reading the filesystem.
*/
async function scanGitRepos(roots, options: any = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) {
if (options.enabled === false) {
return []
}
async function walk(dir, depth) {
if (depth > maxDepth) {
const maxDepthValue = Number(options.maxDepth)
const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH
const pathOptions: RepoScanPathOptions = {}
const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const searchRoots = [
...new Map(
requestedRoots
.map(root => normalizeRepoScanPath(root, pathOptions))
.filter((entry): entry is NormalizedScanPath => entry !== null)
.map(entry => [entry.key, entry.value])
).values()
]
const exclusions = (options.excludePaths ?? [])
.map(excluded => normalizeRepoScanPath(excluded, pathOptions))
.filter((entry): entry is NormalizedScanPath => entry !== null)
const found = new Map<string, { root: string; label: string }>()
function isExcluded(candidate: string): boolean {
return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions))
}
async function walk(dir: string, depth: number): Promise<void> {
if (depth > maxDepth || isExcluded(dir)) {
return
}
let entries
let entries: fs.Dirent[]
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory())
if (gitDir) {
try {
await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK)
} catch {
return
}
subdirs.push(path.join(dir, entry.name))
const normalized = normalizeRepoScanPath(dir, pathOptions)
if (normalized) {
found.set(normalized.key, {
root: normalized.value,
label: path.basename(normalized.value) || normalized.value
})
}
return
}
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
const subdirs = entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name))
.map(entry => path.join(dir, entry.name))
await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1))
}
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0))
return [...found.values()]
}
export { scanGitRepos }

View file

@ -1,3 +1,4 @@
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
@ -10,9 +11,19 @@ import { renameSessionPreferringRpc } from './session-actions-menu'
// must route the ACTIVE row through the session.title RPC (runtime id), which
// persists the row on demand, and otherwise fall back to REST.
const renameSession = vi.fn(async () => ({ ok: true, title: 'rest-title' }))
const request = vi.fn(async () => ({ title: 'rpc-title' }) as never)
const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ request }))
// Hoisted so the vi.mock factories below (which vitest lifts to the top of the
// module) can reference these before the module body runs. This matters because
// projects.ts subscribes to $gateway at import and nanostores fires the
// subscriber synchronously — that reaches the @/store/gateway mock's
// activeGateway() during the transitive import on line 4, before a plain
// module-level const would be initialized (temporal dead zone).
const { renameSession, request, activeGateway } = vi.hoisted(() => ({
renameSession: vi.fn(async () => ({ ok: true, title: 'rest-title' })),
request: vi.fn(async () => ({ title: 'rpc-title' }) as never),
activeGateway: vi.fn<() => { request: unknown } | null>(() => ({ request: undefined }))
}))
// Wire activeGateway's default return to the shared request mock now that it exists.
activeGateway.mockReturnValue({ request })
vi.mock('@/hermes', () => ({
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
@ -23,6 +34,11 @@ vi.mock('@/hermes', () => ({
}))
vi.mock('@/store/gateway', () => ({
// projects.ts subscribes to $gateway at module load (its repo-scan sync fires
// immediately), pulled in transitively via the session store. Provide a real
// atom plus the hoisted activeGateway so the synchronous subscriber doesn't
// throw on an incomplete mock or hit an uninitialized reference.
$gateway: atom(null),
activeGateway: () => activeGateway()
}))

View file

@ -9,6 +9,7 @@ import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/
import { useI18n } from '@/i18n'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
@ -76,6 +77,7 @@ export function ConfigSettings({
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
const saveVersionRef = useRef(0)
const savedDiscoverySignatureRef = useRef<string | undefined>(undefined)
const [saveVersion, setSaveVersion] = useState(0)
// Seed the local draft once, the first time the shared record lands.
@ -85,6 +87,7 @@ export function ConfigSettings({
useEffect(() => {
if (loadedConfig && !configSeeded.current) {
configSeeded.current = true
savedDiscoverySignatureRef.current = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(loadedConfig))
setConfig(loadedConfig)
}
}, [loadedConfig])
@ -95,6 +98,7 @@ export function ConfigSettings({
// the pending debounced autosave is cancelled by its effect cleanup.
useOnProfileSwitch(() => {
configSeeded.current = false
savedDiscoverySignatureRef.current = undefined
setConfig(null)
saveVersionRef.current = 0
setSaveVersion(0)
@ -132,12 +136,20 @@ export function ConfigSettings({
const t = window.setTimeout(() => {
void (async () => {
try {
await saveHermesConfig(config)
const result = await saveHermesConfig(config)
if (!result.ok) {
throw new Error(c.autosaveFailed)
}
// Mirror the saved record into the shared cache so MCP/model surfaces
// reflect the edit without their own refetch.
setHermesConfigCache(config)
if (saveVersionRef.current === v) {
const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(config))
if (savedDiscoverySignatureRef.current !== discoverySignature) {
savedDiscoverySignatureRef.current = discoverySignature
await scanAndRecordRepos(true)
}
onConfigSaved?.()
}
} catch (err) {

View file

@ -390,6 +390,11 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
personality: 'Personality',
showReasoning: 'Reasoning Blocks'
},
desktop: {
repoScanEnabled: 'Automatic Repository Discovery',
repoScanRoots: 'Repository Discovery Roots',
repoScanExcludePaths: 'Excluded Repository Paths'
},
agent: {
maxTurns: 'Max Agent Steps',
imageInputMode: 'Image Attachments',
@ -551,6 +556,11 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
personality: 'Default assistant style for new sessions.',
showReasoning: 'Show reasoning sections when the backend provides them.'
},
desktop: {
repoScanEnabled: 'Scan local folders for Git repositories to show in Projects.',
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
},
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
agent: {
imageInputMode: 'Controls how image attachments are sent to the model.',
@ -645,6 +655,9 @@ export const SECTIONS: DesktopConfigSection[] = [
icon: Monitor,
keys: [
'terminal.cwd',
'desktop.repo_scan_enabled',
'desktop.repo_scan_roots',
'desktop.repo_scan_exclude_paths',
'code_execution.mode',
'terminal.persistent_shell',
'terminal.env_passthrough',

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import {
enumOptionsFor,
@ -15,6 +16,20 @@ import {
} from './helpers'
describe('settings helpers', () => {
it('surfaces repository discovery config in Workspace with user-facing copy', () => {
const workspace = SECTIONS.find(section => section.id === 'workspace')
expect(workspace?.keys).toEqual(
expect.arrayContaining([
'desktop.repo_scan_enabled',
'desktop.repo_scan_roots',
'desktop.repo_scan_exclude_paths'
])
)
expect(fieldCopyForSchemaKey(FIELD_LABELS, 'desktop.repo_scan_enabled')).toBeTruthy()
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
})
it('lists the desktop memory provider options in their declared order', () => {
const options = enumOptionsFor('memory.provider', '', {})

View file

@ -175,7 +175,10 @@ declare global {
createPr: (repoPath: string) => Promise<{ url: string }>
}
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
scanRepos: (
roots: string[],
options?: { maxDepth?: number; enabled?: boolean; excludePaths?: string[] }
) => Promise<{ root: string; label: string }[]>
}
terminal: {
/** Best-effort current working directory of the live PTY child (POSIX

View file

@ -229,8 +229,9 @@ export function setApiRequestProfile(profile: null | string): void {
_apiProfile = profile || null
}
function profileScoped(): { profile?: string } {
return _apiProfile ? { profile: _apiProfile } : {}
function profileScoped(profile?: null | string): { profile?: string } {
const selected = profile === undefined ? _apiProfile : profile
return selected ? { profile: selected } : {}
}
/** Options for a plugin REST call mirrors the app's own `hermesDesktop.api`
@ -649,9 +650,9 @@ export function getLogs(params: {
})
}
export function getHermesConfig(): Promise<HermesConfig> {
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
return window.hermesDesktop.api<HermesConfig>({
...profileScoped(),
...profileScoped(profile),
path: '/api/config',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})

View file

@ -378,6 +378,11 @@ export const ja = defineLocale({
personality: '人格',
showReasoning: '推論ブロック'
},
desktop: {
repoScanEnabled: 'リポジトリの自動検出',
repoScanRoots: 'リポジトリの検索ルート',
repoScanExcludePaths: '除外するリポジトリパス'
},
agent: {
maxTurns: '最大エージェントステップ',
imageInputMode: '画像添付',
@ -533,6 +538,11 @@ export const ja = defineLocale({
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
showReasoning: 'バックエンドが推論内容を提供したときに表示します。'
},
desktop: {
repoScanEnabled: 'ローカルフォルダを検索して Git リポジトリをプロジェクトに表示します。',
repoScanRoots: '検索するフォルダです。空の場合はホームディレクトリを検索します。',
repoScanExcludePaths: 'リポジトリ検出時に除外するフォルダとその配下です。'
},
timezone:
'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
agent: {

View file

@ -367,6 +367,11 @@ export const zhHant = defineLocale({
personality: '人格',
showReasoning: '推理區塊'
},
desktop: {
repoScanEnabled: '自動探索程式碼儲存庫',
repoScanRoots: '程式碼儲存庫掃描根目錄',
repoScanExcludePaths: '排除的程式碼儲存庫路徑'
},
agent: {
maxTurns: '最大代理步數',
imageInputMode: '圖片附件',
@ -522,6 +527,11 @@ export const zhHant = defineLocale({
personality: '新工作階段的預設助手風格。',
showReasoning: '後端提供推理內容時顯示該區塊。'
},
desktop: {
repoScanEnabled: '掃描本機資料夾,並在「專案」中顯示 Git 程式碼儲存庫。',
repoScanRoots: '要掃描的資料夾。留空時掃描主目錄。',
repoScanExcludePaths: '探索程式碼儲存庫時略過這些資料夾及其子目錄。'
},
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
agent: {
imageInputMode: '控制圖片附件如何傳送給模型。',

View file

@ -478,6 +478,11 @@ export const zh: Translations = {
personality: '人格',
showReasoning: '推理过程块'
},
desktop: {
repoScanEnabled: '自动发现代码仓库',
repoScanRoots: '代码仓库扫描根目录',
repoScanExcludePaths: '排除的代码仓库路径'
},
agent: {
maxTurns: '最大智能体步数',
imageInputMode: '图片附件',
@ -633,6 +638,11 @@ export const zh: Translations = {
personality: '新会话的默认助手风格。',
showReasoning: '当后端提供推理内容时予以显示。'
},
desktop: {
repoScanEnabled: '扫描本地文件夹,并在“项目”中显示 Git 代码仓库。',
repoScanRoots: '要扫描的文件夹。留空时扫描主目录。',
repoScanExcludePaths: '发现代码仓库时跳过这些文件夹及其子目录。'
},
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
agent: {
imageInputMode: '控制图片附件如何发送给模型。',

View file

@ -1,7 +1,9 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { $sidebarAgentsGrouped } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeProjectId,
@ -17,7 +19,9 @@ import {
pickProjectFolder,
projectNameForCwd,
refreshProjects,
refreshWorktrees
refreshProjectTree,
refreshWorktrees,
scanAndRecordRepos
} from './projects'
vi.mock('@/i18n', () => ({
@ -36,10 +40,20 @@ vi.mock('@/lib/desktop-fs', () => ({
}))
vi.mock('@/store/gateway', () => ({
$gateway: atom(null),
activeGateway: vi.fn(),
ensureActiveGatewayOpen: vi.fn()
}))
vi.mock('@/lib/desktop-git', () => ({ desktopGit: vi.fn() }))
vi.mock('@/hermes', () => ({
getHermesConfig: vi.fn(),
getProfiles: vi.fn(),
setApiRequestProfile: vi.fn(),
STARTUP_REQUEST_TIMEOUT_MS: 1000
}))
const fs = await import('@/lib/desktop-fs')
const desktopDefaultCwd = vi.mocked(fs.desktopDefaultCwd)
const isDesktopFsRemoteMode = vi.mocked(fs.isDesktopFsRemoteMode)
@ -47,6 +61,13 @@ const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths)
const gw = await import('@/store/gateway')
const activeGateway = vi.mocked(gw.activeGateway)
const gatewayAtom = gw.$gateway
const git = await import('@/lib/desktop-git')
const desktopGit = vi.mocked(git.desktopGit)
const hermes = await import('@/hermes')
const getHermesConfig = vi.mocked(hermes.getHermesConfig)
const notifications = await import('@/store/notifications')
const notify = vi.mocked(notifications.notify)
@ -259,3 +280,124 @@ describe('projects RPC capability', () => {
)
})
})
describe('repository discovery policy', () => {
beforeEach(() => {
vi.clearAllMocks()
$activeGatewayProfile.set('default')
isDesktopFsRemoteMode.mockReturnValue(false)
})
function gatewayWith(request: ReturnType<typeof vi.fn>) {
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
return gateway
}
it('records disabled policy without invoking the filesystem scanner', async () => {
const request = vi.fn(async (method: string) =>
method === 'projects.tree'
? { active_id: null, projects: [], scoped_session_ids: [] }
: { accepted: false, repos: [] }
)
gatewayWith(request)
const scanRepos = vi.fn()
desktopGit.mockReturnValue({ scanRepos } as never)
getHermesConfig.mockResolvedValue({
desktop: {
repo_scan_enabled: false,
repo_scan_exclude_paths: [],
repo_scan_roots: []
}
})
await scanAndRecordRepos()
expect(scanRepos).not.toHaveBeenCalled()
expect(request).toHaveBeenCalledWith('projects.record_repos', {
discovery_policy: { enabled: false, exclude_paths: [], roots: [] },
repos: []
})
})
it('passes custom roots and exclusions to Electron and records on the origin gateway', async () => {
const request = vi.fn(async (method: string) =>
method === 'projects.tree'
? { active_id: null, projects: [], scoped_session_ids: [] }
: { accepted: true, repos: [] }
)
gatewayWith(request)
const scanRepos = vi.fn().mockResolvedValue([{ label: 'repo', root: '/work/repo' }])
desktopGit.mockReturnValue({ scanRepos } as never)
getHermesConfig.mockResolvedValue({
desktop: {
repo_scan_enabled: true,
repo_scan_exclude_paths: ['/work/vendor'],
repo_scan_roots: ['/work']
}
})
await scanAndRecordRepos()
expect(getHermesConfig).toHaveBeenCalledWith('default')
expect(scanRepos).toHaveBeenCalledWith(['/work'], {
enabled: true,
excludePaths: ['/work/vendor']
})
expect(request).toHaveBeenCalledWith('projects.record_repos', {
discovery_policy: {
enabled: true,
exclude_paths: ['/work/vendor'],
roots: ['/work']
},
repos: [{ label: 'repo', root: '/work/repo' }]
})
})
it('does not scan the local filesystem for remote connections', async () => {
isDesktopFsRemoteMode.mockReturnValue(true)
const scanRepos = vi.fn()
desktopGit.mockReturnValue({ scanRepos } as never)
await scanAndRecordRepos(true)
expect(scanRepos).not.toHaveBeenCalled()
expect(getHermesConfig).not.toHaveBeenCalled()
})
})
describe('project tree profile isolation', () => {
it('does not publish a late response from the previous profile', async () => {
let resolveA: ((value: unknown) => void) | undefined
const responseA = new Promise(resolve => {
resolveA = resolve
})
const gatewayA = { connectionState: 'open', request: vi.fn(() => responseA) }
const gatewayB = {
connectionState: 'open',
request: vi.fn().mockResolvedValue({
active_id: null,
projects: [{ id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
})
}
let current = gatewayA
activeGateway.mockImplementation(() => current as never)
gatewayAtom.set(gatewayA as never)
const pendingA = refreshProjectTree()
current = gatewayB
$activeGatewayProfile.set('profile-b')
gatewayAtom.set(gatewayB as never)
await refreshProjectTree()
resolveA?.({
active_id: null,
projects: [{ id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
})
await pendingA
expect($projectTree.get().map(project => project.id)).toEqual(['profile-b'])
})
})

View file

@ -2,15 +2,16 @@ import { atom } from 'nanostores'
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
import { getHermesConfig, type HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
import { desktopGit } from '@/lib/desktop-git'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { persistentAtom } from '@/lib/persisted'
import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
import { setSidebarAgentsGrouped } from '@/store/layout'
import { notify } from '@/store/notifications'
import { requestFreshSession } from '@/store/profile'
import { $activeGatewayProfile, requestFreshSession } from '@/store/profile'
import { $selectedStoredSessionId, $sessions, sessionMatchesStoredId, workspaceCwdForNewSession } from '@/store/session'
import type { ProjectInfo, ProjectsPayload } from '@/types/hermes'
@ -265,6 +266,31 @@ async function gatewayRequest<T>(method: string, params: Record<string, unknown>
return gateway.request<T>(method, params)
}
async function gatewayRequestOn<T>(
gateway: HermesGateway,
method: string,
params: Record<string, unknown> = {}
): Promise<T> {
return gateway.request<T>(method, params)
}
interface ActiveProjectsContext {
gateway: HermesGateway
profile: string
}
async function activeProjectsContext(): Promise<ActiveProjectsContext> {
const profile = $activeGatewayProfile.get() || 'default'
let gateway = activeGateway()
if (!gateway || gateway.connectionState !== 'open') {
gateway = await ensureActiveGatewayOpen()
}
if (!gateway || gateway !== activeGateway() || profile !== ($activeGatewayProfile.get() || 'default')) {
throw new Error('Active Hermes profile changed while connecting')
}
return { gateway, profile }
}
function applyPayload(payload: ProjectsPayload): void {
$projects.set(payload.projects ?? [])
$activeProjectId.set(payload.active_id ?? null)
@ -288,40 +314,52 @@ interface ProjectTreePayload {
scoped_session_ids: string[]
}
// Pull the authoritative project tree (overview structure + counts + preview
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
// cached tree intact so the sidebar doesn't flicker.
export async function refreshProjectTree(): Promise<void> {
$projectTreeLoading.set(true)
let projectTreeRefreshGeneration = 0
async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
const generation = ++projectTreeRefreshGeneration
if (activeGateway() === gateway) {
$projectTreeLoading.set(true)
}
try {
const res = await gatewayRequest<ProjectTreePayload>('projects.tree', { preview_limit: 3 })
// The flat Sessions list shows everything; scoped ids are only used here to
// reconcile the optimistic eviction layer against what the server still lists.
const scoped = new Set(res.scoped_session_ids ?? [])
const res = await gatewayRequestOn<ProjectTreePayload>(gateway, 'projects.tree', {
preview_limit: 3
})
if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) {
return
}
const scoped = new Set(res.scoped_session_ids ?? [])
$projectTree.set(res.projects ?? [])
$activeProjectId.set(res.active_id ?? null)
// Reconcile the optimistic eviction layer against the fresh snapshot: keep
// evicting ids the server still lists (delete in flight) and drop the rest
// (server caught up), so the set can't grow unbounded across a long session.
const tombstones = $removedSessionIds.get()
if (tombstones.size) {
const pending = new Set([...tombstones].filter(id => scoped.has(id)))
if (pending.size !== tombstones.size) {
$removedSessionIds.set(pending)
}
}
markProjectsRpcSuccess()
} catch (err) {
markProjectsRpcFailure(err)
// Backend may not be ready; keep the last known tree.
if (activeGateway() === gateway) {
markProjectsRpcFailure(err)
}
} finally {
$projectTreeLoading.set(false)
if (generation === projectTreeRefreshGeneration && activeGateway() === gateway) {
$projectTreeLoading.set(false)
}
}
}
// Pull the authoritative project tree (overview structure + counts + preview
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
// cached tree intact so the sidebar doesn't flicker.
export async function refreshProjectTree(): Promise<void> {
try {
const { gateway } = await activeProjectsContext()
await refreshProjectTreeOn(gateway)
} catch {
// Backend may not be ready; keep the last known tree.
}
}
@ -340,30 +378,117 @@ export async function fetchProjectSessions(projectId: string): Promise<SidebarPr
}
}
// One filesystem scan per app run: the heavy disk walk happens once, the result
// is cached in the backend, and later opens read the cache. Desktop-only (needs
// the native crawler); elsewhere discovery falls back to session-derived repos.
let didScanRepos = false
export interface RepoDiscoveryPolicy {
enabled: boolean
roots: string[]
exclude_paths: string[]
}
export function repoDiscoveryPolicyFromConfig(config: unknown): RepoDiscoveryPolicy {
const desktopValue = config && typeof config === 'object' ? (config as { desktop?: unknown }).desktop : undefined
const desktop =
desktopValue && typeof desktopValue === 'object'
? (desktopValue as {
repo_scan_enabled?: unknown
repo_scan_exclude_paths?: unknown
repo_scan_roots?: unknown
})
: {}
return {
enabled: desktop.repo_scan_enabled !== false,
roots: Array.isArray(desktop.repo_scan_roots)
? desktop.repo_scan_roots.filter((value): value is string => typeof value === 'string')
: [],
exclude_paths: Array.isArray(desktop.repo_scan_exclude_paths)
? desktop.repo_scan_exclude_paths.filter((value): value is string => typeof value === 'string')
: []
}
}
export function repoDiscoveryPolicySignature(policy: RepoDiscoveryPolicy): string {
return JSON.stringify(policy)
}
interface RepoScanState {
completedSignature?: string
generation: number
runningSignature?: string
}
const repoScanStates = new WeakMap<HermesGateway, RepoScanState>()
const scanningGatewayGenerations = new WeakMap<HermesGateway, number>()
function syncReposScanning(): void {
const gateway = activeGateway()
$reposScanning.set(Boolean(gateway && scanningGatewayGenerations.has(gateway)))
}
$gateway.subscribe(syncReposScanning)
export async function scanAndRecordRepos(force = false): Promise<void> {
const scan = desktopGit()?.scanRepos
if (!scan || (didScanRepos && !force)) {
if (isDesktopFsRemoteMode()) {
return
}
didScanRepos = true
$reposScanning.set(true)
let context: ActiveProjectsContext
try {
context = await activeProjectsContext()
} catch {
return
}
const scan = desktopGit()?.scanRepos
if (!scan) {
return
}
const state = repoScanStates.get(context.gateway) ?? { generation: 0 }
repoScanStates.set(context.gateway, state)
let generation: number | undefined
try {
const repos = await scan([])
await gatewayRequest('projects.record_repos', { repos })
// The disk scan may surface new zero-session repos; refold them into the tree.
await refreshProjectTree()
const policy = repoDiscoveryPolicyFromConfig(await getHermesConfig(context.profile))
const signature = repoDiscoveryPolicySignature(policy)
if (!force && (state.completedSignature === signature || state.runningSignature === signature)) {
return
}
generation = ++state.generation
state.runningSignature = signature
if (!policy.enabled) {
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
discovery_policy: policy,
repos: []
})
} else {
scanningGatewayGenerations.set(context.gateway, generation)
syncReposScanning()
const repos = await scan(policy.roots, {
enabled: true,
excludePaths: policy.exclude_paths
})
if (state.generation !== generation) {
return
}
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
discovery_policy: policy,
repos
})
}
if (state.generation !== generation) {
return
}
state.completedSignature = signature
await refreshProjectTreeOn(context.gateway)
} catch {
didScanRepos = false // let a later open retry a failed scan
state.completedSignature = undefined
} finally {
$reposScanning.set(false)
state.runningSignature = undefined
if (scanningGatewayGenerations.get(context.gateway) === generation) {
scanningGatewayGenerations.delete(context.gateway)
}
syncReposScanning()
}
}

View file

@ -258,6 +258,11 @@ export interface HermesConfig {
skin?: string
interim_assistant_messages?: boolean
}
desktop?: {
repo_scan_enabled?: boolean
repo_scan_roots?: string[]
repo_scan_exclude_paths?: string[]
}
terminal?: {
cwd?: string
}

View file

@ -0,0 +1,2 @@
rudironsoni
# PR #67630 author attribution

View file

@ -3455,6 +3455,11 @@ DEFAULT_CONFIG = {
# Hermes Desktop (Electron app) launch options. These only affect
# `hermes desktop`; they do not touch the CLI/gateway.
"desktop": {
# Git repository discovery for the Desktop Projects sidebar. Empty
# roots preserve the historical bounded scan of the user's home.
"repo_scan_enabled": True,
"repo_scan_roots": [],
"repo_scan_exclude_paths": [],
# Extra Electron command-line flags appended to every desktop launch,
# e.g. ["--ozone-platform=x11"] on headless/VM X11 hosts that need an
# explicit ozone backend, or GPU workaround flags. A list of strings;

View file

@ -596,6 +596,7 @@ def delete_project(conn: sqlite3.Connection, project_id: str) -> bool:
_ACTIVE_META_KEY = "active_id"
_DISCOVERY_POLICY_META_KEY = "repo_discovery_policy"
def set_active(conn: sqlite3.Connection, project_id: Optional[str]) -> None:
@ -618,6 +619,53 @@ def get_active_id(conn: sqlite3.Connection) -> Optional[str]:
return row["value"] if row else None
def get_discovery_policy_key(conn: sqlite3.Connection) -> Optional[str]:
row = conn.execute(
"SELECT value FROM project_meta WHERE key = ?", (_DISCOVERY_POLICY_META_KEY,)
).fetchone()
return row["value"] if row else None
def reconcile_discovered_repos_policy(
conn: sqlite3.Connection,
policy_key: str,
*,
preserve_unversioned: bool = False,
) -> bool:
"""Clear cached scan rows when their discovery policy changes.
Existing pre-policy rows are retained only for the backward-compatible
default policy. Returns whether rows were cleared.
"""
current = get_discovery_policy_key(conn)
if current == policy_key:
return False
cleared = current is not None or not preserve_unversioned
with write_txn(conn):
if cleared:
conn.execute("DELETE FROM discovered_repos")
conn.execute(
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(_DISCOVERY_POLICY_META_KEY, policy_key),
)
return cleared
def clear_discovered_repos(
conn: sqlite3.Connection, *, policy_key: Optional[str] = None
) -> None:
with write_txn(conn):
conn.execute("DELETE FROM discovered_repos")
if policy_key is not None:
conn.execute(
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(_DISCOVERY_POLICY_META_KEY, policy_key),
)
# ---------------------------------------------------------------------------
# Discovered repos (filesystem scan cache)
# ---------------------------------------------------------------------------
@ -628,6 +676,7 @@ def record_discovered_repos(
repos: Iterable[tuple[str, Optional[str]]],
*,
replace: bool = False,
policy_key: Optional[str] = None,
) -> int:
"""Persist scanned git repo roots into the cache.
@ -656,6 +705,12 @@ def record_discovered_repos(
"last_seen = excluded.last_seen",
rows,
)
if policy_key is not None:
conn.execute(
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(_DISCOVERY_POLICY_META_KEY, policy_key),
)
return len(rows)

View file

@ -0,0 +1,16 @@
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.web_server import CONFIG_SCHEMA
def test_desktop_repo_discovery_defaults_preserve_existing_behavior():
desktop = DEFAULT_CONFIG["desktop"]
assert desktop["repo_scan_enabled"] is True
assert desktop["repo_scan_roots"] == []
assert desktop["repo_scan_exclude_paths"] == []
def test_desktop_repo_discovery_keys_are_in_generated_schema():
assert CONFIG_SCHEMA["desktop.repo_scan_enabled"]["type"] == "boolean"
assert CONFIG_SCHEMA["desktop.repo_scan_roots"]["type"] == "list"
assert CONFIG_SCHEMA["desktop.repo_scan_exclude_paths"]["type"] == "list"

View file

@ -45,6 +45,42 @@ def test_record_discovered_repos_replace_drops_stale_rows(conn):
assert rows == {"/www/alpha": "fresh"}
def test_discovery_policy_change_clears_only_discovered_rows(conn):
project_id = pdb.create_project(conn, name="Explicit", folders=["/www/explicit"])
pdb.record_discovered_repos(
conn, [("/www/scanned", "scanned")], policy_key="policy-a"
)
assert pdb.reconcile_discovered_repos_policy(conn, "policy-b") is True
assert pdb.list_discovered_repos(conn) == []
assert pdb.get_project(conn, project_id) is not None
assert pdb.get_discovery_policy_key(conn) == "policy-b"
def test_default_policy_adopts_unversioned_cache_without_clearing(conn):
pdb.record_discovered_repos(conn, [("/www/scanned", "scanned")])
assert (
pdb.reconcile_discovered_repos_policy(
conn, "default-policy", preserve_unversioned=True
)
is False
)
assert [row["root"] for row in pdb.list_discovered_repos(conn)] == [
"/www/scanned"
]
assert pdb.get_discovery_policy_key(conn) == "default-policy"
def test_clear_discovered_repos_records_policy_atomically(conn):
pdb.record_discovered_repos(conn, [("/www/scanned", "scanned")])
pdb.clear_discovered_repos(conn, policy_key="disabled")
assert pdb.list_discovered_repos(conn) == []
assert pdb.get_discovery_policy_key(conn) == "disabled"
def test_create_get_list(conn):
pid = pdb.create_project(conn, name="Hermes Agent", folders=["/tmp/hermes"])
proj = pdb.get_project(conn, pid)
@ -160,9 +196,14 @@ def test_per_profile_isolation(tmp_path):
b = pdb.connect(db_path=tmp_path / "b" / "projects.db")
try:
pdb.create_project(a, name="Only In A", folders=["/a"])
pdb.record_discovered_repos(a, [("/a/scanned", "scanned")])
assert [p.slug for p in pdb.list_projects(a)] == ["only-in-a"]
assert pdb.list_projects(b) == []
assert [row["root"] for row in pdb.list_discovered_repos(a)] == [
"/a/scanned"
]
assert pdb.list_discovered_repos(b) == []
finally:
a.close()
b.close()

View file

@ -263,6 +263,84 @@ def test_record_repos_persists_and_shows_zero_session_repo(tmp_path):
assert by_label["fresh-repo"]["sessions"] == 0
def test_disabled_discovery_clears_cache_and_rejects_new_scan(monkeypatch, tmp_path):
repo = tmp_path / "cached-repo"
repo.mkdir()
session_repo = tmp_path / "session-repo"
session_repo.mkdir()
subprocess.run(
["git", "init"], cwd=session_repo, check=True, capture_output=True
)
server._get_db().create_session("session-repo", "cli", cwd=str(session_repo))
_call("projects.record_repos", {"repos": [{"root": str(repo)}]})
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"desktop": {
"repo_scan_enabled": False,
"repo_scan_roots": [],
"repo_scan_exclude_paths": [],
}
},
)
result = _call(
"projects.record_repos",
{
"repos": [{"root": str(repo)}],
"discovery_policy": {
"enabled": False,
"roots": [],
"exclude_paths": [],
},
},
)
assert result["accepted"] is False
assert all(item["root"] != str(repo) for item in result["repos"])
assert any(item["root"] == str(session_repo) for item in result["repos"])
def test_nondefault_policy_rejects_stale_or_legacy_results(monkeypatch, tmp_path):
root = tmp_path / "allowed"
root.mkdir()
policy = {
"enabled": True,
"roots": [str(root)],
"exclude_paths": [],
}
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"desktop": {
"repo_scan_enabled": True,
"repo_scan_roots": [str(root)],
"repo_scan_exclude_paths": [],
}
},
)
legacy = _call("projects.record_repos", {"repos": [{"root": str(root)}]})
stale = _call(
"projects.record_repos",
{
"repos": [{"root": str(root)}],
"discovery_policy": {**policy, "roots": [str(tmp_path / "other")]},
},
)
accepted = _call(
"projects.record_repos",
{"repos": [{"root": str(root)}], "discovery_policy": policy},
)
assert legacy["accepted"] is False
assert stale["accepted"] is False
assert accepted["accepted"] is True
assert any(item["root"] == str(root) for item in accepted["repos"])
def test_discover_repos_from_full_history(tmp_path):
repo = tmp_path / "myrepo"
(repo / "src").mkdir(parents=True)

View file

@ -12085,7 +12085,67 @@ def _is_session_cwd_junk(cwd: str) -> bool:
return real == home or real == hermes_home
def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]:
def _repo_discovery_policy(raw: dict | None = None) -> dict:
"""Return the effective, profile-local Desktop repository scan policy."""
from hermes_cli.config import DEFAULT_CONFIG
defaults = DEFAULT_CONFIG["desktop"]
source = raw if isinstance(raw, dict) else (_load_cfg().get("desktop") or {})
if not isinstance(source, dict):
source = {}
enabled = source.get("enabled", source.get("repo_scan_enabled", defaults["repo_scan_enabled"]))
roots = source.get("roots", source.get("repo_scan_roots", defaults["repo_scan_roots"]))
excludes = source.get(
"exclude_paths",
source.get("repo_scan_exclude_paths", defaults["repo_scan_exclude_paths"]),
)
return {
"enabled": enabled if isinstance(enabled, bool) else defaults["repo_scan_enabled"],
"roots": [value.strip() for value in roots if isinstance(value, str) and value.strip()]
if isinstance(roots, list)
else list(defaults["repo_scan_roots"]),
"exclude_paths": [
value.strip()
for value in excludes
if isinstance(value, str) and value.strip()
]
if isinstance(excludes, list)
else list(defaults["repo_scan_exclude_paths"]),
}
def _repo_discovery_policy_key(policy: dict) -> str:
def _paths(values: list[str]) -> list[str]:
normalized = set()
home = os.path.expanduser("~")
for value in values:
expanded = os.path.expanduser(value)
if not os.path.isabs(expanded):
expanded = os.path.join(home, expanded)
normalized.add(os.path.normcase(os.path.abspath(expanded)))
return sorted(normalized)
canonical = {
"enabled": bool(policy["enabled"]),
"roots": _paths(policy["roots"]),
"exclude_paths": _paths(policy["exclude_paths"]),
}
return json.dumps(canonical, sort_keys=True, separators=(",", ":"))
def _repo_discovery_policy_is_default(policy: dict) -> bool:
from hermes_cli.config import DEFAULT_CONFIG
return _repo_discovery_policy_key(policy) == _repo_discovery_policy_key(
_repo_discovery_policy(DEFAULT_CONFIG["desktop"])
)
def _discover_repos_payload(
db, *, conn=None, backfill: bool = True, include_cached: bool = True
) -> list[dict]:
"""Merge filesystem-scanned repos (cached) with session-derived repo roots.
Repo-first: the disk scan (persisted by `projects.record_repos`) surfaces
@ -12129,6 +12189,16 @@ def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dic
except Exception:
logger.debug("failed to backfill repo roots", exc_info=True)
if not include_cached:
out = sorted(repos.values(), key=lambda repo: repo["last_active"], reverse=True)
for repo in out:
repo["label"] = (
repo["label"]
or os.path.basename(repo["root"].rstrip("/\\"))
or repo["root"]
)
return out
# Filesystem-scanned roots from the cache (may have zero sessions). Reuse the
# caller's projects.db connection when given, else open a short-lived one.
try:
@ -12165,7 +12235,20 @@ def _(rid, params: dict) -> dict:
db = _get_db()
if db is None:
return _ok(rid, {"repos": []})
return _ok(rid, {"repos": _discover_repos_payload(db)})
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
with pdb.connect_closing() as conn:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
repos = _discover_repos_payload(
db, conn=conn, include_cached=policy["enabled"]
)
return _ok(rid, {"repos": repos, "discovery_policy": policy})
except Exception as e:
return _err(rid, 5061, str(e))
@ -12178,6 +12261,22 @@ def _(rid, params: dict) -> dict:
try:
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
incoming_raw = params.get("discovery_policy")
incoming_policy = (
_repo_discovery_policy(incoming_raw)
if isinstance(incoming_raw, dict)
else None
)
incoming_matches = (
incoming_policy is not None
and _repo_discovery_policy_key(incoming_policy) == policy_key
)
accept_legacy_default = (
incoming_policy is None and _repo_discovery_policy_is_default(policy)
)
pairs: list[tuple[str, str | None]] = []
for item in params.get("repos") or []:
if isinstance(item, str):
@ -12186,10 +12285,34 @@ def _(rid, params: dict) -> dict:
pairs.append((str(item["root"]), item.get("label")))
with pdb.connect_closing() as conn:
pdb.record_discovered_repos(conn, pairs, replace=True)
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
accepted = bool(
policy["enabled"] and (incoming_matches or accept_legacy_default)
)
if accepted:
pdb.record_discovered_repos(
conn, pairs, replace=True, policy_key=policy_key
)
elif not policy["enabled"]:
pdb.clear_discovered_repos(conn, policy_key=policy_key)
db = _get_db()
return _ok(rid, {"repos": _discover_repos_payload(db) if db is not None else []})
return _ok(
rid,
{
"repos": _discover_repos_payload(
db, include_cached=policy["enabled"]
)
if db is not None
else [],
"accepted": accepted,
"discovery_policy": policy,
},
)
except Exception as e:
return _err(rid, 5061, str(e))
@ -12260,11 +12383,28 @@ def _project_tree_inputs(
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
with pdb.connect_closing() as conn:
if include_discovered:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
projects = [p.to_dict() for p in pdb.list_projects(conn)]
active_id = pdb.get_active_id(conn)
# backfill stays off the hot tree path — grouping uses the live resolver.
discovered = _discover_repos_payload(db, conn=conn, backfill=False) if include_discovered else []
discovered = (
_discover_repos_payload(
db,
conn=conn,
backfill=False,
include_cached=policy["enabled"],
)
if include_discovered
else []
)
return sessions, projects, discovered, active_id

View file

@ -54,6 +54,23 @@ The bar along the bottom of the chat shows live session state and exposes quick
Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend).
#### Repository discovery
Hermes Desktop discovers local Git repositories for the Projects sidebar by scanning your home directory to a bounded depth. You can change this per profile in **Settings → Workspace**, or in `config.yaml`:
```yaml
desktop:
repo_scan_enabled: true
repo_scan_roots: []
repo_scan_exclude_paths: []
```
- Set `repo_scan_enabled: false` to stop the filesystem scan completely. Existing disk-discovery cache rows for that profile are cleared; explicit projects and repositories inferred from intentional Hermes sessions remain available.
- Set `repo_scan_roots` to a list of folders to restrict scanning. An empty list preserves the default home-directory scan.
- Set `repo_scan_exclude_paths` to folders whose complete subtrees should be skipped.
Changing any of these values invalidates only that profile's disk-discovery cache and starts a policy-compliant refresh. **Hide from sidebar** remains a separate per-item curation action.
#### Choosing a model
The model picker lives in the **composer**, just left of the microphone. Click it to switch the model, reasoning effort, and fast mode from one dropdown.