Skip to main content

llm_git/
git.rs

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      // `git diff --no-index` exits with 1 when files differ, which is expected.
82      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
158/// Detect a stale `index.lock` from git stderr and return a
159/// [`CommitGenError::GitIndexLocked`] with the resolved path if found.
160fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
161   if !stderr.contains("index.lock") {
162      return None;
163   }
164
165   // Try to extract the exact lock path from the error message.
166   // Git says: "Unable to create '/path/to/.git/index.lock': File exists."
167   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
184/// Ensure the provided directory is inside a git work tree.
185///
186/// # Errors
187/// Returns an error when the directory is not part of a git repository.
188pub 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
226/// Get git diff based on the specified mode
227pub 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         // Get diff for tracked files
255         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
293/// Get git diff --stat to show file-level changes summary
294pub 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         // Get stat for tracked files
325         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/// Execute git commit with the given message
454#[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
532/// Execute git push
533pub 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
585/// Get the current HEAD commit hash
586pub 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
601// === History Rewrite Operations ===
602
603/// Get list of commit hashes to rewrite (in chronological order)
604pub 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
629/// Extract complete metadata for a commit (for rewriting)
630pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
631   // Format: author_name\0author_email\0author_date\0committer_name\
632   // 0committer_email\0committer_date\0message
633   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   // Get tree hash
654   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   // Get parent hashes
664   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) // First is the commit itself
673      .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
690/// Check if working directory is clean
691pub 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
701/// Create timestamped backup branch
702pub 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
722/// Get recent commit messages for style consistency (last N commits)
723pub 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
739/// Extract common scopes from git history by parsing commit messages
740pub 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   // Parse conventional commit format: type(scope): message
756   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   // Sort by frequency (descending)
763   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
769/// Extract scope from a conventional commit message
770fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
771   // Match pattern: type(scope): message
772   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/// Quantified style patterns extracted from commit history
789#[derive(Debug, Clone)]
790pub struct StylePatterns {
791   /// Percentage of commits using scopes (0.0-100.0)
792   pub scope_usage_pct: f32,
793   /// Common verbs with counts (sorted by count descending)
794   pub common_verbs:    Vec<(String, usize)>,
795   /// Average summary length in chars
796   pub avg_length:      usize,
797   /// Summary length range (min, max)
798   pub length_range:    (usize, usize),
799   /// Percentage of commits starting with lowercase (0.0-100.0)
800   pub lowercase_pct:   f32,
801   /// Top scopes with counts (sorted by count descending)
802   pub top_scopes:      Vec<(String, usize)>,
803}
804
805impl StylePatterns {
806   /// Format patterns for prompt injection
807   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
843/// Extract style patterns from commit history
844pub 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      // Parse: type(scope): summary
857      if let Some(colon_pos) = commit.find(':') {
858         let prefix = &commit[..colon_pos];
859         let summary = commit[colon_pos + 1..].trim();
860
861         // Check for scope
862         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         // Check capitalization of summary
871         if let Some(first_char) = summary.chars().next() {
872            if first_char.is_lowercase() {
873               lowercase_count += 1;
874            }
875
876            // Extract first word as verb
877            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   // Sort verbs by count
892   let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
893   common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
894
895   // Sort scopes by count
896   let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
897   top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
898
899   // Calculate length stats
900   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
921/// Rewrite git history with new commit messages
922pub 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   // Get current branch
932   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   // Map old commit hashes to new ones
942   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      // Map old parents to new parents
947      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      // Build commit-tree command
959      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      // Preserve original author/committer metadata
971      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      // Progress reporting
996      if (idx + 1) % 50 == 0 {
997         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
998      }
999   }
1000
1001   // Update branch to new head
1002   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}