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