diff --git a/src/main/ipc/gitIpc.ts b/src/main/ipc/gitIpc.ts index e588a51ea..c7a89cc47 100644 --- a/src/main/ipc/gitIpc.ts +++ b/src/main/ipc/gitIpc.ts @@ -522,6 +522,57 @@ export function registerGitIpc() { return { success: true, url: url || undefined, output: out }; } + // ── Shared merge helpers ──────────────────────────────────────────── + + /** Detect the default branch (tries gh CLI, falls back to git symbolic-ref, then 'main'). */ + async function detectDefaultBranch(cwd: string): Promise { + try { + const { stdout } = await execAsync( + 'gh repo view --json defaultBranchRef -q .defaultBranchRef.name', + { cwd } + ); + if (stdout?.trim()) return stdout.trim(); + } catch { + // gh not available — fall through + } + + try { + const { stdout: symRef } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', { + cwd, + }); + const match = symRef?.trim().match(/refs\/remotes\/origin\/(.+)/); + if (match?.[1]) return match[1]; + } catch { + // fall through + } + + return 'main'; + } + + /** Get the current branch name (empty string when in detached HEAD). */ + async function getCurrentBranch(cwd: string): Promise { + const { stdout } = await execAsync('git branch --show-current', { cwd }); + return (stdout || '').trim(); + } + + /** + * Stage all changes and commit with the given message. + * No-ops when the working tree is clean. Swallows "nothing to commit" errors. + */ + async function stageAndCommit(cwd: string, message: string): Promise { + const { stdout: statusOut } = await execAsync('git status --porcelain --untracked-files=all', { + cwd, + }); + if (!statusOut?.trim()) return; + await execAsync('git add -A', { cwd }); + try { + await execAsync(`git commit -m ${JSON.stringify(message)}`, { cwd }); + } catch (e) { + const msg = String(e); + if (!/nothing to commit/i.test(msg)) throw e; + } + } + // Helper: merge-to-main for remote SSH projects async function mergeToMainRemote( connectionId: string, @@ -2008,21 +2059,8 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } // Get current and default branch names - const { stdout: currentOut } = await execAsync('git branch --show-current', { - cwd: taskPath, - }); - const currentBranch = (currentOut || '').trim(); - - let defaultBranch = 'main'; - try { - const { stdout } = await execAsync( - 'gh repo view --json defaultBranchRef -q .defaultBranchRef.name', - { cwd: taskPath } - ); - if (stdout?.trim()) defaultBranch = stdout.trim(); - } catch { - // gh not available or not a GitHub repo - fall back to 'main' - } + const currentBranch = await getCurrentBranch(taskPath); + const defaultBranch = await detectDefaultBranch(taskPath); // Validate: on a valid feature branch if (!currentBranch) { @@ -2036,19 +2074,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } // Stage and commit any pending changes - const { stdout: statusOut } = await execAsync( - 'git status --porcelain --untracked-files=all', - { cwd: taskPath } - ); - if (statusOut?.trim()) { - await execAsync('git add -A', { cwd: taskPath }); - try { - await execAsync('git commit -m "chore: prepare for merge to main"', { cwd: taskPath }); - } catch (e) { - const msg = String(e); - if (!/nothing to commit/i.test(msg)) throw e; - } - } + await stageAndCommit(taskPath, 'chore: prepare for merge to main'); // Push branch (set upstream if needed) try { @@ -2118,6 +2144,98 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } }); + // Git: Local merge – merge current branch into default branch without a PR + ipcMain.handle( + 'git:local-merge', + async ( + _, + args: { + taskPath: string; + commitMessage?: string; + } + ) => { + const { taskPath, commitMessage } = args || ({} as { taskPath: string }); + + try { + // Get current and default branch names + const currentBranch = await getCurrentBranch(taskPath); + const defaultBranch = await detectDefaultBranch(taskPath); + + // Validate: on a valid feature branch + if (!currentBranch) { + return { success: false, error: 'Not on a branch (detached HEAD state).' }; + } + if (currentBranch === defaultBranch) { + return { + success: false, + error: `Already on ${defaultBranch}. Create a feature branch first.`, + }; + } + + // Find the main repo path (worktree's parent repo) + const { stdout: gitCommonDir } = await execAsync('git rev-parse --git-common-dir', { + cwd: taskPath, + }); + const commonDir = gitCommonDir?.trim(); + if (!commonDir) { + return { success: false, error: 'Could not determine main repository path.' }; + } + const mainRepoPath = path.resolve(taskPath, commonDir, '..'); + + // Verify main repo is clean before committing worktree changes + if (mainRepoPath !== taskPath) { + const { stdout: mainStatus } = await execAsync('git status --porcelain', { + cwd: mainRepoPath, + }); + if (mainStatus?.trim()) { + return { + success: false, + error: + 'Main repository has uncommitted changes. Please commit or stash them before merging.', + }; + } + } + + // Stage and commit any pending changes + await stageAndCommit(taskPath, 'chore: prepare for local merge'); + + // Switch to default branch in the main repo + await execAsync(`git checkout ${JSON.stringify(defaultBranch)}`, { cwd: mainRepoPath }); + + // Merge the feature branch + const mergeMsg = commitMessage || `Merge branch '${currentBranch}' into ${defaultBranch}`; + try { + await execAsync( + `git merge ${JSON.stringify(currentBranch)} -m ${JSON.stringify(mergeMsg)}`, + { cwd: mainRepoPath } + ); + } catch (e) { + // Abort the merge on conflict so the repo isn't left in a dirty state + try { + await execAsync('git merge --abort', { cwd: mainRepoPath }); + } catch { + // ignore abort failures + } + const errMsg = (e as { stderr?: string })?.stderr || String(e); + return { + success: false, + error: `Merge failed (likely conflicts): ${errMsg}`, + }; + } + + return { + success: true, + output: `Successfully merged '${currentBranch}' into '${defaultBranch}' locally.`, + defaultBranch, + featureBranch: currentBranch, + }; + } catch (e) { + log.error('Failed local merge:', e); + return { success: false, error: (e as { message?: string })?.message || String(e) }; + } + } + ); + // Git: Rename branch (local and optionally remote) ipcMain.handle( 'git:rename-branch', diff --git a/src/main/preload.ts b/src/main/preload.ts index 4454b6ca6..374aa771a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -371,6 +371,8 @@ contextBridge.exposeInMainWorld('electronAPI', { fill?: boolean; }) => ipcRenderer.invoke('git:create-pr', args), mergeToMain: (args: { taskPath: string }) => ipcRenderer.invoke('git:merge-to-main', args), + localMerge: (args: { taskPath: string; commitMessage?: string }) => + ipcRenderer.invoke('git:local-merge', args), mergePr: (args: { taskPath: string; prNumber?: number; diff --git a/src/renderer/components/FileChangesPanel.tsx b/src/renderer/components/FileChangesPanel.tsx index adbfdf377..3d9d29f4e 100644 --- a/src/renderer/components/FileChangesPanel.tsx +++ b/src/renderer/components/FileChangesPanel.tsx @@ -23,6 +23,7 @@ import { CheckCircle2, XCircle, GitMerge, + AlertTriangle, } from 'lucide-react'; import { AlertDialog, @@ -34,15 +35,18 @@ import { AlertDialogHeader, AlertDialogTitle, } from './ui/alert-dialog'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; import { useTaskScope } from './TaskScopeContext'; type ActiveTab = 'changes' | 'checks'; -type PrMode = 'create' | 'draft' | 'merge'; +type PrMode = 'create' | 'draft' | 'merge' | 'local-merge'; const PR_MODE_LABELS: Record = { create: 'Create PR', draft: 'Draft PR', merge: 'Merge into Main', + 'local-merge': 'Local Merge', }; interface PrActionButtonProps { @@ -76,7 +80,7 @@ function PrActionButton({ mode, onModeChange, onExecute, isLoading }: PrActionBu - {(['create', 'draft', 'merge'] as PrMode[]) + {(['create', 'draft', 'merge', 'local-merge'] as PrMode[]) .filter((m) => m !== mode) .map((m) => ( @@ -144,7 +148,7 @@ const FileChangesPanelComponent: React.FC = ({ // Reset selectedPath and action loading states when task changes useEffect(() => { setSelectedPath(undefined); - setIsMergingToMain(false); + setIsMerging(false); }, [resolvedTaskPath]); const [stagingFiles, setStagingFiles] = useState>(new Set()); const [unstagingFiles, setUnstagingFiles] = useState>(new Set()); @@ -152,12 +156,20 @@ const FileChangesPanelComponent: React.FC = ({ const [isStagingAll, setIsStagingAll] = useState(false); const [commitMessage, setCommitMessage] = useState(''); const [isCommitting, setIsCommitting] = useState(false); - const [isMergingToMain, setIsMergingToMain] = useState(false); + const [isMerging, setIsMerging] = useState(false); const [showMergeConfirm, setShowMergeConfirm] = useState(false); + const [showLocalMergeConfirm, setShowLocalMergeConfirm] = useState(false); + const [localMergeCommitMsg, setLocalMergeCommitMsg] = useState(''); const [prMode, setPrMode] = useState(() => { try { const stored = localStorage.getItem('emdash:prMode'); - if (stored === 'create' || stored === 'draft' || stored === 'merge') return stored; + if ( + stored === 'create' || + stored === 'draft' || + stored === 'merge' || + stored === 'local-merge' + ) + return stored; // Migrate from old boolean key if (localStorage.getItem('emdash:createPrAsDraft') === 'true') return 'draft'; return 'create'; @@ -243,43 +255,81 @@ const FileChangesPanelComponent: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [safeTaskPath, hasChanges]); - const handleMergeToMain = async () => { - setIsMergingToMain(true); + const executeMerge = async (opts: { + action: () => Promise<{ + success: boolean; + error?: string; + output?: string; + [k: string]: unknown; + }>; + successTitle: string; + successDescription: string; + errorTitle: string; + errorFallback: string; + refreshPrAfter?: boolean; + }) => { + setIsMerging(true); try { - const result = await window.electronAPI.mergeToMain({ taskPath: safeTaskPath }); + const result = await opts.action(); if (result.success) { - toast({ - title: 'Merged to Main', - description: 'Changes have been merged to main.', - }); + toast({ title: opts.successTitle, description: result.output || opts.successDescription }); await refreshChanges(); - try { - await refreshPr(); - } catch { - // PR refresh is best-effort + if (opts.refreshPrAfter) { + try { + await refreshPr(); + } catch { + /* best-effort */ + } } } else { toast({ - title: 'Merge Failed', - description: result.error || 'Failed to merge to main.', + title: opts.errorTitle, + description: result.error || opts.errorFallback, variant: 'destructive', }); } - } catch (_error) { + } catch { toast({ - title: 'Merge Failed', + title: opts.errorTitle, description: 'An unexpected error occurred.', variant: 'destructive', }); } finally { - setIsMergingToMain(false); + setIsMerging(false); } }; + const handleMergeToMain = () => + executeMerge({ + action: () => window.electronAPI.mergeToMain({ taskPath: safeTaskPath }), + successTitle: 'Merged to Main', + successDescription: 'Changes have been merged to main.', + errorTitle: 'Merge Failed', + errorFallback: 'Failed to merge to main.', + refreshPrAfter: true, + }); + + const handleLocalMerge = () => + executeMerge({ + action: () => + window.electronAPI.localMerge({ + taskPath: safeTaskPath, + commitMessage: localMergeCommitMsg.trim() || undefined, + }), + successTitle: 'Local Merge Successful', + successDescription: 'Merged locally.', + errorTitle: 'Local Merge Failed', + errorFallback: 'Failed to merge locally.', + }); + const handlePrAction = async () => { if (prMode === 'merge') { setShowMergeConfirm(true); return; + } else if (prMode === 'local-merge') { + setLocalMergeCommitMsg(''); + setShowLocalMergeConfirm(true); + return; } else { void (async () => { const { captureTelemetry } = await import('../lib/telemetryClient'); @@ -324,7 +374,7 @@ const FileChangesPanelComponent: React.FC = ({ return null; } - const isActionLoading = isCreatingForTaskPath(safeTaskPath) || isMergingToMain; + const isActionLoading = isCreatingForTaskPath(safeTaskPath) || isMerging; return (
@@ -529,6 +579,58 @@ const FileChangesPanelComponent: React.FC = ({ + + + + Local Merge + +
+
+ + + This will merge your worktree branch directly into the default branch in your{' '} + local repository — no PR will be created and nothing will be pushed + to the remote. This action may be difficult to reverse. + +
+
+

What will happen:

+
    +
  1. Any uncommitted changes will be staged and committed
  2. +
  3. Your main repo will checkout the default branch
  4. +
  5. The worktree branch will be merged into the default branch
  6. +
+
+
+ + setLocalMergeCommitMsg(e.target.value)} + className="text-sm" + /> +
+
+ + setShowLocalMergeConfirm(false)}> + Cancel + + { + setShowLocalMergeConfirm(false); + void handleLocalMerge(); + }} + className="bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90" + > + + Merge Locally + + +
+
); }; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 444868b42..cfb78a628 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -507,6 +507,13 @@ declare global { prUrl?: string; error?: string; }>; + localMerge: (args: { taskPath: string; commitMessage?: string }) => Promise<{ + success: boolean; + output?: string; + defaultBranch?: string; + featureBranch?: string; + error?: string; + }>; mergePr: (args: { taskPath: string; prNumber?: number; @@ -1412,6 +1419,13 @@ export interface ElectronAPI { prUrl?: string; error?: string; }>; + localMerge: (args: { taskPath: string; commitMessage?: string }) => Promise<{ + success: boolean; + output?: string; + defaultBranch?: string; + featureBranch?: string; + error?: string; + }>; connectToGitHub: (projectPath: string) => Promise<{ success: boolean; repository?: string;