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
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
// 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 {
parallel?: boolean;
workers?: number;
baseBranch?: string;
}
export interface SpecCreationMetadata {
@@ -1,6 +1,7 @@
import { ipcMain, app } from 'electron';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { IPC_CHANNELS } from '../../shared/constants';
import type {
Project,
@@ -22,6 +23,79 @@ import { insightsService } from '../insights-service';
import { titleGenerator } from '../title-generator';
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');
/**
@@ -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);
// Get base branch from project settings for worktree creation
const baseBranch = project.settings?.mainBranch;
if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
@@ -89,7 +92,8 @@ export function registerTaskExecutionHandlers(
task.specId,
{
parallel: false, // Sequential for planning phase
workers: 1
workers: 1,
baseBranch
}
);
} else {
@@ -103,7 +107,8 @@ export function registerTaskExecutionHandlers(
task.specId,
{
parallel: false,
workers: 1
workers: 1,
baseBranch
}
);
}
+16 -1
View File
@@ -67,6 +67,11 @@ export interface ProjectAPI {
falkorDbUri: string,
openAiApiKey: string
) => 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 => ({
@@ -165,5 +170,15 @@ export const createProjectAPI = (): ProjectAPI => ({
falkorDbUri: string,
openAiApiKey: string
): 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) {
const project = await addProject(path);
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);
onOpenChange(false);
}
@@ -112,6 +123,20 @@ export function AddProjectModal({ open, onOpenChange, onProjectAdded }: AddProje
// Add the project to our store
const project = await addProject(result.data.path);
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
onOpenChange(false);
}
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import {
Zap,
Eye,
@@ -10,19 +11,32 @@ import {
Import,
Radio,
Github,
RefreshCw
RefreshCw,
GitBranch
} from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
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 {
envConfig: ProjectEnvConfig | null;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
// Project settings for main branch
project: Project;
settings: ProjectSettingsType;
setSettings: React.Dispatch<React.SetStateAction<ProjectSettingsType>>;
// Linear state
showLinearKey: boolean;
setShowLinearKey: React.Dispatch<React.SetStateAction<boolean>>;
@@ -44,6 +58,9 @@ interface IntegrationSettingsProps {
export function IntegrationSettings({
envConfig,
updateEnvConfig,
project,
settings,
setSettings,
showLinearKey,
setShowLinearKey,
linearConnectionStatus,
@@ -58,6 +75,38 @@ export function IntegrationSettings({
githubExpanded,
onGitHubToggle
}: 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;
return (
@@ -385,6 +434,47 @@ export function IntegrationSettings({
onCheckedChange={(checked) => updateEnvConfig({ githubAutoSync: checked })}
/>
</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>
@@ -118,6 +118,9 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
<IntegrationSettings
envConfig={envConfig}
updateEnvConfig={updateEnvConfig}
project={project}
settings={settings}
setSettings={setSettings}
showLinearKey={showLinearKey}
setShowLinearKey={setShowLinearKey}
linearConnectionStatus={linearConnectionStatus}
@@ -68,5 +68,21 @@ export const projectMock = {
listDirectory: async () => ({
success: true,
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',
// 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;
+5
View File
@@ -459,6 +459,11 @@ export interface ElectronAPI {
// File explorer operations
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 {
@@ -22,6 +22,8 @@ export interface ProjectSettings {
graphitiMcpEnabled: boolean;
/** Graphiti MCP server URL (default: http://localhost:8000/mcp/) */
graphitiMcpUrl?: string;
/** Main branch name for worktree creation (default: auto-detected or 'main') */
mainBranch?: string;
}
export interface NotificationSettings {
+4 -1
View File
@@ -60,6 +60,7 @@ def handle_build_command(
auto_continue: bool,
skip_qa: bool,
force_bypass_approval: bool,
base_branch: str | None = None,
) -> None:
"""
Handle the main build command.
@@ -75,6 +76,7 @@ def handle_build_command(
auto_continue: Auto-continue mode (non-interactive)
skip_qa: Skip automatic QA validation
force_bypass_approval: Force bypass approval check
base_branch: Base branch for worktree creation (default: current branch)
"""
# Lazy imports to avoid loading heavy modules
from agent import run_autonomous_agent, sync_plan_to_source
@@ -182,7 +184,8 @@ def handle_build_command(
source_spec_dir = spec_dir
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
if localized_spec_dir:
+9
View File
@@ -229,6 +229,14 @@ Environment Variables:
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()
@@ -366,6 +374,7 @@ def main() -> None:
auto_continue=args.auto_continue,
skip_qa=args.skip_qa,
force_bypass_approval=args.force,
base_branch=args.base_branch,
)
+3 -1
View File
@@ -184,6 +184,7 @@ def setup_workspace(
spec_name: str,
mode: WorkspaceMode,
source_spec_dir: Path | None = None,
base_branch: str | None = None,
) -> tuple[Path, WorktreeManager | None, Path | None]:
"""
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")
mode: The workspace mode to use
source_spec_dir: Optional source spec directory to copy to worktree
base_branch: Base branch for worktree creation (default: current branch)
Returns:
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_hook_installed(project_dir)
manager = WorktreeManager(project_dir)
manager = WorktreeManager(project_dir, base_branch=base_branch)
manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC