From 9534e3d016c2a1d8b59982ff3473ce54e75fce03 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 7 Feb 2026 01:33:17 -0800 Subject: [PATCH] Feat: add push to and pull from git with remote selection, along with rebase and merge options (#345) * Add getRemotes API endpoint - Add getRemotes() function to git-service.js using simple-git's getRemotes(true) - Returns array of {name, fetchUrl, pushUrl} for each remote - Add GET /api/git/remotes endpoint to server/index.js - Follows existing patterns for git endpoints (directory query param, error handling) * Add merge and rebase API endpoints - Add rebase(), abortRebase(), merge(), abortMerge() to git-service.js - Add POST /api/git/rebase, /api/git/rebase/abort endpoints - Add POST /api/git/merge, /api/git/merge/abort endpoints - All functions return { success, conflict?, conflictFiles? } - Conflict detection via error message parsing and git status * Add client API functions for git remotes, merge, and rebase - Added GitRemote, GitMergeResult, GitRebaseResult interfaces to types.ts - Added getRemotes(), rebase(), abortRebase(), merge(), abortMerge() to gitApiHttp.ts - Added corresponding exports and runtime wrappers to gitApi.ts - All functions follow existing patterns with proper error handling - Lint and type-check pass * feat(git): add remote selection dropdown to SyncActions - Add remotes prop to SyncActions component - Change callbacks to accept GitRemote parameter - Show dropdown menu when multiple remotes exist - Execute immediately for single remote repos - Display remote name and fetch URL in dropdown items * feat: add BranchIntegrationSection component - Branch selector dropdown (local + remote branches) - Merge and Rebase buttons with loading states - Props: currentBranch, localBranches, remoteBranches, onMerge, onRebase, disabled, isOperating - Follows existing UI patterns (Command + DropdownMenu) - Tooltips for all interactive elements * Add ConflictDialog component for merge/rebase conflicts - Shows when merge/rebase returns conflict - Three action options: Resolve in New Session, Abort, Continue Later - Resolve in New Session opens OpenChamber session in conflict directory - Displays list of conflicted files - Uses theme tokens for colors - Follows existing dialog patterns from AboutDialog.tsx * Integrate git remote selection and branch operations into GitView - Fetch remotes on mount and store in state - Pass remotes to SyncActions and update handleSyncAction to accept GitRemote parameter - Add BranchIntegrationSection component below sync actions for merge/rebase operations - Add ConflictDialog to handle merge/rebase conflicts with option to resolve in new session - Export BranchIntegrationSection and ConflictDialog from git/index.ts - Update GitHeader to accept remotes prop and pass to SyncActions - Handle single vs multiple remote scenarios (immediate action vs dropdown) - Fix React hooks exhaustive-deps warnings by capturing status in local variable * fix: add missing git API methods to web and vscode packages * feat: extend VSCode bridge with git remote/rebase/merge endpoints * feat: add stash support for git operations across UI and API * hive(01-add-types-for-conflict-details): Added MergeConflictDetails interface to packages/u * hive(02-add-server-side-conflict-details-function): Added `getConflictDetails(directory)` function to * hive(03-add-server-endpoint-for-conflict-details): Added GET /api/git/conflict-details endpoint to pa * hive(04-add-client-side-api-for-conflict-details): Added client-side API for conflict details: 1. ** * hive(05-enhance-conflictdialog-with-rich-context): Enhanced ConflictDialog to fetch and use rich conf * hive(06-add-state-persistence-for-conflicts): Added state persistence for merge/rebase conflicts * feat: add conflict details API and AI resolve flow * fix: improve focus handling in git UI and adjust web dev server port * feat: add continue merge/rebase support and logs * fix: address bugs in git merge/rebase feature - Add explicit parentheses to hasUnresolvedConflicts logic for clarity - Add error handling for stash operation in handleStashAndRetry - Fix SSH key path escaping on Windows by normalizing before validation * fix: add default value for remotes prop to prevent crash When remotes is undefined, accessing .length throws TypeError. Add default empty array to handle undefined case gracefully. * fix: replace DialogFooter with plain div for proper button layout DialogFooter's default flex-col-reverse and sm:flex-row styles were conflicting with the intended vertical button stack layout, causing buttons to not display properly. * Fix lint erorr * fix: remove duplicate BranchIntegrationSection and fix broken vscode bridge - Remove duplicate BranchIntegrationSection from GitView.tsx (already in GitHeader) - Fix vscode bridge calling non-existent ensureOpenChamberIgnored function (legacy worktree function was removed, make api:git/ignore-openchamber a no-op) * fix: handleResolveWithAIFromBanner now properly detects conflicts from status The function was checking conflictFiles state which may be empty when the banner is shown. Now it extracts conflict files directly from the git status (files with 'U' status) and properly sets up the conflict dialog state before opening it. --- .gitignore | 1 + packages/ui/src/components/chat/ChatInput.tsx | 16 +- packages/ui/src/components/ui/command.tsx | 12 +- packages/ui/src/components/views/GitView.tsx | 731 ++++++++++++++---- .../views/git/BranchIntegrationSection.tsx | 451 +++++++++++ .../components/views/git/ConflictDialog.tsx | 273 +++++++ .../ui/src/components/views/git/GitHeader.tsx | 40 +- .../views/git/InProgressOperationBanner.tsx | 152 ++++ .../views/git/IntegrateCommitsSection.tsx | 145 ++-- .../src/components/views/git/StashDialog.tsx | 124 +++ .../src/components/views/git/SyncActions.tsx | 221 ++++-- packages/ui/src/components/views/git/index.ts | 3 + packages/ui/src/lib/api/types.ts | 59 ++ packages/ui/src/lib/gitApi.ts | 73 ++ packages/ui/src/lib/gitApiHttp.ts | 120 +++ packages/ui/src/stores/types/sessionTypes.ts | 17 +- packages/ui/src/stores/useSessionStore.ts | 33 +- packages/vscode/src/bridge.ts | 169 ++++ packages/vscode/src/gitService.ts | 313 ++++++++ packages/vscode/webview/api/git.ts | 61 ++ packages/web/package.json | 2 +- packages/web/server/index.js | 160 ++++ packages/web/server/lib/git-service.js | 403 +++++++++- packages/web/src/api/git.ts | 10 + 24 files changed, 3324 insertions(+), 265 deletions(-) create mode 100644 packages/ui/src/components/views/git/BranchIntegrationSection.tsx create mode 100644 packages/ui/src/components/views/git/ConflictDialog.tsx create mode 100644 packages/ui/src/components/views/git/InProgressOperationBanner.tsx create mode 100644 packages/ui/src/components/views/git/StashDialog.tsx 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, });