1use super::commit_logging::{
14 AttemptOutcome, CommitAttemptLog, CommitLogSession, ExtractionAttempt,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::checkpoint::execution_history::{ExecutionStep, StepOutcome};
19use crate::files::llm_output_extraction::{
20 preprocess_raw_content, try_extract_xml_commit_with_trace, CommitExtractionResult,
21};
22use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
23use crate::logger::Logger;
24use crate::pipeline::PipelineRuntime;
25use crate::prompts::{
26 get_stored_or_generate_prompt, prompt_generate_commit_message_with_diff_with_context,
27 prompt_simplified_commit_with_context, prompt_xsd_retry_with_context,
28};
29use std::collections::HashMap;
30use std::fmt;
31use std::fs::{self, File};
32use std::io::Read;
33
34fn preview_commit_message(msg: &str) -> String {
36 let first_line = msg.lines().next().unwrap_or(msg);
37 if first_line.len() > 60 {
38 format!("{}...", &first_line[..60])
39 } else {
40 first_line.to_string()
41 }
42}
43
44const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
55
56pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
67
68fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
81 let agent_lower = commit_agent.to_lowercase();
82
83 if agent_lower.contains("glm")
85 || agent_lower.contains("zhipuai")
86 || agent_lower.contains("zai")
87 || agent_lower.contains("qwen")
88 || agent_lower.contains("deepseek")
89 {
90 100_000 } else if agent_lower.contains("claude")
92 || agent_lower.contains("ccs")
93 || agent_lower.contains("anthropic")
94 {
95 300_000 } else {
97 MAX_SAFE_PROMPT_SIZE }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113enum CommitRetryStrategy {
114 Normal,
116 Simplified,
118}
119
120impl CommitRetryStrategy {
121 const fn description(self) -> &'static str {
123 match self {
124 Self::Normal => "normal XML prompt",
125 Self::Simplified => "simplified XML prompt",
126 }
127 }
128
129 const fn next(self) -> Option<Self> {
131 match self {
132 Self::Normal => Some(Self::Simplified),
133 Self::Simplified => None,
134 }
135 }
136
137 const fn stage_number(self) -> usize {
139 match self {
140 Self::Normal => 1,
141 Self::Simplified => 2,
142 }
143 }
144
145 const fn total_stages() -> usize {
147 2 }
149
150 const fn max_session_retries(self) -> usize {
152 match self {
153 Self::Normal => 10, Self::Simplified => 10, }
156 }
157}
158
159impl fmt::Display for CommitRetryStrategy {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 write!(f, "{}", self.description())
162 }
163}
164
165pub struct CommitMessageResult {
167 pub message: String,
169 pub success: bool,
171 pub _log_path: String,
173 pub generated_prompts: std::collections::HashMap<String, String>,
176}
177
178fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
191 if diff.len() <= max_size {
192 return diff.to_string();
193 }
194
195 let mut files: Vec<DiffFile> = Vec::new();
197 let mut current_file = DiffFile::default();
198 let mut in_file = false;
199
200 for line in diff.lines() {
201 if line.starts_with("diff --git ") {
202 if in_file && !current_file.lines.is_empty() {
204 files.push(std::mem::take(&mut current_file));
205 }
206 in_file = true;
207 current_file.lines.push(line.to_string());
208
209 if let Some(path) = line.split(" b/").nth(1) {
211 current_file.path = path.to_string();
212 current_file.priority = prioritize_file_path(path);
213 }
214 } else if in_file {
215 current_file.lines.push(line.to_string());
216 }
217 }
218
219 if in_file && !current_file.lines.is_empty() {
221 files.push(current_file);
222 }
223
224 let total_files = files.len();
225
226 files.sort_by_key(|f| std::cmp::Reverse(f.priority));
228
229 let mut selected_files = Vec::new();
231 let mut current_size = 0;
232
233 for file in files {
234 let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); if current_size + file_size <= max_size {
237 current_size += file_size;
238 selected_files.push(file);
239 } else if current_size > 0 {
240 break;
243 } else {
244 let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
247 selected_files.push(DiffFile {
248 path: file.path,
249 priority: file.priority,
250 lines: truncated_lines,
251 });
252 break;
253 }
254 }
255
256 let selected_count = selected_files.len();
257 let omitted_count = total_files.saturating_sub(selected_count);
258
259 let mut result = String::new();
261
262 if omitted_count > 0 {
264 use std::fmt::Write;
265 let _ = write!(
266 result,
267 "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
268 );
269 }
270
271 for file in selected_files {
272 for line in &file.lines {
273 result.push_str(line);
274 result.push('\n');
275 }
276 }
277
278 result
279}
280
281#[derive(Debug, Default, Clone)]
283struct DiffFile {
284 path: String,
286 priority: i32,
288 lines: Vec<String>,
290}
291
292fn prioritize_file_path(path: &str) -> i32 {
302 use std::path::Path;
303 let path_lower = path.to_lowercase();
304
305 let has_ext = |ext: &str| -> bool {
307 Path::new(path)
308 .extension()
309 .and_then(std::ffi::OsStr::to_str)
310 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
311 };
312
313 let has_ext_lower = |ext: &str| -> bool {
315 Path::new(&path_lower)
316 .extension()
317 .and_then(std::ffi::OsStr::to_str)
318 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
319 };
320
321 if path_lower.contains("src/") && has_ext_lower("rs") {
323 100
324 } else if path_lower.contains("src/") {
325 80
326 }
327 else if path_lower.contains("test") {
329 40
330 }
331 else if has_ext("toml")
333 || has_ext("json")
334 || path_lower.ends_with("cargo.toml")
335 || path_lower.ends_with("package.json")
336 || path_lower.ends_with("tsconfig.json")
337 {
338 60
339 }
340 else if path_lower.contains("doc") || has_ext("md") {
342 20
343 }
344 else {
346 50
347 }
348}
349
350fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
355 let mut result = Vec::new();
356 let mut current_size = 0;
357
358 for line in lines {
359 let line_size = line.len() + 1; if current_size + line_size <= max_size {
361 current_size += line_size;
362 result.push(line.clone());
363 } else {
364 break;
365 }
366 }
367
368 if let Some(last) = result.last_mut() {
370 last.push_str(" [truncated...]");
371 }
372
373 result
374}
375
376fn check_and_pre_truncate_diff(
380 diff: &str,
381 commit_agent: &str,
382 runtime: &PipelineRuntime,
383) -> (String, bool) {
384 let max_size = max_prompt_size_for_agent(commit_agent);
385 if diff.len() > max_size {
386 runtime.logger.warn(&format!(
387 "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
388 diff.len() / 1024,
389 max_size / 1024
390 ));
391 (truncate_diff_if_large(diff, max_size), true)
392 } else {
393 runtime.logger.info(&format!(
394 "Diff size ({} KB) is within safe limit ({} KB).",
395 diff.len() / 1024,
396 max_size / 1024
397 ));
398 (diff.to_string(), false)
399 }
400}
401
402fn generate_prompt_for_strategy(
410 strategy: CommitRetryStrategy,
411 working_diff: &str,
412 template_context: &crate::prompts::TemplateContext,
413 xsd_error: Option<&str>,
414 prompt_history: &HashMap<String, String>,
415 prompt_key: &str,
416) -> (String, bool) {
417 let full_prompt_key = if xsd_error.is_some() {
420 format!("{}_xsd_retry", prompt_key)
421 } else {
422 prompt_key.to_string()
423 };
424
425 let (prompt, was_replayed) =
426 get_stored_or_generate_prompt(&full_prompt_key, prompt_history, || match strategy {
427 CommitRetryStrategy::Normal => {
428 if let Some(error_msg) = xsd_error {
429 prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
431 } else {
432 prompt_generate_commit_message_with_diff_with_context(
434 template_context,
435 working_diff,
436 )
437 }
438 }
439 CommitRetryStrategy::Simplified => {
440 if let Some(error_msg) = xsd_error {
441 prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
443 } else {
444 prompt_simplified_commit_with_context(template_context, working_diff)
446 }
447 }
448 });
449
450 (prompt, was_replayed)
451}
452
453fn log_commit_attempt(
455 strategy: CommitRetryStrategy,
456 prompt_size_kb: usize,
457 commit_agent: &str,
458 runtime: &PipelineRuntime,
459) {
460 if strategy == CommitRetryStrategy::Normal {
461 runtime.logger.info(&format!(
462 "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
463 CommitRetryStrategy::total_stages(),
464 strategy,
465 prompt_size_kb,
466 commit_agent
467 ));
468 } else {
469 runtime.logger.warn(&format!(
470 "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
471 strategy as usize + 1,
472 CommitRetryStrategy::total_stages(),
473 strategy,
474 prompt_size_kb,
475 commit_agent
476 ));
477 }
478}
479
480fn handle_commit_extraction_result(
489 extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
490 strategy: CommitRetryStrategy,
491 log_dir: &str,
492 runtime: &PipelineRuntime,
493 last_extraction: &mut Option<CommitExtractionResult>,
494 attempt_log: &mut CommitAttemptLog,
495) -> Option<anyhow::Result<CommitMessageResult>> {
496 let log_file = format!("{log_dir}/final.log");
497
498 match extraction_result {
499 Ok(Some(extraction)) => {
500 runtime.logger.info(&format!(
502 "Successfully extracted commit message with {strategy}"
503 ));
504 let message = extraction.clone().into_message();
505 attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
506 *last_extraction = Some(extraction);
507 Some(Ok(CommitMessageResult {
509 message,
510 success: true,
511 _log_path: log_file,
512 generated_prompts: std::collections::HashMap::new(),
513 }))
514 }
515 Ok(None) => {
516 runtime.logger.warn(&format!(
517 "No valid commit message extracted with {strategy}, will try next strategy"
518 ));
519 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
520 "No valid commit message extracted".to_string(),
521 ));
522 None }
524 Err(e) => {
525 runtime.logger.error(&format!(
526 "Failed to extract commit message with {strategy}: {e}"
527 ));
528 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
529 None }
531 }
532}
533
534fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
539 let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
540 for fb in fallbacks {
541 if *fb != primary_agent && !agents_to_try.contains(fb) {
542 agents_to_try.push(fb);
543 }
544 }
545 agents_to_try
546}
547
548struct CommitAttemptContext<'a> {
550 working_diff: &'a str,
552 log_dir: &'a str,
554 diff_was_truncated: bool,
556 template_context: &'a crate::prompts::TemplateContext,
558 prompt_history: &'a HashMap<String, String>,
560 prompt_key: String,
562 generated_prompts: &'a mut std::collections::HashMap<String, String>,
565}
566
567fn run_commit_attempt_with_agent(
574 strategy: CommitRetryStrategy,
575 ctx: &mut CommitAttemptContext<'_>,
576 runtime: &mut PipelineRuntime,
577 registry: &AgentRegistry,
578 agent: &str,
579 last_extraction: &mut Option<CommitExtractionResult>,
580 session: &mut CommitLogSession,
581) -> Option<anyhow::Result<CommitMessageResult>> {
582 let Some(agent_config) = registry.resolve_config(agent) else {
584 runtime
585 .logger
586 .warn(&format!("Agent '{agent}' not found in registry, skipping"));
587 let mut attempt_log = session.new_attempt(agent, strategy.description());
588 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
589 "Agent '{agent}' not found in registry"
590 )));
591 let _ = attempt_log.write_to_file(session.run_dir());
592 return None;
593 };
594
595 let cmd_str = agent_config.build_cmd(true, true, false);
597 let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
598
599 let max_retries = strategy.max_session_retries();
601 let mut xsd_error: Option<String> = None;
602
603 for retry_num in 0..max_retries {
604 let prompt_key = format!("{}_{}", ctx.prompt_key, strategy.stage_number());
608 let (prompt, was_replayed) = generate_prompt_for_strategy(
609 strategy,
610 ctx.working_diff,
611 ctx.template_context,
612 xsd_error.as_deref(),
613 ctx.prompt_history,
614 &prompt_key,
615 );
616 let prompt_size_kb = prompt.len() / 1024;
617
618 if was_replayed && retry_num == 0 {
620 runtime.logger.info(&format!(
621 "Using stored prompt from checkpoint for determinism: {}",
622 prompt_key
623 ));
624 } else if !was_replayed {
625 ctx.generated_prompts
627 .insert(prompt_key.clone(), prompt.clone());
628 }
629
630 let mut attempt_log = session.new_attempt(agent, strategy.description());
632 attempt_log.set_prompt_size(prompt.len());
633 attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
634
635 if retry_num > 0 {
637 runtime.logger.info(&format!(
638 " In-session retry {}/{} for XSD validation",
639 retry_num,
640 max_retries - 1
641 ));
642 if let Some(ref error) = xsd_error {
643 runtime.logger.info(&format!(" XSD error: {}", error));
644 }
645 } else {
646 log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
647 }
648
649 let exit_code = match crate::pipeline::run_with_prompt(
651 &crate::pipeline::PromptCommand {
652 label: &format!("generate commit message ({})", strategy.description()),
653 display_name: agent,
654 cmd_str: &cmd_str,
655 prompt: &prompt,
656 logfile: &logfile,
657 parser_type: agent_config.json_parser,
658 env_vars: &agent_config.env_vars,
659 },
660 runtime,
661 ) {
662 Ok(result) => result.exit_code,
663 Err(e) => {
664 runtime.logger.error(&format!("Failed to run agent: {e}"));
665 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
666 "Agent execution failed: {e}"
667 )));
668 let _ = attempt_log.write_to_file(session.run_dir());
669 return None;
670 }
671 };
672
673 if exit_code != 0 {
674 runtime
675 .logger
676 .warn("Commit agent failed, checking logs for partial output...");
677 }
678
679 let extraction_result = extract_commit_message_from_logs_with_trace(
680 ctx.log_dir,
681 ctx.working_diff,
682 agent,
683 runtime.logger,
684 &mut attempt_log,
685 );
686
687 match &extraction_result {
689 Ok(Some(_)) => {
690 let result = handle_commit_extraction_result(
692 extraction_result,
693 strategy,
694 ctx.log_dir,
695 runtime,
696 last_extraction,
697 &mut attempt_log,
698 );
699
700 if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
701 runtime
702 .logger
703 .warn(&format!("Failed to write attempt log: {e}"));
704 }
705
706 return result;
707 }
708 _ => {
709 }
711 };
712
713 let xsd_error_msg = attempt_log
715 .extraction_attempts
716 .iter()
717 .find(|attempt| attempt.detail.contains("XSD validation failed"))
718 .map(|attempt| attempt.detail.clone());
719
720 if let Some(ref error_msg) = xsd_error_msg {
721 runtime
722 .logger
723 .warn(&format!(" XSD validation failed: {}", error_msg));
724
725 if retry_num < max_retries - 1 {
726 let error = error_msg
728 .strip_prefix("XSD validation failed: ")
729 .unwrap_or(error_msg);
730
731 xsd_error = Some(error.to_string());
733
734 attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
736 let _ = attempt_log.write_to_file(session.run_dir());
737
738 continue;
740 } else {
741 runtime
743 .logger
744 .warn(" No more in-session retries remaining");
745 }
746 }
747
748 let result = handle_commit_extraction_result(
750 extraction_result,
751 strategy,
752 ctx.log_dir,
753 runtime,
754 last_extraction,
755 &mut attempt_log,
756 );
757
758 if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
760 runtime
761 .logger
762 .warn(&format!("Failed to write attempt log: {e}"));
763 }
764
765 if result.is_some() {
767 return result;
768 }
769
770 if retry_num >= max_retries - 1 {
772 break;
773 }
774
775 break;
777 }
778
779 None
780}
781
782fn return_hardcoded_fallback(
784 log_file: &str,
785 runtime: &PipelineRuntime,
786 generated_prompts: std::collections::HashMap<String, String>,
787) -> CommitMessageResult {
788 runtime.logger.warn("");
789 runtime.logger.warn("All recovery methods failed:");
790 runtime.logger.warn(" - All 9 prompt variants exhausted");
791 runtime
792 .logger
793 .warn(" - All agents in fallback chain exhausted");
794 runtime.logger.warn(" - All truncation stages failed");
795 runtime.logger.warn(" - Emergency prompts failed");
796 runtime.logger.warn("");
797 runtime
798 .logger
799 .warn("Using hardcoded fallback commit message as last resort.");
800 runtime.logger.warn(&format!(
801 "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
802 ));
803 runtime.logger.warn("");
804
805 CommitMessageResult {
806 message: HARDCODED_FALLBACK_COMMIT.to_string(),
807 success: true,
808 _log_path: log_file.to_string(),
809 generated_prompts,
810 }
811}
812
813pub fn generate_commit_message(
856 diff: &str,
857 registry: &AgentRegistry,
858 runtime: &mut PipelineRuntime,
859 commit_agent: &str,
860 template_context: &crate::prompts::TemplateContext,
861 prompt_history: &HashMap<String, String>,
862) -> anyhow::Result<CommitMessageResult> {
863 let log_dir = ".agent/logs/commit_generation";
864 let log_file = format!("{log_dir}/final.log");
865
866 fs::create_dir_all(log_dir)?;
867 runtime.logger.info("Generating commit message...");
868
869 let mut session = create_commit_log_session(log_dir, runtime);
871 let (working_diff, diff_was_pre_truncated) =
872 check_and_pre_truncate_diff(diff, commit_agent, runtime);
873
874 let fallbacks = registry.available_fallbacks(AgentRole::Commit);
875 let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
876
877 let mut last_extraction: Option<CommitExtractionResult> = None;
878 let mut total_attempts = 0;
879
880 let prompt_key = format!(
883 "commit_{}",
884 std::time::SystemTime::now()
885 .duration_since(std::time::UNIX_EPOCH)
886 .unwrap_or_default()
887 .as_secs()
888 );
889
890 let mut generated_prompts = std::collections::HashMap::new();
892
893 let mut attempt_ctx = CommitAttemptContext {
894 working_diff: &working_diff,
895 log_dir,
896 diff_was_truncated: diff_was_pre_truncated,
897 template_context,
898 prompt_history,
899 prompt_key,
900 generated_prompts: &mut generated_prompts,
901 };
902
903 if let Some(result) = try_agents_with_strategies(
905 &agents_to_try,
906 &mut attempt_ctx,
907 runtime,
908 registry,
909 &mut last_extraction,
910 &mut session,
911 &mut total_attempts,
912 ) {
913 log_completion(runtime, &session, total_attempts, &result);
914 return result.map(|mut r| {
916 r.generated_prompts = generated_prompts;
917 r
918 });
919 }
920
921 let fallback_ctx = CommitFallbackContext {
923 log_file: &log_file,
924 };
925 handle_commit_fallbacks(
926 &fallback_ctx,
927 runtime,
928 &session,
929 total_attempts,
930 last_extraction.as_ref(),
931 generated_prompts,
932 )
933}
934
935fn create_commit_log_session(log_dir: &str, runtime: &mut PipelineRuntime) -> CommitLogSession {
937 match CommitLogSession::new(log_dir) {
938 Ok(s) => {
939 runtime.logger.info(&format!(
940 "Commit logs will be written to: {}",
941 s.run_dir().display()
942 ));
943 s
944 }
945 Err(e) => {
946 runtime
947 .logger
948 .warn(&format!("Failed to create log session: {e}"));
949 CommitLogSession::new(log_dir).unwrap_or_else(|_| {
950 CommitLogSession::new("/tmp/ralph-commit-logs").expect("fallback session")
951 })
952 }
953 }
954}
955
956fn try_agents_with_strategies(
966 agents: &[&str],
967 ctx: &mut CommitAttemptContext<'_>,
968 runtime: &mut PipelineRuntime,
969 registry: &AgentRegistry,
970 last_extraction: &mut Option<CommitExtractionResult>,
971 session: &mut CommitLogSession,
972 total_attempts: &mut usize,
973) -> Option<anyhow::Result<CommitMessageResult>> {
974 let mut strategy = CommitRetryStrategy::Normal;
975 loop {
976 runtime.logger.info(&format!(
977 "Trying strategy {}/{}: {}",
978 strategy.stage_number(),
979 CommitRetryStrategy::total_stages(),
980 strategy.description()
981 ));
982
983 for (agent_idx, agent) in agents.iter().enumerate() {
984 runtime.logger.info(&format!(
985 " - Agent {}/{}: {agent}",
986 agent_idx + 1,
987 agents.len()
988 ));
989
990 *total_attempts += 1;
991 if let Some(result) = run_commit_attempt_with_agent(
992 strategy,
993 ctx,
994 runtime,
995 registry,
996 agent,
997 last_extraction,
998 session,
999 ) {
1000 return Some(result);
1001 }
1002 }
1003
1004 runtime.logger.warn(&format!(
1005 "All agents failed for strategy: {}",
1006 strategy.description()
1007 ));
1008
1009 match strategy.next() {
1010 Some(next) => strategy = next,
1011 None => break,
1012 }
1013 }
1014 None
1015}
1016
1017fn log_completion(
1019 runtime: &mut PipelineRuntime,
1020 session: &CommitLogSession,
1021 total_attempts: usize,
1022 result: &anyhow::Result<CommitMessageResult>,
1023) {
1024 if let Ok(ref commit_result) = result {
1025 let _ = session.write_summary(
1026 total_attempts,
1027 &format!(
1028 "SUCCESS: {}",
1029 preview_commit_message(&commit_result.message)
1030 ),
1031 );
1032 }
1033 runtime.logger.info(&format!(
1034 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1035 session.run_dir().display()
1036 ));
1037}
1038
1039struct CommitFallbackContext<'a> {
1041 log_file: &'a str,
1042}
1043
1044fn handle_commit_fallbacks(
1050 ctx: &CommitFallbackContext<'_>,
1051 runtime: &mut PipelineRuntime,
1052 session: &CommitLogSession,
1053 total_attempts: usize,
1054 last_extraction: Option<&CommitExtractionResult>,
1055 generated_prompts: std::collections::HashMap<String, String>,
1056) -> anyhow::Result<CommitMessageResult> {
1057 if let Some(extraction) = last_extraction {
1060 let message = extraction.clone().into_message();
1061 let _ = session.write_summary(
1062 total_attempts,
1063 &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
1064 );
1065 runtime.logger.info(&format!(
1066 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1067 session.run_dir().display()
1068 ));
1069 return Ok(CommitMessageResult {
1070 message,
1071 success: true,
1072 _log_path: ctx.log_file.to_string(),
1073 generated_prompts,
1074 });
1075 }
1076
1077 let _ = session.write_summary(
1079 total_attempts,
1080 &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1081 );
1082 runtime.logger.info(&format!(
1083 "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1084 session.run_dir().display()
1085 ));
1086 Ok(return_hardcoded_fallback(
1087 ctx.log_file,
1088 runtime,
1089 generated_prompts,
1090 ))
1091}
1092
1093pub fn commit_with_generated_message(
1110 diff: &str,
1111 commit_agent: &str,
1112 git_user_name: Option<&str>,
1113 git_user_email: Option<&str>,
1114 ctx: &mut PhaseContext<'_>,
1115) -> CommitResultFallback {
1116 let staged = match git_add_all() {
1118 Ok(s) => s,
1119 Err(e) => {
1120 return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1121 }
1122 };
1123
1124 if !staged {
1125 return CommitResultFallback::NoChanges;
1126 }
1127
1128 let start_time = std::time::Instant::now();
1130
1131 let mut runtime = PipelineRuntime {
1133 timer: ctx.timer,
1134 logger: ctx.logger,
1135 colors: ctx.colors,
1136 config: ctx.config,
1137 #[cfg(any(test, feature = "test-utils"))]
1138 agent_executor: None,
1139 };
1140
1141 let result = match generate_commit_message(
1143 diff,
1144 ctx.registry,
1145 &mut runtime,
1146 commit_agent,
1147 ctx.template_context,
1148 &ctx.prompt_history,
1149 ) {
1150 Ok(r) => r,
1151 Err(e) => {
1152 ctx.execution_history.add_step(
1154 ExecutionStep::new(
1155 "commit",
1156 0,
1157 "commit_generation",
1158 StepOutcome::failure(format!("Failed to generate commit message: {e}"), false),
1159 )
1160 .with_agent(commit_agent)
1161 .with_duration(start_time.elapsed().as_secs()),
1162 );
1163 return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1164 }
1165 };
1166
1167 for (key, prompt) in result.generated_prompts {
1169 ctx.capture_prompt(&key, &prompt);
1170 }
1171
1172 if !result.success || result.message.trim().is_empty() {
1174 ctx.logger
1176 .warn("Commit generation returned empty message, using hardcoded fallback...");
1177 let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1178 let commit_result = match git_commit(&fallback_message, git_user_name, git_user_email) {
1179 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1180 Ok(None) => CommitResultFallback::NoChanges,
1181 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1182 };
1183 let outcome = match &commit_result {
1185 CommitResultFallback::Success(oid) => StepOutcome::success(
1186 Some(format!("Commit created: {oid}")),
1187 vec![".".to_string()],
1188 ),
1189 CommitResultFallback::NoChanges => {
1190 StepOutcome::skipped("No changes to commit".to_string())
1191 }
1192 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1193 };
1194 ctx.execution_history.add_step(
1195 ExecutionStep::new("commit", 0, "commit_generation", outcome)
1196 .with_agent(commit_agent)
1197 .with_duration(start_time.elapsed().as_secs()),
1198 );
1199 commit_result
1200 } else {
1201 let commit_result = match git_commit(&result.message, git_user_name, git_user_email) {
1203 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1204 Ok(None) => CommitResultFallback::NoChanges,
1205 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1206 };
1207 let outcome = match &commit_result {
1209 CommitResultFallback::Success(oid) => StepOutcome::success(
1210 Some(format!("Commit created: {oid}")),
1211 vec![".".to_string()],
1212 ),
1213 CommitResultFallback::NoChanges => {
1214 StepOutcome::skipped("No changes to commit".to_string())
1215 }
1216 CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1217 };
1218 let oid_for_history = match &commit_result {
1219 CommitResultFallback::Success(oid) => Some(oid.to_string()),
1220 _ => None,
1221 };
1222 let mut step = ExecutionStep::new("commit", 0, "commit_generation", outcome)
1223 .with_agent(commit_agent)
1224 .with_duration(start_time.elapsed().as_secs());
1225 if let Some(ref oid) = oid_for_history {
1226 step = step.with_git_commit_oid(oid);
1227 }
1228 ctx.execution_history.add_step(step);
1229 commit_result
1230 }
1231}
1232
1233use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1235
1236fn write_parsing_trace_with_logging(
1238 parsing_trace: &ParsingTraceLog,
1239 log_dir: &str,
1240 logger: &Logger,
1241) {
1242 if let Err(e) = parsing_trace.write_to_file(std::path::Path::new(log_dir)) {
1243 logger.warn(&format!("Failed to write parsing trace log: {e}"));
1244 }
1245}
1246
1247fn try_xml_extraction_traced(
1250 content: &str,
1251 step_number: &mut usize,
1252 parsing_trace: &mut ParsingTraceLog,
1253 logger: &Logger,
1254 attempt_log: &mut CommitAttemptLog,
1255 log_dir: &str,
1256) -> Option<CommitExtractionResult> {
1257 let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(content);
1258 logger.info(&format!(" ✓ XML extraction: {xml_detail}"));
1259
1260 parsing_trace.add_step(
1261 ParsingTraceStep::new(*step_number, "XML Extraction")
1262 .with_input(&content[..content.len().min(1000)])
1263 .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1264 .with_success(xml_result.is_some())
1265 .with_details(&xml_detail),
1266 );
1267 *step_number += 1;
1268
1269 if let Some(message) = xml_result {
1270 attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1272 parsing_trace.set_final_message(&message);
1273 write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1274 return Some(CommitExtractionResult::new(message));
1275 }
1276
1277 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1279 logger.info(" ✗ XML extraction failed");
1280 None
1281}
1282
1283fn extract_commit_message_from_logs_with_trace(
1291 log_dir: &str,
1292 _diff: &str,
1293 _agent_cmd: &str,
1294 logger: &Logger,
1295 attempt_log: &mut CommitAttemptLog,
1296) -> anyhow::Result<Option<CommitExtractionResult>> {
1297 let mut parsing_trace = ParsingTraceLog::new(
1299 attempt_log.attempt_number,
1300 &attempt_log.agent,
1301 &attempt_log.strategy,
1302 );
1303
1304 let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1306 return Ok(None);
1307 };
1308
1309 parsing_trace.set_raw_output(&content);
1311
1312 let mut step_number = 1;
1313
1314 if let Some(result) = try_xml_extraction_traced(
1318 &content,
1319 &mut step_number,
1320 &mut parsing_trace,
1321 logger,
1322 attempt_log,
1323 log_dir,
1324 ) {
1325 return Ok(Some(result));
1326 }
1327
1328 parsing_trace.add_step(
1330 ParsingTraceStep::new(step_number, "XML Extraction Failed")
1331 .with_input(&content[..content.len().min(1000)])
1332 .with_success(false)
1333 .with_details("No valid XML found or XSD validation failed"),
1334 );
1335
1336 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1337
1338 Ok(None)
1342}
1343
1344fn read_log_content_with_trace(
1346 log_dir: &str,
1347 logger: &Logger,
1348 attempt_log: &mut CommitAttemptLog,
1349) -> anyhow::Result<Option<String>> {
1350 let log_path = find_most_recent_log(log_dir)?;
1351 let Some(log_file) = log_path else {
1352 logger.warn("No log files found in commit generation directory");
1353 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1354 "File",
1355 "No log files found".to_string(),
1356 ));
1357 return Ok(None);
1358 };
1359
1360 logger.info(&format!(
1361 "Reading commit message from log: {}",
1362 log_file.display()
1363 ));
1364
1365 let mut content = String::new();
1366 let mut file = File::open(&log_file)?;
1367 file.read_to_string(&mut content)?;
1368 attempt_log.set_raw_output(&content);
1369
1370 if content.trim().is_empty() {
1371 logger.warn("Log file is empty");
1372 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1373 "File",
1374 "Log file is empty".to_string(),
1375 ));
1376 return Ok(None);
1377 }
1378
1379 Ok(Some(preprocess_raw_content(&content)))
1381}
1382
1383fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1400 let path = std::path::PathBuf::from(log_path);
1401
1402 if path.is_dir() {
1404 return find_most_recent_log_with_prefix(&path, "");
1405 }
1406
1407 let parent_dir = match path.parent() {
1409 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1410 _ => std::path::PathBuf::from("."),
1411 };
1412
1413 if !parent_dir.exists() {
1414 return Ok(None);
1415 }
1416
1417 let base_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1418
1419 find_most_recent_log_with_prefix(&parent_dir, base_name)
1420}
1421
1422fn find_most_recent_log_with_prefix(
1427 dir: &std::path::Path,
1428 prefix: &str,
1429) -> anyhow::Result<Option<std::path::PathBuf>> {
1430 if !dir.exists() {
1431 return Ok(None);
1432 }
1433
1434 let entries = fs::read_dir(dir)?;
1435 let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1436
1437 for entry in entries.flatten() {
1438 let path = entry.path();
1439
1440 if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
1442 if !file_name.starts_with(prefix)
1443 || path.extension().and_then(|s| s.to_str()) != Some("log")
1444 {
1445 continue;
1446 }
1447 } else {
1448 continue;
1449 }
1450
1451 if let Ok(metadata) = entry.metadata() {
1452 if let Ok(modified) = metadata.modified() {
1453 match &most_recent {
1454 None => {
1455 most_recent = Some((path, modified));
1456 }
1457 Some((_, prev_modified)) if modified > *prev_modified => {
1458 most_recent = Some((path, modified));
1459 }
1460 _ => {}
1461 }
1462 }
1463 }
1464 }
1465
1466 Ok(most_recent.map(|(path, _)| path))
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 #[test]
1474 fn test_find_most_recent_log() {
1475 let result = find_most_recent_log("/nonexistent/path");
1477 assert!(result.is_ok());
1478 assert!(result.unwrap().is_none());
1479 }
1480
1481 #[test]
1482 fn test_truncate_diff_if_large() {
1483 let large_diff = "a".repeat(100_000);
1484 let truncated = truncate_diff_if_large(&large_diff, 10_000);
1485
1486 assert!(truncated.len() < large_diff.len());
1488 }
1489
1490 #[test]
1491 fn test_truncate_preserves_small_diffs() {
1492 let small_diff = "a".repeat(100);
1493 let truncated = truncate_diff_if_large(&small_diff, 10_000);
1494
1495 assert_eq!(truncated, small_diff);
1497 }
1498
1499 #[test]
1500 fn test_truncate_exactly_at_limit() {
1501 let diff = "a".repeat(10_000);
1502 let truncated = truncate_diff_if_large(&diff, 10_000);
1503
1504 assert_eq!(truncated, diff);
1506 }
1507
1508 #[test]
1509 fn test_truncate_preserves_file_boundaries() {
1510 let diff = "diff --git a/file1.rs b/file1.rs\n\
1511 +line1\n\
1512 +line2\n\
1513 diff --git a/file2.rs b/file2.rs\n\
1514 +line3\n\
1515 +line4\n";
1516 let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1517 let truncated = truncate_diff_if_large(&large_diff, 50);
1518
1519 assert!(truncated.contains("diff --git"));
1521 assert!(truncated.contains("Diff truncated"));
1523 }
1524
1525 #[test]
1526 fn test_prioritize_file_path() {
1527 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1529 assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1530
1531 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1533
1534 assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1536
1537 assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1539 }
1540
1541 #[test]
1542 fn test_truncate_keeps_high_priority_files() {
1543 let diff = "diff --git a/README.md b/README.md\n\
1544 +doc change\n\
1545 diff --git a/src/main.rs b/src/main.rs\n\
1546 +important change\n\
1547 diff --git a/tests/test.rs b/tests/test.rs\n\
1548 +test change\n";
1549
1550 let truncated = truncate_diff_if_large(diff, 80);
1552
1553 assert!(truncated.contains("src/main.rs"));
1555 }
1556
1557 #[test]
1558 fn test_truncate_lines_to_fit() {
1559 let lines = vec![
1560 "line1".to_string(),
1561 "line2".to_string(),
1562 "line3".to_string(),
1563 "line4".to_string(),
1564 ];
1565
1566 let truncated = truncate_lines_to_fit(&lines, 18);
1568
1569 assert_eq!(truncated.len(), 3);
1570 assert!(truncated[2].ends_with("[truncated...]"));
1572 }
1573
1574 #[test]
1575 fn test_hardcoded_fallback_commit() {
1576 use crate::files::llm_output_extraction::is_conventional_commit_subject;
1578 assert!(
1579 is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1580 "Hardcoded fallback must be a valid conventional commit"
1581 );
1582 assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1583 assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1584 }
1585}