mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(desktop): base-branch picker for new worktree dialog
The sidebar "New worktree" button branched off whatever HEAD you were on — now a filterable Popover+Command combobox lets you pick any local or remote-tracking branch as the base, defaulting to origin/HEAD. Backend listBaseBranches() queries refs/heads + refs/remotes via for-each-ref, flags origin/HEAD (falling back to the local default for no-remote repos). Mirrored on the Python REST API side (base_branch_list + /api/git/base-branches route).
This commit is contained in:
parent
2627933f33
commit
6f7ee72be5
16 changed files with 390 additions and 19 deletions
|
|
@ -8,6 +8,7 @@ import test from 'node:test'
|
|||
import {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
|
|
@ -210,3 +211,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work
|
|||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: lists local branches and flags the default', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
const branches = await listBaseBranches(dir, 'git')
|
||||
const names = branches.map(b => b.name).sort()
|
||||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: empty on a non-repo path', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-'))
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listBaseBranches(dir, 'git'), [])
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base param branches off a specified local branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -337,9 +337,56 @@ async function switchBranch(repoPath, branch, gitBin) {
|
|||
return { branch: target }
|
||||
}
|
||||
|
||||
// Branches the new worktree can be based on: local heads + remote-tracking
|
||||
// refs. Listed most-recently-committed first; the remote's default branch
|
||||
// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo /
|
||||
// remote backend where the probe can't run.
|
||||
async function listBaseBranches(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [name] = line.split('\t')
|
||||
|
||||
return {
|
||||
name,
|
||||
isRemote: name.startsWith('origin/'),
|
||||
// origin/HEAD when a remote exists; otherwise the local default
|
||||
// (main/master/init.defaultBranch) so a no-remote repo still flags
|
||||
// its trunk.
|
||||
isDefault: Boolean(
|
||||
(remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault)
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ import {
|
|||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
|
|
@ -8527,6 +8527,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
|||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
||||
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import type { HermesGitBaseBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { listBaseBranches } from '@/store/projects'
|
||||
|
||||
// Filterable combobox for picking the base branch of a new worktree. Lists
|
||||
// local + remote-tracking branches, defaults to the default branch
|
||||
// (origin/HEAD, or local main/master when no remote). The current session's
|
||||
// branch is sorted to the top so it's one click away. The parent owns the
|
||||
// selected value via `value` / `onValueChange`.
|
||||
export function BaseBranchPicker({
|
||||
disabled,
|
||||
repoPath,
|
||||
onValueChange,
|
||||
value
|
||||
}: {
|
||||
disabled?: boolean
|
||||
repoPath: string
|
||||
onValueChange: (value: string) => void
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const repoStatus = useStore($repoStatus)
|
||||
const [branches, setBranches] = useState<HermesGitBaseBranch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const list = await listBaseBranches(repoPath)
|
||||
setBranches(list)
|
||||
|
||||
// Default to the remote default (origin/HEAD). Fall back to the local
|
||||
// default branch (main/master) when no remote exists. The value is
|
||||
// always a concrete branch — never undefined.
|
||||
const defaultBranch = list.find(b => b.isDefault)
|
||||
|
||||
if (defaultBranch) {
|
||||
onValueChange(defaultBranch.name)
|
||||
} else {
|
||||
onValueChange(list[0]?.name ?? '')
|
||||
}
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, onValueChange])
|
||||
|
||||
// Load on mount so the default branch fills in before the user opens the
|
||||
// popover — otherwise the button reads "branch off " with nothing after it.
|
||||
useEffect(() => {
|
||||
if (branches.length === 0 && !loading) {
|
||||
void load()
|
||||
}
|
||||
}, [branches.length, loading, load])
|
||||
|
||||
// Pin the current session's branch to the top, keep the rest in git's
|
||||
// most-recently-committed order.
|
||||
const sorted = useMemo(() => {
|
||||
if (!currentBranch) {
|
||||
return branches
|
||||
}
|
||||
|
||||
const idx = branches.findIndex(b => b.name === currentBranch)
|
||||
|
||||
if (idx <= 0) {
|
||||
return branches
|
||||
}
|
||||
|
||||
return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)]
|
||||
}, [branches, currentBranch])
|
||||
|
||||
// The i18n function returns { before, after } so the branch name can be
|
||||
// wrapped in its own styled (underlined) span — works for any word order.
|
||||
const parts = p.branchOff()
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
if (next && branches.length === 0 && !loading) {
|
||||
void load()
|
||||
}
|
||||
|
||||
setOpen(next)
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="group w-full flex justify-start items-center min-w-0 gap-1.5 hover:no-underline hover:text-muted-foreground"
|
||||
disabled={disabled || loading}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="shrink-0">{parts.before}</span>
|
||||
<span className="shrink-0 text-primary underline-offset-4 decoration-current/20 group-hover:underline">
|
||||
{loading ? '...' : value}
|
||||
</span>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.75rem" />
|
||||
<span className="shrink-0">{parts.after}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0">
|
||||
<Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
|
||||
<CommandInput autoFocus placeholder={p.baseBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{p.baseBranchNone}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sorted.map(branch => (
|
||||
<CommandItem
|
||||
key={branch.name}
|
||||
onSelect={() => {
|
||||
onValueChange(branch.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
value={branch.name}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-1.5">
|
||||
<Codicon
|
||||
className="shrink-0 text-(--ui-text-tertiary)"
|
||||
name={branch.isRemote ? 'repo' : 'git-branch'}
|
||||
size="0.8rem"
|
||||
/>
|
||||
{branch.isDefault && (
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">★</span>
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
{value === branch.name && (
|
||||
<Codicon className="ml-auto shrink-0 text-(--ui-accent)" name="check" size="0.8rem" />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn
|
|||
|
||||
import { SidebarCount, SidebarRowLead } from '../chrome'
|
||||
|
||||
import { BaseBranchPicker } from './base-branch-picker'
|
||||
|
||||
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
|
||||
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
|
||||
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
|
||||
|
|
@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
|
|||
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
|
||||
// for that branch under the repo (the lightest way) and we open a new session
|
||||
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
|
||||
// The base branch defaults to the remote default (origin/HEAD); the user can
|
||||
// pick any local or remote-tracking branch via a filterable combobox.
|
||||
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
|
@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
const [selectedBase, setSelectedBase] = useState('')
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
|
|
@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
try {
|
||||
// Pass the typed value as both the dir slug source and the branch, so the
|
||||
// branch is exactly what the user named (the dir is slugified git-side).
|
||||
const result = await startWorkInRepo(repoPath, { branch, name: branch })
|
||||
const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch })
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
|
|
@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
onClick={() => {
|
||||
setConvertMode(false)
|
||||
setName('')
|
||||
setSelectedBase('')
|
||||
setOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
|
|
@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<BaseBranchPicker
|
||||
disabled={pending}
|
||||
onValueChange={setSelectedBase}
|
||||
repoPath={repoPath}
|
||||
value={selectedBase}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
|
|
|
|||
14
apps/desktop/src/global.d.ts
vendored
14
apps/desktop/src/global.d.ts
vendored
|
|
@ -130,6 +130,10 @@ declare global {
|
|||
branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }>
|
||||
// Local branches for the "convert a branch into a worktree" picker.
|
||||
branchList: (repoPath: string) => Promise<HermesGitBranch[]>
|
||||
// Local + remote-tracking branches for the "base branch" picker in the
|
||||
// new-worktree dialog. The remote default (origin/HEAD) is flagged so
|
||||
// the UI can preselect it.
|
||||
baseBranchList: (repoPath: string) => Promise<HermesGitBaseBranch[]>
|
||||
// Compact working-tree status for the composer coding rail. Null on a
|
||||
// non-repo / remote backend (where the Electron probe can't run).
|
||||
repoStatus: (repoPath: string) => Promise<HermesRepoStatus | null>
|
||||
|
|
@ -668,6 +672,16 @@ export interface HermesGitBranch {
|
|||
worktreePath: null | string
|
||||
}
|
||||
|
||||
// A branch the new worktree can be based on: local heads + remote-tracking
|
||||
// refs. `isRemote` distinguishes `origin/main` from a local `main` (the UI
|
||||
// may show a remote glyph); `isDefault` flags origin/HEAD so the dialog can
|
||||
// preselect it.
|
||||
export interface HermesGitBaseBranch {
|
||||
name: string
|
||||
isRemote: boolean
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
// A single changed path from `git status --porcelain=v2`, classified by state
|
||||
// so the coding rail / switcher can group + open the right diff.
|
||||
export interface HermesRepoStatusFile {
|
||||
|
|
|
|||
|
|
@ -1585,6 +1585,9 @@ export const en: Translations = {
|
|||
newWorktreeTitle: 'New worktree',
|
||||
newWorktreeDesc: 'Name the branch for this worktree.',
|
||||
branchPlaceholder: 'e.g. my-feature',
|
||||
branchOff: () => ({ after: '', before: 'branch off ' }),
|
||||
baseBranchPlaceholder: 'Search branches…',
|
||||
baseBranchNone: 'No branches found',
|
||||
startWorkFailed: 'Could not create worktree',
|
||||
convertBranch: 'Convert a branch…',
|
||||
convertBranchTitle: 'Convert a branch',
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,9 @@ export const ja = defineLocale({
|
|||
newWorktreeTitle: '新しいワークツリー',
|
||||
newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。',
|
||||
branchPlaceholder: '例: my-feature',
|
||||
branchOff: () => ({ after: ' から分岐', before: '' }),
|
||||
baseBranchPlaceholder: 'ブランチを検索…',
|
||||
baseBranchNone: 'ブランチが見つかりません',
|
||||
startWorkFailed: 'ワークツリーを作成できませんでした',
|
||||
convertBranch: 'ブランチを変換…',
|
||||
convertBranchTitle: 'ブランチを変換',
|
||||
|
|
|
|||
|
|
@ -1312,6 +1312,9 @@ export interface Translations {
|
|||
newWorktreeTitle: string
|
||||
newWorktreeDesc: string
|
||||
branchPlaceholder: string
|
||||
branchOff: () => { after: string; before: string }
|
||||
baseBranchPlaceholder: string
|
||||
baseBranchNone: string
|
||||
startWorkFailed: string
|
||||
convertBranch: string
|
||||
convertBranchTitle: string
|
||||
|
|
|
|||
|
|
@ -1479,6 +1479,9 @@ export const zhHant = defineLocale({
|
|||
newWorktreeTitle: '新增工作樹',
|
||||
newWorktreeDesc: '為這個工作樹命名分支。',
|
||||
branchPlaceholder: '例如 my-feature',
|
||||
branchOff: () => ({ after: ' 分支', before: '從 ' }),
|
||||
baseBranchPlaceholder: '搜尋分支…',
|
||||
baseBranchNone: '未找到分支',
|
||||
startWorkFailed: '無法建立工作樹',
|
||||
convertBranch: '轉換分支…',
|
||||
convertBranchTitle: '轉換分支',
|
||||
|
|
|
|||
|
|
@ -1761,6 +1761,9 @@ export const zh: Translations = {
|
|||
newWorktreeTitle: '新建工作树',
|
||||
newWorktreeDesc: '为这个工作树命名分支。',
|
||||
branchPlaceholder: '例如 my-feature',
|
||||
branchOff: () => ({ after: ' 分支', before: '从 ' }),
|
||||
baseBranchPlaceholder: '搜索分支…',
|
||||
baseBranchNone: '未找到分支',
|
||||
startWorkFailed: '无法创建工作树',
|
||||
convertBranch: '转换分支…',
|
||||
convertBranchTitle: '转换分支',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
HermesGitBaseBranch,
|
||||
HermesGitBranch,
|
||||
HermesGitWorktree,
|
||||
HermesRepoStatus,
|
||||
|
|
@ -58,6 +59,9 @@ const remoteGit: GitBridge = {
|
|||
branchList: async repoPath =>
|
||||
(await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches,
|
||||
|
||||
baseBranchList: async repoPath =>
|
||||
(await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches,
|
||||
|
||||
repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }),
|
||||
|
||||
fileDiff: async (repoPath, filePath) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { desktopGit } from '@/lib/desktop-git'
|
||||
|
|
@ -709,6 +709,19 @@ export async function listRepoBranches(repoPath: string): Promise<HermesGitBranc
|
|||
return git.branchList(repoPath)
|
||||
}
|
||||
|
||||
// Local + remote-tracking branches for the base-branch picker in the
|
||||
// new-worktree dialog. The remote default (origin/HEAD) is flagged so the
|
||||
// UI can preselect it. Empty on a remote backend / non-repo.
|
||||
export async function listBaseBranches(repoPath: string): Promise<HermesGitBaseBranch[]> {
|
||||
const git = desktopGit()
|
||||
|
||||
if (!git?.baseBranchList || !repoPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return git.baseBranchList(repoPath)
|
||||
}
|
||||
|
||||
export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> {
|
||||
const git = desktopGit()
|
||||
|
||||
|
|
|
|||
|
|
@ -644,3 +644,46 @@ def branch_switch(cwd: str, branch: str) -> dict:
|
|||
raise RuntimeError("Branch name is required.")
|
||||
_git_ok(cwd, ["switch", target])
|
||||
return {"branch": target}
|
||||
|
||||
|
||||
def base_branch_list(cwd: str) -> list[dict]:
|
||||
"""Local heads + remote-tracking refs for the base-branch picker.
|
||||
|
||||
The remote default (origin/HEAD) is flagged so the UI can preselect it.
|
||||
"""
|
||||
out = _git_out(
|
||||
cwd,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)\t%(committerdate:iso)",
|
||||
"--sort=-committerdate",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
],
|
||||
)
|
||||
if not out:
|
||||
return []
|
||||
remote_default = _git_out(
|
||||
cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]
|
||||
).strip()
|
||||
local_default = _default_branch(cwd) if not remote_default else ""
|
||||
result: list[dict] = []
|
||||
for line in out.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
name = line.split("\t")[0]
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"isRemote": name.startswith("origin/"),
|
||||
# origin/HEAD when a remote exists; otherwise the local
|
||||
# default (main/master/init.defaultBranch) so a no-remote
|
||||
# repo still flags its trunk.
|
||||
"isDefault": bool(
|
||||
(remote_default and name == remote_default)
|
||||
or (not remote_default and local_default and name == local_default)
|
||||
),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -2333,6 +2333,11 @@ async def git_branches_route(path: str):
|
|||
return {"branches": await _git_op(_web_git.branch_list, _git_path(path))}
|
||||
|
||||
|
||||
@app.get("/api/git/base-branches")
|
||||
async def git_base_branches_route(path: str):
|
||||
return {"branches": await _git_op(_web_git.base_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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue