refactor(desktop): centralize remote git REST routing

Keep the remote git mirror as a thin facade: route all GETs through gitGet,
all mutations through gitPost, and keep consumers on desktopGit(). On the
backend, route git paths through a single _git_path helper instead of repeating
str(_fs_path(...)) in every endpoint. Behavior unchanged.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 14:37:36 -05:00
parent 4e9439cc3b
commit 453f134b3b
2 changed files with 57 additions and 48 deletions

View file

@ -16,8 +16,6 @@ import { isDesktopFsRemoteMode } from './desktop-fs'
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
@ -28,60 +26,67 @@ function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T>
return desktop.api<T>(body ? { body, method: 'POST', path } : { path })
}
function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
query.set(key, String(value))
}
}
return desktopApi<T>(`/api/git/${route}?${query.toString()}`)
}
function gitPost<T>(route: string, body: Record<string, unknown>): Promise<T> {
return desktopApi<T>(`/api/git/${route}`, body)
}
const remoteGit: GitBridge = {
worktreeList: async repoPath =>
(await desktopApi<{ worktrees: HermesGitWorktree[] }>(`/api/git/worktrees?path=${q(repoPath)}`)).worktrees,
(await gitGet<{ worktrees: HermesGitWorktree[] }>('worktrees', { path: repoPath })).worktrees,
worktreeAdd: (repoPath, options) => desktopApi(`/api/git/worktree/add`, { path: repoPath, ...options }),
worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }),
worktreeRemove: (repoPath, worktreePath, options) =>
desktopApi(`/api/git/worktree/remove`, { force: options?.force ?? false, path: repoPath, worktreePath }),
gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }),
branchSwitch: (repoPath, branch) => desktopApi(`/api/git/branch/switch`, { branch, path: repoPath }),
branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }),
branchList: async repoPath =>
(await desktopApi<{ branches: HermesGitBranch[] }>(`/api/git/branches?path=${q(repoPath)}`)).branches,
(await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches,
repoStatus: repoPath => desktopApi<HermesRepoStatus | null>(`/api/git/status?path=${q(repoPath)}`),
repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }),
fileDiff: async (repoPath, filePath) =>
(await desktopApi<{ diff: string }>(`/api/git/file-diff?path=${q(repoPath)}&file=${q(filePath)}`)).diff,
(await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff,
review: {
list: (repoPath, scope, baseRef) =>
desktopApi<HermesReviewList>(
`/api/git/review/list?path=${q(repoPath)}&scope=${q(scope)}${baseRef ? `&base=${q(baseRef)}` : ''}`
),
gitGet<HermesReviewList>('review/list', { base: baseRef, path: repoPath, scope }),
diff: async (repoPath, filePath, scope, baseRef, staged) =>
(
await desktopApi<{ diff: string }>(
`/api/git/review/diff?path=${q(repoPath)}&file=${q(filePath)}&scope=${q(scope)}&staged=${staged ? 'true' : 'false'}${baseRef ? `&base=${q(baseRef)}` : ''}`
)
).diff,
(await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged }))
.diff,
stage: (repoPath, filePath) => desktopApi(`/api/git/review/stage`, { file: filePath ?? null, path: repoPath }),
stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }),
unstage: (repoPath, filePath) => desktopApi(`/api/git/review/unstage`, { file: filePath ?? null, path: repoPath }),
unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }),
revert: (repoPath, filePath) => desktopApi(`/api/git/review/revert`, { file: filePath ?? null, path: repoPath }),
revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }),
revParse: async (repoPath, ref) =>
(
await desktopApi<{ sha: null | string }>(
`/api/git/review/rev-parse?path=${q(repoPath)}${ref ? `&ref=${q(ref)}` : ''}`
)
).sha,
(await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha,
commit: (repoPath, message, push) => desktopApi(`/api/git/review/commit`, { message, path: repoPath, push }),
commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }),
commitContext: repoPath => desktopApi(`/api/git/review/commit-context?path=${q(repoPath)}`),
commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }),
push: repoPath => desktopApi(`/api/git/review/push`, { path: repoPath }),
push: repoPath => gitPost('review/push', { path: repoPath }),
shipInfo: repoPath => desktopApi<HermesReviewShipInfo>(`/api/git/review/ship-info?path=${q(repoPath)}`),
shipInfo: repoPath => gitGet<HermesReviewShipInfo>('review/ship-info', { path: repoPath }),
createPr: repoPath => desktopApi(`/api/git/review/create-pr`, { path: repoPath })
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
},
// Repo discovery is a local-disk crawl; on a remote gateway the backend

View file

