An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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}