An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 224 lines 7.5 kB view raw
1use anyhow::{Context, Result, anyhow}; 2use std::path::{Path, PathBuf}; 3use std::process::Command; 4 5/// Manages git worktrees for parallel worker isolation. 6/// 7/// Each work package gets its own git worktree branched from the goal branch. 8/// After worker completion, worktree branches are merged back into the goal branch. 9pub struct WorktreeManager { 10 project_path: PathBuf, 11} 12 13impl WorktreeManager { 14 pub fn new(project_path: PathBuf) -> Self { 15 Self { project_path } 16 } 17 18 /// Get the project path. 19 pub fn project_path(&self) -> &Path { 20 &self.project_path 21 } 22 23 /// Create the goal branch from current HEAD. If branch already exists, reuse it. 24 pub fn create_goal_branch(&self, goal_id: &str) -> Result<String> { 25 let branch_name = format!("rustagent/{}", goal_id); 26 27 // Ensure .gitignore entry exists 28 self.ensure_gitignore()?; 29 30 let output = Command::new("git") 31 .args(["branch", &branch_name, "HEAD"]) 32 .current_dir(&self.project_path) 33 .output() 34 .context("failed to run git branch")?; 35 36 if !output.status.success() { 37 let stderr = String::from_utf8_lossy(&output.stderr); 38 if stderr.contains("already exists") { 39 return Ok(branch_name); 40 } 41 return Err(anyhow!("failed to create goal branch: {}", stderr)); 42 } 43 Ok(branch_name) 44 } 45 46 /// Create a worktree for a work package. 47 /// Returns the path to the worktree directory. 48 pub fn create_worktree(&self, goal_id: &str, work_package_id: &str) -> Result<PathBuf> { 49 let branch_name = format!("rustagent/{}-wp-{}", goal_id, work_package_id); 50 let goal_branch = format!("rustagent/{}", goal_id); 51 52 let worktree_path = self 53 .project_path 54 .join(".rustagent") 55 .join("worktrees") 56 .join(format!("{}-wp-{}", goal_id, work_package_id)); 57 58 // Create parent directory 59 if let Some(parent) = worktree_path.parent() { 60 std::fs::create_dir_all(parent)?; 61 } 62 63 let output = Command::new("git") 64 .args([ 65 "worktree", 66 "add", 67 "-b", 68 &branch_name, 69 worktree_path 70 .to_str() 71 .ok_or_else(|| anyhow!("invalid worktree path"))?, 72 &goal_branch, 73 ]) 74 .current_dir(&self.project_path) 75 .output() 76 .context("failed to run git worktree add")?; 77 78 if !output.status.success() { 79 let stderr = String::from_utf8_lossy(&output.stderr); 80 return Err(anyhow!("failed to create worktree: {}", stderr)); 81 } 82 83 Ok(worktree_path) 84 } 85 86 /// Merge a work package branch into the goal branch. 87 /// 88 /// Uses a temporary merge worktree to avoid depending on the main 89 /// worktree's current branch state. 90 pub fn merge_work_package(&self, goal_id: &str, work_package_id: &str) -> Result<()> { 91 let branch_name = format!("rustagent/{}-wp-{}", goal_id, work_package_id); 92 let goal_branch = format!("rustagent/{}", goal_id); 93 94 let merge_path = self 95 .project_path 96 .join(".rustagent") 97 .join("worktrees") 98 .join(format!("{}-merge-tmp", goal_id)); 99 100 // Clean up any leftover merge worktree 101 if merge_path.exists() { 102 let _ = Command::new("git") 103 .args([ 104 "worktree", 105 "remove", 106 "--force", 107 merge_path.to_str().unwrap_or_default(), 108 ]) 109 .current_dir(&self.project_path) 110 .output(); 111 } 112 113 // Create temporary worktree on goal branch 114 let output = Command::new("git") 115 .args([ 116 "worktree", 117 "add", 118 merge_path 119 .to_str() 120 .ok_or_else(|| anyhow!("invalid merge path"))?, 121 &goal_branch, 122 ]) 123 .current_dir(&self.project_path) 124 .output() 125 .context("failed to create merge worktree")?; 126 127 if !output.status.success() { 128 let stderr = String::from_utf8_lossy(&output.stderr); 129 return Err(anyhow!("failed to create merge worktree: {}", stderr)); 130 } 131 132 // Merge the work package branch into goal branch 133 let merge_result = Command::new("git") 134 .args([ 135 "merge", 136 "--no-ff", 137 "-m", 138 &format!("rustagent: merge work package wp-{}", work_package_id), 139 &branch_name, 140 ]) 141 .current_dir(&merge_path) 142 .output() 143 .context("failed to run git merge")?; 144 145 // Clean up merge worktree regardless of outcome 146 let _ = Command::new("git") 147 .args([ 148 "worktree", 149 "remove", 150 "--force", 151 merge_path.to_str().unwrap_or_default(), 152 ]) 153 .current_dir(&self.project_path) 154 .output(); 155 156 if !merge_result.status.success() { 157 let stderr = String::from_utf8_lossy(&merge_result.stderr); 158 return Err(anyhow!("merge conflict in work package branch: {}", stderr)); 159 } 160 161 Ok(()) 162 } 163 164 /// Remove a worktree and its branch after successful merge. 165 pub fn cleanup_worktree(&self, goal_id: &str, work_package_id: &str) -> Result<()> { 166 let branch_name = format!("rustagent/{}-wp-{}", goal_id, work_package_id); 167 let worktree_path = self 168 .project_path 169 .join(".rustagent") 170 .join("worktrees") 171 .join(format!("{}-wp-{}", goal_id, work_package_id)); 172 173 // Remove worktree 174 if worktree_path.exists() { 175 let _ = Command::new("git") 176 .args([ 177 "worktree", 178 "remove", 179 "--force", 180 worktree_path.to_str().unwrap_or_default(), 181 ]) 182 .current_dir(&self.project_path) 183 .output(); 184 } 185 186 // Delete branch 187 let _ = Command::new("git") 188 .args(["branch", "-D", &branch_name]) 189 .current_dir(&self.project_path) 190 .output(); 191 192 Ok(()) 193 } 194 195 /// Get the goal branch name for a goal ID. 196 pub fn goal_branch_name(goal_id: &str) -> String { 197 format!("rustagent/{}", goal_id) 198 } 199 200 /// Ensure `.rustagent/worktrees/` is in `.gitignore`. 201 fn ensure_gitignore(&self) -> Result<()> { 202 let gitignore_path = self.project_path.join(".gitignore"); 203 let entry = ".rustagent/worktrees/"; 204 205 if gitignore_path.exists() { 206 let content = std::fs::read_to_string(&gitignore_path)?; 207 if content.contains(entry) { 208 return Ok(()); 209 } 210 let mut file = std::fs::OpenOptions::new() 211 .append(true) 212 .open(&gitignore_path)?; 213 use std::io::Write; 214 writeln!(file, "\n# Rustagent worktrees (auto-generated)")?; 215 writeln!(file, "{}", entry)?; 216 } else { 217 std::fs::write( 218 &gitignore_path, 219 format!("# Rustagent worktrees (auto-generated)\n{}\n", entry), 220 )?; 221 } 222 Ok(()) 223 } 224}