Skip to main content

ralph_core/
loop_completion.rs

1//! Loop completion handler for worktree-based loops.
2//!
3//! Handles post-completion actions for loops running in git worktrees,
4//! including auto-merge queue integration.
5//!
6//! # Design
7//!
8//! When a loop completes successfully (CompletionPromise):
9//! - **Primary loop**: No special handling (runs in main workspace)
10//! - **Worktree loop with auto-merge**: Enqueue to merge queue for merge-ralph
11//! - **Worktree loop without auto-merge**: Log completion, leave worktree for manual merge
12//!
13//! # Example
14//!
15//! ```no_run
16//! use ralph_core::loop_completion::{LoopCompletionHandler, CompletionAction};
17//! use ralph_core::loop_context::LoopContext;
18//! use std::path::PathBuf;
19//!
20//! // Primary loop - no special action
21//! let primary = LoopContext::primary(PathBuf::from("/project"));
22//! let handler = LoopCompletionHandler::new(true); // auto_merge enabled
23//! let action = handler.handle_completion(&primary, "implement auth").unwrap();
24//! assert!(matches!(action, CompletionAction::None));
25//!
26//! // Worktree loop with auto-merge - enqueues to merge queue
27//! let worktree = LoopContext::worktree(
28//!     "ralph-20250124-a3f2",
29//!     PathBuf::from("/project/.worktrees/ralph-20250124-a3f2"),
30//!     PathBuf::from("/project"),
31//! );
32//! let action = handler.handle_completion(&worktree, "implement auth").unwrap();
33//! assert!(matches!(action, CompletionAction::Enqueued { .. }));
34//! ```
35
36use 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/// Action taken upon loop completion.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum CompletionAction {
45    /// No action needed (primary loop or non-worktree context).
46    None,
47
48    /// Loop was enqueued to the merge queue.
49    Enqueued {
50        /// The loop ID that was enqueued.
51        loop_id: String,
52        /// Landing result details (optional for backwards compatibility).
53        landing: Option<CompletionLanding>,
54    },
55
56    /// Auto-merge is disabled; worktree left for manual handling.
57    ManualMerge {
58        /// The loop ID.
59        loop_id: String,
60        /// Path to the worktree directory.
61        worktree_path: String,
62        /// Landing result details (optional for backwards compatibility).
63        landing: Option<CompletionLanding>,
64    },
65
66    /// Primary loop completed with landing.
67    Landed {
68        /// Landing result details.
69        landing: CompletionLanding,
70    },
71}
72
73/// Landing details included in completion actions.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct CompletionLanding {
76    /// Whether changes were auto-committed.
77    pub committed: bool,
78    /// The commit SHA if a commit was made.
79    pub commit_sha: Option<String>,
80    /// Path to the handoff file.
81    pub handoff_path: String,
82    /// Number of open tasks remaining.
83    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/// Errors that can occur during completion handling.
98#[derive(Debug, thiserror::Error)]
99pub enum CompletionError {
100    /// Failed to enqueue to merge queue.
101    #[error("Failed to enqueue to merge queue: {0}")]
102    EnqueueFailed(#[from] MergeQueueError),
103}
104
105/// Handler for loop completion events.
106///
107/// Determines the appropriate action when a loop completes based on
108/// whether it's a worktree loop and the auto-merge configuration.
109pub struct LoopCompletionHandler {
110    /// Whether auto-merge is enabled (default: true).
111    auto_merge: bool,
112}
113
114impl Default for LoopCompletionHandler {
115    fn default() -> Self {
116        Self::new(true)
117    }
118}
119
120impl LoopCompletionHandler {
121    /// Creates a new completion handler.
122    ///
123    /// # Arguments
124    ///
125    /// * `auto_merge` - If true, completed worktree loops are enqueued for merge-ralph.
126    ///   If false, worktrees are left for manual merge.
127    pub fn new(auto_merge: bool) -> Self {
128        Self { auto_merge }
129    }
130
131    /// Handles loop completion, taking appropriate action based on context.
132    ///
133    /// # Arguments
134    ///
135    /// * `context` - The loop context (primary or worktree)
136    /// * `prompt` - The prompt that was executed (for merge queue metadata)
137    ///
138    /// # Returns
139    ///
140    /// The action that was taken, or an error if the action failed.
141    pub fn handle_completion(
142        &self,
143        context: &LoopContext,
144        prompt: &str,
145    ) -> Result<CompletionAction, CompletionError> {
146        // Execute landing sequence first (for all loops)
147        let landing_result = self.execute_landing(context, prompt);
148
149        // Primary loops complete with landing only
150        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        // Get loop ID from context (worktree loops always have one)
161        let loop_id = match context.loop_id() {
162            Some(id) => id.to_string(),
163            None => {
164                // Shouldn't happen for worktree contexts, but handle gracefully
165                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            // Auto-commit any uncommitted changes before enqueueing
180            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            // Enqueue to merge queue for automatic merge-ralph processing
201            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            // Leave worktree for manual handling
214            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    /// Executes the landing sequence.
229    ///
230    /// Returns the landing result if successful, or None if landing failed.
231    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        // Add .ralph/ to .gitignore so landing doesn't create uncommitted changes
286        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        // Primary loops now return Landed instead of None
310        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        // Create necessary directories
325        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); // auto_merge enabled
333
334        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                // Landing should have been executed
342                assert!(landing.is_some());
343
344                // Verify it was actually enqueued
345                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); // auto_merge disabled
367
368        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                // Landing should have been executed
379                assert!(landing.is_some());
380            }
381            _ => panic!("Expected ManualMerge action, got {:?}", action),
382        }
383
384        // Verify nothing was enqueued
385        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        // Create worktree directory and set up as a git worktree
403        let worktree_path = repo_root.join(".worktrees/ralph-autocommit");
404        let branch_name = "ralph/ralph-autocommit";
405
406        // Create the worktree
407        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        // Create uncommitted changes in the worktree
416        std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
417
418        // Create .ralph directory for merge queue
419        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        // Should enqueue successfully
429        assert!(matches!(action, CompletionAction::Enqueued { .. }));
430
431        // Verify the changes were committed
432        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        // Verify working tree is clean
445        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        // Create worktree
461        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        // Get the initial commit count
473        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        // Create .ralph directory for merge queue
484        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        // Verify no new commit was made
496        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}