1use crate::git_ops::auto_commit_changes;
37use crate::landing::{LandingHandler, LandingResult};
38use crate::loop_context::LoopContext;
39use crate::merge_queue::{MergeQueue, MergeQueueError};
40use tracing::{debug, info, warn};
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum CompletionAction {
45 None,
47
48 Enqueued {
50 loop_id: String,
52 landing: Option<CompletionLanding>,
54 },
55
56 ManualMerge {
58 loop_id: String,
60 worktree_path: String,
62 landing: Option<CompletionLanding>,
64 },
65
66 Landed {
68 landing: CompletionLanding,
70 },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct CompletionLanding {
76 pub committed: bool,
78 pub commit_sha: Option<String>,
80 pub handoff_path: String,
82 pub open_task_count: usize,
84}
85
86impl From<&LandingResult> for CompletionLanding {
87 fn from(result: &LandingResult) -> Self {
88 Self {
89 committed: result.committed,
90 commit_sha: result.commit_sha.clone(),
91 handoff_path: result.handoff_path.to_string_lossy().to_string(),
92 open_task_count: result.open_tasks.len(),
93 }
94 }
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum CompletionError {
100 #[error("Failed to enqueue to merge queue: {0}")]
102 EnqueueFailed(#[from] MergeQueueError),
103}
104
105pub struct LoopCompletionHandler {
110 auto_merge: bool,
112}
113
114impl Default for LoopCompletionHandler {
115 fn default() -> Self {
116 Self::new(true)
117 }
118}
119
120impl LoopCompletionHandler {
121 pub fn new(auto_merge: bool) -> Self {
128 Self { auto_merge }
129 }
130
131 pub fn handle_completion(
142 &self,
143 context: &LoopContext,
144 prompt: &str,
145 ) -> Result<CompletionAction, CompletionError> {
146 let landing_result = self.execute_landing(context, prompt);
148
149 if context.is_primary() {
151 debug!("Primary loop completed with landing");
152 return Ok(match landing_result {
153 Some(result) => CompletionAction::Landed {
154 landing: CompletionLanding::from(&result),
155 },
156 None => CompletionAction::None,
157 });
158 }
159
160 let loop_id = match context.loop_id() {
162 Some(id) => id.to_string(),
163 None => {
164 debug!("Loop completed without loop ID - treating as primary");
166 return Ok(match landing_result {
167 Some(result) => CompletionAction::Landed {
168 landing: CompletionLanding::from(&result),
169 },
170 None => CompletionAction::None,
171 });
172 }
173 };
174
175 let worktree_path = context.workspace().to_string_lossy().to_string();
176 let landing = landing_result.as_ref().map(CompletionLanding::from);
177
178 if self.auto_merge {
179 match auto_commit_changes(context.workspace(), &loop_id) {
181 Ok(result) => {
182 if result.committed {
183 info!(
184 loop_id = %loop_id,
185 commit = ?result.commit_sha,
186 files = result.files_staged,
187 "Auto-committed changes before merge queue"
188 );
189 }
190 }
191 Err(e) => {
192 warn!(
193 loop_id = %loop_id,
194 error = %e,
195 "Auto-commit failed, proceeding with enqueue"
196 );
197 }
198 }
199
200 let queue = MergeQueue::new(context.repo_root());
202 queue.enqueue(&loop_id, prompt)?;
203
204 info!(
205 loop_id = %loop_id,
206 worktree = %worktree_path,
207 committed = ?landing.as_ref().map(|l| l.committed),
208 "Loop completed and enqueued for auto-merge"
209 );
210
211 Ok(CompletionAction::Enqueued { loop_id, landing })
212 } else {
213 info!(
215 loop_id = %loop_id,
216 worktree = %worktree_path,
217 "Loop completed - worktree preserved for manual merge (--no-auto-merge)"
218 );
219
220 Ok(CompletionAction::ManualMerge {
221 loop_id,
222 worktree_path,
223 landing,
224 })
225 }
226 }
227
228 fn execute_landing(&self, context: &LoopContext, prompt: &str) -> Option<LandingResult> {
232 let handler = LandingHandler::new(context.clone());
233
234 match handler.land(prompt) {
235 Ok(result) => {
236 if result.committed {
237 info!(
238 commit = ?result.commit_sha,
239 handoff = %result.handoff_path.display(),
240 "Landing completed with auto-commit"
241 );
242 } else {
243 debug!(
244 handoff = %result.handoff_path.display(),
245 "Landing completed (no changes to commit)"
246 );
247 }
248 Some(result)
249 }
250 Err(e) => {
251 warn!(error = %e, "Landing sequence failed, proceeding without landing");
252 None
253 }
254 }
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::process::Command;
262 use tempfile::TempDir;
263
264 fn init_git_repo(dir: &std::path::Path) {
265 Command::new("git")
266 .args(["init", "--initial-branch=main"])
267 .current_dir(dir)
268 .output()
269 .unwrap();
270
271 Command::new("git")
272 .args(["config", "user.email", "test@test.local"])
273 .current_dir(dir)
274 .output()
275 .unwrap();
276
277 Command::new("git")
278 .args(["config", "user.name", "Test User"])
279 .current_dir(dir)
280 .output()
281 .unwrap();
282
283 std::fs::write(dir.join("README.md"), "# Test").unwrap();
284
285 std::fs::write(dir.join(".gitignore"), ".ralph/\n").unwrap();
287
288 Command::new("git")
289 .args(["add", "README.md", ".gitignore"])
290 .current_dir(dir)
291 .output()
292 .unwrap();
293 Command::new("git")
294 .args(["commit", "-m", "Initial commit"])
295 .current_dir(dir)
296 .output()
297 .unwrap();
298 }
299
300 #[test]
301 fn test_primary_loop_with_landing() {
302 let temp = TempDir::new().unwrap();
303 init_git_repo(temp.path());
304 let context = LoopContext::primary(temp.path().to_path_buf());
305 context.ensure_directories().unwrap();
306 let handler = LoopCompletionHandler::new(true);
307
308 let action = handler.handle_completion(&context, "test prompt").unwrap();
309 assert!(
311 matches!(action, CompletionAction::Landed { .. }),
312 "Expected Landed, got {:?}",
313 action
314 );
315 }
316
317 #[test]
318 fn test_worktree_loop_auto_merge_enqueues() {
319 let temp = TempDir::new().unwrap();
320 let repo_root = temp.path().to_path_buf();
321 init_git_repo(&repo_root);
322 let worktree_path = repo_root.join(".worktrees/ralph-test-1234");
323
324 std::fs::create_dir_all(&worktree_path).unwrap();
326 std::fs::create_dir_all(repo_root.join(".ralph")).unwrap();
327
328 let context =
329 LoopContext::worktree("ralph-test-1234", worktree_path.clone(), repo_root.clone());
330 context.ensure_directories().unwrap();
331
332 let handler = LoopCompletionHandler::new(true); let action = handler
335 .handle_completion(&context, "implement feature X")
336 .unwrap();
337
338 match action {
339 CompletionAction::Enqueued { loop_id, landing } => {
340 assert_eq!(loop_id, "ralph-test-1234");
341 assert!(landing.is_some());
343
344 let queue = MergeQueue::new(&repo_root);
346 let entry = queue.get_entry("ralph-test-1234").unwrap().unwrap();
347 assert_eq!(entry.prompt, "implement feature X");
348 }
349 _ => panic!("Expected Enqueued action, got {:?}", action),
350 }
351 }
352
353 #[test]
354 fn test_worktree_loop_no_auto_merge_manual() {
355 let temp = TempDir::new().unwrap();
356 let repo_root = temp.path().to_path_buf();
357 init_git_repo(&repo_root);
358 let worktree_path = repo_root.join(".worktrees/ralph-test-5678");
359
360 std::fs::create_dir_all(&worktree_path).unwrap();
361
362 let context =
363 LoopContext::worktree("ralph-test-5678", worktree_path.clone(), repo_root.clone());
364 context.ensure_directories().unwrap();
365
366 let handler = LoopCompletionHandler::new(false); let action = handler.handle_completion(&context, "test prompt").unwrap();
369
370 match action {
371 CompletionAction::ManualMerge {
372 loop_id,
373 worktree_path: path,
374 landing,
375 } => {
376 assert_eq!(loop_id, "ralph-test-5678");
377 assert_eq!(path, worktree_path.to_string_lossy());
378 assert!(landing.is_some());
380 }
381 _ => panic!("Expected ManualMerge action, got {:?}", action),
382 }
383
384 let queue = MergeQueue::new(&repo_root);
386 let entry = queue.get_entry("ralph-test-5678").unwrap();
387 assert!(entry.is_none());
388 }
389
390 #[test]
391 fn test_default_handler_has_auto_merge_enabled() {
392 let handler = LoopCompletionHandler::default();
393 assert!(handler.auto_merge);
394 }
395
396 #[test]
397 fn test_worktree_loop_auto_commits_uncommitted_changes() {
398 let temp = TempDir::new().unwrap();
399 let repo_root = temp.path().to_path_buf();
400 init_git_repo(&repo_root);
401
402 let worktree_path = repo_root.join(".worktrees/ralph-autocommit");
404 let branch_name = "ralph/ralph-autocommit";
405
406 std::fs::create_dir_all(repo_root.join(".worktrees")).unwrap();
408 Command::new("git")
409 .args(["worktree", "add", "-b", branch_name])
410 .arg(&worktree_path)
411 .current_dir(&repo_root)
412 .output()
413 .unwrap();
414
415 std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
417
418 std::fs::create_dir_all(repo_root.join(".ralph")).unwrap();
420
421 let context =
422 LoopContext::worktree("ralph-autocommit", worktree_path.clone(), repo_root.clone());
423
424 let handler = LoopCompletionHandler::new(true);
425
426 let action = handler.handle_completion(&context, "add feature").unwrap();
427
428 assert!(matches!(action, CompletionAction::Enqueued { .. }));
430
431 let output = Command::new("git")
433 .args(["log", "-1", "--pretty=%s"])
434 .current_dir(&worktree_path)
435 .output()
436 .unwrap();
437 let message = String::from_utf8_lossy(&output.stdout);
438 assert!(
439 message.contains("auto-commit before merge"),
440 "Expected auto-commit message, got: {}",
441 message
442 );
443
444 let output = Command::new("git")
446 .args(["status", "--porcelain"])
447 .current_dir(&worktree_path)
448 .output()
449 .unwrap();
450 let status = String::from_utf8_lossy(&output.stdout);
451 assert!(status.trim().is_empty(), "Working tree should be clean");
452 }
453
454 #[test]
455 fn test_worktree_loop_no_auto_commit_when_clean() {
456 let temp = TempDir::new().unwrap();
457 let repo_root = temp.path().to_path_buf();
458 init_git_repo(&repo_root);
459
460 let worktree_path = repo_root.join(".worktrees/ralph-clean");
462 let branch_name = "ralph/ralph-clean";
463
464 std::fs::create_dir_all(repo_root.join(".worktrees")).unwrap();
465 Command::new("git")
466 .args(["worktree", "add", "-b", branch_name])
467 .arg(&worktree_path)
468 .current_dir(&repo_root)
469 .output()
470 .unwrap();
471
472 let output = Command::new("git")
474 .args(["rev-list", "--count", "HEAD"])
475 .current_dir(&worktree_path)
476 .output()
477 .unwrap();
478 let initial_count: i32 = String::from_utf8_lossy(&output.stdout)
479 .trim()
480 .parse()
481 .unwrap();
482
483 std::fs::create_dir_all(repo_root.join(".ralph")).unwrap();
485
486 let context =
487 LoopContext::worktree("ralph-clean", worktree_path.clone(), repo_root.clone());
488
489 let handler = LoopCompletionHandler::new(true);
490
491 let action = handler.handle_completion(&context, "no changes").unwrap();
492
493 assert!(matches!(action, CompletionAction::Enqueued { .. }));
494
495 let output = Command::new("git")
497 .args(["rev-list", "--count", "HEAD"])
498 .current_dir(&worktree_path)
499 .output()
500 .unwrap();
501 let final_count: i32 = String::from_utf8_lossy(&output.stdout)
502 .trim()
503 .parse()
504 .unwrap();
505
506 assert_eq!(
507 initial_count, final_count,
508 "No new commit should be made when working tree is clean"
509 );
510 }
511}