1use 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#[derive(Debug, Clone)]
23pub struct LandingResult {
24 pub committed: bool,
26
27 pub commit_sha: Option<String>,
29
30 pub handoff_path: PathBuf,
32
33 pub open_tasks: Vec<String>,
35
36 pub stashes_cleared: usize,
38
39 pub working_tree_clean: bool,
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum LandingError {
46 #[error("IO error: {0}")]
48 Io(#[from] std::io::Error),
49
50 #[error("Git error: {0}")]
52 Git(#[from] crate::git_ops::GitOpsError),
53
54 #[error("Handoff error: {0}")]
56 Handoff(#[from] HandoffError),
57}
58
59#[derive(Debug, Clone)]
61pub struct LandingConfig {
62 pub auto_commit: bool,
64
65 pub clear_stashes: bool,
67
68 pub prune_refs: bool,
70
71 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
86pub struct LandingHandler {
90 context: LoopContext,
91 config: LandingConfig,
92}
93
94impl LandingHandler {
95 pub fn new(context: LoopContext) -> Self {
97 Self {
98 context,
99 config: LandingConfig::default(),
100 }
101 }
102
103 pub fn with_config(context: LoopContext, config: LandingConfig) -> Self {
105 Self { context, config }
106 }
107
108 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 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 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 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 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 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 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 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); 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 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 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 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 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); assert!(!result.working_tree_clean); }
377
378 #[test]
379 fn test_landing_generates_handoff_content() {
380 let (_temp, ctx) = setup_test_context();
381
382 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 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 let ctx =
418 LoopContext::worktree("ralph-test-1234", worktree_path.clone(), repo_root.clone());
419
420 ctx.ensure_directories().unwrap();
422
423 let handler = LandingHandler::new(ctx.clone());
424 let result = handler.land("Worktree prompt").unwrap();
425
426 assert!(result.handoff_path.to_string_lossy().contains(".worktrees"));
428 }
429}