f8f43c9523
Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had drifted behind the Electron-local git ops the same UI drives locally, so the flows broke exactly and only on remote connections: - Convert-a-branch: the picker offers remote-tracking refs, and the Electron op turns "origin/feature" into a local tracking branch. The mirror ran `git worktree add <dir> origin/feature` verbatim, which either fails or detaches HEAD. It now resolves the ref's remote via git (never assuming "origin"), fetches best-effort, and creates the worktree with `--track -b <short-name>`. - branch_list omitted remote-tracking refs entirely and never set the `isRemote` flag the renderer's HermesGitBranch contract requires — the convert picker on a remote gateway couldn't reach a teammate's branch and mislabeled every row's action. - Branching off an `origin/…` base silently wired the new branch to the remote upstream; the mirror now passes `--no-track` like the Electron op does. Renderer side, replace the silent degradation with a capability gate: when a remote backend predates the /api/git worktree routes, worktree creation failed with an opaque "Expected JSON … got HTML" toast. The route-missing shapes now surface a clear "update the Hermes backend" message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint detector); real git errors still pass through untouched. Sibling audit (documented, no code change needed): repo status / review / file-diff / git-root / default-cwd already route through desktopGit()'s REST bridge or /api/fs on remote; repo scan is deliberately a no-op there. Stale comments claiming "empty/false on a remote backend" in projects.ts and coding-status.ts updated to describe the backend-routed reality. Fixes #81724
127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
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' }
|
|
}
|
|
|
|
if (path.startsWith('/api/git/branches')) {
|
|
return {
|
|
branches: [{ checkedOut: false, isDefault: false, isRemote: true, name: 'origin/feature', worktreePath: null }]
|
|
}
|
|
}
|
|
|
|
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('returns undefined after the renderer global is torn down', () => {
|
|
vi.stubGlobal('window', undefined)
|
|
|
|
expect(desktopGit()).toBeUndefined()
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
// The ⌘⇧B "convert a branch into a worktree" flow (#81724): on a remote
|
|
// gateway both halves must reach the backend mirror — the picker's branch
|
|
// list (which now carries remote-tracking refs) and the worktree add that
|
|
// receives the picked `origin/…` name.
|
|
it('routes the convert-a-branch worktree flow through the backend on a remote gateway', async () => {
|
|
$connection.set({ mode: 'remote' } as never)
|
|
|
|
await expect(desktopGit()?.branchList('/srv/work')).resolves.toEqual([
|
|
{ checkedOut: false, isDefault: false, isRemote: true, name: 'origin/feature', worktreePath: null }
|
|
])
|
|
expect(api).toHaveBeenCalledWith({ path: '/api/git/branches?path=%2Fsrv%2Fwork' })
|
|
|
|
await desktopGit()?.worktreeAdd('/srv/work', { existingBranch: 'origin/feature' })
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
body: { existingBranch: 'origin/feature', path: '/srv/work' },
|
|
method: 'POST',
|
|
path: '/api/git/worktree/add'
|
|
})
|
|
})
|
|
})
|