fix(desktop): list base branches once and keep the caller-chosen worktree base (#119745)
The new-worktree base-branch picker re-listed branches in an endless loop
on a non-git folder — the mount effect re-fired whenever
(branches.length === 0 && !loading), which is exactly the state an
empty/failed listing leaves behind — and load() unconditionally called
onValueChange with the default branch, so the base the caller picked
("Branch off from <current>" in the coding row's kebab) was overwritten
the moment the list landed.
- One listing per repo: the load effect is keyed on repoPath only, and an
empty list or a failed listing is a final answer, never a re-trigger.
The effect cleanup ignores a list that settles after the picker moved
to another repo (the dialog remounts per repo) or unmounted, so a stale
response can neither paint the previous repo branches nor set the new
repo base.
- The default branch (origin/HEAD, falling back to the first branch)
fills only an EMPTY value, from the settled list — never from inside
load() — so the caller base stands and a project switch cannot leak a
stale default.
- The popover-open re-load guard is gone with load(): the mount listing
already answered, and re-opening must not re-run the bridge.
Consolidates the two open candidate fixes (credit: jonpol01 #119746 —
once-per-repo effect, value-empty-only fallback, and the regression
tests; JoaoMarcos44 #119882 — the active-cleanup stale-response guard),
superseding both.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGitBaseBranch } from '@/global'
|
||||
import { $worktreeDialog } from '@/store/projects'
|
||||
|
||||
import { BaseBranchPicker } from './base-branch-picker'
|
||||
import { WorktreeDialog } from './worktree-dialog'
|
||||
|
||||
type ActGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
|
||||
const MAIN: HermesGitBaseBranch = { isDefault: true, isRemote: true, name: 'origin/main' }
|
||||
const FEAT: HermesGitBaseBranch = { isDefault: false, isRemote: false, name: 'feat-x' }
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// The bridge answers after an IPC round trip, as the Electron one does.
|
||||
const listing = (answer: () => HermesGitBaseBranch[]) =>
|
||||
vi.fn(async (_repoPath: string) => {
|
||||
await sleep(5)
|
||||
|
||||
return answer()
|
||||
})
|
||||
|
||||
const worktreeAddSpy = () =>
|
||||
vi.fn(async (_repoPath: string, options: { branch: string }) => ({
|
||||
branch: options.branch,
|
||||
path: `/repo/.worktrees/${options.branch}`
|
||||
}))
|
||||
|
||||
function installGit(git: Record<string, unknown>) {
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = { git }
|
||||
}
|
||||
|
||||
async function submitWorktree(name: string) {
|
||||
fireEvent.change(await screen.findByPlaceholderText('e.g. my-feature'), { target: { value: name } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New worktree' }))
|
||||
}
|
||||
|
||||
// act() holds every update until its scope exits, so a load loop gets one pass
|
||||
// per act() and looks finite. Production renders on the real scheduler, and so
|
||||
// do these tests.
|
||||
beforeEach(() => {
|
||||
;(globalThis as ActGlobal).IS_REACT_ACT_ENVIRONMENT = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$worktreeDialog.set(null)
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
;(globalThis as ActGlobal).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
describe('BaseBranchPicker loading', () => {
|
||||
it.each([
|
||||
['an empty list (a folder git cannot list, an unborn HEAD)', () => []],
|
||||
[
|
||||
'a failed listing (a backend without the endpoint)',
|
||||
() => {
|
||||
throw new Error('404 Not Found')
|
||||
}
|
||||
]
|
||||
])('lists the repo once on %s', async (_case, answer: () => HermesGitBaseBranch[]) => {
|
||||
const baseBranchList = listing(answer)
|
||||
installGit({ baseBranchList })
|
||||
|
||||
render(<BaseBranchPicker onValueChange={() => {}} repoPath="/folder" value="" />)
|
||||
await sleep(150)
|
||||
|
||||
expect(baseBranchList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a list from the previous repo does not set the base of the next', async () => {
|
||||
const land: Record<string, (list: HermesGitBaseBranch[]) => void> = {}
|
||||
|
||||
const baseBranchList = vi.fn(
|
||||
(repoPath: string) =>
|
||||
new Promise<HermesGitBaseBranch[]>(resolve => {
|
||||
land[repoPath] = resolve
|
||||
})
|
||||
)
|
||||
|
||||
installGit({ baseBranchList })
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
// The dialog remounts the picker per repo, keyed on the path.
|
||||
const view = render(<BaseBranchPicker key="/a" onValueChange={onValueChange} repoPath="/a" value="" />)
|
||||
await waitFor(() => expect(baseBranchList).toHaveBeenCalledWith('/a'))
|
||||
view.rerender(<BaseBranchPicker key="/b" onValueChange={onValueChange} repoPath="/b" value="" />)
|
||||
await waitFor(() => expect(baseBranchList).toHaveBeenCalledWith('/b'))
|
||||
|
||||
land['/a']([{ ...MAIN, name: 'origin/main-a' }])
|
||||
land['/b']([{ ...MAIN, name: 'origin/main-b' }])
|
||||
|
||||
await waitFor(() => expect(onValueChange).toHaveBeenCalledWith('origin/main-b'))
|
||||
expect(onValueChange).not.toHaveBeenCalledWith('origin/main-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeDialog base branch', () => {
|
||||
it('cuts the worktree from the base the caller chose', async () => {
|
||||
const baseBranchList = listing(() => [MAIN, FEAT])
|
||||
const worktreeAdd = worktreeAddSpy()
|
||||
installGit({ baseBranchList, worktreeAdd })
|
||||
|
||||
render(<WorktreeDialog />)
|
||||
// What the coding row's "Branch off from feat-x" publishes.
|
||||
$worktreeDialog.set({ base: 'feat-x', repoPath: '/repo' })
|
||||
await waitFor(() => expect(baseBranchList).toHaveBeenCalled())
|
||||
await sleep(50)
|
||||
await submitWorktree('my-work')
|
||||
|
||||
await waitFor(() => expect(worktreeAdd).toHaveBeenCalledTimes(1))
|
||||
expect(worktreeAdd.mock.calls[0][1]).toMatchObject({ base: 'feat-x' })
|
||||
})
|
||||
|
||||
it('cuts the worktree from the default branch when the caller names no base', async () => {
|
||||
const baseBranchList = listing(() => [FEAT, MAIN])
|
||||
const worktreeAdd = worktreeAddSpy()
|
||||
installGit({ baseBranchList, worktreeAdd })
|
||||
|
||||
render(<WorktreeDialog />)
|
||||
$worktreeDialog.set({ repoPath: '/repo' })
|
||||
await screen.findByText('origin/main')
|
||||
await submitWorktree('my-work')
|
||||
|
||||
await waitFor(() => expect(worktreeAdd).toHaveBeenCalledTimes(1))
|
||||
expect(worktreeAdd.mock.calls[0][1]).toMatchObject({ base: 'origin/main' })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -43,41 +43,58 @@ export function BaseBranchPicker({
|
||||
|
||||
const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// List the repo once per mount/repo (#119745): an empty list is a real
|
||||
// answer (a folder git cannot list, an unborn HEAD, a backend without the
|
||||
// endpoint) and a failed list is a real failure — neither may re-trigger
|
||||
// the load, or a non-git folder re-runs the bridge in a loop while the
|
||||
// dialog is open. The cleanup ignores a list that lands after the picker
|
||||
// moved to another repo or unmounted, so a late list can neither paint the
|
||||
// previous repo's branches nor set this repo's base.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setBranches([])
|
||||
if (!repoPath) {
|
||||
return
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
listBaseBranches(repoPath)
|
||||
.then(list => {
|
||||
if (active) {
|
||||
setBranches(list)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setBranches([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [repoPath, onValueChange])
|
||||
}, [repoPath])
|
||||
|
||||
// Default to the remote default (origin/HEAD). Fall back to the local
|
||||
// default branch (main/master) when no remote exists. Only an EMPTY value
|
||||
// is filled (#119745): the base the caller chose ("Branch off from
|
||||
// <current>" in the coding row's kebab) stands even though it is not the
|
||||
// default. Runs on the settled list, never inside the load, so a stale
|
||||
// response cannot set another project's base.
|
||||
const fallback = (branches.find(b => b.isDefault) ?? branches[0])?.name
|
||||
|
||||
// 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()
|
||||
if (!value && fallback) {
|
||||
onValueChange(fallback)
|
||||
}
|
||||
}, [branches.length, loading, load])
|
||||
}, [fallback, onValueChange, value])
|
||||
|
||||
// Pin the current session's branch to the top, keep the rest in git's
|
||||
// most-recently-committed order.
|
||||
@@ -103,10 +120,6 @@ export function BaseBranchPicker({
|
||||
<div className="space-y-1.5">
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
if (next && branches.length === 0 && !loading) {
|
||||
void load()
|
||||
}
|
||||
|
||||
setOpen(next)
|
||||
}}
|
||||
open={open}
|
||||
|
||||
Reference in New Issue
Block a user