ralph_workflow/phases/
commit.rs

1//! Commit message generation phase.
2//!
3//! This module handles automated commit message generation using the standard
4//! agent pipeline with fallback support. It replaces the custom implementation
5//! in repo.rs that lacked proper logging and fallback handling.
6//!
7//! The phase:
8//! 1. Takes a git diff as input
9//! 2. Runs the commit agent with the diff via the standard pipeline
10//! 3. Extracts the commit message from agent output
11//! 4. Returns the generated message for use by the caller
12
13use super::commit_logging::{
14    AttemptOutcome, CommitAttemptLog, CommitLogSession, ExtractionAttempt,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::files::llm_output_extraction::{
19    preprocess_raw_content, try_extract_xml_commit_with_trace, CommitExtractionResult,
20};
21use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
22use crate::logger::Logger;
23use crate::pipeline::PipelineRuntime;
24use crate::prompts::{
25    prompt_generate_commit_message_with_diff_with_context, prompt_simplified_commit_with_context,
26    prompt_xsd_retry_with_context,
27};
28use std::fmt;
29use std::fs::{self, File};
30use std::io::Read;
31
32/// Preview a commit message for display (first line, truncated if needed).
33fn preview_commit_message(msg: &str) -> String {
34    let first_line = msg.lines().next().unwrap_or(msg);
35    if first_line.len() > 60 {
36        format!("{}...", &first_line[..60])
37    } else {
38        first_line.to_string()
39    }
40}
41
42/// Maximum safe prompt size in bytes before pre-truncation.
43///
44/// This is a conservative limit to prevent agents from failing with "prompt too long"
45/// errors. Different agents have different token limits:
46/// - GLM: ~100KB effective limit
47/// - Claude CCS: ~300KB effective limit
48/// - Others: vary by model
49///
50/// We use 200KB as a safe middle ground that works for most agents while still
51/// allowing substantial diffs to be processed without truncation.
52const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
53
54/// Absolute last resort fallback commit message.
55///
56/// This is used ONLY when all other methods fail:
57/// - All 8 prompt variants exhausted
58/// - All agents in fallback chain exhausted
59/// - All truncation stages failed
60/// - Emergency no-diff prompt failed
61/// - Deterministic fallback from diff failed
62///
63/// This ensures the commit process NEVER fails completely.
64pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
65
66/// Get the maximum safe prompt size for a specific agent.
67///
68/// Different agents have different token limits. This function returns a
69/// conservative max size for the given agent to prevent "prompt too long" errors.
70///
71/// # Arguments
72///
73/// * `commit_agent` - The commit agent command string
74///
75/// # Returns
76///
77/// Maximum safe prompt size in bytes
78fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
79    let agent_lower = commit_agent.to_lowercase();
80
81    // GLM and similar agents have smaller effective limits
82    if agent_lower.contains("glm")
83        || agent_lower.contains("zhipuai")
84        || agent_lower.contains("zai")
85        || agent_lower.contains("qwen")
86        || agent_lower.contains("deepseek")
87    {
88        100_000 // 100KB for GLM-like agents
89    } else if agent_lower.contains("claude")
90        || agent_lower.contains("ccs")
91        || agent_lower.contains("anthropic")
92    {
93        300_000 // 300KB for Claude-based agents
94    } else {
95        MAX_SAFE_PROMPT_SIZE // Default 200KB
96    }
97}
98
99/// Retry strategy for commit message generation.
100///
101/// Tracks which stage of re-prompting we're in, allowing for progressive
102/// degradation from detailed prompts to minimal ones before falling back
103/// to the next agent in the chain.
104///
105/// With XSD validation, we now have two strategies. Each strategy supports
106/// up to 5 in-session retries with validation feedback.
107///
108/// The XSD retry mechanism is used internally for in-session retries when
109/// validation fails, not as a separate stage.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum CommitRetryStrategy {
112    /// First attempt with normal XML prompt
113    Normal,
114    /// Simplified XML prompt - more direct instructions
115    Simplified,
116}
117
118impl CommitRetryStrategy {
119    /// Get the description of this retry stage for logging
120    const fn description(self) -> &'static str {
121        match self {
122            Self::Normal => "normal XML prompt",
123            Self::Simplified => "simplified XML prompt",
124        }
125    }
126
127    /// Get the next retry strategy, or None if this is the last stage
128    const fn next(self) -> Option<Self> {
129        match self {
130            Self::Normal => Some(Self::Simplified),
131            Self::Simplified => None,
132        }
133    }
134
135    /// Get the 1-based stage number for this strategy
136    const fn stage_number(self) -> usize {
137        match self {
138            Self::Normal => 1,
139            Self::Simplified => 2,
140        }
141    }
142
143    /// Get the total number of retry stages
144    const fn total_stages() -> usize {
145        2 // Normal + Simplified
146    }
147
148    /// Get the maximum number of in-session retries for this strategy
149    const fn max_session_retries(self) -> usize {
150        match self {
151            Self::Normal => 10,     // Allow 10 retries with XSD validation feedback
152            Self::Simplified => 10, // Allow 10 retries for simplified prompt
153        }
154    }
155}
156
157impl fmt::Display for CommitRetryStrategy {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "{}", self.description())
160    }
161}
162
163/// Result of commit message generation.
164pub struct CommitMessageResult {
165    /// The generated commit message (may be empty on failure)
166    pub message: String,
167    /// Whether the generation was successful
168    pub success: bool,
169    /// Path to the agent log file for debugging (currently unused but kept for API compatibility)
170    pub _log_path: String,
171}
172
173/// Truncate diff if it's too large for agents with small context windows.
174///
175/// This is a defensive measure when agents report "prompt too long" errors.
176/// Returns a truncated diff with a summary of omitted content.
177///
178/// # Semantic Awareness
179///
180/// The improved truncation:
181/// 1. Preserves file structure - truncates at file boundaries (after `diff --git` blocks)
182/// 2. Prioritizes important files - keeps files from `src/` over `tests/`, `.md` files, etc.
183/// 3. Preserves last N files - shows what changed at the end
184/// 4. Adds a summary header - includes "First M files shown, N files truncated"
185fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
186    if diff.len() <= max_size {
187        return diff.to_string();
188    }
189
190    // Parse the diff into individual file blocks
191    let mut files: Vec<DiffFile> = Vec::new();
192    let mut current_file = DiffFile::default();
193    let mut in_file = false;
194
195    for line in diff.lines() {
196        if line.starts_with("diff --git ") {
197            // Save previous file if any
198            if in_file && !current_file.lines.is_empty() {
199                files.push(std::mem::take(&mut current_file));
200            }
201            in_file = true;
202            current_file.lines.push(line.to_string());
203
204            // Extract and prioritize the file path
205            if let Some(path) = line.split(" b/").nth(1) {
206                current_file.path = path.to_string();
207                current_file.priority = prioritize_file_path(path);
208            }
209        } else if in_file {
210            current_file.lines.push(line.to_string());
211        }
212    }
213
214    // Don't forget the last file
215    if in_file && !current_file.lines.is_empty() {
216        files.push(current_file);
217    }
218
219    let total_files = files.len();
220
221    // Sort files by priority (highest first) to keep important files
222    files.sort_by_key(|f| std::cmp::Reverse(f.priority));
223
224    // Greedily select files that fit within max_size
225    let mut selected_files = Vec::new();
226    let mut current_size = 0;
227
228    for file in files {
229        let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); // +1 for newline
230
231        if current_size + file_size <= max_size {
232            current_size += file_size;
233            selected_files.push(file);
234        } else if current_size > 0 {
235            // We have at least one file and this one would exceed the limit
236            // Stop adding more files
237            break;
238        } else {
239            // Even the first (highest priority) file is too large
240            // Take at least the first part of it
241            let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
242            selected_files.push(DiffFile {
243                path: file.path,
244                priority: file.priority,
245                lines: truncated_lines,
246            });
247            break;
248        }
249    }
250
251    let selected_count = selected_files.len();
252    let omitted_count = total_files.saturating_sub(selected_count);
253
254    // Build the truncated diff
255    let mut result = String::new();
256
257    // Add summary header at the top
258    if omitted_count > 0 {
259        use std::fmt::Write;
260        let _ = write!(
261            result,
262            "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
263        );
264    }
265
266    for file in selected_files {
267        for line in &file.lines {
268            result.push_str(line);
269            result.push('\n');
270        }
271    }
272
273    result
274}
275
276/// Represents a single file's diff chunk.
277#[derive(Debug, Default, Clone)]
278struct DiffFile {
279    /// File path (extracted from diff header)
280    path: String,
281    /// Priority for selection (higher = more important)
282    priority: i32,
283    /// Lines in this file's diff
284    lines: Vec<String>,
285}
286
287/// Assign a priority score to a file path for truncation selection.
288///
289/// Higher priority files are kept first when truncating:
290/// - src/*.rs: +100 (source code is most important)
291/// - src/*: +80 (other src files)
292/// - tests/*: +40 (tests are important but secondary)
293/// - Cargo.toml, package.json, etc.: +60 (config files)
294/// - docs/*, *.md: +20 (docs are least important)
295/// - Other: +50 (default)
296fn prioritize_file_path(path: &str) -> i32 {
297    use std::path::Path;
298    let path_lower = path.to_lowercase();
299
300    // Helper function for case-insensitive extension check
301    let has_ext = |ext: &str| -> bool {
302        Path::new(path)
303            .extension()
304            .and_then(std::ffi::OsStr::to_str)
305            .is_some_and(|e| e.eq_ignore_ascii_case(ext))
306    };
307
308    // Helper function for case-insensitive file extension check on path_lower
309    let has_ext_lower = |ext: &str| -> bool {
310        Path::new(&path_lower)
311            .extension()
312            .and_then(std::ffi::OsStr::to_str)
313            .is_some_and(|e| e.eq_ignore_ascii_case(ext))
314    };
315
316    // Source code files (highest priority)
317    if path_lower.contains("src/") && has_ext_lower("rs") {
318        100
319    } else if path_lower.contains("src/") {
320        80
321    }
322    // Test files
323    else if path_lower.contains("test") {
324        40
325    }
326    // Config files - use case-insensitive extension check
327    else if has_ext("toml")
328        || has_ext("json")
329        || path_lower.ends_with("cargo.toml")
330        || path_lower.ends_with("package.json")
331        || path_lower.ends_with("tsconfig.json")
332    {
333        60
334    }
335    // Documentation files (lowest priority)
336    else if path_lower.contains("doc") || has_ext("md") {
337        20
338    }
339    // Default priority
340    else {
341        50
342    }
343}
344
345/// Truncate a slice of lines to fit within a maximum size.
346///
347/// This is a fallback for when even a single file is too large.
348/// Returns as many complete lines as will fit.
349fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
350    let mut result = Vec::new();
351    let mut current_size = 0;
352
353    for line in lines {
354        let line_size = line.len() + 1; // +1 for newline
355        if current_size + line_size <= max_size {
356            current_size += line_size;
357            result.push(line.clone());
358        } else {
359            break;
360        }
361    }
362
363    // Add truncation marker to the last line
364    if let Some(last) = result.last_mut() {
365        last.push_str(" [truncated...]");
366    }
367
368    result
369}
370
371/// Check and pre-truncate diff if it exceeds agent's token limits.
372///
373/// Returns the (possibly truncated) diff and whether truncation occurred.
374fn check_and_pre_truncate_diff(
375    diff: &str,
376    commit_agent: &str,
377    runtime: &PipelineRuntime,
378) -> (String, bool) {
379    let max_size = max_prompt_size_for_agent(commit_agent);
380    if diff.len() > max_size {
381        runtime.logger.warn(&format!(
382            "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
383            diff.len() / 1024,
384            max_size / 1024
385        ));
386        (truncate_diff_if_large(diff, max_size), true)
387    } else {
388        runtime.logger.info(&format!(
389            "Diff size ({} KB) is within safe limit ({} KB).",
390            diff.len() / 1024,
391            max_size / 1024
392        ));
393        (diff.to_string(), false)
394    }
395}
396
397/// Generate the appropriate prompt for the current retry strategy.
398///
399/// For XSD retry, the xsd_error parameter is used to provide feedback to the agent.
400/// Note: XSD retry is handled internally within the session, not as a separate stage.
401fn generate_prompt_for_strategy(
402    strategy: CommitRetryStrategy,
403    working_diff: &str,
404    template_context: &crate::prompts::TemplateContext,
405    xsd_error: Option<&str>,
406) -> String {
407    match strategy {
408        CommitRetryStrategy::Normal => {
409            if let Some(error_msg) = xsd_error {
410                // In-session XSD retry with error feedback
411                prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
412            } else {
413                // First attempt with normal XML prompt
414                prompt_generate_commit_message_with_diff_with_context(
415                    template_context,
416                    working_diff,
417                )
418            }
419        }
420        CommitRetryStrategy::Simplified => {
421            if let Some(error_msg) = xsd_error {
422                // In-session XSD retry with error feedback
423                prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
424            } else {
425                // Simplified XML prompt
426                prompt_simplified_commit_with_context(template_context, working_diff)
427            }
428        }
429    }
430}
431
432/// Log the current attempt with prompt size information.
433fn log_commit_attempt(
434    strategy: CommitRetryStrategy,
435    prompt_size_kb: usize,
436    commit_agent: &str,
437    runtime: &PipelineRuntime,
438) {
439    if strategy == CommitRetryStrategy::Normal {
440        runtime.logger.info(&format!(
441            "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
442            CommitRetryStrategy::total_stages(),
443            strategy,
444            prompt_size_kb,
445            commit_agent
446        ));
447    } else {
448        runtime.logger.warn(&format!(
449            "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
450            strategy as usize + 1,
451            CommitRetryStrategy::total_stages(),
452            strategy,
453            prompt_size_kb,
454            commit_agent
455        ));
456    }
457}
458
459/// Handle the extraction result from a commit attempt.
460///
461/// Returns `Some(result)` if we should return early (success),
462/// or `None` if we should continue to the next strategy.
463///
464/// With XSD validation handling everything, we only check:
465/// - Success: Valid commit message extracted
466/// - Failure: No valid message (try next strategy)
467fn handle_commit_extraction_result(
468    extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
469    strategy: CommitRetryStrategy,
470    log_dir: &str,
471    runtime: &PipelineRuntime,
472    last_extraction: &mut Option<CommitExtractionResult>,
473    attempt_log: &mut CommitAttemptLog,
474) -> Option<anyhow::Result<CommitMessageResult>> {
475    let log_file = format!("{log_dir}/final.log");
476
477    match extraction_result {
478        Ok(Some(extraction)) => {
479            // XSD validation already passed - we have a valid commit message
480            runtime.logger.info(&format!(
481                "Successfully extracted commit message with {strategy}"
482            ));
483            let message = extraction.clone().into_message();
484            attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
485            *last_extraction = Some(extraction);
486            Some(Ok(CommitMessageResult {
487                message,
488                success: true,
489                _log_path: log_file,
490            }))
491        }
492        Ok(None) => {
493            runtime.logger.warn(&format!(
494                "No valid commit message extracted with {strategy}, will try next strategy"
495            ));
496            attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
497                "No valid commit message extracted".to_string(),
498            ));
499            None // Continue to next strategy
500        }
501        Err(e) => {
502            runtime.logger.error(&format!(
503                "Failed to extract commit message with {strategy}: {e}"
504            ));
505            attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
506            None // Continue to next strategy
507        }
508    }
509}
510
511/// Build the list of agents to try for commit generation.
512///
513/// This helper function constructs the ordered list of agents to try,
514/// starting with the primary agent and followed by configured fallbacks.
515fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
516    let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
517    for fb in fallbacks {
518        if *fb != primary_agent && !agents_to_try.contains(fb) {
519            agents_to_try.push(fb);
520        }
521    }
522    agents_to_try
523}
524
525/// Context for a commit attempt, bundling related state to avoid too many arguments.
526struct CommitAttemptContext<'a> {
527    /// The diff being processed
528    working_diff: &'a str,
529    /// Log directory path
530    log_dir: &'a str,
531    /// Whether the diff was pre-truncated
532    diff_was_truncated: bool,
533    /// Template context for user template overrides
534    template_context: &'a crate::prompts::TemplateContext,
535}
536
537/// Run a single commit attempt with the given strategy and agent.
538///
539/// This function runs a single agent (not using fallback) to allow for
540/// per-agent prompt variant cycling with in-session XSD validation retry.
541/// Returns Some(result) if we should return early (success or hard error),
542/// or None if we should continue to the next strategy.
543fn run_commit_attempt_with_agent(
544    strategy: CommitRetryStrategy,
545    ctx: &CommitAttemptContext<'_>,
546    runtime: &mut PipelineRuntime,
547    registry: &AgentRegistry,
548    agent: &str,
549    last_extraction: &mut Option<CommitExtractionResult>,
550    session: &mut CommitLogSession,
551) -> Option<anyhow::Result<CommitMessageResult>> {
552    // Get the agent config
553    let Some(agent_config) = registry.resolve_config(agent) else {
554        runtime
555            .logger
556            .warn(&format!("Agent '{agent}' not found in registry, skipping"));
557        let mut attempt_log = session.new_attempt(agent, strategy.description());
558        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
559            "Agent '{agent}' not found in registry"
560        )));
561        let _ = attempt_log.write_to_file(session.run_dir());
562        return None;
563    };
564
565    // Build the command for this agent
566    let cmd_str = agent_config.build_cmd(true, true, false);
567    let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
568
569    // In-session retry loop with XSD validation feedback
570    let max_retries = strategy.max_session_retries();
571    let mut xsd_error: Option<String> = None;
572
573    for retry_num in 0..max_retries {
574        // For initial attempt, xsd_error is None
575        // For retries, we use the XSD error to guide the agent
576        let prompt = generate_prompt_for_strategy(
577            strategy,
578            ctx.working_diff,
579            ctx.template_context,
580            xsd_error.as_deref(),
581        );
582        let prompt_size_kb = prompt.len() / 1024;
583
584        // Create attempt log
585        let mut attempt_log = session.new_attempt(agent, strategy.description());
586        attempt_log.set_prompt_size(prompt.len());
587        attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
588
589        // Log retry attempt if not first attempt
590        if retry_num > 0 {
591            runtime.logger.info(&format!(
592                "  In-session retry {}/{} for XSD validation",
593                retry_num,
594                max_retries - 1
595            ));
596            if let Some(ref error) = xsd_error {
597                runtime.logger.info(&format!("  XSD error: {}", error));
598            }
599        } else {
600            log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
601        }
602
603        // Run the agent directly (without fallback)
604        let exit_code = match crate::pipeline::run_with_prompt(
605            &crate::pipeline::PromptCommand {
606                label: &format!("generate commit message ({})", strategy.description()),
607                display_name: agent,
608                cmd_str: &cmd_str,
609                prompt: &prompt,
610                logfile: &logfile,
611                parser_type: agent_config.json_parser,
612                env_vars: &agent_config.env_vars,
613            },
614            runtime,
615        ) {
616            Ok(result) => result.exit_code,
617            Err(e) => {
618                runtime.logger.error(&format!("Failed to run agent: {e}"));
619                attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
620                    "Agent execution failed: {e}"
621                )));
622                let _ = attempt_log.write_to_file(session.run_dir());
623                return None;
624            }
625        };
626
627        if exit_code != 0 {
628            runtime
629                .logger
630                .warn("Commit agent failed, checking logs for partial output...");
631        }
632
633        let extraction_result = extract_commit_message_from_logs_with_trace(
634            ctx.log_dir,
635            ctx.working_diff,
636            agent,
637            runtime.logger,
638            &mut attempt_log,
639        );
640
641        // Check if we got a valid commit message or need to retry for XSD errors
642        match &extraction_result {
643            Ok(Some(_)) => {
644                // XSD validation passed - we have a valid commit message
645                let result = handle_commit_extraction_result(
646                    extraction_result,
647                    strategy,
648                    ctx.log_dir,
649                    runtime,
650                    last_extraction,
651                    &mut attempt_log,
652                );
653
654                if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
655                    runtime
656                        .logger
657                        .warn(&format!("Failed to write attempt log: {e}"));
658                }
659
660                return result;
661            }
662            _ => {
663                // Extraction failed - continue to check for XSD errors for retry
664            }
665        };
666
667        // Check extraction attempts for XSD validation errors
668        let xsd_error_msg = attempt_log
669            .extraction_attempts
670            .iter()
671            .find(|attempt| attempt.detail.contains("XSD validation failed"))
672            .map(|attempt| attempt.detail.clone());
673
674        if let Some(ref error_msg) = xsd_error_msg {
675            runtime
676                .logger
677                .warn(&format!("  XSD validation failed: {}", error_msg));
678
679            if retry_num < max_retries - 1 {
680                // Extract just the error message (after "XSD validation failed: ")
681                let error = error_msg
682                    .strip_prefix("XSD validation failed: ")
683                    .unwrap_or(error_msg);
684
685                // Store error for next retry attempt
686                xsd_error = Some(error.to_string());
687
688                // Write attempt log but don't return yet
689                attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
690                let _ = attempt_log.write_to_file(session.run_dir());
691
692                // Continue to next retry iteration
693                continue;
694            } else {
695                // No more retries - fall through to handle as extraction failure
696                runtime
697                    .logger
698                    .warn("  No more in-session retries remaining");
699            }
700        }
701
702        // Handle extraction result (failure cases)
703        let result = handle_commit_extraction_result(
704            extraction_result,
705            strategy,
706            ctx.log_dir,
707            runtime,
708            last_extraction,
709            &mut attempt_log,
710        );
711
712        // Write the attempt log
713        if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
714            runtime
715                .logger
716                .warn(&format!("Failed to write attempt log: {e}"));
717        }
718
719        // If we got a result (success or hard error), return it
720        if result.is_some() {
721            return result;
722        }
723
724        // Otherwise, if this was a retry and we exhausted retries, break out
725        if retry_num >= max_retries - 1 {
726            break;
727        }
728
729        // For non-XSD errors, we don't retry in-session - move to next strategy
730        break;
731    }
732
733    None
734}
735
736/// Return the hardcoded fallback commit message as last resort.
737fn return_hardcoded_fallback(log_file: &str, runtime: &PipelineRuntime) -> CommitMessageResult {
738    runtime.logger.warn("");
739    runtime.logger.warn("All recovery methods failed:");
740    runtime.logger.warn("  - All 9 prompt variants exhausted");
741    runtime
742        .logger
743        .warn("  - All agents in fallback chain exhausted");
744    runtime.logger.warn("  - All truncation stages failed");
745    runtime.logger.warn("  - Emergency prompts failed");
746    runtime.logger.warn("");
747    runtime
748        .logger
749        .warn("Using hardcoded fallback commit message as last resort.");
750    runtime.logger.warn(&format!(
751        "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
752    ));
753    runtime.logger.warn("");
754
755    CommitMessageResult {
756        message: HARDCODED_FALLBACK_COMMIT.to_string(),
757        success: true,
758        _log_path: log_file.to_string(),
759    }
760}
761
762/// Generate a commit message using the standard agent pipeline with fallback.
763///
764/// This function uses the same `run_with_fallback()` pipeline as other phases,
765/// which provides:
766/// - Proper stdout/stderr logging
767/// - Configurable fallback chains
768/// - Retry logic with exponential backoff
769/// - Agent error classification
770///
771/// Multi-stage retry logic:
772/// 1. Try initial prompt
773/// 2. On fallback/empty result, try strict JSON prompt
774/// 3. On failure, try V2 strict prompt (with negative examples)
775/// 4. On failure, try ultra-minimal prompt
776/// 5. On failure, try emergency prompt
777/// 6. Only use hardcoded fallback after all prompt variants exhausted
778///
779/// # Agent Cycling Behavior
780///
781/// This function implements proper strategy-first cycling by trying each strategy
782/// with all agents before moving to the next strategy:
783/// - Strategy 1 (initial): Agent 1 → Agent 2 → Agent 3
784/// - Strategy 2 (strict JSON): Agent 1 → Agent 2 → Agent 3
785/// - Strategy 3 (strict JSON V2): Agent 1 → Agent 2 → Agent 3
786/// - etc.
787///
788/// This approach is more efficient because if a particular strategy works well
789/// with any agent, we succeed quickly rather than exhausting all strategies
790/// on the first agent before trying others.
791///
792/// # Arguments
793///
794/// * `diff` - The git diff to generate a commit message for
795/// * `registry` - The agent registry for resolving agents and fallbacks
796/// * `runtime` - The pipeline runtime for execution services
797/// * `commit_agent` - The primary agent to use for commit generation
798///
799/// # Returns
800///
801/// Returns `Ok(CommitMessageResult)` with the generated message and metadata.
802pub fn generate_commit_message(
803    diff: &str,
804    registry: &AgentRegistry,
805    runtime: &mut PipelineRuntime,
806    commit_agent: &str,
807    template_context: &crate::prompts::TemplateContext,
808) -> anyhow::Result<CommitMessageResult> {
809    let log_dir = ".agent/logs/commit_generation";
810    let log_file = format!("{log_dir}/final.log");
811
812    fs::create_dir_all(log_dir)?;
813    runtime.logger.info("Generating commit message...");
814
815    // Create a logging session for this commit generation run
816    let mut session = create_commit_log_session(log_dir, runtime);
817    let (working_diff, diff_was_pre_truncated) =
818        check_and_pre_truncate_diff(diff, commit_agent, runtime);
819
820    let fallbacks = registry.available_fallbacks(AgentRole::Commit);
821    let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
822
823    let mut last_extraction: Option<CommitExtractionResult> = None;
824    let mut total_attempts = 0;
825
826    let attempt_ctx = CommitAttemptContext {
827        working_diff: &working_diff,
828        log_dir,
829        diff_was_truncated: diff_was_pre_truncated,
830        template_context,
831    };
832
833    // Try each agent with all prompt variants
834    if let Some(result) = try_agents_with_strategies(
835        &agents_to_try,
836        &attempt_ctx,
837        runtime,
838        registry,
839        &mut last_extraction,
840        &mut session,
841        &mut total_attempts,
842    ) {
843        log_completion(runtime, &session, total_attempts, &result);
844        return result;
845    }
846
847    // Handle fallback cases
848    let fallback_ctx = CommitFallbackContext {
849        log_file: &log_file,
850    };
851    handle_commit_fallbacks(
852        &fallback_ctx,
853        runtime,
854        &session,
855        total_attempts,
856        last_extraction.as_ref(),
857    )
858}
859
860/// Create a commit log session, with fallback.
861fn create_commit_log_session(log_dir: &str, runtime: &mut PipelineRuntime) -> CommitLogSession {
862    match CommitLogSession::new(log_dir) {
863        Ok(s) => {
864            runtime.logger.info(&format!(
865                "Commit logs will be written to: {}",
866                s.run_dir().display()
867            ));
868            s
869        }
870        Err(e) => {
871            runtime
872                .logger
873                .warn(&format!("Failed to create log session: {e}"));
874            CommitLogSession::new(log_dir).unwrap_or_else(|_| {
875                CommitLogSession::new("/tmp/ralph-commit-logs").expect("fallback session")
876            })
877        }
878    }
879}
880
881/// Try all agents with their strategy variants.
882///
883/// This function implements strategy-first cycling:
884/// - Outer loop: Iterate through strategies
885/// - Inner loop: Try all agents with the current strategy
886/// - Only advance to next strategy if ALL agents failed with current strategy
887///
888/// This ensures each strategy gets the best chance to succeed with all
889/// available agents before we try degraded fallback prompts.
890fn try_agents_with_strategies(
891    agents: &[&str],
892    ctx: &CommitAttemptContext<'_>,
893    runtime: &mut PipelineRuntime,
894    registry: &AgentRegistry,
895    last_extraction: &mut Option<CommitExtractionResult>,
896    session: &mut CommitLogSession,
897    total_attempts: &mut usize,
898) -> Option<anyhow::Result<CommitMessageResult>> {
899    let mut strategy = CommitRetryStrategy::Normal;
900    loop {
901        runtime.logger.info(&format!(
902            "Trying strategy {}/{}: {}",
903            strategy.stage_number(),
904            CommitRetryStrategy::total_stages(),
905            strategy.description()
906        ));
907
908        for (agent_idx, agent) in agents.iter().enumerate() {
909            runtime.logger.info(&format!(
910                "  - Agent {}/{}: {agent}",
911                agent_idx + 1,
912                agents.len()
913            ));
914
915            *total_attempts += 1;
916            if let Some(result) = run_commit_attempt_with_agent(
917                strategy,
918                ctx,
919                runtime,
920                registry,
921                agent,
922                last_extraction,
923                session,
924            ) {
925                return Some(result);
926            }
927        }
928
929        runtime.logger.warn(&format!(
930            "All agents failed for strategy: {}",
931            strategy.description()
932        ));
933
934        match strategy.next() {
935            Some(next) => strategy = next,
936            None => break,
937        }
938    }
939    None
940}
941
942/// Log completion info and write session summary on success.
943fn log_completion(
944    runtime: &mut PipelineRuntime,
945    session: &CommitLogSession,
946    total_attempts: usize,
947    result: &anyhow::Result<CommitMessageResult>,
948) {
949    if let Ok(ref commit_result) = result {
950        let _ = session.write_summary(
951            total_attempts,
952            &format!(
953                "SUCCESS: {}",
954                preview_commit_message(&commit_result.message)
955            ),
956        );
957    }
958    runtime.logger.info(&format!(
959        "Commit generation complete after {total_attempts} attempts. Logs: {}",
960        session.run_dir().display()
961    ));
962}
963
964/// Context for commit fallback handling.
965struct CommitFallbackContext<'a> {
966    log_file: &'a str,
967}
968
969/// Handle fallback cases after all agents exhausted.
970///
971/// With XSD validation handling everything, the fallback logic is simple:
972/// - If we have a last extraction with a valid message, use it
973/// - Otherwise, use the hardcoded fallback
974fn handle_commit_fallbacks(
975    ctx: &CommitFallbackContext<'_>,
976    runtime: &mut PipelineRuntime,
977    session: &CommitLogSession,
978    total_attempts: usize,
979    last_extraction: Option<&CommitExtractionResult>,
980) -> anyhow::Result<CommitMessageResult> {
981    // Use message from last extraction if available
982    // (XSD validation already passed if we have an extraction)
983    if let Some(extraction) = last_extraction {
984        let message = extraction.clone().into_message();
985        let _ = session.write_summary(
986            total_attempts,
987            &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
988        );
989        runtime.logger.info(&format!(
990            "Commit generation complete after {total_attempts} attempts. Logs: {}",
991            session.run_dir().display()
992        ));
993        return Ok(CommitMessageResult {
994            message,
995            success: true,
996            _log_path: ctx.log_file.to_string(),
997        });
998    }
999
1000    // Hardcoded fallback as last resort
1001    let _ = session.write_summary(
1002        total_attempts,
1003        &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1004    );
1005    runtime.logger.info(&format!(
1006        "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1007        session.run_dir().display()
1008    ));
1009    Ok(return_hardcoded_fallback(ctx.log_file, runtime))
1010}
1011
1012/// Create a commit with an automatically generated message using the standard pipeline.
1013///
1014/// This is a replacement for `commit_with_auto_message_fallback_result` in `git_helpers`
1015/// that uses the standard agent pipeline with proper logging and fallback support.
1016///
1017/// # Arguments
1018///
1019/// * `diff` - The git diff to generate a commit message from
1020/// * `commit_agent` - The primary agent to use for commit generation
1021/// * `git_user_name` - Optional git user name
1022/// * `git_user_email` - Optional git user email
1023/// * `ctx` - The phase context containing registry, logger, colors, config, and timer
1024///
1025/// # Returns
1026///
1027/// Returns `CommitResultFallback` indicating success, no changes, or failure.
1028pub fn commit_with_generated_message(
1029    diff: &str,
1030    commit_agent: &str,
1031    git_user_name: Option<&str>,
1032    git_user_email: Option<&str>,
1033    ctx: &mut PhaseContext<'_>,
1034) -> CommitResultFallback {
1035    // Stage all changes first
1036    let staged = match git_add_all() {
1037        Ok(s) => s,
1038        Err(e) => {
1039            return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1040        }
1041    };
1042
1043    if !staged {
1044        return CommitResultFallback::NoChanges;
1045    }
1046
1047    // Set up the runtime
1048    let mut runtime = PipelineRuntime {
1049        timer: ctx.timer,
1050        logger: ctx.logger,
1051        colors: ctx.colors,
1052        config: ctx.config,
1053        #[cfg(any(test, feature = "test-utils"))]
1054        agent_executor: None,
1055    };
1056
1057    // Generate commit message using the standard pipeline
1058    let result = match generate_commit_message(
1059        diff,
1060        ctx.registry,
1061        &mut runtime,
1062        commit_agent,
1063        ctx.template_context,
1064    ) {
1065        Ok(r) => r,
1066        Err(e) => {
1067            return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1068        }
1069    };
1070
1071    // Check if generation succeeded
1072    if !result.success || result.message.trim().is_empty() {
1073        // This should never happen after our fixes, but add defensive fallback
1074        ctx.logger
1075            .warn("Commit generation returned empty message, using hardcoded fallback...");
1076        let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1077        match git_commit(&fallback_message, git_user_name, git_user_email) {
1078            Ok(Some(oid)) => CommitResultFallback::Success(oid),
1079            Ok(None) => CommitResultFallback::NoChanges,
1080            Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1081        }
1082    } else {
1083        // Create the commit with the generated message
1084        match git_commit(&result.message, git_user_name, git_user_email) {
1085            Ok(Some(oid)) => CommitResultFallback::Success(oid),
1086            Ok(None) => CommitResultFallback::NoChanges,
1087            Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1088        }
1089    }
1090}
1091
1092/// Import types needed for parsing trace helpers.
1093use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1094
1095/// Write parsing trace log to file with error handling.
1096fn write_parsing_trace_with_logging(
1097    parsing_trace: &ParsingTraceLog,
1098    log_dir: &str,
1099    logger: &Logger,
1100) {
1101    if let Err(e) = parsing_trace.write_to_file(std::path::Path::new(log_dir)) {
1102        logger.warn(&format!("Failed to write parsing trace log: {e}"));
1103    }
1104}
1105
1106/// Try XML extraction and record in parsing trace.
1107/// Returns `Some(result)` if extraction succeeded (XSD validation passed), `None` otherwise.
1108fn try_xml_extraction_traced(
1109    content: &str,
1110    step_number: &mut usize,
1111    parsing_trace: &mut ParsingTraceLog,
1112    logger: &Logger,
1113    attempt_log: &mut CommitAttemptLog,
1114    log_dir: &str,
1115) -> Option<CommitExtractionResult> {
1116    let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(content);
1117    logger.info(&format!("  ✓ XML extraction: {xml_detail}"));
1118
1119    parsing_trace.add_step(
1120        ParsingTraceStep::new(*step_number, "XML Extraction")
1121            .with_input(&content[..content.len().min(1000)])
1122            .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1123            .with_success(xml_result.is_some())
1124            .with_details(&xml_detail),
1125    );
1126    *step_number += 1;
1127
1128    if let Some(message) = xml_result {
1129        // XSD validation already passed inside try_extract_xml_commit_with_trace
1130        attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1131        parsing_trace.set_final_message(&message);
1132        write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1133        return Some(CommitExtractionResult::new(message));
1134    }
1135
1136    // XML extraction or XSD validation failed
1137    attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1138    logger.info("  ✗ XML extraction failed");
1139    None
1140}
1141
1142/// Extract a commit message from agent logs with full tracing for diagnostics.
1143///
1144/// Records all extraction attempts in the provided `CommitAttemptLog` for debugging.
1145///
1146/// This function also creates and writes a `ParsingTraceLog` that captures
1147/// detailed information about each extraction step, including the exact
1148/// content being processed and XSD validation results.
1149fn extract_commit_message_from_logs_with_trace(
1150    log_dir: &str,
1151    _diff: &str,
1152    _agent_cmd: &str,
1153    logger: &Logger,
1154    attempt_log: &mut CommitAttemptLog,
1155) -> anyhow::Result<Option<CommitExtractionResult>> {
1156    // Create parsing trace log
1157    let mut parsing_trace = ParsingTraceLog::new(
1158        attempt_log.attempt_number,
1159        &attempt_log.agent,
1160        &attempt_log.strategy,
1161    );
1162
1163    // Read and preprocess log content
1164    let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1165        return Ok(None);
1166    };
1167
1168    // Set raw output in parsing trace
1169    parsing_trace.set_raw_output(&content);
1170
1171    let mut step_number = 1;
1172
1173    // XML-only extraction with XSD validation
1174    // The XML extraction includes flexible parsing with 4 strategies and XSD validation
1175    // If XSD validation fails, the error is returned for in-session retry
1176    if let Some(result) = try_xml_extraction_traced(
1177        &content,
1178        &mut step_number,
1179        &mut parsing_trace,
1180        logger,
1181        attempt_log,
1182        log_dir,
1183    ) {
1184        return Ok(Some(result));
1185    }
1186
1187    // XML extraction failed - add final failure step to parsing trace
1188    parsing_trace.add_step(
1189        ParsingTraceStep::new(step_number, "XML Extraction Failed")
1190            .with_input(&content[..content.len().min(1000)])
1191            .with_success(false)
1192            .with_details("No valid XML found or XSD validation failed"),
1193    );
1194
1195    write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1196
1197    // Return None to trigger next strategy/agent fallback
1198    // The in-session retry loop will have already attempted XSD validation retries
1199    // if the error was an XSD validation failure (detected in attempt_log)
1200    Ok(None)
1201}
1202
1203/// Read and preprocess log content for extraction.
1204fn read_log_content_with_trace(
1205    log_dir: &str,
1206    logger: &Logger,
1207    attempt_log: &mut CommitAttemptLog,
1208) -> anyhow::Result<Option<String>> {
1209    let log_path = find_most_recent_log(log_dir)?;
1210    let Some(log_file) = log_path else {
1211        logger.warn("No log files found in commit generation directory");
1212        attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1213            "File",
1214            "No log files found".to_string(),
1215        ));
1216        return Ok(None);
1217    };
1218
1219    logger.info(&format!(
1220        "Reading commit message from log: {}",
1221        log_file.display()
1222    ));
1223
1224    let mut content = String::new();
1225    let mut file = File::open(&log_file)?;
1226    file.read_to_string(&mut content)?;
1227    attempt_log.set_raw_output(&content);
1228
1229    if content.trim().is_empty() {
1230        logger.warn("Log file is empty");
1231        attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1232            "File",
1233            "Log file is empty".to_string(),
1234        ));
1235        return Ok(None);
1236    }
1237
1238    // Apply preprocessing
1239    Ok(Some(preprocess_raw_content(&content)))
1240}
1241
1242/// Find the most recently modified log file in a directory or matching a prefix pattern.
1243///
1244/// Supports two modes:
1245/// 1. **Directory mode**: If `log_path` is a directory, find the most recent `.log` file in it
1246/// 2. **Prefix mode**: If `log_path` is not a directory, treat it as a prefix pattern and
1247///    search for files matching `{prefix}*.log` in the parent directory
1248///
1249/// # Arguments
1250///
1251/// * `log_path` - Either a directory path or a prefix pattern for log files
1252///
1253/// # Returns
1254///
1255/// * `Ok(Some(path))` - Path to the most recent log file
1256/// * `Ok(None)` - No log files found
1257/// * `Err(e)` - Error reading directory
1258fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1259    let path = std::path::PathBuf::from(log_path);
1260
1261    // Mode 1: If path is a directory, search for .log files with empty prefix (matches all)
1262    if path.is_dir() {
1263        return find_most_recent_log_with_prefix(&path, "");
1264    }
1265
1266    // Mode 2: Prefix pattern mode - search parent directory for files starting with the prefix
1267    let parent_dir = match path.parent() {
1268        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1269        _ => std::path::PathBuf::from("."),
1270    };
1271
1272    if !parent_dir.exists() {
1273        return Ok(None);
1274    }
1275
1276    let base_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1277
1278    find_most_recent_log_with_prefix(&parent_dir, base_name)
1279}
1280
1281/// Find the most recently modified log file matching a prefix pattern.
1282///
1283/// Only matches `.log` files that start with `prefix`. If `prefix` is empty,
1284/// matches any `.log` file in the directory.
1285fn find_most_recent_log_with_prefix(
1286    dir: &std::path::Path,
1287    prefix: &str,
1288) -> anyhow::Result<Option<std::path::PathBuf>> {
1289    if !dir.exists() {
1290        return Ok(None);
1291    }
1292
1293    let entries = fs::read_dir(dir)?;
1294    let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1295
1296    for entry in entries.flatten() {
1297        let path = entry.path();
1298
1299        // Only look at .log files that start with the prefix (or any .log file if prefix is empty)
1300        if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
1301            if !file_name.starts_with(prefix)
1302                || path.extension().and_then(|s| s.to_str()) != Some("log")
1303            {
1304                continue;
1305            }
1306        } else {
1307            continue;
1308        }
1309
1310        if let Ok(metadata) = entry.metadata() {
1311            if let Ok(modified) = metadata.modified() {
1312                match &most_recent {
1313                    None => {
1314                        most_recent = Some((path, modified));
1315                    }
1316                    Some((_, prev_modified)) if modified > *prev_modified => {
1317                        most_recent = Some((path, modified));
1318                    }
1319                    _ => {}
1320                }
1321            }
1322        }
1323    }
1324
1325    Ok(most_recent.map(|(path, _)| path))
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::*;
1331
1332    #[test]
1333    fn test_find_most_recent_log() {
1334        // Test with non-existent directory
1335        let result = find_most_recent_log("/nonexistent/path");
1336        assert!(result.is_ok());
1337        assert!(result.unwrap().is_none());
1338    }
1339
1340    #[test]
1341    fn test_truncate_diff_if_large() {
1342        let large_diff = "a".repeat(100_000);
1343        let truncated = truncate_diff_if_large(&large_diff, 10_000);
1344
1345        // Should be truncated
1346        assert!(truncated.len() < large_diff.len());
1347    }
1348
1349    #[test]
1350    fn test_truncate_preserves_small_diffs() {
1351        let small_diff = "a".repeat(100);
1352        let truncated = truncate_diff_if_large(&small_diff, 10_000);
1353
1354        // Should not be modified
1355        assert_eq!(truncated, small_diff);
1356    }
1357
1358    #[test]
1359    fn test_truncate_exactly_at_limit() {
1360        let diff = "a".repeat(10_000);
1361        let truncated = truncate_diff_if_large(&diff, 10_000);
1362
1363        // Should not be modified when at exact limit
1364        assert_eq!(truncated, diff);
1365    }
1366
1367    #[test]
1368    fn test_truncate_preserves_file_boundaries() {
1369        let diff = "diff --git a/file1.rs b/file1.rs\n\
1370            +line1\n\
1371            +line2\n\
1372            diff --git a/file2.rs b/file2.rs\n\
1373            +line3\n\
1374            +line4\n";
1375        let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1376        let truncated = truncate_diff_if_large(&large_diff, 50);
1377
1378        // Should preserve complete file blocks
1379        assert!(truncated.contains("diff --git"));
1380        // Should contain truncation summary
1381        assert!(truncated.contains("Diff truncated"));
1382    }
1383
1384    #[test]
1385    fn test_prioritize_file_path() {
1386        // Source files get highest priority
1387        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1388        assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1389
1390        // Tests get lower priority than src
1391        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1392
1393        // Config files get medium priority
1394        assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1395
1396        // Docs get lowest priority
1397        assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1398    }
1399
1400    #[test]
1401    fn test_truncate_keeps_high_priority_files() {
1402        let diff = "diff --git a/README.md b/README.md\n\
1403            +doc change\n\
1404            diff --git a/src/main.rs b/src/main.rs\n\
1405            +important change\n\
1406            diff --git a/tests/test.rs b/tests/test.rs\n\
1407            +test change\n";
1408
1409        // With a very small limit, should keep src/main.rs first
1410        let truncated = truncate_diff_if_large(diff, 80);
1411
1412        // Should include the high priority src file
1413        assert!(truncated.contains("src/main.rs"));
1414    }
1415
1416    #[test]
1417    fn test_truncate_lines_to_fit() {
1418        let lines = vec![
1419            "line1".to_string(),
1420            "line2".to_string(),
1421            "line3".to_string(),
1422            "line4".to_string(),
1423        ];
1424
1425        // Only fit first 3 lines (each 5 chars + newline = 6)
1426        let truncated = truncate_lines_to_fit(&lines, 18);
1427
1428        assert_eq!(truncated.len(), 3);
1429        // Last line should have truncation marker
1430        assert!(truncated[2].ends_with("[truncated...]"));
1431    }
1432
1433    #[test]
1434    fn test_hardcoded_fallback_commit() {
1435        // The hardcoded fallback should always be a valid conventional commit
1436        use crate::files::llm_output_extraction::is_conventional_commit_subject;
1437        assert!(
1438            is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1439            "Hardcoded fallback must be a valid conventional commit"
1440        );
1441        assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1442        assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1443    }
1444}