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, delete_commit_message_file_with_workspace,
23    read_commit_message_file, read_commit_message_file_with_workspace,
24    write_commit_message_file_with_workspace,
25};
26use crate::git_helpers::{
27    get_repo_root, git_add_all, git_commit, git_diff, git_snapshot, require_git_repo,
28};
29use crate::logger::Colors;
30use crate::logger::Logger;
31use crate::phases::generate_commit_message;
32use crate::pipeline::PipelineRuntime;
33use crate::pipeline::Timer;
34use crate::prompts::TemplateContext;
35use crate::workspace::Workspace;
36use std::env;
37use std::sync::Arc;
38
39/// Configuration for commit message generation in plumbing commands.
40///
41/// Groups related parameters for `handle_generate_commit_msg` to avoid
42/// excessive function arguments.
43pub struct CommitGenerationConfig<'a> {
44    /// The pipeline configuration.
45    pub config: &'a Config,
46    /// Template context for prompt expansion.
47    pub template_context: &'a TemplateContext,
48    /// Workspace for file operations (trait object for DI).
49    pub workspace: &'a dyn crate::workspace::Workspace,
50    /// Agent registry for accessing configured agents.
51    pub registry: &'a AgentRegistry,
52    /// Logger for info/warning messages.
53    pub logger: &'a Logger,
54    /// Color configuration for output.
55    pub colors: Colors,
56    /// Name of the developer agent to use for commit generation.
57    pub developer_agent: &'a str,
58    /// Name of the reviewer agent (not used, kept for API compatibility).
59    pub _reviewer_agent: &'a str,
60    /// Process executor for external command execution.
61    pub executor: Arc<dyn ProcessExecutor>,
62}
63
64/// Handles the `--show-commit-msg` command.
65///
66/// Reads and displays the commit message from `.agent/commit-message.txt`.
67///
68/// # Returns
69///
70/// Returns `Ok(())` on success or an error if the file cannot be read.
71pub fn handle_show_commit_msg() -> anyhow::Result<()> {
72    require_git_repo()?;
73    let repo_root = get_repo_root()?;
74    env::set_current_dir(&repo_root)?;
75
76    match read_commit_message_file() {
77        Ok(msg) => {
78            println!("{msg}");
79            Ok(())
80        }
81        Err(e) => {
82            anyhow::bail!("Failed to read commit message: {e}");
83        }
84    }
85}
86
87/// Handles the `--show-commit-msg` command using workspace abstraction.
88///
89/// This is a testable version that uses `Workspace` for file I/O,
90/// enabling tests to use `MemoryWorkspace` for isolation.
91///
92/// # Arguments
93///
94/// * `workspace` - The workspace to read from
95///
96/// # Returns
97///
98/// Returns `Ok(())` on success or an error if the file cannot be read.
99pub fn handle_show_commit_msg_with_workspace(workspace: &dyn Workspace) -> anyhow::Result<()> {
100    match read_commit_message_file_with_workspace(workspace) {
101        Ok(msg) => {
102            println!("{msg}");
103            Ok(())
104        }
105        Err(e) => {
106            anyhow::bail!("Failed to read commit message: {e}");
107        }
108    }
109}
110
111/// Handles the `--apply-commit` command.
112///
113/// Stages all changes and creates a commit using the stored commit message.
114/// After successful commit, deletes the commit message file.
115///
116/// # Arguments
117///
118/// * `logger` - Logger for info/warning messages
119/// * `colors` - Color configuration for output
120///
121/// # Returns
122///
123/// Returns `Ok(())` on success or an error if commit fails.
124pub fn handle_apply_commit(logger: &Logger, colors: Colors) -> anyhow::Result<()> {
125    require_git_repo()?;
126    let repo_root = get_repo_root()?;
127    env::set_current_dir(&repo_root)?;
128
129    let commit_msg = read_commit_message_file()?;
130
131    logger.info("Staging all changes...");
132    git_add_all()?;
133
134    // Show what we're committing (using libgit2 via git_snapshot)
135    if let Ok(status) = git_snapshot() {
136        if !status.is_empty() {
137            println!("{}Changes to commit:{}", colors.bold(), colors.reset());
138            for line in status.lines().take(20) {
139                println!("  {}{}{}", colors.dim(), line, colors.reset());
140            }
141            println!();
142        }
143    }
144
145    logger.info(&format!(
146        "Commit message: {}{}{}",
147        colors.cyan(),
148        commit_msg,
149        colors.reset()
150    ));
151
152    logger.info("Creating commit...");
153    // Note: Plumbing commands don't have access to config, so we use None
154    // for git identity and executor, falling back to git config (via repo.signature())
155    // and environment variables for identity fallback.
156    if let Some(oid) = git_commit(&commit_msg, None, None, None)? {
157        logger.success(&format!("Commit created successfully: {oid}"));
158        // Clean up the commit message file
159        if let Err(err) = delete_commit_message_file() {
160            logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
161        }
162    } else {
163        logger.warn("Nothing to commit (working tree clean)");
164    }
165
166    Ok(())
167}
168
169/// Handles the `--apply-commit` command using effect handler abstraction.
170///
171/// This is a testable version that uses `AppEffectHandler` for git operations
172/// and `Workspace` for file I/O, enabling tests to use mocks for isolation.
173///
174/// # Arguments
175///
176/// * `workspace` - The workspace for file operations
177/// * `handler` - The effect handler for git operations
178/// * `logger` - Logger for info/warning messages
179/// * `colors` - Color configuration for output
180///
181/// # Returns
182///
183/// Returns `Ok(())` on success or an error if commit fails.
184pub fn handle_apply_commit_with_handler<H: AppEffectHandler>(
185    workspace: &dyn Workspace,
186    handler: &mut H,
187    logger: &Logger,
188    colors: Colors,
189) -> anyhow::Result<()> {
190    let commit_msg = read_commit_message_file_with_workspace(workspace)?;
191
192    logger.info("Staging all changes...");
193
194    // Stage all changes via effect
195    // Mock returns Bool(true) to indicate staged changes exist, production returns Ok
196    match handler.execute(AppEffect::GitAddAll) {
197        AppEffectResult::Ok | AppEffectResult::Bool(true) => {}
198        AppEffectResult::Bool(false) => {
199            // No changes to stage
200        }
201        AppEffectResult::Error(e) => anyhow::bail!("Failed to stage changes: {e}"),
202        other => anyhow::bail!("Unexpected result from GitAddAll: {other:?}"),
203    }
204
205    logger.info(&format!(
206        "Commit message: {}{}{}",
207        colors.cyan(),
208        commit_msg,
209        colors.reset()
210    ));
211
212    logger.info("Creating commit...");
213
214    // Create commit via effect
215    // Note: Plumbing commands don't have access to config, so we use None
216    // for git identity, falling back to git config (via repo.signature())
217    match handler.execute(AppEffect::GitCommit {
218        message: commit_msg,
219        user_name: None,
220        user_email: None,
221    }) {
222        AppEffectResult::String(oid) => {
223            logger.success(&format!("Commit created successfully: {oid}"));
224            // Clean up the commit message file
225            if let Err(err) = delete_commit_message_file_with_workspace(workspace) {
226                logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
227            }
228            Ok(())
229        }
230        AppEffectResult::Commit(crate::app::effect::CommitResult::Success(oid)) => {
231            logger.success(&format!("Commit created successfully: {oid}"));
232            // Clean up the commit message file
233            if let Err(err) = delete_commit_message_file_with_workspace(workspace) {
234                logger.warn(&format!("Failed to delete commit-message.txt: {err}"));
235            }
236            Ok(())
237        }
238        AppEffectResult::Commit(crate::app::effect::CommitResult::NoChanges)
239        | AppEffectResult::Ok => {
240            // No changes to commit (clean working tree)
241            logger.warn("Nothing to commit (working tree clean)");
242            Ok(())
243        }
244        AppEffectResult::Error(e) => anyhow::bail!("Failed to create commit: {e}"),
245        other => anyhow::bail!("Unexpected result from GitCommit: {other:?}"),
246    }
247}
248
249/// Handles the `--generate-commit-msg` command.
250///
251/// Generates a commit message for current changes using the standard pipeline.
252/// Uses the same `generate_commit_message()` function as the main workflow,
253/// ensuring consistent behavior with proper fallback chain support and logging.
254///
255/// # Arguments
256///
257/// * `config` - The pipeline configuration
258/// * `registry` - The agent registry
259/// * `logger` - Logger for info/warning messages
260/// * `colors` - Color configuration for output
261/// * `developer_agent` - Name of the developer agent to use (for commit generation)
262/// * `_reviewer_agent` - Name of the reviewer agent (not used, kept for API compatibility)
263///
264/// # Returns
265///
266/// Returns `Ok(())` on success or an error if generation fails.
267pub fn handle_generate_commit_msg(config: CommitGenerationConfig<'_>) -> anyhow::Result<()> {
268    config.logger.info("Generating commit message...");
269
270    // Generate the commit message using standard pipeline
271    let diff = git_diff()?;
272    if diff.trim().is_empty() {
273        config
274            .logger
275            .warn("No changes detected to generate a commit message for");
276        anyhow::bail!("No changes to commit");
277    }
278
279    // Create a timer for the pipeline runtime
280    let mut timer = Timer::new();
281
282    // Set up pipeline runtime with the injected executor
283    let executor_ref: &dyn ProcessExecutor = &*config.executor;
284    let mut runtime = PipelineRuntime {
285        timer: &mut timer,
286        logger: config.logger,
287        colors: &config.colors,
288        config: config.config,
289        executor: executor_ref,
290        executor_arc: Arc::clone(&config.executor),
291        workspace: config.workspace,
292    };
293
294    // Use the standard commit message generation from phases/commit.rs
295    // This provides:
296    // - Proper fallback chain support
297    // - Structured logging to .agent/logs/
298    // - Meaningful error diagnostics
299    let result = generate_commit_message(
300        &diff,
301        config.registry,
302        &mut runtime,
303        config.developer_agent,
304        config.template_context,
305        config.workspace,
306        &std::collections::HashMap::new(), // Empty prompt history for plumbing command
307    )
308    .map_err(|e| anyhow::anyhow!("Failed to generate commit message: {e}"))?;
309
310    if !result.success || result.message.trim().is_empty() {
311        anyhow::bail!("Commit message generation failed");
312    }
313
314    let commit_message = result.message;
315
316    config.logger.success("Commit message generated:");
317    println!();
318    println!(
319        "{}{}{}",
320        config.colors.cyan(),
321        commit_message,
322        config.colors.reset()
323    );
324    println!();
325
326    // Write the message to file for use with --apply-commit (using workspace)
327    write_commit_message_file_with_workspace(config.workspace, &commit_message)?;
328
329    config
330        .logger
331        .info("Message saved to .agent/commit-message.txt");
332    config
333        .logger
334        .info("Run 'ralph --apply-commit' to create the commit");
335
336    Ok(())
337}