diff --git a/src/main/ipc/gitIpc.ts b/src/main/ipc/gitIpc.ts index 387600cb7..18a0e45f4 100644 --- a/src/main/ipc/gitIpc.ts +++ b/src/main/ipc/gitIpc.ts @@ -2608,4 +2608,58 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); + + ipcMain.handle( + 'git:delete-remote-branch', + async (_, args: { projectPath: string; branch: string; remote?: string }) => { + try { + const { remoteBranchService } = await import('../services/RemoteBranchService'); + const result = await remoteBranchService.deleteRemoteBranch( + args.projectPath, + args.branch, + args.remote + ); + return { ...result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + ); + + ipcMain.handle( + 'git:evaluate-branch-cleanup', + async ( + _, + args: { + projectPath: string; + branch: string; + mode: string; + daysThreshold: number; + } + ) => { + try { + const { remoteBranchService } = await import('../services/RemoteBranchService'); + const { isValidRemoteBranchCleanupMode } = await import('../../shared/remoteBranchCleanup'); + if (!isValidRemoteBranchCleanupMode(args.mode)) { + return { success: true, action: 'skip' as const }; + } + const action = await remoteBranchService.evaluateCleanupAction( + args.projectPath, + args.branch, + args.mode, + args.daysThreshold + ); + return { success: true, action }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + action: 'skip' as const, + }; + } + } + ); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 7ad4ccd93..b29708a93 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -222,6 +222,7 @@ contextBridge.exposeInMainWorld('electronAPI', { worktreePath?: string; branch?: string; taskName?: string; + deleteRemoteBranch?: boolean; }) => ipcRenderer.invoke('worktree:remove', args), worktreeStatus: (args: { worktreePath: string }) => ipcRenderer.invoke('worktree:status', args), worktreeMerge: (args: { projectPath: string; worktreeId: string }) => @@ -394,6 +395,14 @@ contextBridge.exposeInMainWorld('electronAPI', { forceLarge?: boolean; }) => ipcRenderer.invoke('git:get-commit-file-diff', args), gitSoftReset: (args: { taskPath: string }) => ipcRenderer.invoke('git:soft-reset', args), + deleteRemoteBranch: (args: { projectPath: string; branch: string; remote?: string }) => + ipcRenderer.invoke('git:delete-remote-branch', args), + evaluateBranchCleanup: (args: { + projectPath: string; + branch: string; + mode: string; + daysThreshold: number; + }) => ipcRenderer.invoke('git:evaluate-branch-cleanup', args), gitCommitAndPush: (args: { taskPath: string; commitMessage?: string; @@ -862,6 +871,7 @@ export interface ElectronAPI { worktreePath?: string; branch?: string; taskName?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; diff --git a/src/main/services/RemoteBranchService.ts b/src/main/services/RemoteBranchService.ts new file mode 100644 index 000000000..4d5b376fd --- /dev/null +++ b/src/main/services/RemoteBranchService.ts @@ -0,0 +1,212 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { log } from '../lib/logger'; + +const execFileAsync = promisify(execFile); + +/** + * Result of a remote branch deletion attempt. + */ +export interface RemoteBranchDeletionResult { + /** Whether the deletion was executed (true even if the branch was already absent). */ + success: boolean; + /** True if the branch did not exist on the remote (already deleted or never pushed). */ + alreadyAbsent: boolean; + /** True if the remote ('origin' by default) is not configured for the repo. */ + noRemote: boolean; + /** Human-readable detail message suitable for logs / UI toasts. */ + message: string; +} + +/** Patterns that indicate the remote branch was already gone. */ +const ALREADY_ABSENT_PATTERNS = [ + /remote ref does not exist/i, + /unknown revision/i, + /error: unable to delete '[^']*': remote ref does not exist/i, +]; + +/** + * Check whether a given remote alias exists for the repository. + */ +async function hasRemote(projectPath: string, remote: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['remote'], { cwd: projectPath }); + return stdout + .split('\n') + .map((l) => l.trim()) + .includes(remote); + } catch { + return false; + } +} + +/** + * Determine the date (ISO string) of the most recent commit on a branch. + * Returns `null` if the branch doesn't exist locally. + */ +async function getLastCommitDate(projectPath: string, branch: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%aI', branch, '--'], { + cwd: projectPath, + }); + const trimmed = stdout.trim(); + return trimmed || null; + } catch { + return null; + } +} + +/** + * Dedicated service for managing remote branch lifecycle operations. + * + * This service encapsulates the logic for deleting remote branches + * with robust error handling and graceful degradation for common + * failure scenarios (branch already deleted, no remote configured, + * network timeouts, etc.). + */ +export class RemoteBranchService { + /** + * Delete a remote branch. + * + * @param projectPath — Absolute path to the local git repository (or worktree root). + * @param branch — Branch name to delete on the remote. May optionally include + * the `origin/` prefix, which is stripped automatically. + * @param remote — Remote alias (defaults to `'origin'`). + * + * @returns `RemoteBranchDeletionResult` describing the outcome. The method + * **never throws** — all errors are caught and returned as a failed result. + */ + async deleteRemoteBranch( + projectPath: string, + branch: string, + remote = 'origin' + ): Promise { + // ------------------------------------------------------------------- + // Guard: no remote configured + // ------------------------------------------------------------------- + try { + const remoteExists = await hasRemote(projectPath, remote); + if (!remoteExists) { + const msg = `Skipping remote branch deletion — no remote "${remote}" configured.`; + log.info(msg); + return { success: true, alreadyAbsent: false, noRemote: true, message: msg }; + } + } catch (err) { + const msg = `Could not verify remote "${remote}" existence: ${String(err)}`; + log.warn(msg); + return { success: false, alreadyAbsent: false, noRemote: false, message: msg }; + } + + // Normalise: remove leading "origin/" if present + let remoteBranch = branch; + const prefixPattern = new RegExp(`^${remote}/`); + if (prefixPattern.test(remoteBranch)) { + remoteBranch = remoteBranch.replace(prefixPattern, ''); + } + + if (!remoteBranch) { + const msg = 'Cannot delete remote branch — empty branch name.'; + log.warn(msg); + return { success: false, alreadyAbsent: false, noRemote: false, message: msg }; + } + + // ------------------------------------------------------------------- + // Execute: git push --delete + // ------------------------------------------------------------------- + try { + await execFileAsync('git', ['push', remote, '--delete', remoteBranch], { + cwd: projectPath, + timeout: 30_000, // 30 s network timeout + }); + const msg = `Deleted remote branch ${remote}/${remoteBranch}.`; + log.info(msg); + return { success: true, alreadyAbsent: false, noRemote: false, message: msg }; + } catch (error: unknown) { + const stderr = extractStderr(error); + + // Known benign errors: branch was already absent + if (ALREADY_ABSENT_PATTERNS.some((pattern) => pattern.test(stderr))) { + const msg = `Remote branch ${remote}/${remoteBranch} already absent.`; + log.info(msg); + return { success: true, alreadyAbsent: true, noRemote: false, message: msg }; + } + + // Unknown / network error — log, but don't throw + const msg = `Failed to delete remote branch ${remote}/${remoteBranch}: ${stderr}`; + log.warn(msg); + return { success: false, alreadyAbsent: false, noRemote: false, message: msg }; + } + } + + /** + * Determine whether a branch qualifies as "stale" based on the configured + * days threshold and the date of its most recent commit. + * + * @returns `true` if the branch's last commit is older than `daysThreshold` + * days, or if the last commit date cannot be determined (conservative). + */ + async isBranchStale( + projectPath: string, + branch: string, + daysThreshold: number + ): Promise { + const dateStr = await getLastCommitDate(projectPath, branch); + if (!dateStr) { + // Cannot determine — fail closed (skip deletion) to prevent + // accidental removal of live branches we can't verify. + return false; + } + + const commitDate = new Date(dateStr); + if (isNaN(commitDate.getTime())) { + return false; + } + + const now = new Date(); + const diffMs = now.getTime() - commitDate.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays >= daysThreshold; + } + + /** + * Evaluate the cleanup setting to decide whether a remote branch should be + * deleted right now. This does **not** perform the deletion — call + * `deleteRemoteBranch()` separately if the answer is `'delete'`. + * + * @returns `'delete'` | `'skip'` | `'ask'` + */ + async evaluateCleanupAction( + projectPath: string, + branch: string, + mode: import('@shared/remoteBranchCleanup').RemoteBranchCleanupMode, + daysThreshold: number + ): Promise<'delete' | 'skip' | 'ask'> { + switch (mode) { + case 'always': + return 'delete'; + case 'never': + return 'skip'; + case 'ask': + return 'ask'; + case 'auto': { + const stale = await this.isBranchStale(projectPath, branch, daysThreshold); + return stale ? 'delete' : 'skip'; + } + default: + return 'skip'; + } + } +} + +/** Extract the stderr string from a child-process error. */ +function extractStderr(error: unknown): string { + if (error && typeof error === 'object') { + const e = error as Record; + if (typeof e.stderr === 'string' && e.stderr) return e.stderr; + if (typeof e.message === 'string' && e.message) return e.message; + } + return String(error); +} + +/** Module-level singleton, following the project convention. */ +export const remoteBranchService = new RemoteBranchService(); diff --git a/src/main/services/WorktreeService.ts b/src/main/services/WorktreeService.ts index 2ae09511d..508c5f0c0 100644 --- a/src/main/services/WorktreeService.ts +++ b/src/main/services/WorktreeService.ts @@ -5,6 +5,7 @@ import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; import { projectSettingsService } from './ProjectSettingsService'; +import { remoteBranchService } from './RemoteBranchService'; import { minimatch } from 'minimatch'; import { errorTracking } from '../errorTracking'; @@ -405,7 +406,8 @@ export class WorktreeService { projectPath: string, worktreeId: string, worktreePath?: string, - branch?: string + branch?: string, + options?: { deleteRemoteBranch?: boolean } ): Promise { try { const worktree = this.worktrees.get(worktreeId); @@ -516,36 +518,15 @@ export class WorktreeService { } } - // Only try to delete remote branch if a remote exists - const remoteAlias = 'origin'; - const hasRemote = await this.hasRemote(projectPath, remoteAlias); - if (hasRemote) { - let remoteBranchName = branchToDelete; - if (branchToDelete.startsWith('origin/')) { - remoteBranchName = branchToDelete.replace(/^origin\//, ''); - } - try { - await execFileAsync('git', ['push', remoteAlias, '--delete', remoteBranchName], { - cwd: projectPath, - }); - log.info(`Deleted remote branch ${remoteAlias}/${remoteBranchName}`); - } catch (remoteError: any) { - const msg = String(remoteError?.stderr || remoteError?.message || remoteError); - if ( - /remote ref does not exist/i.test(msg) || - /unknown revision/i.test(msg) || - /not found/i.test(msg) - ) { - log.info(`Remote branch ${remoteAlias}/${remoteBranchName} already absent`); - } else { - log.warn( - `Failed to delete remote branch ${remoteAlias}/${remoteBranchName}:`, - remoteError - ); - } - } + // Delete remote branch only when explicitly requested via the cleanup setting. + // The caller (worktreeIpc / task management) evaluates the user's preference + // and passes `deleteRemoteBranch: true` when appropriate. + if (options?.deleteRemoteBranch) { + await remoteBranchService.deleteRemoteBranch(projectPath, branchToDelete); } else { - log.info(`Skipping remote branch deletion - no remote configured (local-only repo)`); + log.info( + `Skipping remote branch deletion for ${branchToDelete} (deleteRemoteBranch not requested)` + ); } } diff --git a/src/main/services/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 530fdb4bf..a7d07025a 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -144,6 +144,7 @@ export function registerWorktreeIpc(): void { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; } ) => { try { @@ -187,7 +188,8 @@ export function registerWorktreeIpc(): void { args.projectPath, args.worktreeId, args.worktreePath, - args.branch + args.branch, + { deleteRemoteBranch: args.deleteRemoteBranch } ); return { success: true }; } catch (error) { diff --git a/src/main/settings.ts b/src/main/settings.ts index beff22606..335d400f0 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -5,6 +5,13 @@ import { homedir } from 'node:os'; import type { ProviderId } from '@shared/providers/registry'; import { isValidProviderId } from '@shared/providers/registry'; import { isValidOpenInAppId, type OpenInAppId } from '@shared/openInApps'; +import type { RemoteBranchCleanupMode } from '@shared/remoteBranchCleanup'; +import { + isValidRemoteBranchCleanupMode, + clampCleanupDays, + DEFAULT_REMOTE_BRANCH_CLEANUP_MODE, + DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS, +} from '@shared/remoteBranchCleanup'; export type DeepPartial = { [K in keyof T]?: NonNullable extends object ? DeepPartial> : T[K]; @@ -18,6 +25,10 @@ const IS_MAC = process.platform === 'darwin'; export interface RepositorySettings { branchPrefix: string; // e.g., 'emdash' pushOnCreate: boolean; + /** How to handle remote branches when a task is archived or deleted. */ + remoteBranchCleanup: RemoteBranchCleanupMode; + /** Number of days after which remote branches are auto-deleted (only for 'auto' mode). */ + remoteBranchCleanupDaysThreshold: number; } export type ShortcutModifier = @@ -134,6 +145,8 @@ const DEFAULT_SETTINGS: AppSettings = { repository: { branchPrefix: 'emdash', pushOnCreate: true, + remoteBranchCleanup: DEFAULT_REMOTE_BRANCH_CLEANUP_MODE, + remoteBranchCleanupDaysThreshold: DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS, }, projectPrep: { autoInstallOnOpenInEditor: true, @@ -339,6 +352,9 @@ export function normalizeSettings(input: AppSettings): AppSettings { repository: { branchPrefix: DEFAULT_SETTINGS.repository.branchPrefix, pushOnCreate: DEFAULT_SETTINGS.repository.pushOnCreate, + remoteBranchCleanup: DEFAULT_SETTINGS.repository.remoteBranchCleanup, + remoteBranchCleanupDaysThreshold: + DEFAULT_SETTINGS.repository.remoteBranchCleanupDaysThreshold, }, projectPrep: { autoInstallOnOpenInEditor: DEFAULT_SETTINGS.projectPrep.autoInstallOnOpenInEditor, @@ -365,6 +381,16 @@ export function normalizeSettings(input: AppSettings): AppSettings { out.repository.branchPrefix = prefix; out.repository.pushOnCreate = push; + + // Remote branch cleanup + const rawCleanupMode = repo?.remoteBranchCleanup; + out.repository.remoteBranchCleanup = isValidRemoteBranchCleanupMode(rawCleanupMode) + ? rawCleanupMode + : DEFAULT_REMOTE_BRANCH_CLEANUP_MODE; + out.repository.remoteBranchCleanupDaysThreshold = clampCleanupDays( + repo?.remoteBranchCleanupDaysThreshold + ); + // Project prep const prep = (input as any)?.projectPrep || {}; out.projectPrep.autoInstallOnOpenInEditor = Boolean( diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f7889a59b..29d0842c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -36,13 +36,13 @@ export function App() { - - + + {renderContent()} - - + + diff --git a/src/renderer/components/ConfirmModal.tsx b/src/renderer/components/ConfirmModal.tsx new file mode 100644 index 000000000..79f977d6b --- /dev/null +++ b/src/renderer/components/ConfirmModal.tsx @@ -0,0 +1,58 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import type { BaseModalProps } from '../contexts/ModalProvider'; + +export interface ConfirmModalProps extends BaseModalProps { + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; +} + +/** + * A reusable confirmation modal that integrates with the app's ModalProvider. + * + * Usage via `showModal('confirmModal', { title, description, onSuccess })`. + * Calls `onSuccess(true)` when the user confirms, `onClose()` when cancelled. + */ +export function ConfirmModal({ + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + onSuccess, + onClose, +}: ConfirmModalProps) { + return ( + { + if (!open) onClose(); + }} + > + + + {title} + + {description} + + {cancelLabel} + onSuccess(true)} + className="bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90" + > + {confirmLabel} + + + + + ); +} diff --git a/src/renderer/components/RemoteBranchCleanupCard.tsx b/src/renderer/components/RemoteBranchCleanupCard.tsx new file mode 100644 index 000000000..d7e5e7ce6 --- /dev/null +++ b/src/renderer/components/RemoteBranchCleanupCard.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { useAppSettings } from '@/contexts/AppSettingsProvider'; +import { Input } from './ui/input'; +import type { RemoteBranchCleanupMode } from '../../shared/remoteBranchCleanup'; + +const CLEANUP_OPTIONS: { value: RemoteBranchCleanupMode; label: string; description: string }[] = [ + { + value: 'never', + label: 'Never delete', + description: 'Keep remote branches when archiving or deleting tasks (default).', + }, + { + value: 'ask', + label: 'Ask every time', + description: 'Prompt before deleting the remote branch.', + }, + { + value: 'always', + label: 'Always delete', + description: 'Automatically delete the remote branch on archive or delete.', + }, + { + value: 'auto', + label: 'Auto-delete after threshold', + description: + 'Delete remote branches whose last commit is older than a configurable number of days.', + }, +]; + +const RemoteBranchCleanupCard: React.FC = () => { + const { settings, updateSettings, isLoading: loading, isSaving: saving } = useAppSettings(); + + const currentMode: RemoteBranchCleanupMode = settings?.repository?.remoteBranchCleanup ?? 'never'; + const currentDays = settings?.repository?.remoteBranchCleanupDaysThreshold ?? 7; + + // Controlled local state for the days input, synced with settings + const [localDays, setLocalDays] = useState(String(currentDays)); + useEffect(() => { + setLocalDays(String(currentDays)); + }, [currentDays]); + + return ( +
+

+ Choose how remote branches are handled when a task is archived or deleted. +

+ +
+ {CLEANUP_OPTIONS.map((option) => { + const isSelected = currentMode === option.value; + return ( + + ); + })} +
+ + {/* Days threshold — only visible in 'auto' mode */} + {currentMode === 'auto' && ( +
+ +
+ { + setLocalDays(e.target.value); + }} + onBlur={() => { + const raw = parseInt(localDays, 10); + if (!Number.isFinite(raw) || raw < 1) { + setLocalDays(String(currentDays)); + return; + } + const clamped = Math.min(365, Math.max(1, raw)); + setLocalDays(String(clamped)); + updateSettings({ + repository: { remoteBranchCleanupDaysThreshold: clamped }, + }); + }} + disabled={loading || saving} + className="w-24" + aria-label="Days threshold for auto-delete" + /> + Days since last commit +
+
+ )} +
+ ); +}; + +export default RemoteBranchCleanupCard; diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index 699ee704b..283a6a4f0 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -19,6 +19,7 @@ import { } from './TaskSettingsRows'; import IntegrationsCard from './IntegrationsCard'; import RepositorySettingsCard from './RepositorySettingsCard'; +import RemoteBranchCleanupCard from './RemoteBranchCleanupCard'; import ThemeCard from './ThemeCard'; import KeyboardSettingsCard from './KeyboardSettingsCard'; import RightSidebarSettingsCard from './RightSidebarSettingsCard'; @@ -241,7 +242,10 @@ export const SettingsPage: React.FC = ({ initialTab, onClose repository: { title: 'Repository', description: 'Configure repository and branch settings.', - sections: [{ title: 'Branch name', component: }], + sections: [ + { title: 'Branch name', component: }, + { title: 'Remote branch cleanup', component: }, + ], }, interface: { title: 'Interface', diff --git a/src/renderer/contexts/ModalProvider.tsx b/src/renderer/contexts/ModalProvider.tsx index 915099a5c..871902d82 100644 --- a/src/renderer/contexts/ModalProvider.tsx +++ b/src/renderer/contexts/ModalProvider.tsx @@ -7,6 +7,7 @@ import { AddRemoteProjectModal } from '@/components/ssh/AddRemoteProjectModal'; import { GithubDeviceFlowModalOverlay } from '@/components/GithubDeviceFlowModal'; import { McpServerModal } from '@/components/mcp/McpServerModal'; import { ChangelogModalOverlay } from '@/components/ChangelogModal'; +import { ConfirmModal } from '@/components/ConfirmModal'; // Define overlays here so we can use them in the showOverlay function const modalRegistry = { @@ -18,6 +19,7 @@ const modalRegistry = { addRemoteProjectModal: AddRemoteProjectModal, githubDeviceFlowModal: GithubDeviceFlowModalOverlay, mcpServerModal: McpServerModal, + confirmModal: ConfirmModal, // eslint-disable-next-line @typescript-eslint/no-explicit-any } satisfies Record>; diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index f36079918..5ba8568d7 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -21,6 +21,8 @@ import { createTask } from '../lib/taskCreationService'; import { useProjectManagementContext } from '../contexts/ProjectManagementProvider'; import { useToast } from './use-toast'; import { useModalContext } from '../contexts/ModalProvider'; +import { useAppSettings } from '../contexts/AppSettingsProvider'; +import type { RemoteBranchCleanupMode } from '../../shared/remoteBranchCleanup'; const LIFECYCLE_TEARDOWN_TIMEOUT_MS = 15000; type LifecycleTarget = { taskId: string; taskPath: string; label: string }; @@ -158,6 +160,7 @@ export function useTaskManagement() { const { toast } = useToast(); const { showModal } = useModalContext(); + const { settings } = useAppSettings(); const queryClient = useQueryClient(); // --------------------------------------------------------------------------- @@ -318,6 +321,62 @@ export function useTaskManagement() { } }; + // --------------------------------------------------------------------------- + // Remote branch cleanup helper + // --------------------------------------------------------------------------- + /** + * Determine whether the remote branch should be deleted based on the user's + * configured cleanup mode. For 'ask' mode, shows a confirmation dialog. + * Returns `true` if the remote branch should be deleted. + */ + const shouldDeleteRemoteBranch = async (project: Project, task: Task): Promise => { + const mode: RemoteBranchCleanupMode = settings?.repository?.remoteBranchCleanup ?? 'never'; + + // Fast exit for the default/no-op mode + if (mode === 'never') return false; + + // No branch to clean up + if (!task.branch) return false; + + // If the task doesn't use a worktree and has no branch, skip + if (task.useWorktree === false) return false; + + try { + const result = await window.electronAPI.evaluateBranchCleanup({ + projectPath: project.path, + branch: task.branch, + mode, + daysThreshold: settings?.repository?.remoteBranchCleanupDaysThreshold ?? 7, + }); + + if (!result.success) return false; + + switch (result.action) { + case 'delete': + return true; + case 'ask': { + const confirmed = await new Promise((resolve) => { + showModal('confirmModal', { + title: 'Delete remote branch?', + description: `Also delete remote branch "${task.branch}" on origin?`, + confirmLabel: 'Delete', + cancelLabel: 'Keep', + onSuccess: () => resolve(true), + onClose: () => resolve(false), + }); + }); + return confirmed; + } + case 'skip': + default: + return false; + } + } catch { + // Never block task deletion over a remote branch failure + return false; + } + }; + // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- @@ -402,10 +461,12 @@ export function useTaskManagement() { project, task, options, + deleteRemoteBranch, }: { project: Project; task: Task; options?: { silent?: boolean }; + deleteRemoteBranch?: boolean; }) => { await runLifecycleTeardownBestEffort(project, task, 'delete', options); @@ -488,6 +549,7 @@ export function useTaskManagement() { worktreePath: task.path, branch: task.branch, taskName: task.name, + deleteRemoteBranch, }) ); } @@ -570,14 +632,21 @@ export function useTaskManagement() { }); return false; } + // Resolve remote branch cleanup intent BEFORE the optimistic mutation + const deleteRemote = await shouldDeleteRemoteBranch(targetProject, task); try { - await deleteTaskMutation.mutateAsync({ project: targetProject, task, options }); + await deleteTaskMutation.mutateAsync({ + project: targetProject, + task, + options, + deleteRemoteBranch: deleteRemote, + }); return true; } catch { return false; } }, - [deleteTaskMutation, toast] + [deleteTaskMutation, toast, settings, showModal] ); // --------------------------------------------------------------------------- @@ -588,10 +657,12 @@ export function useTaskManagement() { project, task, options, + deleteRemoteBranch, }: { project: Project; task: Task; options?: { silent?: boolean }; + deleteRemoteBranch?: boolean; }) => { // PTY cleanup in background — don't block the UI void cleanupPtyResources(task); @@ -599,6 +670,21 @@ export function useTaskManagement() { await runLifecycleTeardownBestEffort(project, task, 'archive', options); await rpc.db.archiveTask(task.id); + // Delete the remote branch if the user/setting requests it. + // This runs after the DB archive so task state is persisted + // even if the network call fails. + if (deleteRemoteBranch && task.branch) { + try { + await window.electronAPI.deleteRemoteBranch({ + projectPath: project.path, + branch: task.branch, + }); + } catch (err) { + const { log } = await import('../lib/logger'); + log.warn('Remote branch deletion failed during archive (non-fatal):', err as Error); + } + } + for (const lifecycleTaskId of getLifecycleTaskIds(task)) { try { await window.electronAPI.lifecycleClearTask({ taskId: lifecycleTaskId }); @@ -648,14 +734,21 @@ export function useTaskManagement() { options?: { silent?: boolean } ): Promise => { if (archivingTaskIdsRef.current.has(task.id)) return false; + // Resolve remote branch cleanup intent BEFORE the optimistic mutation + const deleteRemote = await shouldDeleteRemoteBranch(targetProject, task); try { - await archiveTaskMutation.mutateAsync({ project: targetProject, task, options }); + await archiveTaskMutation.mutateAsync({ + project: targetProject, + task, + options, + deleteRemoteBranch: deleteRemote, + }); return true; } catch { return false; } }, - [archiveTaskMutation] + [archiveTaskMutation, settings, showModal] ); // --------------------------------------------------------------------------- diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 7a5b5607d..cf529b354 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -175,6 +175,7 @@ declare global { worktreePath?: string; branch?: string; taskName?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; @@ -487,6 +488,27 @@ declare global { body?: string; error?: string; }>; + deleteRemoteBranch: (args: { + projectPath: string; + branch: string; + remote?: string; + }) => Promise<{ + success: boolean; + alreadyAbsent?: boolean; + noRemote?: boolean; + message?: string; + error?: string; + }>; + evaluateBranchCleanup: (args: { + projectPath: string; + branch: string; + mode: string; + daysThreshold: number; + }) => Promise<{ + success: boolean; + action?: 'delete' | 'skip' | 'ask'; + error?: string; + }>; gitCommitAndPush: (args: { taskPath: string; commitMessage?: string; @@ -1341,6 +1363,7 @@ export interface ElectronAPI { worktreePath?: string; branch?: string; taskName?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; diff --git a/src/shared/remoteBranchCleanup.ts b/src/shared/remoteBranchCleanup.ts new file mode 100644 index 000000000..0100359e7 --- /dev/null +++ b/src/shared/remoteBranchCleanup.ts @@ -0,0 +1,53 @@ +/** + * Defines the cleanup mode for remote branches when a task is archived or deleted. + * + * - `'ask'` — Prompt the user each time: "Also delete remote branch?" + * - `'always'` — Automatically delete the remote branch. + * - `'never'` — Never delete the remote branch (default / current behavior). + * - `'auto'` — Auto-delete remote branches older than a configurable threshold. + */ +export type RemoteBranchCleanupMode = 'ask' | 'always' | 'never' | 'auto'; + +/** All valid cleanup mode values, used for runtime validation. */ +export const REMOTE_BRANCH_CLEANUP_MODES: readonly RemoteBranchCleanupMode[] = [ + 'ask', + 'always', + 'never', + 'auto', +] as const; + +/** Default cleanup mode when none is configured. */ +export const DEFAULT_REMOTE_BRANCH_CLEANUP_MODE: RemoteBranchCleanupMode = 'never'; + +/** Default threshold in days for the 'auto' mode. */ +export const DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS = 7; + +/** Minimum allowed threshold in days for the 'auto' mode. */ +export const MIN_REMOTE_BRANCH_CLEANUP_DAYS = 1; + +/** Maximum allowed threshold in days for the 'auto' mode. */ +export const MAX_REMOTE_BRANCH_CLEANUP_DAYS = 365; + +/** + * Type guard: returns true if the value is a valid RemoteBranchCleanupMode. + */ +export function isValidRemoteBranchCleanupMode(value: unknown): value is RemoteBranchCleanupMode { + return ( + typeof value === 'string' && + REMOTE_BRANCH_CLEANUP_MODES.includes(value as RemoteBranchCleanupMode) + ); +} + +/** + * Clamp a days-threshold value to the allowed range, returning the default + * if the input is not a finite number. + */ +export function clampCleanupDays(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS; + } + const rounded = Math.round(value); + if (rounded < MIN_REMOTE_BRANCH_CLEANUP_DAYS) return MIN_REMOTE_BRANCH_CLEANUP_DAYS; + if (rounded > MAX_REMOTE_BRANCH_CLEANUP_DAYS) return MAX_REMOTE_BRANCH_CLEANUP_DAYS; + return rounded; +} diff --git a/src/test/main/RemoteBranchService.test.ts b/src/test/main/RemoteBranchService.test.ts new file mode 100644 index 000000000..dd0e0ba78 --- /dev/null +++ b/src/test/main/RemoteBranchService.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/tmp/emdash-test'), + getName: vi.fn().mockReturnValue('emdash-test'), + getVersion: vi.fn().mockReturnValue('0.0.0-test'), + }, +})); + +// Mock logger +vi.mock('../../main/lib/logger', () => ({ + log: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// The promisified execFileAsync mock — returns promises directly. +const mockExecFileAsync = vi.fn(); + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('util', () => ({ + promisify: () => mockExecFileAsync, +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let RemoteBranchService: typeof import('../../main/services/RemoteBranchService').RemoteBranchService; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let service: InstanceType; + +beforeEach(async () => { + vi.resetModules(); + mockExecFileAsync.mockReset(); + + const mod = await import('../../main/services/RemoteBranchService'); + RemoteBranchService = mod.RemoteBranchService; + service = new RemoteBranchService(); +}); + +// ========================================================================= +// deleteRemoteBranch +// ========================================================================= +describe('RemoteBranchService.deleteRemoteBranch', () => { + it('successfully deletes a remote branch', async () => { + // First call: git remote → 'origin\n' + // Second call: git push origin --delete branch → success + mockExecFileAsync + .mockResolvedValueOnce({ stdout: 'origin\n' }) + .mockResolvedValueOnce({ stdout: '' }); + + const result = await service.deleteRemoteBranch('/repo', 'feature/my-branch'); + + expect(result.success).toBe(true); + expect(result.alreadyAbsent).toBe(false); + expect(result.noRemote).toBe(false); + expect(result.message).toContain('Deleted remote branch'); + }); + + it('strips origin/ prefix from branch name', async () => { + mockExecFileAsync + .mockResolvedValueOnce({ stdout: 'origin\n' }) + .mockResolvedValueOnce({ stdout: '' }); + + const result = await service.deleteRemoteBranch('/repo', 'origin/feature/test'); + + expect(result.success).toBe(true); + // Verify the git push command was called with the stripped branch name + const pushCall = mockExecFileAsync.mock.calls[1]; + expect(pushCall[1]).toEqual(['push', 'origin', '--delete', 'feature/test']); + }); + + it('handles "remote ref does not exist" gracefully', async () => { + const err = Object.assign(new Error('git push failed'), { + stderr: "error: unable to delete 'feature/old': remote ref does not exist", + }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }).mockRejectedValueOnce(err); + + const result = await service.deleteRemoteBranch('/repo', 'feature/old'); + + expect(result.success).toBe(true); + expect(result.alreadyAbsent).toBe(true); + expect(result.message).toContain('already absent'); + }); + + it('returns failure for generic "not found" error (not treated as already-absent)', async () => { + const err = Object.assign(new Error('branch not found'), { + stderr: 'error: branch not found', + }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }).mockRejectedValueOnce(err); + + const result = await service.deleteRemoteBranch('/repo', 'feature/gone'); + + expect(result.success).toBe(false); + expect(result.alreadyAbsent).toBe(false); + }); + + it('handles "unknown revision" error gracefully', async () => { + const err = Object.assign(new Error('unknown revision'), { + stderr: "fatal: unknown revision 'feature/x'", + }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }).mockRejectedValueOnce(err); + + const result = await service.deleteRemoteBranch('/repo', 'feature/x'); + + expect(result.success).toBe(true); + expect(result.alreadyAbsent).toBe(true); + }); + + it('returns failure for unexpected git errors', async () => { + const err = Object.assign(new Error('auth failed'), { + stderr: 'fatal: Authentication failed for ...', + }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }).mockRejectedValueOnce(err); + + const result = await service.deleteRemoteBranch('/repo', 'feature/auth-fail'); + + expect(result.success).toBe(false); + expect(result.alreadyAbsent).toBe(false); + expect(result.message).toContain('Failed to delete'); + }); + + it('skips deletion when no remote is configured', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'upstream\n' }); // No 'origin' + + const result = await service.deleteRemoteBranch('/repo', 'feature/no-origin'); + + expect(result.success).toBe(true); + expect(result.noRemote).toBe(true); + expect(result.message).toContain('no remote'); + }); + + it('skips deletion for local-only repos (empty remote list)', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: '' }); + + const result = await service.deleteRemoteBranch('/repo', 'feature/local'); + + expect(result.success).toBe(true); + expect(result.noRemote).toBe(true); + }); + + it('handles error when checking remotes', async () => { + mockExecFileAsync.mockRejectedValueOnce(new Error('not a git repo')); + + const result = await service.deleteRemoteBranch('/not-a-repo', 'feature/test'); + + // hasRemote() catches internally → returns false → noRemote=true + expect(result.success).toBe(true); + expect(result.noRemote).toBe(true); + }); + + it('returns failure for empty branch name', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }); + + const result = await service.deleteRemoteBranch('/repo', ''); + + expect(result.success).toBe(false); + expect(result.message).toContain('empty branch name'); + }); + + it('returns failure for branch that becomes empty after stripping prefix', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'origin\n' }); + + const result = await service.deleteRemoteBranch('/repo', 'origin/'); + + expect(result.success).toBe(false); + expect(result.message).toContain('empty branch name'); + }); + + it('uses custom remote name when provided', async () => { + mockExecFileAsync + .mockResolvedValueOnce({ stdout: 'upstream\n' }) + .mockResolvedValueOnce({ stdout: '' }); + + const result = await service.deleteRemoteBranch('/repo', 'feature/test', 'upstream'); + + expect(result.success).toBe(true); + const pushCall = mockExecFileAsync.mock.calls[1]; + expect(pushCall[1]).toEqual(['push', 'upstream', '--delete', 'feature/test']); + }); +}); + +// ========================================================================= +// isBranchStale +// ========================================================================= +describe('RemoteBranchService.isBranchStale', () => { + it('returns true when branch last commit is older than threshold', async () => { + const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); // 10 days ago + mockExecFileAsync.mockResolvedValueOnce({ stdout: oldDate + '\n' }); + + const result = await service.isBranchStale('/repo', 'feature/old', 7); + + expect(result).toBe(true); + }); + + it('returns false when branch last commit is newer than threshold', async () => { + const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago + mockExecFileAsync.mockResolvedValueOnce({ stdout: recentDate + '\n' }); + + const result = await service.isBranchStale('/repo', 'feature/recent', 7); + + expect(result).toBe(false); + }); + + it('returns false when date cannot be determined (fail-closed)', async () => { + mockExecFileAsync.mockRejectedValueOnce(new Error('unknown revision')); + + const result = await service.isBranchStale('/repo', 'feature/gone', 7); + + expect(result).toBe(false); + }); + + it('returns false when git returns empty output (fail-closed)', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: '' }); + + const result = await service.isBranchStale('/repo', 'feature/empty', 7); + + expect(result).toBe(false); + }); + + it('returns true for exactly the threshold boundary', async () => { + const exactBoundary = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + mockExecFileAsync.mockResolvedValueOnce({ stdout: exactBoundary + '\n' }); + + const result = await service.isBranchStale('/repo', 'feature/edge', 7); + + expect(result).toBe(true); + }); + + it('returns false for invalid date string (fail-closed)', async () => { + mockExecFileAsync.mockResolvedValueOnce({ stdout: 'not-a-real-date\n' }); + + const result = await service.isBranchStale('/repo', 'feature/bad', 7); + + expect(result).toBe(false); + }); +}); + +// ========================================================================= +// evaluateCleanupAction +// ========================================================================= +describe('RemoteBranchService.evaluateCleanupAction', () => { + it('returns "skip" for mode "never"', async () => { + const result = await service.evaluateCleanupAction('/repo', 'feature/x', 'never', 7); + expect(result).toBe('skip'); + }); + + it('returns "delete" for mode "always"', async () => { + const result = await service.evaluateCleanupAction('/repo', 'feature/x', 'always', 7); + expect(result).toBe('delete'); + }); + + it('returns "ask" for mode "ask"', async () => { + const result = await service.evaluateCleanupAction('/repo', 'feature/x', 'ask', 7); + expect(result).toBe('ask'); + }); + + it('returns "delete" for mode "auto" when branch is stale', async () => { + const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + mockExecFileAsync.mockResolvedValueOnce({ stdout: oldDate + '\n' }); + + const result = await service.evaluateCleanupAction('/repo', 'feature/stale', 'auto', 7); + expect(result).toBe('delete'); + }); + + it('returns "skip" for mode "auto" when branch is fresh', async () => { + const freshDate = new Date().toISOString(); + mockExecFileAsync.mockResolvedValueOnce({ stdout: freshDate + '\n' }); + + const result = await service.evaluateCleanupAction('/repo', 'feature/fresh', 'auto', 7); + expect(result).toBe('skip'); + }); + + it('returns "skip" for mode "auto" when branch date is unknown (fail-closed)', async () => { + mockExecFileAsync.mockRejectedValueOnce(new Error('unknown revision')); + + const result = await service.evaluateCleanupAction('/repo', 'feature/unknown', 'auto', 7); + expect(result).toBe('skip'); + }); + + it('returns "skip" for unknown mode', async () => { + const result = await service.evaluateCleanupAction( + '/repo', + 'feature/x', + 'unknown-mode' as any, + 7 + ); + expect(result).toBe('skip'); + }); +}); diff --git a/src/test/main/remoteBranchCleanupSettings.test.ts b/src/test/main/remoteBranchCleanupSettings.test.ts new file mode 100644 index 000000000..c950875f5 --- /dev/null +++ b/src/test/main/remoteBranchCleanupSettings.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/emdash-test', + }, +})); + +import { normalizeSettings } from '../../main/settings'; +import type { AppSettings } from '../../main/settings'; + +/** Minimal valid AppSettings skeleton for normalizeSettings. */ +function makeSettings(overrides?: Partial): AppSettings { + return { + repository: { branchPrefix: 'emdash', pushOnCreate: true }, + projectPrep: { autoInstallOnOpenInEditor: true }, + ...overrides, + } as AppSettings; +} + +describe('normalizeSettings – remoteBranchCleanup', () => { + it('defaults to "never" when repository section is missing', () => { + const result = normalizeSettings(makeSettings()); + expect(result.repository.remoteBranchCleanup).toBe('never'); + }); + + it('defaults to "never" when remoteBranchCleanup is not set', () => { + const result = normalizeSettings( + makeSettings({ repository: { branchPrefix: 'test', pushOnCreate: false } } as any) + ); + expect(result.repository.remoteBranchCleanup).toBe('never'); + }); + + it.each(['ask', 'always', 'never', 'auto'] as const)('preserves valid mode "%s"', (mode) => { + const result = normalizeSettings( + makeSettings({ + repository: { branchPrefix: 'emdash', pushOnCreate: true, remoteBranchCleanup: mode }, + } as any) + ); + expect(result.repository.remoteBranchCleanup).toBe(mode); + }); + + it('coerces invalid mode to "never"', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanup: 'bogus' as any, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanup).toBe('never'); + }); + + it('coerces numeric mode to "never"', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanup: 42 as any, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanup).toBe('never'); + }); +}); + +describe('normalizeSettings – remoteBranchCleanupDaysThreshold', () => { + it('defaults to 7 when not set', () => { + const result = normalizeSettings(makeSettings()); + expect(result.repository.remoteBranchCleanupDaysThreshold).toBe(7); + }); + + it('preserves valid threshold', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanupDaysThreshold: 30, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanupDaysThreshold).toBe(30); + }); + + it('clamps threshold below minimum to 1', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanupDaysThreshold: 0, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanupDaysThreshold).toBe(1); + }); + + it('clamps threshold above maximum to 365', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanupDaysThreshold: 999, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanupDaysThreshold).toBe(365); + }); + + it('defaults non-numeric threshold to 7', () => { + const result = normalizeSettings( + makeSettings({ + repository: { + branchPrefix: 'emdash', + pushOnCreate: true, + remoteBranchCleanupDaysThreshold: 'abc' as any, + }, + } as any) + ); + expect(result.repository.remoteBranchCleanupDaysThreshold).toBe(7); + }); +}); diff --git a/src/test/main/remoteBranchCleanupShared.test.ts b/src/test/main/remoteBranchCleanupShared.test.ts new file mode 100644 index 000000000..63cedb64c --- /dev/null +++ b/src/test/main/remoteBranchCleanupShared.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + isValidRemoteBranchCleanupMode, + clampCleanupDays, + REMOTE_BRANCH_CLEANUP_MODES, + DEFAULT_REMOTE_BRANCH_CLEANUP_MODE, + DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS, + MIN_REMOTE_BRANCH_CLEANUP_DAYS, + MAX_REMOTE_BRANCH_CLEANUP_DAYS, +} from '../../shared/remoteBranchCleanup'; + +describe('isValidRemoteBranchCleanupMode', () => { + it.each(REMOTE_BRANCH_CLEANUP_MODES)('accepts valid mode "%s"', (mode) => { + expect(isValidRemoteBranchCleanupMode(mode)).toBe(true); + }); + + it('rejects invalid string', () => { + expect(isValidRemoteBranchCleanupMode('foo')).toBe(false); + }); + + it('rejects number', () => { + expect(isValidRemoteBranchCleanupMode(42)).toBe(false); + }); + + it('rejects null', () => { + expect(isValidRemoteBranchCleanupMode(null)).toBe(false); + }); + + it('rejects undefined', () => { + expect(isValidRemoteBranchCleanupMode(undefined)).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidRemoteBranchCleanupMode('')).toBe(false); + }); +}); + +describe('clampCleanupDays', () => { + it('returns default for undefined', () => { + expect(clampCleanupDays(undefined)).toBe(DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('returns default for NaN', () => { + expect(clampCleanupDays(NaN)).toBe(DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('returns default for Infinity', () => { + expect(clampCleanupDays(Infinity)).toBe(DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('returns default for non-number', () => { + expect(clampCleanupDays('hello')).toBe(DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('clamps below minimum to MIN', () => { + expect(clampCleanupDays(0)).toBe(MIN_REMOTE_BRANCH_CLEANUP_DAYS); + expect(clampCleanupDays(-10)).toBe(MIN_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('clamps above maximum to MAX', () => { + expect(clampCleanupDays(999)).toBe(MAX_REMOTE_BRANCH_CLEANUP_DAYS); + expect(clampCleanupDays(1000)).toBe(MAX_REMOTE_BRANCH_CLEANUP_DAYS); + }); + + it('rounds fractional values', () => { + expect(clampCleanupDays(7.3)).toBe(7); + expect(clampCleanupDays(7.8)).toBe(8); + }); + + it('preserves valid integer within range', () => { + expect(clampCleanupDays(1)).toBe(1); + expect(clampCleanupDays(30)).toBe(30); + expect(clampCleanupDays(365)).toBe(365); + }); +}); + +describe('constants', () => { + it('DEFAULT_REMOTE_BRANCH_CLEANUP_MODE is "never"', () => { + expect(DEFAULT_REMOTE_BRANCH_CLEANUP_MODE).toBe('never'); + }); + + it('DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS is 7', () => { + expect(DEFAULT_REMOTE_BRANCH_CLEANUP_DAYS).toBe(7); + }); + + it('MIN is 1 and MAX is 365', () => { + expect(MIN_REMOTE_BRANCH_CLEANUP_DAYS).toBe(1); + expect(MAX_REMOTE_BRANCH_CLEANUP_DAYS).toBe(365); + }); +});