Fix branch logic for merge AI
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user