Skip to main content

llm_git/
git.rs

1use std::{
2   collections::HashMap,
3   fs,
4   io::Write,
5   path::{Path, PathBuf},
6   process::{Command, Stdio},
7   sync::OnceLock,
8   time::{SystemTime, UNIX_EPOCH},
9};
10
11pub use self::git_push as push;
12use crate::{
13   config::CommitConfig,
14   error::{CommitGenError, Result},
15   style,
16   types::{CommitMetadata, Mode},
17};
18
19#[derive(Debug, Clone, Copy)]
20struct GitCommandSettings {
21   disable_git_background_features: bool,
22}
23
24impl Default for GitCommandSettings {
25   fn default() -> Self {
26      Self { disable_git_background_features: true }
27   }
28}
29
30static GIT_COMMAND_SETTINGS: OnceLock<GitCommandSettings> = OnceLock::new();
31
32pub fn init_git_command_settings(config: &CommitConfig) {
33   let _ = GIT_COMMAND_SETTINGS.set(GitCommandSettings {
34      disable_git_background_features: config.disable_git_background_features,
35   });
36}
37
38fn current_git_command_settings() -> GitCommandSettings {
39   GIT_COMMAND_SETTINGS.get().copied().unwrap_or_default()
40}
41
42fn apply_git_command_overrides(cmd: &mut Command, settings: GitCommandSettings) {
43   if settings.disable_git_background_features {
44      cmd.args(["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false"]);
45   }
46}
47
48pub fn git_command() -> Command {
49   git_command_with_settings(current_git_command_settings())
50}
51
52/// A temporary Git index file under `.git/llm-git/`.
53///
54/// The file is removed on drop, along with Git's sibling lock file if one was
55/// left behind by an interrupted command.
56pub struct TempGitIndex {
57   path: PathBuf,
58}
59
60impl TempGitIndex {
61   pub fn new(dir: &str) -> Result<Self> {
62      let temp_dir = get_git_dir(dir)?.join("llm-git");
63      fs::create_dir_all(&temp_dir).map_err(|e| {
64         CommitGenError::git(format!("Failed to create temporary git index directory: {e}"))
65      })?;
66
67      let pid = std::process::id();
68      let nanos = SystemTime::now()
69         .duration_since(UNIX_EPOCH)
70         .map_or(0, |duration| duration.as_nanos());
71
72      for attempt in 0..100_u32 {
73         let path = temp_dir.join(format!("index-{pid}-{nanos}-{attempt}"));
74         match fs::OpenOptions::new()
75            .write(true)
76            .create_new(true)
77            .open(&path)
78         {
79            Ok(_) => {
80               let _ = fs::remove_file(&path);
81               return Ok(Self { path });
82            },
83            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {},
84            Err(err) => {
85               return Err(CommitGenError::git(format!(
86                  "Failed to create temporary git index: {err}"
87               )));
88            },
89         }
90      }
91
92      Err(CommitGenError::git("Failed to allocate unique temporary git index path".to_string()))
93   }
94
95   pub fn path(&self) -> &Path {
96      &self.path
97   }
98}
99
100impl Drop for TempGitIndex {
101   fn drop(&mut self) {
102      let _ = fs::remove_file(&self.path);
103      let lock_path = self.path.with_extension("lock");
104      let _ = fs::remove_file(lock_path);
105   }
106}
107
108pub fn git_command_with_index(index_file: &Path) -> Command {
109   let mut cmd = git_command();
110   cmd.env("GIT_INDEX_FILE", index_file);
111   cmd
112}
113
114fn git_command_with_settings(settings: GitCommandSettings) -> Command {
115   let mut cmd = Command::new("git");
116   apply_git_command_overrides(&mut cmd, settings);
117   cmd
118}
119
120fn diff_lines_preserve_cr(input: &str) -> impl Iterator<Item = &str> {
121   input
122      .split_inclusive('\n')
123      .map(|line| line.strip_suffix('\n').unwrap_or(line))
124}
125
126fn list_untracked_files(dir: &str) -> Result<Vec<String>> {
127   let output = git_command()
128      .args(["ls-files", "--others", "--exclude-standard"])
129      .current_dir(dir)
130      .output()
131      .map_err(|e| CommitGenError::git(format!("Failed to list untracked files: {e}")))?;
132
133   if !output.status.success() {
134      let stderr = String::from_utf8_lossy(&output.stderr);
135      return Err(CommitGenError::git(format!("git ls-files failed: {stderr}")));
136   }
137
138   Ok(String::from_utf8_lossy(&output.stdout)
139      .lines()
140      .filter(|path| !path.is_empty())
141      .map(str::to_string)
142      .collect())
143}
144
145fn append_untracked_diff(
146   mut base_diff: String,
147   dir: &str,
148   untracked_files: &[String],
149) -> Result<String> {
150   for file in untracked_files {
151      let file_diff_output = git_command()
152         .args([
153            "diff",
154            "--no-index",
155            "--no-ext-diff",
156            "--no-textconv",
157            "--no-color",
158            "--src-prefix=a/",
159            "--dst-prefix=b/",
160            "/dev/null",
161            file,
162         ])
163         .current_dir(dir)
164         .output()
165         .map_err(|e| CommitGenError::git(format!("Failed to diff untracked file {file}: {e}")))?;
166
167      // `git diff --no-index` exits with 1 when files differ, which is expected.
168      if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
169         let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
170         let lines: Vec<&str> = diff_lines_preserve_cr(&file_diff).collect();
171         if lines.len() >= 2 {
172            let mode = lines
173               .iter()
174               .find_map(|line| line.strip_prefix("new file mode "))
175               .unwrap_or("100644");
176            use std::fmt::Write;
177            if !base_diff.is_empty() {
178               base_diff.push('\n');
179            }
180            writeln!(base_diff, "diff --git a/{file} b/{file}").unwrap();
181            writeln!(base_diff, "new file mode {mode}").unwrap();
182            base_diff.push_str("index 0000000..0000000\n");
183            base_diff.push_str("--- /dev/null\n");
184            writeln!(base_diff, "+++ b/{file}").unwrap();
185            for line in lines
186               .iter()
187               .skip_while(|line| !line.starts_with("@@") && !line.starts_with("Binary files "))
188            {
189               base_diff.push_str(line);
190               base_diff.push('\n');
191            }
192         }
193      }
194   }
195
196   Ok(base_diff)
197}
198
199fn append_untracked_stat(mut stat: String, dir: &str, untracked_files: &[String]) -> String {
200   use std::fmt::Write;
201
202   for file in untracked_files {
203      use std::fs;
204
205      if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
206         let lines = if metadata.is_file() {
207            fs::read_to_string(format!("{dir}/{file}")).map_or(0, |content| content.lines().count())
208         } else {
209            0
210         };
211
212         if !stat.is_empty() && !stat.ends_with('\n') {
213            stat.push('\n');
214         }
215         writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
216      }
217   }
218
219   stat
220}
221
222fn append_untracked_numstat(mut numstat: String, dir: &str, untracked_files: &[String]) -> String {
223   use std::fmt::Write;
224
225   for file in untracked_files {
226      use std::fs;
227
228      let path = format!("{dir}/{file}");
229      if let Ok(metadata) = fs::metadata(&path) {
230         let (added, deleted) = if metadata.is_file() {
231            match fs::read_to_string(&path) {
232               Ok(content) => (content.lines().count().to_string(), "0".to_string()),
233               Err(_) => ("-".to_string(), "-".to_string()),
234            }
235         } else {
236            ("0".to_string(), "0".to_string())
237         };
238
239         if !numstat.is_empty() && !numstat.ends_with('\n') {
240            numstat.push('\n');
241         }
242         writeln!(numstat, "{added}\t{deleted}\t{file}").unwrap();
243      }
244   }
245
246   numstat
247}
248
249/// Detect a stale `index.lock` from git stderr and return a
250/// [`CommitGenError::GitIndexLocked`] with the resolved path if found.
251fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
252   if !stderr.contains("index.lock") {
253      return None;
254   }
255
256   // Try to extract the exact lock path from the error message.
257   // Git says: "Unable to create '/path/to/.git/index.lock': File exists."
258   let lock_path = stderr
259      .lines()
260      .find_map(|line| {
261         let start = line.find('\'')?;
262         let end = line[start + 1..].find('\'')?;
263         let path = &line[start + 1..start + 1 + end];
264         if path.ends_with("index.lock") {
265            Some(PathBuf::from(path))
266         } else {
267            None
268         }
269      })
270      .unwrap_or_else(|| PathBuf::from(dir).join(".git/index.lock"));
271
272   Some(CommitGenError::GitIndexLocked { lock_path })
273}
274
275/// Ensure the provided directory is inside a git work tree.
276///
277/// # Errors
278/// Returns an error when the directory is not part of a git repository.
279#[tracing::instrument(target = "lgit", name = "git.ensure_repo", skip_all, fields(dir))]
280pub fn ensure_git_repo(dir: &str) -> Result<()> {
281   let output = git_command()
282      .args(["rev-parse", "--show-toplevel"])
283      .current_dir(dir)
284      .output()
285      .map_err(|e| CommitGenError::git(format!("Failed to run git rev-parse: {e}")))?;
286
287   if output.status.success() {
288      return Ok(());
289   }
290
291   let stderr = String::from_utf8_lossy(&output.stderr);
292   if stderr.contains("not a git repository") {
293      return Err(CommitGenError::git(
294         "Not a git repository (or any of the parent directories): .git".to_string(),
295      ));
296   }
297
298   Err(CommitGenError::git(format!("Failed to detect git repository: {stderr}")))
299}
300
301#[tracing::instrument(target = "lgit", name = "git.get_git_dir", skip_all, fields(dir))]
302pub fn get_git_dir(dir: &str) -> Result<PathBuf> {
303   let output = git_command()
304      .args(["rev-parse", "--absolute-git-dir"])
305      .current_dir(dir)
306      .output()
307      .map_err(|e| {
308         CommitGenError::git(format!("Failed to run git rev-parse --absolute-git-dir: {e}"))
309      })?;
310
311   if !output.status.success() {
312      let stderr = String::from_utf8_lossy(&output.stderr);
313      return Err(CommitGenError::git(format!("Failed to resolve git dir: {stderr}")));
314   }
315
316   Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
317}
318
319/// Get git diff based on the specified mode
320#[tracing::instrument(target = "lgit", name = "git.diff", skip_all, fields(mode = ?mode, target = ?target, dir))]
321pub fn get_git_diff(
322   mode: &Mode,
323   target: Option<&str>,
324   dir: &str,
325   config: &CommitConfig,
326) -> Result<String> {
327   let max_len = config.max_diff_length;
328   let output = match mode {
329      Mode::Staged => {
330         let output = git_command()
331            .args(["diff", "--cached"])
332            .current_dir(dir)
333            .output()
334            .map_err(|e| CommitGenError::git(format!("Failed to run git diff --cached: {e}")))?;
335         if !output.status.success() {
336            let stderr = String::from_utf8_lossy(&output.stderr);
337            return Err(CommitGenError::git(format!("git diff --cached failed: {stderr}")));
338         }
339         if output.stdout.len() > max_len {
340            tracing::info!("Diff exceeds max_diff_length ({max_len}), retrying with -U1");
341            git_command()
342               .args(["diff", "--cached", "-U1"])
343               .current_dir(dir)
344               .output()
345               .map_err(|e| {
346                  CommitGenError::git(format!("Failed to run git diff --cached -U1: {e}"))
347               })?
348         } else {
349            output
350         }
351      },
352      Mode::Commit => {
353         let target = target.ok_or_else(|| {
354            CommitGenError::ValidationError("--target required for commit mode".to_string())
355         })?;
356         let mut cmd = git_command();
357         cmd.arg("show");
358         if config.exclude_old_message {
359            cmd.arg("--format=");
360         }
361         cmd.arg(target);
362         let output = cmd
363            .current_dir(dir)
364            .output()
365            .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
366         if !output.status.success() {
367            let stderr = String::from_utf8_lossy(&output.stderr);
368            return Err(CommitGenError::git(format!("git show failed: {stderr}")));
369         }
370         if output.stdout.len() > max_len {
371            tracing::info!("Diff exceeds max_diff_length ({max_len}), retrying with -U1");
372            let mut cmd = git_command();
373            cmd.arg("show");
374            if config.exclude_old_message {
375               cmd.arg("--format=");
376            }
377            cmd.arg("-U1")
378               .arg(target)
379               .current_dir(dir)
380               .output()
381               .map_err(|e| CommitGenError::git(format!("Failed to run git show -U1: {e}")))?
382         } else {
383            output
384         }
385      },
386      Mode::Unstaged => {
387         // Get diff for tracked files
388         let tracked_output = git_command()
389            .args(["diff"])
390            .current_dir(dir)
391            .output()
392            .map_err(|e| CommitGenError::git(format!("Failed to run git diff: {e}")))?;
393
394         if !tracked_output.status.success() {
395            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
396            return Err(CommitGenError::git(format!("git diff failed: {stderr}")));
397         }
398
399         let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
400         let diff = if tracked_diff.len() > max_len {
401            tracing::info!("Diff exceeds max_diff_length ({max_len}), retrying with -U1");
402            let output = git_command()
403               .args(["diff", "-U1"])
404               .current_dir(dir)
405               .output()
406               .map_err(|e| CommitGenError::git(format!("Failed to run git diff -U1: {e}")))?;
407            if !output.status.success() {
408               let stderr = String::from_utf8_lossy(&output.stderr);
409               return Err(CommitGenError::git(format!("git diff -U1 failed: {stderr}")));
410            }
411            String::from_utf8_lossy(&output.stdout).to_string()
412         } else {
413            tracked_diff
414         };
415
416         let untracked_files = list_untracked_files(dir)?;
417         return append_untracked_diff(diff, dir, &untracked_files);
418      },
419      Mode::Compose => unreachable!("compose mode handled separately"),
420   };
421
422   if !output.status.success() {
423      let stderr = String::from_utf8_lossy(&output.stderr);
424      return Err(CommitGenError::git(format!("Git command failed: {stderr}")));
425   }
426
427   let diff = String::from_utf8_lossy(&output.stdout).to_string();
428
429   if diff.trim().is_empty() {
430      let mode_str = match mode {
431         Mode::Staged => "staged",
432         Mode::Commit => "commit",
433         Mode::Unstaged => "unstaged",
434         Mode::Compose => "compose",
435      };
436      return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
437   }
438
439   Ok(diff)
440}
441
442/// Get git diff --stat to show file-level changes summary
443#[tracing::instrument(target = "lgit", name = "git.stat", skip_all, fields(mode = ?mode, target = ?target, dir))]
444pub fn get_git_stat(
445   mode: &Mode,
446   target: Option<&str>,
447   dir: &str,
448   config: &CommitConfig,
449) -> Result<String> {
450   let output = match mode {
451      Mode::Staged => git_command()
452         .args(["diff", "--cached", "--stat"])
453         .current_dir(dir)
454         .output()
455         .map_err(|e| {
456            CommitGenError::git(format!("Failed to run git diff --cached --stat: {e}"))
457         })?,
458      Mode::Commit => {
459         let target = target.ok_or_else(|| {
460            CommitGenError::ValidationError("--target required for commit mode".to_string())
461         })?;
462         let mut cmd = git_command();
463         cmd.arg("show");
464         if config.exclude_old_message {
465            cmd.arg("--format=");
466         }
467         cmd.arg("--stat")
468            .arg(target)
469            .current_dir(dir)
470            .output()
471            .map_err(|e| CommitGenError::git(format!("Failed to run git show --stat: {e}")))?
472      },
473      Mode::Unstaged => {
474         // Get stat for tracked files
475         let tracked_output = git_command()
476            .args(["diff", "--stat"])
477            .current_dir(dir)
478            .output()
479            .map_err(|e| CommitGenError::git(format!("Failed to run git diff --stat: {e}")))?;
480
481         if !tracked_output.status.success() {
482            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
483            return Err(CommitGenError::git(format!("git diff --stat failed: {stderr}")));
484         }
485
486         let stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
487         let untracked_files = list_untracked_files(dir)?;
488         return Ok(append_untracked_stat(stat, dir, &untracked_files));
489      },
490      Mode::Compose => unreachable!("compose mode handled separately"),
491   };
492
493   if !output.status.success() {
494      let stderr = String::from_utf8_lossy(&output.stderr);
495      return Err(CommitGenError::git(format!("Git stat command failed: {stderr}")));
496   }
497
498   Ok(String::from_utf8_lossy(&output.stdout).to_string())
499}
500
501#[tracing::instrument(target = "lgit", name = "git.numstat", skip_all, fields(mode = ?mode, target = ?target, dir))]
502pub fn get_git_numstat(
503   mode: &Mode,
504   target: Option<&str>,
505   dir: &str,
506   config: &CommitConfig,
507) -> Result<String> {
508   let output = match mode {
509      Mode::Staged => git_command()
510         .args(["diff", "--cached", "--numstat"])
511         .current_dir(dir)
512         .output()
513         .map_err(|e| {
514            CommitGenError::git(format!("Failed to run git diff --cached --numstat: {e}"))
515         })?,
516      Mode::Commit => {
517         let target = target.ok_or_else(|| {
518            CommitGenError::ValidationError("--target required for commit mode".to_string())
519         })?;
520         let mut cmd = git_command();
521         cmd.arg("show");
522         if config.exclude_old_message {
523            cmd.arg("--format=");
524         }
525         cmd.arg("--numstat")
526            .arg(target)
527            .current_dir(dir)
528            .output()
529            .map_err(|e| CommitGenError::git(format!("Failed to run git show --numstat: {e}")))?
530      },
531      Mode::Unstaged => {
532         let tracked_output = git_command()
533            .args(["diff", "--numstat"])
534            .current_dir(dir)
535            .output()
536            .map_err(|e| CommitGenError::git(format!("Failed to run git diff --numstat: {e}")))?;
537
538         if !tracked_output.status.success() {
539            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
540            return Err(CommitGenError::git(format!("git diff --numstat failed: {stderr}")));
541         }
542
543         let numstat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
544         let untracked_files = list_untracked_files(dir)?;
545         return Ok(append_untracked_numstat(numstat, dir, &untracked_files));
546      },
547      Mode::Compose => unreachable!("compose mode handled separately"),
548   };
549
550   if !output.status.success() {
551      let stderr = String::from_utf8_lossy(&output.stderr);
552      return Err(CommitGenError::git(format!("Git numstat command failed: {stderr}")));
553   }
554
555   Ok(String::from_utf8_lossy(&output.stdout).to_string())
556}
557
558#[tracing::instrument(target = "lgit", name = "git.compose_diff", skip_all, fields(dir))]
559pub fn get_compose_diff(dir: &str) -> Result<String> {
560   get_compose_diff_with_config(dir, &CommitConfig::default())
561}
562
563#[tracing::instrument(
564   target = "lgit",
565   name = "git.compose_diff_with_config",
566   skip_all,
567   fields(dir)
568)]
569pub fn get_compose_diff_with_config(dir: &str, config: &CommitConfig) -> Result<String> {
570   let max_len = config.max_diff_length;
571   let output = git_command()
572      .args([
573         "diff",
574         "--no-ext-diff",
575         "--no-textconv",
576         "--no-color",
577         "--src-prefix=a/",
578         "--dst-prefix=b/",
579         "HEAD",
580      ])
581      .current_dir(dir)
582      .output()
583      .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD: {e}")))?;
584
585   if !output.status.success() {
586      let stderr = String::from_utf8_lossy(&output.stderr);
587      return Err(CommitGenError::git(format!("git diff HEAD failed: {stderr}")));
588   }
589
590   let diff = if output.stdout.len() > max_len {
591      tracing::info!("Compose diff exceeds max_diff_length ({max_len}), retrying with -U1");
592      let output = git_command()
593         .args([
594            "diff",
595            "--no-ext-diff",
596            "--no-textconv",
597            "--no-color",
598            "--src-prefix=a/",
599            "--dst-prefix=b/",
600            "-U1",
601            "HEAD",
602         ])
603         .current_dir(dir)
604         .output()
605         .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD -U1: {e}")))?;
606      if !output.status.success() {
607         let stderr = String::from_utf8_lossy(&output.stderr);
608         return Err(CommitGenError::git(format!("git diff HEAD -U1 failed: {stderr}")));
609      }
610      String::from_utf8_lossy(&output.stdout).to_string()
611   } else {
612      String::from_utf8_lossy(&output.stdout).to_string()
613   };
614
615   let untracked_files = list_untracked_files(dir)?;
616   let diff = append_untracked_diff(diff, dir, &untracked_files)?;
617
618   if diff.trim().is_empty() {
619      return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
620   }
621
622   Ok(diff)
623}
624
625#[tracing::instrument(target = "lgit", name = "git.compose_stat", skip_all, fields(dir))]
626pub fn get_compose_stat(dir: &str) -> Result<String> {
627   let output = git_command()
628      .args(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--stat"])
629      .current_dir(dir)
630      .output()
631      .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD --stat: {e}")))?;
632
633   if !output.status.success() {
634      let stderr = String::from_utf8_lossy(&output.stderr);
635      return Err(CommitGenError::git(format!("git diff HEAD --stat failed: {stderr}")));
636   }
637
638   let stat = String::from_utf8_lossy(&output.stdout).to_string();
639   let untracked_files = list_untracked_files(dir)?;
640   let stat = append_untracked_stat(stat, dir, &untracked_files);
641
642   if stat.trim().is_empty() {
643      return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
644   }
645
646   Ok(stat)
647}
648
649/// Execute git commit with the given message
650#[allow(clippy::fn_params_excessive_bools, reason = "commit flags are naturally boolean")]
651#[tracing::instrument(
652   target = "lgit",
653   name = "git.commit",
654   skip_all,
655   fields(dir, dry_run, sign, signoff, skip_hooks, amend)
656)]
657pub fn git_commit(
658   message: &str,
659   dry_run: bool,
660   dir: &str,
661   sign: bool,
662   signoff: bool,
663   skip_hooks: bool,
664   amend: bool,
665) -> Result<()> {
666   if dry_run {
667      let sign_flag = if sign { " -S" } else { "" };
668      let signoff_flag = if signoff { " -s" } else { "" };
669      let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
670      let amend_flag = if amend { " --amend" } else { "" };
671      let command = format!(
672         "git commit{sign_flag}{signoff_flag}{hooks_flag}{amend_flag} -m \"{}\"",
673         message.replace('\n', "\\n")
674      );
675      if style::pipe_mode() {
676         eprintln!("\n{}", style::boxed_message("DRY RUN", &command, 60));
677      } else {
678         println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
679      }
680      return Ok(());
681   }
682
683   let mut args = vec!["commit"];
684   if sign {
685      args.push("-S");
686   }
687   if signoff {
688      args.push("-s");
689   }
690   if skip_hooks {
691      args.push("--no-verify");
692   }
693   if amend {
694      args.push("--amend");
695   }
696   args.push("-m");
697   args.push(message);
698
699   let output = git_command()
700      .args(&args)
701      .current_dir(dir)
702      .output()
703      .map_err(|e| CommitGenError::git(format!("Failed to run git commit: {e}")))?;
704
705   if !output.status.success() {
706      let stderr = String::from_utf8_lossy(&output.stderr);
707      let stdout = String::from_utf8_lossy(&output.stdout);
708      if let Some(err) = check_index_lock(&stderr, dir) {
709         return Err(err);
710      }
711      return Err(CommitGenError::git(format!("git commit failed: {stderr}{stdout}")));
712   }
713
714   let stdout = String::from_utf8_lossy(&output.stdout);
715   if style::pipe_mode() {
716      eprintln!("\n{stdout}");
717      eprintln!(
718         "{} {}",
719         style::success(style::icons::SUCCESS),
720         style::success("Successfully committed!")
721      );
722   } else {
723      println!("\n{stdout}");
724      println!(
725         "{} {}",
726         style::success(style::icons::SUCCESS),
727         style::success("Successfully committed!")
728      );
729   }
730
731   Ok(())
732}
733
734/// Execute git push
735#[tracing::instrument(target = "lgit", name = "git.push", skip_all, fields(dir))]
736pub fn git_push(dir: &str) -> Result<()> {
737   if style::pipe_mode() {
738      eprintln!("\n{}", style::info("Pushing changes..."));
739   } else {
740      println!("\n{}", style::info("Pushing changes..."));
741   }
742
743   let output = git_command()
744      .args(["push"])
745      .current_dir(dir)
746      .output()
747      .map_err(|e| CommitGenError::git(format!("Failed to run git push: {e}")))?;
748
749   if !output.status.success() {
750      let stderr = String::from_utf8_lossy(&output.stderr);
751      let stdout = String::from_utf8_lossy(&output.stdout);
752      return Err(CommitGenError::git(format!(
753         "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
754      )));
755   }
756
757   let stdout = String::from_utf8_lossy(&output.stdout);
758   let stderr = String::from_utf8_lossy(&output.stderr);
759   if style::pipe_mode() {
760      if !stdout.is_empty() {
761         eprintln!("{stdout}");
762      }
763      if !stderr.is_empty() {
764         eprintln!("{stderr}");
765      }
766      eprintln!(
767         "{} {}",
768         style::success(style::icons::SUCCESS),
769         style::success("Successfully pushed!")
770      );
771   } else {
772      if !stdout.is_empty() {
773         println!("{stdout}");
774      }
775      if !stderr.is_empty() {
776         println!("{stderr}");
777      }
778      println!(
779         "{} {}",
780         style::success(style::icons::SUCCESS),
781         style::success("Successfully pushed!")
782      );
783   }
784
785   Ok(())
786}
787
788/// Get the current HEAD commit hash
789#[tracing::instrument(target = "lgit", name = "git.head_hash", skip_all, fields(dir))]
790pub fn get_head_hash(dir: &str) -> Result<String> {
791   let output = git_command()
792      .args(["rev-parse", "HEAD"])
793      .current_dir(dir)
794      .output()
795      .map_err(|e| CommitGenError::git(format!("Failed to get HEAD hash: {e}")))?;
796
797   if !output.status.success() {
798      let stderr = String::from_utf8_lossy(&output.stderr);
799      return Err(CommitGenError::git(format!("git rev-parse HEAD failed: {stderr}")));
800   }
801
802   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
803}
804
805#[tracing::instrument(target = "lgit", name = "git.current_head_ref", skip_all, fields(dir))]
806pub fn current_head_ref(dir: &str) -> Result<String> {
807   let output = git_command()
808      .args(["symbolic-ref", "-q", "HEAD"])
809      .current_dir(dir)
810      .output()
811      .map_err(|e| CommitGenError::git(format!("Failed to resolve HEAD ref: {e}")))?;
812
813   if output.status.success() {
814      let refname = String::from_utf8_lossy(&output.stdout).trim().to_string();
815      if !refname.is_empty() {
816         return Ok(refname);
817      }
818   }
819
820   Ok("HEAD".to_string())
821}
822
823#[tracing::instrument(target = "lgit", name = "git.write_real_index_tree", skip_all, fields(dir))]
824pub fn write_real_index_tree(dir: &str) -> Result<String> {
825   let output = git_command()
826      .arg("write-tree")
827      .current_dir(dir)
828      .output()
829      .map_err(|e| CommitGenError::git(format!("Failed to write real index tree: {e}")))?;
830
831   if !output.status.success() {
832      let stderr = String::from_utf8_lossy(&output.stderr);
833      return Err(CommitGenError::git(format!("git write-tree failed: {stderr}")));
834   }
835
836   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
837}
838
839/// Commit `tree` directly, bypassing the live index.
840///
841/// Used when the index drifted while a message was being generated: the
842/// analyzed snapshot is committed as-is, the branch (or detached HEAD)
843/// advances, and the index and worktree are left untouched — anything staged
844/// mid-run stays staged for a later commit. Commit hooks do not run.
845///
846/// Returns `Ok(None)` when `tree` is already HEAD's tree (the same content
847/// was committed mid-run), `Ok(Some(hash))` otherwise.
848#[tracing::instrument(
849   target = "lgit",
850   name = "git.commit_snapshot_tree",
851   skip_all,
852   fields(dir, tree, sign, signoff, amend)
853)]
854pub fn commit_snapshot_tree(
855   message: &str,
856   tree: &str,
857   dir: &str,
858   sign: bool,
859   signoff: bool,
860   amend: bool,
861) -> Result<Option<String>> {
862   let message = if signoff {
863      append_signoff_trailer(message, dir)?
864   } else {
865      message.to_string()
866   };
867
868   // Unborn branch (no commits yet) has no head and no parents.
869   let head = get_head_hash(dir).ok();
870   let head_ref = current_head_ref(dir)?;
871
872   let mut parents: Vec<String> = Vec::new();
873   if let Some(head) = &head {
874      if amend {
875         parents = rev_parse_parents(head, dir)?;
876      } else {
877         if rev_parse_tree_of(head, dir)? == tree {
878            return Ok(None);
879         }
880         parents.push(head.clone());
881      }
882   }
883
884   let parent_refs: Vec<&str> = parents.iter().map(String::as_str).collect();
885   let hash = commit_tree(tree, &parent_refs, &message, dir, sign)?;
886   update_ref_checked(&head_ref, &hash, head.as_deref().unwrap_or(""), dir)?;
887   Ok(Some(hash))
888}
889
890/// Tree oid of a commit-ish.
891fn rev_parse_tree_of(commitish: &str, dir: &str) -> Result<String> {
892   let output = git_command()
893      .args(["rev-parse", &format!("{commitish}^{{tree}}")])
894      .current_dir(dir)
895      .output()
896      .map_err(|e| CommitGenError::git(format!("Failed to resolve tree of {commitish}: {e}")))?;
897
898   if !output.status.success() {
899      let stderr = String::from_utf8_lossy(&output.stderr);
900      return Err(CommitGenError::git(format!(
901         "git rev-parse {commitish}^{{tree}} failed: {stderr}"
902      )));
903   }
904
905   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
906}
907
908/// Parent hashes of a commit-ish (empty for a root commit).
909fn rev_parse_parents(commitish: &str, dir: &str) -> Result<Vec<String>> {
910   let output = git_command()
911      .args(["rev-parse", &format!("{commitish}^@")])
912      .current_dir(dir)
913      .output()
914      .map_err(|e| CommitGenError::git(format!("Failed to resolve parents of {commitish}: {e}")))?;
915
916   if !output.status.success() {
917      let stderr = String::from_utf8_lossy(&output.stderr);
918      return Err(CommitGenError::git(format!("git rev-parse {commitish}^@ failed: {stderr}")));
919   }
920
921   Ok(String::from_utf8_lossy(&output.stdout)
922      .lines()
923      .map(str::to_string)
924      .collect())
925}
926
927#[tracing::instrument(target = "lgit", name = "git.read_tree_into_index", skip_all, fields(dir, treeish, index = %index_file.display()))]
928pub fn read_tree_into_index(index_file: &Path, treeish: &str, dir: &str) -> Result<()> {
929   let output = git_command_with_index(index_file)
930      .arg("read-tree")
931      .arg(treeish)
932      .current_dir(dir)
933      .output()
934      .map_err(|e| CommitGenError::git(format!("Failed to read tree into temporary index: {e}")))?;
935
936   if !output.status.success() {
937      let stderr = String::from_utf8_lossy(&output.stderr);
938      return Err(CommitGenError::git(format!("git read-tree {treeish} failed: {stderr}")));
939   }
940
941   Ok(())
942}
943
944#[tracing::instrument(target = "lgit", name = "git.write_index_tree", skip_all, fields(dir, index = %index_file.display()))]
945pub fn write_index_tree(index_file: &Path, dir: &str) -> Result<String> {
946   let output = git_command_with_index(index_file)
947      .arg("write-tree")
948      .current_dir(dir)
949      .output()
950      .map_err(|e| CommitGenError::git(format!("Failed to write temporary index tree: {e}")))?;
951
952   if !output.status.success() {
953      let stderr = String::from_utf8_lossy(&output.stderr);
954      return Err(CommitGenError::git(format!(
955         "git write-tree failed for temporary index: {stderr}"
956      )));
957   }
958
959   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
960}
961
962#[tracing::instrument(
963   target = "lgit",
964   name = "git.commit_tree",
965   skip_all,
966   fields(dir, parents = parents.len(), tree, sign)
967)]
968pub fn commit_tree(
969   tree: &str,
970   parents: &[&str],
971   message: &str,
972   dir: &str,
973   sign: bool,
974) -> Result<String> {
975   let mut cmd = git_command();
976   cmd.arg("commit-tree");
977   if sign {
978      cmd.arg("-S");
979   }
980   cmd.arg(tree);
981   for parent in parents {
982      cmd.arg("-p").arg(parent);
983   }
984   cmd.arg("-F").arg("-");
985
986   let mut child = cmd
987      .current_dir(dir)
988      .stdin(Stdio::piped())
989      .stdout(Stdio::piped())
990      .stderr(Stdio::piped())
991      .spawn()
992      .map_err(|e| CommitGenError::git(format!("Failed to spawn git commit-tree: {e}")))?;
993
994   {
995      let Some(mut stdin) = child.stdin.take() else {
996         return Err(CommitGenError::git("Failed to open git commit-tree stdin".to_string()));
997      };
998      stdin
999         .write_all(message.as_bytes())
1000         .map_err(|e| CommitGenError::git(format!("Failed to write commit message: {e}")))?;
1001   }
1002
1003   let output = child
1004      .wait_with_output()
1005      .map_err(|e| CommitGenError::git(format!("Failed to wait for git commit-tree: {e}")))?;
1006
1007   if !output.status.success() {
1008      let stderr = String::from_utf8_lossy(&output.stderr);
1009      return Err(CommitGenError::git(format!("git commit-tree failed: {stderr}")));
1010   }
1011
1012   let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
1013   if hash.is_empty() {
1014      return Err(CommitGenError::git("git commit-tree returned an empty hash".to_string()));
1015   }
1016
1017   Ok(hash)
1018}
1019
1020#[tracing::instrument(
1021   target = "lgit",
1022   name = "git.update_ref_checked",
1023   skip_all,
1024   fields(dir, refname, new, old)
1025)]
1026pub fn update_ref_checked(refname: &str, new: &str, old: &str, dir: &str) -> Result<()> {
1027   let output = git_command()
1028      .args(["update-ref", refname, new, old])
1029      .current_dir(dir)
1030      .output()
1031      .map_err(|e| CommitGenError::git(format!("Failed to update {refname}: {e}")))?;
1032
1033   if !output.status.success() {
1034      let stderr = String::from_utf8_lossy(&output.stderr);
1035      return Err(CommitGenError::git(format!("git update-ref failed for {refname}: {stderr}")));
1036   }
1037
1038   Ok(())
1039}
1040
1041#[tracing::instrument(target = "lgit", name = "git.reset_mixed", skip_all, fields(dir, treeish))]
1042pub fn reset_mixed_to(treeish: &str, dir: &str) -> Result<()> {
1043   let output = git_command()
1044      .args(["reset", "--mixed", "-q", treeish])
1045      .current_dir(dir)
1046      .output()
1047      .map_err(|e| CommitGenError::git(format!("Failed to reset index to {treeish}: {e}")))?;
1048
1049   if !output.status.success() {
1050      let stderr = String::from_utf8_lossy(&output.stderr);
1051      return Err(CommitGenError::git(format!("git reset --mixed failed: {stderr}")));
1052   }
1053
1054   Ok(())
1055}
1056
1057/// Reset the index entries for `paths` to their state in `treeish`, leaving
1058/// every other index entry and the worktree untouched.
1059///
1060/// Used after compose when the real index drifted mid-run: the committed
1061/// snapshot paths are refreshed while anything staged during the run stays
1062/// staged.
1063#[tracing::instrument(target = "lgit", name = "git.reset_paths", skip_all, fields(dir, treeish, path_count = paths.len()))]
1064pub fn reset_paths_to(treeish: &str, paths: &[String], dir: &str) -> Result<()> {
1065   if paths.is_empty() {
1066      return Ok(());
1067   }
1068
1069   let output = git_command()
1070      .args(["reset", "-q", treeish, "--"])
1071      .args(paths)
1072      .current_dir(dir)
1073      .output()
1074      .map_err(|e| CommitGenError::git(format!("Failed to reset paths to {treeish}: {e}")))?;
1075
1076   if !output.status.success() {
1077      let stderr = String::from_utf8_lossy(&output.stderr);
1078      return Err(CommitGenError::git(format!("git reset {treeish} -- <paths> failed: {stderr}")));
1079   }
1080
1081   Ok(())
1082}
1083
1084#[tracing::instrument(target = "lgit", name = "git.append_signoff", skip_all, fields(dir))]
1085pub fn append_signoff_trailer(message: &str, dir: &str) -> Result<String> {
1086   let output = git_command()
1087      .args(["var", "GIT_COMMITTER_IDENT"])
1088      .current_dir(dir)
1089      .output()
1090      .map_err(|e| CommitGenError::git(format!("Failed to read committer identity: {e}")))?;
1091
1092   if !output.status.success() {
1093      let stderr = String::from_utf8_lossy(&output.stderr);
1094      return Err(CommitGenError::git(format!("git var GIT_COMMITTER_IDENT failed: {stderr}")));
1095   }
1096
1097   let ident = String::from_utf8_lossy(&output.stdout);
1098   let Some(end) = ident.find('>') else {
1099      return Err(CommitGenError::git(format!(
1100         "Could not parse committer identity: {}",
1101         ident.trim()
1102      )));
1103   };
1104   let signer = ident[..=end].trim();
1105   let trailer = format!("Signed-off-by: {signer}");
1106   let trimmed = message.trim_end();
1107   let mut signed = String::with_capacity(trimmed.len() + trailer.len() + 3);
1108   signed.push_str(trimmed);
1109   signed.push_str("\n\n");
1110   signed.push_str(&trailer);
1111   Ok(signed)
1112}
1113
1114// === History Rewrite Operations ===
1115
1116/// Get list of commit hashes to rewrite (in chronological order)
1117#[tracing::instrument(target = "lgit", name = "git.commit_list", skip_all, fields(dir, start_ref = ?start_ref))]
1118pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
1119   let mut args = vec!["rev-list", "--reverse"];
1120   let range;
1121   if let Some(start) = start_ref {
1122      range = format!("{start}..HEAD");
1123      args.push(&range);
1124   } else {
1125      args.push("HEAD");
1126   }
1127
1128   let output = git_command()
1129      .args(&args)
1130      .current_dir(dir)
1131      .output()
1132      .map_err(|e| CommitGenError::git(format!("Failed to run git rev-list: {e}")))?;
1133
1134   if !output.status.success() {
1135      let stderr = String::from_utf8_lossy(&output.stderr);
1136      return Err(CommitGenError::git(format!("git rev-list failed: {stderr}")));
1137   }
1138
1139   let stdout = String::from_utf8_lossy(&output.stdout);
1140   Ok(stdout.lines().map(|s| s.to_string()).collect())
1141}
1142
1143/// Extract complete metadata for a commit (for rewriting)
1144#[tracing::instrument(target = "lgit", name = "git.commit_metadata", skip_all, fields(dir, hash))]
1145pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
1146   // Format: author_name\0author_email\0author_date\0committer_name\
1147   // 0committer_email\0committer_date\0message
1148   let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
1149
1150   let info_output = git_command()
1151      .args(["show", "-s", &format!("--format={format_str}"), hash])
1152      .current_dir(dir)
1153      .output()
1154      .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
1155
1156   if !info_output.status.success() {
1157      let stderr = String::from_utf8_lossy(&info_output.stderr);
1158      return Err(CommitGenError::git(format!("git show failed for {hash}: {stderr}")));
1159   }
1160
1161   let info = String::from_utf8_lossy(&info_output.stdout);
1162   let parts: Vec<&str> = info.splitn(7, '\0').collect();
1163
1164   if parts.len() < 7 {
1165      return Err(CommitGenError::git(format!("Failed to parse commit metadata for {hash}")));
1166   }
1167
1168   // Get tree hash
1169   let tree_output = git_command()
1170      .args(["rev-parse", &format!("{hash}^{{tree}}")])
1171      .current_dir(dir)
1172      .output()
1173      .map_err(|e| CommitGenError::git(format!("Failed to get tree hash: {e}")))?;
1174   let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
1175      .trim()
1176      .to_string();
1177
1178   // Get parent hashes
1179   let parents_output = git_command()
1180      .args(["rev-list", "--parents", "-n", "1", hash])
1181      .current_dir(dir)
1182      .output()
1183      .map_err(|e| CommitGenError::git(format!("Failed to get parent hashes: {e}")))?;
1184   let parents_line = String::from_utf8_lossy(&parents_output.stdout);
1185   let parent_hashes: Vec<String> = parents_line
1186      .split_whitespace()
1187      .skip(1) // First is the commit itself
1188      .map(|s| s.to_string())
1189      .collect();
1190
1191   Ok(CommitMetadata {
1192      hash: hash.to_string(),
1193      author_name: parts[0].to_string(),
1194      author_email: parts[1].to_string(),
1195      author_date: parts[2].to_string(),
1196      committer_name: parts[3].to_string(),
1197      committer_email: parts[4].to_string(),
1198      committer_date: parts[5].to_string(),
1199      message: parts[6].trim().to_string(),
1200      parent_hashes,
1201      tree_hash,
1202   })
1203}
1204
1205/// Check if working directory is clean
1206#[tracing::instrument(target = "lgit", name = "git.check_worktree_clean", skip_all, fields(dir))]
1207pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
1208   let output = git_command()
1209      .args(["status", "--porcelain"])
1210      .current_dir(dir)
1211      .output()
1212      .map_err(|e| CommitGenError::git(format!("Failed to check working tree: {e}")))?;
1213
1214   Ok(output.stdout.is_empty())
1215}
1216
1217/// Create timestamped backup branch
1218#[tracing::instrument(target = "lgit", name = "git.create_backup_branch", skip_all, fields(dir))]
1219pub fn create_backup_branch(dir: &str) -> Result<String> {
1220   use chrono::Local;
1221
1222   let timestamp = Local::now().format("%Y%m%d-%H%M%S");
1223   let backup_name = format!("backup-rewrite-{timestamp}");
1224
1225   let output = git_command()
1226      .args(["branch", &backup_name])
1227      .current_dir(dir)
1228      .output()
1229      .map_err(|e| CommitGenError::git(format!("Failed to create backup branch: {e}")))?;
1230
1231   if !output.status.success() {
1232      let stderr = String::from_utf8_lossy(&output.stderr);
1233      return Err(CommitGenError::git(format!("git branch failed: {stderr}")));
1234   }
1235
1236   Ok(backup_name)
1237}
1238
1239/// Get recent commit messages for style consistency (last N commits)
1240#[tracing::instrument(target = "lgit", name = "git.recent_commits", skip_all, fields(dir, count))]
1241pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
1242   let output = git_command()
1243      .args(["log", &format!("-{count}"), "--pretty=format:%s"])
1244      .current_dir(dir)
1245      .output()
1246      .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1247
1248   if !output.status.success() {
1249      let stderr = String::from_utf8_lossy(&output.stderr);
1250      return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1251   }
1252
1253   let stdout = String::from_utf8_lossy(&output.stdout);
1254   Ok(stdout.lines().map(|s| s.to_string()).collect())
1255}
1256
1257/// Extract common scopes from git history by parsing commit messages
1258#[tracing::instrument(target = "lgit", name = "git.common_scopes", skip_all, fields(dir, limit))]
1259pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
1260   let output = git_command()
1261      .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
1262      .current_dir(dir)
1263      .output()
1264      .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1265
1266   if !output.status.success() {
1267      let stderr = String::from_utf8_lossy(&output.stderr);
1268      return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1269   }
1270
1271   let stdout = String::from_utf8_lossy(&output.stdout);
1272   let mut scope_counts: HashMap<String, usize> = HashMap::new();
1273
1274   // Parse conventional commit format: type(scope): message
1275   for line in stdout.lines() {
1276      if let Some(scope) = extract_scope_from_commit(line) {
1277         *scope_counts.entry(scope).or_insert(0) += 1;
1278      }
1279   }
1280
1281   // Sort by frequency (descending)
1282   let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
1283   scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1284
1285   Ok(scopes)
1286}
1287
1288/// Extract scope from a conventional commit message
1289fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
1290   // Match pattern: type(scope): message
1291   let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
1292   if parts.len() < 2 {
1293      return None;
1294   }
1295
1296   let prefix = parts[0];
1297   if let Some(scope_start) = prefix.find('(')
1298      && let Some(scope_end) = prefix.find(')')
1299      && scope_start < scope_end
1300   {
1301      return Some(prefix[scope_start + 1..scope_end].to_string());
1302   }
1303
1304   None
1305}
1306
1307/// Quantified style patterns extracted from commit history
1308#[derive(Debug, Clone)]
1309pub struct StylePatterns {
1310   /// Percentage of commits using scopes (0.0-100.0)
1311   pub scope_usage_pct: f32,
1312   /// Common verbs with counts (sorted by count descending)
1313   pub common_verbs:    Vec<(String, usize)>,
1314   /// Average summary length in chars
1315   pub avg_length:      usize,
1316   /// Summary length range (min, max)
1317   pub length_range:    (usize, usize),
1318   /// Percentage of commits starting with lowercase (0.0-100.0)
1319   pub lowercase_pct:   f32,
1320   /// Top scopes with counts (sorted by count descending)
1321   pub top_scopes:      Vec<(String, usize)>,
1322}
1323
1324impl StylePatterns {
1325   /// Format patterns for prompt injection
1326   pub fn format_for_prompt(&self) -> String {
1327      let mut lines = Vec::new();
1328
1329      lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
1330
1331      if !self.common_verbs.is_empty() {
1332         let verbs: Vec<_> = self
1333            .common_verbs
1334            .iter()
1335            .take(5)
1336            .map(|(v, c)| format!("{v} ({c})"))
1337            .collect();
1338         lines.push(format!("Common verbs: {}", verbs.join(", ")));
1339      }
1340
1341      lines.push(format!(
1342         "Average length: {} chars (range: {}-{})",
1343         self.avg_length, self.length_range.0, self.length_range.1
1344      ));
1345
1346      lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
1347
1348      if !self.top_scopes.is_empty() {
1349         let scopes: Vec<_> = self
1350            .top_scopes
1351            .iter()
1352            .take(5)
1353            .map(|(s, c)| format!("{s} ({c})"))
1354            .collect();
1355         lines.push(format!("Top scopes: {}", scopes.join(", ")));
1356      }
1357
1358      lines.join("\n")
1359   }
1360}
1361
1362/// Extract style patterns from commit history
1363pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
1364   if commits.is_empty() {
1365      return None;
1366   }
1367
1368   let mut scope_count = 0;
1369   let mut lowercase_count = 0;
1370   let mut verb_counts: HashMap<String, usize> = HashMap::new();
1371   let mut scope_counts: HashMap<String, usize> = HashMap::new();
1372   let mut lengths = Vec::new();
1373
1374   for commit in commits {
1375      // Parse: type(scope): summary
1376      if let Some(colon_pos) = commit.find(':') {
1377         let prefix = &commit[..colon_pos];
1378         let summary = commit[colon_pos + 1..].trim();
1379
1380         // Check for scope
1381         if let Some(paren_start) = prefix.find('(')
1382            && let Some(paren_end) = prefix.find(')')
1383         {
1384            scope_count += 1;
1385            let scope = &prefix[paren_start + 1..paren_end];
1386            *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
1387         }
1388
1389         // Check capitalization of summary
1390         if let Some(first_char) = summary.chars().next() {
1391            if first_char.is_lowercase() {
1392               lowercase_count += 1;
1393            }
1394
1395            // Extract first word as verb
1396            let first_word = summary.split_whitespace().next().unwrap_or("");
1397            if !first_word.is_empty() {
1398               *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
1399            }
1400         }
1401
1402         lengths.push(summary.len());
1403      }
1404   }
1405
1406   let total = commits.len();
1407   let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
1408   let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
1409
1410   // Sort verbs by count
1411   let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
1412   common_verbs.sort_by_key(|verb| std::cmp::Reverse(verb.1));
1413
1414   // Sort scopes by count
1415   let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
1416   top_scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1417
1418   // Calculate length stats
1419   let avg_length = if lengths.is_empty() {
1420      0
1421   } else {
1422      lengths.iter().sum::<usize>() / lengths.len()
1423   };
1424   let length_range = if lengths.is_empty() {
1425      (0, 0)
1426   } else {
1427      (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
1428   };
1429
1430   Some(StylePatterns {
1431      scope_usage_pct,
1432      common_verbs,
1433      avg_length,
1434      length_range,
1435      lowercase_pct,
1436      top_scopes,
1437   })
1438}
1439
1440/// Rewrite git history with new commit messages
1441#[tracing::instrument(target = "lgit", name = "git.rewrite_history", skip_all, fields(dir, commit_count = commits.len()))]
1442pub fn rewrite_history(
1443   commits: &[CommitMetadata],
1444   new_messages: &[String],
1445   dir: &str,
1446) -> Result<()> {
1447   if commits.len() != new_messages.len() {
1448      return Err(CommitGenError::Other("Commit count mismatch".to_string()));
1449   }
1450
1451   // Get current branch
1452   let branch_output = git_command()
1453      .args(["rev-parse", "--abbrev-ref", "HEAD"])
1454      .current_dir(dir)
1455      .output()
1456      .map_err(|e| CommitGenError::git(format!("Failed to get current branch: {e}")))?;
1457   let current_branch = String::from_utf8_lossy(&branch_output.stdout)
1458      .trim()
1459      .to_string();
1460
1461   // Map old commit hashes to new ones
1462   let mut parent_map: HashMap<String, String> = HashMap::new();
1463   let mut new_head: Option<String> = None;
1464
1465   for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
1466      // Map old parents to new parents
1467      let new_parents: Vec<String> = commit
1468         .parent_hashes
1469         .iter()
1470         .map(|old_parent| {
1471            parent_map
1472               .get(old_parent)
1473               .cloned()
1474               .unwrap_or_else(|| old_parent.clone())
1475         })
1476         .collect();
1477
1478      // Build commit-tree command
1479      let mut cmd = git_command();
1480      cmd.arg("commit-tree")
1481         .arg(&commit.tree_hash)
1482         .arg("-m")
1483         .arg(new_msg)
1484         .current_dir(dir);
1485
1486      for parent in &new_parents {
1487         cmd.arg("-p").arg(parent);
1488      }
1489
1490      // Preserve original author/committer metadata
1491      cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
1492         .env("GIT_AUTHOR_EMAIL", &commit.author_email)
1493         .env("GIT_AUTHOR_DATE", &commit.author_date)
1494         .env("GIT_COMMITTER_NAME", &commit.committer_name)
1495         .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
1496         .env("GIT_COMMITTER_DATE", &commit.committer_date);
1497
1498      let output = cmd
1499         .output()
1500         .map_err(|e| CommitGenError::git(format!("Failed to run git commit-tree: {e}")))?;
1501
1502      if !output.status.success() {
1503         let stderr = String::from_utf8_lossy(&output.stderr);
1504         return Err(CommitGenError::git(format!(
1505            "commit-tree failed for {}: {}",
1506            commit.hash, stderr
1507         )));
1508      }
1509
1510      let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
1511
1512      parent_map.insert(commit.hash.clone(), new_hash.clone());
1513      new_head = Some(new_hash);
1514
1515      // Progress reporting
1516      if (idx + 1) % 50 == 0 {
1517         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
1518      }
1519   }
1520
1521   // Update branch to new head
1522   if let Some(head) = new_head {
1523      let update_output = git_command()
1524         .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
1525         .current_dir(dir)
1526         .output()
1527         .map_err(|e| CommitGenError::git(format!("Failed to update ref: {e}")))?;
1528
1529      if !update_output.status.success() {
1530         let stderr = String::from_utf8_lossy(&update_output.stderr);
1531         return Err(CommitGenError::git(format!("git update-ref failed: {stderr}")));
1532      }
1533
1534      let reset_output = git_command()
1535         .args(["reset", "--hard", &head])
1536         .current_dir(dir)
1537         .output()
1538         .map_err(|e| CommitGenError::git(format!("Failed to reset: {e}")))?;
1539
1540      if !reset_output.status.success() {
1541         let stderr = String::from_utf8_lossy(&reset_output.stderr);
1542         return Err(CommitGenError::git(format!("git reset failed: {stderr}")));
1543      }
1544   }
1545
1546   Ok(())
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551   use super::*;
1552
1553   #[test]
1554   fn test_git_command_applies_background_feature_overrides_when_enabled() {
1555      let cmd =
1556         git_command_with_settings(GitCommandSettings { disable_git_background_features: true });
1557      let args: Vec<String> = cmd
1558         .get_args()
1559         .map(|arg| arg.to_string_lossy().into_owned())
1560         .collect();
1561
1562      assert_eq!(args, vec![
1563         "-c".to_string(),
1564         "core.fsmonitor=false".to_string(),
1565         "-c".to_string(),
1566         "core.untrackedCache=false".to_string(),
1567      ]);
1568   }
1569
1570   fn run_test_git(dir: &tempfile::TempDir, args: &[&str]) -> String {
1571      let output = git_command()
1572         .args(args)
1573         .current_dir(dir.path())
1574         .output()
1575         .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
1576      assert!(
1577         output.status.success(),
1578         "git {:?} failed: {}",
1579         args,
1580         String::from_utf8_lossy(&output.stderr)
1581      );
1582      String::from_utf8_lossy(&output.stdout).to_string()
1583   }
1584
1585   #[test]
1586   fn test_commit_snapshot_tree_commits_snapshot_and_keeps_drifted_staging() {
1587      let dir = tempfile::TempDir::new().unwrap();
1588      let dir_str = dir.path().to_str().unwrap();
1589      run_test_git(&dir, &["init"]);
1590      run_test_git(&dir, &["config", "user.name", "Guard Test"]);
1591      run_test_git(&dir, &["config", "user.email", "guard@test.local"]);
1592      run_test_git(&dir, &["config", "commit.gpgsign", "false"]);
1593      std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
1594      run_test_git(&dir, &["add", "a.txt"]);
1595      run_test_git(&dir, &["commit", "-m", "base"]);
1596
1597      // The analyzed snapshot: a.txt modified and staged.
1598      std::fs::write(dir.path().join("a.txt"), "two\n").unwrap();
1599      run_test_git(&dir, &["add", "a.txt"]);
1600      let snapshot_tree = write_real_index_tree(dir_str).unwrap();
1601
1602      // Mid-run drift: another file gets staged.
1603      std::fs::write(dir.path().join("b.txt"), "drift\n").unwrap();
1604      run_test_git(&dir, &["add", "b.txt"]);
1605
1606      let hash =
1607         commit_snapshot_tree("feat: snapshot", &snapshot_tree, dir_str, false, false, false)
1608            .unwrap()
1609            .expect("snapshot differs from HEAD");
1610
1611      // HEAD advanced to exactly the snapshot tree.
1612      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD"]).trim(), hash);
1613      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD^{tree}"]).trim(), snapshot_tree);
1614      assert_eq!(run_test_git(&dir, &["show", "HEAD:a.txt"]), "two\n");
1615      assert!(
1616         !run_test_git(&dir, &["ls-tree", "--name-only", "HEAD"]).contains("b.txt"),
1617         "drifted staging must not enter the commit"
1618      );
1619
1620      // The drifted staging survives, staged for the next commit.
1621      assert_eq!(run_test_git(&dir, &["diff", "--cached", "--name-only"]).trim(), "b.txt");
1622      assert_eq!(std::fs::read_to_string(dir.path().join("b.txt")).unwrap(), "drift\n");
1623
1624      // Re-committing the same snapshot is a no-op.
1625      let again =
1626         commit_snapshot_tree("feat: again", &snapshot_tree, dir_str, false, false, false).unwrap();
1627      assert_eq!(again, None);
1628      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD"]).trim(), hash);
1629   }
1630
1631   #[test]
1632   fn test_git_command_skips_background_feature_overrides_when_disabled() {
1633      let cmd =
1634         git_command_with_settings(GitCommandSettings { disable_git_background_features: false });
1635      assert!(cmd.get_args().next().is_none());
1636   }
1637
1638   #[test]
1639   fn test_get_git_diff_uses_minimal_context_when_large() {
1640      let dir = tempfile::TempDir::new().unwrap();
1641      let dir_str = dir.path().to_str().unwrap();
1642      run_test_git(&dir, &["init"]);
1643      run_test_git(&dir, &["config", "user.name", "Context Test"]);
1644      run_test_git(&dir, &["config", "user.email", "context@test.local"]);
1645      run_test_git(&dir, &["config", "commit.gpgsign", "false"]);
1646
1647      // Base file with many lines; every 5th line will change.
1648      let base: String = (0..200)
1649         .map(|i| {
1650            if i % 5 == 0 {
1651               format!("base {i}\n")
1652            } else {
1653               format!("stable {i}\n")
1654            }
1655         })
1656         .collect();
1657      std::fs::write(dir.path().join("file.txt"), &base).unwrap();
1658      run_test_git(&dir, &["add", "file.txt"]);
1659      run_test_git(&dir, &["commit", "-m", "base"]);
1660
1661      // Modify every 5th line.
1662      let changed: String = (0..200)
1663         .map(|i| {
1664            if i % 5 == 0 {
1665               format!("changed {i}\n")
1666            } else {
1667               format!("stable {i}\n")
1668            }
1669         })
1670         .collect();
1671      std::fs::write(dir.path().join("file.txt"), changed).unwrap();
1672      run_test_git(&dir, &["add", "file.txt"]);
1673
1674      // Force -U1 fallback by setting a small max_diff_length.
1675      let config = CommitConfig { max_diff_length: 500, ..Default::default() };
1676      let minimal_diff = get_git_diff(&Mode::Staged, None, dir_str, &config).unwrap();
1677
1678      // Verify the fallback was triggered and the output matches the explicit -U1
1679      // diff.
1680      let default_diff = run_test_git(&dir, &["diff", "--cached"]);
1681      assert!(default_diff.len() > config.max_diff_length);
1682      let explicit_u1 = run_test_git(&dir, &["diff", "--cached", "-U1"]);
1683      assert_eq!(minimal_diff, explicit_u1);
1684   }
1685}