@ -1931,6 +1931,10 @@ async def _git_op(fn, *args):
raise HTTPException(status_code=400, detail=str(exc) or "git operation failed")
def _git_path(path: str) -> str:
return str(_fs_path(path))
class GitPathBody(BaseModel):
path: str
@ -1967,79 +1971,79 @@ class GitBranchSwitchBody(BaseModel):
@app.get("/api/git/status")
async def git_status_route(path: str):
return await _git_op(_web_git.repo_status, str(_fs_path(path)))
return await _git_op(_web_git.repo_status, _git_path(path))
@app.get("/api/git/worktrees")
async def git_worktrees_route(path: str):
return {"worktrees": await _git_op(_web_git.worktree_list, str(_fs_path(path)))}
return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))}
@app.get("/api/git/branches")
async def git_branches_route(path: str):
return {"branches": await _git_op(_web_git.branch_list, str(_fs_path(path)))}
return {"branches": await _git_op(_web_git.branch_list, _git_path(path))}
@app.get("/api/git/review/list")
async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None):
return await _git_op(_web_git.review_list, str(_fs_path(path)), scope, base)
return await _git_op(_web_git.review_list, _git_path(path), scope, base)
@app.get("/api/git/review/diff")
async def git_review_diff_route(
path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False
):
return {"diff": await _git_op(_web_git.review_diff, str(_fs_path(path)), file, scope, base, staged)}
return {"diff": await _git_op(_web_git.review_diff, _git_path(path), file, scope, base, staged)}
@app.get("/api/git/file-diff")
async def git_file_diff_route(path: str, file: str):
return {"diff": await _git_op(_web_git.file_diff_vs_head, str(_fs_path(path)), file)}
return {"diff": await _git_op(_web_git.file_diff_vs_head, _git_path(path), file)}
@app.get("/api/git/review/commit-context")
async def git_commit_context_route(path: str):
return await _git_op(_web_git.review_commit_context, str(_fs_path(path)))
return await _git_op(_web_git.review_commit_context, _git_path(path))
@app.get("/api/git/review/rev-parse")
async def git_rev_parse_route(path: str, ref: Optional[str] = None):
return {"sha": await _git_op(_web_git.review_rev_parse, str(_fs_path(path)), ref)}
return {"sha": await _git_op(_web_git.review_rev_parse, _git_path(path), ref)}
@app.get("/api/git/review/ship-info")
async def git_ship_info_route(path: str):
return await _git_op(_web_git.review_ship_info, str(_fs_path(path)))
return await _git_op(_web_git.review_ship_info, _git_path(path))
@app.post("/api/git/review/stage")
async def git_stage_route(body: GitFileBody):
return await _git_op(_web_git.review_stage, str(_fs_path(body.path)), body.file)
return await _git_op(_web_git.review_stage, _git_path(body.path), body.file)
@app.post("/api/git/review/unstage")
async def git_unstage_route(body: GitFileBody):
return await _git_op(_web_git.review_unstage, str(_fs_path(body.path)), body.file)
return await _git_op(_web_git.review_unstage, _git_path(body.path), body.file)
@app.post("/api/git/review/revert")
async def git_revert_route(body: GitFileBody):
return await _git_op(_web_git.review_revert, str(_fs_path(body.path)), body.file)
return await _git_op(_web_git.review_revert, _git_path(body.path), body.file)
@app.post("/api/git/review/commit")
async def git_commit_route(body: GitCommitBody):
return await _git_op(_web_git.review_commit, str(_fs_path(body.path)), body.message, body.push)
return await _git_op(_web_git.review_commit, _git_path(body.path), body.message, body.push)
@app.post("/api/git/review/push")
async def git_push_route(body: GitPathBody):
return await _git_op(_web_git.review_push, str(_fs_path(body.path)))
return await _git_op(_web_git.review_push, _git_path(body.path))
@app.post("/api/git/review/create-pr")
async def git_create_pr_route(body: GitPathBody):
return await _git_op(_web_git.review_create_pr, str(_fs_path(body.path)))
return await _git_op(_web_git.review_create_pr, _git_path(body.path))
@app.post("/api/git/worktree/add")
@ -2054,19 +2058,19 @@ async def git_worktree_add_route(body: GitWorktreeAddBody):
}.items()
if value
}
return await _git_op(_web_git.worktree_add, str(_fs_path(body.path)), options)
return await _git_op(_web_git.worktree_add, _git_path(body.path), options)
@app.post("/api/git/worktree/remove")
async def git_worktree_remove_route(body: GitWorktreeRemoveBody):
return await _git_op(
_web_git.worktree_remove, str(_fs_path(body.path)), str(_fs_path(body.worktreePath)), body.force
_web_git.worktree_remove, _git_path(body.path), _git_path(body.worktreePath), body.force
)
@app.post("/api/git/branch/switch")
async def git_branch_switch_route(body: GitBranchSwitchBody):
return await _git_op(_web_git.branch_switch, str(_fs_path(body.path)), body.branch)
return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch)
@app.get("/api/status")