llm_git/
compose.rs

1use std::{path::Path, sync::OnceLock, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6   api::generate_conventional_analysis,
7   config::CommitConfig,
8   diff::smart_truncate_diff,
9   error::{CommitGenError, Result},
10   git::{get_git_diff, get_git_stat, get_head_hash, git_commit},
11   normalization::{format_commit_message, post_process_commit_message},
12   patch::{reset_staging, stage_group_changes},
13   types::{
14      Args, ChangeGroup, CommitType, ComposeAnalysis, ConventionalAnalysis, ConventionalCommit,
15      Mode,
16   },
17   validation::validate_commit_message,
18};
19
20static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
21
22fn get_client() -> &'static reqwest::blocking::Client {
23   CLIENT.get_or_init(|| {
24      reqwest::blocking::Client::builder()
25         .timeout(Duration::from_secs(120))
26         .connect_timeout(Duration::from_secs(30))
27         .build()
28         .expect("Failed to build HTTP client")
29   })
30}
31
32#[derive(Debug, Serialize)]
33struct Message {
34   role:    String,
35   content: String,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39struct FunctionParameters {
40   #[serde(rename = "type")]
41   param_type: String,
42   properties: serde_json::Value,
43   required:   Vec<String>,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47struct Function {
48   name:        String,
49   description: String,
50   parameters:  FunctionParameters,
51}
52
53#[derive(Debug, Serialize, Deserialize)]
54struct Tool {
55   #[serde(rename = "type")]
56   tool_type: String,
57   function:  Function,
58}
59
60#[derive(Debug, Serialize)]
61struct ApiRequest {
62   model:       String,
63   max_tokens:  u32,
64   temperature: f32,
65   tools:       Vec<Tool>,
66   #[serde(skip_serializing_if = "Option::is_none")]
67   tool_choice: Option<serde_json::Value>,
68   messages:    Vec<Message>,
69}
70
71#[derive(Debug, Deserialize, Serialize)]
72struct ToolCall {
73   function: FunctionCall,
74}
75
76#[derive(Debug, Deserialize, Serialize)]
77struct FunctionCall {
78   name:      String,
79   arguments: String,
80}
81
82#[derive(Debug, Deserialize, Serialize)]
83struct Choice {
84   message: ResponseMessage,
85}
86
87#[derive(Debug, Deserialize, Serialize)]
88struct ResponseMessage {
89   #[serde(default)]
90   tool_calls:    Vec<ToolCall>,
91   #[serde(default)]
92   content:       Option<String>,
93   #[serde(default)]
94   function_call: Option<FunctionCall>,
95}
96
97#[derive(Debug, Deserialize, Serialize)]
98struct ApiResponse {
99   choices: Vec<Choice>,
100}
101
102const COMPOSE_PROMPT: &str = r#"Split this git diff into 1-{MAX_COMMITS} logical, atomic commit groups.
103
104## Git Stat
105{STAT}
106
107## Git Diff
108{DIFF}
109
110## Rules (CRITICAL)
1111. **EXHAUSTIVENESS**: You MUST account for 100% of changes. Every file and hunk in the diff above must appear in exactly one group.
1122. **Atomicity**: Each group represents ONE logical change (feat/fix/refactor/etc.) that leaves codebase working.
1133. **Prefer fewer groups**: Default to 1-3 commits. Only split when changes are truly independent/separable.
1144. **Group related**: Implementation + tests go together. Refactoring + usage updates go together.
1155. **Dependencies**: Use indices. Group 2 depending on Group 1 means: dependencies: [0].
1166. **Hunk selection**:
117   - If entire file → hunks: ["ALL"]
118   - If partial → copy exact hunk headers from diff above (format: "@@ -10,5 +10,7 @@")
119   - Double-check headers exist in diff
120
121## Good Example (2 independent changes)
122groups: [
123  {
124    changes: [
125      {path: "src/api.rs", hunks: ["ALL"]},
126      {path: "tests/api_test.rs", hunks: ["@@ -15,3 +15,8 @@"]}
127    ],
128    type: "feat", scope: "api", rationale: "add user endpoint with test",
129    dependencies: []
130  },
131  {
132    changes: [
133      {path: "src/utils.rs", hunks: ["ALL"]}
134    ],
135    type: "fix", scope: "utils", rationale: "fix string parsing bug",
136    dependencies: []
137  }
138]
139
140## Bad Example (over-splitting)
141❌ DON'T create 6 commits for: function rename + call sites. That's ONE refactor group.
142❌ DON'T split tests from implementation unless they test something from a prior group.
143
144## Bad Example (incomplete)
145❌ DON'T forget files. If diff shows 5 files, groups must cover all 5.
146
147Return groups in dependency order."#;
148
149#[derive(Deserialize)]
150struct ComposeResult {
151   groups: Vec<ChangeGroup>,
152}
153
154fn parse_compose_groups_from_content(content: &str) -> Result<Vec<ChangeGroup>> {
155   fn try_parse(input: &str) -> Option<Vec<ChangeGroup>> {
156      let trimmed = input.trim();
157      if trimmed.is_empty() {
158         return None;
159      }
160
161      serde_json::from_str::<ComposeResult>(trimmed)
162         .map(|r| r.groups)
163         .ok()
164   }
165
166   let trimmed = content.trim();
167   if trimmed.is_empty() {
168      return Err(CommitGenError::Other(
169         "Model returned an empty compose analysis response".to_string(),
170      ));
171   }
172
173   if let Some(groups) = try_parse(trimmed) {
174      return Ok(groups);
175   }
176
177   if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
178      && end >= start
179   {
180      let candidate = &trimmed[start..=end];
181      if let Some(groups) = try_parse(candidate) {
182         return Ok(groups);
183      }
184   }
185
186   let segments: Vec<&str> = trimmed.split("```").collect();
187   for (idx, segment) in segments.iter().enumerate() {
188      if idx % 2 == 1 {
189         let block = segment.trim();
190         let mut lines = block.lines();
191         let first_line = lines.next().unwrap_or_default();
192
193         let mut owned_candidate: Option<String> = None;
194         let json_candidate = if first_line.trim_start().starts_with('{') {
195            block
196         } else {
197            let rest: String = lines.collect::<Vec<_>>().join("\n");
198            let trimmed_rest = rest.trim();
199            if trimmed_rest.is_empty() {
200               block
201            } else {
202               owned_candidate = Some(trimmed_rest.to_string());
203               owned_candidate.as_deref().unwrap()
204            }
205         };
206
207         if let Some(groups) = try_parse(json_candidate) {
208            return Ok(groups);
209         }
210      }
211   }
212
213   Err(CommitGenError::Other("Failed to parse compose analysis from model response".to_string()))
214}
215
216fn parse_compose_groups_from_json(
217   raw: &str,
218) -> std::result::Result<Vec<ChangeGroup>, serde_json::Error> {
219   let trimmed = raw.trim();
220   if trimmed.starts_with('[') {
221      serde_json::from_str::<Vec<ChangeGroup>>(trimmed)
222   } else {
223      serde_json::from_str::<ComposeResult>(trimmed).map(|r| r.groups)
224   }
225}
226
227fn debug_failed_payload(source: &str, payload: &str, err: &serde_json::Error) {
228   let preview = payload.trim();
229   let preview = if preview.len() > 2000 {
230      format!("{}…", &preview[..2000])
231   } else {
232      preview.to_string()
233   };
234   eprintln!("Compose debug: failed to parse {source} payload ({err}); preview: {preview}");
235}
236
237fn group_affects_only_dependency_files(group: &ChangeGroup) -> bool {
238   group
239      .changes
240      .iter()
241      .all(|change| is_dependency_manifest(&change.path))
242}
243
244fn is_dependency_manifest(path: &str) -> bool {
245   const DEP_MANIFESTS: &[&str] = &[
246      "Cargo.toml",
247      "Cargo.lock",
248      "package.json",
249      "package-lock.json",
250      "pnpm-lock.yaml",
251      "yarn.lock",
252      "bun.lock",
253      "bun.lockb",
254      "go.mod",
255      "go.sum",
256      "requirements.txt",
257      "Pipfile",
258      "Pipfile.lock",
259      "pyproject.toml",
260      "Gemfile",
261      "Gemfile.lock",
262      "composer.json",
263      "composer.lock",
264      "build.gradle",
265      "build.gradle.kts",
266      "gradle.properties",
267      "pom.xml",
268   ];
269
270   let path = Path::new(path);
271   let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
272      return false;
273   };
274
275   if DEP_MANIFESTS.contains(&file_name) {
276      return true;
277   }
278
279   Path::new(file_name)
280      .extension()
281      .is_some_and(|ext| ext.eq_ignore_ascii_case("lock") || ext.eq_ignore_ascii_case("lockb"))
282}
283
284/// Call AI to analyze and group changes for compose mode
285pub fn analyze_for_compose(
286   diff: &str,
287   stat: &str,
288   config: &CommitConfig,
289   max_commits: usize,
290) -> Result<ComposeAnalysis> {
291   let client = get_client();
292
293   let tool = Tool {
294      tool_type: "function".to_string(),
295      function:  Function {
296         name:        "create_compose_analysis".to_string(),
297         description: "Split changes into logical commit groups with dependencies".to_string(),
298         parameters:  FunctionParameters {
299            param_type: "object".to_string(),
300            properties: serde_json::json!({
301               "groups": {
302                  "type": "array",
303                  "description": "Array of change groups in dependency order",
304                  "items": {
305                     "type": "object",
306                     "properties": {
307                        "changes": {
308                           "type": "array",
309                           "description": "File changes with specific hunks",
310                           "items": {
311                              "type": "object",
312                              "properties": {
313                                 "path": {
314                                    "type": "string",
315                                    "description": "File path"
316                                 },
317                                 "hunks": {
318                                    "type": "array",
319                                    "description": "Hunk headers from diff (e.g., ['@@ -10,5 +10,7 @@']) or ['ALL'] for entire file",
320                                    "items": { "type": "string" }
321                                 }
322                              },
323                              "required": ["path", "hunks"]
324                           }
325                        },
326                        "type": {
327                           "type": "string",
328                           "enum": ["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"],
329                           "description": "Commit type for this group"
330                        },
331                        "scope": {
332                           "type": "string",
333                           "description": "Optional scope (module/component). Omit if broad."
334                        },
335                        "rationale": {
336                           "type": "string",
337                           "description": "Brief explanation of why these changes belong together"
338                        },
339                        "dependencies": {
340                           "type": "array",
341                           "description": "Indices of groups this depends on (e.g., [0, 1])",
342                           "items": { "type": "integer" }
343                        }
344                     },
345                     "required": ["changes", "type", "rationale", "dependencies"]
346                  }
347               }
348            }),
349            required:   vec!["groups".to_string()],
350         },
351      },
352   };
353
354   let prompt = COMPOSE_PROMPT
355      .replace("{STAT}", stat)
356      .replace("{DIFF}", diff)
357      .replace("{MAX_COMMITS}", &max_commits.to_string());
358
359   let request = ApiRequest {
360      model:       config.analysis_model.clone(),
361      max_tokens:  8000,
362      temperature: config.temperature,
363      tools:       vec![tool],
364      tool_choice: Some(
365         serde_json::json!({ "type": "function", "function": { "name": "create_compose_analysis" } }),
366      ),
367      messages:    vec![Message { role: "user".to_string(), content: prompt }],
368   };
369
370   let response = client
371      .post(format!("{}/chat/completions", config.api_base_url))
372      .header("content-type", "application/json")
373      .json(&request)
374      .send()
375      .map_err(CommitGenError::HttpError)?;
376
377   let status = response.status();
378   if !status.is_success() {
379      let error_text = response
380         .text()
381         .unwrap_or_else(|_| "Unknown error".to_string());
382      return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
383   }
384
385   let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
386
387   if api_response.choices.is_empty() {
388      return Err(CommitGenError::Other(
389         "API returned empty response for compose analysis".to_string(),
390      ));
391   }
392
393   let mut last_parse_error: Option<CommitGenError> = None;
394
395   for choice in &api_response.choices {
396      let message = &choice.message;
397
398      if let Some(tool_call) = message.tool_calls.first()
399         && tool_call.function.name == "create_compose_analysis"
400      {
401         let args = &tool_call.function.arguments;
402         match parse_compose_groups_from_json(args) {
403            Ok(groups) => {
404               let dependency_order = compute_dependency_order(&groups)?;
405               return Ok(ComposeAnalysis { groups, dependency_order });
406            },
407            Err(err) => {
408               debug_failed_payload("tool_call", args, &err);
409               last_parse_error =
410                  Some(CommitGenError::Other(format!("Failed to parse compose analysis: {err}")));
411            },
412         }
413      }
414
415      if let Some(function_call) = &message.function_call
416         && function_call.name == "create_compose_analysis"
417      {
418         let args = &function_call.arguments;
419         match parse_compose_groups_from_json(args) {
420            Ok(groups) => {
421               let dependency_order = compute_dependency_order(&groups)?;
422               return Ok(ComposeAnalysis { groups, dependency_order });
423            },
424            Err(err) => {
425               debug_failed_payload("function_call", args, &err);
426               last_parse_error =
427                  Some(CommitGenError::Other(format!("Failed to parse compose analysis: {err}")));
428            },
429         }
430      }
431
432      if let Some(content) = &message.content {
433         match parse_compose_groups_from_content(content) {
434            Ok(groups) => {
435               let dependency_order = compute_dependency_order(&groups)?;
436               return Ok(ComposeAnalysis { groups, dependency_order });
437            },
438            Err(err) => last_parse_error = Some(err),
439         }
440      }
441   }
442
443   if let Some(err) = last_parse_error {
444      debug_compose_response(&api_response);
445      return Err(err);
446   }
447
448   debug_compose_response(&api_response);
449   Err(CommitGenError::Other("No compose analysis found in API response".to_string()))
450}
451
452fn debug_compose_response(response: &ApiResponse) {
453   let raw_preview = serde_json::to_string(response).map_or_else(
454      |_| "<failed to serialize response>".to_string(),
455      |json| {
456         if json.len() > 4000 {
457            format!("{}…", &json[..4000])
458         } else {
459            json
460         }
461      },
462   );
463
464   eprintln!(
465      "Compose debug: received {} choice(s) from analysis model\n  raw: {}",
466      response.choices.len(),
467      raw_preview
468   );
469
470   for (idx, choice) in response.choices.iter().enumerate() {
471      let message = &choice.message;
472      let tool_call = message.tool_calls.first();
473      let tool_name = tool_call.map_or("<none>", |tc| tc.function.name.as_str());
474      let tool_args_len = tool_call.map_or(0, |tc| tc.function.arguments.len());
475
476      let function_call_name = message
477         .function_call
478         .as_ref()
479         .map_or("<none>", |fc| fc.name.as_str());
480      let function_call_args_len = message
481         .function_call
482         .as_ref()
483         .map_or(0, |fc| fc.arguments.len());
484
485      let content_preview = message.content.as_deref().map_or_else(
486         || "<none>".to_string(),
487         |c| {
488            let trimmed = c.trim();
489            if trimmed.len() > 200 {
490               format!("{}…", &trimmed[..200])
491            } else {
492               trimmed.to_string()
493            }
494         },
495      );
496
497      eprintln!(
498         "Choice #{idx}: tool_call={tool_name} (args {tool_args_len} chars), \
499          function_call={function_call_name} (args {function_call_args_len} chars), \
500          content_preview={content_preview}"
501      );
502   }
503}
504
505/// Compute topological order for commit groups based on dependencies
506fn compute_dependency_order(groups: &[ChangeGroup]) -> Result<Vec<usize>> {
507   let n = groups.len();
508   let mut in_degree = vec![0; n];
509   let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
510
511   // Build graph
512   for (i, group) in groups.iter().enumerate() {
513      for &dep in &group.dependencies {
514         if dep >= n {
515            return Err(CommitGenError::Other(format!(
516               "Invalid dependency index {dep} (max: {n})"
517            )));
518         }
519         adjacency[dep].push(i);
520         in_degree[i] += 1;
521      }
522   }
523
524   // Kahn's algorithm for topological sort
525   let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
526   let mut order = Vec::new();
527
528   while let Some(node) = queue.pop() {
529      order.push(node);
530      for &neighbor in &adjacency[node] {
531         in_degree[neighbor] -= 1;
532         if in_degree[neighbor] == 0 {
533            queue.push(neighbor);
534         }
535      }
536   }
537
538   if order.len() != n {
539      return Err(CommitGenError::Other(
540         "Circular dependency detected in commit groups".to_string(),
541      ));
542   }
543
544   Ok(order)
545}
546
547/// Validate groups for exhaustiveness and correctness
548fn validate_compose_groups(groups: &[ChangeGroup], full_diff: &str) -> Result<()> {
549   use std::collections::{HashMap, HashSet};
550
551   // Extract all files from diff
552   let mut diff_files: HashSet<String> = HashSet::new();
553   for line in full_diff.lines() {
554      if line.starts_with("diff --git")
555         && let Some(b_part) = line.split_whitespace().nth(3)
556         && let Some(path) = b_part.strip_prefix("b/")
557      {
558         diff_files.insert(path.to_string());
559      }
560   }
561
562   // Track which files are covered by groups
563   let mut covered_files: HashSet<String> = HashSet::new();
564   let mut file_coverage: HashMap<String, usize> = HashMap::new();
565
566   for (idx, group) in groups.iter().enumerate() {
567      for change in &group.changes {
568         covered_files.insert(change.path.clone());
569         *file_coverage.entry(change.path.clone()).or_insert(0) += 1;
570
571         // Warn about invalid hunk headers
572         if !(change.hunks.len() == 1 && change.hunks[0] == "ALL") {
573            for hunk in &change.hunks {
574               if !hunk.starts_with("@@") {
575                  eprintln!(
576                     "⚠ Warning: Group {idx} references invalid hunk header '{hunk}' in {}",
577                     change.path
578                  );
579               }
580            }
581         }
582      }
583
584      // Check for invalid dependency indices
585      for &dep in &group.dependencies {
586         if dep >= groups.len() {
587            return Err(CommitGenError::Other(format!(
588               "Group {idx} has invalid dependency {dep} (only {} groups total)",
589               groups.len()
590            )));
591         }
592         if dep == idx {
593            return Err(CommitGenError::Other(format!("Group {idx} depends on itself (circular)")));
594         }
595      }
596   }
597
598   // Check for missing files
599   let missing_files: Vec<&String> = diff_files.difference(&covered_files).collect();
600   if !missing_files.is_empty() {
601      eprintln!("⚠ Warning: Groups don't cover all files. Missing:");
602      for file in &missing_files {
603         eprintln!("   - {file}");
604      }
605      return Err(CommitGenError::Other(format!(
606         "Non-exhaustive groups: {} file(s) not covered",
607         missing_files.len()
608      )));
609   }
610
611   // Check for duplicate file coverage
612   let duplicates: Vec<_> = file_coverage
613      .iter()
614      .filter(|&(_, count)| *count > 1)
615      .collect();
616
617   if !duplicates.is_empty() {
618      eprintln!("⚠ Warning: Some files appear in multiple groups:");
619      for (file, count) in duplicates {
620         eprintln!("   - {file} ({count} times)");
621      }
622   }
623
624   // Warn if empty groups
625   for (idx, group) in groups.iter().enumerate() {
626      if group.changes.is_empty() {
627         return Err(CommitGenError::Other(format!("Group {idx} has no changes")));
628      }
629   }
630
631   Ok(())
632}
633
634/// Execute compose: stage groups, generate messages, create commits
635pub fn execute_compose(
636   analysis: &ComposeAnalysis,
637   config: &CommitConfig,
638   args: &Args,
639) -> Result<Vec<String>> {
640   let dir = &args.dir;
641
642   // Reset staging area
643   println!("Resetting staging area...");
644   reset_staging(dir)?;
645
646   // Capture the full diff against the original HEAD once so we can reuse the same
647   // hunk metadata even after earlier groups move HEAD forward.
648   let baseline_diff_output = std::process::Command::new("git")
649      .args(["diff", "HEAD"])
650      .current_dir(dir)
651      .output()
652      .map_err(|e| CommitGenError::GitError(format!("Failed to get baseline diff: {e}")))?;
653
654   if !baseline_diff_output.status.success() {
655      let stderr = String::from_utf8_lossy(&baseline_diff_output.stderr);
656      return Err(CommitGenError::GitError(format!("git diff HEAD failed: {stderr}")));
657   }
658
659   let baseline_diff = String::from_utf8_lossy(&baseline_diff_output.stdout).to_string();
660
661   let mut commit_hashes = Vec::new();
662
663   for (idx, &group_idx) in analysis.dependency_order.iter().enumerate() {
664      let mut group = analysis.groups[group_idx].clone();
665      let dependency_only = group_affects_only_dependency_files(&group);
666
667      if dependency_only && group.commit_type.as_str() != "build" {
668         group.commit_type = CommitType::new("build")?;
669      }
670
671      println!(
672         "\n[{}/{}] Creating commit for group: {}",
673         idx + 1,
674         analysis.dependency_order.len(),
675         group.rationale
676      );
677      println!("  Type: {}", group.commit_type);
678      if let Some(ref scope) = group.scope {
679         println!("  Scope: {scope}");
680      }
681      let files: Vec<String> = group.changes.iter().map(|c| c.path.clone()).collect();
682      println!("  Files: {}", files.join(", "));
683
684      // Stage changes for this group (with hunk awareness)
685      stage_group_changes(&group, dir, &baseline_diff)?;
686
687      // Get diff and stat for this specific group
688      let diff = get_git_diff(&Mode::Staged, None, dir, config)?;
689      let stat = get_git_stat(&Mode::Staged, None, dir, config)?;
690
691      // Truncate if needed
692      let diff = if diff.len() > config.max_diff_length {
693         smart_truncate_diff(&diff, config.max_diff_length, config)
694      } else {
695         diff
696      };
697
698      // Generate commit message using existing infrastructure
699      println!("  Generating commit message...");
700      let message_analysis = generate_conventional_analysis(
701         &stat,
702         &diff,
703         &config.analysis_model,
704         Some(&group.rationale),
705         "",
706         config,
707      )?;
708
709      let ConventionalAnalysis {
710         commit_type: analysis_commit_type,
711         scope: analysis_scope,
712         body: analysis_body,
713         issue_refs: _,
714      } = message_analysis;
715
716      let summary = crate::api::generate_summary_from_analysis(
717         &stat,
718         group.commit_type.as_str(),
719         group.scope.as_ref().map(|s| s.as_str()),
720         &analysis_body,
721         Some(&group.rationale),
722         config,
723      )?;
724
725      let final_commit_type = if dependency_only {
726         CommitType::new("build")?
727      } else {
728         analysis_commit_type
729      };
730
731      let mut commit = ConventionalCommit {
732         commit_type: final_commit_type,
733         scope: analysis_scope,
734         summary,
735         body: analysis_body,
736         footers: vec![],
737      };
738
739      post_process_commit_message(&mut commit, config);
740
741      if let Err(e) = validate_commit_message(&commit, config) {
742         eprintln!("  Warning: Validation failed: {e}");
743      }
744
745      let formatted_message = format_commit_message(&commit);
746
747      println!(
748         "  Message:\n{}",
749         formatted_message
750            .lines()
751            .take(3)
752            .collect::<Vec<_>>()
753            .join("\n")
754      );
755
756      // Create commit (unless preview mode)
757      if !args.compose_preview {
758         git_commit(&formatted_message, false, dir)?;
759         let hash = get_head_hash(dir)?;
760         commit_hashes.push(hash);
761
762         // Run tests if requested
763         if args.compose_test_after_each {
764            println!("  Running tests...");
765            let test_result = std::process::Command::new("cargo")
766               .arg("test")
767               .current_dir(dir)
768               .status();
769
770            if let Ok(status) = test_result {
771               if !status.success() {
772                  return Err(CommitGenError::Other(format!(
773                     "Tests failed after commit {idx}. Aborting."
774                  )));
775               }
776               println!("  ✓ Tests passed");
777            }
778         }
779      }
780   }
781
782   Ok(commit_hashes)
783}
784
785/// Main entry point for compose mode
786pub fn run_compose_mode(args: &Args, config: &CommitConfig) -> Result<()> {
787   let max_rounds = config.compose_max_rounds;
788
789   for round in 1..=max_rounds {
790      if round > 1 {
791         println!("\n=== Compose Round {round}/{max_rounds} ===");
792      } else {
793         println!("=== Compose Mode ===");
794      }
795      println!("Analyzing all changes for intelligent splitting...\n");
796
797      run_compose_round(args, config, round)?;
798
799      // Check if there are remaining changes
800      if args.compose_preview {
801         break;
802      }
803
804      let remaining_diff_output = std::process::Command::new("git")
805         .args(["diff", "HEAD"])
806         .current_dir(&args.dir)
807         .output()
808         .map_err(|e| CommitGenError::GitError(format!("Failed to check remaining diff: {e}")))?;
809
810      if !remaining_diff_output.status.success() {
811         continue;
812      }
813
814      let remaining_diff = String::from_utf8_lossy(&remaining_diff_output.stdout);
815      if remaining_diff.trim().is_empty() {
816         println!("\n✓ All changes committed successfully");
817         break;
818      }
819
820      eprintln!("\n⚠ Uncommitted changes remain after round {round}");
821
822      let stat_output = std::process::Command::new("git")
823         .args(["diff", "HEAD", "--stat"])
824         .current_dir(&args.dir)
825         .output()
826         .ok();
827
828      if let Some(output) = stat_output
829         && output.status.success()
830      {
831         let stat = String::from_utf8_lossy(&output.stdout);
832         eprintln!("{stat}");
833      }
834
835      if round < max_rounds {
836         eprintln!("Starting another compose round...");
837         continue;
838      }
839      eprintln!("Reached max rounds ({max_rounds}). Remaining changes need manual commit.");
840   }
841
842   Ok(())
843}
844
845/// Run a single round of compose
846fn run_compose_round(args: &Args, config: &CommitConfig, round: usize) -> Result<()> {
847   // Get combined diff (staged + unstaged)
848   let diff_staged = get_git_diff(&Mode::Staged, None, &args.dir, config).unwrap_or_default();
849   let diff_unstaged = get_git_diff(&Mode::Unstaged, None, &args.dir, config).unwrap_or_default();
850
851   let combined_diff = if diff_staged.is_empty() {
852      diff_unstaged
853   } else if diff_unstaged.is_empty() {
854      diff_staged
855   } else {
856      format!("{diff_staged}\n{diff_unstaged}")
857   };
858
859   if combined_diff.is_empty() {
860      return Err(CommitGenError::NoChanges { mode: "working directory".to_string() });
861   }
862
863   let stat_staged = get_git_stat(&Mode::Staged, None, &args.dir, config).unwrap_or_default();
864   let stat_unstaged = get_git_stat(&Mode::Unstaged, None, &args.dir, config).unwrap_or_default();
865
866   let combined_stat = if stat_staged.is_empty() {
867      stat_unstaged
868   } else if stat_unstaged.is_empty() {
869      stat_staged
870   } else {
871      format!("{stat_staged}\n{stat_unstaged}")
872   };
873
874   // Save original diff for validation (before possible truncation)
875   let original_diff = combined_diff.clone();
876
877   // Truncate if needed
878   let diff = if combined_diff.len() > config.max_diff_length {
879      println!(
880         "Warning: Applying smart truncation (diff size: {} characters)",
881         combined_diff.len()
882      );
883      smart_truncate_diff(&combined_diff, config.max_diff_length, config)
884   } else {
885      combined_diff
886   };
887
888   let max_commits = args.compose_max_commits.unwrap_or(3);
889
890   println!("Analyzing changes (max {max_commits} commits)...");
891   let analysis = analyze_for_compose(&diff, &combined_stat, config, max_commits)?;
892
893   // Validate groups for exhaustiveness and correctness
894   println!("Validating groups...");
895   validate_compose_groups(&analysis.groups, &original_diff)?;
896
897   println!("\n=== Proposed Commit Groups ===");
898   for (idx, &group_idx) in analysis.dependency_order.iter().enumerate() {
899      let mut group = analysis.groups[group_idx].clone();
900      if group_affects_only_dependency_files(&group) && group.commit_type.as_str() != "build" {
901         group.commit_type = CommitType::new("build")?;
902      }
903      println!(
904         "\n{}. [{}{}] {}",
905         idx + 1,
906         group.commit_type,
907         group
908            .scope
909            .as_ref()
910            .map(|s| format!("({s})"))
911            .unwrap_or_default(),
912         group.rationale
913      );
914      println!("   Changes:");
915      for change in &group.changes {
916         if change.hunks.len() == 1 && change.hunks[0] == "ALL" {
917            println!("     - {} (all changes)", change.path);
918         } else {
919            println!("     - {} ({} hunks)", change.path, change.hunks.len());
920         }
921      }
922      if !group.dependencies.is_empty() {
923         println!("   Depends on: {:?}", group.dependencies);
924      }
925   }
926
927   if args.compose_preview {
928      println!("\n✓ Preview complete (use --compose without --compose-preview to execute)");
929      return Ok(());
930   }
931
932   // TODO: Interactive mode would go here
933
934   println!("\nExecuting compose (round {round})...");
935   let hashes = execute_compose(&analysis, config, args)?;
936
937   println!("✓ Round {round}: Created {} commit(s)", hashes.len());
938   Ok(())
939}