Skip to main content

ralph_core/
landing.rs

1//! Landing handler for clean session boundaries.
2//!
3//! Orchestrates the "land the plane" sequence on loop completion:
4//! 1. Verify task state (log warnings for open tasks)
5//! 2. Auto-commit uncommitted changes
6//! 3. Clean git state (stashes, prune refs)
7//! 4. Generate handoff prompt
8//!
9//! This pattern ensures clean session boundaries and enables seamless
10//! handoffs between Ralph loops.
11
12use crate::git_ops::{
13    AutoCommitResult, auto_commit_changes, clean_stashes, is_working_tree_clean, prune_remote_refs,
14};
15use crate::handoff::{HandoffError, HandoffWriter};
16use crate::loop_context::LoopContext;
17use crate::task_store::TaskStore;
18use std::path::PathBuf;
19use tracing::{debug, info, warn};
20
21/// Result of the landing sequence.
22#[derive(Debug, Clone)]
23pub struct LandingResult {
24    /// Whether changes were auto-committed.
25    pub committed: bool,
26
27    /// The commit SHA if a commit was made.
28    pub commit_sha: Option<String>,
29
30    /// Path to the generated handoff file.
31    pub handoff_path: PathBuf,
32
33    /// IDs of tasks that remain open.
34    pub open_tasks: Vec<String>,
35
36    /// Number of stashes that were cleared.
37    pub stashes_cleared: usize,
38
39    /// Whether the working tree is clean after landing.
40    pub working_tree_clean: bool,
41}
42
43/// Errors that can occur during landing.
44#[derive(Debug, thiserror::Error)]
45pub enum LandingError {
46    /// IO error during file operations.
47    #[error("IO error: {0}")]
48    Io(#[from] std::io::Error),
49
50    /// Git operation failed.
51    #[error("Git error: {0}")]
52    Git(#[from] crate::git_ops::GitOpsError),
53
54    /// Handoff generation failed.
55    #[error("Handoff error: {0}")]
56    Handoff(#[from] HandoffError),
57}
58
59/// Configuration for the landing handler.
60#[derive(Debug, Clone)]
61pub struct LandingConfig {
62    /// Whether to auto-commit uncommitted changes.
63    pub auto_commit: bool,
64
65    /// Whether to clear git stashes.
66    pub clear_stashes: bool,
67
68    /// Whether to prune remote refs.
69    pub prune_refs: bool,
70
71    /// Whether to generate the handoff file.
72    pub generate_handoff: bool,
73}
74
75impl Default for LandingConfig {
76    fn default() -> Self {
77        Self {
78            auto_commit: true,
79            clear_stashes: true,
80            prune_refs: true,
81            generate_handoff: true,
82        }
83    }
84}
85
86/// Handler for the landing sequence.
87///
88/// Orchestrates clean session exit with commit, cleanup, and handoff.
89pub struct LandingHandler {
90    context: LoopContext,
91    config: LandingConfig,
92}
93
94impl LandingHandler {
95    /// Creates a new landing handler for the given loop context.
96    pub fn new(context: LoopContext) -> Self {
97        Self {
98            context,
99            config: LandingConfig::default(),
100        }
101    }
102
103    /// Creates a landing handler with custom configuration.
104    pub fn with_config(context: LoopContext, config: LandingConfig) -> Self {
105        Self { context, config }
106    }
107
108    /// Executes the landing sequence.
109    ///
110    /// # Arguments
111    ///
112    /// * `prompt` - The original prompt that started this loop
113    ///
114    /// # Returns
115    ///
116    /// A `LandingResult` with details about what was done, or an error if
117    /// a critical step failed.
118    pub fn land(&self, prompt: &str) -> Result<LandingResult, LandingError> {
119        let workspace = self.context.workspace();
120        let loop_id = self.context.loop_id().unwrap_or("primary").to_string();
121
122        info!(loop_id = %loop_id, "Beginning landing sequence");
123
124        // Step 1: Verify task state
125        let open_tasks = self.verify_tasks();
126        if !open_tasks.is_empty() {
127            warn!(
128                loop_id = %loop_id,
129                open_tasks = ?open_tasks,
130                "Landing with {} open tasks",
131                open_tasks.len()
132            );
133        }
134
135        // Step 2: Auto-commit uncommitted changes
136        let commit_result = if self.config.auto_commit {
137            match auto_commit_changes(workspace, &loop_id) {
138                Ok(result) => {
139                    if result.committed {
140                        info!(
141                            loop_id = %loop_id,
142                            commit = ?result.commit_sha,
143                            files = result.files_staged,
144                            "Auto-committed changes during landing"
145                        );
146                    }
147                    result
148                }
149                Err(e) => {
150                    warn!(loop_id = %loop_id, error = %e, "Auto-commit failed during landing");
151                    AutoCommitResult::no_commit()
152                }
153            }
154        } else {
155            AutoCommitResult::no_commit()
156        };
157
158        // Step 3: Clean git state
159        let stashes_cleared = if self.config.clear_stashes {
160            match clean_stashes(workspace) {
161                Ok(count) => {
162                    if count > 0 {
163                        debug!(loop_id = %loop_id, count, "Cleared stashes during landing");
164                    }
165                    count
166                }
167                Err(e) => {
168                    warn!(loop_id = %loop_id, error = %e, "Failed to clear stashes");
169                    0
170                }
171            }
172        } else {
173            0
174        };
175
176        if self.config.prune_refs
177            && let Err(e) = prune_remote_refs(workspace)
178        {
179            warn!(loop_id = %loop_id, error = %e, "Failed to prune remote refs");
180        }
181
182        // Step 4: Generate handoff prompt
183        let handoff_path = if self.config.generate_handoff {
184            let writer = HandoffWriter::new(self.context.clone());
185            match writer.write(prompt) {
186                Ok(result) => {
187                    info!(
188                        loop_id = %loop_id,
189                        path = %result.path.display(),
190                        completed = result.completed_tasks,
191                        open = result.open_tasks,
192                        "Generated handoff file"
193                    );
194                    result.path
195                }
196                Err(e) => {
197                    warn!(loop_id = %loop_id, error = %e, "Failed to generate handoff");
198                    self.context.handoff_path()
199                }
200            }
201        } else {
202            self.context.handoff_path()
203        };
204
205        // Check final working tree state
206        let working_tree_clean = is_working_tree_clean(workspace).unwrap_or(false);
207
208        Ok(LandingResult {
209            committed: commit_result.committed,
210            commit_sha: commit_result.commit_sha,
211            handoff_path,
212            open_tasks,
213            stashes_cleared,
214            working_tree_clean,
215        })
216    }
217
218    /// Verifies task state and returns list of open task IDs.
219    fn verify_tasks(&self) -> Vec<String> {
220        let tasks_path = self.context.tasks_path();
221
222        match TaskStore::load(&tasks_path) {
223            Ok(store) => store.open().iter().map(|t| t.id.clone()).collect(),
224            Err(e) => {
225                debug!(error = %e, "Could not load tasks for verification");
226                Vec::new()
227            }
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::task::Task;
236    use std::fs;
237    use std::process::Command;
238    use tempfile::TempDir;
239
240    fn init_git_repo(dir: &std::path::Path) {
241        Command::new("git")
242            .args(["init", "--initial-branch=main"])
243            .current_dir(dir)
244            .output()
245            .unwrap();
246
247        Command::new("git")
248            .args(["config", "user.email", "test@test.local"])
249            .current_dir(dir)
250            .output()
251            .unwrap();
252
253        Command::new("git")
254            .args(["config", "user.name", "Test User"])
255            .current_dir(dir)
256            .output()
257            .unwrap();
258
259        fs::write(dir.join("README.md"), "# Test").unwrap();
260        Command::new("git")
261            .args(["add", "README.md"])
262            .current_dir(dir)
263            .output()
264            .unwrap();
265        Command::new("git")
266            .args(["commit", "-m", "Initial commit"])
267            .current_dir(dir)
268            .output()
269            .unwrap();
270    }
271
272    fn setup_test_context() -> (TempDir, LoopContext) {
273        let temp = TempDir::new().unwrap();
274        init_git_repo(temp.path());
275
276        // Add .ralph/ to .gitignore so handoff files don't create uncommitted changes
277        fs::write(temp.path().join(".gitignore"), ".ralph/\n").unwrap();
278        Command::new("git")
279            .args(["add", ".gitignore"])
280            .current_dir(temp.path())
281            .output()
282            .unwrap();
283        Command::new("git")
284            .args(["commit", "-m", "Add gitignore"])
285            .current_dir(temp.path())
286            .output()
287            .unwrap();
288
289        let ctx = LoopContext::primary(temp.path().to_path_buf());
290        ctx.ensure_directories().unwrap();
291        (temp, ctx)
292    }
293
294    #[test]
295    fn test_landing_clean_repo() {
296        let (_temp, ctx) = setup_test_context();
297        let handler = LandingHandler::new(ctx.clone());
298
299        let result = handler.land("Test prompt").unwrap();
300
301        assert!(!result.committed); // No changes to commit (.ralph/ is gitignored)
302        assert!(result.commit_sha.is_none());
303        assert!(result.open_tasks.is_empty());
304        assert!(result.working_tree_clean);
305        assert!(result.handoff_path.exists());
306    }
307
308    #[test]
309    fn test_landing_with_uncommitted_changes() {
310        let (temp, ctx) = setup_test_context();
311
312        // Create uncommitted changes (outside .ralph/ which is gitignored)
313        fs::write(temp.path().join("new_file.txt"), "content").unwrap();
314
315        let handler = LandingHandler::new(ctx.clone());
316        let result = handler.land("Test prompt").unwrap();
317
318        assert!(result.committed);
319        assert!(result.commit_sha.is_some());
320        assert!(result.working_tree_clean);
321    }
322
323    #[test]
324    fn test_landing_with_open_tasks() {
325        let (_temp, ctx) = setup_test_context();
326
327        // Create an open task
328        let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
329        let task = Task::new("Open task".to_string(), 1);
330        store.add(task);
331        store.save().unwrap();
332
333        let handler = LandingHandler::new(ctx.clone());
334        let result = handler.land("Test prompt").unwrap();
335
336        assert_eq!(result.open_tasks.len(), 1);
337    }
338
339    #[test]
340    fn test_landing_with_stashes() {
341        let (temp, ctx) = setup_test_context();
342
343        // Create a stash
344        fs::write(temp.path().join("README.md"), "# Modified").unwrap();
345        Command::new("git")
346            .args(["stash", "push", "-m", "test stash"])
347            .current_dir(temp.path())
348            .output()
349            .unwrap();
350
351        let handler = LandingHandler::new(ctx.clone());
352        let result = handler.land("Test prompt").unwrap();
353
354        assert_eq!(result.stashes_cleared, 1);
355    }
356
357    #[test]
358    fn test_landing_config_disables_features() {
359        let (temp, ctx) = setup_test_context();
360
361        // Create uncommitted changes
362        fs::write(temp.path().join("new_file.txt"), "content").unwrap();
363
364        let config = LandingConfig {
365            auto_commit: false,
366            clear_stashes: false,
367            prune_refs: false,
368            generate_handoff: false,
369        };
370
371        let handler = LandingHandler::with_config(ctx.clone(), config);
372        let result = handler.land("Test prompt").unwrap();
373
374        assert!(!result.committed); // Auto-commit disabled
375        assert!(!result.working_tree_clean); // Changes still there
376    }
377
378    #[test]
379    fn test_landing_generates_handoff_content() {
380        let (_temp, ctx) = setup_test_context();
381
382        // Create some tasks
383        let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
384        let task1 = Task::new("Completed task".to_string(), 1);
385        let id1 = task1.id.clone();
386        store.add(task1);
387        store.close(&id1);
388
389        let task2 = Task::new("Open task".to_string(), 2);
390        store.add(task2);
391        store.save().unwrap();
392
393        let handler = LandingHandler::new(ctx.clone());
394        let result = handler.land("Original prompt here").unwrap();
395
396        let content = fs::read_to_string(&result.handoff_path).unwrap();
397
398        assert!(content.contains("Session Handoff"));
399        assert!(content.contains("[x] Completed task"));
400        assert!(content.contains("[ ] Open task"));
401        assert!(content.contains("Original prompt here"));
402    }
403
404    #[test]
405    fn test_worktree_landing() {
406        let temp = TempDir::new().unwrap();
407        let repo_root = temp.path().to_path_buf();
408        init_git_repo(&repo_root);
409
410        // Create .ralph directories
411        fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
412
413        let worktree_path = repo_root.join(".worktrees/ralph-test-1234");
414        fs::create_dir_all(&worktree_path).unwrap();
415
416        // Create a worktree context
417        let ctx =
418            LoopContext::worktree("ralph-test-1234", worktree_path.clone(), repo_root.clone());
419
420        // Need to ensure directories exist for the worktree context
421        ctx.ensure_directories().unwrap();
422
423        let handler = LandingHandler::new(ctx.clone());
424        let result = handler.land("Worktree prompt").unwrap();
425
426        // Handoff should be in the worktree's agent dir
427        assert!(result.handoff_path.to_string_lossy().contains(".worktrees"));
428    }
429}