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:
ethernet 2026-07-10 13:40:23 -04:00
parent 2627933f33
commit 6f7ee72be5
16 changed files with 390 additions and 19 deletions

View file

@ -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 })
}
})

View file

@ -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,

View file

@ -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.

View file

@ -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),

View file

@ -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>
)
}

View file

@ -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 ? (

View file

@ -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 {

View file

@ -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',

View file

@ -1527,6 +1527,9 @@ export const ja = defineLocale({
newWorktreeTitle: '新しいワークツリー',
newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。',
branchPlaceholder: '例: my-feature',
branchOff: () => ({ after: ' から分岐', before: '' }),
baseBranchPlaceholder: 'ブランチを検索…',
baseBranchNone: 'ブランチが見つかりません',
startWorkFailed: 'ワークツリーを作成できませんでした',
convertBranch: 'ブランチを変換…',
convertBranchTitle: 'ブランチを変換',

View file

@ -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

View file

@ -1479,6 +1479,9 @@ export const zhHant = defineLocale({
newWorktreeTitle: '新增工作樹',
newWorktreeDesc: '為這個工作樹命名分支。',
branchPlaceholder: '例如 my-feature',
branchOff: () => ({ after: ' 分支', before: '從 ' }),
baseBranchPlaceholder: '搜尋分支…',
baseBranchNone: '未找到分支',
startWorkFailed: '無法建立工作樹',
convertBranch: '轉換分支…',
convertBranchTitle: '轉換分支',

View file

@ -1761,6 +1761,9 @@ export const zh: Translations = {
newWorktreeTitle: '新建工作树',
newWorktreeDesc: '为这个工作树命名分支。',
branchPlaceholder: '例如 my-feature',
branchOff: () => ({ after: ' 分支', before: '从 ' }),
baseBranchPlaceholder: '搜索分支…',
baseBranchNone: '未找到分支',
startWorkFailed: '无法创建工作树',
convertBranch: '转换分支…',
convertBranchTitle: '转换分支',

View file

@ -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) =>

View file

@ -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()

View file

@ -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

View file

@ -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)