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.
This commit is contained in:
gsxdsm
2026-02-07 11:33:17 +02:00
committed by GitHub
parent 57cc3325d8
commit 9534e3d016
24 changed files with 3324 additions and 265 deletions
+1
View File
@@ -27,3 +27,4 @@ local-dev*
*.sln
*.sw?
.opencode/plans/*
.hive
+15 -1
View File
@@ -114,6 +114,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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
+7 -5
View File
@@ -65,10 +65,10 @@ function CommandDialog({
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => {
return (
<div
data-slot="command-input-wrapper"
@@ -76,6 +76,7 @@ function CommandInput({
>
<RiSearchLine className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-8 w-full rounded-lg bg-transparent py-2 typography-meta outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
@@ -85,7 +86,8 @@ function CommandInput({
/>
</div>
)
}
})
CommandInput.displayName = "CommandInput"
function CommandList({
className,
+596 -135
View File
@@ -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<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
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<string, (typeof files)[number]>();
@@ -603,22 +678,22 @@ export const GitView: React.FC = () => {
});
}, [status, changeEntries, hasUserAdjustedSelection]);
const handleSyncAction = async (action: Exclude<SyncAction, null>) => {
const handleSyncAction = async (action: Exclude<SyncAction, null>, 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 (
<div className="flex h-full items-center justify-center px-4 text-center">
@@ -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}
/>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
<div className="flex flex-col gap-3">
{/* Two-column layout on large screens: Changes + Commit */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{hasChanges ? (
<ChangesSection
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : (
<div className="lg:col-span-2 flex justify-center">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => handleSyncAction('pull')}
isPulling={syncAction === 'pull'}
/>
</div>
)}
{/* In-progress operation banner */}
{currentDirectory && (
(status?.mergeInProgress?.head) ||
(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto)
) && (
<InProgressOperationBanner
mergeInProgress={status?.mergeInProgress}
rebaseInProgress={status?.rebaseInProgress}
onContinue={handleContinueOperation}
onAbort={handleAbortOperation}
onResolveWithAI={handleResolveWithAIFromBanner}
hasUnresolvedConflicts={hasUnresolvedConflicts}
isLoading={isLoading}
/>
)}
{changeEntries.length > 0 && (
<CommitSection
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
)}
</div>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
<IntegrateCommitsSection
repoRoot={repoRootForIntegrate}
sourceBranch={sourceBranchForIntegrate}
worktreeMetadata={worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
<div className="flex flex-col gap-3">
{/* Two-column layout on large screens: Changes + Commit */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{hasChanges ? (
<ChangesSection
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : null}
) : (
<div className="lg:col-span-2 flex justify-center">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => {
if (remotes.length > 0) {
handleSyncAction('pull', remotes[0]);
} else {
toast.error('No remotes configured');
}
}}
isPulling={syncAction === 'pull'}
/>
</div>
)}
{currentDirectory && status?.current && status?.tracking ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
{changeEntries.length > 0 && (
<CommitSection
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
) : null}
{/* History below, constrained width */}
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
/>
)}
</div>
</ScrollableOverlay>
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4">
<DialogTitle>Pick a gitmoji</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
<CommandInput
placeholder="Search gitmojis..."
value={gitmojiSearch}
onValueChange={setGitmojiSearch}
/>
<CommandList>
<CommandEmpty>No gitmojis found.</CommandEmpty>
<CommandGroup>
{(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) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
>
<span className="text-lg">{entry.emoji}</span>
<span className="typography-ui-label text-foreground">{entry.code}</span>
<span className="typography-meta text-muted-foreground">{entry.description}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
<IntegrateCommitsSection
repoRoot={repoRootForIntegrate}
sourceBranch={sourceBranchForIntegrate}
worktreeMetadata={worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : null}
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
{currentDirectory && status?.current && status?.tracking ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
/>
) : null}
{/* History below, constrained width */}
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
/>
</div>
</ScrollableOverlay>
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4">
<DialogTitle>Pick a gitmoji</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
<CommandInput
placeholder="Search gitmojis..."
value={gitmojiSearch}
onValueChange={setGitmojiSearch}
/>
<CommandList>
<CommandEmpty>No gitmojis found.</CommandEmpty>
<CommandGroup>
{(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) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
>
<span className="text-lg">{entry.emoji}</span>
<span className="typography-ui-label text-foreground">{entry.code}</span>
<span className="typography-meta text-muted-foreground">{entry.description}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
{currentDirectory && (
<ConflictDialog
open={conflictDialogOpen}
onOpenChange={setConflictDialogOpen}
conflictFiles={conflictFiles}
directory={currentDirectory}
operation={conflictOperation}
onAbort={handleAbortConflict}
onClearState={clearConflictState}
/>
)}
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
operation={stashDialogOperation}
targetBranch={stashDialogBranch}
onConfirm={handleStashAndRetry}
/>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
</div>
);
@@ -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<BranchIntegrationSectionProps> = ({
currentBranch,
localBranches,
remoteBranches,
onMerge,
onRebase,
disabled = false,
isOperating = false,
operationLogs = [],
onOperationComplete,
}) => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [operation, setOperation] = React.useState<OperationType>('merge');
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(null);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const [branchSearch, setBranchSearch] = React.useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
const logContainerRef = React.useRef<HTMLDivElement>(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 (
<>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5"
onClick={handleOpenDialog}
disabled={isDisabled}
>
{isOperating ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiGitMergeLine className="size-4" />
)}
<span className="hidden sm:inline">Integrate</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Merge or rebase another branch
</TooltipContent>
</Tooltip>
<Dialog open={dialogOpen} onOpenChange={(open) => {
if (!open) {
handleClose();
} else {
setDialogOpen(true);
}
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Integrate Branch</DialogTitle>
<DialogDescription>
{isOperating ? (
operationCompleted ? (
hasError ? 'Operation failed' : 'Operation completed'
) : (
`${operation === 'merge' ? 'Merging' : 'Rebasing'} in progress...`
)
) : (
<>
Choose how to integrate changes from another branch into{' '}
<span className="font-mono text-foreground">{currentBranch || 'current branch'}</span>
</>
)}
</DialogDescription>
</DialogHeader>
{/* Show operation log when operating */}
{isOperating ? (
<div className="space-y-3">
<div
ref={logContainerRef}
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
>
<div className="space-y-2">
{operationLogs.map((log, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-0.5 shrink-0">
{log.status === 'running' && (
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
)}
{log.status === 'done' && (
<RiCheckLine className="size-3.5 text-success" />
)}
{log.status === 'error' && (
<RiCloseLine className="size-3.5 text-destructive" />
)}
{log.status === 'pending' && (
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
)}
</div>
<span className={cn(
'typography-micro',
log.status === 'error' && 'text-destructive',
log.status === 'done' && 'text-muted-foreground',
log.status === 'running' && 'text-foreground',
log.status === 'pending' && 'text-muted-foreground/60'
)}>
{log.message}
</span>
</div>
))}
</div>
</div>
{operationCompleted && (
<DialogFooter>
<Button
variant="default"
size="sm"
onClick={handleClose}
>
{hasError ? 'Close' : 'Done'}
</Button>
</DialogFooter>
)}
</div>
) : (
<>
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">Operation</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOperation('merge')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'merge'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitMergeLine className={cn(
'size-4',
operation === 'merge' ? 'text-primary' : 'text-muted-foreground'
)} />
<span className={cn(
'typography-ui-label',
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
)}>
Merge
</span>
</div>
<p className="typography-micro text-muted-foreground">
Combines branches with a merge commit. Preserves history.
</p>
</button>
<button
type="button"
onClick={() => setOperation('rebase')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'rebase'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitBranchLine className={cn(
'size-4',
operation === 'rebase' ? 'text-primary' : 'text-muted-foreground'
)} />
<span className={cn(
'typography-ui-label',
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
)}>
Rebase
</span>
</div>
<p className="typography-micro text-muted-foreground">
Replays commits on top. Creates linear history.
</p>
</button>
</div>
</div>
{/* Branch Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'}
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full justify-between h-10"
>
<span className={cn(
'truncate',
!selectedBranch && 'text-muted-foreground'
)}>
{selectedBranch || 'Select a branch...'}
</span>
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
<Command>
<CommandInput
ref={searchInputRef}
placeholder="Search branches..."
value={branchSearch}
onValueChange={setBranchSearch}
/>
<CommandList>
<CommandEmpty>No branches found.</CommandEmpty>
{filteredLocal.length > 0 && (
<CommandGroup heading="Local branches">
{filteredLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
onSelect={() => handleSelectBranch(branch)}
>
<span className="typography-ui-label text-foreground truncate">
{branch}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{filteredLocal.length > 0 && filteredRemote.length > 0 && (
<CommandSeparator />
)}
{filteredRemote.length > 0 && (
<CommandGroup heading="Remote branches">
{filteredRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
onSelect={() => handleSelectBranch(branch)}
>
<span className="typography-ui-label text-foreground truncate">
{branch}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Summary */}
{selectedBranch && (
<div className="rounded-lg bg-muted/50 p-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? (
<>
This will merge{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
{' '}into{' '}
<span className="font-mono text-foreground">{currentBranch}</span>
</>
) : (
<>
This will rebase{' '}
<span className="font-mono text-foreground">{currentBranch}</span>
{' '}onto{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
</>
)}
</p>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="gap-1.5"
>
{operation === 'merge' ? (
<>
<RiGitMergeLine className="size-4" />
Merge
</>
) : (
<>
<RiGitBranchLine className="size-4" />
Rebase
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
};
@@ -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<ConflictDialogProps> = ({
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<MergeConflictDetails | null>(null);
const [loadError, setLoadError] = React.useState<string | null>(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 <file>
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
<div className="flex flex-col gap-4 overflow-hidden">
<DialogHeader>
<div className="flex items-center gap-2">
<RiAlertLine className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{operationLabel} Conflicts Detected</DialogTitle>
</div>
<DialogDescription>
The {operation} operation resulted in conflicts that need to be resolved.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground">
<RiLoader4Line className="size-4 animate-spin" />
<span className="typography-meta">Loading conflict details...</span>
</div>
)}
{loadError && (
<div className="rounded-lg bg-[var(--status-error-bg)] p-3 text-[var(--status-error)] typography-meta break-words">
Error loading details: {loadError}
</div>
)}
{displayFiles.length > 0 && (
<div className="space-y-2 overflow-hidden">
<div className="flex items-center justify-between">
<p className="typography-meta text-muted-foreground">Conflicted files:</p>
<span className="typography-micro px-1.5 py-0.5 rounded bg-[var(--surface-elevated)] text-muted-foreground">
{displayFiles.length}
</span>
</div>
<div className="bg-[var(--surface-elevated)] rounded-lg p-3 max-h-40 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
{displayFiles.map((file, index) => (
<li
key={index}
className="typography-micro text-foreground font-mono truncate block"
title={file}
>
{file}
</li>
))}
</ul>
</div>
</div>
)}
{conflictDetails?.headInfo && (
<div className="space-y-1 overflow-hidden">
<p className="typography-meta text-muted-foreground">Head information:</p>
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
{conflictDetails.headInfo}
</div>
</div>
)}
<div className="flex flex-col gap-2 pt-2">
<Button
variant="default"
onClick={handleResolveInNewSession}
disabled={isLoading || !conflictDetails}
className="w-full gap-2"
>
{isLoading ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAddLine className="size-4" />
)}
Resolve in New Session
</Button>
<Button
variant="outline"
onClick={() => void handleResolveInCurrentSession()}
disabled={isLoading || !conflictDetails || !currentSessionId}
className="w-full gap-2"
>
{isLoading ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiChat1Line className="size-4" />
)}
Resolve in Current Session
</Button>
<div className="flex gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={handleContinueLater} className="flex-1">
Continue Later
</Button>
<Button variant="ghost" size="sm" onClick={handleAbort} className="flex-1 text-[var(--status-error)]">
Abort {operationLabel}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -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<string, { ahead?: number; behind?: number }> | 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<void>;
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
@@ -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<GitHeaderProps> = ({
remoteBranches,
branchInfo,
syncAction,
remotes,
onFetch,
onPull,
onPush,
@@ -197,6 +208,12 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
onMerge,
onRebase,
branchOperation,
operationLogs,
onOperationComplete,
isBusy,
onOpenBranchPicker,
}) => {
if (!status) {
@@ -247,12 +264,27 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<SyncActions
syncAction={syncAction}
remotes={remotes}
onFetch={onFetch}
onPull={onPull}
onPush={onPush}
disabled={!status}
/>
<div className="h-4 w-px bg-border/60" />
<BranchIntegrationSection
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
onMerge={onMerge}
onRebase={onRebase}
disabled={isBusy}
isOperating={branchOperation !== null}
operationLogs={operationLogs}
onOperationComplete={onOperationComplete}
/>
<div className="flex-1" />
{onOpenBranchPicker ? (
@@ -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<void>;
onAbort: () => Promise<void>;
onResolveWithAI?: () => void;
hasUnresolvedConflicts?: boolean;
isLoading?: boolean;
}
export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps> = ({
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 (
<div className="bg-[var(--status-warning-bg)] border border-[var(--status-warning)] rounded-lg p-3 mx-3 mt-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<OperationIcon className="size-4 text-[var(--status-warning)] shrink-0" />
<div className="min-w-0">
<p className="typography-label text-[var(--status-warning)]">
{operationLabel} in Progress
</p>
{description && (
<p className="typography-micro text-muted-foreground truncate">
{description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasUnresolvedConflicts && onResolveWithAI && (
<Button
variant="outline"
size="sm"
onClick={onResolveWithAI}
disabled={isProcessing || isLoading}
className="gap-1.5"
>
<RiSparklingLine className="size-4" />
Resolve with AI
</Button>
)}
{processingAction !== 'continue' && (
<Button
variant="ghost"
size="sm"
onClick={handleAbort}
disabled={isProcessing || isLoading}
className="gap-1.5"
>
{processingAction === 'abort' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiCloseLine className="size-4" />
)}
Abort
</Button>
)}
{!hasUnresolvedConflicts && (
<Button
variant="default"
size="sm"
onClick={handleContinue}
disabled={isProcessing || isLoading}
className="gap-1.5"
>
{processingAction === 'continue' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiCheckLine className="size-4" />
)}
Continue
</Button>
)}
</div>
</div>
{hasUnresolvedConflicts && (
<p className="typography-micro text-[var(--status-warning)] mt-2">
Conflicts must be resolved before continuing. Use &quot;Resolve with AI&quot; or resolve manually, then stage changes and click Continue.
</p>
)}
</div>
);
};
@@ -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<HTMLInputElement>(null);
const [targetBranch, setTargetBranch] = React.useState<string>(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 <file>
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:
<div className="flex-1" />
<DropdownMenu>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
Target
@@ -358,7 +389,7 @@ Goal:
className="w-72 p-0 max-h-(--radix-dropdown-menu-content-available-height) flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
<CommandInput placeholder="Search branches..." />
<CommandInput ref={searchInputRef} placeholder="Search branches..." />
<CommandList
className="h-full min-h-0"
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
@@ -373,6 +404,7 @@ Goal:
onSelect={() => {
setTargetBranch(branch);
persistTarget(branch);
setBranchDropdownOpen(false);
}}
>
{branch}
@@ -465,10 +497,21 @@ Goal:
<Button
size="sm"
variant="secondary"
className="h-7 px-2 py-0 typography-meta"
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details })}
className="h-7 px-2 py-0 typography-meta gap-1"
disabled={!currentSessionId}
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, false)}
>
Resolve with AI
<RiSparklingLine className="size-3.5" />
Current Session
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 px-2 py-0 typography-meta gap-1"
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, true)}
>
<RiSparklingLine className="size-3.5" />
New Session
</Button>
<Button size="sm" className="h-7 px-2 py-0 typography-meta" onClick={() => void handleContinue()}>
Continue
@@ -0,0 +1,124 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { RiAlertLine, RiLoader4Line } from '@remixicon/react';
interface StashDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
operation: 'merge' | 'rebase';
targetBranch: string;
onConfirm: (restoreAfter: boolean) => Promise<void>;
}
export const StashDialog: React.FC<StashDialogProps> = ({
open,
onOpenChange,
operation,
targetBranch,
onConfirm,
}) => {
const [restoreAfter, setRestoreAfter] = React.useState(true);
const [isProcessing, setIsProcessing] = React.useState(false);
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
const handleConfirm = async () => {
setIsProcessing(true);
try {
await onConfirm(restoreAfter);
onOpenChange(false);
} catch (err) {
// Show error to user - parent may also handle it but user should see feedback
const message = err instanceof Error ? err.message : `Failed to ${operation}`;
toast.error(message);
} finally {
setIsProcessing(false);
}
};
const handleCancel = () => {
if (!isProcessing) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<RiAlertLine className="size-5 text-[var(--status-warning)]" />
<DialogTitle>Uncommitted Changes</DialogTitle>
</div>
<DialogDescription>
You have uncommitted changes that would be overwritten by {operation}.
Would you like to stash them temporarily?
</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="typography-meta text-muted-foreground mb-3">
This will:
</p>
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
<li>Stash your uncommitted changes</li>
<li>{operationLabel} <span className="font-mono text-primary">{targetBranch}</span></li>
{restoreAfter && <li>Restore your stashed changes</li>}
</ol>
</div>
<div className="flex items-center gap-2 py-2">
<Checkbox
checked={restoreAfter}
onChange={setRestoreAfter}
disabled={isProcessing}
ariaLabel="Restore changes after operation"
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
>
Restore changes after {operation}
</span>
</div>
<DialogFooter className="gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isProcessing}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onClick={handleConfirm}
disabled={isProcessing}
className="gap-1.5"
>
{isProcessing ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
Processing...
</>
) : (
`Stash & ${operationLabel}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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<SyncActionsProps> = ({
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 = (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onClick}
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
<span className="hidden sm:inline">{label}</span>
</Button>
);
return (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
);
};
const renderDropdownButton = (
action: SyncAction,
icon: React.ReactNode,
loadingIcon: React.ReactNode,
label: string,
onSelect: (remote: GitRemote) => void,
tooltipText: string
) => {
return (
<DropdownMenu>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
<span className="hidden sm:inline">{label}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="min-w-[200px]">
{remotes.map((remote) => (
<DropdownMenuItem key={remote.name} onSelect={() => onSelect(remote)}>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
</span>
<span className="typography-meta text-muted-foreground truncate">
{remote.fetchUrl}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<div className="flex items-center gap-0.5">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onFetch}
disabled={isDisabled}
>
{syncAction === 'fetch' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiRefreshLine className="size-4" />
)}
<span className="hidden sm:inline">Fetch</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Fetch from remote</TooltipContent>
</Tooltip>
{hasMultipleRemotes
? renderDropdownButton(
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Fetch',
onFetch,
'Fetch from remote'
)
: renderButton(
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Fetch',
handleFetch,
'Fetch from remote'
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onPull}
disabled={isDisabled}
>
{syncAction === 'pull' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowDownLine className="size-4" />
)}
<span className="hidden sm:inline">Pull</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Pull changes</TooltipContent>
</Tooltip>
{hasMultipleRemotes
? renderDropdownButton(
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
onPull,
'Pull changes'
)
: renderButton(
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
handlePull,
'Pull changes'
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onPush}
disabled={isDisabled}
>
{syncAction === 'push' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowUpLine className="size-4" />
)}
<span className="hidden sm:inline">Push</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Push changes</TooltipContent>
</Tooltip>
{hasMultipleRemotes
? renderDropdownButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
onPush,
'Push changes'
)
: renderButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
handlePush,
'Push changes'
)}
</div>
);
};
@@ -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';
+59
View File
@@ -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<string, { insertions: number; deletions: number }>;
/** 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<DiscoveredGitCredential[]>;
getGlobalGitIdentity?(): Promise<GitIdentitySummary | null>;
getRemoteUrl?(directory: string, remote?: string): Promise<string | null>;
getRemotes(directory: string): Promise<GitRemote[]>;
rebase(directory: string, options: { onto: string }): Promise<GitRebaseResult>;
abortRebase(directory: string): Promise<{ success: boolean }>;
continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>;
merge(directory: string, options: { branch: string }): Promise<GitMergeResult>;
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<MergeConflictDetails>;
}
export interface FileListEntry {
+73
View File
@@ -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<import('./api/types').GitRemote[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getRemotes(directory);
return gitHttp.getRemotes(directory);
}
export async function rebase(
directory: string,
options: { onto: string }
): Promise<import('./api/types').GitRebaseResult> {
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<import('./api/types').GitMergeResult> {
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<import('./api/types').MergeConflictDetails> {
const runtime = getRuntimeGit();
if (runtime?.getConflictDetails) return runtime.getConflictDetails(directory);
return gitHttp.getConflictDetails(directory);
}
+120
View File
@@ -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<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
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<MergeConflictDetails> {
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();
}
+15 -2
View File
@@ -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<void>;
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<Session | null>;
@@ -178,7 +189,7 @@ export interface SessionStore {
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null;
@@ -256,4 +267,6 @@ export interface SessionStore {
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null;
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void;
consumePendingSyntheticParts: () => SyntheticContextPart[] | null;
}
+29 -4
View File
@@ -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<SessionStore>()(
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<SessionStore>()(
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<SessionStore>()(
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<SessionStore>()(
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<SessionStore>()(
// 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<SessionStore>()(
}
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",
+169
View File
@@ -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 <project>/.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}` };
}
+313
View File
@@ -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<string, { insertions: number; deletions: number }>;
/** 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<GitStatusResult>
}
}
// 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<GitStatusResult>
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<GitStatusResult> {
}
}
// Check for in-progress operations
const inProgressState = await checkInProgressOperations(directory);
return {
current,
tracking,
@@ -390,6 +478,7 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
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<GitRemote[]> {
const result = await execGit(['remote', '-v'], directory);
if (result.exitCode !== 0) {
return [];
}
const remoteMap = new Map<string, GitRemote>();
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<GitRebaseResult> {
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<GitMergeResult> {
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 };
}
+61
View File
@@ -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> => {
void id; // Unused for now
},
getRemotes: async (directory: string): Promise<GitRemote[]> => {
return sendBridgeMessage<GitRemote[]>('api:git/remotes', { directory });
},
rebase: async (directory: string, options: { onto: string }): Promise<GitRebaseResult> => {
return sendBridgeMessage<GitRebaseResult>('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<GitMergeResult> => {
return sendBridgeMessage<GitMergeResult>('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 });
},
});
+1 -1
View File
@@ -97,4 +97,4 @@
"package.json",
"README.md"
]
}
}
+160
View File
@@ -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 {
+402 -1
View File
@@ -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;
}
}
+10
View File
@@ -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,
});