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;
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;
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    /// Agent registry for accessing configured agents.
47    pub registry: &'a AgentRegistry,
48    /// Logger for info/warning messages.
49    pub logger: &'a Logger,
50    /// Color configuration for output.
51    pub colors: Colors,
52    /// Name of the developer agent to use for commit generation.
53    pub developer_agent: &'a str,
54    /// Name of the reviewer agent (not used, kept for API compatibility).
55    pub _reviewer_agent: &'a str,
56    /// Process executor for external command execution.
57    pub executor: Arc<dyn ProcessExecutor>,
58}
59
60/// Handles the `--show-commit-msg` command using workspace abstraction.
61///
62/// This is a testable version that uses `Workspace` for file I/O,
63/// enabling tests to use `MemoryWorkspace` for isolation.
64///
65/// # Arguments
66///
67/// * `workspace` - The workspace to read from
68///
69/// # Returns
70///
71/// Returns `Ok(())` on success or an error if the file cannot be read.
72pub fn handle_show_commit_msg_with_workspace(workspace: &dyn Workspace) -> anyhow::Result<()> {
73    match read_commit_message_file_with_workspace(workspace) {
74        Ok(msg) => {
75            println!("{msg}");
76            Ok(())
77        }
78        Err(e) => {
79            anyhow::bail!("Failed to read commit message: {e}");
80        }
81    }
82}
83
84/// Handles the `--apply-commit` command using effect handler abstraction.
85///
86/// This is a testable version that uses `AppEffectHandler` for git operations
87/// and `Workspace` for file I/O, enabling tests to use mocks for isolation.
88///
89/// # Arguments
90///
91/// * `workspace` - The workspace for file operations
92/// * `handler` - The effect handler for git operations
93/// * `logger` - Logger for info/warning messages
94/// * `colors` - Color configuration for output
95///
96/// # Returns
97///
98/// Returns `Ok(())` on success or an error if commit fails.
99pub fn handle_apply_commit_with_handler<H: AppEffectHandler>(
100    workspace: &dyn Workspace,
101    handler: &mut H,
102    logger: &Logger,
103    colors: Colors,
104) -> anyhow::Result<()> {
105    let commit_msg = read_commit_message_file_with_workspace(workspace)?;
106
107    logger.info("Staging all changes...");
108
109    // Stage all changes via effect
110    // Mock returns Bool(true) to indicate staged changes exist, production returns Ok
111    match handler.execute(AppEffect::GitAddAll) {
112        AppEffectResult::Ok | AppEffectResult::Bool(true) => {}
113        AppEffectResult::Bool(false) => {
114            // No changes to stage
115        }
116        AppEffectResult::Error(e) => anyhow::bail!("Failed to stage changes: {e}"),
117        other => anyhow::bail!("Unexpected result from GitAddAll: {other:?}"),
118    }
119
120    logger.info(&format!(
121        "Commit message: {}{}{}",
122        colors.cyan(),
123        commit_msg,
124        colors.reset()
125    ));
126
127    logger.info("Creating commit...");
128
129    // Create commit via effect
130    // Note: Plumbing commands don't have access to config, so we use None
131    // for git identity, falling back to git config (via repo.signature())
132    match handler.execute(AppEffect::GitCommit {
133        message: commit_msg,
134        user_name: None,
135        user_email: None,
136    }) {
137        AppEffectResult::String(oid) => {
138            logger.success(&format!("Commit created successfully: {oid}"));
139            // Clean up the commit message file
140            if let Err(err) = delete_commit_message_file_with_workspace(workspace) {
141                logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
142            }
143            Ok(())
144        }
145        AppEffectResult::Commit(crate::app::effect::CommitResult::Success(oid)) => {
146            logger.success(&format!("Commit created successfully: {oid}"));
147            // Clean up the commit message file
148            if let Err(err) = delete_commit_message_file_with_workspace(workspace) {
149                logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
150            }
151            Ok(())
152        }
153        AppEffectResult::Commit(crate::app::effect::CommitResult::NoChanges)
154        | AppEffectResult::Ok => {
155            // No changes to commit (clean working tree)
156            logger.warn("Nothing to commit (working tree clean)");
157            Ok(())
158        }
159        AppEffectResult::Error(e) => anyhow::bail!("Failed to create commit: {e}"),
160        other => anyhow::bail!("Unexpected result from GitCommit: {other:?}"),
161    }
162}
163
164/// Handles the `--generate-commit-msg` command.
165///
166/// Generates a commit message for current changes using the standard pipeline.
167/// Uses the same `generate_commit_message()` function as the main workflow,
168/// ensuring consistent behavior with proper fallback chain support and logging.
169///
170/// # Arguments
171///
172/// * `config` - The pipeline configuration
173/// * `registry` - The agent registry
174/// * `logger` - Logger for info/warning messages
175/// * `colors` - Color configuration for output
176/// * `developer_agent` - Name of the developer agent to use (for commit generation)
177/// * `_reviewer_agent` - Name of the reviewer agent (not used, kept for API compatibility)
178///
179/// # Returns
180///
181/// Returns `Ok(())` on success or an error if generation fails.
182pub fn handle_generate_commit_msg(config: CommitGenerationConfig<'_>) -> anyhow::Result<()> {
183    config.logger.info("Generating commit message...");
184
185    // Generate the commit message using standard pipeline
186    let diff = git_diff()?;
187    if diff.trim().is_empty() {
188        config
189            .logger
190            .warn("No changes detected to generate a commit message for");
191        anyhow::bail!("No changes to commit");
192    }
193
194    // Create a timer for the pipeline runtime
195    let mut timer = Timer::new();
196
197    // Set up pipeline runtime with the injected executor
198    let executor_ref: &dyn ProcessExecutor = &*config.executor;
199    let mut runtime = PipelineRuntime {
200        timer: &mut timer,
201        logger: config.logger,
202        colors: &config.colors,
203        config: config.config,
204        executor: executor_ref,
205        executor_arc: Arc::clone(&config.executor),
206        workspace: config.workspace,
207    };
208
209    // Use the standard commit message generation from phases/commit.rs
210    // This provides:
211    // - Proper fallback chain support
212    // - Structured logging to .agent/logs/
213    // - Meaningful error diagnostics
214    let result = generate_commit_message(
215        &diff,
216        config.registry,
217        &mut runtime,
218        config.developer_agent,
219        config.template_context,
220        config.workspace,
221        &std::collections::HashMap::new(), // Empty prompt history for plumbing command
222    )
223    .map_err(|e| anyhow::anyhow!("Failed to generate commit message: {e}"))?;
224
225    if !result.success || result.message.trim().is_empty() {
226        anyhow::bail!("Commit message generation failed");
227    }
228
229    let commit_message = result.message;
230
231    config.logger.success("Commit message generated:");
232    println!();
233    println!(
234        "{}{}{}",
235        config.colors.cyan(),
236        commit_message,
237        config.colors.reset()
238    );
239    println!();
240
241    // Write the message to file for use with --apply-commit (using workspace)
242    write_commit_message_file_with_workspace(config.workspace, &commit_message)?;
243
244    config
245        .logger
246        .info("Message saved to .agent/commit-message.txt");
247    config
248        .logger
249        .info("Run 'ralph --apply-commit' to create the commit");
250
251    Ok(())
252}