Skip to content

Commit fbff615

Browse files
authored
Merge pull request #101 from beNative/codex/add-buttons-to-prune-and-delete-branches
Add branch maintenance buttons to repository branches tab
2 parents 3964fa0 + 1830252 commit fbff615

File tree

5 files changed

+318
-14
lines changed

5 files changed

+318
-14
lines changed

components/modals/RepoFormModal.tsx

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,6 +1932,8 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
19321932
const [branchFilter, setBranchFilter] = useState('');
19331933
const [debouncedBranchFilter, setDebouncedBranchFilter] = useState('');
19341934
const [isDeletingBranches, setIsDeletingBranches] = useState(false);
1935+
const [isPruningRemoteBranches, setIsPruningRemoteBranches] = useState(false);
1936+
const [isCleaningLocalBranches, setIsCleaningLocalBranches] = useState(false);
19351937
const branchItemRefs = useRef<{ local: Map<string, HTMLDivElement>; remote: Map<string, HTMLDivElement> }>({
19361938
local: new Map<string, HTMLDivElement>(),
19371939
remote: new Map<string, HTMLDivElement>(),
@@ -1968,6 +1970,7 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
19681970

19691971
const formInputStyle = "block w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-1.5 px-3 text-gray-900 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500";
19701972
const formLabelStyle = "block text-sm font-medium text-gray-700 dark:text-gray-300";
1973+
const branchActionButtonStyle = 'inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed';
19711974

19721975
const isGitRepo = formData.vcs === VcsType.Git;
19731976
const isSvnRepo = formData.vcs === VcsType.Svn;
@@ -2573,6 +2576,58 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
25732576
});
25742577
}, [repository, selectedBranches, branchInfo?.current, confirmAction, setToast, fetchBranches, onRefreshState, isGitRepo]);
25752578

