Files
hermes-agent/apps/desktop/src/lib/desktop-git.ts
T
Teknium f8f43c9523 fix(desktop): make git worktrees work end-to-end on a remote gateway backend
Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the
backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had
drifted behind the Electron-local git ops the same UI drives locally, so
the flows broke exactly and only on remote connections:

- Convert-a-branch: the picker offers remote-tracking refs, and the
  Electron op turns "origin/feature" into a local tracking branch. The
  mirror ran `git worktree add <dir> origin/feature` verbatim, which
  either fails or detaches HEAD. It now resolves the ref's remote via
  git (never assuming "origin"), fetches best-effort, and creates the
  worktree with `--track -b <short-name>`.
- branch_list omitted remote-tracking refs entirely and never set the
  `isRemote` flag the renderer's HermesGitBranch contract requires —
  the convert picker on a remote gateway couldn't reach a teammate's
  branch and mislabeled every row's action.
- Branching off an `origin/…` base silently wired the new branch to the
  remote upstream; the mirror now passes `--no-track` like the Electron
  op does.

Renderer side, replace the silent degradation with a capability gate:
when a remote backend predates the /api/git worktree routes, worktree
creation failed with an opaque "Expected JSON … got HTML" toast. The
route-missing shapes now surface a clear "update the Hermes backend"
message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint
detector); real git errors still pass through untouched.

Sibling audit (documented, no code change needed): repo status / review /
file-diff / git-root / default-cwd already route through desktopGit()'s
REST bridge or /api/fs on remote; repo scan is deliberately a no-op there.
Stale comments claiming "empty/false on a remote backend" in projects.ts
and coding-status.ts updated to describe the backend-routed reality.

Fixes #81724
2026-08-16 20:53:44 -07:00

135 lines
5.2 KiB
TypeScript

import type {
HermesGitBaseBranch,
HermesGitBranch,
HermesGitWorktree,
HermesRepoPullRequests,
HermesRepoStatus,
HermesReviewList,
HermesReviewShipInfo
} from '@/global'
import { desktopFsProfile, 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<NonNullable<Window['hermesDesktop']>['git']>
function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop.api<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
}
function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
query.set(key, String(value))
}
}
return desktopApi<T>(`/api/git/${route}?${query.toString()}`)
}
function gitPost<T>(route: string, body: Record<string, unknown>): Promise<T> {
return desktopApi<T>(`/api/git/${route}`, body)
}
const remoteGit: GitBridge = {
worktreeList: async repoPath =>
(await gitGet<{ worktrees: HermesGitWorktree[] }>('worktrees', { path: repoPath })).worktrees,
worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }),
worktreeRemove: (repoPath, worktreePath, options) =>
gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }),
branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }),
branchList: async repoPath =>
(await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches,
baseBranchList: async repoPath =>
(await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches,
repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }),
fileDiff: async (repoPath, filePath) =>
(await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff,
review: {
list: (repoPath, scope, baseRef) =>
gitGet<HermesReviewList>('review/list', { base: baseRef, path: repoPath, scope }),
diff: async (repoPath, filePath, scope, baseRef, staged) =>
(await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged }))
.diff,
stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }),
unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }),
revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }),
revParse: async (repoPath, ref) =>
(await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha,
commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }),
commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }),
push: repoPath => gitPost('review/push', { path: repoPath }),
shipInfo: repoPath => gitGet<HermesReviewShipInfo>('review/ship-info', { path: repoPath }),
prList: (repoPath, branches, numbers) =>
gitPost<HermesRepoPullRequests>('review/pr-list', { branches, numbers: numbers ?? [], path: repoPath }),
// Remote gateways have no PR-comment route yet; resolve to null so the
// paste degrades to a plain URL instead of throwing mid-paste.
fetchPrComment: async () => null,
createPr: repoPath => gitPost('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 {
if (typeof window === 'undefined') {
return undefined
}
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
}
// True only for "the /api/git route does not exist on this backend" shapes:
// the backend catch-all ('404: {"detail":"No such API endpoint: ...}'), a bare
// FastAPI 404 (directly or through the IPC bridge's "Error invoking remote
// method" wrapper), and the Electron JSON-guard ("endpoint is likely
// missing"). Transient failures (timeouts, 5xx, connection refused) must NOT
// match — they are retryable, not a capability verdict. Mirrors the sidebar
// batch-endpoint detector in hermes.ts.
export function isGitEndpointMissingError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return (
/no such api endpoint/i.test(message) ||
/endpoint is likely missing/i.test(message) ||
/(?:^\s*|error:\s*)404\b/i.test(message)
)
}