diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 6c974590..70be749d 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -359,6 +359,12 @@ export function NewWorktreeDialog({ void fetchBranches(projectDirectory, git); }, [projectDirectory, git, fetchBranches]); + React.useEffect(() => { + if (!open || !projectDirectory || !git) return; + if (branches?.all) return; + void fetchBranches(projectDirectory, git); + }, [open, projectDirectory, git, branches?.all, fetchBranches]); + React.useEffect(() => { if (!existingBranchDropdownOpen && !existingBranchPickerOpen) { setExistingBranchQuery(''); diff --git a/packages/ui/src/stores/useGitStore.repro.test.ts b/packages/ui/src/stores/useGitStore.repro.test.ts new file mode 100644 index 00000000..f0566091 --- /dev/null +++ b/packages/ui/src/stores/useGitStore.repro.test.ts @@ -0,0 +1,158 @@ +/** + * Reproduction test for issue #1564: + * Source branch list is sometimes empty when creating a worktree. + * + * Root cause: NewWorktreeDialog relies on useGitBranches(projectDirectory) from + * the Zustand store, but does NOT auto-fetch branches when the dialog opens. + * Branches only get populated if: + * - The Git tab has been opened (GitView's ensureAll) + * - A draft session has been started (ChatInput's branch fetch) + * - The user manually clicks the refresh button in the dialog + * + * This test demonstrates that branches start as null (empty list) for a + * directory that hasn't been explicitly fetched, matching the "sometimes empty" + * behavior reported in the issue. + */ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { useGitStore } from './useGitStore'; + +type GitAPI = Parameters['fetchStatus']>[1]; + +const createGitApi = (branchesResult?: { all: string[]; current: string }): GitAPI => ({ + checkIsGitRepository: async () => true, + getGitStatus: async () => ({ + current: 'main', + tracking: null, + ahead: 0, + behind: 0, + files: [], + isClean: true, + }), + getGitBranches: async () => ({ + all: branchesResult?.all ?? ['main', 'develop', 'remotes/origin/main'], + current: branchesResult?.current ?? 'main', + branches: {}, + }), + getGitLog: async () => ({ all: [], latest: null, total: 0 }), + getCurrentGitIdentity: async () => null, + getGitFileDiff: async (_directory: string, options: { path: string }) => ({ + original: '', + modified: '', + path: options.path, + }), +}); + +describe('Issue #1564 - Source branch list is empty in create worktree dialog', () => { + beforeEach(() => { + // Reset store state - simulating a fresh app load where + // no Git tab has been opened and no draft session started + useGitStore.setState({ + directories: new Map(), + activeDirectory: null, + }); + }); + + test('branches are null (empty list) for a directory that has not been fetched', () => { + // Simulate what happens when NewWorktreeDialog mounts: + // projectDirectory = '/my-project' but no branches were fetched yet + const directory = '/my-project'; + const state = useGitStore.getState().directories.get(directory); + + // The dialog calls useGitBranches(projectDirectory), which returns: + // state.directories.get(directory)?.branches ?? null + // Since the directory doesn't exist in the store, branches is null + expect(state).toBe(undefined); + + // This directly simulates the selector in the dialog: + const branches = useGitStore.getState().directories.get(directory)?.branches ?? null; + expect(branches).toBeNull(); + + // The dialog then computes: + // localBranches = branches?.all?.filter(...) ?? [] + // remoteBranches = branches?.all?.filter(...) ?? [] + // Both are empty arrays, so the dropdown shows "No branches found" + const localBranches = branches?.all?.filter((b) => !b.startsWith('remotes/')) ?? []; + const remoteBranches = branches?.all?.filter((b) => b.startsWith('remotes/')) ?? []; + expect(localBranches).toEqual([]); + expect(remoteBranches).toEqual([]); + }); + + test('branches ARE available AFTER fetchBranches is called', async () => { + const directory = '/my-project'; + const git = createGitApi({ all: ['main', 'develop', 'feature/new-feature', 'remotes/origin/main'], current: 'main' }); + + // Simulate the user clicking the refresh button in the dialog + await useGitStore.getState().fetchBranches(directory, git); + + const dirState = useGitStore.getState().directories.get(directory); + expect(dirState).toBeTruthy(); + expect(dirState?.branches).not.toBeNull(); + expect(dirState?.branches?.all).toEqual(['main', 'develop', 'feature/new-feature', 'remotes/origin/main']); + + const branches = dirState!.branches!; + const localBranches = branches.all.filter((b) => !b.startsWith('remotes/')).sort(); + const remoteBranches = branches.all.filter((b) => b.startsWith('remotes/')).sort(); + + expect(localBranches).toEqual(['develop', 'feature/new-feature', 'main']); + expect(remoteBranches).toEqual(['remotes/origin/main']); + }); + + test('simulates the ChatInput flow: branches become available after draft session start', async () => { + // The ChatInput component fetches branches when showDraftTargetSelectors is true + // This is triggered when a draft session is started + const directory = '/my-project'; + const git = createGitApi({ all: ['main', 'hotfix', 'remotes/origin/main'], current: 'main' }); + + // Before draft session - branches not fetched + expect(useGitStore.getState().directories.get(directory)?.branches).toBe(undefined); + + // Simulate ChatInput's useEffect that runs on draft session start: + // React.useEffect(() => { + // if (!showDraftTargetSelectors || ...) return; + // void fetchBranches(selectedDraftProjectPath, runtimeGit) + // }, [...]); + await useGitStore.getState().fetchBranches(directory, git); + + // After fetch - branches are now available + const branches = useGitStore.getState().directories.get(directory)?.branches; + expect(branches).not.toBeNull(); + expect(branches?.all).toContain('main'); + expect(branches?.all).toContain('hotfix'); + + // This is the exact same state the dialog would now see + const localBranches = branches!.all.filter((b) => !b.startsWith('remotes/')).sort(); + expect(localBranches).toEqual(['hotfix', 'main']); + }); + + test('demonstrates the missing auto-fetch in NewWorktreeDialog', () => { + // The core issue: NewWorktreeDialog should call fetchBranches (or ensureAll) + // when it opens with no branches cached, but it doesn't. + // + // Looking at the dialog code: + // - Line 242: const branches = useGitBranches(projectDirectory); + // - Line 243: const isLoadingBranches = useGitLoadingBranches(projectDirectory); + // - Line 355-360: handleFetchBranches only triggered by manual refresh button + // - Lines 616-647: reset effect on open does NOT call fetchBranches + // + // Compare with ChatInput's approach (lines 3557-3563): + // if (selectedDraftProjectBranches?.all) { return; } + // void fetchBranches(selectedDraftProjectPath, runtimeGit) + // ChatInput auto-fetches if branches aren't available. + // + // The dialog should similarly auto-fetch: + // React.useEffect(() => { + // if (!open || !projectDirectory || !git) return; + // if (branches?.all) return; // already cached + // void fetchBranches(projectDirectory, git); + // }, [open, projectDirectory, git, branches?.all, fetchBranches]); + + // Verify the issue exists by checking that opening the dialog + // doesn't trigger any branch fetching for un-fetched directories + const directory = '/unfetched-project'; + const hasBranches = useGitStore.getState().directories.get(directory)?.branches?.all; + expect(hasBranches).toBe(undefined); + + // The dialog would show "No branches found" in this state + // because branches?.all is undefined -> localBranches = [] && remoteBranches = [] + }); +});