1use std::{collections::HashMap, path::PathBuf, process::Command, sync::OnceLock};
2
3pub use self::git_push as push;
4use crate::{
5 config::CommitConfig,
6 error::{CommitGenError, Result},
7 style,
8 types::{CommitMetadata, Mode},
9};
10
11#[derive(Debug, Clone, Copy)]
12struct GitCommandSettings {
13 disable_git_background_features: bool,
14}
15
16impl Default for GitCommandSettings {
17 fn default() -> Self {
18 Self { disable_git_background_features: true }
19 }
20}
21
22static GIT_COMMAND_SETTINGS: OnceLock<GitCommandSettings> = OnceLock::new();
23
24pub fn init_git_command_settings(config: &CommitConfig) {
25 let _ = GIT_COMMAND_SETTINGS.set(GitCommandSettings {
26 disable_git_background_features: config.disable_git_background_features,
27 });
28}
29
30fn current_git_command_settings() -> GitCommandSettings {
31 GIT_COMMAND_SETTINGS.get().copied().unwrap_or_default()
32}
33
34fn apply_git_command_overrides(cmd: &mut Command, settings: GitCommandSettings) {
35 if settings.disable_git_background_features {
36 cmd.args(["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false"]);
37 }
38}
39
40pub fn git_command() -> Command {
41 git_command_with_settings(current_git_command_settings())
42}
43
44fn git_command_with_settings(settings: GitCommandSettings) -> Command {
45 let mut cmd = Command::new("git");
46 apply_git_command_overrides(&mut cmd, settings);
47 cmd
48}
49
50fn list_untracked_files(dir: &str) -> Result<Vec<String>> {
51 let output = git_command()
52 .args(["ls-files", "--others", "--exclude-standard"])
53 .current_dir(dir)
54 .output()
55 .map_err(|e| CommitGenError::git(format!("Failed to list untracked files: {e}")))?;
56
57 if !output.status.success() {
58 let stderr = String::from_utf8_lossy(&output.stderr);
59 return Err(CommitGenError::git(format!("git ls-files failed: {stderr}")));
60 }
61
62 Ok(String::from_utf8_lossy(&output.stdout)
63 .lines()
64 .filter(|path| !path.is_empty())
65 .map(str::to_string)
66 .collect())
67}
68
69fn append_untracked_diff(
70 mut base_diff: String,
71 dir: &str,
72 untracked_files: &[String],
73) -> Result<String> {
74 for file in untracked_files {
75 let file_diff_output = git_command()
76 .args(["diff", "--no-index", "/dev/null", file])
77 .current_dir(dir)
78 .output()
79 .map_err(|e| CommitGenError::git(format!("Failed to diff untracked file {file}: {e}")))?;
80
81 if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
83 let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
84 let lines: Vec<&str> = file_diff.lines().collect();
85 if lines.len() >= 2 {
86 use std::fmt::Write;
87 if !base_diff.is_empty() {
88 base_diff.push('\n');
89 }
90 writeln!(base_diff, "diff --git a/{file} b/{file}").unwrap();
91 base_diff.push_str("new file mode 100644\n");
92 base_diff.push_str("index 0000000..0000000\n");
93 base_diff.push_str("--- /dev/null\n");
94 writeln!(base_diff, "+++ b/{file}").unwrap();
95 for line in lines.iter().skip(2) {
96 base_diff.push_str(line);
97 base_diff.push('\n');
98 }
99 }
100 }
101 }
102
103 Ok(base_diff)
104}
105
106fn append_untracked_stat(mut stat: String, dir: &str, untracked_files: &[String]) -> String {
107 use std::fmt::Write;
108
109 for file in untracked_files {
110 use std::fs;
111
112 if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
113 let lines = if metadata.is_file() {
114 fs::read_to_string(format!("{dir}/{file}"))
115 .map(|content| content.lines().count())
116 .unwrap_or(0)
117 } else {
118 0
119 };
120
121 if !stat.is_empty() && !stat.ends_with('\n') {
122 stat.push('\n');
123 }
124 writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
125 }
126 }
127
128 stat
129}
130
131fn append_untracked_numstat(mut numstat: String, dir: &str, untracked_files: &[String]) -> String {
132 use std::fmt::Write;
133
134 for file in untracked_files {
135 use std::fs;
136
137 let path = format!("{dir}/{file}");
138 if let Ok(metadata) = fs::metadata(&path) {
139 let (added, deleted) = if metadata.is_file() {
140 match fs::read_to_string(&path) {
141 Ok(content) => (content.lines().count().to_string(), "0".to_string()),
142 Err(_) => ("-".to_string(), "-".to_string()),
143 }
144 } else {
145 ("0".to_string(), "0".to_string())
146 };
147
148 if !numstat.is_empty() && !numstat.ends_with('\n') {
149 numstat.push('\n');
150 }
151 writeln!(numstat, "{added}\t{deleted}\t{file}").unwrap();
152 }
153 }
154
155 numstat
156}
157
158fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
161 if !stderr.contains("index.lock") {
162 return None;
163 }
164
165 let lock_path = stderr
168 .lines()
169 .find_map(|line| {
170 let start = line.find('\'')?;
171 let end = line[start + 1..].find('\'')?;
172 let path = &line[start + 1..start + 1 + end];
173 if path.ends_with("index.lock") {
174 Some(PathBuf::from(path))
175 } else {
176 None
177 }
178 })
179 .unwrap_or_else(|| PathBuf::from(dir).join(".git/index.lock"));
180
181 Some(CommitGenError::GitIndexLocked { lock_path })
182}
183
184pub fn ensure_git_repo(dir: &str) -> Result<()> {
189 let output = git_command()
190 .args(["rev-parse", "--show-toplevel"])
191 .current_dir(dir)
192 .output()
193 .map_err(|e| CommitGenError::git(format!("Failed to run git rev-parse: {e}")))?;
194
195 if output.status.success() {
196 return Ok(());
197 }
198
199 let stderr = String::from_utf8_lossy(&output.stderr);
200 if stderr.contains("not a git repository") {
201 return Err(CommitGenError::git(
202 "Not a git repository (or any of the parent directories): .git".to_string(),
203 ));
204 }
205
206 Err(CommitGenError::git(format!("Failed to detect git repository: {stderr}")))
207}
208
209pub fn get_git_dir(dir: &str) -> Result<PathBuf> {
210 let output = git_command()
211 .args(["rev-parse", "--absolute-git-dir"])
212 .current_dir(dir)
213 .output()
214 .map_err(|e| {
215 CommitGenError::git(format!("Failed to run git rev-parse --absolute-git-dir: {e}"))
216 })?;
217
218 if !output.status.success() {
219 let stderr = String::from_utf8_lossy(&output.stderr);
220 return Err(CommitGenError::git(format!("Failed to resolve git dir: {stderr}")));
221 }
222
223 Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
224}
225
226pub fn get_git_diff(
228 mode: &Mode,
229 target: Option<&str>,
230 dir: &str,
231 config: &CommitConfig,
232) -> Result<String> {
233 let output = match mode {
234 Mode::Staged => git_command()
235 .args(["diff", "--cached"])
236 .current_dir(dir)
237 .output()
238 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --cached: {e}")))?,
239 Mode::Commit => {
240 let target = target.ok_or_else(|| {
241 CommitGenError::ValidationError("--target required for commit mode".to_string())
242 })?;
243 let mut cmd = git_command();
244 cmd.arg("show");
245 if config.exclude_old_message {
246 cmd.arg("--format=");
247 }
248 cmd.arg(target)
249 .current_dir(dir)
250 .output()
251 .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?
252 },
253 Mode::Unstaged => {
254 let tracked_output = git_command()
256 .args(["diff"])
257 .current_dir(dir)
258 .output()
259 .map_err(|e| CommitGenError::git(format!("Failed to run git diff: {e}")))?;
260
261 if !tracked_output.status.success() {
262 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
263 return Err(CommitGenError::git(format!("git diff failed: {stderr}")));
264 }
265
266 let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
267 let untracked_files = list_untracked_files(dir)?;
268 return append_untracked_diff(tracked_diff, dir, &untracked_files);
269 },
270 Mode::Compose => unreachable!("compose mode handled separately"),
271 };
272
273 if !output.status.success() {
274 let stderr = String::from_utf8_lossy(&output.stderr);
275 return Err(CommitGenError::git(format!("Git command failed: {stderr}")));
276 }
277
278 let diff = String::from_utf8_lossy(&output.stdout).to_string();
279
280 if diff.trim().is_empty() {
281 let mode_str = match mode {
282 Mode::Staged => "staged",
283 Mode::Commit => "commit",
284 Mode::Unstaged => "unstaged",
285 Mode::Compose => "compose",
286 };
287 return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
288 }
289
290 Ok(diff)
291}
292
293pub fn get_git_stat(
295 mode: &Mode,
296 target: Option<&str>,
297 dir: &str,
298 config: &CommitConfig,
299) -> Result<String> {
300 let output = match mode {
301 Mode::Staged => git_command()
302 .args(["diff", "--cached", "--stat"])
303 .current_dir(dir)
304 .output()
305 .map_err(|e| {
306 CommitGenError::git(format!("Failed to run git diff --cached --stat: {e}"))
307 })?,
308 Mode::Commit => {
309 let target = target.ok_or_else(|| {
310 CommitGenError::ValidationError("--target required for commit mode".to_string())
311 })?;
312 let mut cmd = git_command();
313 cmd.arg("show");
314 if config.exclude_old_message {
315 cmd.arg("--format=");
316 }
317 cmd.arg("--stat")
318 .arg(target)
319 .current_dir(dir)
320 .output()
321 .map_err(|e| CommitGenError::git(format!("Failed to run git show --stat: {e}")))?
322 },
323 Mode::Unstaged => {
324 let tracked_output = git_command()
326 .args(["diff", "--stat"])
327 .current_dir(dir)
328 .output()
329 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --stat: {e}")))?;
330
331 if !tracked_output.status.success() {
332 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
333 return Err(CommitGenError::git(format!("git diff --stat failed: {stderr}")));
334 }
335
336 let stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
337 let untracked_files = list_untracked_files(dir)?;
338 return Ok(append_untracked_stat(stat, dir, &untracked_files));
339 },
340 Mode::Compose => unreachable!("compose mode handled separately"),
341 };
342
343 if !output.status.success() {
344 let stderr = String::from_utf8_lossy(&output.stderr);
345 return Err(CommitGenError::git(format!("Git stat command failed: {stderr}")));
346 }
347
348 Ok(String::from_utf8_lossy(&output.stdout).to_string())
349}
350
351pub fn get_git_numstat(
352 mode: &Mode,
353 target: Option<&str>,
354 dir: &str,
355 config: &CommitConfig,
356) -> Result<String> {
357 let output = match mode {
358 Mode::Staged => git_command()
359 .args(["diff", "--cached", "--numstat"])
360 .current_dir(dir)
361 .output()
362 .map_err(|e| {
363 CommitGenError::git(format!("Failed to run git diff --cached --numstat: {e}"))
364 })?,
365 Mode::Commit => {
366 let target = target.ok_or_else(|| {
367 CommitGenError::ValidationError("--target required for commit mode".to_string())
368 })?;
369 let mut cmd = git_command();
370 cmd.arg("show");
371 if config.exclude_old_message {
372 cmd.arg("--format=");
373 }
374 cmd.arg("--numstat")
375 .arg(target)
376 .current_dir(dir)
377 .output()
378 .map_err(|e| CommitGenError::git(format!("Failed to run git show --numstat: {e}")))?
379 },
380 Mode::Unstaged => {
381 let tracked_output = git_command()
382 .args(["diff", "--numstat"])
383 .current_dir(dir)
384 .output()
385 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --numstat: {e}")))?;
386
387 if !tracked_output.status.success() {
388 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
389 return Err(CommitGenError::git(format!("git diff --numstat failed: {stderr}")));
390 }
391
392 let numstat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
393 let untracked_files = list_untracked_files(dir)?;
394 return Ok(append_untracked_numstat(numstat, dir, &untracked_files));
395 },
396 Mode::Compose => unreachable!("compose mode handled separately"),
397 };
398
399 if !output.status.success() {
400 let stderr = String::from_utf8_lossy(&output.stderr);
401 return Err(CommitGenError::git(format!("Git numstat command failed: {stderr}")));
402 }
403
404 Ok(String::from_utf8_lossy(&output.stdout).to_string())
405}
406
407pub fn get_compose_diff(dir: &str) -> Result<String> {
408 let output = git_command()
409 .args(["diff", "HEAD"])
410 .current_dir(dir)
411 .output()
412 .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD: {e}")))?;
413
414 if !output.status.success() {
415 let stderr = String::from_utf8_lossy(&output.stderr);
416 return Err(CommitGenError::git(format!("git diff HEAD failed: {stderr}")));
417 }
418
419 let diff = String::from_utf8_lossy(&output.stdout).to_string();
420 let untracked_files = list_untracked_files(dir)?;
421 let diff = append_untracked_diff(diff, dir, &untracked_files)?;
422
423 if diff.trim().is_empty() {
424 return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
425 }
426
427 Ok(diff)
428}
429
430pub fn get_compose_stat(dir: &str) -> Result<String> {
431 let output = git_command()
432 .args(["diff", "HEAD", "--stat"])
433 .current_dir(dir)
434 .output()
435 .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD --stat: {e}")))?;
436
437 if !output.status.success() {
438 let stderr = String::from_utf8_lossy(&output.stderr);
439 return Err(CommitGenError::git(format!("git diff HEAD --stat failed: {stderr}")));
440 }
441
442 let stat = String::from_utf8_lossy(&output.stdout).to_string();
443 let untracked_files = list_untracked_files(dir)?;
444 let stat = append_untracked_stat(stat, dir, &untracked_files);
445
446 if stat.trim().is_empty() {
447 return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
448 }
449
450 Ok(stat)
451}
452
453#[allow(clippy::fn_params_excessive_bools, reason = "commit flags are naturally boolean")]
455pub fn git_commit(
456 message: &str,
457 dry_run: bool,
458 dir: &str,
459 sign: bool,
460 signoff: bool,
461 skip_hooks: bool,
462 amend: bool,
463) -> Result<()> {
464 if dry_run {
465 let sign_flag = if sign { " -S" } else { "" };
466 let signoff_flag = if signoff { " -s" } else { "" };
467 let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
468 let amend_flag = if amend { " --amend" } else { "" };
469 let command = format!(
470 "git commit{sign_flag}{signoff_flag}{hooks_flag}{amend_flag} -m \"{}\"",
471 message.replace('\n', "\\n")
472 );
473 if style::pipe_mode() {
474 eprintln!("\n{}", style::boxed_message("DRY RUN", &command, 60));
475 } else {
476 println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
477 }
478 return Ok(());
479 }
480
481 let mut args = vec!["commit"];
482 if sign {
483 args.push("-S");
484 }
485 if signoff {
486 args.push("-s");
487 }
488 if skip_hooks {
489 args.push("--no-verify");
490 }
491 if amend {
492 args.push("--amend");
493 }
494 args.push("-m");
495 args.push(message);
496
497 let output = git_command()
498 .args(&args)
499 .current_dir(dir)
500 .output()
501 .map_err(|e| CommitGenError::git(format!("Failed to run git commit: {e}")))?;
502
503 if !output.status.success() {
504 let stderr = String::from_utf8_lossy(&output.stderr);
505 let stdout = String::from_utf8_lossy(&output.stdout);
506 if let Some(err) = check_index_lock(&stderr, dir) {
507 return Err(err);
508 }
509 return Err(CommitGenError::git(format!("git commit failed: {stderr}{stdout}")));
510 }
511
512 let stdout = String::from_utf8_lossy(&output.stdout);
513 if style::pipe_mode() {
514 eprintln!("\n{stdout}");
515 eprintln!(
516 "{} {}",
517 style::success(style::icons::SUCCESS),
518 style::success("Successfully committed!")
519 );
520 } else {
521 println!("\n{stdout}");
522 println!(
523 "{} {}",
524 style::success(style::icons::SUCCESS),
525 style::success("Successfully committed!")
526 );
527 }
528
529 Ok(())
530}
531
532pub fn git_push(dir: &str) -> Result<()> {
534 if style::pipe_mode() {
535 eprintln!("\n{}", style::info("Pushing changes..."));
536 } else {
537 println!("\n{}", style::info("Pushing changes..."));
538 }
539
540 let output = git_command()
541 .args(["push"])
542 .current_dir(dir)
543 .output()
544 .map_err(|e| CommitGenError::git(format!("Failed to run git push: {e}")))?;
545
546 if !output.status.success() {
547 let stderr = String::from_utf8_lossy(&output.stderr);
548 let stdout = String::from_utf8_lossy(&output.stdout);
549 return Err(CommitGenError::git(format!(
550 "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
551 )));
552 }
553
554 let stdout = String::from_utf8_lossy(&output.stdout);
555 let stderr = String::from_utf8_lossy(&output.stderr);
556 if style::pipe_mode() {
557 if !stdout.is_empty() {
558 eprintln!("{stdout}");
559 }
560 if !stderr.is_empty() {
561 eprintln!("{stderr}");
562 }
563 eprintln!(
564 "{} {}",
565 style::success(style::icons::SUCCESS),
566 style::success("Successfully pushed!")
567 );
568 } else {
569 if !stdout.is_empty() {
570 println!("{stdout}");
571 }
572 if !stderr.is_empty() {
573 println!("{stderr}");
574 }
575 println!(
576 "{} {}",
577 style::success(style::icons::SUCCESS),
578 style::success("Successfully pushed!")
579 );
580 }
581
582 Ok(())
583}
584
585pub fn get_head_hash(dir: &str) -> Result<String> {
587 let output = git_command()
588 .args(["rev-parse", "HEAD"])
589 .current_dir(dir)
590 .output()
591 .map_err(|e| CommitGenError::git(format!("Failed to get HEAD hash: {e}")))?;
592
593 if !output.status.success() {
594 let stderr = String::from_utf8_lossy(&output.stderr);
595 return Err(CommitGenError::git(format!("git rev-parse HEAD failed: {stderr}")));
596 }
597
598 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
599}
600
601pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
605 let mut args = vec!["rev-list", "--reverse"];
606 let range;
607 if let Some(start) = start_ref {
608 range = format!("{start}..HEAD");
609 args.push(&range);
610 } else {
611 args.push("HEAD");
612 }
613
614 let output = git_command()
615 .args(&args)
616 .current_dir(dir)
617 .output()
618 .map_err(|e| CommitGenError::git(format!("Failed to run git rev-list: {e}")))?;
619
620 if !output.status.success() {
621 let stderr = String::from_utf8_lossy(&output.stderr);
622 return Err(CommitGenError::git(format!("git rev-list failed: {stderr}")));
623 }
624
625 let stdout = String::from_utf8_lossy(&output.stdout);
626 Ok(stdout.lines().map(|s| s.to_string()).collect())
627}
628
629pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
631 let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
634
635 let info_output = git_command()
636 .args(["show", "-s", &format!("--format={format_str}"), hash])
637 .current_dir(dir)
638 .output()
639 .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
640
641 if !info_output.status.success() {
642 let stderr = String::from_utf8_lossy(&info_output.stderr);
643 return Err(CommitGenError::git(format!("git show failed for {hash}: {stderr}")));
644 }
645
646 let info = String::from_utf8_lossy(&info_output.stdout);
647 let parts: Vec<&str> = info.splitn(7, '\0').collect();
648
649 if parts.len() < 7 {
650 return Err(CommitGenError::git(format!("Failed to parse commit metadata for {hash}")));
651 }
652
653 let tree_output = git_command()
655 .args(["rev-parse", &format!("{hash}^{{tree}}")])
656 .current_dir(dir)
657 .output()
658 .map_err(|e| CommitGenError::git(format!("Failed to get tree hash: {e}")))?;
659 let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
660 .trim()
661 .to_string();
662
663 let parents_output = git_command()
665 .args(["rev-list", "--parents", "-n", "1", hash])
666 .current_dir(dir)
667 .output()
668 .map_err(|e| CommitGenError::git(format!("Failed to get parent hashes: {e}")))?;
669 let parents_line = String::from_utf8_lossy(&parents_output.stdout);
670 let parent_hashes: Vec<String> = parents_line
671 .split_whitespace()
672 .skip(1) .map(|s| s.to_string())
674 .collect();
675
676 Ok(CommitMetadata {
677 hash: hash.to_string(),
678 author_name: parts[0].to_string(),
679 author_email: parts[1].to_string(),
680 author_date: parts[2].to_string(),
681 committer_name: parts[3].to_string(),
682 committer_email: parts[4].to_string(),
683 committer_date: parts[5].to_string(),
684 message: parts[6].trim().to_string(),
685 parent_hashes,
686 tree_hash,
687 })
688}
689
690pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
692 let output = git_command()
693 .args(["status", "--porcelain"])
694 .current_dir(dir)
695 .output()
696 .map_err(|e| CommitGenError::git(format!("Failed to check working tree: {e}")))?;
697
698 Ok(output.stdout.is_empty())
699}
700
701pub fn create_backup_branch(dir: &str) -> Result<String> {
703 use chrono::Local;
704
705 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
706 let backup_name = format!("backup-rewrite-{timestamp}");
707
708 let output = git_command()
709 .args(["branch", &backup_name])
710 .current_dir(dir)
711 .output()
712 .map_err(|e| CommitGenError::git(format!("Failed to create backup branch: {e}")))?;
713
714 if !output.status.success() {
715 let stderr = String::from_utf8_lossy(&output.stderr);
716 return Err(CommitGenError::git(format!("git branch failed: {stderr}")));
717 }
718
719 Ok(backup_name)
720}
721
722pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
724 let output = git_command()
725 .args(["log", &format!("-{count}"), "--pretty=format:%s"])
726 .current_dir(dir)
727 .output()
728 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
729
730 if !output.status.success() {
731 let stderr = String::from_utf8_lossy(&output.stderr);
732 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
733 }
734
735 let stdout = String::from_utf8_lossy(&output.stdout);
736 Ok(stdout.lines().map(|s| s.to_string()).collect())
737}
738
739pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
741 let output = git_command()
742 .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
743 .current_dir(dir)
744 .output()
745 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
746
747 if !output.status.success() {
748 let stderr = String::from_utf8_lossy(&output.stderr);
749 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
750 }
751
752 let stdout = String::from_utf8_lossy(&output.stdout);
753 let mut scope_counts: HashMap<String, usize> = HashMap::new();
754
755 for line in stdout.lines() {
757 if let Some(scope) = extract_scope_from_commit(line) {
758 *scope_counts.entry(scope).or_insert(0) += 1;
759 }
760 }
761
762 let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
764 scopes.sort_by(|a, b| b.1.cmp(&a.1));
765
766 Ok(scopes)
767}
768
769fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
771 let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
773 if parts.len() < 2 {
774 return None;
775 }
776
777 let prefix = parts[0];
778 if let Some(scope_start) = prefix.find('(')
779 && let Some(scope_end) = prefix.find(')')
780 && scope_start < scope_end
781 {
782 return Some(prefix[scope_start + 1..scope_end].to_string());
783 }
784
785 None
786}
787
788#[derive(Debug, Clone)]
790pub struct StylePatterns {
791 pub scope_usage_pct: f32,
793 pub common_verbs: Vec<(String, usize)>,
795 pub avg_length: usize,
797 pub length_range: (usize, usize),
799 pub lowercase_pct: f32,
801 pub top_scopes: Vec<(String, usize)>,
803}
804
805impl StylePatterns {
806 pub fn format_for_prompt(&self) -> String {
808 let mut lines = Vec::new();
809
810 lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
811
812 if !self.common_verbs.is_empty() {
813 let verbs: Vec<_> = self
814 .common_verbs
815 .iter()
816 .take(5)
817 .map(|(v, c)| format!("{v} ({c})"))
818 .collect();
819 lines.push(format!("Common verbs: {}", verbs.join(", ")));
820 }
821
822 lines.push(format!(
823 "Average length: {} chars (range: {}-{})",
824 self.avg_length, self.length_range.0, self.length_range.1
825 ));
826
827 lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
828
829 if !self.top_scopes.is_empty() {
830 let scopes: Vec<_> = self
831 .top_scopes
832 .iter()
833 .take(5)
834 .map(|(s, c)| format!("{s} ({c})"))
835 .collect();
836 lines.push(format!("Top scopes: {}", scopes.join(", ")));
837 }
838
839 lines.join("\n")
840 }
841}
842
843pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
845 if commits.is_empty() {
846 return None;
847 }
848
849 let mut scope_count = 0;
850 let mut lowercase_count = 0;
851 let mut verb_counts: HashMap<String, usize> = HashMap::new();
852 let mut scope_counts: HashMap<String, usize> = HashMap::new();
853 let mut lengths = Vec::new();
854
855 for commit in commits {
856 if let Some(colon_pos) = commit.find(':') {
858 let prefix = &commit[..colon_pos];
859 let summary = commit[colon_pos + 1..].trim();
860
861 if let Some(paren_start) = prefix.find('(')
863 && let Some(paren_end) = prefix.find(')')
864 {
865 scope_count += 1;
866 let scope = &prefix[paren_start + 1..paren_end];
867 *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
868 }
869
870 if let Some(first_char) = summary.chars().next() {
872 if first_char.is_lowercase() {
873 lowercase_count += 1;
874 }
875
876 let first_word = summary.split_whitespace().next().unwrap_or("");
878 if !first_word.is_empty() {
879 *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
880 }
881 }
882
883 lengths.push(summary.len());
884 }
885 }
886
887 let total = commits.len();
888 let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
889 let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
890
891 let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
893 common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
894
895 let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
897 top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
898
899 let avg_length = if lengths.is_empty() {
901 0
902 } else {
903 lengths.iter().sum::<usize>() / lengths.len()
904 };
905 let length_range = if lengths.is_empty() {
906 (0, 0)
907 } else {
908 (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
909 };
910
911 Some(StylePatterns {
912 scope_usage_pct,
913 common_verbs,
914 avg_length,
915 length_range,
916 lowercase_pct,
917 top_scopes,
918 })
919}
920
921pub fn rewrite_history(
923 commits: &[CommitMetadata],
924 new_messages: &[String],
925 dir: &str,
926) -> Result<()> {
927 if commits.len() != new_messages.len() {
928 return Err(CommitGenError::Other("Commit count mismatch".to_string()));
929 }
930
931 let branch_output = git_command()
933 .args(["rev-parse", "--abbrev-ref", "HEAD"])
934 .current_dir(dir)
935 .output()
936 .map_err(|e| CommitGenError::git(format!("Failed to get current branch: {e}")))?;
937 let current_branch = String::from_utf8_lossy(&branch_output.stdout)
938 .trim()
939 .to_string();
940
941 let mut parent_map: HashMap<String, String> = HashMap::new();
943 let mut new_head: Option<String> = None;
944
945 for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
946 let new_parents: Vec<String> = commit
948 .parent_hashes
949 .iter()
950 .map(|old_parent| {
951 parent_map
952 .get(old_parent)
953 .cloned()
954 .unwrap_or_else(|| old_parent.clone())
955 })
956 .collect();
957
958 let mut cmd = git_command();
960 cmd.arg("commit-tree")
961 .arg(&commit.tree_hash)
962 .arg("-m")
963 .arg(new_msg)
964 .current_dir(dir);
965
966 for parent in &new_parents {
967 cmd.arg("-p").arg(parent);
968 }
969
970 cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
972 .env("GIT_AUTHOR_EMAIL", &commit.author_email)
973 .env("GIT_AUTHOR_DATE", &commit.author_date)
974 .env("GIT_COMMITTER_NAME", &commit.committer_name)
975 .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
976 .env("GIT_COMMITTER_DATE", &commit.committer_date);
977
978 let output = cmd
979 .output()
980 .map_err(|e| CommitGenError::git(format!("Failed to run git commit-tree: {e}")))?;
981
982 if !output.status.success() {
983 let stderr = String::from_utf8_lossy(&output.stderr);
984 return Err(CommitGenError::git(format!(
985 "commit-tree failed for {}: {}",
986 commit.hash, stderr
987 )));
988 }
989
990 let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
991
992 parent_map.insert(commit.hash.clone(), new_hash.clone());
993 new_head = Some(new_hash);
994
995 if (idx + 1) % 50 == 0 {
997 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
998 }
999 }
1000
1001 if let Some(head) = new_head {
1003 let update_output = git_command()
1004 .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
1005 .current_dir(dir)
1006 .output()
1007 .map_err(|e| CommitGenError::git(format!("Failed to update ref: {e}")))?;
1008
1009 if !update_output.status.success() {
1010 let stderr = String::from_utf8_lossy(&update_output.stderr);
1011 return Err(CommitGenError::git(format!("git update-ref failed: {stderr}")));
1012 }
1013
1014 let reset_output = git_command()
1015 .args(["reset", "--hard", &head])
1016 .current_dir(dir)
1017 .output()
1018 .map_err(|e| CommitGenError::git(format!("Failed to reset: {e}")))?;
1019
1020 if !reset_output.status.success() {
1021 let stderr = String::from_utf8_lossy(&reset_output.stderr);
1022 return Err(CommitGenError::git(format!("git reset failed: {stderr}")));
1023 }
1024 }
1025
1026 Ok(())
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 #[test]
1034 fn test_git_command_applies_background_feature_overrides_when_enabled() {
1035 let cmd =
1036 git_command_with_settings(GitCommandSettings { disable_git_background_features: true });
1037 let args: Vec<String> = cmd
1038 .get_args()
1039 .map(|arg| arg.to_string_lossy().into_owned())
1040 .collect();
1041
1042 assert_eq!(args, vec![
1043 "-c".to_string(),
1044 "core.fsmonitor=false".to_string(),
1045 "-c".to_string(),
1046 "core.untrackedCache=false".to_string(),
1047 ]);
1048 }
1049
1050 #[test]
1051 fn test_git_command_skips_background_feature_overrides_when_disabled() {
1052 let cmd =
1053 git_command_with_settings(GitCommandSettings { disable_git_background_features: false });
1054 assert!(cmd.get_args().next().is_none());
1055 }
1056}