Skip to main content

ralph_workflow/app/
plumbing.rs

1//! Plumbing commands for low-level git operations.
2//!
3//! This module handles plumbing commands that operate directly on
4//! commit messages and git state without running the full pipeline:
5//! - `--show-commit-msg`: Display the stored commit message
6//! - `--apply-commit`: Stage and commit using the stored message
7//! - `--generate-commit-msg`: Generate a commit message for staged changes
8//!
9//! # Workspace Support
10//!
11//! Plumbing commands have two variants:
12//! - Direct functions (e.g., `handle_show_commit_msg`) - use real filesystem
13//! - Workspace-aware functions (e.g., `handle_show_commit_msg_with_workspace`) - use injected workspace
14//!
15//! Tests should use the workspace-aware variants with `MemoryWorkspace` for isolation.
16
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::app::effect::{AppEffect, AppEffectHandler, AppEffectResult};
19use crate::config::Config;
20use crate::executor::ProcessExecutor;
21use crate::files::{
22    delete_commit_message_file_with_workspace, read_commit_message_file_with_workspace,
23    write_commit_message_file_with_workspace,
24};
25use crate::git_helpers::git_diff;
26use crate::logger::Colors;
27use crate::logger::Logger;
28use crate::phases::generate_commit_message_with_chain;
29use crate::pipeline::PipelineRuntime;
30use crate::pipeline::Timer;
31use crate::prompts::TemplateContext;
32use crate::workspace::Workspace;
33use std::sync::Arc;
34
35/// Configuration for commit message generation in plumbing commands.
36///
37/// Groups related parameters for `handle_generate_commit_msg` to avoid
38/// excessive function arguments.
39pub struct CommitGenerationConfig<'a> {
40    /// The pipeline configuration.
41    pub config: &'a Config,
42    /// Template context for prompt expansion.
43    pub template_context: &'a TemplateContext,
44    /// Workspace for file operations (trait object for DI).
45    pub workspace: &'a dyn crate::workspace::Workspace,
46    /// Arc-wrapped workspace for spawning into threads.
47    pub workspace_arc: Arc<dyn crate::workspace::Workspace>,
48    /// Agent registry for accessing configured agents.
49    pub registry: &'a AgentRegistry,
50    /// Logger for info/warning messages.
51    pub logger: &'a Logger,
52    /// Color configuration for output.
53    pub colors: Colors,
54    /// Name of the developer agent to use for commit generation.
55    pub developer_agent: &'a str,
56    /// Name of the reviewer agent (not used, kept for API compatibility).
57    pub reviewer_agent: &'a str,
58    /// Process executor for external command execution.
59    pub executor: Arc<dyn ProcessExecutor>,
60}
61
62/// Handles the `--show-commit-msg` command using workspace abstraction.
63///
64/// This is a testable version that uses `Workspace` for file I/O,
65/// enabling tests to use `MemoryWorkspace` for isolation.
66///
67/// # Arguments
68///
69/// * `workspace` - The workspace to read from
70///
71/// # Returns
72///
73/// Returns `Ok(())` on success or an error if the file cannot be read.
74///
75/// # Errors
76///
77/// Returns error if the operation fails.
78pub fn handle_show_commit_msg_with_workspace(workspace: &dyn Workspace) -> anyhow::Result<()> {
79    match read_commit_message_file_with_workspace(workspace) {
80        Ok(msg) => {
81            println!("{msg}");
82            Ok(())
83        }
84        Err(e) => {
85            anyhow::bail!("Failed to read commit message: {e}");
86        }
87    }
88}
89
90/// Handles the `--apply-commit` command using effect handler abstraction.
91///
92/// This is a testable version that uses `AppEffectHandler` for git operations
93/// and `Workspace` for file I/O, enabling tests to use mocks for isolation.
94///
95/// # Arguments
96///
97/// * `workspace` - The workspace for file operations
98/// * `handler` - The effect handler for git operations
99/// * `logger` - Logger for info/warning messages
100/// * `colors` - Color configuration for output
101///
102/// # Returns
103///
104/// Returns `Ok(())` on success or an error if commit fails.
105///
106/// # Errors
107///
108/// Returns error if the operation fails.
109pub fn handle_apply_commit_with_handler<H: AppEffectHandler>(
110    workspace: &dyn Workspace,
111    handler: &mut H,
112    logger: &Logger,
113    colors: Colors,
114) -> anyhow::Result<()> {
115    let commit_msg = read_commit_message_file_with_workspace(workspace)?;
116
117    logger.info("Staging all changes...");
118
119    // Stage all changes via effect
120    // Mock returns Bool(true) to indicate staged changes exist, production returns Ok
121    match handler.execute(AppEffect::GitAddAll) {
122        AppEffectResult::Ok | AppEffectResult::Bool(true | false) => {
123            // No changes to stage if Bool(false)
124        }
125        AppEffectResult::Error(e) => anyhow::bail!("Failed to stage changes: {e}"),
126        other => anyhow::bail!("Unexpected result from GitAddAll: {other:?}"),
127    }
128
129    logger.info(&format!(
130        "Commit message: {}{}{}",
131        colors.cyan(),
132        commit_msg,
133        colors.reset()
134    ));
135
136    logger.info("Creating commit...");
137
138    // Create commit via effect
139    // Note: Plumbing commands don't have access to config, so we use None
140    // for git identity, falling back to git config (via repo.signature())
141    match handler.execute(AppEffect::GitCommit {
142        message: commit_msg,
143        user_name: None,
144        user_email: None,
145    }) {
146        AppEffectResult::String(oid)
147        | AppEffectResult::Commit(crate::app::effect::CommitResult::Success(oid)) => {
148            logger.success(&format!("Commit created successfully: {oid}"));
149            // Clean up the commit message file
150            if let Err(err) = delete_commit_message_file_with_workspace(workspace) {
151                logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
152            }
153            Ok(())
154        }
155        AppEffectResult::Commit(crate::app::effect::CommitResult::NoChanges)
156        | AppEffectResult::Ok => {
157            // No changes to commit (clean working tree)
158            logger.warn("Nothing to commit (working tree clean)");
159            Ok(())
160        }
161        AppEffectResult::Error(e) => anyhow::bail!("Failed to create commit: {e}"),
162        other => anyhow::bail!("Unexpected result from GitCommit: {other:?}"),
163    }
164}
165
166/// Handles the `--generate-commit-msg` command.
167///
168/// Generates a commit message for current changes using the standard pipeline.
169/// Uses the same `generate_commit_message()` function as the main workflow,
170/// ensuring consistent behavior with reducer-driven validation.
171///
172/// # Arguments
173///
174/// * `config` - The pipeline configuration
175/// * `registry` - The agent registry
176/// * `logger` - Logger for info/warning messages
177/// * `colors` - Color configuration for output
178/// * `developer_agent` - Name of the developer agent to use (for commit generation)
179/// * `_reviewer_agent` - Name of the reviewer agent (not used, kept for API compatibility)
180///
181/// # Returns
182///
183/// Returns `Ok(())` on success or an error if generation fails.
184///
185/// # Errors
186///
187/// Returns error if the operation fails.
188pub fn handle_generate_commit_msg(config: &CommitGenerationConfig<'_>) -> anyhow::Result<()> {
189    config.logger.info("Generating commit message...");
190
191    // Generate the commit message using standard pipeline
192    let diff = git_diff()?;
193    if diff.trim().is_empty() {
194        config
195            .logger
196            .warn("No changes detected to generate a commit message for");
197        anyhow::bail!("No changes to commit");
198    }
199
200    // Create a timer for the pipeline runtime
201    let mut timer = Timer::new();
202
203    // Set up pipeline runtime with the injected executor
204    let executor_ref: &dyn ProcessExecutor = &*config.executor;
205    let mut runtime = PipelineRuntime {
206        timer: &mut timer,
207        logger: config.logger,
208        colors: &config.colors,
209        config: config.config,
210        executor: executor_ref,
211        executor_arc: Arc::clone(&config.executor),
212        workspace: config.workspace,
213        workspace_arc: Arc::clone(&config.workspace_arc),
214    };
215
216    // Get the commit agent chain from the fallback config.
217    // If no commit chain is configured, fall back to using the developer agent.
218    let fallback_config = config.registry.fallback_config();
219    let commit_chain = fallback_config.get_fallbacks(AgentRole::Commit);
220    let agents: Vec<String> = if commit_chain.is_empty() {
221        // No commit chain configured, use developer agent as fallback
222        vec![config.developer_agent.to_string()]
223    } else {
224        commit_chain.to_vec()
225    };
226
227    // Use the chain-aware commit message generation from phases/commit.rs.
228    let result = generate_commit_message_with_chain(
229        &diff,
230        config.registry,
231        &mut runtime,
232        &agents,
233        config.template_context,
234        config.workspace,
235    )
236    .map_err(|e| anyhow::anyhow!("Failed to generate commit message: {e}"))?;
237    let commit_message = match result.outcome {
238        crate::phases::commit::CommitMessageOutcome::Message(message) => message,
239        crate::phases::commit::CommitMessageOutcome::Skipped { reason } => {
240            config.logger.warn(&format!(
241                "No commit needed (agent requested skip): {reason}"
242            ));
243            return Ok(());
244        }
245    };
246
247    config.logger.success("Commit message generated:");
248    println!();
249    println!(
250        "{}{}{}",
251        config.colors.cyan(),
252        commit_message,
253        config.colors.reset()
254    );
255    println!();
256
257    // Write the message to file for use with --apply-commit (using workspace)
258    write_commit_message_file_with_workspace(config.workspace, &commit_message)?;
259
260    config
261        .logger
262        .info("Message saved to .agent/commit-message.txt");
263    config
264        .logger
265        .info("Run 'ralph --apply-commit' to create the commit");
266
267    Ok(())
268}