ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
use super::MainEffectHandler;
use crate::phases::PhaseContext;
use crate::prompts::PromptHistoryEntry;
use crate::reducer::effect::EffectResult;
use crate::reducer::event::{ConflictStrategy, PipelineEvent, RebasePhase};
use anyhow::Result;

fn event_for_continue_strategy_remaining_conflicts(
    files: Vec<std::path::PathBuf>,
) -> PipelineEvent {
    if files.is_empty() {
        PipelineEvent::rebase_conflict_resolved(Vec::new())
    } else {
        PipelineEvent::rebase_conflict_detected(files)
    }
}

impl MainEffectHandler {
    pub(super) fn run_rebase(
        &self,
        ctx: &mut PhaseContext<'_>,
        phase: RebasePhase,
        target_branch: &str,
    ) -> Result<EffectResult> {
        if matches!(phase, RebasePhase::Initial) {
            return run_initial_rebase_phase(self, ctx, phase);
        }
        run_non_initial_rebase_phase(ctx, phase, target_branch)
    }

    pub(super) fn resolve_rebase_conflicts(
        ctx: &PhaseContext<'_>,
        strategy: ConflictStrategy,
    ) -> EffectResult {
        use crate::git_helpers::{abort_rebase, continue_rebase, get_conflicted_files};

        match strategy {
            ConflictStrategy::Continue => {
                resolve_conflict_continue(ctx, &continue_rebase, &get_conflicted_files)
            }
            ConflictStrategy::Abort => resolve_conflict_abort(ctx, &abort_rebase),
            ConflictStrategy::Skip => {
                EffectResult::event(PipelineEvent::rebase_conflict_resolved(Vec::new()))
            }
        }
    }
}

fn run_initial_rebase_phase(
    handler: &MainEffectHandler,
    ctx: &mut PhaseContext<'_>,
    phase: RebasePhase,
) -> Result<EffectResult> {
    let run_context = ctx.run_context.clone();
    let mut local_prompt_history = handler.state.prompt_history.clone();
    let run_result = crate::app::rebase::run_initial_rebase(
        ctx.logger,
        *ctx.colors,
        ctx,
        &run_context,
        ctx.executor,
        &mut local_prompt_history,
    )?;

    let event = match run_result.outcome {
        crate::app::rebase::InitialRebaseOutcome::Succeeded { new_head } => {
            PipelineEvent::rebase_succeeded(phase, new_head)
        }
        crate::app::rebase::InitialRebaseOutcome::Skipped { reason } => {
            PipelineEvent::rebase_skipped(phase, reason)
        }
    };

    let result = run_result.prompt_replay_hits.into_iter().fold(
        EffectResult::event(event),
        |r, (key, was_replayed)| {
            r.with_ui_event(crate::reducer::ui_event::UIEvent::PromptReplayHit {
                key,
                was_replayed,
            })
        },
    );
    let result = prompt_captured_events_for_prompt_history_delta(
        &handler.state.prompt_history,
        &local_prompt_history,
    )
    .into_iter()
    .fold(result, |r, ev| r.with_additional_event(ev));

    Ok(result)
}

fn run_non_initial_rebase_phase(
    ctx: &PhaseContext<'_>,
    phase: RebasePhase,
    target_branch: &str,
) -> Result<EffectResult> {
    use crate::git_helpers::rebase_onto;
    match rebase_onto(target_branch, ctx.executor) {
        Ok(_) => Ok(rebase_onto_success_result(ctx, phase)),
        Err(e) => Ok(EffectResult::event(PipelineEvent::rebase_failed(
            phase,
            e.to_string(),
        ))),
    }
}

fn rebase_onto_success_result(ctx: &PhaseContext<'_>, phase: RebasePhase) -> EffectResult {
    use crate::git_helpers::get_conflicted_files;
    let conflicted_files = get_conflicted_files().unwrap_or_default();
    if conflicted_files.is_empty() {
        let new_head = resolve_head_oid(ctx.repo_root, "unknown");
        EffectResult::event(PipelineEvent::rebase_succeeded(phase, new_head))
    } else {
        let files = conflicted_files
            .into_iter()
            .map(std::convert::Into::into)
            .collect();
        EffectResult::event(PipelineEvent::rebase_conflict_detected(files))
    }
}

fn resolve_conflict_continue(
    ctx: &PhaseContext<'_>,
    continue_fn: &dyn Fn(&dyn crate::executor::ProcessExecutor) -> std::io::Result<()>,
    get_conflicts_fn: &dyn Fn() -> std::io::Result<Vec<String>>,
) -> EffectResult {
    match continue_fn(ctx.executor) {
        Ok(()) => {
            let files = get_conflicts_fn()
                .unwrap_or_default()
                .into_iter()
                .map(std::convert::Into::into)
                .collect();
            EffectResult::event(event_for_continue_strategy_remaining_conflicts(files))
        }
        Err(e) => EffectResult::event(PipelineEvent::rebase_failed(
            RebasePhase::PostReview,
            e.to_string(),
        )),
    }
}

