Skip to main content

ralph_workflow/app/runner/pipeline_execution/
helpers.rs

1// Helper functions for pipeline execution.
2//
3// This module contains:
4// - command_requires_prompt_setup: Classify commands by PROMPT.md dependency
5// - handle_repo_commands_without_prompt_setup: Early repo commands that bypass PROMPT.md
6// - prepare_agent_phase_for_workspace: Shared agent-phase setup for pipeline and repo commands
7// - validate_prompt_and_setup_backup: Validate PROMPT.md and set up backup/protection
8// - setup_prompt_monitor: Set up PROMPT.md monitoring for deletion detection
9// - print_review_guidelines: Print review guidelines if detected
10// - create_phase_context_with_config: Create the phase context with a modified config
11// - print_pipeline_info_with_config: Print pipeline info with a specific config
12// - save_start_commit_or_warn: Save starting commit or warn if it fails
13// - check_prompt_restoration: Check for PROMPT.md restoration after a phase
14// - handle_rebase_only: Handle --rebase-only flag
15
16use 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
182/// Validate PROMPT.md and set up backup/protection.
183pub(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    // Create a backup of PROMPT.md to protect against accidental deletion.
202    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    // Permission locking is now handled by the reducer via LockPromptPermissions effect.
217    // The runner no longer directly manipulates file permissions.
218
219    Ok(())
220}
221
222/// Set up PROMPT.md monitoring for deletion detection.
223pub(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
247/// Print review guidelines if detected.
248pub(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
262/// Create the phase context with a modified config (for resume restoration).
263///
264/// When resuming from a checkpoint, this function enforces the configured
265/// `execution_history_limit` by using `clone_bounded()` to drop oldest entries
266/// beyond the limit. This prevents legacy checkpoints with oversized history
267/// from reintroducing unbounded memory growth.
268pub(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    // Restore execution history and prompt history from checkpoint if available.
278    // IMPORTANT: When loading from checkpoint, we MUST enforce the configured
279    // execution_history_limit using clone_bounded() to prevent oversized legacy
280    // checkpoints from loading arbitrarily large history into memory.
281    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
319/// Print pipeline info with a specific config.
320pub(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
332/// Save starting commit or warn if it fails.
333///
334/// This is best-effort: failures here must not terminate the pipeline.
335pub(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    // Display start commit information to user
355    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            // Only show error in debug mode since this is informational
371            if ctx.config.verbosity.is_debug() {
372                ctx.logger
373                    .warn(&format!("Failed to get start commit summary: {e}"));
374            }
375        }
376    }
377}
378
379/// Check for PROMPT.md restoration after a phase.
380pub(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
398/// Handle --rebase-only flag.
399///
400/// This function performs a rebase to the default branch with AI conflict resolution and exits,
401/// without running the full pipeline.
402pub 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    // Check if we're on main/master branch
412    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            // Get the actual conflicted files
437            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            // For --rebase-only, we don't have a full PhaseContext, so we use a wrapper
450            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                    // Conflicts resolved, continue the rebase
461                    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                    // AI resolution failed
476                    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}