feat: Add conflict source branch detection and fix re-render cascade in BoardView

This commit is contained in:
gsxdsm
2026-03-02 07:20:11 -08:00
parent 33a2e04bf0
commit c11f390764
8 changed files with 158 additions and 16 deletions

View File

@@ -98,6 +98,7 @@ async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean; hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[]; conflictFiles?: string[];
conflictSourceBranch?: string;
}> { }> {
try { try {
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI) // Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
@@ -153,10 +154,84 @@ async function detectConflictState(worktreePath: string): Promise<{
// Fall back to empty list if diff fails // Fall back to empty list if diff fails
} }
// Detect the source branch involved in the conflict
let conflictSourceBranch: string | undefined;
try {
if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name
const mergeHead = (
await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
} else if (conflictType === 'rebase') {
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
const headNamePath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name');
try {
const ontoName = (await secureFs.readFile(headNamePath, 'utf-8')).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
} catch {
// onto-name may not exist; try to resolve the onto commit
try {
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = (await secureFs.readFile(ontoPath, 'utf-8')).trim();
if (ontoCommit) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
}
} catch {
// Could not resolve onto commit
}
}
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = (
await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
}
} catch {
// Ignore source branch detection errors
}
return { return {
hasConflicts: conflictFiles.length > 0, hasConflicts: conflictFiles.length > 0,
conflictType, conflictType,
conflictFiles, conflictFiles,
conflictSourceBranch,
}; };
} catch { } catch {
// If anything fails, assume no conflicts // If anything fails, assume no conflicts

View File

@@ -715,15 +715,27 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
const selectedWorktreeBranch = const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Aggregate running auto tasks across all worktrees for this project // Aggregate running auto tasks across all worktrees for this project.
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree); // IMPORTANT: Use a derived selector with shallow equality instead of subscribing
const runningAutoTasksAllWorktrees = useMemo(() => { // to the raw autoModeByWorktree object. The raw subscription caused the entire
if (!currentProject?.id) return []; // BoardView to re-render on EVERY auto-mode state change (any worktree), which
const prefix = `${currentProject.id}::`; // during worktree switches cascaded through DndContext/KanbanBoard and triggered
return Object.entries(autoModeByWorktree) // React error #185 (maximum update depth exceeded), crashing the board view.
.filter(([key]) => key.startsWith(prefix)) const runningAutoTasksAllWorktrees = useAppStore(
.flatMap(([, state]) => state.runningTasks ?? []); useShallow((state) => {
}, [autoModeByWorktree, currentProject?.id]); if (!currentProject?.id) return [] as string[];
const prefix = `${currentProject.id}::`;
const tasks: string[] = [];
for (const [key, worktreeState] of Object.entries(state.autoModeByWorktree)) {
if (key.startsWith(prefix) && worktreeState.runningTasks) {
for (const task of worktreeState.runningTasks) {
tasks.push(task);
}
}
}
return tasks;
})
);
// Get in-progress features for keyboard shortcuts (needed before actions hook) // Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined // Must be after runningAutoTasks is defined
@@ -1506,6 +1518,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
runningAutoTasks: runningAutoTasksAllWorktrees, runningAutoTasks: runningAutoTasksAllWorktrees,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
stopFeature: autoMode.stopFeature,
}); });
// Handle dependency link creation // Handle dependency link creation

View File

