hermes-agent/apps/desktop/src/store/coding-status.ts
Brooklyn Nicholson fc86e35764 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).
2026-06-28 14:26:09 -05:00

166 lines
5.7 KiB
TypeScript

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'
import { $workspaceChangeTick } from './workspace-events'
// Live working-tree status for the active session's cwd — the data backbone of
// the composer coding rail. It's the same "cheaply re-read git truth at the
// right moments" model as the sidebar worktree probe: a single bounded
// `git status --porcelain=v2` per refresh, driven by structural edges (cwd
// change, turn settle, window focus, worktree mutation), never per-token and
// never touching the conversation/system-prompt cache.
export const $repoStatus = atom<HermesRepoStatus | null>(null)
export const $repoStatusLoading = atom(false)
// The repo's real worktrees (for the coding rail's "jump to a worktree" menu).
// Refreshed on the same edges as the status probe; empty off a repo.
export const $repoWorktrees = atom<HermesGitWorktree[]>([])
const REPO_STATUS_REFRESH_DEBOUNCE_MS = 100
export type RepoChangeKind = 'added' | 'conflicted' | 'modified'
// Absolute file path → its git change kind, for VS Code-style file-tree tinting.
// Reuses the same bounded $repoStatus probe (capped file list); git reports
// repo-root-relative paths, so we join them onto the active cwd. Deletions never
// appear — the file is gone from disk, so there's no tree row to tint.
export const $repoChangeByPath = computed([$repoStatus, $currentCwd], (status, cwd) => {
const map = new Map<string, RepoChangeKind>()
const root = (cwd || '').replace(/[/\\]+$/, '')
if (!status || !root) {
return map
}
for (const file of status.files) {
const kind: RepoChangeKind = file.conflicted ? 'conflicted' : file.untracked ? 'added' : 'modified'
map.set(`${root}/${file.path}`, kind)
}
return map
})
async function loadWorktrees(target: string): Promise<void> {
const list = desktopGit()?.worktreeList
if (!list) {
$repoWorktrees.set([])
return
}
try {
const worktrees = await list(target)
if (inflightCwd === target) {
$repoWorktrees.set(worktrees)
}
} catch {
if (inflightCwd === target) {
$repoWorktrees.set([])
}
}
}
// Coalesce overlapping probes: many triggers can fire around a turn boundary
// (busy flip + worktree token + focus), but only the latest cwd matters.
let inflightCwd: null | string = null
let repoStatusRefreshSeq = 0
let repoStatusRefreshTimer: ReturnType<typeof setTimeout> | null = null
const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null
/**
* Re-probe the working tree for `cwd` (defaults to the active session's cwd).
* Best-effort: a non-repo, a remote backend, or a missing probe clears the
* status so the rail hides rather than showing stale data.
*/
export async function refreshRepoStatus(cwd?: null | string): Promise<void> {
const target = normalizeCwd(cwd ?? $currentCwd.get())
const probe = desktopGit()?.repoStatus
const seq = (repoStatusRefreshSeq += 1)
if (!target || !probe) {
$repoStatus.set(null)
$repoWorktrees.set([])
return
}
inflightCwd = target
$repoStatusLoading.set(true)
try {
const status = await probe(target)
// Drop the result if the cwd moved on while we were probing (a fast session
// switch) — the newer probe owns the atom.
if (seq === repoStatusRefreshSeq && inflightCwd === target) {
$repoStatus.set(status)
// Worktrees only matter inside a repo; clear them otherwise.
if (status) {
void loadWorktrees(target)
} else {
$repoWorktrees.set([])
}
}
} catch {
if (seq === repoStatusRefreshSeq && inflightCwd === target) {
$repoStatus.set(null)
$repoWorktrees.set([])
}
} finally {
if (seq === repoStatusRefreshSeq && inflightCwd === target) {
$repoStatusLoading.set(false)
}
}
}
function scheduleRepoStatusRefresh(cwd?: null | string): void {
if (repoStatusRefreshTimer) {
clearTimeout(repoStatusRefreshTimer)
}
repoStatusRefreshTimer = setTimeout(() => {
repoStatusRefreshTimer = null
void refreshRepoStatus(cwd)
}, REPO_STATUS_REFRESH_DEBOUNCE_MS)
}
// ── Triggers ─────────────────────────────────────────────────────────────────
// Wired once at module load (mirrors projects.ts's module-scope subscriptions).
// Each is a structural edge where the working tree may have changed under us.
// The active session's cwd changed (session switch / new chat) → re-probe.
$currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd))
// A worktree add/remove (desktop op, or the agent's out-of-band git in a settled
// turn / a window refocus — both already bump this token) → re-probe.
$worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh())
// A file-mutating tool finished (event-driven, not polled) → re-probe so the
// rail's branch/+/- move exactly when the agent touches the tree.
$workspaceChangeTick.subscribe(() => scheduleRepoStatusRefresh())
// A turn settling is the backstop for changes no tool diff announced (e.g. a
// raw `git` in the terminal): one final refresh when the agent goes idle.
let prevBusy = $busy.get()
$busy.subscribe(busy => {
if (prevBusy && !busy) {
scheduleRepoStatusRefresh()
}
prevBusy = busy
})
// External changes while the window was away (an outside terminal) — refresh on
// refocus, the git-GUI standard.
if (typeof window !== 'undefined') {
window.addEventListener('focus', () => scheduleRepoStatusRefresh())
}