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
136pub(crate) struct RepoCommandParams<'a> {
137 pub(crate) args: &'a Args,
138 pub(crate) config: &'a crate::config::Config,
139 pub(crate) registry: &'a AgentRegistry,
140 pub(crate) developer_agent: &'a str,
141 pub(crate) reviewer_agent: &'a str,
142 pub(crate) logger: &'a Logger,
143 pub(crate) colors: Colors,
144 pub(crate) executor: &'a std::sync::Arc<dyn ProcessExecutor>,
145 pub(crate) app_handler: &'a mut dyn crate::app::effect::AppEffectHandler,
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 app_handler,
163 repo_root,
164 workspace,
165 } = params;
166
167 crate::app::pipeline_setup::handle_repo_commands_boundary(RepoCommandBoundaryParams {
168 args,
169 config,
170 registry,
171 developer_agent,
172 reviewer_agent,
173 logger,
174 colors,
175 executor,
176 app_handler,
177 repo_root,
178 workspace,
179 })
180}
181
182pub(crate) fn validate_prompt_and_setup_backup(ctx: &PipelineContext) -> anyhow::Result<()> {
184 let prompt_validation = validate_prompt_md_with_workspace(
185 &*ctx.workspace,
186 ctx.config.behavior.strict_validation,
187 ctx.args.interactive,
188 );
189 prompt_validation
190 .errors
191 .iter()
192 .for_each(|err| ctx.logger.error(err));
193 prompt_validation
194 .warnings
195 .iter()
196 .for_each(|warn| ctx.logger.warn(warn));
197 if !prompt_validation.is_valid() {
198 anyhow::bail!("PROMPT.md validation errors");
199 }
200
201 match create_prompt_backup_with_workspace(&*ctx.workspace) {
203 Ok(None) => {}
204 Ok(Some(warning)) => {
205 ctx.logger.warn(&format!(
206 "PROMPT.md backup created but: {warning}. Continuing anyway."
207 ));
208 }
209 Err(e) => {
210 ctx.logger.warn(&format!(
211 "Failed to create PROMPT.md backup: {e}. Continuing anyway."
212 ));
213 }
214 }
215
216 Ok(())
220}
221
222pub(crate) fn setup_prompt_monitor(ctx: &PipelineContext) -> Option<PromptMonitor> {
224 match PromptMonitor::new() {
225 Ok(mut monitor) => {
226 if let Err(e) = monitor.start() {
227 ctx.logger.warn(&format!(
228 "Failed to start PROMPT.md monitoring: {e}. Continuing anyway."
229 ));
230 None
231 } else {
232 if ctx.config.verbosity.is_debug() {
233 ctx.logger.info("Started real-time PROMPT.md monitoring");
234 }
235 Some(monitor)
236 }
237 }
238 Err(e) => {
239 ctx.logger.warn(&format!(
240 "Failed to create PROMPT.md monitor: {e}. Continuing anyway."
241 ));
242 None
243 }
244 }
245}
246
247pub(crate) fn print_review_guidelines(
249 ctx: &PipelineContext,
250 review_guidelines: Option<&crate::guidelines::ReviewGuidelines>,
251) {
252 if let Some(guidelines) = review_guidelines {
253 ctx.logger.info(&format!(
254 "Review guidelines: {}{}{}",
255 ctx.colors.dim(),
256 guidelines.summary(),
257 ctx.colors.reset()
258 ));
259 }
260}
261
262pub(crate) fn create_phase_context_with_config<'ctx>(
269 ctx: &'ctx PipelineContext,
270 config: &'ctx crate::config::Config,
271 timer: &'ctx mut Timer,
272 review_guidelines: Option<&'ctx crate::guidelines::ReviewGuidelines>,
273 run_context: &'ctx crate::checkpoint::RunContext,
274 resume_checkpoint: Option<&PipelineCheckpoint>,
275 cloud_reporter: &'ctx dyn crate::cloud::CloudReporter,
276) -> PhaseContext<'ctx> {
277 let execution_history = resume_checkpoint.map_or_else(
282 crate::checkpoint::execution_history::ExecutionHistory::new,
283 |checkpoint| {
284 checkpoint.execution_history.as_ref().map_or_else(
285 crate::checkpoint::execution_history::ExecutionHistory::new,
286 |h| h.clone_bounded(config.execution_history_limit),
287 )
288 },
289 );
290
291 PhaseContext {
292 config,
293 registry: &ctx.registry,
294 logger: &ctx.logger,
295 colors: &ctx.colors,
296 timer,
297 developer_agent: &ctx.developer_agent,
298 reviewer_agent: &ctx.reviewer_agent,
299 review_guidelines,
300 template_context: &ctx.template_context,
301 run_context: run_context.clone(),
302 execution_history,
303 executor: &*ctx.executor,
304 executor_arc: std::sync::Arc::clone(&ctx.executor),
305 repo_root: &ctx.repo_root,
306 workspace: &*ctx.workspace,
307 workspace_arc: std::sync::Arc::clone(&ctx.workspace),
308 run_log_context: &ctx.run_log_context,
309 cloud_reporter: if config.cloud.enabled {
310 Some(cloud_reporter)
311 } else {
312 None
313 },
314 cloud: &config.cloud,
315 env: &crate::runtime::environment::RealGitEnvironment,
316 }
317}
318
319pub(crate) fn print_pipeline_info_with_config(
321 ctx: &PipelineContext,
322 _config: &crate::config::Config,
323) {
324 ctx.logger.info(&format!(
325 "Working directory: {}{}{}",
326 ctx.colors.cyan(),
327 ctx.repo_root.display(),
328 ctx.colors.reset()
329 ));
330}
331
332pub(crate) fn save_start_commit_or_warn(ctx: &PipelineContext) {
336 match crate::git_helpers::save_start_commit() {
337 Ok(()) => {
338 if ctx.config.verbosity.is_debug() {
339 ctx.logger
340 .info("Saved starting commit for incremental diff generation");
341 }
342 }
343 Err(e) => {
344 ctx.logger.warn(&format!(
345 "Failed to save starting commit: {e}. \
346 Incremental diffs may be unavailable as a result."
347 ));
348 ctx.logger.info(
349 "To fix this issue, ensure .agent directory is writable and you have a valid HEAD commit.",
350 );
351 }
352 }
353
354 match crate::git_helpers::get_start_commit_summary() {
356 Ok(summary) => {
357 if ctx.config.verbosity.is_debug() || summary.commits_since > 5 || summary.is_stale {
358 ctx.logger.info(&summary.format_compact());
359 if summary.is_stale {
360 ctx.logger.warn(
361 "Start commit is stale. Consider running: ralph --reset-start-commit",
362 );
363 } else if summary.commits_since > 5 {
364 ctx.logger
365 .info("Tip: Run 'ralph --show-baseline' for more details");
366 }
367 }
368 }
369 Err(e) => {
370 if ctx.config.verbosity.is_debug() {
372 ctx.logger
373 .warn(&format!("Failed to get start commit summary: {e}"));
374 }
375 }
376 }
377}
378
379pub(crate) fn check_prompt_restoration(
381 ctx: &PipelineContext,
382 prompt_monitor: &mut Option<PromptMonitor>,
383 phase: &str,
384) {
385 if let Some(ref mut monitor) = prompt_monitor {
386 monitor.drain_warnings().iter().for_each(|warning| {
387 ctx.logger
388 .warn(&format!("PROMPT.md monitor warning: {warning}"));
389 });
390 if monitor.check_and_restore() {
391 ctx.logger.warn(&format!(
392 "PROMPT.md was deleted and restored during {phase} phase"
393 ));
394 }
395 }
396}
397
398pub fn handle_rebase_only(
403 _args: &Args,
404 config: &crate::config::Config,
405 template_context: &TemplateContext,
406 logger: &Logger,
407 colors: Colors,
408 executor: &std::sync::Arc<dyn ProcessExecutor>,
409 repo_root: &std::path::Path,
410) -> anyhow::Result<()> {
411 if is_main_or_master_branch()? {
413 logger.warn("Already on main/master branch - rebasing on main is not recommended");
414 logger.info("Tip: Use git worktrees to work on feature branches in parallel:");
415 logger.info(" git worktree add ../feature-branch feature-branch");
416 logger.info("This allows multiple AI agents to work on different features simultaneously.");
417 logger.info("Proceeding with rebase anyway as requested...");
418 }
419
420 logger.header("Rebase to default branch", Colors::cyan);
421
422 match run_rebase_to_default(logger, colors, &**executor) {
423 Ok(RebaseResult::Success) => {
424 logger.success("Rebase completed successfully");
425 Ok(())
426 }
427 Ok(RebaseResult::NoOp { reason }) => {
428 logger.info(&format!("No rebase needed: {reason}"));
429 Ok(())
430 }
431 Ok(RebaseResult::Failed(err)) => {
432 logger.error(&format!("Rebase failed: {err}"));
433 anyhow::bail!("Rebase failed: {err}")
434 }
435 Ok(RebaseResult::Conflicts(_conflicts)) => {
436 let conflicted_files = get_conflicted_files()?;
438 if conflicted_files.is_empty() {
439 logger.warn("Rebase reported conflicts but no conflicted files found");
440 let _ = abort_rebase(&**executor);
441 return Ok(());
442 }
443
444 logger.warn(&format!(
445 "Rebase resulted in {} conflict(s), attempting AI resolution",
446 conflicted_files.len()
447 ));
448
449 match try_resolve_conflicts_without_phase_ctx(
451 &conflicted_files,
452 config,
453 template_context,
454 logger,
455 colors,
456 executor,
457 repo_root,
458 ) {
459 Ok(true) => {
460 logger.info("Continuing rebase after conflict resolution");
462 match continue_rebase(&**executor) {
463 Ok(()) => {
464 logger.success("Rebase completed successfully after AI resolution");
465 Ok(())
466 }
467 Err(e) => {
468 logger.error(&format!("Failed to continue rebase: {e}"));
469 let _ = abort_rebase(&**executor);
470 anyhow::bail!("Rebase failed after conflict resolution")
471 }
472 }
473 }
474 Ok(false) => {
475 logger.error("AI conflict resolution failed, aborting rebase");
477 let _ = abort_rebase(&**executor);
478 anyhow::bail!("Rebase conflicts could not be resolved by AI")
479 }
480 Err(e) => {
481 logger.error(&format!("Conflict resolution error: {e}"));
482 let _ = abort_rebase(&**executor);
483 anyhow::bail!("Rebase conflict resolution failed: {e}")
484 }
485 }
486 }
487 Err(e) => {
488 logger.error(&format!("Rebase failed: {e}"));
489 anyhow::bail!("Rebase failed: {e}")
490 }
491 }
492}
493
494const fn should_write_complete_checkpoint(
495 final_phase: crate::reducer::event::PipelinePhase,
496) -> bool {
497 matches!(final_phase, crate::reducer::event::PipelinePhase::Complete)
498}
499
500#[cfg(test)]
501mod helpers_tests {
502 use super::command_requires_prompt_setup;
503 use super::should_write_complete_checkpoint;
504 use super::CommandExitCleanupGuard;
505 use crate::git_helpers::agent_phase_test_lock;
506 use crate::reducer::event::PipelinePhase;
507 use crate::workspace::WorkspaceFs;
508 use clap::Parser;
509 #[cfg(unix)]
510 use std::os::unix::fs::PermissionsExt;
511
512 #[test]
513 fn test_should_write_complete_checkpoint_only_on_complete_phase() {
514 assert!(should_write_complete_checkpoint(PipelinePhase::Complete));
515 assert!(!should_write_complete_checkpoint(
516 PipelinePhase::Interrupted
517 ));
518 assert!(!should_write_complete_checkpoint(
519 PipelinePhase::AwaitingDevFix
520 ));
521 }
522
523 #[test]
524 fn test_command_requires_prompt_setup_only_for_prompt_dependent_commands() {
525 let default_args = crate::cli::Args::parse_from(["ralph"]);
526 assert!(command_requires_prompt_setup(&default_args));
527
528 let generate_commit_args = crate::cli::Args::parse_from(["ralph", "--generate-commit-msg"]);
529 assert!(!command_requires_prompt_setup(&generate_commit_args));
530
531 let dry_run_args = crate::cli::Args::parse_from(["ralph", "--dry-run"]);
532 assert!(!command_requires_prompt_setup(&dry_run_args));
533
534 let rebase_only_args = crate::cli::Args::parse_from(["ralph", "--rebase-only"]);
535 assert!(!command_requires_prompt_setup(&rebase_only_args));
536
537 let apply_commit_args = crate::cli::Args::parse_from(["ralph", "--apply-commit"]);
538 assert!(!command_requires_prompt_setup(&apply_commit_args));
539
540 let inspect_checkpoint_args =
541 crate::cli::Args::parse_from(["ralph", "--inspect-checkpoint"]);
542 assert!(!command_requires_prompt_setup(&inspect_checkpoint_args));
543 }
544
545 #[test]
546 fn test_command_cleanup_guard_without_ownership_preserves_existing_protections() {
547 let _test_lock = agent_phase_test_lock().lock().unwrap();
548 let tempdir = tempfile::tempdir().unwrap();
549 let repo_root = tempdir.path();
550 let _repo = git2::Repository::init(repo_root).unwrap();
551 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
552 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
553
554 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
555 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
556 std::fs::write(&marker_path, "").unwrap();
557
558 {
559 let _guard = CommandExitCleanupGuard::new(&logger, &workspace, true);
560 }
561
562 assert!(
563 marker_path.exists(),
564 "cleanup guard must not remove protections that this command did not create"
565 );
566 }
567
568 #[test]
569 fn test_command_cleanup_guard_with_ownership_removes_protections() {
570 let _test_lock = agent_phase_test_lock().lock().unwrap();
571 let tempdir = tempfile::tempdir().unwrap();
572 let repo_root = tempdir.path();
573 let _repo = git2::Repository::init(repo_root).unwrap();
574 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
575 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
576
577 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
578 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
579 std::fs::write(&marker_path, "").unwrap();
580
581 {
582 let mut guard = CommandExitCleanupGuard::new(&logger, &workspace, true);
583 guard.mark_owned();
584 }
585
586 assert!(
587 !marker_path.exists(),
588 "cleanup guard must remove protections owned by this command"
589 );
590 }
591
592 #[test]
593 #[cfg(unix)]
594 fn test_command_cleanup_guard_for_promptless_command_preserves_prompt_permissions() {
595 let _test_lock = agent_phase_test_lock().lock().unwrap();
596 let tempdir = tempfile::tempdir().unwrap();
597 let repo_root = tempdir.path();
598 let _repo = git2::Repository::init(repo_root).unwrap();
599 let logger = crate::logger::Logger::new(crate::logger::Colors::with_enabled(false));
600 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
601
602 let prompt_path = repo_root.join("PROMPT.md");
603 std::fs::write(&prompt_path, "# locked\n").unwrap();
604 std::fs::set_permissions(&prompt_path, std::fs::Permissions::from_mode(0o444)).unwrap();
605
606 let marker_path = repo_root.join(".git/ralph/no_agent_commit");
607 std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
608 std::fs::write(&marker_path, "").unwrap();
609
610 {
611 let mut guard = CommandExitCleanupGuard::new(&logger, &workspace, false);
612 guard.mark_owned();
613 }
614
615 let mode = std::fs::metadata(&prompt_path)
616 .unwrap()
617 .permissions()
618 .mode()
619 & 0o777;
620 assert_eq!(
621 mode, 0o444,
622 "promptless commands must not unlock PROMPT.md permissions"
623 );
624 assert!(
625 !marker_path.exists(),
626 "promptless commands must still remove their owned protections"
627 );
628 }
629}