2579+
const handlePruneRemoteBranches = useCallback(async () => {
2580+
if (!repository) {
2581+
return;
2582+
}
2583+
if (!isGitRepo) {
2584+
setToast({ message: 'Remote pruning is only supported for Git repositories.', type: 'info' });
2585+
return;
2586+
}
2587+
2588+
setIsPruningRemoteBranches(true);
2589+
try {
2590+
const result = await window.electronAPI?.pruneRemoteBranches(repository.localPath);
2591+
if (result?.success) {
2592+
setToast({ message: result?.message ?? 'Pruned stale remote branches.', type: 'success' });
2593+
await fetchBranches();
2594+
await onRefreshState(repository.id);
2595+
} else {
2596+
setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' });
2597+
}
2598+
} catch (error: any) {
2599+
setToast({ message: `Error: ${error?.message || 'Failed to prune remote branches.'}`, type: 'error' });
2600+
} finally {
2601+
setIsPruningRemoteBranches(false);
2602+
}
2603+
}, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]);
2604+
2605+
const handleCleanupLocalBranches = useCallback(async () => {
2606+
if (!repository) {
2607+
return;
2608+
}
2609+
if (!isGitRepo) {
2610+
setToast({ message: 'Local branch cleanup is only supported for Git repositories.', type: 'info' });
2611+
return;
2612+
}
2613+
2614+
setIsCleaningLocalBranches(true);
2615+
try {
2616+
const result = await window.electronAPI?.cleanupLocalBranches(repository.localPath);
2617+
if (result?.success) {
2618+
setToast({ message: result?.message ?? 'Removed merged or stale local branches.', type: 'success' });
2619+
await fetchBranches();
2620+
await onRefreshState(repository.id);
2621+
} else {
2622+
setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' });
2623+
}
2624+
} catch (error: any) {
2625+
setToast({ message: `Error: ${error?.message || 'Failed to clean up local branches.'}`, type: 'error' });
2626+
} finally {
2627+
setIsCleaningLocalBranches(false);
2628+
}
2629+
}, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]);
2630+
25762631
const handleMergeBranch = async () => {
25772632
if (!repository || !branchToMerge) return;
25782633
if (!isGitRepo) {
@@ -3017,29 +3072,51 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
30173072
<p className="text-sm">
30183073
Current branch: <span className="font-bold font-mono text-blue-600 dark:text-blue-400">{branchInfo?.current}</span>
30193074
</p>
3020-
<div className="max-w-md flex flex-col sm:flex-row sm:items-center gap-2">
3075+
<div className="max-w-2xl flex flex-col sm:flex-row sm:items-center gap-2">
30213076
<input
30223077
type="text"
30233078
value={branchFilter}
30243079
onChange={event => setBranchFilter(event.target.value)}
30253080
placeholder="Filter branches"
3026-
className={`${formInputStyle} flex-1`}
3081+
className={`${formInputStyle} flex-1 min-w-[12rem]`}
30273082
/>
3028-
<button
3029-
type="button"
3030-
onClick={fetchBranches}
3031-
disabled={branchesLoading}
3032-
className="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-600 border border-blue-600 rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 hover:bg-blue-50 disabled:opacity-60 disabled:cursor-not-allowed"
3033-
>
3034-
{branchesLoading ? (
3083+
<div className="flex flex-wrap items-center gap-2">
3084+
<button
3085+
type="button"
3086+
onClick={fetchBranches}
3087+
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
3088+
className={`${branchActionButtonStyle} text-blue-600 border-blue-600 hover:bg-blue-50 focus-visible:ring-blue-500`}
3089+
>
3090+
{branchesLoading ? (
3091+
<>
3092+
<ArrowPathIcon className="h-4 w-4 animate-spin" />
3093+
Refreshing...
3094+
</>
3095+
) : (
3096+
'Refresh'
3097+
)}
3098+
</button>
3099+
{isGitRepo && (
30353100
<>
3036-
<ArrowPathIcon className="h-4 w-4 animate-spin" />
3037-
Refreshing...
3101+
<button
3102+
type="button"
3103+
onClick={handlePruneRemoteBranches}
3104+
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
3105+
className={`${branchActionButtonStyle} text-amber-600 border-amber-600 hover:bg-amber-50 focus-visible:ring-amber-500`}
3106+
>
3107+
{isPruningRemoteBranches ? 'Pruning…' : 'Prune Remotes'}
3108+
</button>
3109+
<button
3110+
type="button"
3111+
onClick={handleCleanupLocalBranches}
3112+
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
3113+
className={`${branchActionButtonStyle} text-emerald-600 border-emerald-600 hover:bg-emerald-50 focus-visible:ring-emerald-500`}
3114+
>
3115+
{isCleaningLocalBranches ? 'Cleaning…' : 'Clean Local Branches'}
3116+
</button>
30383117
</>
3039-
) : (
3040-
'Refresh'
30413118
)}
3042-
</button>
3119+
</div>
30433120
</div>
30443121
<p className="text-xs text-gray-500 dark:text-gray-400">Tip: Use Shift or Ctrl/Cmd-click to select multiple branches.</p>
30453122
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-6 overflow-hidden">

electron/electron.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface IElectronAPI {
3333
listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }) => Promise<BranchInfo>;
3434
checkoutBranch: (args: { repoPath: string; branch: string; vcs?: 'git' | 'svn' }) => Promise<{ success: boolean; error?: string }>;
3535
createBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
36+
pruneRemoteBranches: (repoPath: string) => Promise<{ success: boolean; error?: string; message?: string }>;
37+
cleanupLocalBranches: (repoPath: string) => Promise<{ success: boolean; error?: string; message?: string }>;
3638
deleteBranch: (repoPath: string, branch: string, isRemote: boolean, remoteName?: string) => Promise<{ success: boolean; error?: string }>;
3739
mergeBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
3840
ignoreFilesAndPush: (args: { repo: Repository; filesToIgnore: string[] }) => Promise<{ success: boolean; error?: string }>;

electron/main.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3297,6 +3297,125 @@ ipcMain.handle('checkout-branch', async (event, arg1: any, arg2?: any) => {
32973297
return { success: false, error: 'Unsupported repository type' };
32983298
});
32993299
ipcMain.handle('create-branch', (e, repoPath: string, branch: string) => simpleGitCommand(repoPath, `checkout -b ${branch}`));
3300+
ipcMain.handle('prune-stale-remote-branches', async (event, repoPath: string) => {
3301+
try {
3302+
const settings = await readSettings();
3303+
const gitCmd = getExecutableCommand(VcsTypeEnum.Git, settings);
3304+
const { stdout } = await execAsync(`${gitCmd} remote`, { cwd: repoPath });
3305+
const remoteNames = stdout
3306+
.split(/\r?\n/)
3307+
.map(name => name.trim())
3308+
.filter(Boolean);
3309+
3310+
if (remoteNames.length === 0) {
3311+
return { success: true, message: 'No remotes configured; nothing to prune.' };
3312+
}
3313+
3314+
for (const remoteName of remoteNames) {
3315+
await execAsync(`${gitCmd} remote prune ${JSON.stringify(remoteName)}`, { cwd: repoPath });
3316+
}
3317+
3318+
return {
3319+
success: true,
3320+
message: `Pruned stale branches from ${remoteNames.length} remote${remoteNames.length === 1 ? '' : 's'}.`,
3321+
};
3322+
} catch (error: any) {
3323+
return { success: false, error: error?.stderr || error?.message || 'Failed to prune remote branches.' };
3324+
}
3325+
});
3326+
ipcMain.handle('cleanup-merged-local-branches', async (event, repoPath: string) => {
3327+
try {
3328+
const settings = await readSettings();
3329+
const gitCmd = getExecutableCommand(VcsTypeEnum.Git, settings);
3330+
const deletableBranches = new Map<string, { force: boolean }>();
3331+
3332+
const { stdout: currentStdout } = await execAsync(`${gitCmd} branch --show-current`, { cwd: repoPath });
3333+
const currentBranch = currentStdout.trim();
3334+
3335+
const { stdout: allBranchesStdout } = await execAsync(`${gitCmd} branch --format="%(refname:short)"`, { cwd: repoPath });
3336+
const allBranches = allBranchesStdout
3337+
.split(/\r?\n/)
3338+
.map(name => name.trim())
3339+
.filter(Boolean);
3340+
const branchSet = new Set(allBranches);
3341+
3342+
const addBranchForDeletion = (branchName: string, force: boolean) => {
3343+
if (!branchName || !branchSet.has(branchName)) {
3344+
return;
3345+
}
3346+
if (branchName === currentBranch) {
3347+
return;
3348+
}
3349+
if (isProtectedBranch(branchName, 'local')) {
3350+
return;
3351+
}
3352+
3353+
const existing = deletableBranches.get(branchName);
3354+
if (existing) {
3355+
existing.force = existing.force || force;
3356+
return;
3357+
}
3358+
deletableBranches.set(branchName, { force });
3359+
};
3360+
3361+
try {
3362+
const { stdout: mergedStdout } = await execAsync(
3363+
`${gitCmd} branch --merged main --format="%(refname:short)"`,
3364+
{ cwd: repoPath }
3365+
);
3366+
mergedStdout
3367+
.split(/\r?\n/)
3368+
.map(name => name.trim())
3369+
.filter(Boolean)
3370+
.forEach(branchName => {
3371+
if (branchName !== 'main') {
3372+
addBranchForDeletion(branchName, false);
3373+
}
3374+
});
3375+
} catch (error: any) {
3376+
const message = error?.stderr || error?.message || '';
3377+
if (!/not a valid object name|unknown revision|did not match any file|unknown switch/.test(message)) {
3378+
return { success: false, error: message || 'Failed to determine merged branches.' };
3379+
}
3380+
}
3381+
3382+
const { stdout: verboseStdout } = await execAsync(`${gitCmd} branch -vv`, { cwd: repoPath });
3383+
verboseStdout
3384+
.split(/\r?\n/)
3385+
.map(line => line.trimEnd())
3386+
.filter(Boolean)
3387+
.forEach(line => {
3388+
const withoutMarker = line.startsWith('*') ? line.slice(1).trimStart() : line;
3389+
const firstSpaceIndex = withoutMarker.indexOf(' ');
3390+
const branchName = firstSpaceIndex === -1 ? withoutMarker : withoutMarker.slice(0, firstSpaceIndex);
3391+
const remainder = firstSpaceIndex === -1 ? '' : withoutMarker.slice(firstSpaceIndex + 1);
3392+
3393+
if (!branchName) {
3394+
return;
3395+
}
3396+
3397+
if (/\[.*gone.*\]/i.test(remainder)) {
3398+
addBranchForDeletion(branchName, true);
3399+
}
3400+
});
3401+
3402+
if (deletableBranches.size === 0) {
3403+
return { success: true, message: 'No merged or stale local branches found.' };
3404+
}
3405+
3406+
for (const [branchName, { force }] of deletableBranches) {
3407+
const deleteFlag = force ? '-D' : '-d';
3408+
await execAsync(`${gitCmd} branch ${deleteFlag} ${JSON.stringify(branchName)}`, { cwd: repoPath });
3409+
}
3410+
3411+
return {
3412+
success: true,
3413+
message: `Deleted ${deletableBranches.size} local branch${deletableBranches.size === 1 ? '' : 'es'}.`,
3414+
};
3415+
} catch (error: any) {
3416+
return { success: false, error: error?.stderr || error?.message || 'Failed to clean up local branches.' };
3417+
}
3418+
});
33003419
ipcMain.handle('delete-branch', (e, repoPath: string, branch: string, isRemote: boolean, remoteName?: string) => {
33013420
if (isRemote) {
33023421
const originalTrimmed = branch.trim();

electron/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
4141
listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }): Promise<BranchInfo> => ipcRenderer.invoke('list-branches', args),
4242
checkoutBranch: (args: { repoPath: string; branch: string; vcs?: 'git' | 'svn' }): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('checkout-branch', args),
4343
createBranch: (repoPath: string, branch: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('create-branch', repoPath, branch),
44+
pruneRemoteBranches: (repoPath: string): Promise<{ success: boolean; error?: string; message?: string }> => ipcRenderer.invoke('prune-stale-remote-branches', repoPath),
45+
cleanupLocalBranches: (repoPath: string): Promise<{ success: boolean; error?: string; message?: string }> => ipcRenderer.invoke('cleanup-merged-local-branches', repoPath),
4446
deleteBranch: (repoPath: string, branch: string, isRemote: boolean, remoteName?: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('delete-branch', repoPath, branch, isRemote, remoteName),
4547
mergeBranch: (repoPath: string, branch: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('merge-branch', repoPath, branch),
4648
ignoreFilesAndPush: (args: { repo: Repository, filesToIgnore: string[] }): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('ignore-files-and-push', args),

0 commit comments

Comments
 (0)