diff --git a/components/modals/RepoFormModal.tsx b/components/modals/RepoFormModal.tsx index b9f614f..ff465c2 100644 --- a/components/modals/RepoFormModal.tsx +++ b/components/modals/RepoFormModal.tsx @@ -1932,6 +1932,8 @@ const RepoEditView: React.FC = ({ 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; remote: Map }>({ local: new Map(), remote: new Map(), @@ -1968,6 +1970,7 @@ const RepoEditView: React.FC = ({ 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; @@ -2573,6 +2576,58 @@ const RepoEditView: React.FC = ({ 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) { @@ -3017,29 +3072,51 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor

Current branch: {branchInfo?.current}

-
+
setBranchFilter(event.target.value)} placeholder="Filter branches" - className={`${formInputStyle} flex-1`} + className={`${formInputStyle} flex-1 min-w-[12rem]`} /> - + {isGitRepo && ( <> - - Refreshing... + + - ) : ( - 'Refresh' )} - +

Tip: Use Shift or Ctrl/Cmd-click to select multiple branches.

diff --git a/electron/electron.d.ts b/electron/electron.d.ts index 6507106..31217df 100644 --- a/electron/electron.d.ts +++ b/electron/electron.d.ts @@ -33,6 +33,8 @@ export interface IElectronAPI { listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }) => Promise; 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 }>; diff --git a/electron/main.ts b/electron/main.ts index 832a441..7b34a7c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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(); + + 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(); diff --git a/electron/preload.ts b/electron/preload.ts index d9ecc0a..577f529 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', { listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }): Promise => 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), diff --git a/index.html b/index.html index f67772d..4ec49b0 100644 --- a/index.html +++ b/index.html @@ -177,6 +177,59 @@ await delay(150); return 'valid'; }, + async getProjectInfo(repoPath) { + await delay(200); + const repoId = repoIdByPath.get(repoPath); + const nodeCapabilities = { + engine: 'node@20.10.0', + declaredManager: 'npm', + packageManagers: { pnpm: true, yarn: false, npm: true, bun: false }, + typescript: true, + testFrameworks: ['vitest'], + linters: ['eslint', 'prettier'], + bundlers: ['vite'], + monorepo: { workspaces: false, turbo: false, nx: false, yarnBerryPnp: false }, + }; + + if (repoId === 'repo-ui-stability') { + return { + tags: ['node', 'react', 'frontend'], + files: { dproj: [], pomXml: [], csproj: [], sln: [] }, + nodejs: nodeCapabilities, + }; + } + + return { + tags: ['node', 'service'], + files: { dproj: [], pomXml: [], csproj: [], sln: [] }, + nodejs: { + ...nodeCapabilities, + testFrameworks: ['jest'], + }, + docker: { + composeFiles: ['docker-compose.yml'], + dockerfiles: ['Dockerfile'], + }, + }; + }, + async getProjectSuggestions({ repoPath }) { + await delay(120); + const repoId = repoIdByPath.get(repoPath); + if (repoId === 'repo-ui-stability') { + return [ + { label: 'Run lint', value: 'npm run lint', group: 'npm scripts' }, + { label: 'Launch Storybook', value: 'npm run storybook', group: 'npm scripts' }, + ]; + } + return [ + { label: 'Run integration tests', value: 'npm run test:integration', group: 'npm scripts' }, + { label: 'Start API locally', value: 'npm run dev', group: 'npm scripts' }, + ]; + }, + async getDelphiVersions() { + await delay(50); + return []; + }, async getDetailedVcsStatus(repo) { await delay(350); const nextIndex = refreshIndexByRepo.get(repo.id) === 1 ? 0 : 1; @@ -189,6 +242,57 @@ const index = repoId ? refreshIndexByRepo.get(repoId) ?? 0 : 0; return branchSamples[index]; }, + async pruneRemoteBranches() { + await delay(150); + console.info('Simulated pruning stale remote branches'); + return { success: true, message: 'Simulated prune completed.' }; + }, + async cleanupLocalBranches() { + await delay(150); + console.info('Simulated cleanup of merged or stale local branches'); + return { success: true, message: 'Simulated cleanup completed.' }; + }, + async getGithubPat() { + await delay(100); + return ''; + }, + async getAllReleases() { + await delay(200); + return [ + { + id: 1, + tagName: 'v1.1.0', + name: 'Stability Improvements', + body: 'Improved caching and added new dashboards.', + isDraft: false, + isPrerelease: false, + url: 'https://example.com/releases/v1.1.0', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), + }, + ]; + }, + async updateRelease() { + await delay(150); + return { success: true }; + }, + async createRelease() { + await delay(150); + return { success: true }; + }, + async deleteRelease() { + await delay(150); + return { success: true }; + }, + async discoverRemoteUrl({ localPath }) { + await delay(120); + const repoId = repoIdByPath.get(localPath); + const repo = sampleRepositories.find(r => r.id === repoId); + return { url: repo?.remoteUrl ?? null }; + }, + async showDirectoryPicker() { + await delay(80); + return { canceled: false, filePaths: ['/Users/dev/projects'] }; + }, async getLatestRelease() { await delay(200); return {