mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Merge pull request #54385 from NousResearch/bb/project-folder-picker-remote
feat(desktop): remote-gateway-aware folder picker + git cockpit (status, review, worktrees)
This commit is contained in:
commit
28097d9cd9
19 changed files with 1430 additions and 92 deletions
|
|
@ -5,6 +5,7 @@ import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
|||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
|
|
@ -262,7 +263,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectDesktopPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
|
|
@ -347,7 +348,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const previewUrl = await readDesktopFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
|
|
@ -395,7 +396,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectDesktopPaths({
|
||||
title: copy.attachImages,
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
|
|
|
|||
|
|
@ -1149,7 +1149,8 @@ export function ChatSidebar({
|
|||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
const showSessionSections =
|
||||
showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0
|
||||
|
||||
// Each reorderable list reports its OWN new id order; persisting is a direct,
|
||||
// typed write — no id-prefix sniffing to figure out which level moved.
|
||||
|
|
@ -1537,7 +1538,7 @@ export function ChatSidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
|
|
@ -1618,6 +1619,29 @@ function SidebarSessionSkeletons() {
|
|||
)
|
||||
}
|
||||
|
||||
function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-(--ui-text-quaternary)" name="root-folder" size="1.25rem" />
|
||||
<p className="text-xs text-(--ui-text-tertiary)">{s.noSessions}</p>
|
||||
<Button
|
||||
className="mt-0.5 text-(--ui-text-secondary)"
|
||||
onClick={onNewProject}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{s.projects.newButton}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
|||
|
|
@ -87,21 +87,25 @@ export function ProjectDialog() {
|
|||
}
|
||||
|
||||
const pickFolder = async () => {
|
||||
const dir = await pickProjectFolder()
|
||||
try {
|
||||
const dir = await pickProjectFolder()
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
} catch (err) {
|
||||
notifyError(err, p.createFailed)
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -145,7 +149,10 @@ export function ProjectDialog() {
|
|||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={event => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
|||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { FileActionDialogs } from './right-sidebar/file-actions'
|
||||
import { RemoteFolderPicker } from './right-sidebar/files/remote-picker'
|
||||
import { ReviewPane } from './right-sidebar/review'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
|
|
@ -1127,6 +1128,7 @@ export function DesktopController() {
|
|||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
<RemoteFolderPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
|
|
|||
|
|
@ -120,14 +120,14 @@ export function RemoteFolderPicker() {
|
|||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<div className="border-b border-border/70 px-4 py-3">
|
||||
<DialogContent className="flex h-[min(36rem,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<div className="shrink-0 border-b border-border/70 px-4 py-3">
|
||||
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
@ -166,7 +166,7 @@ export function RemoteFolderPicker() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => close()} size="sm" variant="ghost">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { $currentCwd } from '@/store/session'
|
|||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { RemoteFolderPicker } from './files/remote-picker'
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
|
||||
|
|
@ -82,8 +81,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSide
|
|||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RemoteFolderPicker />
|
||||
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ describe('createBackendSessionForSend profile routing', () => {
|
|||
cleanup()
|
||||
$newChatProfile.set(null)
|
||||
$activeGatewayProfile.set('default')
|
||||
$currentCwd.set('')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
|
@ -145,6 +146,14 @@ describe('createBackendSessionForSend profile routing', () => {
|
|||
|
||||
expect(params).toMatchObject({ profile: 'default' })
|
||||
})
|
||||
|
||||
it('passes the current workspace cwd into session.create', async () => {
|
||||
const params = await createWith(() => {
|
||||
$currentCwd.set('/remote/worktree')
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ cwd: '/remote/worktree' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
|
||||
|
|
@ -344,6 +353,7 @@ describe('resumeSession failure recovery', () => {
|
|||
const runtimeIdByStoredSessionIdRef = {
|
||||
current: new Map([['stored-1', 'runtime-stale']])
|
||||
} satisfies MutableRefObject<Map<string, string>>
|
||||
|
||||
const sessionStateByRuntimeIdRef = {
|
||||
current: new Map([
|
||||
[
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { $connection } from '@/store/session'
|
|||
|
||||
import {
|
||||
desktopDefaultCwd,
|
||||
desktopFileDiff,
|
||||
desktopGitRoot,
|
||||
readDesktopDir,
|
||||
readDesktopFileDataUrl,
|
||||
|
|
@ -39,6 +40,10 @@ const api = vi.fn(async ({ path }: { path: string }) => {
|
|||
return { cwd: '/backend/project', branch: 'main' }
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/git/file-diff?')) {
|
||||
return { diff: 'remote diff' }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
|
|
@ -107,6 +112,23 @@ describe('desktop filesystem facade', () => {
|
|||
expect(gitRoot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('targets the active profile backend so a remote profile never reads local disk', async () => {
|
||||
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
|
||||
|
||||
await readDesktopDir('/srv/project')
|
||||
await desktopDefaultCwd()
|
||||
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fsrv%2Fproject', profile: 'remote-docker' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' })
|
||||
})
|
||||
|
||||
it('routes file diffs through backend git in remote mode', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
|
||||
await expect(desktopFileDiff('/repo', 'src/a b.ts')).resolves.toBe('remote diff')
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/git/file-diff?path=%2Frepo&file=src%2Fa%20b.ts' })
|
||||
})
|
||||
|
||||
it('uses the registered in-app directory picker in remote mode', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
|
|
@ -120,15 +142,15 @@ describe('desktop filesystem facade', () => {
|
|||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat the remote directory picker as a general file picker', async () => {
|
||||
it('limits the remote picker to single-directory selection', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
|
||||
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
|
||||
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/remote/project'])
|
||||
|
||||
expect(remoteSelect).not.toHaveBeenCalled()
|
||||
expect(remoteSelect).toHaveBeenCalledWith({ directories: true, multiple: false })
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export function isDesktopFsRemoteMode() {
|
|||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
// Active profile for FS/git REST calls. Without it the Electron api bridge
|
||||
// hits the primary (local) backend even when the user switched to a remote profile.
|
||||
export function desktopFsProfile(): string | undefined {
|
||||
return $connection.get()?.profile || undefined
|
||||
}
|
||||
|
||||
function fsPath(endpoint: string, filePath: string) {
|
||||
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
|
@ -46,24 +52,26 @@ function bridge() {
|
|||
return desktop
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
const desktop = bridge()
|
||||
function remoteFsApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
|
||||
return bridge().api<T>(
|
||||
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
|
||||
)
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readDir(path)
|
||||
return bridge().readDir(path)
|
||||
}
|
||||
|
||||
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
|
||||
return remoteFsApi<HermesReadDirResult>(fsPath('list', path))
|
||||
}
|
||||
|
||||
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileText(path)
|
||||
return bridge().readFileText(path)
|
||||
}
|
||||
|
||||
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
|
||||
return remoteFsApi<HermesReadFileTextResult>(fsPath('read-text', path))
|
||||
}
|
||||
|
||||
// Save UTF-8 text back to a file. Local writes go through the hardened Electron
|
||||
|
|
@ -81,23 +89,17 @@ export async function writeDesktopFileText(path: string, content: string): Promi
|
|||
return desktop.writeTextFile(path, content)
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ ok?: boolean; path?: string }>({
|
||||
body: { content, path },
|
||||
method: 'POST',
|
||||
path: '/api/fs/write-text'
|
||||
})
|
||||
const result = await remoteFsApi<{ ok?: boolean; path?: string }>('/api/fs/write-text', { content, path })
|
||||
|
||||
return { path: result.path || path }
|
||||
}
|
||||
|
||||
export async function readDesktopFileDataUrl(path: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileDataUrl(path)
|
||||
return bridge().readFileDataUrl(path)
|
||||
}
|
||||
|
||||
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
|
||||
const result = await remoteFsApi<string | { dataUrl?: string }>(fsPath('read-data-url', path))
|
||||
|
||||
return typeof result === 'string' ? result : result.dataUrl || ''
|
||||
}
|
||||
|
|
@ -109,9 +111,7 @@ export async function desktopGitRoot(path: string): Promise<string | null> {
|
|||
return desktop.gitRoot ? desktop.gitRoot(path) : null
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
|
||||
|
||||
return result.root
|
||||
return (await remoteFsApi<{ root: string | null }>(fsPath('git-root', path))).root
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
|
|
@ -119,7 +119,7 @@ export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string
|
|||
return null
|
||||
}
|
||||
|
||||
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
|
||||
return remoteFsApi<{ branch: string; cwd: string }>('/api/fs/default-cwd')
|
||||
}
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only.
|
||||
|
|
@ -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()) {
|
||||
const result = await remoteFsApi<{ diff: string }>(
|
||||
`/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}`
|
||||
)
|
||||
|
||||
if (isDesktopFsRemoteMode() || !desktop.git?.fileDiff) {
|
||||
return ''
|
||||
return result.diff || ''
|
||||
}
|
||||
|
||||
return desktop.git.fileDiff(repoRoot, filePath)
|
||||
const git = bridge().git
|
||||
|
||||
return git?.fileDiff ? git.fileDiff(repoRoot, filePath) : ''
|
||||
}
|
||||
|
||||
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
|
||||
|
|
@ -174,9 +178,9 @@ export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Pr
|
|||
return desktop.selectPaths(options)
|
||||
}
|
||||
|
||||
if (!options?.directories || options.multiple !== false) {
|
||||
if (!options?.directories) {
|
||||
return []
|
||||
}
|
||||
|
||||
return remotePicker ? remotePicker.selectPaths(options) : []
|
||||
return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : []
|
||||
}
|
||||
|
|
|
|||
93
apps/desktop/src/lib/desktop-git.test.ts
Normal file
93
apps/desktop/src/lib/desktop-git.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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('targets the active profile backend so a remote profile never touches the local repo', async () => {
|
||||
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
|
||||
|
||||
await desktopGit()?.repoStatus('/srv/work')
|
||||
await desktopGit()?.review.stage('/srv/work', 'a.txt')
|
||||
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork', profile: 'remote-docker' })
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
body: { file: 'a.txt', path: '/srv/work' },
|
||||
method: 'POST',
|
||||
path: '/api/git/review/stage',
|
||||
profile: 'remote-docker'
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
101
apps/desktop/src/lib/desktop-git.ts
Normal file
101
apps/desktop/src/lib/desktop-git.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type {
|
||||
HermesGitBranch,
|
||||
HermesGitWorktree,
|
||||
HermesRepoStatus,
|
||||
HermesReviewList,
|
||||
HermesReviewShipInfo
|
||||
} from '@/global'
|
||||
|
||||
import { desktopFsProfile, isDesktopFsRemoteMode } from './desktop-fs'
|
||||
|
||||
// Remote-aware git facade. Locally the desktop runs git through Electron
|
||||
// (window.hermesDesktop.git); on a remote gateway that's the wrong filesystem,
|
||||
// so we mirror the same surface over the dashboard REST API (/api/git/*) — the
|
||||
// coding rail, worktree lanes, review pane, and branch ops then act on the
|
||||
// BACKEND repo where sessions actually run. Mirrors desktop-fs.ts.
|
||||
|
||||
type GitBridge = NonNullable<NonNullable<Window['hermesDesktop']>['git']>
|
||||
|
||||
function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
throw new Error('Hermes Desktop bridge is unavailable')
|
||||
}
|
||||
|
||||
return desktop.api<T>(
|
||||
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
|
||||
)
|
||||
}
|
||||
|
||||
function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
query.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
return desktopApi<T>(`/api/git/${route}?${query.toString()}`)
|
||||
}
|
||||
|
||||
function gitPost<T>(route: string, body: Record<string, unknown>): Promise<T> {
|
||||
return desktopApi<T>(`/api/git/${route}`, body)
|
||||
}
|
||||
|
||||
const remoteGit: GitBridge = {
|
||||
worktreeList: async repoPath =>
|
||||
(await gitGet<{ worktrees: HermesGitWorktree[] }>('worktrees', { path: repoPath })).worktrees,
|
||||
|
||||
worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }),
|
||||
|
||||
worktreeRemove: (repoPath, worktreePath, options) =>
|
||||
gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }),
|
||||
|
||||
branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }),
|
||||
|
||||
branchList: async repoPath =>
|
||||
(await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches,
|
||||
|
||||
repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }),
|
||||
|
||||
fileDiff: async (repoPath, filePath) =>
|
||||
(await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff,
|
||||
|
||||
review: {
|
||||
list: (repoPath, scope, baseRef) =>
|
||||
gitGet<HermesReviewList>('review/list', { base: baseRef, path: repoPath, scope }),
|
||||
|
||||
diff: async (repoPath, filePath, scope, baseRef, staged) =>
|
||||
(await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged }))
|
||||
.diff,
|
||||
|
||||
stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }),
|
||||
|
||||
unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }),
|
||||
|
||||
revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }),
|
||||
|
||||
revParse: async (repoPath, ref) =>
|
||||
(await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha,
|
||||
|
||||
commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }),
|
||||
|
||||
commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }),
|
||||
|
||||
push: repoPath => gitPost('review/push', { path: repoPath }),
|
||||
|
||||
shipInfo: repoPath => gitGet<HermesReviewShipInfo>('review/ship-info', { path: repoPath }),
|
||||
|
||||
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
|
||||
},
|
||||
|
||||
// Repo discovery is a local-disk crawl; on a remote gateway the backend
|
||||
// already merges session-derived repos, so this is a no-op.
|
||||
scanRepos: async () => []
|
||||
}
|
||||
|
||||
export function desktopGit(): GitBridge | undefined {
|
||||
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,39 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $sidebarAgentsGrouped } from '@/store/layout'
|
||||
|
||||
import {
|
||||
$activeProjectId,
|
||||
$projectScope,
|
||||
$worktreeRefreshToken,
|
||||
ALL_PROJECTS,
|
||||
createProject,
|
||||
enterProject,
|
||||
exitProjectScope,
|
||||
pickProjectFolder,
|
||||
refreshWorktrees
|
||||
} from './projects'
|
||||
|
||||
vi.mock('@/lib/desktop-fs', () => ({
|
||||
desktopDefaultCwd: vi.fn(),
|
||||
isDesktopFsRemoteMode: vi.fn(),
|
||||
selectDesktopPaths: vi.fn(),
|
||||
writeDesktopFileText: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
activeGateway: vi.fn(),
|
||||
ensureActiveGatewayOpen: vi.fn()
|
||||
}))
|
||||
|
||||
const fs = await import('@/lib/desktop-fs')
|
||||
const desktopDefaultCwd = vi.mocked(fs.desktopDefaultCwd)
|
||||
const isDesktopFsRemoteMode = vi.mocked(fs.isDesktopFsRemoteMode)
|
||||
const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths)
|
||||
|
||||
const gw = await import('@/store/gateway')
|
||||
const activeGateway = vi.mocked(gw.activeGateway)
|
||||
|
||||
describe('project scope', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
|
|
@ -50,3 +75,68 @@ describe('worktree refresh', () => {
|
|||
expect($worktreeRefreshToken.get()).toBe(before + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickProjectFolder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the remote-aware directory picker locally', async () => {
|
||||
isDesktopFsRemoteMode.mockReturnValue(false)
|
||||
selectDesktopPaths.mockResolvedValue(['/local/repo'])
|
||||
|
||||
await expect(pickProjectFolder()).resolves.toBe('/local/repo')
|
||||
expect(selectDesktopPaths).toHaveBeenCalledWith({ defaultPath: undefined, directories: true, multiple: false })
|
||||
})
|
||||
|
||||
it('seeds the picker with the backend cwd on a remote gateway', async () => {
|
||||
isDesktopFsRemoteMode.mockReturnValue(true)
|
||||
desktopDefaultCwd.mockResolvedValue({ branch: 'main', cwd: '/backend/work' })
|
||||
selectDesktopPaths.mockResolvedValue(['/backend/work/repo'])
|
||||
|
||||
await expect(pickProjectFolder()).resolves.toBe('/backend/work/repo')
|
||||
expect(selectDesktopPaths).toHaveBeenCalledWith({
|
||||
defaultPath: '/backend/work',
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the picker is cancelled (empty selection)', async () => {
|
||||
isDesktopFsRemoteMode.mockReturnValue(false)
|
||||
selectDesktopPaths.mockResolvedValue([])
|
||||
|
||||
await expect(pickProjectFolder()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProject', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
$sidebarAgentsGrouped.set(false)
|
||||
$activeProjectId.set(null)
|
||||
})
|
||||
|
||||
it('creates the project and flips into the grouped view so a blank slate shows it', async () => {
|
||||
const created = { folders: [], id: 'p_new', name: 'Demo', primary_path: '/srv/demo' }
|
||||
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === 'projects.create') {
|
||||
return { project: created }
|
||||
}
|
||||
|
||||
// Reconcile (fire-and-forget) re-reads list + tree; echo the project back
|
||||
// so the optimistic state survives instead of being wiped to empty.
|
||||
return { active_id: 'p_new', projects: [created], scoped_session_ids: [] }
|
||||
})
|
||||
|
||||
activeGateway.mockReturnValue({ connectionState: 'open', request } as never)
|
||||
|
||||
const result = await createProject({ folders: ['/srv/demo'], name: 'Demo', use: true })
|
||||
|
||||
expect(result).toEqual(created)
|
||||
expect(request).toHaveBeenCalledWith('projects.create', expect.objectContaining({ name: 'Demo' }))
|
||||
expect($sidebarAgentsGrouped.get()).toBe(true)
|
||||
expect($activeProjectId.get()).toBe('p_new')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { atom } from 'nanostores'
|
|||
|
||||
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } 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'
|
||||
|
|
@ -280,7 +282,7 @@ export async function fetchProjectSessions(projectId: string): Promise<SidebarPr
|
|||
let didScanRepos = false
|
||||
|
||||
export async function scanAndRecordRepos(force = false): Promise<void> {
|
||||
const scan = window.hermesDesktop?.git?.scanRepos
|
||||
const scan = desktopGit()?.scanRepos
|
||||
|
||||
if (!scan || (didScanRepos && !force)) {
|
||||
return
|
||||
|
|
@ -334,20 +336,19 @@ export async function generateProjectIdea(name: string): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
// Write IDEA.md to a project's primary folder (desktop only, best-effort). Local
|
||||
// fs write is hardened in the electron main; a remote backend / missing bridge
|
||||
// just skips it.
|
||||
// Write IDEA.md to a project's primary folder (best-effort). Routes through the
|
||||
// remote-aware fs write, so it lands on the backend for a remote gateway and on
|
||||
// disk locally — the project is created regardless of whether the file lands.
|
||||
async function writeProjectIdea(folder: null | string | undefined, idea: string): Promise<void> {
|
||||
const dir = (folder || '').trim()
|
||||
const body = idea.trim()
|
||||
const write = window.hermesDesktop?.writeTextFile
|
||||
|
||||
if (!dir || !body || !write) {
|
||||
if (!dir || !body) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await write(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`)
|
||||
await writeDesktopFileText(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`)
|
||||
} catch {
|
||||
// Best-effort: the project is created regardless of whether IDEA.md lands.
|
||||
}
|
||||
|
|
@ -443,6 +444,8 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectI
|
|||
if (input.use) {
|
||||
$activeProjectId.set(created.id)
|
||||
}
|
||||
|
||||
setSidebarAgentsGrouped(true)
|
||||
}
|
||||
|
||||
reconcileProjects()
|
||||
|
|
@ -630,7 +633,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
|
||||
|
|
@ -645,7 +648,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 []
|
||||
|
|
@ -655,7 +658,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
|
||||
|
|
@ -708,7 +711,7 @@ export async function removeWorktreePath(
|
|||
worktreePath: string,
|
||||
options?: { force?: boolean }
|
||||
): Promise<void> {
|
||||
const git = window.hermesDesktop?.git
|
||||
const git = desktopGit()
|
||||
|
||||
if (!git) {
|
||||
return
|
||||
|
|
@ -732,20 +735,15 @@ export async function copyPath(path: null | string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Open the native directory picker (reuses the Electron default-project-dir
|
||||
// chooser). Returns the chosen absolute path, or null when cancelled.
|
||||
// Pick a project folder via the remote-aware picker: a remote gateway browses
|
||||
// the backend filesystem (seeded at its default cwd) where sessions run; local
|
||||
// mode opens the native dialog. Returns the absolute path, or null if cancelled.
|
||||
export async function pickProjectFolder(): Promise<null | string> {
|
||||
const pick = window.hermesDesktop?.settings?.pickDefaultProjectDir
|
||||
const [dir] = await selectDesktopPaths({
|
||||
defaultPath: (await desktopDefaultCwd())?.cwd,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (!pick) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pick()
|
||||
|
||||
return result.canceled ? null : result.dir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return dir || null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
646
hermes_cli/web_git.py
Normal file
646
hermes_cli/web_git.py
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
"""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 re
|
||||
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 _walk_entries(raw: str):
|
||||
"""Yield (tag, xy, path) per changed file from ``git status --porcelain=v2 -z``,
|
||||
skipping branch headers and the rename/copy origin-path records. One walker
|
||||
feeds the rail, the review list, and the commit flow."""
|
||||
records = raw.split("\0")
|
||||
i = 0
|
||||
while i < len(records):
|
||||
rec = records[i]
|
||||
tag = rec[0] if rec else ""
|
||||
if tag == "?":
|
||||
yield "?", "??", rec[2:]
|
||||
elif tag == "u":
|
||||
yield "u", rec.split(" ")[1], rec.split(" ", 10)[-1]
|
||||
elif 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 # rename/copy: the origin path is the next NUL record
|
||||
yield tag, xy, resolve_rename_path(path)
|
||||
i += 1
|
||||
|
||||
|
||||
def _entry_staged(tag: str, xy: str) -> bool:
|
||||
"""A tracked entry whose index (staged) code is set."""
|
||||
return tag in ("1", "2") and xy[0] not in (".", "?")
|
||||
|
||||
|
||||
def _classify(tag: str, xy: str, path: str) -> dict:
|
||||
y = xy[1] if len(xy) > 1 else "."
|
||||
return {
|
||||
"path": path,
|
||||
"staged": _entry_staged(tag, xy),
|
||||
"unstaged": tag == "?" or (tag in ("1", "2") and y not in (".", "?")),
|
||||
"untracked": tag == "?",
|
||||
"conflicted": tag == "u",
|
||||
}
|
||||
|
||||
|
||||
def _status_letter(tag: str, xy: str) -> str:
|
||||
if tag in ("?", "u"):
|
||||
return tag.upper() if tag == "u" else "?"
|
||||
code = xy[0] if xy[0] != "." else (xy[1] if len(xy) > 1 else ".")
|
||||
return (code if code != "." else "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
|
||||
|
||||
code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "--branch", "-z"])
|
||||
if code != 0:
|
||||
return None
|
||||
|
||||
branch: str | None = None
|
||||
detached = False
|
||||
ahead = behind = 0
|
||||
for rec in raw.split("\0"):
|
||||
if rec.startswith("# branch.head "):
|
||||
head = rec[len("# branch.head ") :]
|
||||
detached = head == "(detached)"
|
||||
branch = None if detached else head
|
||||
elif rec.startswith("# branch.ab "):
|
||||
for tok in rec.split()[2:]:
|
||||
if tok.startswith("+"):
|
||||
ahead = int(tok[1:] or 0)
|
||||
elif tok.startswith("-"):
|
||||
behind = int(tok[1:] or 0)
|
||||
|
||||
files = [_classify(tag, xy, path) for tag, xy, path in _walk_entries(raw)]
|
||||
|
||||
# +/- vs HEAD (tracked), then fold in untracked insertions — `git diff HEAD`
|
||||
# ignores them, so a new-file-only turn would otherwise read +0 (bounded scan).
|
||||
added = removed = 0
|
||||
for a, r in _numstat(cwd, ["HEAD"]).values():
|
||||
added += a
|
||||
removed += r
|
||||
added += sum(_untracked_insertions(cwd, f["path"]) for f in files[:_UNTRACKED_SCAN_CAP] if f["untracked"])
|
||||
|
||||
return {
|
||||
"branch": branch,
|
||||
"defaultBranch": _default_branch_name(cwd),
|
||||
"detached": detached,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"staged": sum(f["staged"] for f in files),
|
||||
"unstaged": sum(f["unstaged"] for f in files),
|
||||
"untracked": sum(f["untracked"] for f in files),
|
||||
"conflicted": sum(f["conflicted"] for f in files),
|
||||
"changed": len(files),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"files": 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
|
||||
files = [
|
||||
{"path": path, "added": a, "removed": r, "status": "M", "staged": False}
|
||||
for path, (a, r) in _numstat(cwd, [rng]).items()
|
||||
]
|
||||
if scope == "lastTurn":
|
||||
seen = {f["path"] for f in files}
|
||||
_, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"])
|
||||
files += [
|
||||
{"path": path, "added": 0, "removed": 0, "status": "?", "staged": False}
|
||||
for tag, _xy, path in _walk_entries(raw)
|
||||
if tag == "?" and path not in seen
|
||||
]
|
||||
files.sort(key=lambda f: f["path"])
|
||||
_fill_untracked_counts(cwd, files)
|
||||
return {"files": files, "base": base}
|
||||
|
||||
code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"])
|
||||
if code != 0:
|
||||
return {"files": [], "base": None}
|
||||
staged = _numstat(cwd, ["--cached"])
|
||||
unstaged = _numstat(cwd, [])
|
||||
|
||||
files = []
|
||||
for tag, xy, path in _walk_entries(raw):
|
||||
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": _status_letter(tag, xy),
|
||||
"staged": _entry_staged(tag, xy),
|
||||
}
|
||||
)
|
||||
files.sort(key=lambda f: f["path"])
|
||||
_fill_untracked_counts(cwd, files)
|
||||
return {"files": files, "base": None}
|
||||
|
||||
|
||||
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."""
|
||||
_, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"])
|
||||
if not any(_entry_staged(tag, xy) for tag, xy, _ in _walk_entries(raw)):
|
||||
_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": ""}
|
||||
code, raw, _ = _git(cwd, ["status", "--porcelain=v2", "-z"])
|
||||
if code != 0:
|
||||
return {"diff": "", "recent": ""}
|
||||
entries = list(_walk_entries(raw))
|
||||
|
||||
has_staged = any(_entry_staged(tag, xy) for tag, xy, _ in entries)
|
||||
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 = [path for tag, _xy, path in entries if tag == "?"]
|
||||
if untracked:
|
||||
visible = untracked[:_COMMIT_CONTEXT_UNTRACKED_MAX]
|
||||
note = "\n# New (untracked) files:\n" + "".join(f"# {p}\n" for p in visible)
|
||||
if len(untracked) > len(visible):
|
||||
note += f"# ... {len(untracked) - len(visible)} more omitted\n"
|
||||
diff = f"{diff}{note}" if diff else note
|
||||
|
||||
return {"diff": diff or "", "recent": _git_out(cwd, ["log", "-n", "10", "--pretty=format:%s"]).strip()}
|
||||
|
||||
|
||||
# ── 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:
|
||||
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:
|
||||
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}
|
||||
|
|
@ -1910,6 +1910,169 @@ 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")
|
||||
|
||||
|
||||
def _git_path(path: str) -> str:
|
||||
return str(_fs_path(path))
|
||||
|
||||
|
||||
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, _git_path(path))
|
||||
|
||||
|
||||
@app.get("/api/git/worktrees")
|
||||
async def git_worktrees_route(path: str):
|
||||
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, _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, _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, _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, _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, _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, _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, _git_path(path))
|
||||
|
||||
|
||||
@app.post("/api/git/review/stage")
|
||||
async def git_stage_route(body: GitFileBody):
|
||||
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, _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, _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, _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, _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, _git_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, _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, _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, _git_path(body.path), body.branch)
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status(profile: Optional[str] = None):
|
||||
status_scope = None
|
||||
|
|
|
|||
177
tests/hermes_cli/test_web_server_git.py
Normal file
177
tests/hermes_cli/test_web_server_git.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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"] == body["defaultBranch"]
|
||||
assert body["branch"]
|
||||
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_commit_with_nothing_staged_commits_all_changes(client, repo):
|
||||
assert client.post(
|
||||
"/api/git/review/commit", json={"path": str(repo), "message": "commit all", "push": False}
|
||||
).json() == {"ok": True}
|
||||
|
||||
assert client.get("/api/git/status", params={"path": str(repo)}).json()["changed"] == 0
|
||||
|
||||
|
||||
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_worktree_add_initializes_plain_folder(client, tmp_path):
|
||||
folder = tmp_path / "plain-project"
|
||||
folder.mkdir()
|
||||
(folder / "notes.txt").write_text("not committed\n")
|
||||
|
||||
added = client.post(
|
||||
"/api/git/worktree/add", json={"path": str(folder), "branch": "feature/plain"}
|
||||
).json()
|
||||
|
||||
assert added["branch"] == "feature/plain"
|
||||
assert Path(added["path"]).is_dir()
|
||||
assert (folder / ".git").exists()
|
||||
_git(folder, "rev-parse", "--verify", "HEAD")
|
||||
|
||||
status = client.get("/api/git/status", params={"path": str(folder)}).json()
|
||||
assert status["branch"] == status["defaultBranch"]
|
||||
assert status["branch"]
|
||||
# Existing files are not silently committed by repo initialization.
|
||||
assert any(file["path"] == "notes.txt" and file["untracked"] for file in status["files"])
|
||||
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue