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