ralph_workflow/app/runner/pipeline_execution/
helpers.rs1use crate::app::context::PipelineContext;
17use crate::app::pipeline_setup::RepoCommandBoundaryParams;
18use crate::app::rebase::conflicts::try_resolve_conflicts_without_phase_ctx;
19use crate::app::rebase::orchestration::run_rebase_to_default;
20use crate::checkpoint::PipelineCheckpoint;
21use crate::files::protection::monitoring::PromptMonitor;
22use crate::files::{create_prompt_backup_with_workspace, validate_prompt_md_with_workspace};
23use crate::git_helpers::{
24 abort_rebase, continue_rebase, get_conflicted_files, is_main_or_master_branch, RebaseResult,
25};
26use crate::phases::PhaseContext;
27use crate::pipeline::Timer;
28
29pub(crate) const fn command_requires_prompt_setup(args: &Args) -> bool {
30 !args.recovery.dry_run
31 && !args.recovery.inspect_checkpoint
32 && !args.rebase_flags.rebase_only
33 && !args.commit_plumbing.generate_commit_msg
34 && !args.commit_plumbing.apply_commit
35 && !args.commit_display.show_commit_msg
36 && !args.commit_display.reset_start_commit
37 && !args.commit_display.show_baseline
38}
39
40pub struct CommandExitCleanupGuard<'a> {
41 logger: &'a Logger,
42 workspace: &'a dyn crate::workspace::Workspace,
43 owns_cleanup: bool,
44 restore_prompt_permissions: bool,
45}
46
47impl<'a> CommandExitCleanupGuard<'a> {
48 pub const fn new(
49 logger: &'a Logger,
50 workspace: &'a dyn crate::workspace::Workspace,
51 restore_prompt_permissions: bool,
52 ) -> Self {
53 Self {
54 logger,
55 workspace,
56 owns_cleanup: false,
57 restore_prompt_permissions,
58 }
59 }
60
61 pub(crate) const fn mark_owned(&mut self) {
62 self.owns_cleanup = true;
63 }
64}
65
66impl Drop for CommandExitCleanupGuard<'_> {
67 fn drop(&mut self) {
68 if !self.owns_cleanup {
69 return;
70 }
71 if self.restore_prompt_permissions {
72 if let Some(warning) = crate::files::make_prompt_writable_with_workspace(self.workspace)
73 {
74 self.logger.warn(&format!(
75 "PROMPT.md permission restore during command cleanup: {warning}"
76 ));
77 }
78 }
79 crate::git_helpers::cleanup_agent_phase_protections_silent_at(self.workspace.root());
80 }
81}
82
83pub(crate) fn prepare_agent_phase_for_workspace(
84 repo_root: &std::path::Path,
85 workspace: &dyn crate::workspace::Workspace,
86 logger: &Logger,
87 git_helpers: &mut crate::git_helpers::GitHelpers,
88 restore_prompt_permissions: bool,
89) {
90 if let Err(err) = crate::git_helpers::cleanup_orphaned_marker_with_workspace(workspace, logger)
91 {
92 logger.warn(&format!("Failed to cleanup orphaned marker: {err}"));
93 }
94
95 if restore_prompt_permissions {
96 if let Some(warning) = crate::files::make_prompt_writable_with_workspace(workspace) {
97 logger.warn(&format!(
98 "PROMPT.md permission restore on startup: {warning}"
99 ));
100 }
101 }
102
103 if let Err(err) = crate::git_helpers::create_marker_with_workspace(workspace) {
104 logger.warn(&format!("Failed to create agent phase marker: {err}"));
105 }
106
107 if crate::interrupt::is_user_interrupt_requested() {
108 return;
109 }
110
111 crate::git_helpers::cleanup_orphaned_wrapper_at(repo_root);
112
113 let hooks_dir = crate::git_helpers::get_hooks_dir_in_repo(repo_root);
114 let ralph_hook_detected = hooks_dir.ok().is_some_and(|dir| {
115 crate::git_helpers::RALPH_HOOK_NAMES.iter().any(|name| {
116 crate::files::file_contains_marker(&dir.join(name), crate::git_helpers::HOOK_MARKER)
117 .unwrap_or(false)
118 })
119 });
120
121 if ralph_hook_detected {
122 if let Err(err) = crate::git_helpers::uninstall_hooks_in_repo(repo_root, logger) {
123 logger.warn(&format!("Startup hook cleanup warning: {err}"));
124 }
125 }
126
127 if crate::interrupt::is_user_interrupt_requested() {
128 return;
129 }
130
131 if let Err(err) = crate::git_helpers::start_agent_phase_in_repo(repo_root, git_helpers) {
132 logger.warn(&format!("Failed to start agent phase: {err}"));
133 }
134}
135
136#[derive(Copy, Clone)]
137pub(crate) struct RepoCommandParams<'a> {
138 pub(crate) args: &'a Args,
139 pub(crate) config: &'a crate::config::Config,
140 pub(crate) registry: &'a AgentRegistry,
141 pub(crate) developer_agent: &'a str,
142 pub(crate) reviewer_agent: &'a str,
143 pub(crate) logger: &'a Logger,
144 pub(crate) colors: Colors,
145 pub(crate) executor: &'a std::sync::Arc<dyn ProcessExecutor>,
146 pub(crate) repo_root: &'a std::path::Path,
147 pub(crate) workspace: &'a std::sync::Arc<dyn crate::workspace::Workspace>,
148}
149
150pub(crate) fn handle_repo_commands_without_prompt_setup(
151 params: RepoCommandParams<'_>,
152) -> anyhow::Result<bool> {
153 let RepoCommandParams {
154 args,
155 config,
156 registry,
157 developer_agent,
158 reviewer_agent,
159 logger,
160 colors,
161 executor,
162 repo_root,
163 workspace,
164 } = params;
165
166 crate::app::pipeline_setup::handle_repo_commands_boundary(RepoCommandBoundaryParams {
167 args,
168 config,
169 registry,
170 developer_agent,
171 reviewer_agent,
172 logger,
173 colors,
174 executor,
175 repo_root,
176 workspace,
177 })
178}
179
180pub(crate) fn validate_prompt_and_setup_backup(ctx: &PipelineContext) -> anyhow::Result<()> {
182 let prompt_validation = validate_prompt_md_with_workspace(
183 &*ctx.workspace,
184 ctx.config.behavior.strict_validation,
185 ctx.args.interactive,
186 );
187 prompt_validation
188 .errors
189 .iter()
190 .for_each(|err| ctx.logger.error(err));
191 prompt_validation
192 .warnings
193 .iter()
194 .for_each(|warn| ctx.logger.warn(warn));
195 if !prompt_validation.is_valid() {
196 anyhow::bail!("PROMPT.md validation errors");
197 }
198
199 match create_prompt_backup_with_workspace(&*ctx.workspace) {
201 Ok(None) => {}
202 Ok(Some(warning)) => {
203 ctx.logger.warn(&format!(
204 "PROMPT.md backup created but: {warning}. Continuing anyway."
205 ));
206 }
207 Err(e) => {
208 ctx.logger.warn(&format!(
209 "Failed to create PROMPT.md backup: {e}. Continuing anyway."
210 ));
211 }
212 }
213
214 Ok(())
218}
219
220pub(crate) fn setup_prompt_monitor(ctx: &PipelineContext) -> Option<PromptMonitor> {
222 match PromptMonitor::new() {
223 Ok(mut monitor) => {
224 if let Err(e) = monitor.start() {
225 ctx.logger.warn(&format!(
226 "Failed to start PROMPT.md monitoring: {e}. Continuing anyway."
227 ));
228 None
229 } else {
230 if ctx.config.verbosity.is_debug() {
231 ctx.logger.info("Started real-time PROMPT.md monitoring");
232 }
233 Some(monitor)
234 }
235 }
236 Err(e) => {
237 ctx.logger.warn(&format!(
238 "Failed to create PROMPT.md monitor: {e}. Continuing anyway."
239 ));
240 None
241 }
242 }
243}
244
245pub(crate) fn print_review_guidelines(
247 ctx: &PipelineContext,
248 review_guidelines: Option<&crate::guidelines::ReviewGuidelines>,
249) {
250 if let Some(guidelines) = review_guidelines {
251 ctx.logger.info(&format!(
252 "Review guidelines: {}{}{}",
253 ctx.colors.dim(),
254 guidelines.summary(),
255 ctx.colors.reset()
256 ));
257 }
258}
259
260pub(crate) fn create_phase_context_with_config<'ctx>(
267 ctx: &'ctx PipelineContext,
268 config: &'ctx crate::config::Config,
269 timer: &'ctx mut Timer,
270 review_guidelines: Option<&'ctx crate::guidelines::ReviewGuidelines>,
271 run_context: &'ctx crate::checkpoint::RunContext,
272 resume_checkpoint: Option<&PipelineCheckpoint>,
273 cloud_reporter: &'ctx dyn crate::cloud::CloudReporter,
274) -> PhaseContext<'ctx> {
275 let execution_history = resume_checkpoint.map_or_else(
280 crate::checkpoint::execution_history::ExecutionHistory::new,
281 |checkpoint| {
282 checkpoint.execution_history.as_ref().map_or_else(
283 crate::checkpoint::execution_history::ExecutionHistory::new,
284 |h| h.clone_bounded(config.execution_history_limit),
285 )
286 },
287 );
288
289 PhaseContext {
290 config,
291 registry: &ctx.registry,
292 logger: &ctx.logger,
293 colors: &ctx.colors,
294 timer,
295 developer_agent: &ctx.developer_agent,
296 reviewer_agent: &ctx.reviewer_agent,
297 review_guidelines,
298 template_context: &ctx.template_context,
299 run_context: run_context.clone(),
300 execution_history,
301 executor: &*ctx.executor,
302 executor_arc: std::sync::Arc::clone(&ctx.executor),
303 repo_root: &ctx.repo_root,
304 workspace: &*ctx.workspace,
305 workspace_arc: std::sync::Arc::clone(&ctx.workspace),
306 run_log_context: &ctx.run_log_context,
307 cloud_reporter: if config.cloud.enabled {
308 Some(cloud_reporter)
309 } else {
310 None
311 },
312 cloud: &config.cloud,
313 env: &crate::runtime::environment::RealGitEnvironment,
314 }
315}
316
317pub(crate) fn print_pipeline_info_with_config(
319 ctx: &PipelineContext,
320 _config: &crate::config::Config,
321) {
322 ctx.logger.info(&format!(
323 "Working directory: {}{}{}",
324 ctx.colors.cyan(),
325 ctx.repo_root.display(),
326 ctx.colors.reset()
327 ));
328}
329
330pub(crate) fn save_start_commit_or_warn(ctx: &PipelineContext) {
334 match crate::git_helpers::save_start_commit() {
335 Ok(()) => {
336 if ctx.config.verbosity.is_debug() {
337 ctx.logger
338 .info("Saved starting commit for incremental diff generation");
339 }
340 }
341 Err(e) => {
342 ctx.logger.warn(&format!(
343 "Failed to save starting commit: {e}. \
344 Incremental diffs may be unavailable as a result."
345 ));
346 ctx.logger.info(
347 "To fix this issue, ensure .agent directory is writable and you have a valid HEAD commit.",
348 );
349 }
350 }
351
352 match crate::git_helpers::get_start_commit_summary() {
354 Ok(summary) => {
355 if ctx.config.verbosity.is_debug() || summary.commits_since > 5 || summary.is_stale {
356 ctx.logger.info(&summary.format_compact());
357 if summary.is_stale {
358 ctx.logger.warn(
359 "Start commit is stale. Consider running: ralph --reset-start-commit",
360 );
361 } else if summary.commits_since > 5 {
362 ctx.logger
363 .info("Tip: Run 'ralph --show-baseline' for more details");
364 }
365 }
366 }
367 Err(e) => {
368 if ctx.config.verbosity.is_debug() {
370 ctx.logger
371 .warn(&format!("Failed to get start commit summary: {e}"));
372 }
373 }
374 }
375}
376
377pub(crate) fn check_prompt_restoration(
379 ctx: &PipelineContext,
380 prompt_monitor: &mut Option<PromptMonitor>,
381 phase: &str,
382) {
383 if let Some(ref mut monitor) = prompt_monitor {
384 monitor.drain_warnings().iter().for_each(|warning| {
385 ctx.logger
386 .warn(&format!("PROMPT.md monitor warning: {warning}"));
387 });
388 if monitor.check_and_restore() {
389 ctx.logger.warn(&format!(
390 "PROMPT.md was deleted and restored during {phase} phase"
391 ));
392 }
393 }
394}
395
396pub fn handle_rebase_only(
401 _args: &Args,
402 config: &crate::config::Config,
403 template_context: &TemplateContext,
404 logger: &Logger,
405 colors: Colors,
406 executor: &std::sync::Arc<dyn ProcessExecutor>,
407 repo_root: &std::path::Path,
408) -> anyhow::Result<()> {
409 if is_main_or_master_branch()? {
411 logger.warn("Already on main/master branch - rebasing on main is not recommended");
412 logger.info("Tip: Use git worktrees to work on feature branches in parallel:");
413 logger.info(" git worktree add ../feature-branch feature-branch");
414 logger.info("This allows multiple AI agents to work on different features simultaneously.");
415 logger.info("Proceeding with rebase anyway as requested...");
416 }
417
418 logger.header("Rebase to default branch", Colors::cyan);
419
420 match run_rebase_to_default(logger, colors, &**executor) {
421 Ok(RebaseResult::Success) => {
422 logger.success("Rebase completed successfully");
423 Ok(())
424 }
425 Ok(RebaseResult::NoOp { reason }) => {
426 logger.info(&format!("No rebase needed: {reason}"));
427 Ok(())
428 }
429 Ok(RebaseResult::Failed(err)) => {
430 logger.error(&format!("Rebase failed: {err}"));
431 anyhow::bail!("Rebase failed: {err}")
432 }
433 Ok(RebaseResult::Conflicts(_conflicts)) => {
434 let conflicted_files = get_conflicted_files()?;
436 if conflicted_files.is_empty() {
437 logger.warn("Rebase reported conflicts but no conflicted files found");
438 let _ = abort_rebase(&**executor);
439 return Ok(());
440 }
441
442 logger.warn(&format!(
443 "Rebase resulted in {} conflict(s), attempting AI resolution",
444 conflicted_files.len()
445 ));
446
447 match try_resolve_conflicts_without_phase_ctx(
449 &conflicted_files,
450 config,
451 template_context,
452 logger,
453 colors,
454 executor,
455 repo_root,
456 ) {
457 Ok(true) => {
458 logger.info("Continuing rebase after conflict resolution");
460 match continue_rebase(&**executor) {
461 Ok(()) => {
462 logger.success("Rebase completed successfully after AI resolution");
463 Ok(())
464 }
465 Err(e) => {
466 logger.error(&format!("Failed to continue rebase: {e}"));
467 let _ = abort_rebase(&**executor);
468 anyhow::bail!("Rebase failed after conflict resolution")
469 }
470 }
471 }
472 Ok(false) => {
473 logger.error("AI conflict resolution failed, aborting rebase");
475 let _ = abort_rebase(&**executor);
476 anyhow::bail!("Rebase conflicts could not be resolved by AI")
477 }
478 Err(e) => {
479 logger.error(&format!("Conflict resolution error: {e}"));
480 let _ = abort_rebase(&**executor);
481 anyhow::bail!("Rebase conflict resolution failed: {e}")
482 }
483 }
484 }
485 Err(e) => {
486 logger.error(&format!("Rebase failed: {e}"));
487 anyhow::bail!("Rebase failed: {e}")
488 }
489 }
490}
491
492const fn should_write_complete_checkpoint(
493 final_phase: crate::reducer::event::PipelinePhase,
494) -> bool {
495 matches!(final_phase, crate::reducer::event::PipelinePhase::Complete)
496}
497
498#[cfg(test)]
499mod helpers_tests {
500 use super::command_requires_prompt_setup;
501 use super::should_write_complete_checkpoint;
502 use super::CommandExitCleanupGuard;
503 use crate::git_helpers::agent_phase_test_lock;
504 use crate::reducer::event::PipelinePhase;
505 use crate::workspace::WorkspaceFs;
506 use clap::Parser;
507 #[cfg(unix)]
508 use std::os::unix::fs::PermissionsExt;
509
510 #[test]
511 fn test_should_write_complete_checkpoint_only_on_complete_phase() {
512 assert!(should_write_complete_checkpoint(PipelinePhase::Complete));
513 assert!(!should_write_complete_checkpoint(
514 PipelinePhase::Interrupted
515 ));
516 assert!(!should_write_complete_checkpoint(
517 PipelinePhase::AwaitingDevFix
518 ));
519 }
520
521 #[test]
522 fn test_command_requires_prompt_setup_only_for_prompt_dependent_commands() {
523 let default_args = crate::cli::Args::parse_from(["ralph"]);
524 assert!(command_requires_prompt_setup(&default_args));
525
526 let generate_commit_args = crate::cli::Args::parse_from(["ralph", "--generate-commit-msg"]);
527 assert!(!command_requires_prompt_setup(&generate_commit_args));
528
529 let dry_run_args = crate::cli::Args::parse_from(["ralph", "--dry-run"]);
530 assert!(!command_requires_prompt_setup(&dry_run_args));
531
532 let rebase_only_args = crate::cli::Args::parse_from(["ralph", "--rebase-only"]);
533 assert!(!command_requires_prompt_setup(&rebase_only_args));
534
535 let apply_commit_args = crate::cli::Args::parse_from(["ralph", "--apply-commit"]);
536 assert!(!command_requires_prompt_setup(&apply_commit_args));
537
538 let inspect_checkpoint_args =
539 crate::cli::Args::parse_from(["ralph", "--inspect-checkpoint"]);
540 assert!(!command_requires_prompt_setup(&inspect_checkpoint_args));
541 }
542
543 #[test]
544 fn test_command_cleanup_guard_without_ownership_preserves_existing_protections() {
545 let _test_lock = agent_phase_test_lock().lock().unwrap();
546 let tempdir = tempfile::tempdir().unwrap();
547 let repo_root = tempdir.path();
548 let _repo = git2::Repository::init(repo_root).unwrap();
549 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
550 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
551
552 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
553 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
554 std::fs::write(&marker_path, "").unwrap();
555
556 {
557 let _guard = CommandExitCleanupGuard::new(&logger, &workspace, true);
558 }
559
560 assert!(
561 marker_path.exists(),
562 "cleanup guard must not remove protections that this command did not create"
563 );
564 }
565
566 #[test]
567 fn test_command_cleanup_guard_with_ownership_removes_protections() {
568 let _test_lock = agent_phase_test_lock().lock().unwrap();
569 let tempdir = tempfile::tempdir().unwrap();
570 let repo_root = tempdir.path();
571 let _repo = git2::Repository::init(repo_root).unwrap();
572 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
573 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
574
575 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
576 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
577 std::fs::write(&marker_path, "").unwrap();
578
579 {
580 let mut guard = CommandExitCleanupGuard::new(&logger, &workspace, true);
581 guard.mark_owned();
582 }
583
584 assert!(
585 !marker_path.exists(),
586 "cleanup guard must remove protections owned by this command"
587 );
588 }
589
590 #[test]
591 #[cfg(unix)]
592 fn test_command_cleanup_guard_for_promptless_command_preserves_prompt_permissions() {
593 let _test_lock = agent_phase_test_lock().lock().unwrap();
594 let tempdir = tempfile::tempdir().unwrap();
595 let repo_root = tempdir.path();
596 let _repo = git2::Repository::init(repo_root).unwrap();
597 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
598 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
599
600 let prompt_path = repo_root.join("PROMPT.md");
601 std::fs::write(&prompt_path, "# locked\n").unwrap();
602 std::fs::set_permissions(&prompt_path, std::fs::Permissions::from_mode(0o444)).unwrap();
603
604 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
605 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
606 std::fs::write(&marker_path, "").unwrap();
607
608 {
609 let mut guard = CommandExitCleanupGuard::new(&logger, &workspace, false);
610 guard.mark_owned();
611 }
612
613 let mode = std::fs::metadata(&prompt_path)
614 .unwrap()
615 .permissions()
616 .mode()
617 & 0o777;
618 assert_eq!(
619 mode, 0o444,
620 "promptless commands must not unlock PROMPT.md permissions"
621 );
622 assert!(
623 !marker_path.exists(),
624 "promptless commands must still remove their owned protections"
625 );
626 }
627}