From d355e0e71dc22cfd6d2eed9184b2897124101d47 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 21 Jul 2026 10:09:31 -0400 Subject: [PATCH] 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 --------- Co-authored-by: Rudimar Ronsoni Co-authored-by: Austin Pickett --- apps/desktop/electron/git-repo-scan.test.ts | 76 +++++++ apps/desktop/electron/git-repo-scan.ts | 170 ++++++++++----- .../chat/sidebar/session-actions-menu.test.ts | 22 +- .../src/app/settings/config-settings.tsx | 14 +- apps/desktop/src/app/settings/constants.ts | 13 ++ apps/desktop/src/app/settings/helpers.test.ts | 15 ++ apps/desktop/src/global.d.ts | 5 +- apps/desktop/src/hermes.ts | 9 +- apps/desktop/src/i18n/ja.ts | 10 + apps/desktop/src/i18n/zh-hant.ts | 10 + apps/desktop/src/i18n/zh.ts | 10 + apps/desktop/src/store/projects.test.ts | 144 ++++++++++++- apps/desktop/src/store/projects.ts | 199 ++++++++++++++---- apps/desktop/src/types/hermes.ts | 5 + contributors/emails/rudimar@outlook.com | 2 + hermes_cli/config.py | 5 + hermes_cli/projects_db.py | 55 +++++ .../test_desktop_repo_discovery_config.py | 16 ++ tests/hermes_cli/test_projects_db.py | 41 ++++ tests/tui_gateway/test_projects_rpc.py | 78 +++++++ tui_gateway/server.py | 150 ++++++++++++- website/docs/user-guide/desktop.md | 17 ++ 22 files changed, 957 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/electron/git-repo-scan.test.ts create mode 100644 contributors/emails/rudimar@outlook.com create mode 100644 tests/hermes_cli/test_desktop_repo_discovery_config.py diff --git a/apps/desktop/electron/git-repo-scan.test.ts b/apps/desktop/electron/git-repo-scan.test.ts new file mode 100644 index 000000000000..43f8302fc2c8 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/electron/git-repo-scan.ts b/apps/desktop/electron/git-repo-scan.ts index 36ac189e66e4..4e41a69edf3b 100644 --- a/apps/desktop/electron/git-repo-scan.ts +++ b/apps/desktop/electron/git-repo-scan.ts @@ -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(items: T[], limit: number, fn: (item: T) => Promise): Promise { let cursor = 0 - async function worker() { + async function worker(): Promise { 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() + + function isExcluded(candidate: string): boolean { + return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions)) + } + + async function walk(dir: string, depth: number): Promise { + 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 } diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts index 8bdc509a021a..8dea6d97d552 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts @@ -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() })) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 50708ef4b88a..cdd2a645cb77 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) const saveVersionRef = useRef(0) + const savedDiscoverySignatureRef = useRef(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) { diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 596e71259b12..055f72ac19a1 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -390,6 +390,11 @@ export const FIELD_LABELS: Record = 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 = 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', diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 616876a2f268..33aa771d32b5 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -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', '', {}) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 89a14d443040..611e3a6f2bbf 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a3d4199fa789..e33952904260 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { +export function getHermesConfig(profile?: string): Promise { return window.hermesDesktop.api({ - ...profileScoped(), + ...profileScoped(profile), path: '/api/config', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3ade882c6ab8..2ba5593d4860 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index d061a6d57a79..ea3f10cb7a54 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '控制圖片附件如何傳送給模型。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 5e165f6be0a5..fd57ed7d06f0 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '控制图片附件如何发送给模型。', diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index d6b75856a422..69b2dae04b05 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -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) { + 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']) + }) +}) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 18c5727cc1f9..5dfd6d250875 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -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(method: string, params: Record return gateway.request(method, params) } +async function gatewayRequestOn( + gateway: HermesGateway, + method: string, + params: Record = {} +): Promise { + return gateway.request(method, params) +} + +interface ActiveProjectsContext { + gateway: HermesGateway + profile: string +} + +async function activeProjectsContext(): Promise { + 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 { - $projectTreeLoading.set(true) +let projectTreeRefreshGeneration = 0 +async function refreshProjectTreeOn(gateway: HermesGateway): Promise { + const generation = ++projectTreeRefreshGeneration + if (activeGateway() === gateway) { + $projectTreeLoading.set(true) + } try { - const res = await gatewayRequest('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(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 { + 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 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() +const scanningGatewayGenerations = new WeakMap() + +function syncReposScanning(): void { + const gateway = activeGateway() + $reposScanning.set(Boolean(gateway && scanningGatewayGenerations.has(gateway))) +} + +$gateway.subscribe(syncReposScanning) export async function scanAndRecordRepos(force = false): Promise { - 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() } } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index fcfae5cf7bde..fc70e5d4db3a 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 } diff --git a/contributors/emails/rudimar@outlook.com b/contributors/emails/rudimar@outlook.com new file mode 100644 index 000000000000..7491885c51f0 --- /dev/null +++ b/contributors/emails/rudimar@outlook.com @@ -0,0 +1,2 @@ +rudironsoni +# PR #67630 author attribution diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 70dfa2fda77a..5ef6ae381352 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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; diff --git a/hermes_cli/projects_db.py b/hermes_cli/projects_db.py index 0512a58326cb..53bead2227aa 100644 --- a/hermes_cli/projects_db.py +++ b/hermes_cli/projects_db.py @@ -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) diff --git a/tests/hermes_cli/test_desktop_repo_discovery_config.py b/tests/hermes_cli/test_desktop_repo_discovery_config.py new file mode 100644 index 000000000000..40acd79261e3 --- /dev/null +++ b/tests/hermes_cli/test_desktop_repo_discovery_config.py @@ -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" diff --git a/tests/hermes_cli/test_projects_db.py b/tests/hermes_cli/test_projects_db.py index ddcf73111c78..9de9e8b40c20 100644 --- a/tests/hermes_cli/test_projects_db.py +++ b/tests/hermes_cli/test_projects_db.py @@ -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() diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 2d38c1d7a535..731ae56edabd 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 49b97fd8f3e8..d841cdc39f8a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index c8895bf4abb4..5e7e966e05e6 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -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.