Fix branch logic for merge AI

This commit is contained in:
AndyMik90
2025-12-17 10:34:13 +01:00
parent ce9c2cddc1
commit 2d2a8131cc
15 changed files with 329 additions and 8 deletions
@@ -125,6 +125,11 @@ export class AgentManager extends EventEmitter {
// Force: When user starts a task from the UI, that IS their approval // Force: When user starts a task from the UI, that IS their approval
args.push('--force'); args.push('--force');
// Pass base branch if specified (ensures worktrees are created from the correct branch)
if (options.baseBranch) {
args.push('--base-branch', options.baseBranch);
}
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
// The options.parallel and options.workers are kept for future use or logging purposes // The options.parallel and options.workers are kept for future use or logging purposes
+1
View File
@@ -40,6 +40,7 @@ export interface IdeationConfig {
export interface TaskExecutionOptions { export interface TaskExecutionOptions {
parallel?: boolean; parallel?: boolean;
workers?: number; workers?: number;
baseBranch?: string;
} }
export interface SpecCreationMetadata { export interface SpecCreationMetadata {
@@ -1,6 +1,7 @@
import { ipcMain, app } from 'electron'; import { ipcMain, app } from 'electron';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import path from 'path'; import path from 'path';
import { execSync } from 'child_process';
import { IPC_CHANNELS } from '../../shared/constants'; import { IPC_CHANNELS } from '../../shared/constants';
import type { import type {
Project, Project,
@@ -22,6 +23,79 @@ import { insightsService } from '../insights-service';
import { titleGenerator } from '../title-generator'; import { titleGenerator } from '../title-generator';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
// ============================================
// Git Helper Functions
// ============================================
/**
* Get list of git branches for a directory
*/
function getGitBranches(projectPath: string): string[] {
try {
const result = execSync('git branch --list --format="%(refname:short)"', {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
return result.trim().split('\n').filter(b => b.trim());
} catch {
return [];
}
}
/**
* Get the current git branch for a directory
*/
function getCurrentGitBranch(projectPath: string): string | null {
try {
const result = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
return result.trim() || null;
} catch {
return null;
}
}
/**
* Detect the main branch for a git repository
* Checks for common main branch names in order of preference
*/
function detectMainBranch(projectPath: string): string | null {
const branches = getGitBranches(projectPath);
if (branches.length === 0) return null;
// Check for common main branch names in order of preference
const mainBranchCandidates = ['main', 'master', 'develop', 'dev', 'trunk'];
for (const candidate of mainBranchCandidates) {
if (branches.includes(candidate)) {
return candidate;
}
}
// If none of the common names found, check for origin/HEAD reference
try {
const result = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
const ref = result.trim();
// Extract branch name from refs/remotes/origin/main
const match = ref.match(/refs\/remotes\/origin\/(.+)/);
if (match && branches.includes(match[1])) {
return match[1];
}
} catch {
// origin/HEAD not set, continue with fallback
}
// Fallback: return the first branch (usually the current one)
return branches[0] || null;
}
const settingsPath = path.join(app.getPath('userData'), 'settings.json'); const settingsPath = path.join(app.getPath('userData'), 'settings.json');
/** /**
@@ -330,4 +404,65 @@ export function registerProjectHandlers(
} }
} }
); );
// ============================================
// Git Operations
// ============================================
// Get all branches for a project
ipcMain.handle(
IPC_CHANNELS.GIT_GET_BRANCHES,
async (_, projectPath: string): Promise<IPCResult<string[]>> => {
try {
if (!existsSync(projectPath)) {
return { success: false, error: 'Directory does not exist' };
}
const branches = getGitBranches(projectPath);
return { success: true, data: branches };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
);
// Get current branch for a project
ipcMain.handle(
IPC_CHANNELS.GIT_GET_CURRENT_BRANCH,
async (_, projectPath: string): Promise<IPCResult<string | null>> => {
try {
if (!existsSync(projectPath)) {
return { success: false, error: 'Directory does not exist' };
}
const branch = getCurrentGitBranch(projectPath);
return { success: true, data: branch };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
);
// Auto-detect main branch for a project
ipcMain.handle(
IPC_CHANNELS.GIT_DETECT_MAIN_BRANCH,
async (_, projectPath: string): Promise<IPCResult<string | null>> => {
try {
if (!existsSync(projectPath)) {
return { success: false, error: 'Directory does not exist' };
}
const mainBranch = detectMainBranch(projectPath);
return { success: true, data: mainBranch };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
);
} }
@@ -62,6 +62,9 @@ export function registerTaskExecutionHandlers(
console.log('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); console.log('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation);
// Get base branch from project settings for worktree creation
const baseBranch = project.settings?.mainBranch;
if (needsSpecCreation) { if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec // No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title; const taskDescription = task.description || task.title;
@@ -89,7 +92,8 @@ export function registerTaskExecutionHandlers(
task.specId, task.specId,
{ {
parallel: false, // Sequential for planning phase parallel: false, // Sequential for planning phase
workers: 1 workers: 1,
baseBranch
} }
); );
} else { } else {
@@ -103,7 +107,8 @@ export function registerTaskExecutionHandlers(
task.specId, task.specId,
{ {
parallel: false, parallel: false,
workers: 1 workers: 1,
baseBranch
} }
); );
} }
+16 -1
View File
@@ -67,6 +67,11 @@ export interface ProjectAPI {
falkorDbUri: string, falkorDbUri: string,
openAiApiKey: string openAiApiKey: string
) => Promise<IPCResult<GraphitiConnectionTestResult>>; ) => Promise<IPCResult<GraphitiConnectionTestResult>>;
// Git Operations
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
} }
export const createProjectAPI = (): ProjectAPI => ({ export const createProjectAPI = (): ProjectAPI => ({
@@ -165,5 +170,15 @@ export const createProjectAPI = (): ProjectAPI => ({
falkorDbUri: string, falkorDbUri: string,
openAiApiKey: string openAiApiKey: string
): Promise<IPCResult<GraphitiConnectionTestResult>> => ): Promise<IPCResult<GraphitiConnectionTestResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.GRAPHITI_TEST_CONNECTION, falkorDbUri, openAiApiKey) ipcRenderer.invoke(IPC_CHANNELS.GRAPHITI_TEST_CONNECTION, falkorDbUri, openAiApiKey),
// Git Operations
getGitBranches: (projectPath: string): Promise<IPCResult<string[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath),
getCurrentGitBranch: (projectPath: string): Promise<IPCResult<string | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, projectPath),
detectMainBranch: (projectPath: string): Promise<IPCResult<string | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_DETECT_MAIN_BRANCH, projectPath)
}); });
@@ -63,6 +63,17 @@ export function AddProjectModal({ open, onOpenChange, onProjectAdded }: AddProje
if (path) { if (path) {
const project = await addProject(path); const project = await addProject(path);
if (project) { if (project) {
// Auto-detect and save the main branch for the project
try {
const mainBranchResult = await window.electronAPI.detectMainBranch(path);
if (mainBranchResult.success && mainBranchResult.data) {
await window.electronAPI.updateProjectSettings(project.id, {
mainBranch: mainBranchResult.data
});
}
} catch {
// Non-fatal - main branch can be set later in settings
}
onProjectAdded?.(project, !project.autoBuildPath); onProjectAdded?.(project, !project.autoBuildPath);
onOpenChange(false); onOpenChange(false);
} }
@@ -112,6 +123,20 @@ export function AddProjectModal({ open, onOpenChange, onProjectAdded }: AddProje
// Add the project to our store // Add the project to our store
const project = await addProject(result.data.path); const project = await addProject(result.data.path);
if (project) { if (project) {
// For new projects with git init, set main branch
// Git init creates 'main' branch by default on modern git
if (initGit) {
try {
const mainBranchResult = await window.electronAPI.detectMainBranch(result.data.path);
if (mainBranchResult.success && mainBranchResult.data) {
await window.electronAPI.updateProjectSettings(project.id, {
mainBranch: mainBranchResult.data
});
}
} catch {
// Non-fatal - main branch can be set later in settings
}
}
onProjectAdded?.(project, true); // New projects always need init onProjectAdded?.(project, true); // New projects always need init
onOpenChange(false); onOpenChange(false);
} }
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { import {
Zap, Zap,
Eye, Eye,
@@ -10,19 +11,32 @@ import {
Import, Import,
Radio, Radio,
Github, Github,
RefreshCw RefreshCw,
GitBranch
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import type { ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus } from '../../../shared/types'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui/select';
import type { ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus, Project, ProjectSettings as ProjectSettingsType } from '../../../shared/types';
interface IntegrationSettingsProps { interface IntegrationSettingsProps {
envConfig: ProjectEnvConfig | null; envConfig: ProjectEnvConfig | null;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void; updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
// Project settings for main branch
project: Project;
settings: ProjectSettingsType;
setSettings: React.Dispatch<React.SetStateAction<ProjectSettingsType>>;
// Linear state // Linear state
showLinearKey: boolean; showLinearKey: boolean;
setShowLinearKey: React.Dispatch<React.SetStateAction<boolean>>; setShowLinearKey: React.Dispatch<React.SetStateAction<boolean>>;
@@ -44,6 +58,9 @@ interface IntegrationSettingsProps {
export function IntegrationSettings({ export function IntegrationSettings({
envConfig, envConfig,
updateEnvConfig, updateEnvConfig,
project,
settings,
setSettings,
showLinearKey, showLinearKey,
setShowLinearKey, setShowLinearKey,
linearConnectionStatus, linearConnectionStatus,
@@ -58,6 +75,38 @@ export function IntegrationSettings({
githubExpanded, githubExpanded,
onGitHubToggle onGitHubToggle
}: IntegrationSettingsProps) { }: IntegrationSettingsProps) {
// Branch selection state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Load branches when GitHub section expands
useEffect(() => {
if (githubExpanded && project.path) {
loadBranches();
}
}, [githubExpanded, project.path]);
const loadBranches = async () => {
setIsLoadingBranches(true);
try {
const result = await window.electronAPI.getGitBranches(project.path);
if (result.success && result.data) {
setBranches(result.data);
// Auto-detect main branch if not set
if (!settings.mainBranch) {
const detectResult = await window.electronAPI.detectMainBranch(project.path);
if (detectResult.success && detectResult.data) {
setSettings(prev => ({ ...prev, mainBranch: detectResult.data! }));
}
}
}
} catch (error) {
console.error('Failed to load branches:', error);
} finally {
setIsLoadingBranches(false);
}
};
if (!envConfig) return null; if (!envConfig) return null;
return ( return (
@@ -385,6 +434,47 @@ export function IntegrationSettings({
onCheckedChange={(checked) => updateEnvConfig({ githubAutoSync: checked })} onCheckedChange={(checked) => updateEnvConfig({ githubAutoSync: checked })}
/> />
</div> </div>
<Separator />
{/* Main Branch Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info" />
<Label className="text-sm font-medium text-foreground">Main Branch</Label>
</div>
<p className="text-xs text-muted-foreground">
The base branch for creating task worktrees. All new tasks will branch from here.
</p>
<Select
value={settings.mainBranch || ''}
onValueChange={(value) => setSettings(prev => ({ ...prev, mainBranch: value }))}
disabled={isLoadingBranches || branches.length === 0}
>
<SelectTrigger>
{isLoadingBranches ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Loading branches...</span>
</div>
) : (
<SelectValue placeholder="Select main branch" />
)}
</SelectTrigger>
<SelectContent>
{branches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
{settings.mainBranch && (
<p className="text-xs text-muted-foreground">
Tasks will be created on branches like <code className="px-1 bg-muted rounded">auto-claude/task-name</code> from <code className="px-1 bg-muted rounded">{settings.mainBranch}</code>
</p>
)}
</div>
</> </>
)} )}
</div> </div>
@@ -118,6 +118,9 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
<IntegrationSettings <IntegrationSettings
envConfig={envConfig} envConfig={envConfig}
updateEnvConfig={updateEnvConfig} updateEnvConfig={updateEnvConfig}
project={project}
settings={settings}
setSettings={setSettings}
showLinearKey={showLinearKey} showLinearKey={showLinearKey}
setShowLinearKey={setShowLinearKey} setShowLinearKey={setShowLinearKey}
linearConnectionStatus={linearConnectionStatus} linearConnectionStatus={linearConnectionStatus}
@@ -68,5 +68,21 @@ export const projectMock = {
listDirectory: async () => ({ listDirectory: async () => ({
success: true, success: true,
data: [] data: []
}),
// Git operations
getGitBranches: async () => ({
success: true,
data: ['main', 'develop', 'feature/test']
}),
getCurrentGitBranch: async () => ({
success: true,
data: 'main'
}),
detectMainBranch: async () => ({
success: true,
data: 'main'
}) })
}; };
+6 -1
View File
@@ -246,5 +246,10 @@ export const IPC_CHANNELS = {
INSIGHTS_ERROR: 'insights:error', INSIGHTS_ERROR: 'insights:error',
// File explorer operations // File explorer operations
FILE_EXPLORER_LIST: 'fileExplorer:list' FILE_EXPLORER_LIST: 'fileExplorer:list',
// Git operations
GIT_GET_BRANCHES: 'git:getBranches',
GIT_GET_CURRENT_BRANCH: 'git:getCurrentBranch',
GIT_DETECT_MAIN_BRANCH: 'git:detectMainBranch'
} as const; } as const;
+5
View File
@@ -459,6 +459,11 @@ export interface ElectronAPI {
// File explorer operations // File explorer operations
listDirectory: (dirPath: string) => Promise<IPCResult<FileNode[]>>; listDirectory: (dirPath: string) => Promise<IPCResult<FileNode[]>>;
// Git operations
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
} }
declare global { declare global {
@@ -22,6 +22,8 @@ export interface ProjectSettings {
graphitiMcpEnabled: boolean; graphitiMcpEnabled: boolean;
/** Graphiti MCP server URL (default: http://localhost:8000/mcp/) */ /** Graphiti MCP server URL (default: http://localhost:8000/mcp/) */
graphitiMcpUrl?: string; graphitiMcpUrl?: string;
/** Main branch name for worktree creation (default: auto-detected or 'main') */
mainBranch?: string;
} }
export interface NotificationSettings { export interface NotificationSettings {
+4 -1
View File
@@ -60,6 +60,7 @@ def handle_build_command(
auto_continue: bool, auto_continue: bool,
skip_qa: bool, skip_qa: bool,
force_bypass_approval: bool, force_bypass_approval: bool,
base_branch: str | None = None,
) -> None: ) -> None:
""" """
Handle the main build command. Handle the main build command.
@@ -75,6 +76,7 @@ def handle_build_command(
auto_continue: Auto-continue mode (non-interactive) auto_continue: Auto-continue mode (non-interactive)
skip_qa: Skip automatic QA validation skip_qa: Skip automatic QA validation
force_bypass_approval: Force bypass approval check force_bypass_approval: Force bypass approval check
base_branch: Base branch for worktree creation (default: current branch)
""" """
# Lazy imports to avoid loading heavy modules # Lazy imports to avoid loading heavy modules
from agent import run_autonomous_agent, sync_plan_to_source from agent import run_autonomous_agent, sync_plan_to_source
@@ -182,7 +184,8 @@ def handle_build_command(
source_spec_dir = spec_dir source_spec_dir = spec_dir
working_dir, worktree_manager, localized_spec_dir = setup_workspace( working_dir, worktree_manager, localized_spec_dir = setup_workspace(
project_dir, spec_dir.name, workspace_mode, source_spec_dir=spec_dir project_dir, spec_dir.name, workspace_mode, source_spec_dir=spec_dir,
base_branch=base_branch
) )
# Use the localized spec directory (inside worktree) for AI access # Use the localized spec directory (inside worktree) for AI access
if localized_spec_dir: if localized_spec_dir:
+9
View File
@@ -229,6 +229,14 @@ Environment Variables:
help="Skip approval check and start build anyway (for debugging)", help="Skip approval check and start build anyway (for debugging)",
) )
# Base branch for worktree creation
parser.add_argument(
"--base-branch",
type=str,
default=None,
help="Base branch for creating worktrees (default: auto-detect or current branch)",
)
return parser.parse_args() return parser.parse_args()
@@ -366,6 +374,7 @@ def main() -> None:
auto_continue=args.auto_continue, auto_continue=args.auto_continue,
skip_qa=args.skip_qa, skip_qa=args.skip_qa,
force_bypass_approval=args.force, force_bypass_approval=args.force,
base_branch=args.base_branch,
) )
+3 -1
View File
@@ -184,6 +184,7 @@ def setup_workspace(
spec_name: str, spec_name: str,
mode: WorkspaceMode, mode: WorkspaceMode,
source_spec_dir: Path | None = None, source_spec_dir: Path | None = None,
base_branch: str | None = None,
) -> tuple[Path, WorktreeManager | None, Path | None]: ) -> tuple[Path, WorktreeManager | None, Path | None]:
""" """
Set up the workspace based on user's choice. Set up the workspace based on user's choice.
@@ -195,6 +196,7 @@ def setup_workspace(
spec_name: Name of the spec being built (e.g., "001-feature-name") spec_name: Name of the spec being built (e.g., "001-feature-name")
mode: The workspace mode to use mode: The workspace mode to use
source_spec_dir: Optional source spec directory to copy to worktree source_spec_dir: Optional source spec directory to copy to worktree
base_branch: Base branch for worktree creation (default: current branch)
Returns: Returns:
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None) Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
@@ -215,7 +217,7 @@ def setup_workspace(
# Ensure timeline tracking hook is installed (once per session) # Ensure timeline tracking hook is installed (once per session)
ensure_timeline_hook_installed(project_dir) ensure_timeline_hook_installed(project_dir)
manager = WorktreeManager(project_dir) manager = WorktreeManager(project_dir, base_branch=base_branch)
manager.setup() manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC # Get or create worktree for THIS SPECIFIC SPEC