Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions src/main/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function parseBranchLine(line: string): {
}

export async function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
const SEP = '%x00'
const SEP = '%00'
const format = [
'%(refname:short)',
'%(HEAD)',
Expand All @@ -205,7 +205,10 @@ export async function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
'%(subject)'
].join(SEP)

const stdout = await git(['branch', `--format=${format}`, '--sort=-committerdate'], cwd)
const stdout = await git(
['for-each-ref', `--format=${format}`, '--sort=-committerdate', 'refs/heads/', 'refs/remotes/'],
cwd
)

let worktrees: WorktreeInfo[] = []
try {
Expand All @@ -226,8 +229,24 @@ export async function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
.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
Expand All @@ -250,12 +269,45 @@ export async function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
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) */
Expand Down
54 changes: 36 additions & 18 deletions src/renderer/src/components/BranchesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,35 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re
hasWorktree: boolean
} | null>(null)

const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [br, wt] = await Promise.all([
api.getBranchDetails(project.cwd),
api.listWorktrees(project.cwd)
])
setBranches(br)
setWorktrees(wt)
setSelected(new Set())
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load branch data')
} finally {
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(() => {
loadData()
const signal = { cancelled: false }
loadData(signal)
return () => {
signal.cancelled = true
}
}, [loadData])

const showAction = useCallback((msg: string) => {
Expand Down Expand Up @@ -801,6 +810,15 @@ function BranchRow({
</span>
)}

{branch.remoteOnly && (
<span
className="text-[9px] px-1.5 py-0.5 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 cursor-help"
title="Remote: This branch only exists on the remote — no local checkout"
>
remote
</span>
)}

{stale && (
<span
className="text-[9px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 cursor-help"
Expand Down
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface BranchDetail {
aheadCount: number
dirty: boolean
pr: PrInfo
remoteOnly: boolean
}

export interface BranchFile {
Expand Down
Loading