Fix concurrency limits and remote branch fetching issues (#788)

* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -17,11 +17,17 @@ import {
FileWarning,
Wrench,
Sparkles,
GitMerge,
GitCommitHorizontal,
FileText,
Settings,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Checkbox } from '@/components/ui/checkbox';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
@@ -37,6 +43,7 @@ type PullPhase =
| 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
| 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong
@@ -53,6 +60,9 @@ interface PullResult {
stashed?: boolean;
stashRestored?: boolean;
stashRecoveryFailed?: boolean;
isMerge?: boolean;
isFastForward?: boolean;
mergeAffectedFiles?: string[];
}
interface GitPullDialogProps {
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
remote?: string;
onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when user chooses to commit the merge — opens the commit dialog */
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
}
export function GitPullDialog({
@@ -71,10 +83,54 @@ export function GitPullDialog({
remote,
onPulled,
onCreateConflictResolutionFeature,
onCommitMerge,
}: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [rememberChoice, setRememberChoice] = useState(false);
const [showMergeFiles, setShowMergeFiles] = useState(false);
const mergePostAction = useAppStore((s) => s.mergePostAction);
const setMergePostAction = useAppStore((s) => s.setMergePostAction);
/**
* Determine the appropriate phase after a successful pull.
* If the pull resulted in a merge (not fast-forward) and no conflicts,
* check user preference before deciding whether to show merge prompt.
*/
const handleSuccessfulPull = useCallback(
(result: PullResult) => {
setPullResult(result);
if (result.isMerge && !result.hasConflicts) {
// Merge happened — check user preference
if (mergePostAction === 'commit') {
// User preference: auto-commit
setPhase('success');
onPulled?.();
// Auto-trigger commit dialog
if (worktree && onCommitMerge) {
onCommitMerge(worktree);
onOpenChange(false);
}
} else if (mergePostAction === 'manual') {
// User preference: manual review
setPhase('success');
onPulled?.();
} else {
// No preference — show merge prompt; onPulled will be called from the
// user-action handlers (handleCommitMerge / handleMergeManually) once
// the user makes their choice, consistent with the conflict phase.
setPhase('merge-complete');
}
} else {
setPhase('success');
onPulled?.();
}
},
[mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]
);
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
@@ -103,9 +159,7 @@ export function GitPullDialog({
setPhase('local-changes');
} else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date)
setPullResult(result.result);
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unexpected response: success but no recognizable fields
setPullResult(result.result ?? null);
@@ -116,18 +170,33 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
// Reset state when dialog opens
// Keep a ref to the latest checkForLocalChanges to break the circular dependency
// between the "reset/start" effect and the callback chain. Without this, any
// change in onPulled (passed from the parent) would recreate handleSuccessfulPull
// → checkForLocalChanges → re-trigger the effect while the dialog is already open,
// causing the pull flow to restart unintentionally.
const checkForLocalChangesRef = useRef(checkForLocalChanges);
useEffect(() => {
checkForLocalChangesRef.current = checkForLocalChanges;
});
// Reset state when dialog opens and start the initial pull check.
// Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` —
// so that parent callback re-creations don't restart the pull flow mid-flight.
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
setRememberChoice(false);
setShowMergeFiles(false);
// Start the initial check using the ref so we always call the latest version
// without making it a dependency of this effect.
checkForLocalChangesRef.current();
}
}, [open, worktree, checkForLocalChanges]);
}, [open, worktree]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -155,8 +224,7 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
@@ -167,7 +235,7 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
@@ -186,6 +254,35 @@ export function GitPullDialog({
onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
const handleCommitMerge = useCallback(() => {
if (!worktree || !onCommitMerge) {
// No handler available — show feedback and bail without persisting preference
toast.error('Commit merge is not available', {
description: 'The commit merge action is not configured for this context.',
duration: 4000,
});
return;
}
if (rememberChoice) {
setMergePostAction('commit');
}
onPulled?.();
onCommitMerge(worktree);
onOpenChange(false);
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
const handleMergeManually = useCallback(() => {
if (rememberChoice) {
setMergePostAction('manual');
}
toast.info('Merge left for manual review', {
description: 'Review the merged files and commit when ready.',
duration: 5000,
});
onPulled?.();
onOpenChange(false);
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
@@ -336,6 +433,137 @@ export function GitPullDialog({
</>
)}
{/* Merge Complete Phase — post-merge prompt */}
{phase === 'merge-complete' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Pull resulted in a merge on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<span>
{' '}
affecting {pullResult.mergeAffectedFiles.length} file
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowMergeFiles(!showMergeFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showMergeFiles ? 'Hide' : 'Show'} affected files (
{pullResult.mergeAffectedFiles.length})
</button>
{showMergeFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{pullResult.mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
{pullResult?.stashed &&
pullResult?.stashRestored &&
!pullResult?.stashRecoveryFailed && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Open the commit dialog with a merge
commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is for
manual review
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked === true)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{(rememberChoice || mergePostAction) && (
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
<span className="opacity-70">
Current:{' '}
{mergePostAction === 'commit'
? 'auto-commit'
: mergePostAction === 'manual'
? 'manual review'
: 'ask every time'}
</span>
<button
onClick={() => {
setMergePostAction(null);
setRememberChoice(false);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset preference
</button>
</span>
)}
</div>
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
{worktree && onCommitMerge && (
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
)}
</DialogFooter>
</>
)}
{/* Conflict Phase */}
{phase === 'conflict' && (
<>