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).
This commit is contained in:
Brooklyn Nicholson 2026-06-28 14:26:09 -05:00
parent 304f0650c4
commit fc86e35764
10 changed files with 1206 additions and 16 deletions

View file

@ -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({})

View file

@ -155,16 +155,20 @@ export async function copyTextToClipboard(text: string): Promise<void> {
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<string> {
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<string[]> {

View file

@ -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()
})
})

View file

@ -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<NonNullable<Window['hermesDesktop']>['git']>
const q = (value: string) => encodeURIComponent(value)
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 } : { 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<HermesRepoStatus | null>(`/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<HermesReviewList>(
`/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<HermesReviewShipInfo>(`/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
}

View file

@ -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<void> {
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<void> {
const target = normalizeCwd(cwd ?? $currentCwd.get())
const probe = window.hermesDesktop?.git?.repoStatus
const probe = desktopGit()?.repoStatus
const seq = (repoStatusRefreshSeq += 1)
if (!target || !probe) {

View file

@ -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<null | { path: string; branch: string }> {
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<HermesGitBranch[]> {
const git = window.hermesDesktop?.git
const git = desktopGit()
if (!git?.branchList || !repoPath) {
return []
@ -656,7 +657,7 @@ export async function listRepoBranches(repoPath: string): Promise<HermesGitBranc
}
export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> {
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<void> {
const git = window.hermesDesktop?.git
const git = desktopGit()
if (!git) {
return

View file

@ -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<void> {
}
export async function stageReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.stage(repoCwd() ?? '', path)
await desktopGit()?.review?.stage(repoCwd() ?? '', path)
await afterMutation()
}
export async function unstageReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.unstage(repoCwd() ?? '', path)
await desktopGit()?.review?.unstage(repoCwd() ?? '', path)
await afterMutation()
}
export async function revertReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.revert(repoCwd() ?? '', path)
await desktopGit()?.review?.revert(repoCwd() ?? '', path)
await afterMutation()
}

703
hermes_cli/web_git.py Normal file
View file

@ -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}

View file

@ -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

View file

@ -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