From 53dc10d8d5160b6fba27c40e5cf4ced6c0c43534 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 00:04:37 +0200 Subject: [PATCH 1/4] Fix race condition in BranchesView data loading Add cancellation signal to the useEffect that fetches branch data so stale responses from prior renders don't overwrite current results. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/src/components/BranchesView.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/BranchesView.tsx b/src/renderer/src/components/BranchesView.tsx index 150e8fb..7cd6da3 100644 --- a/src/renderer/src/components/BranchesView.tsx +++ b/src/renderer/src/components/BranchesView.tsx @@ -28,7 +28,7 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re hasWorktree: boolean } | null>(null) - const loadData = useCallback(async () => { + const loadData = useCallback(async (signal?: { cancelled: boolean }) => { setLoading(true) setError(null) try { @@ -36,18 +36,24 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re api.getBranchDetails(project.cwd), api.listWorktrees(project.cwd) ]) + if (signal?.cancelled) return setBranches(br) setWorktrees(wt) setSelected(new Set()) } catch (e) { + if (signal?.cancelled) return setError(e instanceof Error ? e.message : 'Failed to load branch data') } finally { - setLoading(false) + if (!signal?.cancelled) setLoading(false) } }, [project.cwd]) useEffect(() => { - loadData() + const signal = { cancelled: false } + loadData(signal) + return () => { + signal.cancelled = true + } }, [loadData]) const showAction = useCallback((msg: string) => { From 1e0c4bf2ef3508fbde1082c0933a1de56ffd46e5 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 00:15:56 +0200 Subject: [PATCH 2/4] Include remote-only branches in BranchesView Use git branch --all to fetch both local and remote branches. Remote branches without a local counterpart are shown with a "remote" badge. Deduplicates by preferring local branches when both exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/worktree.ts | 64 ++++++++++++++++++-- src/renderer/src/components/BranchesView.tsx | 9 +++ src/shared/types.ts | 1 + 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 78228eb..ec72f77 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -205,7 +205,10 @@ export async function getBranchDetails(cwd: string): Promise { '%(subject)' ].join(SEP) - const stdout = await git(['branch', `--format=${format}`, '--sort=-committerdate'], cwd) + const stdout = await git( + ['branch', '--all', `--format=${format}`, '--sort=-committerdate'], + cwd + ) let worktrees: WorktreeInfo[] = [] try { @@ -226,8 +229,24 @@ export async function getBranchDetails(cwd: string): Promise { .map((line) => parseBranchLine(line)) .filter((obj) => obj !== null) - const branches: BranchDetail[] = await Promise.all( - parsed.map(async (obj) => { + // Separate local and remote entries. Remote refs start with "origin/". + const localEntries: typeof parsed = [] + const remoteEntries: typeof parsed = [] + for (const obj of parsed) { + if (obj.name.startsWith('origin/')) { + // Skip origin/HEAD pointer + if (obj.name === 'origin/HEAD') continue + remoteEntries.push(obj) + } else { + localEntries.push(obj) + } + } + + const localNames = new Set(localEntries.map((e) => e.name)) + + // Build details for local branches + const localBranches: BranchDetail[] = await Promise.all( + localEntries.map(async (obj) => { const name: string = obj.name const worktreePath = wtByBranch.get(name) || '' const isMain = name === mainBranch @@ -250,12 +269,47 @@ export async function getBranchDetails(cwd: string): Promise { worktreePath, aheadCount, dirty, - pr + pr, + remoteOnly: false } }) ) - return branches + // Build details for remote-only branches (no local counterpart) + const remoteBranches: BranchDetail[] = await Promise.all( + remoteEntries + .filter((obj) => { + const shortName = obj.name.replace(/^origin\//, '') + return !localNames.has(shortName) + }) + .map(async (obj) => { + const shortName = obj.name.replace(/^origin\//, '') + const isMain = obj.name === mainBranch + + const aheadCount = isMain + ? 0 + : await getAheadCount(cwd, obj.name, mainBranch) + + const pr = prStatuses.get(shortName) ?? NO_PR + + return { + name: shortName, + isHead: false, + upstream: obj.name, + gone: false, + lastCommitDate: obj.date || '', + lastCommitRelative: obj.relative || '', + lastCommitSubject: obj.subject || '', + worktreePath: '', + aheadCount, + dirty: false, + pr, + remoteOnly: true + } + }) + ) + + return [...localBranches, ...remoteBranches] } /** List files changed on a branch (committed vs origin/main + uncommitted in worktree) */ diff --git a/src/renderer/src/components/BranchesView.tsx b/src/renderer/src/components/BranchesView.tsx index 7cd6da3..c48b170 100644 --- a/src/renderer/src/components/BranchesView.tsx +++ b/src/renderer/src/components/BranchesView.tsx @@ -807,6 +807,15 @@ function BranchRow({ )} + {branch.remoteOnly && ( + + remote + + )} + {stale && ( Date: Tue, 7 Apr 2026 00:23:00 +0200 Subject: [PATCH 3/4] Switch from git branch to git for-each-ref for reliable NUL separators git branch --format does not interpret %x00 as NUL bytes in git 2.43. git for-each-ref with %00 produces actual NUL bytes, fixing branch parsing that was silently returning empty results. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/worktree.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/worktree.ts b/src/main/worktree.ts index ec72f77..5055629 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -194,7 +194,7 @@ export function parseBranchLine(line: string): { } export async function getBranchDetails(cwd: string): Promise { - const SEP = '%x00' + const SEP = '%00' const format = [ '%(refname:short)', '%(HEAD)', @@ -206,7 +206,13 @@ export async function getBranchDetails(cwd: string): Promise { ].join(SEP) const stdout = await git( - ['branch', '--all', `--format=${format}`, '--sort=-committerdate'], + [ + 'for-each-ref', + `--format=${format}`, + '--sort=-committerdate', + 'refs/heads/', + 'refs/remotes/' + ], cwd ) From 87d887c961019e53225f75748db0b5fa2fbdaa43 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 00:28:24 +0200 Subject: [PATCH 4/4] Fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/worktree.ts | 12 +----- src/renderer/src/components/BranchesView.tsx | 41 +++++++++++--------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 5055629..bdc238c 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -206,13 +206,7 @@ export async function getBranchDetails(cwd: string): Promise { ].join(SEP) const stdout = await git( - [ - 'for-each-ref', - `--format=${format}`, - '--sort=-committerdate', - 'refs/heads/', - 'refs/remotes/' - ], + ['for-each-ref', `--format=${format}`, '--sort=-committerdate', 'refs/heads/', 'refs/remotes/'], cwd ) @@ -292,9 +286,7 @@ export async function getBranchDetails(cwd: string): Promise { const shortName = obj.name.replace(/^origin\//, '') const isMain = obj.name === mainBranch - const aheadCount = isMain - ? 0 - : await getAheadCount(cwd, obj.name, mainBranch) + const aheadCount = isMain ? 0 : await getAheadCount(cwd, obj.name, mainBranch) const pr = prStatuses.get(shortName) ?? NO_PR diff --git a/src/renderer/src/components/BranchesView.tsx b/src/renderer/src/components/BranchesView.tsx index c48b170..eaf749a 100644 --- a/src/renderer/src/components/BranchesView.tsx +++ b/src/renderer/src/components/BranchesView.tsx @@ -28,25 +28,28 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re hasWorktree: boolean } | null>(null) - const loadData = useCallback(async (signal?: { cancelled: boolean }) => { - setLoading(true) - setError(null) - try { - const [br, wt] = await Promise.all([ - api.getBranchDetails(project.cwd), - api.listWorktrees(project.cwd) - ]) - if (signal?.cancelled) return - setBranches(br) - setWorktrees(wt) - setSelected(new Set()) - } catch (e) { - if (signal?.cancelled) return - setError(e instanceof Error ? e.message : 'Failed to load branch data') - } finally { - if (!signal?.cancelled) setLoading(false) - } - }, [project.cwd]) + const loadData = useCallback( + async (signal?: { cancelled: boolean }) => { + setLoading(true) + setError(null) + try { + const [br, wt] = await Promise.all([ + api.getBranchDetails(project.cwd), + api.listWorktrees(project.cwd) + ]) + if (signal?.cancelled) return + setBranches(br) + setWorktrees(wt) + setSelected(new Set()) + } catch (e) { + if (signal?.cancelled) return + setError(e instanceof Error ? e.message : 'Failed to load branch data') + } finally { + if (!signal?.cancelled) setLoading(false) + } + }, + [project.cwd] + ) useEffect(() => { const signal = { cancelled: false }