use crate::git_ops::{
AutoCommitResult, auto_commit_changes, clean_stashes, is_working_tree_clean, prune_remote_refs,
};
use crate::handoff::{HandoffError, HandoffWriter};
use crate::loop_context::LoopContext;
use crate::task_store::TaskStore;
use std::path::PathBuf;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct LandingResult {
pub committed: bool,
pub commit_sha: Option<String>,
pub handoff_path: PathBuf,
pub open_tasks: Vec<String>,
pub stashes_cleared: usize,
pub working_tree_clean: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum LandingError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Git error: {0}")]
Git(#[from] crate::git_ops::GitOpsError),
#[error("Handoff error: {0}")]
Handoff(#[from] HandoffError),
}
#[derive(Debug, Clone)]
pub struct LandingConfig {
pub auto_commit: bool,
pub clear_stashes: bool,
pub prune_refs: bool,
pub generate_handoff: bool,
}
impl Default for LandingConfig {
fn default() -> Self {
Self {
auto_commit: true,
clear_stashes: true,
prune_refs: true,
generate_handoff: true,
}
}
}
pub struct LandingHandler {
context: LoopContext,
config: LandingConfig,
}
impl LandingHandler {
pub fn new(context: LoopContext) -> Self {
Self {
context,
config: LandingConfig::default(),
}
}
pub fn with_config(context: LoopContext, config: LandingConfig) -> Self {
Self { context, config }
}
pub fn land(&self, prompt: &str) -> Result<LandingResult, LandingError> {
let workspace = self.context.workspace();
let loop_id = self.context.loop_id().unwrap_or("primary").to_string();
info!(loop_id = %loop_id, "Beginning landing sequence");
let open_tasks = self.verify_tasks();
if !open_tasks.is_empty() {
warn!(
loop_id = %loop_id,
open_tasks = ?open_tasks,
"Landing with {} open tasks",
open_tasks.len()
);
}
let commit_result = if self.config.auto_commit {
match auto_commit_changes(workspace, &loop_id) {
Ok(result) => {
if result.committed {
info!(
loop_id = %loop_id,
commit = ?result.commit_sha,
files = result.files_staged,
"Auto-committed changes during landing"
);
}
result
}
Err(e) => {
warn!(loop_id = %loop_id, error = %e, "Auto-commit failed during landing");
AutoCommitResult::no_commit()
}
}
} else {
AutoCommitResult::no_commit()
};
let stashes_cleared = if self.config.clear_stashes {
match clean_stashes(workspace) {
Ok(count) => {
if count > 0 {
debug!(loop_id = %loop_id, count, "Cleared stashes during landing");
}
count
}
Err(e) => {
warn!(loop_id = %loop_id, error = %e, "Failed to clear stashes");
0
}
}
} else {
0
};
if self.config.prune_refs
&& let Err(e) = prune_remote_refs(workspace)
{
warn!(loop_id = %loop_id, error = %e, "Failed to prune remote refs");
}
let handoff_path = if self.config.generate_handoff {
let writer = HandoffWriter::new(self.context.clone());
match writer.write(prompt) {
Ok(result) => {
info!(
loop_id = %loop_id,
path = %result.path.display(),
completed = result.completed_tasks,
open = result.open_tasks,
"Generated handoff file"
);
result.path
}
Err(e) => {
warn!(loop_id = %loop_id, error = %e, "Failed to generate handoff");
self.context.handoff_path()
}
}
} else {
self.context.handoff_path()
};
let working_tree_clean = is_working_tree_clean(workspace).unwrap_or(false);
Ok(LandingResult {
committed: commit_result.committed,
commit_sha: commit_result.commit_sha,
handoff_path,
open_tasks,
stashes_cleared,
working_tree_clean,
})
}
fn verify_tasks(&self) -> Vec<String> {
let tasks_path = self.context.tasks_path();
match TaskStore::load(&tasks_path) {
Ok(store) => store.open().iter().map(|t| t.id.clone()).collect(),
Err(e) => {
debug!(error = %e, "Could not load tasks for verification");
Vec::new()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::Task;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_git_repo(dir: &std::path::Path) {
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.local"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.unwrap();
fs::write(dir.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.unwrap();
}
fn setup_test_context() -> (TempDir, LoopContext) {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join(".gitignore"), ".ralph/\n").unwrap();
Command::new("git")
.args(["add", ".gitignore"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add gitignore"])
.current_dir(temp.path())
.output()
.unwrap();
let ctx = LoopContext::primary(temp.path().to_path_buf());
ctx.ensure_directories().unwrap();
(temp, ctx)
}
#[test]
fn test_landing_clean_repo() {
let (_temp, ctx) = setup_test_context();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Test prompt").unwrap();
assert!(!result.committed); assert!(result.commit_sha.is_none());
assert!(result.open_tasks.is_empty());
assert!(result.working_tree_clean);
assert!(result.handoff_path.exists());
}
#[test]
fn test_landing_with_uncommitted_changes() {
let (temp, ctx) = setup_test_context();
fs::write(temp.path().join("new_file.txt"), "content").unwrap();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Test prompt").unwrap();
assert!(result.committed);
assert!(result.commit_sha.is_some());
assert!(result.working_tree_clean);
}
#[test]
fn test_landing_with_open_tasks() {
let (_temp, ctx) = setup_test_context();
let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
let task = Task::new("Open task".to_string(), 1);
store.add(task);
store.save().unwrap();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Test prompt").unwrap();
assert_eq!(result.open_tasks.len(), 1);
}
#[test]
fn test_landing_with_stashes() {
let (temp, ctx) = setup_test_context();
fs::write(temp.path().join("README.md"), "# Modified").unwrap();
Command::new("git")
.args(["stash", "push", "-m", "test stash"])
.current_dir(temp.path())
.output()
.unwrap();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Test prompt").unwrap();
assert_eq!(result.stashes_cleared, 1);
}
#[test]
fn test_landing_config_disables_features() {
let (temp, ctx) = setup_test_context();
fs::write(temp.path().join("new_file.txt"), "content").unwrap();
let config = LandingConfig {
auto_commit: false,
clear_stashes: false,
prune_refs: false,
generate_handoff: false,
};
let handler = LandingHandler::with_config(ctx.clone(), config);
let result = handler.land("Test prompt").unwrap();
assert!(!result.committed); assert!(!result.working_tree_clean); }
#[test]
fn test_landing_generates_handoff_content() {
let (_temp, ctx) = setup_test_context();
let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
let task1 = Task::new("Completed task".to_string(), 1);
let id1 = task1.id.clone();
store.add(task1);
store.close(&id1);
let task2 = Task::new("Open task".to_string(), 2);
store.add(task2);
store.save().unwrap();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Original prompt here").unwrap();
let content = fs::read_to_string(&result.handoff_path).unwrap();
assert!(content.contains("Session Handoff"));
assert!(content.contains("[x] Completed task"));
assert!(content.contains("[ ] Open task"));
assert!(content.contains("Original prompt here"));
}
#[test]
fn test_worktree_landing() {
let temp = TempDir::new().unwrap();
let repo_root = temp.path().to_path_buf();
init_git_repo(&repo_root);
fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
let worktree_path = repo_root.join(".worktrees/ralph-test-1234");
fs::create_dir_all(&worktree_path).unwrap();
let ctx =
LoopContext::worktree("ralph-test-1234", worktree_path.clone(), repo_root.clone());
ctx.ensure_directories().unwrap();
let handler = LandingHandler::new(ctx.clone());
let result = handler.land("Worktree prompt").unwrap();
assert!(result.handoff_path.to_string_lossy().contains(".worktrees"));
}
}