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
105 changes: 91 additions & 14 deletions components/modals/RepoFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,8 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
const [branchFilter, setBranchFilter] = useState('');
const [debouncedBranchFilter, setDebouncedBranchFilter] = useState('');
const [isDeletingBranches, setIsDeletingBranches] = useState(false);
const [isPruningRemoteBranches, setIsPruningRemoteBranches] = useState(false);
const [isCleaningLocalBranches, setIsCleaningLocalBranches] = useState(false);
const branchItemRefs = useRef<{ local: Map<string, HTMLDivElement>; remote: Map<string, HTMLDivElement> }>({
local: new Map<string, HTMLDivElement>(),
remote: new Map<string, HTMLDivElement>(),
Expand Down Expand Up @@ -1968,6 +1970,7 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor

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";
const formLabelStyle = "block text-sm font-medium text-gray-700 dark:text-gray-300";
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';

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

const handlePruneRemoteBranches = useCallback(async () => {
if (!repository) {
return;
}
if (!isGitRepo) {
setToast({ message: 'Remote pruning is only supported for Git repositories.', type: 'info' });
return;
}

setIsPruningRemoteBranches(true);
try {
const result = await window.electronAPI?.pruneRemoteBranches(repository.localPath);
if (result?.success) {
setToast({ message: result?.message ?? 'Pruned stale remote branches.', type: 'success' });
await fetchBranches();
await onRefreshState(repository.id);
} else {
setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' });
}
} catch (error: any) {
setToast({ message: `Error: ${error?.message || 'Failed to prune remote branches.'}`, type: 'error' });
} finally {
setIsPruningRemoteBranches(false);
}
}, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]);

const handleCleanupLocalBranches = useCallback(async () => {
if (!repository) {
return;
}
if (!isGitRepo) {
setToast({ message: 'Local branch cleanup is only supported for Git repositories.', type: 'info' });
return;
}

setIsCleaningLocalBranches(true);
try {
const result = await window.electronAPI?.cleanupLocalBranches(repository.localPath);
if (result?.success) {
setToast({ message: result?.message ?? 'Removed merged or stale local branches.', type: 'success' });
await fetchBranches();
await onRefreshState(repository.id);
} else {
setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' });
}
} catch (error: any) {
setToast({ message: `Error: ${error?.message || 'Failed to clean up local branches.'}`, type: 'error' });
} finally {
setIsCleaningLocalBranches(false);
}
}, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]);

