diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 214d4da..9305b63 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ rollupOptions: { external: ['node-pty'] } + }, + server: { + watch: { + ignored: ['**/.konductor/**'] + } } }, preload: { @@ -22,6 +27,11 @@ export default defineConfig({ } }, plugins: [react()], + server: { + watch: { + ignored: ['**/.konductor/**'] + } + }, define: { __APP_VERSION__: JSON.stringify(pkg.version) } diff --git a/src/main/fileWatcher.ts b/src/main/fileWatcher.ts index 53ff4d6..67408c2 100644 --- a/src/main/fileWatcher.ts +++ b/src/main/fileWatcher.ts @@ -86,7 +86,7 @@ export function createFileWatcher( // Watch for FS changes to trigger git status refresh watcher = watch(cwd, { - ignored: ['**/.git/**', '**/node_modules/**'], + ignored: ['**/.git/**', '**/node_modules/**', '**/.konductor/**'], persistent: true, ignoreInitial: true, awaitWriteFinish: { diff --git a/src/main/index.ts b/src/main/index.ts index 096105a..c5dd50b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,7 +29,9 @@ import { getBranchDetails, deleteBranch, deleteRemoteBranch, - fetchPrune + fetchPrune, + getBranchFiles, + getBranchDiff } from './worktree' import { getGitHubRepo, listPullRequests, listIssues } from './github' @@ -207,6 +209,27 @@ app.whenReady().then(() => { return shell.openExternal(url) }) + ipcMain.handle( + 'get-branch-files', + (_event, cwd: string, branch: string, worktreePath: string) => { + return getBranchFiles(cwd, branch, worktreePath) + } + ) + + ipcMain.handle( + 'get-branch-diff', + ( + _event, + cwd: string, + branch: string, + filePath: string, + source: 'committed' | 'uncommitted', + worktreePath: string + ) => { + return getBranchDiff(cwd, branch, filePath, source, worktreePath) + } + ) + ipcMain.handle('select-directory', async () => { if (!mainWindow) return null const result = await dialog.showOpenDialog(mainWindow, { diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 3bcd229..814e9e8 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' import { join } from 'path' -import { mkdir, rm } from 'fs/promises' +import { mkdir } from 'fs/promises' function git(args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { @@ -89,12 +89,24 @@ export async function createWorktree( } export async function removeWorktree(repoRoot: string, worktreePath: string): Promise { - await rm(worktreePath, { recursive: true, force: true }) - - await new Promise((resolve) => { - execFile('git', ['worktree', 'prune'], { cwd: repoRoot }, () => { + // Use git worktree remove first — it handles cleanup of .git references + await new Promise((resolve, reject) => { + execFile('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }, (err) => { + if (err) { + reject(err) + return + } resolve() }) + }).catch(async () => { + // Fallback: force-remove the directory (handles suid binaries like chrome-sandbox + // that Node's fs.rm can't delete) then prune the worktree list + await new Promise((resolve) => { + execFile('rm', ['-rf', worktreePath], () => resolve()) + }) + await new Promise((resolve) => { + execFile('git', ['worktree', 'prune'], { cwd: repoRoot }, () => resolve()) + }) }) } @@ -120,6 +132,14 @@ export function listBranches(cwd: string): Promise { }) } +export type PrState = 'open' | 'merged' | 'closed' | 'none' + +export interface PrInfo { + state: PrState + number: number + url: string +} + export interface BranchDetail { name: string isHead: boolean @@ -129,6 +149,77 @@ export interface BranchDetail { lastCommitRelative: string lastCommitSubject: string worktreePath: string + aheadCount: number + dirty: boolean + pr: PrInfo +} + +function getAheadCount(cwd: string, branch: string, mainBranch: string): Promise { + return new Promise((resolve) => { + execFile('git', ['rev-list', '--count', `${mainBranch}..${branch}`], { cwd }, (err, stdout) => { + if (err) { + resolve(-1) + return + } + resolve(parseInt(stdout.trim(), 10) || 0) + }) + }) +} + +const NO_PR: PrInfo = { state: 'none', number: 0, url: '' } + +function getPrStatus(cwd: string, branch: string): Promise { + return new Promise((resolve) => { + execFile( + 'gh', + [ + 'pr', + 'list', + '--head', + branch, + '--state', + 'all', + '--json', + 'state,number,url', + '--limit', + '1' + ], + { cwd }, + (err, stdout) => { + if (err) { + resolve(NO_PR) + return + } + try { + const prs = JSON.parse(stdout.trim()) + if (prs.length === 0) { + resolve(NO_PR) + return + } + const pr = prs[0] + resolve({ + state: (pr.state as string).toLowerCase() as PrState, + number: pr.number, + url: pr.url + }) + } catch { + resolve(NO_PR) + } + } + ) + }) +} + +function isWorktreeDirty(worktreePath: string): Promise { + return new Promise((resolve) => { + execFile('git', ['status', '--porcelain'], { cwd: worktreePath }, (err, stdout) => { + if (err) { + resolve(false) + return + } + resolve(stdout.trim().length > 0) + }) + }) } export function getBranchDetails(cwd: string): Promise { @@ -161,24 +252,41 @@ export function getBranchDetails(cwd: string): Promise { } const wtByBranch = new Map(worktrees.map((w) => [w.branch, w.path])) + const mainBranch = 'origin/' + (worktrees.find((w) => w.isMain)?.branch ?? 'main') - const branches: BranchDetail[] = stdout + const parsed = stdout .trim() .split('\n') .filter((line) => line.length > 0) - .map((line) => { - const obj = JSON.parse(line) + .map((line) => JSON.parse(line)) + + const branches: BranchDetail[] = await Promise.all( + parsed.map(async (obj) => { + const name: string = obj.name + const worktreePath = wtByBranch.get(name) || '' + const isMain = name === mainBranch + + const [aheadCount, dirty, pr] = await Promise.all([ + isMain ? Promise.resolve(0) : getAheadCount(cwd, name, mainBranch), + worktreePath ? isWorktreeDirty(worktreePath) : Promise.resolve(false), + isMain ? Promise.resolve(NO_PR) : getPrStatus(cwd, name) + ]) + return { - name: obj.name, + name, isHead: obj.head === '*', upstream: obj.upstream || '', gone: obj.track.includes('gone'), lastCommitDate: obj.date || '', lastCommitRelative: obj.relative || '', lastCommitSubject: obj.subject || '', - worktreePath: wtByBranch.get(obj.name) || '' + worktreePath, + aheadCount, + dirty, + pr } }) + ) resolve(branches) } @@ -186,6 +294,94 @@ export function getBranchDetails(cwd: string): Promise { }) } +export interface BranchFile { + path: string + status: 'A' | 'M' | 'D' | 'R' | 'U' + source: 'committed' | 'uncommitted' +} + +/** List files changed on a branch (committed vs origin/main + uncommitted in worktree) */ +export async function getBranchFiles( + cwd: string, + branch: string, + worktreePath: string +): Promise { + const files: BranchFile[] = [] + + // Committed changes: branch vs origin/main + const committed = await new Promise((resolve) => { + execFile('git', ['diff', '--name-status', 'origin/main...' + branch], { cwd }, (err, stdout) => + resolve(err ? '' : stdout) + ) + }) + + for (const line of committed.trim().split('\n')) { + if (!line) continue + const [statusRaw, ...pathParts] = line.split('\t') + const status = statusRaw.charAt(0) as BranchFile['status'] + const path = pathParts[pathParts.length - 1] // handles renames (R\told\tnew) + if (path) files.push({ path, status, source: 'committed' }) + } + + // Uncommitted changes in the worktree + if (worktreePath) { + const uncommitted = await new Promise((resolve) => { + execFile( + 'git', + ['status', '--porcelain', '--no-renames'], + { cwd: worktreePath }, + (err, stdout) => resolve(err ? '' : stdout) + ) + }) + + for (const line of uncommitted.trim().split('\n')) { + if (!line) continue + const xy = line.substring(0, 2) + const path = line.substring(3) + let status: BranchFile['status'] = 'M' + if (xy.includes('?')) status = 'A' + else if (xy.includes('D')) status = 'D' + else if (xy.includes('U')) status = 'U' + files.push({ path, status, source: 'uncommitted' }) + } + } + + return files +} + +/** Get diff for a single file — either committed (branch vs origin/main) or uncommitted (worktree vs HEAD) */ +export function getBranchDiff( + cwd: string, + branch: string, + filePath: string, + source: 'committed' | 'uncommitted', + worktreePath: string +): Promise { + return new Promise((resolve) => { + if (source === 'committed') { + execFile('git', ['diff', 'origin/main...' + branch, '--', filePath], { cwd }, (err, stdout) => + resolve(err && !stdout ? '' : stdout || '') + ) + } else { + // Uncommitted: run in the worktree directory + const dir = worktreePath || cwd + execFile('git', ['diff', 'HEAD', '--', filePath], { cwd: dir }, (_err, stdout) => { + if (stdout && stdout.trim()) { + resolve(stdout) + } else { + // No diff from HEAD — file is likely untracked, try --no-index + execFile( + 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd: dir }, + (_err2, stdout2) => resolve(stdout2 || '') + ) + } + }) + } + }) +} + export function deleteBranch(cwd: string, branch: string, force: boolean): Promise { return new Promise((resolve, reject) => { execFile('git', ['branch', force ? '-D' : '-d', branch], { cwd }, (err) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9974842..e71d7c3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,7 +3,7 @@ import type { ChangedFile } from '../main/fileWatcher' import type { ActivityState } from '../main/activityWatcher' import type { SessionInfo } from '../main/sessionManager' import type { PersistedState } from '../main/store' -import type { WorktreeInfo, BranchDetail } from '../main/worktree' +import type { WorktreeInfo, BranchDetail, BranchFile } from '../main/worktree' import type { GitHubRepo, GitHubPR, GitHubIssue } from '../main/github' export type UpdateStatus = { status: 'available' | 'ready'; version: string } @@ -49,6 +49,14 @@ export interface KonductorAPI { listPullRequests: (cwd: string, state: string) => Promise listIssues: (cwd: string, state: string) => Promise openExternal: (url: string) => Promise + getBranchFiles: (cwd: string, branch: string, worktreePath: string) => Promise + getBranchDiff: ( + cwd: string, + branch: string, + filePath: string, + source: 'committed' | 'uncommitted', + worktreePath: string + ) => Promise } const api: KonductorAPI = { @@ -149,7 +157,16 @@ const api: KonductorAPI = { listPullRequests: (cwd: string, state: string) => ipcRenderer.invoke('list-pull-requests', cwd, state), listIssues: (cwd: string, state: string) => ipcRenderer.invoke('list-issues', cwd, state), - openExternal: (url: string) => ipcRenderer.invoke('open-external', url) + openExternal: (url: string) => ipcRenderer.invoke('open-external', url), + getBranchFiles: (cwd: string, branch: string, worktreePath: string) => + ipcRenderer.invoke('get-branch-files', cwd, branch, worktreePath), + getBranchDiff: ( + cwd: string, + branch: string, + filePath: string, + source: 'committed' | 'uncommitted', + worktreePath: string + ) => ipcRenderer.invoke('get-branch-diff', cwd, branch, filePath, source, worktreePath) } if (process.contextIsolated) { diff --git a/src/renderer/src/components/BranchesView.tsx b/src/renderer/src/components/BranchesView.tsx index 05384a6..f44a98b 100644 --- a/src/renderer/src/components/BranchesView.tsx +++ b/src/renderer/src/components/BranchesView.tsx @@ -3,6 +3,14 @@ import type { Project } from '../types' const api = window.konductorAPI +type PrState = 'open' | 'merged' | 'closed' | 'none' + +interface PrInfo { + state: PrState + number: number + url: string +} + interface BranchDetail { name: string isHead: boolean @@ -12,6 +20,9 @@ interface BranchDetail { lastCommitRelative: string lastCommitSubject: string worktreePath: string + aheadCount: number + dirty: boolean + pr: PrInfo } interface WorktreeInfo { @@ -84,20 +95,36 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re } }, [project.cwd, loadData, showAction]) + const removeBranch = useCallback((name: string) => { + setBranches((prev) => prev.filter((b) => b.name !== name)) + setWorktrees((prev) => prev.filter((w) => w.branch !== name)) + setSelected((prev) => { + const next = new Set(prev) + next.delete(name) + return next + }) + }, []) + + const clearWorktreeFromBranch = useCallback((worktreePath: string) => { + setBranches((prev) => + prev.map((b) => (b.worktreePath === worktreePath ? { ...b, worktreePath: '' } : b)) + ) + setWorktrees((prev) => prev.filter((w) => w.path !== worktreePath)) + }, []) + const handleDeleteBranch = useCallback( async (branch: string, force: boolean) => { setDeleting(branch) setConfirmDelete(null) setError(null) try { - // If the branch has a worktree, remove it first const branchInfo = branches.find((b) => b.name === branch) if (branchInfo?.worktreePath) { await api.removeWorktree(project.cwd, branchInfo.worktreePath) } await api.deleteBranch(project.cwd, branch, force) + removeBranch(branch) showAction(`Deleted branch ${branch}`) - await loadData() } catch (e) { const msg = e instanceof Error ? e.message : 'Failed to delete branch' if (msg.includes('not fully merged') && !force) { @@ -109,7 +136,7 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re setDeleting(null) } }, - [project.cwd, branches, loadData, showAction] + [project.cwd, branches, removeBranch, showAction] ) const handleRemoveWorktree = useCallback( @@ -117,19 +144,19 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re setError(null) try { await api.removeWorktree(project.cwd, worktreePath) + clearWorktreeFromBranch(worktreePath) showAction('Removed worktree') - await loadData() } catch (e) { setError(e instanceof Error ? e.message : 'Failed to remove worktree') } }, - [project.cwd, loadData, showAction] + [project.cwd, clearWorktreeFromBranch, showAction] ) const handleBulkDelete = useCallback(async () => { if (selected.size === 0) return setError(null) - let deleted = 0 + const deleted: string[] = [] for (const branch of selected) { try { const branchInfo = branches.find((b) => b.name === branch) @@ -137,14 +164,14 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re await api.removeWorktree(project.cwd, branchInfo.worktreePath) } await api.deleteBranch(project.cwd, branch, true) - deleted++ + deleted.push(branch) } catch { // continue with others } } - showAction(`Deleted ${deleted} branch${deleted !== 1 ? 'es' : ''}`) - await loadData() - }, [selected, branches, project.cwd, loadData, showAction]) + for (const name of deleted) removeBranch(name) + showAction(`Deleted ${deleted.length} branch${deleted.length !== 1 ? 'es' : ''}`) + }, [selected, branches, project.cwd, removeBranch, showAction]) const toggleSelect = useCallback((name: string) => { setSelected((prev) => { @@ -155,9 +182,45 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re }) }, []) + const STALE_DAYS = 3 + + // A branch is only stale if there's no active local work (dirty files override everything). + // If clean, stale when: + // 1. PR merged or closed (work is done or abandoned) + // 2. Remote tracking ref gone (branch deleted on remote) + // 3. No commits ahead of main (nothing unique on this branch) + // 4. Last commit older than STALE_DAYS (abandoned work) + const isStale = (b: BranchDetail): boolean => { + // Active uncommitted work — never stale + if (b.dirty) return false + + if (b.pr.state === 'merged' || b.pr.state === 'closed') return true + if (b.gone) return true + if (b.aheadCount === 0) return true + if (b.lastCommitDate) { + const ageMs = Date.now() - new Date(b.lastCommitDate).getTime() + const ageDays = ageMs / (1000 * 60 * 60 * 24) + if (ageDays > STALE_DAYS) return true + } + return false + } + + const staleReason = (b: BranchDetail): string => { + if (b.pr.state === 'merged') return `PR #${b.pr.number} merged` + if (b.pr.state === 'closed') return `PR #${b.pr.number} closed without merging` + if (b.gone) return 'Remote branch deleted — likely merged' + if (b.aheadCount === 0) return 'No commits ahead of main' + if (b.lastCommitDate) { + const ageMs = Date.now() - new Date(b.lastCommitDate).getTime() + const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)) + if (ageDays > STALE_DAYS) return `Last commit ${ageDays} days ago` + } + return '' + } + // Filter logic const filteredBranches = branches.filter((b) => { - if (filter === 'stale') return b.gone + if (filter === 'stale') return isStale(b) if (filter === 'worktrees') return b.worktreePath !== '' return true }) @@ -166,7 +229,7 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re const headBranch = filteredBranches.find((b) => b.isHead) const otherBranches = filteredBranches.filter((b) => !b.isHead) - const staleBranches = branches.filter((b) => b.gone) + const staleBranches = branches.filter(isStale) const worktreeBranches = branches.filter((b) => b.worktreePath) // Selectable = not HEAD, not main worktree @@ -279,6 +342,18 @@ export default function BranchesView({ project, onBack }: BranchesViewProps): Re
+ {filter === 'stale' && staleBranches.length > 0 && selected.size === 0 && ( + + )} + {selected.size > 0 && ( + ))} + + )} + {uncommittedFiles.length > 0 && ( + <> +
+ Uncommitted ({uncommittedFiles.length}) +
+ {uncommittedFiles.map((f) => ( + + ))} + + )} +
+ + {/* Diff view */} +
+ {selectedFile ? ( + <> +
+ {selectedFile.path} + ({selectedFile.source}) +
+ {diffLoading ? ( +
Loading diff...
+ ) : diffLines.length === 0 ? ( +
No diff available
+ ) : ( +
+ {diffLines.map((line, i) => { + if (line.type === 'header') { + return ( +
+ {line.text} +
+ ) + } + const bg = + line.type === 'add' + ? 'bg-green-400/10' + : line.type === 'remove' + ? 'bg-red-400/10' + : '' + const markerColor = + line.type === 'add' + ? 'text-green-400' + : line.type === 'remove' + ? 'text-red-400' + : 'text-transparent' + const marker = line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' ' + return ( +
+ + {marker} + + + {line.text} + +
+ ) + })} +
+ )} + + ) : ( +
+ Select a file to view diff +
+ )} +
+ + ) +} + function BranchRow({ branch, + projectCwd, isMainWorktree, + stale, + staleReasonText, selected, canSelect, deleting, @@ -466,120 +785,224 @@ function BranchRow({ onDelete, onRemoveWorktree }: BranchRowProps): React.JSX.Element { + const [expanded, setExpanded] = useState(false) + const hasFiles = branch.aheadCount > 0 || branch.dirty + return ( -
- {/* Checkbox */} -
- {canSelect ? ( - - ) : ( -
- )} -
+
+
+ {/* Checkbox */} +
+ {canSelect ? ( + + ) : ( +
+ )} +
- {/* Branch name + badges */} -
-
- hasFiles && setExpanded((p) => !p)} + className={`w-4 flex items-center justify-center ${hasFiles ? 'text-gray-500 hover:text-gray-300' : 'text-gray-700 cursor-default'}`} + title={ + hasFiles ? (expanded ? 'Collapse details' : 'Show changed files') : 'No changed files' + } + > + - {branch.name} - + + + - {branch.isHead && ( - - HEAD + {/* Branch name + badges */} +
+
+ + {branch.name} - )} - {branch.gone && ( - - gone - - )} + {branch.isHead && ( + + HEAD + + )} + + {stale && ( + + stale + + )} + + {branch.gone && !stale && ( + + gone + + )} + + {branch.worktreePath && ( + + worktree + + )} + + {branch.dirty && ( + + dirty + + )} + + {!isMainWorktree && branch.aheadCount > 0 && ( + + +{branch.aheadCount} ahead + + )} + + {branch.pr.state === 'open' && ( + + PR #{branch.pr.number} + + )} + + {branch.pr.state === 'merged' && ( + + PR #{branch.pr.number} merged + + )} + + {branch.pr.state === 'closed' && ( + + PR #{branch.pr.number} closed + + )} + + {isMainWorktree && ( + + main + + )} +
- {branch.worktreePath && ( - - worktree - + {/* Stale reason */} + {stale && staleReasonText && ( +
{staleReasonText}
)} - {isMainWorktree && ( - - main - + {/* Commit subject */} +
+ {branch.lastCommitSubject} +
+ + {/* Worktree path */} + {branch.worktreePath && ( +
{branch.worktreePath}
)}
- {/* Commit subject */} -
{branch.lastCommitSubject}
- - {/* Worktree path */} - {branch.worktreePath && ( -
{branch.worktreePath}
- )} -
- - {/* Upstream */} -
- {branch.upstream || no upstream} -
+ {/* Upstream */} +
+ {branch.upstream || no upstream} +
- {/* Last commit */} -
- {branch.lastCommitRelative} -
+ {/* Last commit */} +
+ {branch.lastCommitRelative} +
- {/* Actions */} -
- {branch.worktreePath && !isMainWorktree && ( - - )} + + + + + )} - {canSelect && !isMainWorktree && ( - - )} + + + + + )} +
+ + {/* Expandable details panel */} + {expanded && }
) }