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
284pub 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
505fn 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 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 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
547fn validate_compose_groups(groups: &[ChangeGroup], full_diff: &str) -> Result<()> {
549 use std::collections::{HashMap, HashSet};
550
551 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 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 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 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 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 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 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
634pub fn execute_compose(
636 analysis: &ComposeAnalysis,
637 config: &CommitConfig,
638 args: &Args,
639) -> Result<Vec<String>> {
640 let dir = &args.dir;
641
642 println!("Resetting staging area...");
644 reset_staging(dir)?;
645
646 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_group_changes(&group, dir, &baseline_diff)?;
686
687 let diff = get_git_diff(&Mode::Staged, None, dir, config)?;
689 let stat = get_git_stat(&Mode::Staged, None, dir, config)?;
690
691 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 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 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 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
785pub 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 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
845fn run_compose_round(args: &Args, config: &CommitConfig, round: usize) -> Result<()> {
847 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 let original_diff = combined_diff.clone();
876
877 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 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 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}