const handleMergeBranch = async () => {
if (!repository || !branchToMerge) return;
if (!isGitRepo) {
Expand Down Expand Up @@ -3017,29 +3072,51 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
<p className="text-sm">
Current branch: <span className="font-bold font-mono text-blue-600 dark:text-blue-400">{branchInfo?.current}</span>
</p>
<div className="max-w-md flex flex-col sm:flex-row sm:items-center gap-2">
<div className="max-w-2xl flex flex-col sm:flex-row sm:items-center gap-2">
<input
type="text"
value={branchFilter}
onChange={event => setBranchFilter(event.target.value)}
placeholder="Filter branches"
className={`${formInputStyle} flex-1`}
className={`${formInputStyle} flex-1 min-w-[12rem]`}
/>
<button
type="button"
onClick={fetchBranches}
disabled={branchesLoading}
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"
>
{branchesLoading ? (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={fetchBranches}
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
className={`${branchActionButtonStyle} text-blue-600 border-blue-600 hover:bg-blue-50 focus-visible:ring-blue-500`}
>
{branchesLoading ? (
<>
<ArrowPathIcon className="h-4 w-4 animate-spin" />
Refreshing...
</>
) : (
'Refresh'
)}
</button>
{isGitRepo && (
<>
<ArrowPathIcon className="h-4 w-4 animate-spin" />
Refreshing...
<button
type="button"
onClick={handlePruneRemoteBranches}
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
className={`${branchActionButtonStyle} text-amber-600 border-amber-600 hover:bg-amber-50 focus-visible:ring-amber-500`}
>
{isPruningRemoteBranches ? 'Pruning…' : 'Prune Remotes'}
</button>
<button
type="button"
onClick={handleCleanupLocalBranches}
disabled={branchesLoading || isPruningRemoteBranches || isCleaningLocalBranches}
className={`${branchActionButtonStyle} text-emerald-600 border-emerald-600 hover:bg-emerald-50 focus-visible:ring-emerald-500`}
>
{isCleaningLocalBranches ? 'Cleaning…' : 'Clean Local Branches'}
</button>
</>
) : (
'Refresh'
)}
</button>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">Tip: Use Shift or Ctrl/Cmd-click to select multiple branches.</p>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-6 overflow-hidden">
Expand Down
2 changes: 2 additions & 0 deletions electron/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface IElectronAPI {
listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }) => Promise<BranchInfo>;
checkoutBranch: (args: { repoPath: string; branch: string; vcs?: 'git' | 'svn' }) => Promise<{ success: boolean; error?: string }>;
createBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
pruneRemoteBranches: (repoPath: string) => Promise<{ success: boolean; error?: string; message?: string }>;
cleanupLocalBranches: (repoPath: string) => Promise<{ success: boolean; error?: string; message?: string }>;
deleteBranch: (repoPath: string, branch: string, isRemote: boolean, remoteName?: string) => Promise<{ success: boolean; error?: string }>;
mergeBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
ignoreFilesAndPush: (args: { repo: Repository; filesToIgnore: string[] }) => Promise<{ success: boolean; error?: string }>;
Expand Down
119 changes: 119 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3297,6 +3297,125 @@ ipcMain.handle('checkout-branch', async (event, arg1: any, arg2?: any) => {
return { success: false, error: 'Unsupported repository type' };
});
ipcMain.handle('create-branch', (e, repoPath: string, branch: string) => simpleGitCommand(repoPath, `checkout -b ${branch}`));
ipcMain.handle('prune-stale-remote-branches', async (event, repoPath: string) => {
try {
const settings = await readSettings();
const gitCmd = getExecutableCommand(VcsTypeEnum.Git, settings);
const { stdout } = await execAsync(`${gitCmd} remote`, { cwd: repoPath });
const remoteNames = stdout
.split(/\r?\n/)
.map(name => name.trim())
.filter(Boolean);

if (remoteNames.length === 0) {
return { success: true, message: 'No remotes configured; nothing to prune.' };
}

for (const remoteName of remoteNames) {
await execAsync(`${gitCmd} remote prune ${JSON.stringify(remoteName)}`, { cwd: repoPath });
}

return {
success: true,
message: `Pruned stale branches from ${remoteNames.length} remote${remoteNames.length === 1 ? '' : 's'}.`,
};
} catch (error: any) {
return { success: false, error: error?.stderr || error?.message || 'Failed to prune remote branches.' };
}
});
ipcMain.handle('cleanup-merged-local-branches', async (event, repoPath: string) => {
try {
const settings = await readSettings();
const gitCmd = getExecutableCommand(VcsTypeEnum.Git, settings);
const deletableBranches = new Map<string, { force: boolean }>();

const { stdout: currentStdout } = await execAsync(`${gitCmd} branch --show-current`, { cwd: repoPath });
const currentBranch = currentStdout.trim();

const { stdout: allBranchesStdout } = await execAsync(`${gitCmd} branch --format="%(refname:short)"`, { cwd: repoPath });
const allBranches = allBranchesStdout
.split(/\r?\n/)
.map(name => name.trim())
.filter(Boolean);
const branchSet = new Set(allBranches);

const addBranchForDeletion = (branchName: string, force: boolean) => {
if (!branchName || !branchSet.has(branchName)) {
return;
}
if (branchName === currentBranch) {
return;
}
if (isProtectedBranch(branchName, 'local')) {
return;
}

const existing = deletableBranches.get(branchName);
if (existing) {
existing.force = existing.force || force;
return;
}
deletableBranches.set(branchName, { force });
};

try {
const { stdout: mergedStdout } = await execAsync(
`${gitCmd} branch --merged main --format="%(refname:short)"`,
{ cwd: repoPath }
);
mergedStdout
.split(/\r?\n/)
.map(name => name.trim())
.filter(Boolean)
.forEach(branchName => {
if (branchName !== 'main') {
addBranchForDeletion(branchName, false);
}
});
} catch (error: any) {
const message = error?.stderr || error?.message || '';
if (!/not a valid object name|unknown revision|did not match any file|unknown switch/.test(message)) {
return { success: false, error: message || 'Failed to determine merged branches.' };
}
}

const { stdout: verboseStdout } = await execAsync(`${gitCmd} branch -vv`, { cwd: repoPath });
verboseStdout
.split(/\r?\n/)
.map(line => line.trimEnd())
.filter(Boolean)
.forEach(line => {
const withoutMarker = line.startsWith('*') ? line.slice(1).trimStart() : line;
const firstSpaceIndex = withoutMarker.indexOf(' ');
const branchName = firstSpaceIndex === -1 ? withoutMarker : withoutMarker.slice(0, firstSpaceIndex);
const remainder = firstSpaceIndex === -1 ? '' : withoutMarker.slice(firstSpaceIndex + 1);

if (!branchName) {
return;
}

if (/\[.*gone.*\]/i.test(remainder)) {
addBranchForDeletion(branchName, true);
}
});

if (deletableBranches.size === 0) {
return { success: true, message: 'No merged or stale local branches found.' };
}

for (const [branchName, { force }] of deletableBranches) {
const deleteFlag = force ? '-D' : '-d';
await execAsync(`${gitCmd} branch ${deleteFlag} ${JSON.stringify(branchName)}`, { cwd: repoPath });
}

return {
success: true,
message: `Deleted ${deletableBranches.size} local branch${deletableBranches.size === 1 ? '' : 'es'}.`,
};
} catch (error: any) {
return { success: false, error: error?.stderr || error?.message || 'Failed to clean up local branches.' };
}
});
ipcMain.handle('delete-branch', (e, repoPath: string, branch: string, isRemote: boolean, remoteName?: string) => {
if (isRemote) {
const originalTrimmed = branch.trim();
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }): Promise<BranchInfo> => ipcRenderer.invoke('list-branches', args),
checkoutBranch: (args: { repoPath: string; branch: string; vcs?: 'git' | 'svn' }): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('checkout-branch', args),
createBranch: (repoPath: string, branch: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('create-branch', repoPath, branch),
pruneRemoteBranches: (repoPath: string): Promise<{ success: boolean; error?: string; message?: string }> => ipcRenderer.invoke('prune-stale-remote-branches', repoPath),
cleanupLocalBranches: (repoPath: string): Promise<{ success: boolean; error?: string; message?: string }> => ipcRenderer.invoke('cleanup-merged-local-branches', repoPath),
deleteBranch: (repoPath: string, branch: string, isRemote: boolean, remoteName?: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('delete-branch', repoPath, branch, isRemote, remoteName),
mergeBranch: (repoPath: string, branch: string): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('merge-branch', repoPath, branch),
ignoreFilesAndPush: (args: { repo: Repository, filesToIgnore: string[] }): Promise<{ success: boolean, error?: string }> => ipcRenderer.invoke('ignore-files-and-push', args),
Expand Down
Loading