From 4526fccdbe6bd804eb26aa131f82d6e319be101b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 13:49:45 -0500 Subject: [PATCH 01/11] fix(desktop): make project "Add folder" picker remote-gateway aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-project / add-folder dialog (PR #49037) picked folders via the native Electron dialog (pickDefaultProjectDir), which only browses the LOCAL machine. On a remote gateway that picks a path that doesn't exist on the backend where sessions actually run. Route pickProjectFolder() through selectDesktopPaths({directories, multiple:false}) — the same remote-aware path the retired right-sidebar picker used: local mode opens the native directory dialog, remote mode browses the backend filesystem via the in-app RemoteFolderPicker. Seed it with the backend's default cwd on remote so it opens somewhere useful. --- apps/desktop/src/store/projects.test.ts | 49 ++++++++++++++++++++++++- apps/desktop/src/store/projects.ts | 20 +++++----- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 3e379f8fafa..32749f2b19c 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { $projectScope, @@ -6,9 +6,21 @@ import { ALL_PROJECTS, enterProject, exitProjectScope, + pickProjectFolder, refreshWorktrees } from './projects' +vi.mock('@/lib/desktop-fs', () => ({ + desktopDefaultCwd: vi.fn(), + isDesktopFsRemoteMode: vi.fn(), + selectDesktopPaths: vi.fn() +})) + +const fs = await import('@/lib/desktop-fs') +const desktopDefaultCwd = vi.mocked(fs.desktopDefaultCwd) +const isDesktopFsRemoteMode = vi.mocked(fs.isDesktopFsRemoteMode) +const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths) + describe('project scope', () => { beforeEach(() => { window.localStorage.clear() @@ -50,3 +62,38 @@ describe('worktree refresh', () => { expect($worktreeRefreshToken.get()).toBe(before + 1) }) }) + +describe('pickProjectFolder', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the remote-aware directory picker locally (no backend default cwd probe)', async () => { + isDesktopFsRemoteMode.mockReturnValue(false) + selectDesktopPaths.mockResolvedValue(['/local/repo']) + + await expect(pickProjectFolder()).resolves.toBe('/local/repo') + expect(selectDesktopPaths).toHaveBeenCalledWith({ defaultPath: undefined, directories: true, multiple: false }) + expect(desktopDefaultCwd).not.toHaveBeenCalled() + }) + + it('seeds the picker with the backend cwd on a remote gateway', async () => { + isDesktopFsRemoteMode.mockReturnValue(true) + desktopDefaultCwd.mockResolvedValue({ branch: 'main', cwd: '/backend/work' }) + selectDesktopPaths.mockResolvedValue(['/backend/work/repo']) + + await expect(pickProjectFolder()).resolves.toBe('/backend/work/repo') + expect(selectDesktopPaths).toHaveBeenCalledWith({ + defaultPath: '/backend/work', + directories: true, + multiple: false + }) + }) + + it('returns null when the picker is cancelled (empty selection)', async () => { + isDesktopFsRemoteMode.mockReturnValue(false) + selectDesktopPaths.mockResolvedValue([]) + + await expect(pickProjectFolder()).resolves.toBeNull() + }) +}) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index d30ed4c1972..241898cc11a 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -2,6 +2,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBranch } from '@/global' +import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths } from '@/lib/desktop-fs' import { persistentAtom } from '@/lib/persisted' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' import { setSidebarAgentsGrouped } from '@/store/layout' @@ -732,19 +733,18 @@ export async function copyPath(path: null | string): Promise { } } -// Open the native directory picker (reuses the Electron default-project-dir -// chooser). Returns the chosen absolute path, or null when cancelled. +// Pick a folder for a project. Routes through the remote-aware folder picker +// (selectDesktopPaths): a remote gateway browses the BACKEND filesystem via the +// in-app RemoteFolderPicker — where sessions actually run — while local mode +// uses the native directory dialog. Returns the chosen absolute path, or null +// when cancelled. Seeded with the backend's default cwd on remote so the picker +// opens somewhere useful instead of "/". export async function pickProjectFolder(): Promise { - const pick = window.hermesDesktop?.settings?.pickDefaultProjectDir - - if (!pick) { - return null - } - try { - const result = await pick() + const defaultPath = isDesktopFsRemoteMode() ? (await desktopDefaultCwd())?.cwd : undefined + const [dir] = await selectDesktopPaths({ defaultPath, directories: true, multiple: false }) - return result.canceled ? null : result.dir + return dir || null } catch { return null } From 304f0650c4071951cbdc067a47e87eb3c0d01fe3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:13:36 -0500 Subject: [PATCH 02/11] style(desktop): tighten pickProjectFolder comment --- apps/desktop/src/store/projects.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 241898cc11a..3f283a8334f 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -733,12 +733,9 @@ export async function copyPath(path: null | string): Promise { } } -// Pick a folder for a project. Routes through the remote-aware folder picker -// (selectDesktopPaths): a remote gateway browses the BACKEND filesystem via the -// in-app RemoteFolderPicker — where sessions actually run — while local mode -// uses the native directory dialog. Returns the chosen absolute path, or null -// when cancelled. Seeded with the backend's default cwd on remote so the picker -// opens somewhere useful instead of "/". +// Pick a project folder via the remote-aware picker: a remote gateway browses +// the backend filesystem (seeded at its default cwd) where sessions run; local +// mode opens the native dialog. Returns the absolute path, or null if cancelled. export async function pickProjectFolder(): Promise { try { const defaultPath = isDesktopFsRemoteMode() ? (await desktopDefaultCwd())?.cwd : undefined From fc86e35764f7b339d505092dbad3686cfa44c576 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:26:09 -0500 Subject: [PATCH 03/11] feat(desktop): make the git cockpit work over a remote gateway After the folder picker fix, an added remote folder was still half-usable: the desktop's git GUI (coding-rail status, worktree lanes, review pane, branch switch, file diff) all ran Electron-local git on the USER's machine, so against a remote-gateway repo they silently degraded to empty. Mirror the whole surface over the dashboard REST API so it acts on the BACKEND repo where sessions actually run: - hermes_cli/web_git.py: git/gh logic (status, worktrees, branches, review list/diff/stage/unstage/revert/commit/commit-context/push/ship-info/ create-pr, file-diff, worktree add/remove, branch switch) shelling to the system git, mirroring the Electron ops' shapes. - web_server.py: /api/git/* routes (same auth gate + _fs_path hardening as /api/fs, executor-offloaded, mutations -> 400). - apps/desktop desktop-git.ts: remote-aware facade exposing the same shape as window.hermesDesktop.git; coding-status / review / projects / model / desktop-fs route through desktopGit() so local stays Electron, remote hits /api/git/*. Tests: tests/hermes_cli/test_web_server_git.py (real repo: status counts, review classification, diff incl. untracked all-add, stage+commit roundtrip, worktree/branch lifecycle, commit-context, gh-absent ship-info, auth) and desktop-git.test.ts (local vs remote routing, envelope unwrap, POST bodies). --- .../src/app/chat/sidebar/projects/model.ts | 3 +- apps/desktop/src/lib/desktop-fs.ts | 14 +- apps/desktop/src/lib/desktop-git.test.ts | 78 ++ apps/desktop/src/lib/desktop-git.ts | 94 +++ apps/desktop/src/store/coding-status.ts | 5 +- apps/desktop/src/store/projects.ts | 9 +- apps/desktop/src/store/review.ts | 9 +- hermes_cli/web_git.py | 703 ++++++++++++++++++ hermes_cli/web_server.py | 159 ++++ tests/hermes_cli/test_web_server_git.py | 148 ++++ 10 files changed, 1206 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/lib/desktop-git.test.ts create mode 100644 apps/desktop/src/lib/desktop-git.ts create mode 100644 hermes_cli/web_git.py create mode 100644 tests/hermes_cli/test_web_server_git.py diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts index e5931f0bcb8..1c17c676e0c 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/model.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' +import { desktopGit } from '@/lib/desktop-git' import { mapPool } from '@/lib/pool' import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout' import { $worktreeRefreshToken } from '@/store/projects' @@ -88,7 +89,7 @@ export function useRepoWorktreeMap( const refreshToken = useStore($worktreeRefreshToken) useEffect(() => { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!enabled || !repoPaths.length || !git?.worktreeList) { setMap({}) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 17035173dca..1451ac81e79 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -155,16 +155,20 @@ export async function copyTextToClipboard(text: string): Promise { await bridge().writeClipboard(text) } -// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo / -// remote backend (the diff view simply doesn't show then). Local only. +// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo. +// Remote gateway → backend git (/api/git/file-diff); local → Electron git. export async function desktopFileDiff(repoRoot: string, filePath: string): Promise { const desktop = bridge() - if (isDesktopFsRemoteMode() || !desktop.git?.fileDiff) { - return '' + if (isDesktopFsRemoteMode()) { + const result = await desktop.api<{ diff: string }>({ + path: `/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}` + }) + + return result.diff || '' } - return desktop.git.fileDiff(repoRoot, filePath) + return desktop.git?.fileDiff ? desktop.git.fileDiff(repoRoot, filePath) : '' } export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise { diff --git a/apps/desktop/src/lib/desktop-git.test.ts b/apps/desktop/src/lib/desktop-git.test.ts new file mode 100644 index 00000000000..8c3e31394d0 --- /dev/null +++ b/apps/desktop/src/lib/desktop-git.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { desktopGit } from './desktop-git' + +const repoStatus = vi.fn(async () => ({ branch: 'main' })) +const worktreeList = vi.fn(async () => [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/r' }]) +const localGit = { repoStatus, review: { stage: vi.fn() }, worktreeList } + +const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/git/status')) { + return { branch: 'remote-main' } + } + + if (path.startsWith('/api/git/worktrees')) { + return { worktrees: [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' }] } + } + + if (path.startsWith('/api/git/review/diff')) { + return { diff: 'remote-diff' } + } + + return { ok: true } +}) + +describe('desktop git facade', () => { + beforeEach(() => { + vi.stubGlobal('window', { hermesDesktop: { api, git: localGit } }) + $connection.set(null) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + $connection.set(null) + }) + + it('uses Electron git locally', async () => { + $connection.set({ mode: 'local' } as never) + + await expect(desktopGit()?.repoStatus('/work')).resolves.toEqual({ branch: 'main' }) + expect(repoStatus).toHaveBeenCalledWith('/work') + expect(api).not.toHaveBeenCalled() + }) + + it('routes reads through the backend REST mirror on a remote gateway', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(desktopGit()?.repoStatus('/srv/work')).resolves.toEqual({ branch: 'remote-main' }) + expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork' }) + + // List endpoints unwrap their envelope to the bare array the bridge returns. + await expect(desktopGit()?.worktreeList('/srv/work')).resolves.toEqual([ + { branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' } + ]) + + // review.diff unwraps { diff } to a string. + await expect(desktopGit()?.review.diff('/srv/work', 'a.txt', 'uncommitted', null, false)).resolves.toBe( + 'remote-diff' + ) + + expect(repoStatus).not.toHaveBeenCalled() + }) + + it('sends mutations as POST bodies on a remote gateway', async () => { + $connection.set({ mode: 'remote' } as never) + + await desktopGit()?.review.stage('/srv/work', 'a.txt') + + expect(api).toHaveBeenCalledWith({ + body: { file: 'a.txt', path: '/srv/work' }, + method: 'POST', + path: '/api/git/review/stage' + }) + expect(localGit.review.stage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts new file mode 100644 index 00000000000..7d5cda7544e --- /dev/null +++ b/apps/desktop/src/lib/desktop-git.ts @@ -0,0 +1,94 @@ +import type { + HermesGitBranch, + HermesGitWorktree, + HermesRepoStatus, + HermesReviewList, + HermesReviewShipInfo +} from '@/global' + +import { isDesktopFsRemoteMode } from './desktop-fs' + +// Remote-aware git facade. Locally the desktop runs git through Electron +// (window.hermesDesktop.git); on a remote gateway that's the wrong filesystem, +// so we mirror the same surface over the dashboard REST API (/api/git/*) — the +// coding rail, worktree lanes, review pane, and branch ops then act on the +// BACKEND repo where sessions actually run. Mirrors desktop-fs.ts. + +type GitBridge = NonNullable['git']> + +const q = (value: string) => encodeURIComponent(value) + +function desktopApi(path: string, body?: Record): Promise { + const desktop = window.hermesDesktop + + if (!desktop) { + throw new Error('Hermes Desktop bridge is unavailable') + } + + return desktop.api(body ? { body, method: 'POST', path } : { path }) +} + +const remoteGit: GitBridge = { + worktreeList: async repoPath => + (await desktopApi<{ worktrees: HermesGitWorktree[] }>(`/api/git/worktrees?path=${q(repoPath)}`)).worktrees, + + worktreeAdd: (repoPath, options) => desktopApi(`/api/git/worktree/add`, { path: repoPath, ...options }), + + worktreeRemove: (repoPath, worktreePath, options) => + desktopApi(`/api/git/worktree/remove`, { force: options?.force ?? false, path: repoPath, worktreePath }), + + branchSwitch: (repoPath, branch) => desktopApi(`/api/git/branch/switch`, { branch, path: repoPath }), + + branchList: async repoPath => + (await desktopApi<{ branches: HermesGitBranch[] }>(`/api/git/branches?path=${q(repoPath)}`)).branches, + + repoStatus: repoPath => desktopApi(`/api/git/status?path=${q(repoPath)}`), + + fileDiff: async (repoPath, filePath) => + (await desktopApi<{ diff: string }>(`/api/git/file-diff?path=${q(repoPath)}&file=${q(filePath)}`)).diff, + + review: { + list: (repoPath, scope, baseRef) => + desktopApi( + `/api/git/review/list?path=${q(repoPath)}&scope=${q(scope)}${baseRef ? `&base=${q(baseRef)}` : ''}` + ), + + diff: async (repoPath, filePath, scope, baseRef, staged) => + ( + await desktopApi<{ diff: string }>( + `/api/git/review/diff?path=${q(repoPath)}&file=${q(filePath)}&scope=${q(scope)}&staged=${staged ? 'true' : 'false'}${baseRef ? `&base=${q(baseRef)}` : ''}` + ) + ).diff, + + stage: (repoPath, filePath) => desktopApi(`/api/git/review/stage`, { file: filePath ?? null, path: repoPath }), + + unstage: (repoPath, filePath) => desktopApi(`/api/git/review/unstage`, { file: filePath ?? null, path: repoPath }), + + revert: (repoPath, filePath) => desktopApi(`/api/git/review/revert`, { file: filePath ?? null, path: repoPath }), + + revParse: async (repoPath, ref) => + ( + await desktopApi<{ sha: null | string }>( + `/api/git/review/rev-parse?path=${q(repoPath)}${ref ? `&ref=${q(ref)}` : ''}` + ) + ).sha, + + commit: (repoPath, message, push) => desktopApi(`/api/git/review/commit`, { message, path: repoPath, push }), + + commitContext: repoPath => desktopApi(`/api/git/review/commit-context?path=${q(repoPath)}`), + + push: repoPath => desktopApi(`/api/git/review/push`, { path: repoPath }), + + shipInfo: repoPath => desktopApi(`/api/git/review/ship-info?path=${q(repoPath)}`), + + createPr: repoPath => desktopApi(`/api/git/review/create-pr`, { path: repoPath }) + }, + + // Repo discovery is a local-disk crawl; on a remote gateway the backend + // already merges session-derived repos, so this is a no-op. + scanRepos: async () => [] +} + +export function desktopGit(): GitBridge | undefined { + return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git +} diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts index f3ea71a3642..bd353e0d78a 100644 --- a/apps/desktop/src/store/coding-status.ts +++ b/apps/desktop/src/store/coding-status.ts @@ -1,6 +1,7 @@ import { atom, computed } from 'nanostores' import type { HermesGitWorktree, HermesRepoStatus } from '@/global' +import { desktopGit } from '@/lib/desktop-git' import { $worktreeRefreshToken } from './projects' import { $busy, $currentCwd } from './session' @@ -44,7 +45,7 @@ export const $repoChangeByPath = computed([$repoStatus, $currentCwd], (status, c }) async function loadWorktrees(target: string): Promise { - const list = window.hermesDesktop?.git?.worktreeList + const list = desktopGit()?.worktreeList if (!list) { $repoWorktrees.set([]) @@ -80,7 +81,7 @@ const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null */ export async function refreshRepoStatus(cwd?: null | string): Promise { const target = normalizeCwd(cwd ?? $currentCwd.get()) - const probe = window.hermesDesktop?.git?.repoStatus + const probe = desktopGit()?.repoStatus const seq = (repoStatusRefreshSeq += 1) if (!target || !probe) { diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 3f283a8334f..8190cdf4e4a 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBranch } from '@/global' import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths } from '@/lib/desktop-fs' +import { desktopGit } from '@/lib/desktop-git' import { persistentAtom } from '@/lib/persisted' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' import { setSidebarAgentsGrouped } from '@/store/layout' @@ -631,7 +632,7 @@ export async function startWorkInRepo( repoPath: string, options?: { name?: string; branch?: string; base?: string; existingBranch?: string } ): Promise { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!git || !repoPath) { return null @@ -646,7 +647,7 @@ export async function startWorkInRepo( // Local branches for the composer's "convert a branch into a worktree" picker. // Empty on a remote backend / non-repo (the Electron probe can't run). export async function listRepoBranches(repoPath: string): Promise { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!git?.branchList || !repoPath) { return [] @@ -656,7 +657,7 @@ export async function listRepoBranches(repoPath: string): Promise { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!git || !repoPath || !branch.trim()) { return @@ -709,7 +710,7 @@ export async function removeWorktreePath( worktreePath: string, options?: { force?: boolean } ): Promise { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!git) { return diff --git a/apps/desktop/src/store/review.ts b/apps/desktop/src/store/review.ts index 8aa2a5c02f8..8c3a9946d81 100644 --- a/apps/desktop/src/store/review.ts +++ b/apps/desktop/src/store/review.ts @@ -4,6 +4,7 @@ import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import type { HermesReviewFile, HermesReviewShipInfo } from '@/global' import { matchesQuery } from '@/hooks/use-media-query' +import { desktopGit } from '@/lib/desktop-git' import { isExcludedPath } from '@/lib/excluded-paths' import { requestOneShot } from '@/lib/oneshot' import { Codecs, persistentAtom } from '@/lib/persisted' @@ -94,7 +95,7 @@ let shipInfoLastCheckedAt = 0 // either is missing (no session, remote backend), so callers bail in one line. function reviewCtx(): { cwd: string; review: ReviewBridge } | null { const cwd = repoCwd() - const review = window.hermesDesktop?.git?.review + const review = desktopGit()?.review return cwd && review ? { cwd, review } : null } @@ -294,17 +295,17 @@ async function afterMutation(): Promise { } export async function stageReviewFile(path: null | string): Promise { - await window.hermesDesktop?.git?.review?.stage(repoCwd() ?? '', path) + await desktopGit()?.review?.stage(repoCwd() ?? '', path) await afterMutation() } export async function unstageReviewFile(path: null | string): Promise { - await window.hermesDesktop?.git?.review?.unstage(repoCwd() ?? '', path) + await desktopGit()?.review?.unstage(repoCwd() ?? '', path) await afterMutation() } export async function revertReviewFile(path: null | string): Promise { - await window.hermesDesktop?.git?.review?.revert(repoCwd() ?? '', path) + await desktopGit()?.review?.revert(repoCwd() ?? '', path) await afterMutation() } diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py new file mode 100644 index 00000000000..a71974b605f --- /dev/null +++ b/hermes_cli/web_git.py @@ -0,0 +1,703 @@ +"""Backend git operations for the desktop coding rail + Codex-style review pane. + +The desktop's git affordances (coding-rail status, worktree lanes, review pane, +branch switch) run as Electron-local git on the user's machine. On a *remote* +gateway those would operate on the wrong filesystem, so this module mirrors them +over the dashboard's authenticated REST surface — the same pattern as ``/api/fs``. + +Everything shells out to the system ``git`` (and ``gh`` for ship info / PRs). +Reads degrade to ``None`` / empty on a non-repo; mutations raise so the renderer +can surface a toast. Callers pass an already path-hardened ``cwd``. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +_GIT_TIMEOUT = 30 +_GH_TIMEOUT = 30 +_MAX_BUFFER = 32 * 1024 * 1024 +_UNTRACKED_LINE_MAX_BYTES = 1024 * 1024 +_UNTRACKED_SCAN_CAP = 500 +_COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 +_COMMIT_CONTEXT_UNTRACKED_MAX = 80 +_TRUNK_BRANCHES = ("main", "master") + + +def _git(cwd: str, args: list[str], *, timeout: int = _GIT_TIMEOUT) -> tuple[int, str, str]: + """Run ``git`` in ``cwd``. Returns (returncode, stdout, stderr); never raises + on a non-zero exit (callers decide what an error means).""" + try: + proc = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.SubprocessError): + return 1, "", "git invocation failed" + return proc.returncode, proc.stdout, proc.stderr + + +def _git_out(cwd: str, args: list[str]) -> str: + """stdout of a git command, or "" on any failure.""" + code, out, _ = _git(cwd, args) + return out if code == 0 else "" + + +def _git_ok(cwd: str, args: list[str]) -> None: + """Run a git mutation, raising RuntimeError with stderr on failure.""" + code, _, err = _git(cwd, args) + if code != 0: + raise RuntimeError(err.strip() or f"git {' '.join(args)} failed") + + +def _is_dir(cwd: str) -> bool: + try: + return Path(cwd).is_dir() + except OSError: + return False + + +# ── shared helpers ─────────────────────────────────────────────────────────── + + +def resolve_rename_path(raw: str) -> str: + """``old => new`` (and ``dir/{old => new}/f``) → the NEW path, so a row + addresses the real file for diff/stage.""" + path = str(raw or "").strip() + if " => " not in path: + return path + head, _, tail = path.partition("{") + if tail and "}" in tail: + inner, _, suffix = tail.partition("}") + _, _, to = inner.partition(" => ") + return f"{head}{to}{suffix}".replace("//", "/") + return path.split(" => ")[-1].strip() + + +def _numstat(cwd: str, args: list[str]) -> dict[str, tuple[int, int]]: + """``git diff --numstat`` → {path: (added, removed)}; binary files (``-``) → 0.""" + out = _git_out(cwd, ["diff", "--numstat", *args]) + counts: dict[str, tuple[int, int]] = {} + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + added = 0 if parts[0] == "-" else int(parts[0] or 0) + removed = 0 if parts[1] == "-" else int(parts[1] or 0) + counts[resolve_rename_path(parts[2])] = (added, removed) + return counts + + +def _untracked_insertions(cwd: str, rel: str) -> int: + """Line count of an untracked file (newlines + a final unterminated line), + so the review tree can show +N for new files. Binary / oversized → 0.""" + try: + target = Path(cwd) / rel + st = target.stat() + if not os.path.isfile(target) or st.st_size > _UNTRACKED_LINE_MAX_BYTES: + return 0 + data = target.read_bytes() + if b"\0" in data: + return 0 + lines = data.count(b"\n") + return lines + 1 if data and not data.endswith(b"\n") else lines + except OSError: + return 0 + + +def _fill_untracked_counts(cwd: str, files: list[dict]) -> None: + for file in files: + if file["status"] == "?" and file["added"] == 0 and file["removed"] == 0: + file["added"] = _untracked_insertions(cwd, file["path"]) + + +def _branch_base(cwd: str) -> str | None: + """Merge-base with the remote default branch for "all branch changes".""" + candidates: list[str] = [] + head = _git_out(cwd, ["rev-parse", "--abbrev-ref", "origin/HEAD"]).strip() + if head: + candidates.append(head) + candidates += ["origin/main", "origin/master", "main", "master"] + for ref in candidates: + base = _git_out(cwd, ["merge-base", "HEAD", ref]).strip() + if base: + return base + return None + + +def _default_branch_name(cwd: str) -> str | None: + """The repo's trunk name ("main"/"master"/…), preferring origin/HEAD.""" + head = _git_out(cwd, ["rev-parse", "--abbrev-ref", "origin/HEAD"]).strip() + if head and head != "origin/HEAD": + return head.split("/", 1)[-1] + for ref in ( + "refs/heads/main", + "refs/heads/master", + "refs/remotes/origin/main", + "refs/remotes/origin/master", + ): + code, _, _ = _git(cwd, ["rev-parse", "--verify", "--quiet", ref]) + if code == 0: + return ref.split("/")[-1] + return None + + +# ── porcelain v2 status parsing ────────────────────────────────────────────── + + +def _parse_status_v2(cwd: str) -> dict | None: + """Parse ``git status --porcelain=v2 --branch -z`` into branch + classified + files. None when ``cwd`` isn't a git repo.""" + code, out, _ = _git(cwd, ["status", "--porcelain=v2", "--branch", "-z"]) + if code != 0: + return None + + branch: str | None = None + detached = False + ahead = behind = 0 + files: list[dict] = [] + untracked = 0 + conflicted = 0 + + records = out.split("\0") + i = 0 + while i < len(records): + rec = records[i] + if not rec: + i += 1 + continue + tag = rec[0] + if tag == "#": + if rec.startswith("# branch.head "): + head = rec[len("# branch.head ") :] + if head == "(detached)": + detached = True + else: + branch = head + elif rec.startswith("# branch.ab "): + for tok in rec[len("# branch.ab ") :].split(): + if tok.startswith("+"): + ahead = int(tok[1:] or 0) + elif tok.startswith("-"): + behind = int(tok[1:] or 0) + elif tag in ("1", "2"): + fields = rec.split(" ") + xy = fields[1] + path = rec.split(" ", 8)[-1] if tag == "1" else rec.split(" ", 9)[-1] + if tag == "2": + # Rename/copy: NUL-separated origin path follows in the next record. + i += 1 + files.append(_classify(xy, resolve_rename_path(path))) + elif tag == "u": + path = rec.split(" ", 10)[-1] + files.append({"path": path, "staged": False, "unstaged": False, "untracked": False, "conflicted": True}) + conflicted += 1 + elif tag == "?": + path = rec[2:] + files.append({"path": path, "staged": False, "unstaged": True, "untracked": True, "conflicted": False}) + untracked += 1 + i += 1 + + return { + "branch": branch, + "detached": detached, + "ahead": ahead, + "behind": behind, + "files": files, + "untracked": untracked, + "conflicted": conflicted, + } + + +def _classify(xy: str, path: str) -> dict: + x = xy[0] if xy else "." + y = xy[1] if len(xy) > 1 else "." + return { + "path": path, + "staged": x not in (".", "?"), + "unstaged": y not in (".", "?"), + "untracked": False, + "conflicted": x == "U" or y == "U", + } + + +def _status_letter(xy: str) -> str: + x = xy[0] if xy else "." + y = xy[1] if len(xy) > 1 else "." + code = x if x != "." else y + return (code or "M").upper() + + +# ── coding rail ────────────────────────────────────────────────────────────── + + +def repo_status(cwd: str) -> dict | None: + """Compact working-tree status for the coding rail. None on a non-repo.""" + if not _is_dir(cwd): + return None + parsed = _parse_status_v2(cwd) + if parsed is None: + return None + + files = parsed["files"] + added = removed = 0 + summary = _numstat(cwd, ["HEAD"]) + for a, r in summary.values(): + added += a + removed += r + + # `git diff HEAD` ignores untracked files; fold their insertions into `added` + # so a new-file-only turn registers in the rail (bounded scan). + untracked_paths = [f["path"] for f in files if f["untracked"]][:_UNTRACKED_SCAN_CAP] + for rel in untracked_paths: + added += _untracked_insertions(cwd, rel) + + return { + "branch": None if parsed["detached"] else parsed["branch"], + "defaultBranch": _default_branch_name(cwd), + "detached": parsed["detached"], + "ahead": parsed["ahead"], + "behind": parsed["behind"], + "staged": sum(1 for f in files if f["staged"]), + "unstaged": sum(1 for f in files if f["unstaged"]), + "untracked": parsed["untracked"], + "conflicted": parsed["conflicted"], + "changed": len(files), + "added": added, + "removed": removed, + "files": [ + {k: f[k] for k in ("path", "staged", "unstaged", "untracked", "conflicted")} for f in files[:200] + ], + } + + +# ── review pane ────────────────────────────────────────────────────────────── + + +def review_list(cwd: str, scope: str, base_ref: str | None) -> dict: + """Changed files for a scope. Mirrors the Electron reviewList shapes.""" + if not _is_dir(cwd): + return {"files": [], "base": None} + + if scope in ("branch", "lastTurn"): + base = _branch_base(cwd) if scope == "branch" else base_ref + if not base: + return {"files": [], "base": None} + rng = f"{base}...HEAD" if scope == "branch" else base + counts = _numstat(cwd, [rng]) + files = [ + {"path": path, "added": a, "removed": r, "status": "M", "staged": False} + for path, (a, r) in counts.items() + ] + if scope == "lastTurn": + parsed = _parse_status_v2(cwd) + for f in parsed["files"] if parsed else []: + if f["untracked"] and not any(x["path"] == f["path"] for x in files): + files.append({"path": f["path"], "added": 0, "removed": 0, "status": "?", "staged": False}) + files.sort(key=lambda f: f["path"]) + _fill_untracked_counts(cwd, files) + return {"files": files, "base": base} + + parsed = _parse_status_v2(cwd) + if parsed is None: + return {"files": [], "base": None} + staged = _numstat(cwd, ["--cached"]) + unstaged = _numstat(cwd, []) + + files = [] + code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) + for entry in _iter_status_entries(raw): + path = entry["path"] + sa, sr = staged.get(path, (0, 0)) + ua, ur = unstaged.get(path, (0, 0)) + files.append( + { + "path": path, + "added": sa + ua, + "removed": sr + ur, + "status": entry["letter"], + "staged": entry["staged"], + } + ) + files.sort(key=lambda f: f["path"]) + _fill_untracked_counts(cwd, files) + return {"files": files, "base": None} + + +def _iter_status_entries(raw: str): + """Yield {path, letter, staged} from porcelain v2 -z output (for review_list).""" + records = raw.split("\0") + i = 0 + while i < len(records): + rec = records[i] + if not rec: + i += 1 + continue + tag = rec[0] + if tag in ("1", "2"): + xy = rec.split(" ")[1] + path = rec.split(" ", 8)[-1] if tag == "1" else rec.split(" ", 9)[-1] + if tag == "2": + i += 1 + path = resolve_rename_path(path) + x = xy[0] if xy else "." + yield {"path": path, "letter": _status_letter(xy), "staged": x not in (".", "?")} + elif tag == "u": + path = rec.split(" ", 10)[-1] + yield {"path": path, "letter": "U", "staged": False} + elif tag == "?": + yield {"path": rec[2:], "letter": "?", "staged": False} + i += 1 + + +def review_diff(cwd: str, file_path: str, scope: str, base_ref: str | None, staged: bool) -> str: + if not _is_dir(cwd): + return "" + if scope == "branch": + base = _branch_base(cwd) + return _git_out(cwd, ["diff", f"{base}...HEAD", "--", file_path]) if base else "" + if scope == "lastTurn": + return _git_out(cwd, ["diff", base_ref, "--", file_path]) if base_ref else "" + if staged: + return _git_out(cwd, ["diff", "--cached", "--", file_path]) + worktree = _git_out(cwd, ["diff", "--", file_path]) + if worktree.strip(): + return worktree + # Untracked: synthesize an all-add diff (exits non-zero by design). + _, out, _ = _git(cwd, ["diff", "--no-index", "--", os.devnull, file_path]) + return out + + +def file_diff_vs_head(cwd: str, file_path: str) -> str: + """Working-tree-vs-HEAD diff for one file (the preview's diff view). Unlike + review_diff, never all-adds a clean tracked file; only a genuinely untracked one.""" + if not _is_dir(cwd): + return "" + head = _git_out(cwd, ["diff", "HEAD", "--", file_path]) + if head.strip(): + return head + status = _git_out(cwd, ["status", "--porcelain", "--", file_path]) + if not status.strip().startswith("??"): + return "" + _, out, _ = _git(cwd, ["diff", "--no-index", "--", os.devnull, file_path]) + return out + + +def review_stage(cwd: str, file_path: str | None) -> dict: + _git_ok(cwd, ["add", "--", file_path] if file_path else ["add", "-A"]) + return {"ok": True} + + +def review_unstage(cwd: str, file_path: str | None) -> dict: + _git_ok(cwd, ["reset", "-q", "HEAD", "--", file_path] if file_path else ["reset", "-q", "HEAD"]) + return {"ok": True} + + +def review_revert(cwd: str, file_path: str | None) -> dict: + """Discard changes back to the committed state (restore tracked, remove untracked).""" + target = ["--", file_path] if file_path else ["--", "."] + _git(cwd, ["checkout", "HEAD", *target]) + _git(cwd, ["clean", "-fd", *target]) + return {"ok": True} + + +def review_rev_parse(cwd: str, ref: str | None) -> str | None: + out = _git_out(cwd, ["rev-parse", ref or "HEAD"]).strip() + return out or None + + +def review_commit(cwd: str, message: str, push: bool) -> dict: + """Commit the working tree; stage everything first when nothing is staged.""" + parsed = _parse_status_v2(cwd) + if not parsed or not any(f["staged"] for f in parsed["files"]): + _git_ok(cwd, ["add", "-A"]) + _git_ok(cwd, ["commit", "-m", message]) + if push: + _review_push(cwd) + return {"ok": True} + + +def _review_push(cwd: str) -> None: + upstream = _git_out(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).strip() + if upstream: + _git_ok(cwd, ["push"]) + return + branch = _git_out(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).strip() + if branch and branch != "HEAD": + _git_ok(cwd, ["push", "-u", "origin", branch]) + + +def review_push(cwd: str) -> dict: + _review_push(cwd) + return {"ok": True} + + +def review_commit_context(cwd: str) -> dict: + """Diff of what WILL commit + recent subjects, for drafting a commit message.""" + if not _is_dir(cwd): + return {"diff": "", "recent": ""} + parsed = _parse_status_v2(cwd) + if parsed is None: + return {"diff": "", "recent": ""} + + has_staged = any(f["staged"] for f in parsed["files"]) + diff = _git_out(cwd, ["diff", "--cached"]) if has_staged else _git_out(cwd, ["diff", "HEAD"]) + if len(diff) > _COMMIT_CONTEXT_DIFF_MAX_CHARS: + omitted = len(diff) - _COMMIT_CONTEXT_DIFF_MAX_CHARS + diff = f"{diff[:_COMMIT_CONTEXT_DIFF_MAX_CHARS]}\n# diff truncated: {omitted} chars omitted\n" + + untracked = [f["path"] for f in parsed["files"] if f["untracked"]] + if untracked: + visible = untracked[:_COMMIT_CONTEXT_UNTRACKED_MAX] + omitted = len(untracked) - len(visible) + note = "\n# New (untracked) files:\n" + "\n".join(f"# {p}" for p in visible) + "\n" + if omitted > 0: + note += f"# ... {omitted} more omitted\n" + diff = f"{diff}{note}" if diff else note + + recent = _git_out(cwd, ["log", "-n", "10", "--pretty=format:%s"]).strip() + return {"diff": diff or "", "recent": recent} + + +# ── ship flow (gh) ─────────────────────────────────────────────────────────── + + +def _gh(cwd: str, args: list[str]) -> tuple[bool, str]: + if not shutil.which("gh"): + return False, "" + try: + proc = subprocess.run( + ["gh", *args], cwd=cwd, capture_output=True, text=True, timeout=_GH_TIMEOUT + ) + except (OSError, subprocess.SubprocessError): + return False, "" + return proc.returncode == 0, proc.stdout or "" + + +def review_ship_info(cwd: str) -> dict: + """gh availability/auth + this branch's PR. ghReady false when gh missing/unauthed.""" + if not _is_dir(cwd): + return {"ghReady": False, "pr": None} + auth_ok, _ = _gh(cwd, ["auth", "status"]) + if not auth_ok: + return {"ghReady": False, "pr": None} + view_ok, out = _gh(cwd, ["pr", "view", "--json", "url,state,number"]) + if not view_ok: + return {"ghReady": True, "pr": None} + try: + pr = json.loads(out) + except json.JSONDecodeError: + return {"ghReady": True, "pr": None} + if pr and pr.get("url"): + return {"ghReady": True, "pr": {"url": pr["url"], "state": pr.get("state"), "number": pr.get("number")}} + return {"ghReady": True, "pr": None} + + +def review_create_pr(cwd: str) -> dict: + """Create a PR for the current branch (push first), letting gh fill title/body.""" + try: + _review_push(cwd) + except RuntimeError: + pass + created, out = _gh(cwd, ["pr", "create", "--fill"]) + if not created: + raise RuntimeError("gh pr create failed (is gh installed and authenticated?)") + url = next((line for line in reversed(out.strip().splitlines()) if line.strip()), "") + return {"url": url} + + +# ── worktrees & branches ───────────────────────────────────────────────────── + + +def _parse_worktrees(out: str) -> list[dict]: + trees: list[dict] = [] + cur: dict | None = None + for line in out.split("\n"): + if line.startswith("worktree "): + if cur: + trees.append(cur) + cur = {"path": line[9:].strip(), "branch": None, "detached": False, "bare": False, "locked": False} + elif cur is None: + continue + elif line.startswith("branch "): + cur["branch"] = line[7:].strip().replace("refs/heads/", "", 1) + elif line == "detached": + cur["detached"] = True + elif line == "bare": + cur["bare"] = True + elif line.startswith("locked"): + cur["locked"] = True + if cur: + trees.append(cur) + return trees + + +def worktree_list(cwd: str) -> list[dict]: + out = _git_out(cwd, ["worktree", "list", "--porcelain"]) + if not out: + return [] + return [ + { + "path": tree["path"], + "branch": tree["branch"], + "isMain": index == 0, + "detached": tree["detached"], + "locked": tree["locked"], + } + for index, tree in enumerate(_parse_worktrees(out)) + ] + + +def _main_root(cwd: str) -> str: + for tree in worktree_list(cwd): + if tree["isMain"]: + return tree["path"] + return cwd + + +def _sanitize_branch(name: str) -> str: + import re + + value = str(name or "") + value = re.sub(r"\s+", "-", value) + value = re.sub(r"[^\w./-]", "", value) + value = re.sub(r"-{2,}", "-", value) + value = re.sub(r"/{2,}", "/", value) + value = re.sub(r"\.{2,}", ".", value) + return re.sub(r"^[-./]+|[-./]+$", "", value) + + +def _slugify(name: str) -> str: + import re + + slug = re.sub(r"[^a-z0-9]+", "-", str(name or "").strip().lower()) + slug = re.sub(r"^-+|-+$", "", slug)[:40].rstrip("-") + return slug or "work" + + +def _default_branch(cwd: str) -> str: + remote = _git_out( + cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"] + ).strip().replace("origin/", "", 1) + if remote: + return remote + configured = _git_out(cwd, ["config", "--get", "init.defaultBranch"]).strip() + if configured: + return configured + for branch in _TRUNK_BRANCHES: + if _git_out(cwd, ["show-ref", "--verify", f"refs/heads/{branch}"]).strip(): + return branch + return "" + + +def _ensure_repo(cwd: str) -> None: + """A new project folder may not be a repo (or has no commit to branch from); + init it with a root commit so worktrees just work. No-op for a committed repo.""" + inside = _git_out(cwd, ["rev-parse", "--is-inside-work-tree"]).strip() + needs_root = False + if inside != "true": + _git_ok(cwd, ["init"]) + needs_root = True + else: + code, _, _ = _git(cwd, ["rev-parse", "--verify", "HEAD"]) + needs_root = code != 0 + if needs_root: + _git_ok( + cwd, + [ + "-c", + "user.email=hermes@localhost", + "-c", + "user.name=Hermes", + "commit", + "--allow-empty", + "-m", + "Initial commit", + ], + ) + + +def _unique_dir(base: str) -> str: + candidate = base + n = 1 + while os.path.exists(candidate): + n += 1 + candidate = f"{base}-{n}" + return candidate + + +def worktree_add(cwd: str, options: dict) -> dict: + _ensure_repo(cwd) + root = _main_root(cwd) + options = options or {} + + existing = _sanitize_branch(options.get("existingBranch") or "") + if options.get("existingBranch"): + if not existing: + raise RuntimeError("Branch name is required.") + if existing == _default_branch(root): + _git_ok(root, ["switch", existing]) + return {"path": root, "branch": existing, "repoRoot": root} + target = _unique_dir(os.path.join(root, ".worktrees", _slugify(existing))) + _git_ok(root, ["worktree", "add", target, existing]) + return {"path": target, "branch": existing, "repoRoot": root} + + slug = _slugify(options.get("name") or f"work-{os.urandom(4).hex()}") + branch = _sanitize_branch(options.get("branch") or "") or f"hermes/{slug}" + target = _unique_dir(os.path.join(root, ".worktrees", slug)) + args = ["worktree", "add", "-b", branch, target] + if options.get("base"): + args.append(str(options["base"])) + code, _, err = _git(root, args) + if code != 0: + if "already exists" in (err or "").lower(): + _git_ok(root, ["worktree", "add", target, branch]) + else: + raise RuntimeError(err.strip() or "git worktree add failed") + return {"path": target, "branch": branch, "repoRoot": root} + + +def worktree_remove(cwd: str, worktree_path: str, force: bool) -> dict: + root = _main_root(cwd) + args = ["worktree", "remove"] + if force: + args.append("--force") + args.append(worktree_path) + _git_ok(root, args) + return {"removed": worktree_path} + + +def branch_list(cwd: str) -> list[dict]: + out = _git_out( + cwd, ["for-each-ref", "--format=%(refname:short)", "--sort=-committerdate", "refs/heads"] + ) + if not out: + return [] + trees = worktree_list(cwd) + path_by_branch = {t["branch"]: t["path"] for t in trees if t["branch"]} + trunk = _default_branch(cwd) + return [ + { + "name": name, + "checkedOut": name in path_by_branch, + "isDefault": bool(trunk and name == trunk), + "worktreePath": path_by_branch.get(name), + } + for name in (line.strip() for line in out.split("\n")) + if name + ] + + +def branch_switch(cwd: str, branch: str) -> dict: + target = _sanitize_branch(branch) + if not target: + raise RuntimeError("Branch name is required.") + _git_ok(cwd, ["switch", target]) + return {"branch": target} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f489d43bcc6..aea336af31e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1910,6 +1910,165 @@ async def fs_default_cwd(): return {"cwd": cwd, "branch": _fs_git_branch(cwd)} +# --------------------------------------------------------------------------- +# Git ops — the remote half of the desktop coding rail + review pane. +# +# The desktop runs these as Electron-local git on the user's machine; over a +# remote gateway that's the wrong filesystem, so we mirror them here (same auth +# gate + path hardening as /api/fs). Logic lives in ``hermes_cli.web_git``; +# these are thin, executor-offloaded wrappers (git/gh can block). +# --------------------------------------------------------------------------- + +from hermes_cli import web_git as _web_git # noqa: E402 + + +async def _git_op(fn, *args): + """Run a (blocking) git op off the event loop; map a failed mutation to 400.""" + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor(None, fn, *args) + except RuntimeError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "git operation failed") + + +class GitPathBody(BaseModel): + path: str + + +class GitFileBody(BaseModel): + path: str + file: Optional[str] = None + + +class GitCommitBody(BaseModel): + path: str + message: str + push: bool = False + + +class GitWorktreeAddBody(BaseModel): + path: str + name: Optional[str] = None + branch: Optional[str] = None + base: Optional[str] = None + existingBranch: Optional[str] = None + + +class GitWorktreeRemoveBody(BaseModel): + path: str + worktreePath: str + force: bool = False + + +class GitBranchSwitchBody(BaseModel): + path: str + branch: str + + +@app.get("/api/git/status") +async def git_status_route(path: str): + return await _git_op(_web_git.repo_status, str(_fs_path(path))) + + +@app.get("/api/git/worktrees") +async def git_worktrees_route(path: str): + return {"worktrees": await _git_op(_web_git.worktree_list, str(_fs_path(path)))} + + +@app.get("/api/git/branches") +async def git_branches_route(path: str): + return {"branches": await _git_op(_web_git.branch_list, str(_fs_path(path)))} + + +@app.get("/api/git/review/list") +async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None): + return await _git_op(_web_git.review_list, str(_fs_path(path)), scope, base) + + +@app.get("/api/git/review/diff") +async def git_review_diff_route( + path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False +): + return {"diff": await _git_op(_web_git.review_diff, str(_fs_path(path)), file, scope, base, staged)} + + +@app.get("/api/git/file-diff") +async def git_file_diff_route(path: str, file: str): + return {"diff": await _git_op(_web_git.file_diff_vs_head, str(_fs_path(path)), file)} + + +@app.get("/api/git/review/commit-context") +async def git_commit_context_route(path: str): + return await _git_op(_web_git.review_commit_context, str(_fs_path(path))) + + +@app.get("/api/git/review/rev-parse") +async def git_rev_parse_route(path: str, ref: Optional[str] = None): + return {"sha": await _git_op(_web_git.review_rev_parse, str(_fs_path(path)), ref)} + + +@app.get("/api/git/review/ship-info") +async def git_ship_info_route(path: str): + return await _git_op(_web_git.review_ship_info, str(_fs_path(path))) + + +@app.post("/api/git/review/stage") +async def git_stage_route(body: GitFileBody): + return await _git_op(_web_git.review_stage, str(_fs_path(body.path)), body.file) + + +@app.post("/api/git/review/unstage") +async def git_unstage_route(body: GitFileBody): + return await _git_op(_web_git.review_unstage, str(_fs_path(body.path)), body.file) + + +@app.post("/api/git/review/revert") +async def git_revert_route(body: GitFileBody): + return await _git_op(_web_git.review_revert, str(_fs_path(body.path)), body.file) + + +@app.post("/api/git/review/commit") +async def git_commit_route(body: GitCommitBody): + return await _git_op(_web_git.review_commit, str(_fs_path(body.path)), body.message, body.push) + + +@app.post("/api/git/review/push") +async def git_push_route(body: GitPathBody): + return await _git_op(_web_git.review_push, str(_fs_path(body.path))) + + +@app.post("/api/git/review/create-pr") +async def git_create_pr_route(body: GitPathBody): + return await _git_op(_web_git.review_create_pr, str(_fs_path(body.path))) + + +@app.post("/api/git/worktree/add") +async def git_worktree_add_route(body: GitWorktreeAddBody): + options = { + key: value + for key, value in { + "name": body.name, + "branch": body.branch, + "base": body.base, + "existingBranch": body.existingBranch, + }.items() + if value + } + return await _git_op(_web_git.worktree_add, str(_fs_path(body.path)), options) + + +@app.post("/api/git/worktree/remove") +async def git_worktree_remove_route(body: GitWorktreeRemoveBody): + return await _git_op( + _web_git.worktree_remove, str(_fs_path(body.path)), str(_fs_path(body.worktreePath)), body.force + ) + + +@app.post("/api/git/branch/switch") +async def git_branch_switch_route(body: GitBranchSwitchBody): + return await _git_op(_web_git.branch_switch, str(_fs_path(body.path)), body.branch) + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None diff --git a/tests/hermes_cli/test_web_server_git.py b/tests/hermes_cli/test_web_server_git.py new file mode 100644 index 00000000000..25879388076 --- /dev/null +++ b/tests/hermes_cli/test_web_server_git.py @@ -0,0 +1,148 @@ +import subprocess +from pathlib import Path + +import pytest + +from hermes_cli import web_server + +pytest.importorskip("starlette.testclient") +from starlette.testclient import TestClient + + +@pytest.fixture +def client(): + previous = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.auth_required = False + test_client = TestClient(web_server.app) + test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN + try: + yield test_client + finally: + if previous is None: + try: + delattr(web_server.app.state, "auth_required") + except AttributeError: + pass + else: + web_server.app.state.auth_required = previous + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "repo" + root.mkdir() + _git(root, "init", "-q") + _git(root, "config", "user.email", "t@example.com") + _git(root, "config", "user.name", "Test") + (root / "a.txt").write_text("one\ntwo\n") + _git(root, "add", "-A") + _git(root, "commit", "-qm", "init") + # A tracked modification + a brand-new untracked file (the new-file case the + # rail/review must surface). + (root / "a.txt").write_text("one\ntwo\nthree\n") + (root / "new.py").write_text("print(1)\nprint(2)\n") + return root + + +def test_status_reports_branch_and_change_counts(client, repo): + body = client.get("/api/git/status", params={"path": str(repo)}).json() + + assert body["branch"] == "main" + assert body["defaultBranch"] == "main" + assert body["detached"] is False + # 1 tracked-modified + 1 untracked = 2 changed paths. + assert body["changed"] == 2 + assert body["untracked"] == 1 + # +1 (a.txt) folded with +2 (untracked new.py) since `git diff HEAD` skips untracked. + assert body["added"] == 3 + assert {f["path"] for f in body["files"]} == {"a.txt", "new.py"} + + +def test_status_returns_null_outside_repo(client, tmp_path): + plain = tmp_path / "plain" + plain.mkdir() + + assert client.get("/api/git/status", params={"path": str(plain)}).json() is None + + +def test_review_list_classifies_modified_and_untracked(client, repo): + body = client.get("/api/git/review/list", params={"path": str(repo)}).json() + + files = {f["path"]: f for f in body["files"]} + assert files["a.txt"]["status"] == "M" + assert files["a.txt"]["added"] == 1 + assert files["new.py"]["status"] == "?" + assert files["new.py"]["added"] == 2 # untracked insertions counted from disk + + +def test_review_diff_shows_change_and_synthesizes_untracked(client, repo): + tracked = client.get( + "/api/git/review/diff", params={"path": str(repo), "file": "a.txt"} + ).json()["diff"] + assert "+three" in tracked + + untracked = client.get( + "/api/git/review/diff", params={"path": str(repo), "file": "new.py"} + ).json()["diff"] + assert "print(1)" in untracked # all-add diff for a file git doesn't track yet + + +def test_stage_commit_roundtrip_clears_changes(client, repo): + assert client.post("/api/git/review/stage", json={"path": str(repo), "file": "a.txt"}).json() == {"ok": True} + staged = client.get("/api/git/status", params={"path": str(repo)}).json() + assert staged["staged"] >= 1 + + assert client.post( + "/api/git/review/commit", json={"path": str(repo), "message": "tracked change", "push": False} + ).json() == {"ok": True} + + after = client.get("/api/git/status", params={"path": str(repo)}).json() + # The tracked change is committed; only the untracked file remains. + assert after["changed"] == 1 + assert after["untracked"] == 1 + + +def test_worktrees_and_branch_lifecycle(client, repo): + worktrees = client.get("/api/git/worktrees", params={"path": str(repo)}).json()["worktrees"] + assert any(tree["isMain"] and tree["path"] == str(repo) for tree in worktrees) + + added = client.post( + "/api/git/worktree/add", json={"path": str(repo), "branch": "feature/x"} + ).json() + assert added["branch"] == "feature/x" + assert Path(added["path"]).is_dir() + + branches = client.get("/api/git/branches", params={"path": str(repo)}).json()["branches"] + assert any(b["name"] == "feature/x" and b["checkedOut"] for b in branches) + + removed = client.post( + "/api/git/worktree/remove", json={"path": str(repo), "worktreePath": added["path"], "force": True} + ).json() + assert removed["removed"] + + +def test_commit_context_includes_diff_and_untracked(client, repo): + body = client.get("/api/git/review/commit-context", params={"path": str(repo)}).json() + + assert "+three" in body["diff"] + assert "new.py" in body["diff"] # untracked files listed since they carry no diff + + +def test_ship_info_degrades_without_gh(client, repo, monkeypatch): + monkeypatch.setattr(web_server._web_git.shutil, "which", lambda _name: None) + + assert client.get("/api/git/review/ship-info", params={"path": str(repo)}).json() == { + "ghReady": False, + "pr": None, + } + + +def test_git_endpoints_require_auth(repo): + unauth = TestClient(web_server.app) + + assert unauth.get("/api/git/status", params={"path": str(repo)}).status_code == 401 + assert unauth.post("/api/git/review/stage", json={"path": str(repo)}).status_code == 401 From e4cf3a2e9d10a0325e0c32b2f20a791435888e01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:29:59 -0500 Subject: [PATCH 04/11] refactor(web_git): unify porcelain-v2 parsing into one walker Collapse the two near-duplicate status parsers (_parse_status_v2 + _iter_status_entries) into a single _walk_entries generator feeding the rail, review list, and commit flow; share the staged predicate; hoist `import re`. Behavior unchanged. --- hermes_cli/web_git.py | 227 ++++++++++++++++-------------------------- 1 file changed, 85 insertions(+), 142 deletions(-) diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py index a71974b605f..292f6c81144 100644 --- a/hermes_cli/web_git.py +++ b/hermes_cli/web_git.py @@ -14,6 +14,7 @@ from __future__ import annotations import json import os +import re import shutil import subprocess from pathlib import Path @@ -152,87 +153,49 @@ def _default_branch_name(cwd: str) -> str | None: # ── porcelain v2 status parsing ────────────────────────────────────────────── -def _parse_status_v2(cwd: str) -> dict | None: - """Parse ``git status --porcelain=v2 --branch -z`` into branch + classified - files. None when ``cwd`` isn't a git repo.""" - code, out, _ = _git(cwd, ["status", "--porcelain=v2", "--branch", "-z"]) - if code != 0: - return None - - branch: str | None = None - detached = False - ahead = behind = 0 - files: list[dict] = [] - untracked = 0 - conflicted = 0 - - records = out.split("\0") +def _walk_entries(raw: str): + """Yield (tag, xy, path) per changed file from ``git status --porcelain=v2 -z``, + skipping branch headers and the rename/copy origin-path records. One walker + feeds the rail, the review list, and the commit flow.""" + records = raw.split("\0") i = 0 while i < len(records): rec = records[i] - if not rec: - i += 1 - continue - tag = rec[0] - if tag == "#": - if rec.startswith("# branch.head "): - head = rec[len("# branch.head ") :] - if head == "(detached)": - detached = True - else: - branch = head - elif rec.startswith("# branch.ab "): - for tok in rec[len("# branch.ab ") :].split(): - if tok.startswith("+"): - ahead = int(tok[1:] or 0) - elif tok.startswith("-"): - behind = int(tok[1:] or 0) + tag = rec[0] if rec else "" + if tag == "?": + yield "?", "??", rec[2:] + elif tag == "u": + yield "u", rec.split(" ")[1], rec.split(" ", 10)[-1] elif tag in ("1", "2"): - fields = rec.split(" ") - xy = fields[1] + xy = rec.split(" ")[1] path = rec.split(" ", 8)[-1] if tag == "1" else rec.split(" ", 9)[-1] if tag == "2": - # Rename/copy: NUL-separated origin path follows in the next record. - i += 1 - files.append(_classify(xy, resolve_rename_path(path))) - elif tag == "u": - path = rec.split(" ", 10)[-1] - files.append({"path": path, "staged": False, "unstaged": False, "untracked": False, "conflicted": True}) - conflicted += 1 - elif tag == "?": - path = rec[2:] - files.append({"path": path, "staged": False, "unstaged": True, "untracked": True, "conflicted": False}) - untracked += 1 + i += 1 # rename/copy: the origin path is the next NUL record + yield tag, xy, resolve_rename_path(path) i += 1 - return { - "branch": branch, - "detached": detached, - "ahead": ahead, - "behind": behind, - "files": files, - "untracked": untracked, - "conflicted": conflicted, - } + +def _entry_staged(tag: str, xy: str) -> bool: + """A tracked entry whose index (staged) code is set.""" + return tag in ("1", "2") and xy[0] not in (".", "?") -def _classify(xy: str, path: str) -> dict: - x = xy[0] if xy else "." +def _classify(tag: str, xy: str, path: str) -> dict: y = xy[1] if len(xy) > 1 else "." return { "path": path, - "staged": x not in (".", "?"), - "unstaged": y not in (".", "?"), - "untracked": False, - "conflicted": x == "U" or y == "U", + "staged": _entry_staged(tag, xy), + "unstaged": tag == "?" or (tag in ("1", "2") and y not in (".", "?")), + "untracked": tag == "?", + "conflicted": tag == "u", } -def _status_letter(xy: str) -> str: - x = xy[0] if xy else "." - y = xy[1] if len(xy) > 1 else "." - code = x if x != "." else y - return (code or "M").upper() +def _status_letter(tag: str, xy: str) -> str: + if tag in ("?", "u"): + return tag.upper() if tag == "u" else "?" + code = xy[0] if xy[0] != "." else (xy[1] if len(xy) > 1 else ".") + return (code if code != "." else "M").upper() # ── coding rail ────────────────────────────────────────────────────────────── @@ -242,39 +205,50 @@ def repo_status(cwd: str) -> dict | None: """Compact working-tree status for the coding rail. None on a non-repo.""" if not _is_dir(cwd): return None - parsed = _parse_status_v2(cwd) - if parsed is None: + + code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "--branch", "-z"]) + if code != 0: return None - files = parsed["files"] + branch: str | None = None + detached = False + ahead = behind = 0 + for rec in raw.split("\0"): + if rec.startswith("# branch.head "): + head = rec[len("# branch.head ") :] + detached = head == "(detached)" + branch = None if detached else head + elif rec.startswith("# branch.ab "): + for tok in rec.split()[2:]: + if tok.startswith("+"): + ahead = int(tok[1:] or 0) + elif tok.startswith("-"): + behind = int(tok[1:] or 0) + + files = [_classify(tag, xy, path) for tag, xy, path in _walk_entries(raw)] + + # +/- vs HEAD (tracked), then fold in untracked insertions — `git diff HEAD` + # ignores them, so a new-file-only turn would otherwise read +0 (bounded scan). added = removed = 0 - summary = _numstat(cwd, ["HEAD"]) - for a, r in summary.values(): + for a, r in _numstat(cwd, ["HEAD"]).values(): added += a removed += r - - # `git diff HEAD` ignores untracked files; fold their insertions into `added` - # so a new-file-only turn registers in the rail (bounded scan). - untracked_paths = [f["path"] for f in files if f["untracked"]][:_UNTRACKED_SCAN_CAP] - for rel in untracked_paths: - added += _untracked_insertions(cwd, rel) + added += sum(_untracked_insertions(cwd, f["path"]) for f in files[:_UNTRACKED_SCAN_CAP] if f["untracked"]) return { - "branch": None if parsed["detached"] else parsed["branch"], + "branch": branch, "defaultBranch": _default_branch_name(cwd), - "detached": parsed["detached"], - "ahead": parsed["ahead"], - "behind": parsed["behind"], - "staged": sum(1 for f in files if f["staged"]), - "unstaged": sum(1 for f in files if f["unstaged"]), - "untracked": parsed["untracked"], - "conflicted": parsed["conflicted"], + "detached": detached, + "ahead": ahead, + "behind": behind, + "staged": sum(f["staged"] for f in files), + "unstaged": sum(f["unstaged"] for f in files), + "untracked": sum(f["untracked"] for f in files), + "conflicted": sum(f["conflicted"] for f in files), "changed": len(files), "added": added, "removed": removed, - "files": [ - {k: f[k] for k in ("path", "staged", "unstaged", "untracked", "conflicted")} for f in files[:200] - ], + "files": files[:200], } @@ -291,30 +265,30 @@ def review_list(cwd: str, scope: str, base_ref: str | None) -> dict: if not base: return {"files": [], "base": None} rng = f"{base}...HEAD" if scope == "branch" else base - counts = _numstat(cwd, [rng]) files = [ {"path": path, "added": a, "removed": r, "status": "M", "staged": False} - for path, (a, r) in counts.items() + for path, (a, r) in _numstat(cwd, [rng]).items() ] if scope == "lastTurn": - parsed = _parse_status_v2(cwd) - for f in parsed["files"] if parsed else []: - if f["untracked"] and not any(x["path"] == f["path"] for x in files): - files.append({"path": f["path"], "added": 0, "removed": 0, "status": "?", "staged": False}) + seen = {f["path"] for f in files} + _, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) + files += [ + {"path": path, "added": 0, "removed": 0, "status": "?", "staged": False} + for tag, _xy, path in _walk_entries(raw) + if tag == "?" and path not in seen + ] files.sort(key=lambda f: f["path"]) _fill_untracked_counts(cwd, files) return {"files": files, "base": base} - parsed = _parse_status_v2(cwd) - if parsed is None: + code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) + if code != 0: return {"files": [], "base": None} staged = _numstat(cwd, ["--cached"]) unstaged = _numstat(cwd, []) files = [] - code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) - for entry in _iter_status_entries(raw): - path = entry["path"] + for tag, xy, path in _walk_entries(raw): sa, sr = staged.get(path, (0, 0)) ua, ur = unstaged.get(path, (0, 0)) files.append( @@ -322,8 +296,8 @@ def review_list(cwd: str, scope: str, base_ref: str | None) -> dict: "path": path, "added": sa + ua, "removed": sr + ur, - "status": entry["letter"], - "staged": entry["staged"], + "status": _status_letter(tag, xy), + "staged": _entry_staged(tag, xy), } ) files.sort(key=lambda f: f["path"]) @@ -331,32 +305,6 @@ def review_list(cwd: str, scope: str, base_ref: str | None) -> dict: return {"files": files, "base": None} -def _iter_status_entries(raw: str): - """Yield {path, letter, staged} from porcelain v2 -z output (for review_list).""" - records = raw.split("\0") - i = 0 - while i < len(records): - rec = records[i] - if not rec: - i += 1 - continue - tag = rec[0] - if tag in ("1", "2"): - xy = rec.split(" ")[1] - path = rec.split(" ", 8)[-1] if tag == "1" else rec.split(" ", 9)[-1] - if tag == "2": - i += 1 - path = resolve_rename_path(path) - x = xy[0] if xy else "." - yield {"path": path, "letter": _status_letter(xy), "staged": x not in (".", "?")} - elif tag == "u": - path = rec.split(" ", 10)[-1] - yield {"path": path, "letter": "U", "staged": False} - elif tag == "?": - yield {"path": rec[2:], "letter": "?", "staged": False} - i += 1 - - def review_diff(cwd: str, file_path: str, scope: str, base_ref: str | None, staged: bool) -> str: if not _is_dir(cwd): return "" @@ -415,8 +363,8 @@ def review_rev_parse(cwd: str, ref: str | None) -> str | None: def review_commit(cwd: str, message: str, push: bool) -> dict: """Commit the working tree; stage everything first when nothing is staged.""" - parsed = _parse_status_v2(cwd) - if not parsed or not any(f["staged"] for f in parsed["files"]): + _, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) + if not any(_entry_staged(tag, xy) for tag, xy, _ in _walk_entries(raw)): _git_ok(cwd, ["add", "-A"]) _git_ok(cwd, ["commit", "-m", message]) if push: @@ -443,27 +391,26 @@ def review_commit_context(cwd: str) -> dict: """Diff of what WILL commit + recent subjects, for drafting a commit message.""" if not _is_dir(cwd): return {"diff": "", "recent": ""} - parsed = _parse_status_v2(cwd) - if parsed is None: + code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"]) + if code != 0: return {"diff": "", "recent": ""} + entries = list(_walk_entries(raw)) - has_staged = any(f["staged"] for f in parsed["files"]) + has_staged = any(_entry_staged(tag, xy) for tag, xy, _ in entries) diff = _git_out(cwd, ["diff", "--cached"]) if has_staged else _git_out(cwd, ["diff", "HEAD"]) if len(diff) > _COMMIT_CONTEXT_DIFF_MAX_CHARS: omitted = len(diff) - _COMMIT_CONTEXT_DIFF_MAX_CHARS diff = f"{diff[:_COMMIT_CONTEXT_DIFF_MAX_CHARS]}\n# diff truncated: {omitted} chars omitted\n" - untracked = [f["path"] for f in parsed["files"] if f["untracked"]] + untracked = [path for tag, _xy, path in entries if tag == "?"] if untracked: visible = untracked[:_COMMIT_CONTEXT_UNTRACKED_MAX] - omitted = len(untracked) - len(visible) - note = "\n# New (untracked) files:\n" + "\n".join(f"# {p}" for p in visible) + "\n" - if omitted > 0: - note += f"# ... {omitted} more omitted\n" + note = "\n# New (untracked) files:\n" + "".join(f"# {p}\n" for p in visible) + if len(untracked) > len(visible): + note += f"# ... {len(untracked) - len(visible)} more omitted\n" diff = f"{diff}{note}" if diff else note - recent = _git_out(cwd, ["log", "-n", "10", "--pretty=format:%s"]).strip() - return {"diff": diff or "", "recent": recent} + return {"diff": diff or "", "recent": _git_out(cwd, ["log", "-n", "10", "--pretty=format:%s"]).strip()} # ── ship flow (gh) ─────────────────────────────────────────────────────────── @@ -563,8 +510,6 @@ def _main_root(cwd: str) -> str: def _sanitize_branch(name: str) -> str: - import re - value = str(name or "") value = re.sub(r"\s+", "-", value) value = re.sub(r"[^\w./-]", "", value) @@ -575,8 +520,6 @@ def _sanitize_branch(name: str) -> str: def _slugify(name: str) -> str: - import re - slug = re.sub(r"[^a-z0-9]+", "-", str(name or "").strip().lower()) slug = re.sub(r"^-+|-+$", "", slug)[:40].rstrip("-") return slug or "work" From 9b7122118716474a53f559df44b8baf0526ce2ce Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:31:58 -0500 Subject: [PATCH 05/11] fix(desktop): write project IDEA.md through the remote-aware fs path writeProjectIdea used the local-only Electron writeTextFile, so on a remote gateway IDEA.md never landed on the backend (where the project folder lives). Route it through writeDesktopFileText (local Electron / POST /api/fs/write-text). --- apps/desktop/src/store/projects.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 8190cdf4e4a..eb993cd400f 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -2,7 +2,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBranch } from '@/global' -import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths } from '@/lib/desktop-fs' +import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' import { persistentAtom } from '@/lib/persisted' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' @@ -336,20 +336,19 @@ export async function generateProjectIdea(name: string): Promise { } } -// Write IDEA.md to a project's primary folder (desktop only, best-effort). Local -// fs write is hardened in the electron main; a remote backend / missing bridge -// just skips it. +// Write IDEA.md to a project's primary folder (best-effort). Routes through the +// remote-aware fs write, so it lands on the backend for a remote gateway and on +// disk locally — the project is created regardless of whether the file lands. async function writeProjectIdea(folder: null | string | undefined, idea: string): Promise { const dir = (folder || '').trim() const body = idea.trim() - const write = window.hermesDesktop?.writeTextFile - if (!dir || !body || !write) { + if (!dir || !body) { return } try { - await write(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`) + await writeDesktopFileText(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`) } catch { // Best-effort: the project is created regardless of whether IDEA.md lands. } From 4e9439cc3b33d74ad511f320dc1c5c0a66423127 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:35:23 -0500 Subject: [PATCH 06/11] fix(desktop): route composer context picking through remote-aware fs Second pass on the remote-project flow: the project dialog and git cockpit were remote-aware, but the composer's Add file/folder context picker still called the native Electron picker directly. Route it through selectDesktopPaths so remote sessions use the backend-aware picker instead of local disk paths; preserve local multi-select behavior and keep remote folder selection single because the in-app remote picker only supports one directory. Also use readDesktopFileDataUrl for image previews so an already-known backend image path can be read through /api/fs/read-data-url, and add focused coverage for backend file-diff routing plus the plain-folder git init/worktree path. --- .../app/chat/hooks/use-composer-actions.ts | 10 ++++--- apps/desktop/src/lib/desktop-fs.test.ts | 12 ++++++++ tests/hermes_cli/test_web_server_git.py | 28 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index ecc13808413..f72f9782398 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -5,6 +5,7 @@ import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' +import { isDesktopFsRemoteMode, readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs' import { addComposerAttachment, type ComposerAttachment, @@ -262,10 +263,11 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const pickContextPaths = useCallback( async (kind: 'file' | 'folder') => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: kind === 'file' ? 'Add files as context' : 'Add folders as context', defaultPath: currentCwd || undefined, - directories: kind === 'folder' + directories: kind === 'folder', + multiple: kind === 'folder' && isDesktopFsRemoteMode() ? false : undefined }) if (!paths?.length) { @@ -347,7 +349,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway attachToMain(baseAttachment) try { - const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const previewUrl = await readDesktopFileDataUrl(filePath) if (previewUrl) { addComposerAttachment({ ...baseAttachment, previewUrl }) @@ -395,7 +397,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway ) const pickImages = useCallback(async () => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: copy.attachImages, defaultPath: currentCwd || undefined, filters: [ diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index d9c999773f4..0d5955313c6 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -4,6 +4,7 @@ import { $connection } from '@/store/session' import { desktopDefaultCwd, + desktopFileDiff, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl, @@ -39,6 +40,10 @@ const api = vi.fn(async ({ path }: { path: string }) => { return { cwd: '/backend/project', branch: 'main' } } + if (path.startsWith('/api/git/file-diff?')) { + return { diff: 'remote diff' } + } + throw new Error(`unexpected path ${path}`) }) @@ -107,6 +112,13 @@ describe('desktop filesystem facade', () => { expect(gitRoot).not.toHaveBeenCalled() }) + it('routes file diffs through backend git in remote mode', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(desktopFileDiff('/repo', 'src/a b.ts')).resolves.toBe('remote diff') + expect(api).toHaveBeenCalledWith({ path: '/api/git/file-diff?path=%2Frepo&file=src%2Fa%20b.ts' }) + }) + it('uses the registered in-app directory picker in remote mode', async () => { const remoteSelect = vi.fn(async () => ['/remote/project']) $connection.set({ mode: 'remote' } as never) diff --git a/tests/hermes_cli/test_web_server_git.py b/tests/hermes_cli/test_web_server_git.py index 25879388076..a3f98c670f6 100644 --- a/tests/hermes_cli/test_web_server_git.py +++ b/tests/hermes_cli/test_web_server_git.py @@ -106,6 +106,14 @@ def test_stage_commit_roundtrip_clears_changes(client, repo): assert after["untracked"] == 1 +def test_commit_with_nothing_staged_commits_all_changes(client, repo): + assert client.post( + "/api/git/review/commit", json={"path": str(repo), "message": "commit all", "push": False} + ).json() == {"ok": True} + + assert client.get("/api/git/status", params={"path": str(repo)}).json()["changed"] == 0 + + def test_worktrees_and_branch_lifecycle(client, repo): worktrees = client.get("/api/git/worktrees", params={"path": str(repo)}).json()["worktrees"] assert any(tree["isMain"] and tree["path"] == str(repo) for tree in worktrees) @@ -125,6 +133,26 @@ def test_worktrees_and_branch_lifecycle(client, repo): assert removed["removed"] +def test_worktree_add_initializes_plain_folder(client, tmp_path): + folder = tmp_path / "plain-project" + folder.mkdir() + (folder / "notes.txt").write_text("not committed\n") + + added = client.post( + "/api/git/worktree/add", json={"path": str(folder), "branch": "feature/plain"} + ).json() + + assert added["branch"] == "feature/plain" + assert Path(added["path"]).is_dir() + assert (folder / ".git").exists() + _git(folder, "rev-parse", "--verify", "HEAD") + + status = client.get("/api/git/status", params={"path": str(folder)}).json() + assert status["branch"] == "main" + # Existing files are not silently committed by repo initialization. + assert any(file["path"] == "notes.txt" and file["untracked"] for file in status["files"]) + + def test_commit_context_includes_diff_and_untracked(client, repo): body = client.get("/api/git/review/commit-context", params={"path": str(repo)}).json() From 453f134b3bc213f05db52b22c6a0eb18ef115339 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:37:36 -0500 Subject: [PATCH 07/11] refactor(desktop): centralize remote git REST routing Keep the remote git mirror as a thin facade: route all GETs through gitGet, all mutations through gitPost, and keep consumers on desktopGit(). On the backend, route git paths through a single _git_path helper instead of repeating str(_fs_path(...)) in every endpoint. Behavior unchanged. --- apps/desktop/src/lib/desktop-git.ts | 65 ++++++++++++++++------------- hermes_cli/web_server.py | 40 ++++++++++-------- 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 7d5cda7544e..67a596f04d7 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -16,8 +16,6 @@ import { isDesktopFsRemoteMode } from './desktop-fs' type GitBridge = NonNullable['git']> -const q = (value: string) => encodeURIComponent(value) - function desktopApi(path: string, body?: Record): Promise { const desktop = window.hermesDesktop @@ -28,60 +26,67 @@ function desktopApi(path: string, body?: Record): Promise return desktop.api(body ? { body, method: 'POST', path } : { path }) } +function gitGet(route: string, params: Record): Promise { + const query = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + query.set(key, String(value)) + } + } + + return desktopApi(`/api/git/${route}?${query.toString()}`) +} + +function gitPost(route: string, body: Record): Promise { + return desktopApi(`/api/git/${route}`, body) +} + const remoteGit: GitBridge = { worktreeList: async repoPath => - (await desktopApi<{ worktrees: HermesGitWorktree[] }>(`/api/git/worktrees?path=${q(repoPath)}`)).worktrees, + (await gitGet<{ worktrees: HermesGitWorktree[] }>('worktrees', { path: repoPath })).worktrees, - worktreeAdd: (repoPath, options) => desktopApi(`/api/git/worktree/add`, { path: repoPath, ...options }), + worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }), worktreeRemove: (repoPath, worktreePath, options) => - desktopApi(`/api/git/worktree/remove`, { force: options?.force ?? false, path: repoPath, worktreePath }), + gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }), - branchSwitch: (repoPath, branch) => desktopApi(`/api/git/branch/switch`, { branch, path: repoPath }), + branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }), branchList: async repoPath => - (await desktopApi<{ branches: HermesGitBranch[] }>(`/api/git/branches?path=${q(repoPath)}`)).branches, + (await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches, - repoStatus: repoPath => desktopApi(`/api/git/status?path=${q(repoPath)}`), + repoStatus: repoPath => gitGet('status', { path: repoPath }), fileDiff: async (repoPath, filePath) => - (await desktopApi<{ diff: string }>(`/api/git/file-diff?path=${q(repoPath)}&file=${q(filePath)}`)).diff, + (await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff, review: { list: (repoPath, scope, baseRef) => - desktopApi( - `/api/git/review/list?path=${q(repoPath)}&scope=${q(scope)}${baseRef ? `&base=${q(baseRef)}` : ''}` - ), + gitGet('review/list', { base: baseRef, path: repoPath, scope }), diff: async (repoPath, filePath, scope, baseRef, staged) => - ( - await desktopApi<{ diff: string }>( - `/api/git/review/diff?path=${q(repoPath)}&file=${q(filePath)}&scope=${q(scope)}&staged=${staged ? 'true' : 'false'}${baseRef ? `&base=${q(baseRef)}` : ''}` - ) - ).diff, + (await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged })) + .diff, - stage: (repoPath, filePath) => desktopApi(`/api/git/review/stage`, { file: filePath ?? null, path: repoPath }), + stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }), - unstage: (repoPath, filePath) => desktopApi(`/api/git/review/unstage`, { file: filePath ?? null, path: repoPath }), + unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }), - revert: (repoPath, filePath) => desktopApi(`/api/git/review/revert`, { file: filePath ?? null, path: repoPath }), + revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }), revParse: async (repoPath, ref) => - ( - await desktopApi<{ sha: null | string }>( - `/api/git/review/rev-parse?path=${q(repoPath)}${ref ? `&ref=${q(ref)}` : ''}` - ) - ).sha, + (await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha, - commit: (repoPath, message, push) => desktopApi(`/api/git/review/commit`, { message, path: repoPath, push }), + commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }), - commitContext: repoPath => desktopApi(`/api/git/review/commit-context?path=${q(repoPath)}`), + commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }), - push: repoPath => desktopApi(`/api/git/review/push`, { path: repoPath }), + push: repoPath => gitPost('review/push', { path: repoPath }), - shipInfo: repoPath => desktopApi(`/api/git/review/ship-info?path=${q(repoPath)}`), + shipInfo: repoPath => gitGet('review/ship-info', { path: repoPath }), - createPr: repoPath => desktopApi(`/api/git/review/create-pr`, { path: repoPath }) + createPr: repoPath => gitPost('review/create-pr', { path: repoPath }) }, // Repo discovery is a local-disk crawl; on a remote gateway the backend diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index aea336af31e..e0c8c760a4b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1931,6 +1931,10 @@ async def _git_op(fn, *args): raise HTTPException(status_code=400, detail=str(exc) or "git operation failed") +def _git_path(path: str) -> str: + return str(_fs_path(path)) + + class GitPathBody(BaseModel): path: str @@ -1967,79 +1971,79 @@ class GitBranchSwitchBody(BaseModel): @app.get("/api/git/status") async def git_status_route(path: str): - return await _git_op(_web_git.repo_status, str(_fs_path(path))) + return await _git_op(_web_git.repo_status, _git_path(path)) @app.get("/api/git/worktrees") async def git_worktrees_route(path: str): - return {"worktrees": await _git_op(_web_git.worktree_list, str(_fs_path(path)))} + return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))} @app.get("/api/git/branches") async def git_branches_route(path: str): - return {"branches": await _git_op(_web_git.branch_list, str(_fs_path(path)))} + return {"branches": await _git_op(_web_git.branch_list, _git_path(path))} @app.get("/api/git/review/list") async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None): - return await _git_op(_web_git.review_list, str(_fs_path(path)), scope, base) + return await _git_op(_web_git.review_list, _git_path(path), scope, base) @app.get("/api/git/review/diff") async def git_review_diff_route( path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False ): - return {"diff": await _git_op(_web_git.review_diff, str(_fs_path(path)), file, scope, base, staged)} + return {"diff": await _git_op(_web_git.review_diff, _git_path(path), file, scope, base, staged)} @app.get("/api/git/file-diff") async def git_file_diff_route(path: str, file: str): - return {"diff": await _git_op(_web_git.file_diff_vs_head, str(_fs_path(path)), file)} + return {"diff": await _git_op(_web_git.file_diff_vs_head, _git_path(path), file)} @app.get("/api/git/review/commit-context") async def git_commit_context_route(path: str): - return await _git_op(_web_git.review_commit_context, str(_fs_path(path))) + return await _git_op(_web_git.review_commit_context, _git_path(path)) @app.get("/api/git/review/rev-parse") async def git_rev_parse_route(path: str, ref: Optional[str] = None): - return {"sha": await _git_op(_web_git.review_rev_parse, str(_fs_path(path)), ref)} + return {"sha": await _git_op(_web_git.review_rev_parse, _git_path(path), ref)} @app.get("/api/git/review/ship-info") async def git_ship_info_route(path: str): - return await _git_op(_web_git.review_ship_info, str(_fs_path(path))) + return await _git_op(_web_git.review_ship_info, _git_path(path)) @app.post("/api/git/review/stage") async def git_stage_route(body: GitFileBody): - return await _git_op(_web_git.review_stage, str(_fs_path(body.path)), body.file) + return await _git_op(_web_git.review_stage, _git_path(body.path), body.file) @app.post("/api/git/review/unstage") async def git_unstage_route(body: GitFileBody): - return await _git_op(_web_git.review_unstage, str(_fs_path(body.path)), body.file) + return await _git_op(_web_git.review_unstage, _git_path(body.path), body.file) @app.post("/api/git/review/revert") async def git_revert_route(body: GitFileBody): - return await _git_op(_web_git.review_revert, str(_fs_path(body.path)), body.file) + return await _git_op(_web_git.review_revert, _git_path(body.path), body.file) @app.post("/api/git/review/commit") async def git_commit_route(body: GitCommitBody): - return await _git_op(_web_git.review_commit, str(_fs_path(body.path)), body.message, body.push) + return await _git_op(_web_git.review_commit, _git_path(body.path), body.message, body.push) @app.post("/api/git/review/push") async def git_push_route(body: GitPathBody): - return await _git_op(_web_git.review_push, str(_fs_path(body.path))) + return await _git_op(_web_git.review_push, _git_path(body.path)) @app.post("/api/git/review/create-pr") async def git_create_pr_route(body: GitPathBody): - return await _git_op(_web_git.review_create_pr, str(_fs_path(body.path))) + return await _git_op(_web_git.review_create_pr, _git_path(body.path)) @app.post("/api/git/worktree/add") @@ -2054,19 +2058,19 @@ async def git_worktree_add_route(body: GitWorktreeAddBody): }.items() if value } - return await _git_op(_web_git.worktree_add, str(_fs_path(body.path)), options) + return await _git_op(_web_git.worktree_add, _git_path(body.path), options) @app.post("/api/git/worktree/remove") async def git_worktree_remove_route(body: GitWorktreeRemoveBody): return await _git_op( - _web_git.worktree_remove, str(_fs_path(body.path)), str(_fs_path(body.worktreePath)), body.force + _web_git.worktree_remove, _git_path(body.path), _git_path(body.worktreePath), body.force ) @app.post("/api/git/branch/switch") async def git_branch_switch_route(body: GitBranchSwitchBody): - return await _git_op(_web_git.branch_switch, str(_fs_path(body.path)), body.branch) + return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch) @app.get("/api/status") From 8d8c7111d96d8c9451573e942746f16fb9a555da Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:39:33 -0500 Subject: [PATCH 08/11] refactor(desktop): keep remote fs routing inside the fs facade Let UI callers ask for folders/files without knowing remote-picker limits: selectDesktopPaths now normalizes remote directory selection to a single folder inside the facade. Project creation and composer context picking no longer branch on remote mode; they route through desktop-fs helpers just like git callers route through desktopGit(). Behavior unchanged except remote folder context now works through the same backend picker path. --- .../src/app/chat/hooks/use-composer-actions.ts | 5 ++--- apps/desktop/src/lib/desktop-fs.test.ts | 6 +++--- apps/desktop/src/lib/desktop-fs.ts | 4 ++-- apps/desktop/src/store/projects.test.ts | 3 +-- apps/desktop/src/store/projects.ts | 11 +++++++---- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index f72f9782398..a8afdd12830 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -5,7 +5,7 @@ import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' -import { isDesktopFsRemoteMode, readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs' +import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs' import { addComposerAttachment, type ComposerAttachment, @@ -266,8 +266,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const paths = await selectDesktopPaths({ title: kind === 'file' ? 'Add files as context' : 'Add folders as context', defaultPath: currentCwd || undefined, - directories: kind === 'folder', - multiple: kind === 'folder' && isDesktopFsRemoteMode() ? false : undefined + directories: kind === 'folder' }) if (!paths?.length) { diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 0d5955313c6..0e057aef192 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -132,15 +132,15 @@ describe('desktop filesystem facade', () => { expect(selectPaths).not.toHaveBeenCalled() }) - it('does not treat the remote directory picker as a general file picker', async () => { + it('limits the remote picker to single-directory selection', async () => { const remoteSelect = vi.fn(async () => ['/remote/project']) $connection.set({ mode: 'remote' } as never) setDesktopFsRemotePicker({ selectPaths: remoteSelect }) await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([]) - await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([]) + await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/remote/project']) - expect(remoteSelect).not.toHaveBeenCalled() + expect(remoteSelect).toHaveBeenCalledWith({ directories: true, multiple: false }) expect(selectPaths).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 1451ac81e79..1c0510e54da 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -178,9 +178,9 @@ export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Pr return desktop.selectPaths(options) } - if (!options?.directories || options.multiple !== false) { + if (!options?.directories) { return [] } - return remotePicker ? remotePicker.selectPaths(options) : [] + return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : [] } diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 32749f2b19c..2c2af5f0197 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -68,13 +68,12 @@ describe('pickProjectFolder', () => { vi.clearAllMocks() }) - it('uses the remote-aware directory picker locally (no backend default cwd probe)', async () => { + it('uses the remote-aware directory picker locally', async () => { isDesktopFsRemoteMode.mockReturnValue(false) selectDesktopPaths.mockResolvedValue(['/local/repo']) await expect(pickProjectFolder()).resolves.toBe('/local/repo') expect(selectDesktopPaths).toHaveBeenCalledWith({ defaultPath: undefined, directories: true, multiple: false }) - expect(desktopDefaultCwd).not.toHaveBeenCalled() }) it('seeds the picker with the backend cwd on a remote gateway', async () => { diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index eb993cd400f..a77a991018d 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -2,7 +2,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBranch } from '@/global' -import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' +import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' import { persistentAtom } from '@/lib/persisted' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' @@ -282,7 +282,7 @@ export async function fetchProjectSessions(projectId: string): Promise { - const scan = window.hermesDesktop?.git?.scanRepos + const scan = desktopGit()?.scanRepos if (!scan || (didScanRepos && !force)) { return @@ -738,8 +738,11 @@ export async function copyPath(path: null | string): Promise { // mode opens the native dialog. Returns the absolute path, or null if cancelled. export async function pickProjectFolder(): Promise { try { - const defaultPath = isDesktopFsRemoteMode() ? (await desktopDefaultCwd())?.cwd : undefined - const [dir] = await selectDesktopPaths({ defaultPath, directories: true, multiple: false }) + const [dir] = await selectDesktopPaths({ + defaultPath: (await desktopDefaultCwd())?.cwd, + directories: true, + multiple: false + }) return dir || null } catch { From 19bae1b9e0d32f9ba05830e44ba8c1cdbdb246c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 14:44:28 -0500 Subject: [PATCH 09/11] test(desktop): assert new backend sessions carry workspace cwd Pin the desktop-to-gateway cwd handoff: createBackendSessionForSend must pass the current workspace cwd into session.create so the backend registers the session cwd before the agent/tools run. --- .../src/app/session/hooks/use-session-actions.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index f165504ad5e..8b806d5bdae 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -112,6 +112,7 @@ describe('createBackendSessionForSend profile routing', () => { cleanup() $newChatProfile.set(null) $activeGatewayProfile.set('default') + $currentCwd.set('') vi.restoreAllMocks() }) @@ -145,6 +146,14 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ profile: 'default' }) }) + + it('passes the current workspace cwd into session.create', async () => { + const params = await createWith(() => { + $currentCwd.set('/remote/worktree') + }) + + expect(params).toMatchObject({ cwd: '/remote/worktree' }) + }) }) // ── Resume failure recovery (the "stuck loading session window" bug) ────────── @@ -344,6 +353,7 @@ describe('resumeSession failure recovery', () => { const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-1', 'runtime-stale']]) } satisfies MutableRefObject> + const sessionStateByRuntimeIdRef = { current: new Map([ [ From c7542358f2ba5b4b7ef3f49d9e13f9b2e92c98e7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 16:23:39 -0500 Subject: [PATCH 10/11] fix(desktop): remote project picker UX and profile-scoped fs/git routing Route FS/git REST through the active profile, mount the remote folder picker at app root, keep the project dialog open while picking, show a first-run blank state, flip into grouped view on create, and constrain the picker scroll area so Select stays reachable. --- apps/desktop/src/app/chat/sidebar/index.tsx | 28 +++++++++- .../src/app/chat/sidebar/project-dialog.tsx | 35 +++++++----- apps/desktop/src/app/desktop-controller.tsx | 2 + .../app/right-sidebar/files/remote-picker.tsx | 10 ++-- apps/desktop/src/app/right-sidebar/index.tsx | 3 -- apps/desktop/src/lib/desktop-fs.test.ts | 10 ++++ apps/desktop/src/lib/desktop-fs.ts | 54 +++++++++---------- apps/desktop/src/lib/desktop-git.test.ts | 15 ++++++ apps/desktop/src/lib/desktop-git.ts | 6 ++- apps/desktop/src/store/projects.test.ts | 46 +++++++++++++++- apps/desktop/src/store/projects.ts | 18 +++---- 11 files changed, 163 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 19665341f3d..ca38d65908b 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1149,7 +1149,8 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const showSessionSections = + showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 // Each reorderable list reports its OWN new id order; persisting is a direct, // typed write — no id-prefix sniffing to figure out which level moved. @@ -1537,7 +1538,7 @@ export function ChatSidebar({ )} - {contentVisible && !showSessionSections &&
} + {contentVisible && !showSessionSections && } {contentVisible && (
@@ -1618,6 +1619,29 @@ function SidebarSessionSkeletons() { ) } +function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { + const { t } = useI18n() + const s = t.sidebar + + return ( +
+
+ +

{s.noSessions}

+ +
+
+ ) +} + function SidebarPinnedEmptyState() { const { t } = useI18n() diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx index 16cbb04983d..dcd9f067f43 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx @@ -87,21 +87,25 @@ export function ProjectDialog() { } const pickFolder = async () => { - const dir = await pickProjectFolder() + try { + const dir = await pickProjectFolder() - if (!dir) { - return + if (!dir) { + return + } + + const projectId = state?.projectId + + if (mode === 'add-folder' && projectId) { + await runSubmit(() => addProjectFolder(projectId, dir)) + + return + } + + setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) + } catch (err) { + notifyError(err, p.createFailed) } - - const projectId = state?.projectId - - if (mode === 'add-folder' && projectId) { - await runSubmit(() => addProjectFolder(projectId, dir)) - - return - } - - setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) } const submit = async () => { @@ -145,7 +149,10 @@ export function ProjectDialog() { return ( - + event.preventDefault()} + > {title} {mode === 'create' && {p.createDesc}} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 9df44d628ce..f9dc2d8320b 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -124,6 +124,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay' import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' import { RightSidebarPane } from './right-sidebar' import { FileActionDialogs } from './right-sidebar/file-actions' +import { RemoteFolderPicker } from './right-sidebar/files/remote-picker' import { ReviewPane } from './right-sidebar/review' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' @@ -1127,6 +1128,7 @@ export function DesktopController() { + {settingsOpen && ( diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx index 9f7244693bc..66c24ed40a2 100644 --- a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx @@ -120,14 +120,14 @@ export function RemoteFolderPicker() { return ( !open && close()} open={Boolean(pending)}> - -
+ +
{pending?.title || r.remotePickerTitle} {r.remotePickerDescription}
-
-
+
+
{crumbs.map((crumb, index) => (
-
+
{currentPath}