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
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
180/// Validate PROMPT.md and set up backup/protection.
181pub(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    // Create a backup of PROMPT.md to protect against accidental deletion.
200    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    // Permission locking is now handled by the reducer via LockPromptPermissions effect.
215    // The runner no longer directly manipulates file permissions.
216
217    Ok(())
218}
219
220/// Set up PROMPT.md monitoring for deletion detection.
221pub(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
245/// Print review guidelines if detected.
246pub(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
260/// Create the phase context with a modified config (for resume restoration).
261///
262/// When resuming from a checkpoint, this function enforces the configured
263/// `execution_history_limit` by using `clone_bounded()` to drop oldest entries
264/// beyond the limit. This prevents legacy checkpoints with oversized history
265/// from reintroducing unbounded memory growth.
266pub(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    // Restore execution history and prompt history from checkpoint if available.
276    // IMPORTANT: When loading from checkpoint, we MUST enforce the configured
277    // execution_history_limit using clone_bounded() to prevent oversized legacy
278    // checkpoints from loading arbitrarily large history into memory.
279    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
317/// Print pipeline info with a specific config.
318pub(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
330/// Save starting commit or warn if it fails.
331///
332/// This is best-effort: failures here must not terminate the pipeline.
333pub(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    // Display start commit information to user
353    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            // Only show error in debug mode since this is informational
369            if ctx.config.verbosity.is_debug() {
370                ctx.logger
371                    .warn(&format!("Failed to get start commit summary: {e}"));
372            }
373        }
374    }
375}
376
377/// Check for PROMPT.md restoration after a phase.
378pub(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
396/// Handle --rebase-only flag.
397///
398/// This function performs a rebase to the default branch with AI conflict resolution and exits,
399/// without running the full pipeline.
400pub 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    // Check if we're on main/master branch
410    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            // Get the actual conflicted files
435            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            // For --rebase-only, we don't have a full PhaseContext, so we use a wrapper
448            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                    // Conflicts resolved, continue the rebase
459                    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                    // AI resolution failed
474                    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}