@@ -1,9 +1,8 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store'; import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants'; import { COLUMNS, ColumnId } from '../constants';
@@ -20,6 +19,7 @@ interface UseBoardDragDropProps {
runningAutoTasks: string[]; runningAutoTasks: string[];
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>; persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>; handleStartImplementation: (feature: Feature) => Promise<boolean>;
stopFeature: (featureId: string) => Promise<void>;
} }
export function useBoardDragDrop({ export function useBoardDragDrop({
@@ -28,6 +28,7 @@ export function useBoardDragDrop({
runningAutoTasks, runningAutoTasks,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
stopFeature,
}: UseBoardDragDropProps) { }: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>( const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
@@ -39,11 +40,22 @@ export function useBoardDragDrop({
// and triggers React error #185 (maximum update depth exceeded). // and triggers React error #185 (maximum update depth exceeded).
const moveFeature = useAppStore((s) => s.moveFeature); const moveFeature = useAppStore((s) => s.moveFeature);
const updateFeature = useAppStore((s) => s.updateFeature); const updateFeature = useAppStore((s) => s.updateFeature);
const autoMode = useAutoMode();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName // at execution time based on feature.branchName
// Clear stale activeFeature when features list changes (e.g. during worktree switches).
// Without this, the DragOverlay in KanbanBoard can try to render a feature from
// a previous worktree, causing property access crashes.
useEffect(() => {
setActiveFeature((current) => {
if (!current) return null;
// If the active feature is no longer in the features list, clear it
const stillExists = features.some((f) => f.id === current.id);
return stillExists ? current : null;
});
}, [features]);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
const { active } = event; const { active } = event;
@@ -244,7 +256,7 @@ export function useBoardDragDrop({
// If the feature is currently running, stop it first // If the feature is currently running, stop it first
if (isRunningTask) { if (isRunningTask) {
try { try {
await autoMode.stopFeature(featureId); await stopFeature(featureId);
logger.info('Stopped running feature via drag to backlog:', featureId); logger.info('Stopped running feature via drag to backlog:', featureId);
} catch (error) { } catch (error) {
logger.error('Error stopping feature during drag to backlog:', error); logger.error('Error stopping feature during drag to backlog:', error);
@@ -339,7 +351,7 @@ export function useBoardDragDrop({
updateFeature, updateFeature,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
autoMode, stopFeature,
] ]
); );

View File

@@ -46,11 +46,19 @@ import {
ArrowLeftRight, ArrowLeftRight,
Check, Check,
Hash, Hash,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types'; import type {
WorktreeInfo,
DevServerInfo,
PRInfo,
GitRepoStatus,
TestSessionInfo,
MergeConflictInfo,
} from '../types';
import { TooltipWrapper } from './tooltip-wrapper'; import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import { import {
@@ -137,6 +145,8 @@ interface WorktreeActionsDropdownProps {
onAbortOperation?: (worktree: WorktreeInfo) => void; onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void; onContinueOperation?: (worktree: WorktreeInfo) => void;
/** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
/** Terminal quick scripts configured for the project */ /** Terminal quick scripts configured for the project */
terminalScripts?: TerminalScript[]; terminalScripts?: TerminalScript[];
@@ -293,6 +303,7 @@ export function WorktreeActionsDropdown({
onCherryPick, onCherryPick,
onAbortOperation, onAbortOperation,
onContinueOperation, onContinueOperation,
onCreateConflictResolutionFeature,
hasInitScript, hasInitScript,
terminalScripts, terminalScripts,
onRunTerminalScript, onRunTerminalScript,
@@ -467,6 +478,23 @@ export function WorktreeActionsDropdown({
: 'Operation'} : 'Operation'}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onCreateConflictResolutionFeature && (
<DropdownMenuItem
onClick={() =>
onCreateConflictResolutionFeature({
sourceBranch: worktree.branch,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
conflictFiles: worktree.conflictFiles,
operationType: worktree.conflictType,
})
}
className="text-xs text-purple-500 focus:text-purple-600"
>
<Sparkles className="w-3.5 h-3.5 mr-2" />
Resolve with AI
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}

View File

@@ -27,6 +27,7 @@ import type {
PRInfo, PRInfo,
GitRepoStatus, GitRepoStatus,
TestSessionInfo, TestSessionInfo,
MergeConflictInfo,
} from '../types'; } from '../types';
import { WorktreeDropdownItem } from './worktree-dropdown-item'; import { WorktreeDropdownItem } from './worktree-dropdown-item';
import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { BranchSwitchDropdown } from './branch-switch-dropdown';
@@ -129,6 +130,8 @@ export interface WorktreeDropdownProps {
onAbortOperation?: (worktree: WorktreeInfo) => void; onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void; onContinueOperation?: (worktree: WorktreeInfo) => void;
/** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Remotes cache: maps worktree path to list of remotes */ /** Remotes cache: maps worktree path to list of remotes */
remotesCache?: Record<string, Array<{ name: string; url: string }>>; remotesCache?: Record<string, Array<{ name: string; url: string }>>;
/** Pull from a specific remote, bypassing the remote selection dialog */ /** Pull from a specific remote, bypassing the remote selection dialog */
@@ -241,6 +244,7 @@ export function WorktreeDropdown({
onCherryPick, onCherryPick,
onAbortOperation, onAbortOperation,
onContinueOperation, onContinueOperation,
onCreateConflictResolutionFeature,
remotesCache, remotesCache,
onPullWithRemote, onPullWithRemote,
onPushWithRemote, onPushWithRemote,
@@ -607,6 +611,7 @@ export function WorktreeDropdown({
onCherryPick={onCherryPick} onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation} onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation} onContinueOperation={onContinueOperation}
onCreateConflictResolutionFeature={onCreateConflictResolutionFeature}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript} onRunTerminalScript={onRunTerminalScript}

View File

@@ -12,6 +12,7 @@ import type {
PRInfo, PRInfo,
GitRepoStatus, GitRepoStatus,
TestSessionInfo, TestSessionInfo,
MergeConflictInfo,
} from '../types'; } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -95,6 +96,8 @@ interface WorktreeTabProps {
onAbortOperation?: (worktree: WorktreeInfo) => void; onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void; onContinueOperation?: (worktree: WorktreeInfo) => void;
/** Create a feature to resolve merge/rebase/cherry-pick conflicts with AI */
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
/** Whether a test command is configured in project settings */ /** Whether a test command is configured in project settings */
hasTestCommand?: boolean; hasTestCommand?: boolean;
@@ -195,6 +198,7 @@ export function WorktreeTab({
onCherryPick, onCherryPick,
onAbortOperation, onAbortOperation,
onContinueOperation, onContinueOperation,
onCreateConflictResolutionFeature,
hasInitScript, hasInitScript,
hasTestCommand = false, hasTestCommand = false,
remotes, remotes,
@@ -579,6 +583,7 @@ export function WorktreeTab({
onCherryPick={onCherryPick} onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation} onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation} onContinueOperation={onContinueOperation}
onCreateConflictResolutionFeature={onCreateConflictResolutionFeature}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript} onRunTerminalScript={onRunTerminalScript}

View File

@@ -1071,6 +1071,7 @@ export function WorktreePanel({
onCherryPick={handleCherryPick} onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript} onRunTerminalScript={handleRunTerminalScript}
@@ -1310,6 +1311,7 @@ export function WorktreePanel({
onCherryPick={handleCherryPick} onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript} onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts} onEditScripts={handleEditScripts}
@@ -1391,6 +1393,7 @@ export function WorktreePanel({
onCherryPick={handleCherryPick} onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand} hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
@@ -1478,6 +1481,7 @@ export function WorktreePanel({
onCherryPick={handleCherryPick} onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand} hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts} terminalScripts={terminalScripts}

View File

@@ -28,7 +28,7 @@ test.describe('Feature Deep Link', () => {
let projectPath: string; let projectPath: string;
let projectName: string; let projectName: string;
test.beforeEach(async (_fixtures, testInfo) => { test.beforeEach(async ({}, testInfo) => {
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`; projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
projectPath = path.join(TEST_TEMP_DIR, projectName); projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true }); fs.mkdirSync(projectPath, { recursive: true });