fn resolve_conflict_abort(
    ctx: &PhaseContext<'_>,
    abort_fn: &dyn Fn(&dyn crate::executor::ProcessExecutor) -> std::io::Result<()>,
) -> EffectResult {
    match abort_fn(ctx.executor) {
        Ok(()) => {
            let restored_to = resolve_head_oid(ctx.repo_root, "HEAD");
            EffectResult::event(PipelineEvent::rebase_aborted(
                RebasePhase::PostReview,
                restored_to,
            ))
        }
        Err(e) => EffectResult::event(PipelineEvent::rebase_failed(
            RebasePhase::PostReview,
            e.to_string(),
        )),
    }
}

fn resolve_head_oid(repo_root: &std::path::Path, fallback: &str) -> String {
    git2::Repository::open(repo_root).map_or_else(
        |_| fallback.to_string(),
        |repo| {
            repo.head()
                .ok()
                .and_then(|head| head.peel_to_commit().ok())
                .map_or_else(|| fallback.to_string(), |commit| commit.id().to_string())
        },
    )
}

fn prompt_captured_events_for_prompt_history_delta(
    original: &std::collections::HashMap<String, PromptHistoryEntry>,
    updated: &std::collections::HashMap<String, PromptHistoryEntry>,
) -> Vec<PipelineEvent> {
    updated
        .iter()
        .filter_map(|(key, entry)| {
            let should_emit = original.get(key).is_none_or(|existing| {
                existing.content != entry.content || existing.content_id != entry.content_id
            });
            if should_emit {
                Some(PipelineEvent::PromptInput(
                    crate::reducer::event::PromptInputEvent::PromptCaptured {
                        key: key.clone(),
                        content: entry.content.clone(),
                        content_id: entry.content_id.clone(),
                    },
                ))
            } else {
                None
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::prompts::PromptHistoryEntry;

    #[test]
    fn continue_strategy_reports_detected_when_conflicts_remain() {
        use crate::reducer::event::RebaseEvent;
        use std::path::PathBuf;

        let event = event_for_continue_strategy_remaining_conflicts(vec![PathBuf::from("a.txt")]);
        assert!(matches!(
            event,
            PipelineEvent::Rebase(RebaseEvent::ConflictDetected { files })
                if files == vec![PathBuf::from("a.txt")]
        ));
    }

    #[test]
    fn continue_strategy_reports_resolved_when_no_conflicts_remain() {
        use crate::reducer::event::RebaseEvent;
        let event = event_for_continue_strategy_remaining_conflicts(Vec::new());
        assert!(matches!(
            event,
            PipelineEvent::Rebase(RebaseEvent::ConflictResolved { files }) if files.is_empty()
        ));
    }

    #[test]
    fn emits_prompt_captured_when_rebase_updates_existing_prompt_history_entry() {
        let mut original = std::collections::HashMap::new();
        original.insert(
            "planning_conflict_resolution".to_string(),
            PromptHistoryEntry::new("old".to_string(), Some("id1".to_string())),
        );
        let mut updated = original.clone();
        updated.insert(
            "planning_conflict_resolution".to_string(),
            PromptHistoryEntry::new("new".to_string(), Some("id1".to_string())),
        );

        let events = prompt_captured_events_for_prompt_history_delta(&original, &updated);
        assert_eq!(events.len(), 1);
        assert!(matches!(
            &events[0],
            PipelineEvent::PromptInput(crate::reducer::event::PromptInputEvent::PromptCaptured {
                key,
                content,
                content_id: Some(id),
            }) if key == "planning_conflict_resolution" && content == "new" && id == "id1"
        ));
    }

    #[test]
    fn emits_prompt_captured_when_rebase_adds_new_prompt_history_entry() {
        let original = std::collections::HashMap::new();
        let mut updated = std::collections::HashMap::new();
        updated.insert(
            "development_conflict_resolution".to_string(),
            PromptHistoryEntry::from_string("prompt".to_string()),
        );

        let events = prompt_captured_events_for_prompt_history_delta(&original, &updated);
        assert_eq!(events.len(), 1);
        assert!(matches!(
            &events[0],
            PipelineEvent::PromptInput(crate::reducer::event::PromptInputEvent::PromptCaptured {
                key,
                content,
                content_id: None,
            }) if key == "development_conflict_resolution" && content == "prompt"
        ));
    }
}