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