diff --git a/.gitignore b/.gitignore index 20178d76..8c42bfe6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ local-dev* *.sln *.sw? .opencode/plans/* +.hive diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5addce2e..763da996 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -114,6 +114,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection); const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); const pendingInputText = useSessionStore((state) => state.pendingInputText); + const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); @@ -418,7 +419,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo let primaryText = ''; let primaryAttachments: AttachedFile[] = []; let agentMentionName: string | undefined; - const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = []; + const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = []; + + // Consume any pending synthetic parts (from conflict resolution, etc.) + const syntheticParts = consumePendingSyntheticParts(); // Process queued messages first for (let i = 0; i < queuedMessages.length; i++) { @@ -483,6 +487,16 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } + // Add synthetic parts (from conflict resolution, etc.) + if (syntheticParts && syntheticParts.length > 0) { + for (const part of syntheticParts) { + additionalParts.push({ + text: part.text, + synthetic: true, + }); + } + } + if (!primaryText && additionalParts.length === 0) return; // Clear queue and input diff --git a/packages/ui/src/components/ui/command.tsx b/packages/ui/src/components/ui/command.tsx index eebf7e1c..b7c35a3f 100644 --- a/packages/ui/src/components/ui/command.tsx +++ b/packages/ui/src/components/ui/command.tsx @@ -65,10 +65,10 @@ function CommandDialog({ ) } -function CommandInput({ - className, - ...props -}: React.ComponentProps) { +const CommandInput = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { return (
) -} +}) +CommandInput.displayName = "CommandInput" function CommandList({ className, diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 38c0440c..876131c1 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -43,11 +43,17 @@ import { ChangesSection } from './git/ChangesSection'; import { CommitSection } from './git/CommitSection'; import { HistorySection } from './git/HistorySection'; import { PullRequestSection } from './git/PullRequestSection'; +import { ConflictDialog } from './git/ConflictDialog'; +import { StashDialog } from './git/StashDialog'; +import { InProgressOperationBanner } from './git/InProgressOperationBanner'; +import type { OperationLogEntry } from './git/BranchIntegrationSection'; +import type { GitRemote } from '@/lib/gitApi'; import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; +type BranchOperation = 'merge' | 'rebase' | null; type GitViewSnapshot = { @@ -357,6 +363,67 @@ export const GitView: React.FC = () => { const [remoteUrl, setRemoteUrl] = React.useState(null); const [gitmojiEmojis, setGitmojiEmojis] = React.useState([]); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); + const [remotes, setRemotes] = React.useState([]); + const [branchOperation, setBranchOperation] = React.useState(null); + const [operationLogs, setOperationLogs] = React.useState([]); + const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false); + const [conflictFiles, setConflictFiles] = React.useState([]); + const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge'); + + // Conflict state persistence key + const conflictStorageKey = React.useMemo(() => { + if (!currentSessionId) return null; + return `openchamber.conflict:${currentSessionId}`; + }, [currentSessionId]); + + // Save conflict state to localStorage + const persistConflictState = React.useCallback(( + directory: string, + files: string[], + operation: 'merge' | 'rebase' + ) => { + if (!conflictStorageKey || typeof window === 'undefined') return; + const payload = { directory, conflictFiles: files, operation }; + window.localStorage.setItem(conflictStorageKey, JSON.stringify(payload)); + }, [conflictStorageKey]); + + // Clear conflict state from localStorage + const clearConflictState = React.useCallback(() => { + if (!conflictStorageKey || typeof window === 'undefined') return; + window.localStorage.removeItem(conflictStorageKey); + }, [conflictStorageKey]); + + // Restore conflict state from localStorage on mount + React.useEffect(() => { + if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return; + + const raw = window.localStorage.getItem(conflictStorageKey); + if (!raw) return; + + try { + const parsed = JSON.parse(raw) as { + directory: string; + conflictFiles: string[]; + operation: 'merge' | 'rebase'; + }; + + // Validate the stored state matches current directory + if (parsed.directory !== currentDirectory) { + window.localStorage.removeItem(conflictStorageKey); + return; + } + + // Restore conflict state + setConflictFiles(parsed.conflictFiles ?? []); + setConflictOperation(parsed.operation ?? 'merge'); + setConflictDialogOpen(true); + } catch { + window.localStorage.removeItem(conflictStorageKey); + } + }, [conflictStorageKey, currentDirectory]); + const [stashDialogOpen, setStashDialogOpen] = React.useState(false); + const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); + const [stashDialogBranch, setStashDialogBranch] = React.useState(''); const handleCopyCommitHash = React.useCallback((hash: string) => { navigator.clipboard @@ -443,6 +510,14 @@ export const GitView: React.FC = () => { git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null)); }, [currentDirectory, git]); + React.useEffect(() => { + if (!currentDirectory || !git?.getRemotes) { + setRemotes([]); + return; + } + git.getRemotes(currentDirectory).then(setRemotes).catch(() => setRemotes([])); + }, [currentDirectory, git]); + React.useEffect(() => { if (!settingsGitmojiEnabled) { setGitmojiEmojis([]); @@ -567,7 +642,7 @@ export const GitView: React.FC = () => { }; }, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); - const changeEntries = React.useMemo(() => { + const changeEntries = React.useMemo(() => { if (!status) return []; const files = status.files ?? []; const unique = new Map(); @@ -603,22 +678,22 @@ export const GitView: React.FC = () => { }); }, [status, changeEntries, hasUserAdjustedSelection]); - const handleSyncAction = async (action: Exclude) => { + const handleSyncAction = async (action: Exclude, remote: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); try { if (action === 'fetch') { - await git.gitFetch(currentDirectory); - toast.success('Fetched latest updates'); + await git.gitFetch(currentDirectory, { remote: remote.name }); + toast.success(`Fetched from ${remote.name}`); } else if (action === 'pull') { - const result = await git.gitPull(currentDirectory); + const result = await git.gitPull(currentDirectory, { remote: remote.name }); toast.success( - `Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'}` + `Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}` ); } else if (action === 'push') { - await git.gitPush(currentDirectory); - toast.success('Pushed to remote'); + await git.gitPush(currentDirectory, { remote: remote.name }); + toast.success(`Pushed to ${remote.name}`); } await refreshStatusAndBranches(false); @@ -1017,6 +1092,343 @@ export const GitView: React.FC = () => { [currentDirectory, setLogMaxCount, fetchLog, git] ); + const isUncommittedChangesError = React.useCallback((error: unknown): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return ( + message.includes('uncommitted changes') || + message.includes('local changes') || + message.includes('your local changes would be overwritten') || + message.includes('please commit your changes or stash them') || + message.includes('cannot rebase: you have unstaged changes') || + message.includes('error: cannot pull with rebase') + ); + }, []); + + // Helper to add/update operation logs + const addOperationLog = React.useCallback((message: string, status: OperationLogEntry['status']) => { + setOperationLogs(prev => [...prev, { message, status, timestamp: Date.now() }]); + }, []); + + const updateLastLog = React.useCallback((status: OperationLogEntry['status'], message?: string) => { + setOperationLogs(prev => { + if (prev.length === 0) return prev; + const updated = [...prev]; + updated[updated.length - 1] = { + ...updated[updated.length - 1], + status, + ...(message ? { message } : {}), + }; + return updated; + }); + }, []); + + // Called at start of operation to reset logs + const resetOperationLogs = React.useCallback(() => { + setOperationLogs([]); + }, []); + + // Called when dialog is closed to fully reset state + const handleOperationComplete = React.useCallback(() => { + setOperationLogs([]); + setBranchOperation(null); + }, []); + + const handleMerge = React.useCallback( + async (branch: string) => { + if (!currentDirectory) return; + setBranchOperation('merge'); + resetOperationLogs(); + + const currentBranch = status?.current; + + try { + // If it's a remote branch (contains '/'), fetch latest first + const slashIndex = branch.indexOf('/'); + if (slashIndex > 0) { + const remote = branch.substring(0, slashIndex); + const remoteBranch = branch.substring(slashIndex + 1); + addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running'); + await git.gitFetch(currentDirectory, { remote, branch: remoteBranch }); + updateLastLog('done', `Fetched ${remote}/${remoteBranch}`); + } + + addOperationLog(`Merging ${branch} into ${currentBranch}...`, 'running'); + const result = await git.merge(currentDirectory, { branch }); + + if (result.conflict) { + updateLastLog('error', `Merge conflicts detected`); + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('merge'); + setConflictDialogOpen(true); + persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + } else { + updateLastLog('done', `Merged ${branch} into ${currentBranch}`); + clearConflictState(); + addOperationLog('Refreshing repository status...', 'running'); + await refreshStatusAndBranches(); + await refreshLog(); + updateLastLog('done', 'Repository status updated'); + } + } catch (err) { + if (isUncommittedChangesError(err)) { + updateLastLog('error', 'Uncommitted changes detected'); + setStashDialogOperation('merge'); + setStashDialogBranch(branch); + setStashDialogOpen(true); + } else { + const message = err instanceof Error ? err.message : `Failed to merge ${branch}`; + updateLastLog('error', message); + } + } + // Note: branchOperation is cleared when dialog closes via handleOperationComplete + }, + [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + ); + + const handleRebase = React.useCallback( + async (branch: string) => { + if (!currentDirectory) return; + setBranchOperation('rebase'); + resetOperationLogs(); + + const currentBranch = status?.current; + + try { + // If it's a remote branch (contains '/'), fetch latest first + const slashIndex = branch.indexOf('/'); + if (slashIndex > 0) { + const remote = branch.substring(0, slashIndex); + const remoteBranch = branch.substring(slashIndex + 1); + addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running'); + await git.gitFetch(currentDirectory, { remote, branch: remoteBranch }); + updateLastLog('done', `Fetched ${remote}/${remoteBranch}`); + } + + addOperationLog(`Rebasing ${currentBranch} onto ${branch}...`, 'running'); + const result = await git.rebase(currentDirectory, { onto: branch }); + + if (result.conflict) { + updateLastLog('error', `Rebase conflicts detected`); + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('rebase'); + setConflictDialogOpen(true); + persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + } else { + updateLastLog('done', `Rebased ${currentBranch} onto ${branch}`); + clearConflictState(); + addOperationLog('Refreshing repository status...', 'running'); + await refreshStatusAndBranches(); + await refreshLog(); + updateLastLog('done', 'Repository status updated'); + } + } catch (err) { + if (isUncommittedChangesError(err)) { + updateLastLog('error', 'Uncommitted changes detected'); + setStashDialogOperation('rebase'); + setStashDialogBranch(branch); + setStashDialogOpen(true); + } else { + const message = err instanceof Error ? err.message : `Failed to rebase onto ${branch}`; + updateLastLog('error', message); + } + } + // Note: branchOperation is cleared when dialog closes via handleOperationComplete + }, + [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + ); + + const handleAbortConflict = React.useCallback(async () => { + if (!currentDirectory) return; + + try { + if (conflictOperation === 'merge') { + await git.abortMerge(currentDirectory); + toast.success('Merge aborted'); + } else { + await git.abortRebase(currentDirectory); + toast.success('Rebase aborted'); + } + clearConflictState(); + await refreshStatusAndBranches(); + await refreshLog(); + } catch (err) { + const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`; + toast.error(message); + } + }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState]); + + // Check if there are unresolved conflicts (files with 'U' status) + const hasUnresolvedConflicts = React.useMemo(() => { + if (!status?.files) return false; + return status.files.some((f) => + (f.index === 'U' || f.working_dir === 'U') || + (f.index === 'A' && f.working_dir === 'A') || + (f.index === 'D' && f.working_dir === 'D') + ); + }, [status?.files]); + + const handleContinueOperation = React.useCallback(async () => { + if (!currentDirectory) return; + + try { + const isMerge = !!status?.mergeInProgress?.head; + const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto); + + if (isMerge) { + const result = await git.continueMerge(currentDirectory); + if (result.conflict) { + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('merge'); + setConflictDialogOpen(true); + persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + toast.error('Merge conflicts detected'); + } else { + clearConflictState(); + toast.success('Merge completed'); + await refreshStatusAndBranches(); + await refreshLog(); + } + } else if (isRebase) { + const result = await git.continueRebase(currentDirectory); + if (result.conflict) { + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('rebase'); + setConflictDialogOpen(true); + persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + toast.error('Rebase conflicts detected'); + } else { + clearConflictState(); + toast.success('Rebase step completed'); + await refreshStatusAndBranches(); + await refreshLog(); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to continue operation'; + toast.error(message); + } + }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState]); + + const handleAbortOperation = React.useCallback(async () => { + if (!currentDirectory) return; + + try { + const isMerge = !!status?.mergeInProgress?.head; + if (isMerge) { + await git.abortMerge(currentDirectory); + toast.success('Merge aborted'); + } else { + await git.abortRebase(currentDirectory); + toast.success('Rebase aborted'); + } + clearConflictState(); + await refreshStatusAndBranches(); + await refreshLog(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to abort operation'; + toast.error(message); + } + }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState]); + + const handleResolveWithAIFromBanner = React.useCallback(() => { + if (!currentDirectory) return; + + // Determine operation type from status + const isMerge = !!status?.mergeInProgress?.head; + const operation = isMerge ? 'merge' : 'rebase'; + + // Get conflict files from status (files with 'U' status indicate unmerged/conflicted) + const filesWithConflicts = status?.files + ?.filter((f) => f.index === 'U' || f.working_dir === 'U') + .map((f) => f.path) ?? []; + + // Update conflict state and open dialog + if (filesWithConflicts.length > 0) { + setConflictFiles(filesWithConflicts); + } + setConflictOperation(operation); + setConflictDialogOpen(true); + }, [currentDirectory, status]); + + const handleStashAndRetry = React.useCallback( + async (restoreAfter: boolean) => { + if (!currentDirectory) return; + + const currentBranch = status?.current; + const operation = stashDialogOperation; + const branch = stashDialogBranch; + + // Stash changes + try { + await git.stash(currentDirectory, { + message: `Auto-stash before ${operation} with ${branch}`, + includeUntracked: true, + }); + } catch (stashErr) { + const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes'; + toast.error(msg); + return; + } + + let operationSucceeded = false; + let hasConflict = false; + + try { + // Perform the operation + if (operation === 'merge') { + const result = await git.merge(currentDirectory, { branch }); + if (result.conflict) { + hasConflict = true; + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('merge'); + setConflictDialogOpen(true); + } else { + operationSucceeded = true; + toast.success(`Merged ${branch} into ${currentBranch}`); + } + } else { + const result = await git.rebase(currentDirectory, { onto: branch }); + if (result.conflict) { + hasConflict = true; + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation('rebase'); + setConflictDialogOpen(true); + } else { + operationSucceeded = true; + toast.success(`Rebased ${currentBranch} onto ${branch}`); + } + } + + // Restore stashed changes if requested and operation succeeded + if (restoreAfter && operationSucceeded) { + try { + await git.stashPop(currentDirectory); + toast.success('Stashed changes restored'); + } catch (popErr) { + const popMessage = popErr instanceof Error ? popErr.message : 'Failed to restore stashed changes'; + toast.error(popMessage); + } + } else if (restoreAfter && hasConflict) { + toast.info('Stashed changes will need to be restored manually after resolving conflicts'); + } + + await refreshStatusAndBranches(); + await refreshLog(); + } catch (err) { + // If the operation failed (not due to conflicts), try to restore stash + if (restoreAfter) { + try { + await git.stashPop(currentDirectory); + } catch { + // Ignore stash pop errors in this case + } + } + throw err; + } + }, + [currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog] + ); + if (!currentDirectory) { return (
@@ -1060,9 +1472,10 @@ export const GitView: React.FC = () => { remoteBranches={remoteBranches} branchInfo={branches?.branches} syncAction={syncAction} - onFetch={() => handleSyncAction('fetch')} - onPull={() => handleSyncAction('pull')} - onPush={() => handleSyncAction('push')} + remotes={remotes} + onFetch={(remote) => handleSyncAction('fetch', remote)} + onPull={(remote) => handleSyncAction('pull', remote)} + onPush={(remote) => handleSyncAction('push', remote)} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} onRenameBranch={handleRenameBranch} @@ -1071,141 +1484,189 @@ export const GitView: React.FC = () => { onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} + onMerge={handleMerge} + onRebase={handleRebase} + branchOperation={branchOperation} + operationLogs={operationLogs} + onOperationComplete={handleOperationComplete} + isBusy={isBusy} onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined} /> - -
- {/* Two-column layout on large screens: Changes + Commit */} -
- {hasChanges ? ( - useUIStore.getState().navigateToDiff(path)} - onRevertFile={handleRevertFile} - /> - ) : ( -
- handleSyncAction('pull')} - isPulling={syncAction === 'pull'} - /> -
- )} + {/* In-progress operation banner */} + {currentDirectory && ( + (status?.mergeInProgress?.head) || + (status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto) + ) && ( + + )} - {changeEntries.length > 0 && ( - handleCommit({ pushAfter: false })} - onCommitAndPush={() => handleCommit({ pushAfter: true })} - commitAction={commitAction} - isBusy={isBusy} - gitmojiEnabled={settingsGitmojiEnabled} - onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} - /> - )} -
- - {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? ( - { - if (!currentDirectory) return; - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); - }} + +
+ {/* Two-column layout on large screens: Changes + Commit */} +
+ {hasChanges ? ( + useUIStore.getState().navigateToDiff(path)} + onRevertFile={handleRevertFile} /> - ) : null} + ) : ( +
+ { + if (remotes.length > 0) { + handleSyncAction('pull', remotes[0]); + } else { + toast.error('No remotes configured'); + } + }} + isPulling={syncAction === 'pull'} + /> +
+ )} - {currentDirectory && status?.current && status?.tracking ? ( - 0 && ( + handleCommit({ pushAfter: false })} + onCommitAndPush={() => handleCommit({ pushAfter: true })} + commitAction={commitAction} + isBusy={isBusy} + gitmojiEnabled={settingsGitmojiEnabled} + onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} /> - ) : null} - - {/* History below, constrained width */} - + )}
- - - - - Pick a gitmoji - - - - - No gitmojis found. - - {(gitmojiEmojis.length === 0 - ? [] - : gitmojiEmojis.filter((entry) => { - const term = gitmojiSearch.trim().toLowerCase(); - if (!term) return true; - return ( - entry.emoji.includes(term) || - entry.code.toLowerCase().includes(term) || - entry.description.toLowerCase().includes(term) - ); - }) - ).map((entry) => ( - handleSelectGitmoji(entry.emoji, entry.code)} - > - {entry.emoji} - {entry.code} - {entry.description} - - ))} - - - - - + {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? ( + { + if (!currentDirectory) return; + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + }} + /> + ) : null} - + ) : null} + + {/* History below, constrained width */} + +
+
+ + + + + Pick a gitmoji + + + + + No gitmojis found. + + {(gitmojiEmojis.length === 0 + ? [] + : gitmojiEmojis.filter((entry) => { + const term = gitmojiSearch.trim().toLowerCase(); + if (!term) return true; + return ( + entry.emoji.includes(term) || + entry.code.toLowerCase().includes(term) || + entry.description.toLowerCase().includes(term) + ); + }) + ).map((entry) => ( + handleSelectGitmoji(entry.emoji, entry.code)} + > + {entry.emoji} + {entry.code} + {entry.description} + + ))} + + + + + + + {currentDirectory && ( + + )} + + + +
); diff --git a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx new file mode 100644 index 00000000..112cb6d7 --- /dev/null +++ b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx @@ -0,0 +1,451 @@ +import React from 'react'; +import { + RiGitMergeLine, + RiGitBranchLine, + RiLoader4Line, + RiArrowDownSLine, + RiCheckLine, + RiCloseLine, +} from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +type OperationType = 'merge' | 'rebase'; + +export interface OperationLogEntry { + message: string; + status: 'pending' | 'running' | 'done' | 'error'; + timestamp: number; +} + +interface BranchIntegrationSectionProps { + currentBranch: string | null | undefined; + localBranches: string[]; + remoteBranches: string[]; + onMerge: (branch: string) => void; + onRebase: (branch: string) => void; + disabled?: boolean; + isOperating?: boolean; + operationLogs?: OperationLogEntry[]; + onOperationComplete?: () => void; +} + +export const BranchIntegrationSection: React.FC = ({ + currentBranch, + localBranches, + remoteBranches, + onMerge, + onRebase, + disabled = false, + isOperating = false, + operationLogs = [], + onOperationComplete, +}) => { + const [dialogOpen, setDialogOpen] = React.useState(false); + const [operation, setOperation] = React.useState('merge'); + const [selectedBranch, setSelectedBranch] = React.useState(null); + const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); + const [branchSearch, setBranchSearch] = React.useState(''); + const searchInputRef = React.useRef(null); + const logContainerRef = React.useRef(null); + + const isDisabled = disabled || isOperating; + + // Check if operation completed (all logs are done or error) + const operationCompleted = operationLogs.length > 0 && + operationLogs.every(log => log.status === 'done' || log.status === 'error'); + const hasError = operationLogs.some(log => log.status === 'error'); + + // Auto-scroll log container when new entries are added + React.useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [operationLogs]); + + // Filter branches based on search + const filteredLocal = React.useMemo(() => { + const term = branchSearch.toLowerCase(); + const filtered = localBranches.filter((b) => b !== currentBranch); + if (!term) return filtered; + return filtered.filter((b) => b.toLowerCase().includes(term)); + }, [branchSearch, localBranches, currentBranch]); + + const filteredRemote = React.useMemo(() => { + const term = branchSearch.toLowerCase(); + if (!term) return remoteBranches; + return remoteBranches.filter((b) => b.toLowerCase().includes(term)); + }, [branchSearch, remoteBranches]); + + const handleOpenDialog = () => { + setDialogOpen(true); + setSelectedBranch(null); + setOperation('merge'); + setBranchSearch(''); + }; + + const handleSelectBranch = (branch: string) => { + setSelectedBranch(branch); + setBranchDropdownOpen(false); + setBranchSearch(''); + }; + + const handleConfirm = () => { + if (!selectedBranch) return; + + // Don't close dialog - keep it open to show progress + if (operation === 'merge') { + onMerge(selectedBranch); + } else { + onRebase(selectedBranch); + } + }; + + const handleCancel = () => { + // Don't allow cancel during operation + if (isOperating) return; + + setSelectedBranch(null); + setOperation('merge'); + setBranchSearch(''); + setDialogOpen(false); + }; + + const handleClose = () => { + // Only allow closing when operation is complete or not started + if (isOperating && !operationCompleted) return; + + if (operationCompleted) { + onOperationComplete?.(); + } + setSelectedBranch(null); + setOperation('merge'); + setBranchSearch(''); + setDialogOpen(false); + }; + + React.useEffect(() => { + if (!branchDropdownOpen) { + setBranchSearch(''); + } else { + // Focus the search input when dropdown opens + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + return () => clearTimeout(timer); + } + }, [branchDropdownOpen]); + + return ( + <> + + + + + + Merge or rebase another branch + + + + { + if (!open) { + handleClose(); + } else { + setDialogOpen(true); + } + }}> + + + Integrate Branch + + {isOperating ? ( + operationCompleted ? ( + hasError ? 'Operation failed' : 'Operation completed' + ) : ( + `${operation === 'merge' ? 'Merging' : 'Rebasing'} in progress...` + ) + ) : ( + <> + Choose how to integrate changes from another branch into{' '} + {currentBranch || 'current branch'} + + )} + + + + {/* Show operation log when operating */} + {isOperating ? ( +
+
+
+ {operationLogs.map((log, index) => ( +
+
+ {log.status === 'running' && ( + + )} + {log.status === 'done' && ( + + )} + {log.status === 'error' && ( + + )} + {log.status === 'pending' && ( +
+ )} +
+ + {log.message} + +
+ ))} +
+
+ + {operationCompleted && ( + + + + )} +
+ ) : ( + <> + {/* Operation Selection */} +
+

Operation

+
+ + + +
+
+ + {/* Branch Selection */} +
+

+ {operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'} +

+ + + + + + + + + No branches found. + + {filteredLocal.length > 0 && ( + + {filteredLocal.map((branch) => ( + handleSelectBranch(branch)} + > + + {branch} + + + ))} + + )} + + {filteredLocal.length > 0 && filteredRemote.length > 0 && ( + + )} + + {filteredRemote.length > 0 && ( + + {filteredRemote.map((branch) => ( + handleSelectBranch(branch)} + > + + {branch} + + + ))} + + )} + + + + +
+ + {/* Summary */} + {selectedBranch && ( +
+

+ {operation === 'merge' ? ( + <> + This will merge{' '} + {selectedBranch} + {' '}into{' '} + {currentBranch} + + ) : ( + <> + This will rebase{' '} + {currentBranch} + {' '}onto{' '} + {selectedBranch} + + )} +

+
+ )} + + + + + + + )} + +
+ + ); +}; diff --git a/packages/ui/src/components/views/git/ConflictDialog.tsx b/packages/ui/src/components/views/git/ConflictDialog.tsx new file mode 100644 index 00000000..a15354f9 --- /dev/null +++ b/packages/ui/src/components/views/git/ConflictDialog.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { toast } from '@/components/ui'; +import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi'; + +interface ConflictDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflictFiles?: string[]; + directory: string; + operation: 'merge' | 'rebase'; + onAbort: () => void; + onClearState?: () => void; +} + +export const ConflictDialog: React.FC = ({ + open, + onOpenChange, + conflictFiles = [], + directory, + operation, + onAbort, + onClearState, +}) => { + const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + + const [isLoading, setIsLoading] = React.useState(false); + const [conflictDetails, setConflictDetails] = React.useState(null); + const [loadError, setLoadError] = React.useState(null); + + // Fetch conflict details when dialog opens + React.useEffect(() => { + if (!open || !directory) return; + + setIsLoading(true); + setLoadError(null); + setConflictDetails(null); + + getConflictDetails(directory) + .then((details) => { + setConflictDetails(details); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : 'Failed to load conflict details'; + setLoadError(message); + }) + .finally(() => { + setIsLoading(false); + }); + }, [open, directory]); + + const buildConflictContext = React.useCallback((): { + visibleText: string; + instructionsText: string; + payloadText: string; + } | null => { + if (!conflictDetails) return null; + + const operationLabel = operation === 'merge' ? 'merge' : 'rebase'; + const headRef = conflictDetails.headInfo || (operation === 'merge' ? 'MERGE_HEAD' : 'REBASE_HEAD'); + const continueCmd = operation === 'merge' ? 'git commit --no-edit' : 'git rebase --continue'; + + const visibleText = `Resolve ${operationLabel} conflicts, stage the resolved files, and complete the ${operationLabel}. Preserve the intent of changes from ${headRef}.`; + + const instructionsText = `Git ${operationLabel} operation is in progress with conflicts. +- Directory: ${directory} +- Operation: ${operation} +- Head Info: ${conflictDetails.headInfo || 'N/A'} + +Required steps: +1. Read each conflicted file to understand the conflict markers (<<<<<<< HEAD, =======, >>>>>>> ...) +2. Edit each file to resolve conflicts by choosing the correct code or merging both changes appropriately +3. Stage all resolved files with: git add +4. Complete the ${operationLabel} with: ${continueCmd} + +Important: +- Remove ALL conflict markers from files (<<<<<<< HEAD, =======, >>>>>>>) +- Make sure the final code is syntactically correct and preserves intent from both sides +- Do not leave any files with unresolved conflict markers +- After completing all steps, confirm the ${operationLabel} was successful +`; + + const payloadText = `${operationLabel} conflict context (JSON)\n${JSON.stringify( + { + directory, + operation: conflictDetails.operation, + headInfo: conflictDetails.headInfo, + statusPorcelain: conflictDetails.statusPorcelain, + unmergedFiles: conflictDetails.unmergedFiles, + diff: conflictDetails.diff, + }, + null, + 2 + )}`; + + return { visibleText, instructionsText, payloadText }; + }, [conflictDetails, directory, operation]); + + const handleAbort = () => { + onAbort(); + onOpenChange(false); + }; + + const handleContinueLater = () => { + onClearState?.(); + onOpenChange(false); + }; + + const handleResolveInCurrentSession = () => { + const context = buildConflictContext(); + if (!context) { + toast.error('No conflict details available'); + return; + } + + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first or use "New Session".' }); + return; + } + + // Set the visible text in the input and the synthetic parts for when user sends + setPendingInputText(context.visibleText, 'replace'); + setPendingSyntheticParts([ + { text: context.instructionsText, synthetic: true }, + { text: context.payloadText, synthetic: true }, + ]); + + setActiveMainTab('chat'); + onClearState?.(); + onOpenChange(false); + }; + + const handleResolveInNewSession = () => { + const context = buildConflictContext(); + if (!context) { + toast.error('No conflict details available'); + return; + } + + // Open new session with the conflict context as initial prompt + synthetic parts + openNewSessionDraft({ + directoryOverride: directory, + initialPrompt: context.visibleText, + syntheticParts: [ + { text: context.instructionsText, synthetic: true }, + { text: context.payloadText, synthetic: true }, + ], + }); + // Navigate to chat tab so user sees the new session + setActiveMainTab('chat'); + onClearState?.(); + onOpenChange(false); + }; + + const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase'; + const displayFiles = conflictDetails?.unmergedFiles || conflictFiles; + + return ( + + +
+ +
+ + {operationLabel} Conflicts Detected +
+ + The {operation} operation resulted in conflicts that need to be resolved. + +
+ + {isLoading && ( +
+ + Loading conflict details... +
+ )} + + {loadError && ( +
+ Error loading details: {loadError} +
+ )} + + {displayFiles.length > 0 && ( +
+
+

Conflicted files:

+ + {displayFiles.length} + +
+
+
    + {displayFiles.map((file, index) => ( +
  • + {file} +
  • + ))} +
+
+
+ )} + + {conflictDetails?.headInfo && ( +
+

Head information:

+
+ {conflictDetails.headInfo} +
+
+ )} + +
+ + +
+ + +
+
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index f758e8b1..c232f17c 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -24,9 +24,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; -import type { GitStatus, GitIdentityProfile } from '@/lib/api/types'; +import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection'; +import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; type SyncAction = 'fetch' | 'pull' | 'push' | null; +type BranchOperation = 'merge' | 'rebase' | null; interface GitHeaderProps { status: GitStatus | null; @@ -34,9 +36,10 @@ interface GitHeaderProps { remoteBranches: string[]; branchInfo: Record | undefined; syncAction: SyncAction; - onFetch: () => void; - onPull: () => void; - onPush: () => void; + remotes: GitRemote[]; + onFetch: (remote: GitRemote) => void; + onPull: (remote: GitRemote) => void; + onPush: (remote: GitRemote) => void; onCheckoutBranch: (branch: string) => void; onCreateBranch: (name: string) => Promise; onRenameBranch?: (oldName: string, newName: string) => Promise; @@ -45,6 +48,13 @@ interface GitHeaderProps { onSelectIdentity: (profile: GitIdentityProfile) => void; isApplyingIdentity: boolean; isWorktreeMode: boolean; + // Branch integration (merge/rebase) + onMerge: (branch: string) => void; + onRebase: (branch: string) => void; + branchOperation: BranchOperation; + operationLogs: OperationLogEntry[]; + onOperationComplete: () => void; + isBusy: boolean; onOpenBranchPicker?: () => void; } @@ -186,6 +196,7 @@ export const GitHeader: React.FC = ({ remoteBranches, branchInfo, syncAction, + remotes, onFetch, onPull, onPush, @@ -197,6 +208,12 @@ export const GitHeader: React.FC = ({ onSelectIdentity, isApplyingIdentity, isWorktreeMode, + onMerge, + onRebase, + branchOperation, + operationLogs, + onOperationComplete, + isBusy, onOpenBranchPicker, }) => { if (!status) { @@ -247,12 +264,27 @@ export const GitHeader: React.FC = ({ +
+ + +
{onOpenBranchPicker ? ( diff --git a/packages/ui/src/components/views/git/InProgressOperationBanner.tsx b/packages/ui/src/components/views/git/InProgressOperationBanner.tsx new file mode 100644 index 00000000..c5069173 --- /dev/null +++ b/packages/ui/src/components/views/git/InProgressOperationBanner.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { + RiGitMergeLine, + RiGitBranchLine, + RiLoader4Line, + RiCheckLine, + RiCloseLine, + RiSparklingLine, +} from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import type { GitMergeInProgress, GitRebaseInProgress } from '@/lib/api/types'; + +interface InProgressOperationBannerProps { + mergeInProgress: GitMergeInProgress | null | undefined; + rebaseInProgress: GitRebaseInProgress | null | undefined; + onContinue: () => Promise; + onAbort: () => Promise; + onResolveWithAI?: () => void; + hasUnresolvedConflicts?: boolean; + isLoading?: boolean; +} + +export const InProgressOperationBanner: React.FC = ({ + mergeInProgress, + rebaseInProgress, + onContinue, + onAbort, + onResolveWithAI, + hasUnresolvedConflicts = false, + isLoading = false, +}) => { + const [processingAction, setProcessingAction] = React.useState<'continue' | 'abort' | null>(null); + + // Only show banner if we have actual in-progress operation data + const hasMergeInProgress = mergeInProgress && mergeInProgress.head; + const hasRebaseInProgress = rebaseInProgress && (rebaseInProgress.headName || rebaseInProgress.onto); + const operation = hasMergeInProgress ? 'merge' : hasRebaseInProgress ? 'rebase' : null; + + if (!operation) { + return null; + } + + const handleContinue = async () => { + setProcessingAction('continue'); + try { + await onContinue(); + } finally { + setProcessingAction(null); + } + }; + + const handleAbort = async () => { + setProcessingAction('abort'); + try { + await onAbort(); + } finally { + setProcessingAction(null); + } + }; + + const isProcessing = processingAction !== null; + + const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase'; + const OperationIcon = operation === 'merge' ? RiGitMergeLine : RiGitBranchLine; + + // Build description + let description = ''; + if (mergeInProgress) { + description = mergeInProgress.message + ? `Merging: ${mergeInProgress.message}` + : `Merge in progress (${mergeInProgress.head})`; + } else if (rebaseInProgress) { + description = rebaseInProgress.headName + ? `Rebasing ${rebaseInProgress.headName} onto ${rebaseInProgress.onto}` + : `Rebase in progress`; + } + + return ( +
+
+
+ +
+

+ {operationLabel} in Progress +

+ {description && ( +

+ {description} +

+ )} +
+
+ +
+ {hasUnresolvedConflicts && onResolveWithAI && ( + + )} + + {processingAction !== 'continue' && ( + + )} + + {!hasUnresolvedConflicts && ( + + )} +
+
+ + {hasUnresolvedConflicts && ( +

+ Conflicts must be resolved before continuing. Use "Resolve with AI" or resolve manually, then stage changes and click Continue. +

+ )} +
+ ); +}; diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 5774d812..a9a7210b 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -1,16 +1,17 @@ import * as React from 'react'; -import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal } from '@remixicon/react'; +import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal, RiSparklingLine } from '@remixicon/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; + import { Command, CommandEmpty, @@ -20,8 +21,6 @@ import { CommandList, } from '@/components/ui/command'; import { toast } from '@/components/ui'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { execCommand } from '@/lib/execCommands'; @@ -65,12 +64,24 @@ export const IntegrateCommitsSection: React.FC<{ const currentSessionId = useSessionStore((s) => s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const [isOpen, setIsOpen] = React.useState(true); + const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); + const searchInputRef = React.useRef(null); const [targetBranch, setTargetBranch] = React.useState(defaultTargetBranch); React.useEffect(() => { setTargetBranch(defaultTargetBranch); }, [defaultTargetBranch]); + // Focus search input when branch dropdown opens + React.useEffect(() => { + if (branchDropdownOpen) { + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + return () => clearTimeout(timer); + } + }, [branchDropdownOpen]); + const isEligible = Boolean( repoRoot && sourceBranch && targetBranch && targetBranch !== 'HEAD' && sourceBranch !== targetBranch ); @@ -171,32 +182,30 @@ export const IntegrateCommitsSection: React.FC<{ [currentSessionId, worktreeMetadata] ); - const handleResolveWithAi = React.useCallback(async (payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => { - setActiveMainTab('chat'); - if (!currentSessionId) { - toast.error('No active session', { description: 'Open a chat session first.' }); - return; - } - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - if (!providerID || !modelID) { - toast.error('No model selected'); - return; - } + const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft); - const visibleText = `Resolve cherry-pick conflicts and keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}. After edits, report if I can continue process.`; - const instructionsText = `Worktree commit integration is in progress. + const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => { + const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`; + const instructionsText = `Worktree commit integration (cherry-pick) is in progress with conflicts. - Repo root: ${payload.state.repoRoot} - Temp target worktree: ${payload.state.tempWorktreePath} - Source branch: ${payload.state.sourceBranch} - Target branch: ${payload.state.targetBranch} +- Current commit: ${payload.state.currentCommit} -Goal: -- Resolve conflicts inside the temp target worktree directory. -- Do NOT change intent of the commit being applied. -- After edits, say whether I can click "Continue". +Required steps: +1. Read each conflicted file in the temp worktree to understand the conflict markers (<<<<<<< HEAD, =======, >>>>>>> ...) +2. Edit each file to resolve conflicts by choosing the correct code or merging both changes appropriately +3. Stage all resolved files with: git add +4. Complete the cherry-pick with: git cherry-pick --continue + +Important: +- Work inside the temp worktree directory: ${payload.state.tempWorktreePath} +- Remove ALL conflict markers from files (<<<<<<< HEAD, =======, >>>>>>>) +- Preserve the intent of the commit being applied +- Make sure the final code is syntactically correct +- Do not leave any files with unresolved conflict markers +- After completing all steps, confirm the cherry-pick was successful `; const payloadText = `Cherry-pick conflict context (JSON)\n${JSON.stringify({ repoRoot: payload.state.repoRoot, @@ -212,24 +221,46 @@ Goal: diff: payload.details.diff, }, null, 2)}`; - void useMessageStore.getState().sendMessage( - visibleText, - providerID, - modelID, - currentAgentName ?? undefined, - currentSessionId, - undefined, - null, - [ - { text: instructionsText, synthetic: true }, - { text: payloadText, synthetic: true }, - ], - currentVariant - ).catch((e) => { - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to send message', { description: message }); - }); - }, [currentSessionId, setActiveMainTab]); + return { visibleText, instructionsText, payloadText }; + }, []); + + const setPendingInputText = useSessionStore((s) => s.setPendingInputText); + const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts); + + const handleResolveWithAi = React.useCallback(( + payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }, + useNewSession: boolean + ) => { + const context = buildConflictContext(payload); + + if (useNewSession) { + // Open new session with the conflict context as initial prompt + synthetic parts + openNewSessionDraft({ + directoryOverride: payload.state.tempWorktreePath, + initialPrompt: context.visibleText, + syntheticParts: [ + { text: context.instructionsText, synthetic: true }, + { text: context.payloadText, synthetic: true }, + ], + }); + // Navigate to chat tab so user sees the new session + setActiveMainTab('chat'); + return; + } + + // Use current session - set pending input text and synthetic parts + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first or use "New Session".' }); + return; + } + + setPendingInputText(context.visibleText, 'replace'); + setPendingSyntheticParts([ + { text: context.instructionsText, synthetic: true }, + { text: context.payloadText, synthetic: true }, + ]); + setActiveMainTab('chat'); + }, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts]); const handleMove = React.useCallback(async () => { if (ui.kind !== 'ready') return; @@ -345,7 +376,7 @@ Goal:
- + + + + + + + ); +}; diff --git a/packages/ui/src/components/views/git/SyncActions.tsx b/packages/ui/src/components/views/git/SyncActions.tsx index 165c4d8c..84127e52 100644 --- a/packages/ui/src/components/views/git/SyncActions.tsx +++ b/packages/ui/src/components/views/git/SyncActions.tsx @@ -7,87 +7,186 @@ import { } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { GitRemote } from '@/lib/gitApi'; type SyncAction = 'fetch' | 'pull' | 'push' | null; interface SyncActionsProps { syncAction: SyncAction; - onFetch: () => void; - onPull: () => void; - onPush: () => void; + remotes: GitRemote[]; + onFetch: (remote: GitRemote) => void; + onPull: (remote: GitRemote) => void; + onPush: (remote: GitRemote) => void; disabled: boolean; } export const SyncActions: React.FC = ({ syncAction, + remotes = [], onFetch, onPull, onPush, disabled, }) => { - const isDisabled = disabled || syncAction !== null; + const hasNoRemotes = remotes.length === 0; + const isDisabled = disabled || syncAction !== null || hasNoRemotes; + const hasMultipleRemotes = remotes.length > 1; + + const handleFetch = () => { + const remote = remotes[0]; + if (remotes.length === 1 && remote) { + onFetch(remote); + } + }; + + const handlePull = () => { + const remote = remotes[0]; + if (remotes.length === 1 && remote) { + onPull(remote); + } + }; + + const handlePush = () => { + const remote = remotes[0]; + if (remotes.length === 1 && remote) { + onPush(remote); + } + }; + + const renderButton = ( + action: SyncAction, + icon: React.ReactNode, + loadingIcon: React.ReactNode, + label: string, + onClick: () => void, + tooltipText: string + ) => { + const button = ( + + ); + + return ( + + {button} + {tooltipText} + + ); + }; + + const renderDropdownButton = ( + action: SyncAction, + icon: React.ReactNode, + loadingIcon: React.ReactNode, + label: string, + onSelect: (remote: GitRemote) => void, + tooltipText: string + ) => { + return ( + + + + + + + + {tooltipText} + + + {remotes.map((remote) => ( + onSelect(remote)}> +
+ + {remote.name} + + + {remote.fetchUrl} + +
+
+ ))} +
+
+ ); + }; return (
- - - - - Fetch from remote - + {hasMultipleRemotes + ? renderDropdownButton( + 'fetch', + , + , + 'Fetch', + onFetch, + 'Fetch from remote' + ) + : renderButton( + 'fetch', + , + , + 'Fetch', + handleFetch, + 'Fetch from remote' + )} - - - - - Pull changes - + {hasMultipleRemotes + ? renderDropdownButton( + 'pull', + , + , + 'Pull', + onPull, + 'Pull changes' + ) + : renderButton( + 'pull', + , + , + 'Pull', + handlePull, + 'Pull changes' + )} - - - - - Push changes - + {hasMultipleRemotes + ? renderDropdownButton( + 'push', + , + , + 'Push', + onPush, + 'Push changes' + ) + : renderButton( + 'push', + , + , + 'Push', + handlePush, + 'Push changes' + )}
); }; diff --git a/packages/ui/src/components/views/git/index.ts b/packages/ui/src/components/views/git/index.ts index 9f973a9a..b94fec98 100644 --- a/packages/ui/src/components/views/git/index.ts +++ b/packages/ui/src/components/views/git/index.ts @@ -9,3 +9,6 @@ export { HistorySection } from './HistorySection'; export { HistoryCommitRow } from './HistoryCommitRow'; export { SyncActions } from './SyncActions'; export { BranchSelector } from './BranchSelector'; +export { BranchIntegrationSection } from './BranchIntegrationSection'; +export { ConflictDialog } from './ConflictDialog'; +export { StashDialog } from './StashDialog'; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 35f87cd6..b4e0595d 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -90,6 +90,20 @@ export interface GitStatusFile { working_dir: string; } +export interface GitMergeInProgress { + /** Short SHA of MERGE_HEAD */ + head: string; + /** First line of MERGE_MSG */ + message: string; +} + +export interface GitRebaseInProgress { + /** Branch name being rebased */ + headName: string; + /** Short SHA of the onto commit */ + onto: string; +} + export interface GitStatus { current: string; tracking: string | null; @@ -98,6 +112,10 @@ export interface GitStatus { files: GitStatusFile[]; isClean: boolean; diffStats?: Record; + /** Present when a merge is in progress with conflicts */ + mergeInProgress?: GitMergeInProgress | null; + /** Present when a rebase is in progress */ + rebaseInProgress?: GitRebaseInProgress | null; } export interface GitDiffResponse { @@ -168,6 +186,37 @@ export interface GitPullResult { deletions: number; } +export interface GitRemote { + name: string; + fetchUrl: string; + pushUrl: string; +} + +export interface GitMergeResult { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +export interface GitRebaseResult { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +export interface MergeConflictDetails { + /** Git status --porcelain output showing current state */ + statusPorcelain: string; + /** List of unmerged file paths */ + unmergedFiles: string[]; + /** Git diff output showing current conflict state */ + diff: string; + /** Information about MERGE_HEAD or REBASE_HEAD */ + headInfo: string; + /** The operation type: 'merge' or 'rebase' */ + operation: 'merge' | 'rebase'; +} + export type GitIdentityAuthType = 'ssh' | 'token'; export interface GitIdentityProfile { @@ -297,6 +346,16 @@ export interface GitAPI { discoverGitCredentials?(): Promise; getGlobalGitIdentity?(): Promise; getRemoteUrl?(directory: string, remote?: string): Promise; + getRemotes(directory: string): Promise; + rebase(directory: string, options: { onto: string }): Promise; + abortRebase(directory: string): Promise<{ success: boolean }>; + continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>; + merge(directory: string, options: { branch: string }): Promise; + abortMerge(directory: string): Promise<{ success: boolean }>; + continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>; + stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>; + stashPop(directory: string): Promise<{ success: boolean }>; + getConflictDetails(directory: string): Promise; } export interface FileListEntry { diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index b73334c0..6d6431d7 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -21,6 +21,10 @@ export type { GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, DiscoveredGitCredential, + GitRemote, + GitMergeResult, + GitRebaseResult, + MergeConflictDetails, } from './api/types'; declare global { @@ -262,3 +266,72 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise< if (runtime?.getRemoteUrl) return runtime.getRemoteUrl(directory, remote); return gitHttp.getRemoteUrl(directory, remote); } + +export async function getRemotes(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.getRemotes(directory); + return gitHttp.getRemotes(directory); +} + +export async function rebase( + directory: string, + options: { onto: string } +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.rebase(directory, options); + return gitHttp.rebase(directory, options); +} + +export async function abortRebase(directory: string): Promise<{ success: boolean }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.abortRebase(directory); + return gitHttp.abortRebase(directory); +} + +export async function merge( + directory: string, + options: { branch: string } +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.merge(directory, options); + return gitHttp.merge(directory, options); +} + +export async function abortMerge(directory: string): Promise<{ success: boolean }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.abortMerge(directory); + return gitHttp.abortMerge(directory); +} + +export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.continueRebase(directory); + return gitHttp.continueRebase(directory); +} + +export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.continueMerge(directory); + return gitHttp.continueMerge(directory); +} + +export async function stash( + directory: string, + options?: { message?: string; includeUntracked?: boolean } +): Promise<{ success: boolean }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.stash(directory, options); + return gitHttp.stash(directory, options); +} + +export async function stashPop(directory: string): Promise<{ success: boolean }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.stashPop(directory); + return gitHttp.stashPop(directory); +} + +export async function getConflictDetails(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getConflictDetails) return runtime.getConflictDetails(directory); + return gitHttp.getConflictDetails(directory); +} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 685bbc24..75821b7d 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -21,6 +21,7 @@ import type { GitIdentityProfile, GitIdentitySummary, DiscoveredGitCredential, + MergeConflictDetails, } from './api/types'; declare global { @@ -562,3 +563,122 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise< const data = await response.json(); return data.url ?? null; } + +export async function getRemotes(directory: string): Promise> { + const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory)); + if (!response.ok) { + throw new Error(`Failed to get remotes: ${response.statusText}`); + } + return response.json(); +} + +export async function rebase( + directory: string, + options: { onto: string } +): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { + const response = await fetch(buildUrl(`${API_BASE}/rebase`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to rebase'); + } + return response.json(); +} + +export async function abortRebase(directory: string): Promise<{ success: boolean }> { + const response = await fetch(buildUrl(`${API_BASE}/rebase/abort`, directory), { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to abort rebase'); + } + return response.json(); +} + +export async function merge( + directory: string, + options: { branch: string } +): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> { + const response = await fetch(buildUrl(`${API_BASE}/merge`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to merge'); + } + return response.json(); +} + +export async function abortMerge(directory: string): Promise<{ success: boolean }> { + const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to abort merge'); + } + return response.json(); +} + +export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + const response = await fetch(buildUrl(`${API_BASE}/rebase/continue`, directory), { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to continue rebase'); + } + return response.json(); +} + +export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + const response = await fetch(buildUrl(`${API_BASE}/merge/continue`, directory), { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to continue merge'); + } + return response.json(); +} + +export async function stash( + directory: string, + options?: { message?: string; includeUntracked?: boolean } +): Promise<{ success: boolean }> { + const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options || {}), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to stash'); + } + return response.json(); +} + +export async function stashPop(directory: string): Promise<{ success: boolean }> { + const response = await fetch(buildUrl(`${API_BASE}/stash/pop`, directory), { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to pop stash'); + } + return response.json(); +} + +export async function getConflictDetails(directory: string): Promise { + const response = await fetch(buildUrl(`${API_BASE}/conflict-details`, directory)); + if (!response.ok) { + throw new Error(`Failed to get conflict details: ${response.statusText}`); + } + return response.json(); +} diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 5e294474..bc3090d8 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -89,11 +89,20 @@ export const getActiveSessionWindow = () => { export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS; export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW; +/** Synthetic context parts to attach when sending initial message */ +export interface SyntheticContextPart { + text: string; + synthetic: true; +} + export type NewSessionDraftState = { open: boolean; directoryOverride: string | null; parentID: string | null; title?: string; + initialPrompt?: string; + /** Synthetic context parts to include with the initial message */ + syntheticParts?: SyntheticContextPart[]; }; export interface SessionStore { @@ -157,6 +166,8 @@ export interface SessionStore { pendingInputText: string | null; pendingInputMode: 'replace' | 'append'; + /** Synthetic context parts to include with the next message sent */ + pendingSyntheticParts: SyntheticContextPart[] | null; newSessionDraft: NewSessionDraftState; @@ -165,7 +176,7 @@ export interface SessionStore { setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void; loadSessions: () => Promise; - openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string }) => void; + openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[] }) => void; closeNewSessionDraft: () => void; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; @@ -178,7 +189,7 @@ export interface SessionStore { unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise; abortCurrentOperation: () => Promise; acknowledgeSessionAbort: (sessionId: string) => void; armAbortPrompt: (durationMs?: number) => number | null; @@ -256,4 +267,6 @@ export interface SessionStore { forkFromMessage: (sessionId: string, messageId: string) => Promise; setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void; consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null; + setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void; + consumePendingSyntheticParts: () => SyntheticContextPart[] | null; } diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index ef50f7ac..38e424a4 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -4,7 +4,7 @@ import { devtools } from "zustand/middleware"; import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; -import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes"; +import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes"; import { getActiveSessionWindow, getMemoryLimits } from "./types/sessionTypes"; import { useSessionStore as useSessionManagementStore } from "./sessionStore"; @@ -103,6 +103,7 @@ export const useSessionStore = create()( userSummaryTitles: new Map(), pendingInputText: null, pendingInputMode: 'replace', + pendingSyntheticParts: null, newSessionDraft: { open: true, directoryOverride: null, parentID: null }, getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => { @@ -135,9 +136,13 @@ export const useSessionStore = create()( directoryOverride: directory, parentID: options?.parentID ?? null, title: options?.title, + initialPrompt: options?.initialPrompt, + syntheticParts: options?.syntheticParts, }, currentSessionId: null, error: null, + // Set pending input text if initialPrompt is provided + ...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}), }); try { @@ -169,7 +174,7 @@ export const useSessionStore = create()( closeNewSessionDraft: () => { const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId; set({ - newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined }, + newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined }, currentSessionId: realCurrentSessionId, }); }, @@ -313,7 +318,7 @@ export const useSessionStore = create()( get().evictLeastRecentlyUsed(); }, loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit), - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => { + sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => { const draft = get().newSessionDraft; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; @@ -384,13 +389,21 @@ export const useSessionStore = create()( // ignored } + // Capture synthetic parts before clearing draft + const draftSyntheticParts = draft.syntheticParts; + get().closeNewSessionDraft(); setStatus(created.id, 'busy'); + // Merge draft synthetic parts with any additional parts passed to sendMessage + const mergedAdditionalParts = draftSyntheticParts?.length + ? [...(additionalParts || []), ...draftSyntheticParts] + : additionalParts; + try { return await useMessageStore .getState() - .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts, variant); + .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant); } catch (error) { setStatus(created.id, 'idle'); throw error; @@ -781,6 +794,18 @@ export const useSessionStore = create()( } return { text, mode }; }, + + setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => { + set({ pendingSyntheticParts: parts }); + }, + + consumePendingSyntheticParts: () => { + const parts = get().pendingSyntheticParts; + if (parts !== null) { + set({ pendingSyntheticParts: null }); + } + return parts; + }, }), { name: "composed-session-store", diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index d0b5c3a0..1855913c 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2269,6 +2269,97 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: true, data: result }; } + case 'api:git/remotes': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.getRemotes(directory); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase': { + const { directory, onto } = (payload || {}) as { directory?: string; onto?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + if (!onto) { + return { id, type, success: false, error: 'onto is required' }; + } + const result = await gitService.rebase(directory, { onto }); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/abort': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.abortRebase(directory); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + if (!branch) { + return { id, type, success: false, error: 'branch is required' }; + } + const result = await gitService.merge(directory, { branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/abort': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.abortMerge(directory); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/continue': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.continueRebase(directory); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/continue': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.continueMerge(directory); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash': { + const { directory, message, includeUntracked } = (payload || {}) as { + directory?: string; + message?: string; + includeUntracked?: boolean; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.stash(directory, { message, includeUntracked }); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash/pop': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.stashPop(directory); + return { id, type, success: true, data: result }; + } + case 'api:git/log': { const { directory, maxCount, from, to, file } = (payload || {}) as { directory?: string; @@ -2408,6 +2499,84 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:git/ignore-openchamber': { + // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. + // This is now a no-op since the function was removed with legacy worktree support. + return { id, type, success: true, data: { success: true } }; + } + + case 'api:git/conflict-details': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + try { + // Get git status --porcelain + const statusResult = await execGit(['status', '--porcelain'], directory); + const statusPorcelain = statusResult.stdout; + + // Get unmerged files (files with conflicts) + const unmergedResult = await execGit(['diff', '--name-only', '--diff-filter=U'], directory); + const unmergedFiles = unmergedResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // Get current diff + const diffResult = await execGit(['diff'], directory); + const diff = diffResult.stdout; + + // Detect operation type and get head info + let operation: 'merge' | 'rebase' = 'merge'; + let headInfo = ''; + + // Check for MERGE_HEAD (merge in progress) + const mergeHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory); + const mergeHeadExists = mergeHeadResult.exitCode === 0; + + if (mergeHeadExists) { + operation = 'merge'; + const mergeHead = mergeHeadResult.stdout.trim(); + // Try to read MERGE_MSG file + let mergeMsg = ''; + try { + const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG'); + mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8'); + } catch { + // MERGE_MSG may not exist + } + headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`; + } else { + // Check for REBASE_HEAD (rebase in progress) + const rebaseHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory); + const rebaseHeadExists = rebaseHeadResult.exitCode === 0; + + if (rebaseHeadExists) { + operation = 'rebase'; + const rebaseHead = rebaseHeadResult.stdout.trim(); + headInfo = `REBASE_HEAD: ${rebaseHead}`; + } + } + + return { + id, + type, + success: true, + data: { + statusPorcelain: statusPorcelain.trim(), + unmergedFiles, + diff: diff.trim(), + headInfo: headInfo.trim(), + operation, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + default: return { id, type, success: false, error: `Unknown message type: ${type}` }; } diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 3b2bc7d4..54730d1f 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -243,6 +243,20 @@ export interface GitStatusFile { working_dir: string; } +export interface GitMergeInProgress { + /** Short SHA of MERGE_HEAD */ + head: string; + /** First line of MERGE_MSG */ + message: string; +} + +export interface GitRebaseInProgress { + /** Branch name being rebased */ + headName: string; + /** Short SHA of the onto commit */ + onto: string; +} + export interface GitStatusResult { current: string; tracking: string | null; @@ -251,6 +265,10 @@ export interface GitStatusResult { files: GitStatusFile[]; isClean: boolean; diffStats?: Record; + /** Present when a merge is in progress with conflicts */ + mergeInProgress?: GitMergeInProgress | null; + /** Present when a rebase is in progress */ + rebaseInProgress?: GitRebaseInProgress | null; } /** @@ -323,6 +341,9 @@ export async function getGitStatus(directory: string): Promise } } + // Check for in-progress operations + const inProgressState = await checkInProgressOperations(directory); + return { current: head?.name || '', tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null, @@ -330,9 +351,73 @@ export async function getGitStatus(directory: string): Promise behind: head?.behind || 0, files, isClean: files.length === 0, + ...inProgressState, }; } +/** + * Check for in-progress merge/rebase operations + */ +async function checkInProgressOperations(directory: string): Promise<{ + mergeInProgress?: GitMergeInProgress | null; + rebaseInProgress?: GitRebaseInProgress | null; +}> { + const result: { + mergeInProgress?: GitMergeInProgress | null; + rebaseInProgress?: GitRebaseInProgress | null; + } = {}; + + const gitDir = path.join(directory, '.git'); + + try { + // Check MERGE_HEAD for merge in progress + const mergeHeadPath = path.join(gitDir, 'MERGE_HEAD'); + const mergeHeadExists = await fs.promises.stat(mergeHeadPath).then(() => true).catch(() => false); + + if (mergeHeadExists) { + const mergeHead = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => ''); + const headSha = mergeHead.trim().slice(0, 7); + // Only set mergeInProgress if we actually have a valid head SHA + if (headSha) { + const mergeMsg = await fs.promises.readFile(path.join(gitDir, 'MERGE_MSG'), 'utf8').catch(() => ''); + result.mergeInProgress = { + head: headSha, + message: mergeMsg.split('\n')[0] || '', + }; + } + } + } catch { + // ignore + } + + try { + // Check for rebase in progress (.git/rebase-merge or .git/rebase-apply) + const rebaseMergeExists = await fs.promises.stat(path.join(gitDir, 'rebase-merge')).then(() => true).catch(() => false); + const rebaseApplyExists = await fs.promises.stat(path.join(gitDir, 'rebase-apply')).then(() => true).catch(() => false); + + if (rebaseMergeExists || rebaseApplyExists) { + const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply'; + const headName = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'head-name'), 'utf8').catch(() => ''); + const onto = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'onto'), 'utf8').catch(() => ''); + + const headNameTrimmed = headName.trim().replace('refs/heads/', ''); + const ontoTrimmed = onto.trim().slice(0, 7); + + // Only set rebaseInProgress if we have valid data + if (headNameTrimmed || ontoTrimmed) { + result.rebaseInProgress = { + headName: headNameTrimmed, + onto: ontoTrimmed, + }; + } + } + } catch { + // ignore + } + + return result; +} + /** * Fallback: Get git status using raw git commands */ @@ -383,6 +468,9 @@ async function getGitStatusRaw(directory: string): Promise { } } + // Check for in-progress operations + const inProgressState = await checkInProgressOperations(directory); + return { current, tracking, @@ -390,6 +478,7 @@ async function getGitStatusRaw(directory: string): Promise { behind, files, isClean: files.length === 0, + ...inProgressState, }; } @@ -1319,3 +1408,227 @@ export async function setGitIdentity( return { success: true }; } + +// ============== Remote Operations ============== + +export interface GitRemote { + name: string; + fetchUrl: string; + pushUrl: string; +} + +/** + * Get list of remotes + */ +export async function getRemotes(directory: string): Promise { + const result = await execGit(['remote', '-v'], directory); + if (result.exitCode !== 0) { + return []; + } + + const remoteMap = new Map(); + const lines = result.stdout.split('\n').filter(Boolean); + + for (const line of lines) { + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (match) { + const [, name, url, type] = match; + if (!remoteMap.has(name)) { + remoteMap.set(name, { name, fetchUrl: '', pushUrl: '' }); + } + const remote = remoteMap.get(name)!; + if (type === 'fetch') { + remote.fetchUrl = url; + } else { + remote.pushUrl = url; + } + } + } + + return Array.from(remoteMap.values()); +} + +// ============== Merge & Rebase Operations ============== + +export interface GitMergeResult { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +export interface GitRebaseResult { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +/** + * Rebase current branch onto target + */ +export async function rebase( + directory: string, + options: { onto: string } +): Promise { + const result = await execGit(['rebase', options.onto], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('could not apply') || + output.includes('merge conflict'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Rebase failed'); +} + +/** + * Abort an in-progress rebase + */ +export async function abortRebase(directory: string): Promise<{ success: boolean }> { + const result = await execGit(['rebase', '--abort'], directory); + return { success: result.exitCode === 0 }; +} + +/** + * Merge branch into current + */ +export async function merge( + directory: string, + options: { branch: string } +): Promise { + const result = await execGit(['merge', options.branch], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('merge conflict') || + output.includes('automatic merge failed'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Merge failed'); +} + +/** + * Abort an in-progress merge + */ +export async function abortMerge(directory: string): Promise<{ success: boolean }> { + const result = await execGit(['merge', '--abort'], directory); + return { success: result.exitCode === 0 }; +} + +/** + * Continue an in-progress rebase after conflicts are resolved + */ +export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + const result = await execGit(['rebase', '--continue'], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('needs merge') || + output.includes('unmerged'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Continue rebase failed'); +} + +/** + * Continue an in-progress merge after conflicts are resolved + */ +export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + // For merge, we commit after resolving conflicts + const result = await execGit(['commit', '--no-edit'], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('needs merge') || + output.includes('unmerged'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Continue merge failed'); +} + +// ============== Stash Operations ============== + +/** + * Stash changes + */ +export async function stash( + directory: string, + options?: { message?: string; includeUntracked?: boolean } +): Promise<{ success: boolean }> { + const args = ['stash', 'push']; + + // Include untracked files by default + if (options?.includeUntracked !== false) { + args.push('--include-untracked'); + } + + if (options?.message) { + args.push('-m', options.message); + } + + const result = await execGit(args, directory); + return { success: result.exitCode === 0 }; +} + +/** + * Pop the most recent stash + */ +export async function stashPop(directory: string): Promise<{ success: boolean }> { + const result = await execGit(['stash', 'pop'], directory); + return { success: result.exitCode === 0 }; +} diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index ab9f80d1..709a10eb 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -26,6 +26,9 @@ import type { GitCommitFilesResponse, GitIdentitySummary, GitIdentityProfile, + GitRemote, + GitRebaseResult, + GitMergeResult, } from '@openchamber/ui/lib/api/types'; export const createVSCodeGitAPI = (): GitAPI => ({ @@ -220,4 +223,62 @@ export const createVSCodeGitAPI = (): GitAPI => ({ deleteGitIdentity: async (id: string): Promise => { void id; // Unused for now }, + + getRemotes: async (directory: string): Promise => { + return sendBridgeMessage('api:git/remotes', { directory }); + }, + + rebase: async (directory: string, options: { onto: string }): Promise => { + return sendBridgeMessage('api:git/rebase', { + directory, + onto: options.onto, + }); + }, + + abortRebase: async (directory: string): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/rebase/abort', { directory }); + }, + + merge: async (directory: string, options: { branch: string }): Promise => { + return sendBridgeMessage('api:git/merge', { + directory, + branch: options.branch, + }); + }, + + abortMerge: async (directory: string): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/merge/abort', { directory }); + }, + + continueRebase: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => { + return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/rebase/continue', { directory }); + }, + + continueMerge: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => { + return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/merge/continue', { directory }); + }, + + stash: async ( + directory: string, + options?: { message?: string; includeUntracked?: boolean } + ): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/stash', { + directory, + ...options, + }); + }, + + stashPop: async (directory: string): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/stash/pop', { directory }); + }, + + getConflictDetails: async (directory: string) => { + return sendBridgeMessage<{ + statusPorcelain: string; + unmergedFiles: string[]; + diff: string; + headInfo: string; + operation: 'merge' | 'rebase'; + }>('api:git/conflict-details', { directory }); + }, }); diff --git a/packages/web/package.json b/packages/web/package.json index 66562a63..b6a8dc49 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -97,4 +97,4 @@ "package.json", "README.md" ] -} +} \ No newline at end of file diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6ca3d6ad..6f0ac758 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -7323,6 +7323,166 @@ Context: } }); + app.get('/api/git/remotes', async (req, res) => { + const { getRemotes } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const remotes = await getRemotes(directory); + res.json(remotes); + } catch (error) { + console.error('Failed to get remotes:', error); + res.status(500).json({ error: error.message || 'Failed to get remotes' }); + } + }); + + app.post('/api/git/rebase', async (req, res) => { + const { rebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await rebase(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to rebase:', error); + res.status(500).json({ error: error.message || 'Failed to rebase' }); + } + }); + + app.post('/api/git/rebase/abort', async (req, res) => { + const { abortRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort rebase:', error); + res.status(500).json({ error: error.message || 'Failed to abort rebase' }); + } + }); + + app.post('/api/git/merge', async (req, res) => { + const { merge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await merge(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to merge:', error); + res.status(500).json({ error: error.message || 'Failed to merge' }); + } + }); + + app.post('/api/git/merge/abort', async (req, res) => { + const { abortMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort merge:', error); + res.status(500).json({ error: error.message || 'Failed to abort merge' }); + } + }); + + app.post('/api/git/rebase/continue', async (req, res) => { + const { continueRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue rebase:', error); + res.status(500).json({ error: error.message || 'Failed to continue rebase' }); + } + }); + + app.post('/api/git/merge/continue', async (req, res) => { + const { continueMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue merge:', error); + res.status(500).json({ error: error.message || 'Failed to continue merge' }); + } + }); + + app.get('/api/git/conflict-details', async (req, res) => { + const { getConflictDetails } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await getConflictDetails(directory); + res.json(result); + } catch (error) { + console.error('Failed to get conflict details:', error); + res.status(500).json({ error: error.message || 'Failed to get conflict details' }); + } + }); + + app.post('/api/git/stash', async (req, res) => { + const { stash } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stash(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to stash:', error); + res.status(500).json({ error: error.message || 'Failed to stash' }); + } + }); + + app.post('/api/git/stash/pop', async (req, res) => { + const { stashPop } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stashPop(directory); + res.json(result); + } catch (error) { + console.error('Failed to pop stash:', error); + res.status(500).json({ error: error.message || 'Failed to pop stash' }); + } + }); + app.post('/api/git/commit', async (req, res) => { const { commit } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 522fd0ac..07cf776e 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -9,6 +9,54 @@ const fsp = fs.promises; const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; +/** + * Escape an SSH key path for use in core.sshCommand. + * Handles Windows/Unix differences and prevents command injection. + */ +function escapeSshKeyPath(sshKeyPath) { + const isWindows = process.platform === 'win32'; + + // Normalize path first on Windows (convert backslashes to forward slashes) + let normalizedPath = sshKeyPath; + if (isWindows) { + normalizedPath = sshKeyPath.replace(/\\/g, '/'); + } + + // Validate: reject paths with characters that could enable injection + // Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives) + // Note: backslash is not in this list since we've already normalized Windows paths + const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/; + if (dangerousChars.test(normalizedPath)) { + throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`); + } + + if (isWindows) { + // On Windows, Git (via MSYS/MinGW) expects Unix-style paths + // Convert "C:/path" to "/c/path" for MSYS compatibility + let unixPath = normalizedPath; + const driveMatch = unixPath.match(/^([A-Za-z]):\//); + if (driveMatch) { + unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`; + } + + // Use single quotes for the path (prevents shell interpretation) + return `'${unixPath}'`; + } else { + // On Unix, use single quotes and escape any single quotes in the path + // Single quotes prevent all shell interpretation except for single quotes themselves + const escaped = normalizedPath.replace(/'/g, "'\\''"); + return `'${escaped}'`; + } +} + +/** + * Build the SSH command string for git config + */ +function buildSshCommand(sshKeyPath) { + const escapedPath = escapeSshKeyPath(sshKeyPath); + return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`; +} + const isSocketPath = async (candidate) => { if (!candidate || typeof candidate !== 'string') { return false; @@ -221,7 +269,7 @@ export async function setLocalIdentity(directory, profile) { if (authType === 'ssh' && profile.sshKey) { await git.addConfig( 'core.sshCommand', - `ssh -i ${profile.sshKey}`, + buildSshCommand(profile.sshKey), false, 'local' ); @@ -404,6 +452,58 @@ export async function getStatus(directory) { } } + // Check for in-progress operations + let mergeInProgress = null; + let rebaseInProgress = null; + + try { + // Check MERGE_HEAD for merge in progress + const mergeHeadExists = await git + .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']) + .then(() => true) + .catch(() => false); + + if (mergeHeadExists) { + const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => ''); + const headSha = mergeHead.trim().slice(0, 7); + // Only set mergeInProgress if we actually have a valid head SHA + if (headSha) { + const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => ''); + mergeInProgress = { + head: headSha, + message: mergeMsg.split('\n')[0] || '', + }; + } + } + } catch { + // ignore + } + + try { + // Check for rebase in progress (.git/rebase-merge or .git/rebase-apply) + const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); + const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); + + if (rebaseMergeExists || rebaseApplyExists) { + const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply'; + const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => ''); + const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => ''); + + const headNameTrimmed = headName.trim().replace('refs/heads/', ''); + const ontoTrimmed = onto.trim().slice(0, 7); + + // Only set rebaseInProgress if we have valid data + if (headNameTrimmed || ontoTrimmed) { + rebaseInProgress = { + headName: headNameTrimmed, + onto: ontoTrimmed, + }; + } + } + } catch { + // ignore + } + return { current: status.current, tracking, @@ -416,6 +516,8 @@ export async function getStatus(directory) { })), isClean: status.isClean(), diffStats, + mergeInProgress, + rebaseInProgress, }; } catch (error) { console.error('Failed to get Git status:', error); @@ -1201,3 +1303,302 @@ export async function renameBranch(directory, oldName, newName) { throw error; } } + +export async function getRemotes(directory) { + const git = await createGit(directory); + + try { + const remotes = await git.getRemotes(true); + + return remotes.map((remote) => ({ + name: remote.name, + fetchUrl: remote.refs.fetch, + pushUrl: remote.refs.push + })); + } catch (error) { + console.error('Failed to get remotes:', error); + throw error; + } +} + +export async function rebase(directory, options = {}) { + const git = await createGit(directory); + + try { + const { onto } = options; + if (!onto) { + throw new Error('onto parameter is required for rebase'); + } + + await git.rebase([onto]); + + return { + success: true, + conflict: false + }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = errorMessage.includes('conflict') || + errorMessage.includes('could not apply') || + errorMessage.includes('merge conflict'); + + if (isConflict) { + // Get list of conflicted files + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [] + }; + } + + console.error('Failed to rebase:', error); + throw error; + } +} + +export async function abortRebase(directory) { + const git = await createGit(directory); + + try { + await git.rebase(['--abort']); + return { success: true }; + } catch (error) { + console.error('Failed to abort rebase:', error); + throw error; + } +} + +export async function merge(directory, options = {}) { + const git = await createGit(directory); + + try { + const { branch } = options; + if (!branch) { + throw new Error('branch parameter is required for merge'); + } + + await git.merge([branch]); + + return { + success: true, + conflict: false + }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = errorMessage.includes('conflict') || + errorMessage.includes('merge conflict') || + errorMessage.includes('automatic merge failed'); + + if (isConflict) { + // Get list of conflicted files + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [] + }; + } + + console.error('Failed to merge:', error); + throw error; + } +} + +export async function abortMerge(directory) { + const git = await createGit(directory); + + try { + await git.merge(['--abort']); + return { success: true }; + } catch (error) { + console.error('Failed to abort merge:', error); + throw error; + } +} + +export async function continueRebase(directory) { + const directoryPath = normalizeDirectoryPath(directory); + const git = await createGit(directoryPath); + + try { + // Set GIT_EDITOR to prevent editor prompts + await git.env('GIT_EDITOR', 'true').rebase(['--continue']); + return { success: true, conflict: false }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = errorMessage.includes('conflict') || + errorMessage.includes('needs merge') || + errorMessage.includes('unmerged') || + errorMessage.includes('fix conflicts'); + + if (isConflict) { + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [] + }; + } + + // Check for "nothing to commit" which means rebase step is complete + if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) { + // Skip this commit and continue + try { + await git.env('GIT_EDITOR', 'true').rebase(['--skip']); + return { success: true, conflict: false }; + } catch { + // If skip also fails, the rebase may be complete + return { success: true, conflict: false }; + } + } + + console.error('Failed to continue rebase:', error); + throw error; + } +} + +export async function continueMerge(directory) { + const directoryPath = normalizeDirectoryPath(directory); + const git = await createGit(directoryPath); + + try { + // Check if there are still unmerged files + const status = await git.status(); + if (status.conflicted && status.conflicted.length > 0) { + return { + success: false, + conflict: true, + conflictFiles: status.conflicted + }; + } + + // For merge, we commit after resolving conflicts + // Use --no-edit to use the default merge commit message + await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null }); + return { success: true, conflict: false }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = errorMessage.includes('conflict') || + errorMessage.includes('needs merge') || + errorMessage.includes('unmerged') || + errorMessage.includes('fix conflicts'); + + if (isConflict) { + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [] + }; + } + + // "nothing to commit" can happen if all conflicts resolved to one side + if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) { + // The merge is effectively complete (all changes already committed or no changes needed) + return { success: true, conflict: false }; + } + + console.error('Failed to continue merge:', error); + throw error; + } +} + +export async function getConflictDetails(directory) { + const directoryPath = normalizeDirectoryPath(directory); + const git = await createGit(directoryPath); + + try { + // Get git status --porcelain + const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => ''); + + // Get unmerged files + const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => ''); + const unmergedFiles = unmergedFilesRaw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // Get current diff + const diff = await git.raw(['diff']).catch(() => ''); + + // Detect operation type and get head info + let operation = 'merge'; + let headInfo = ''; + + // Check for MERGE_HEAD (merge in progress) + const mergeHeadExists = await git + .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']) + .then(() => true) + .catch(() => false); + + if (mergeHeadExists) { + operation = 'merge'; + const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => ''); + const mergeMsg = await fsp + .readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8') + .catch(() => ''); + headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`; + } else { + // Check for REBASE_HEAD (rebase in progress) + const rebaseHeadExists = await git + .raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD']) + .then(() => true) + .catch(() => false); + + if (rebaseHeadExists) { + operation = 'rebase'; + const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => ''); + headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`; + } + } + + return { + statusPorcelain: statusPorcelain.trim(), + unmergedFiles, + diff: diff.trim(), + headInfo: headInfo.trim(), + operation, + }; + } catch (error) { + console.error('Failed to get conflict details:', error); + throw error; + } +} + +// ============== Stash Operations ============== + +export async function stash(directory, options = {}) { + const git = await createGit(directory); + + try { + const args = ['stash', 'push']; + + // Include untracked files by default + if (options.includeUntracked !== false) { + args.push('--include-untracked'); + } + + if (options.message) { + args.push('-m', options.message); + } + + await git.raw(args); + return { success: true }; + } catch (error) { + console.error('Failed to stash:', error); + throw error; + } +} + +export async function stashPop(directory) { + const git = await createGit(directory); + + try { + await git.raw(['stash', 'pop']); + return { success: true }; + } catch (error) { + console.error('Failed to pop stash:', error); + throw error; + } +} diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 1552ecf6..0cef1865 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -38,4 +38,14 @@ export const createWebGitAPI = (): GitAPI => ({ createGitIdentity: gitApiHttp.createGitIdentity, updateGitIdentity: gitApiHttp.updateGitIdentity, deleteGitIdentity: gitApiHttp.deleteGitIdentity, + getRemotes: gitApiHttp.getRemotes, + rebase: gitApiHttp.rebase, + abortRebase: gitApiHttp.abortRebase, + continueRebase: gitApiHttp.continueRebase, + merge: gitApiHttp.merge, + abortMerge: gitApiHttp.abortMerge, + continueMerge: gitApiHttp.continueMerge, + stash: gitApiHttp.stash, + stashPop: gitApiHttp.stashPop, + getConflictDetails: gitApiHttp.getConflictDetails, });