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 output = match mode {
328      Mode::Staged => git_command()
329         .args(["diff", "--cached"])
330         .current_dir(dir)
331         .output()
332         .map_err(|e| CommitGenError::git(format!("Failed to run git diff --cached: {e}")))?,
333      Mode::Commit => {
334         let target = target.ok_or_else(|| {
335            CommitGenError::ValidationError("--target required for commit mode".to_string())
336         })?;
337         let mut cmd = git_command();
338         cmd.arg("show");
339         if config.exclude_old_message {
340            cmd.arg("--format=");
341         }
342         cmd.arg(target)
343            .current_dir(dir)
344            .output()
345            .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?
346      },
347      Mode::Unstaged => {
348         // Get diff for tracked files
349         let tracked_output = git_command()
350            .args(["diff"])
351            .current_dir(dir)
352            .output()
353            .map_err(|e| CommitGenError::git(format!("Failed to run git diff: {e}")))?;
354
355         if !tracked_output.status.success() {
356            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
357            return Err(CommitGenError::git(format!("git diff failed: {stderr}")));
358         }
359
360         let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
361         let untracked_files = list_untracked_files(dir)?;
362         return append_untracked_diff(tracked_diff, dir, &untracked_files);
363      },
364      Mode::Compose => unreachable!("compose mode handled separately"),
365   };
366
367   if !output.status.success() {
368      let stderr = String::from_utf8_lossy(&output.stderr);
369      return Err(CommitGenError::git(format!("Git command failed: {stderr}")));
370   }
371
372   let diff = String::from_utf8_lossy(&output.stdout).to_string();
373
374   if diff.trim().is_empty() {
375      let mode_str = match mode {
376         Mode::Staged => "staged",
377         Mode::Commit => "commit",
378         Mode::Unstaged => "unstaged",
379         Mode::Compose => "compose",
380      };
381      return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
382   }
383
384   Ok(diff)
385}
386
387/// Get git diff --stat to show file-level changes summary
388#[tracing::instrument(target = "lgit", name = "git.stat", skip_all, fields(mode = ?mode, target = ?target, dir))]
389pub fn get_git_stat(
390   mode: &Mode,
391   target: Option<&str>,
392   dir: &str,
393   config: &CommitConfig,
394) -> Result<String> {
395   let output = match mode {
396      Mode::Staged => git_command()
397         .args(["diff", "--cached", "--stat"])
398         .current_dir(dir)
399         .output()
400         .map_err(|e| {
401            CommitGenError::git(format!("Failed to run git diff --cached --stat: {e}"))
402         })?,
403      Mode::Commit => {
404         let target = target.ok_or_else(|| {
405            CommitGenError::ValidationError("--target required for commit mode".to_string())
406         })?;
407         let mut cmd = git_command();
408         cmd.arg("show");
409         if config.exclude_old_message {
410            cmd.arg("--format=");
411         }
412         cmd.arg("--stat")
413            .arg(target)
414            .current_dir(dir)
415            .output()
416            .map_err(|e| CommitGenError::git(format!("Failed to run git show --stat: {e}")))?
417      },
418      Mode::Unstaged => {
419         // Get stat for tracked files
420         let tracked_output = git_command()
421            .args(["diff", "--stat"])
422            .current_dir(dir)
423            .output()
424            .map_err(|e| CommitGenError::git(format!("Failed to run git diff --stat: {e}")))?;
425
426         if !tracked_output.status.success() {
427            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
428            return Err(CommitGenError::git(format!("git diff --stat failed: {stderr}")));
429         }
430
431         let stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
432         let untracked_files = list_untracked_files(dir)?;
433         return Ok(append_untracked_stat(stat, dir, &untracked_files));
434      },
435      Mode::Compose => unreachable!("compose mode handled separately"),
436   };
437
438   if !output.status.success() {
439      let stderr = String::from_utf8_lossy(&output.stderr);
440      return Err(CommitGenError::git(format!("Git stat command failed: {stderr}")));
441   }
442
443   Ok(String::from_utf8_lossy(&output.stdout).to_string())
444}
445
446#[tracing::instrument(target = "lgit", name = "git.numstat", skip_all, fields(mode = ?mode, target = ?target, dir))]
447pub fn get_git_numstat(
448   mode: &Mode,
449   target: Option<&str>,
450   dir: &str,
451   config: &CommitConfig,
452) -> Result<String> {
453   let output = match mode {
454      Mode::Staged => git_command()
455         .args(["diff", "--cached", "--numstat"])
456         .current_dir(dir)
457         .output()
458         .map_err(|e| {
459            CommitGenError::git(format!("Failed to run git diff --cached --numstat: {e}"))
460         })?,
461      Mode::Commit => {
462         let target = target.ok_or_else(|| {
463            CommitGenError::ValidationError("--target required for commit mode".to_string())
464         })?;
465         let mut cmd = git_command();
466         cmd.arg("show");
467         if config.exclude_old_message {
468            cmd.arg("--format=");
469         }
470         cmd.arg("--numstat")
471            .arg(target)
472            .current_dir(dir)
473            .output()
474            .map_err(|e| CommitGenError::git(format!("Failed to run git show --numstat: {e}")))?
475      },
476      Mode::Unstaged => {
477         let tracked_output = git_command()
478            .args(["diff", "--numstat"])
479            .current_dir(dir)
480            .output()
481            .map_err(|e| CommitGenError::git(format!("Failed to run git diff --numstat: {e}")))?;
482
483         if !tracked_output.status.success() {
484            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
485            return Err(CommitGenError::git(format!("git diff --numstat failed: {stderr}")));
486         }
487
488         let numstat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
489         let untracked_files = list_untracked_files(dir)?;
490         return Ok(append_untracked_numstat(numstat, dir, &untracked_files));
491      },
492      Mode::Compose => unreachable!("compose mode handled separately"),
493   };
494
495   if !output.status.success() {
496      let stderr = String::from_utf8_lossy(&output.stderr);
497      return Err(CommitGenError::git(format!("Git numstat command failed: {stderr}")));
498   }
499
500   Ok(String::from_utf8_lossy(&output.stdout).to_string())
501}
502
503#[tracing::instrument(target = "lgit", name = "git.compose_diff", skip_all, fields(dir))]
504pub fn get_compose_diff(dir: &str) -> Result<String> {
505   let output = git_command()
506      .args([
507         "diff",
508         "--no-ext-diff",
509         "--no-textconv",
510         "--no-color",
511         "--src-prefix=a/",
512         "--dst-prefix=b/",
513         "HEAD",
514      ])
515      .current_dir(dir)
516      .output()
517      .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD: {e}")))?;
518
519   if !output.status.success() {
520      let stderr = String::from_utf8_lossy(&output.stderr);
521      return Err(CommitGenError::git(format!("git diff HEAD failed: {stderr}")));
522   }
523
524   let diff = String::from_utf8_lossy(&output.stdout).to_string();
525   let untracked_files = list_untracked_files(dir)?;
526   let diff = append_untracked_diff(diff, dir, &untracked_files)?;
527
528   if diff.trim().is_empty() {
529      return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
530   }
531
532   Ok(diff)
533}
534
535#[tracing::instrument(target = "lgit", name = "git.compose_stat", skip_all, fields(dir))]
536pub fn get_compose_stat(dir: &str) -> Result<String> {
537   let output = git_command()
538      .args(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--stat"])
539      .current_dir(dir)
540      .output()
541      .map_err(|e| CommitGenError::git(format!("Failed to run git diff HEAD --stat: {e}")))?;
542
543   if !output.status.success() {
544      let stderr = String::from_utf8_lossy(&output.stderr);
545      return Err(CommitGenError::git(format!("git diff HEAD --stat failed: {stderr}")));
546   }
547
548   let stat = String::from_utf8_lossy(&output.stdout).to_string();
549   let untracked_files = list_untracked_files(dir)?;
550   let stat = append_untracked_stat(stat, dir, &untracked_files);
551
552   if stat.trim().is_empty() {
553      return Err(CommitGenError::NoChanges { mode: "compose".to_string() });
554   }
555
556   Ok(stat)
557}
558
559/// Execute git commit with the given message
560#[allow(clippy::fn_params_excessive_bools, reason = "commit flags are naturally boolean")]
561#[tracing::instrument(
562   target = "lgit",
563   name = "git.commit",
564   skip_all,
565   fields(dir, dry_run, sign, signoff, skip_hooks, amend)
566)]
567pub fn git_commit(
568   message: &str,
569   dry_run: bool,
570   dir: &str,
571   sign: bool,
572   signoff: bool,
573   skip_hooks: bool,
574   amend: bool,
575) -> Result<()> {
576   if dry_run {
577      let sign_flag = if sign { " -S" } else { "" };
578      let signoff_flag = if signoff { " -s" } else { "" };
579      let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
580      let amend_flag = if amend { " --amend" } else { "" };
581      let command = format!(
582         "git commit{sign_flag}{signoff_flag}{hooks_flag}{amend_flag} -m \"{}\"",
583         message.replace('\n', "\\n")
584      );
585      if style::pipe_mode() {
586         eprintln!("\n{}", style::boxed_message("DRY RUN", &command, 60));
587      } else {
588         println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
589      }
590      return Ok(());
591   }
592
593   let mut args = vec!["commit"];
594   if sign {
595      args.push("-S");
596   }
597   if signoff {
598      args.push("-s");
599   }
600   if skip_hooks {
601      args.push("--no-verify");
602   }
603   if amend {
604      args.push("--amend");
605   }
606   args.push("-m");
607   args.push(message);
608
609   let output = git_command()
610      .args(&args)
611      .current_dir(dir)
612      .output()
613      .map_err(|e| CommitGenError::git(format!("Failed to run git commit: {e}")))?;
614
615   if !output.status.success() {
616      let stderr = String::from_utf8_lossy(&output.stderr);
617      let stdout = String::from_utf8_lossy(&output.stdout);
618      if let Some(err) = check_index_lock(&stderr, dir) {
619         return Err(err);
620      }
621      return Err(CommitGenError::git(format!("git commit failed: {stderr}{stdout}")));
622   }
623
624   let stdout = String::from_utf8_lossy(&output.stdout);
625   if style::pipe_mode() {
626      eprintln!("\n{stdout}");
627      eprintln!(
628         "{} {}",
629         style::success(style::icons::SUCCESS),
630         style::success("Successfully committed!")
631      );
632   } else {
633      println!("\n{stdout}");
634      println!(
635         "{} {}",
636         style::success(style::icons::SUCCESS),
637         style::success("Successfully committed!")
638      );
639   }
640
641   Ok(())
642}
643
644/// Execute git push
645#[tracing::instrument(target = "lgit", name = "git.push", skip_all, fields(dir))]
646pub fn git_push(dir: &str) -> Result<()> {
647   if style::pipe_mode() {
648      eprintln!("\n{}", style::info("Pushing changes..."));
649   } else {
650      println!("\n{}", style::info("Pushing changes..."));
651   }
652
653   let output = git_command()
654      .args(["push"])
655      .current_dir(dir)
656      .output()
657      .map_err(|e| CommitGenError::git(format!("Failed to run git push: {e}")))?;
658
659   if !output.status.success() {
660      let stderr = String::from_utf8_lossy(&output.stderr);
661      let stdout = String::from_utf8_lossy(&output.stdout);
662      return Err(CommitGenError::git(format!(
663         "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
664      )));
665   }
666
667   let stdout = String::from_utf8_lossy(&output.stdout);
668   let stderr = String::from_utf8_lossy(&output.stderr);
669   if style::pipe_mode() {
670      if !stdout.is_empty() {
671         eprintln!("{stdout}");
672      }
673      if !stderr.is_empty() {
674         eprintln!("{stderr}");
675      }
676      eprintln!(
677         "{} {}",
678         style::success(style::icons::SUCCESS),
679         style::success("Successfully pushed!")
680      );
681   } else {
682      if !stdout.is_empty() {
683         println!("{stdout}");
684      }
685      if !stderr.is_empty() {
686         println!("{stderr}");
687      }
688      println!(
689         "{} {}",
690         style::success(style::icons::SUCCESS),
691         style::success("Successfully pushed!")
692      );
693   }
694
695   Ok(())
696}
697
698/// Get the current HEAD commit hash
699#[tracing::instrument(target = "lgit", name = "git.head_hash", skip_all, fields(dir))]
700pub fn get_head_hash(dir: &str) -> Result<String> {
701   let output = git_command()
702      .args(["rev-parse", "HEAD"])
703      .current_dir(dir)
704      .output()
705      .map_err(|e| CommitGenError::git(format!("Failed to get HEAD hash: {e}")))?;
706
707   if !output.status.success() {
708      let stderr = String::from_utf8_lossy(&output.stderr);
709      return Err(CommitGenError::git(format!("git rev-parse HEAD failed: {stderr}")));
710   }
711
712   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
713}
714
715#[tracing::instrument(target = "lgit", name = "git.current_head_ref", skip_all, fields(dir))]
716pub fn current_head_ref(dir: &str) -> Result<String> {
717   let output = git_command()
718      .args(["symbolic-ref", "-q", "HEAD"])
719      .current_dir(dir)
720      .output()
721      .map_err(|e| CommitGenError::git(format!("Failed to resolve HEAD ref: {e}")))?;
722
723   if output.status.success() {
724      let refname = String::from_utf8_lossy(&output.stdout).trim().to_string();
725      if !refname.is_empty() {
726         return Ok(refname);
727      }
728   }
729
730   Ok("HEAD".to_string())
731}
732
733#[tracing::instrument(target = "lgit", name = "git.write_real_index_tree", skip_all, fields(dir))]
734pub fn write_real_index_tree(dir: &str) -> Result<String> {
735   let output = git_command()
736      .arg("write-tree")
737      .current_dir(dir)
738      .output()
739      .map_err(|e| CommitGenError::git(format!("Failed to write real index tree: {e}")))?;
740
741   if !output.status.success() {
742      let stderr = String::from_utf8_lossy(&output.stderr);
743      return Err(CommitGenError::git(format!("git write-tree failed: {stderr}")));
744   }
745
746   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
747}
748
749/// Commit `tree` directly, bypassing the live index.
750///
751/// Used when the index drifted while a message was being generated: the
752/// analyzed snapshot is committed as-is, the branch (or detached HEAD)
753/// advances, and the index and worktree are left untouched — anything staged
754/// mid-run stays staged for a later commit. Commit hooks do not run.
755///
756/// Returns `Ok(None)` when `tree` is already HEAD's tree (the same content
757/// was committed mid-run), `Ok(Some(hash))` otherwise.
758#[tracing::instrument(
759   target = "lgit",
760   name = "git.commit_snapshot_tree",
761   skip_all,
762   fields(dir, tree, sign, signoff, amend)
763)]
764pub fn commit_snapshot_tree(
765   message: &str,
766   tree: &str,
767   dir: &str,
768   sign: bool,
769   signoff: bool,
770   amend: bool,
771) -> Result<Option<String>> {
772   let message = if signoff {
773      append_signoff_trailer(message, dir)?
774   } else {
775      message.to_string()
776   };
777
778   // Unborn branch (no commits yet) has no head and no parents.
779   let head = get_head_hash(dir).ok();
780   let head_ref = current_head_ref(dir)?;
781
782   let mut parents: Vec<String> = Vec::new();
783   if let Some(head) = &head {
784      if amend {
785         parents = rev_parse_parents(head, dir)?;
786      } else {
787         if rev_parse_tree_of(head, dir)? == tree {
788            return Ok(None);
789         }
790         parents.push(head.clone());
791      }
792   }
793
794   let parent_refs: Vec<&str> = parents.iter().map(String::as_str).collect();
795   let hash = commit_tree(tree, &parent_refs, &message, dir, sign)?;
796   update_ref_checked(&head_ref, &hash, head.as_deref().unwrap_or(""), dir)?;
797   Ok(Some(hash))
798}
799
800/// Tree oid of a commit-ish.
801fn rev_parse_tree_of(commitish: &str, dir: &str) -> Result<String> {
802   let output = git_command()
803      .args(["rev-parse", &format!("{commitish}^{{tree}}")])
804      .current_dir(dir)
805      .output()
806      .map_err(|e| CommitGenError::git(format!("Failed to resolve tree of {commitish}: {e}")))?;
807
808   if !output.status.success() {
809      let stderr = String::from_utf8_lossy(&output.stderr);
810      return Err(CommitGenError::git(format!("git rev-parse {commitish}^{{tree}} failed: {stderr}")));
811   }
812
813   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
814}
815
816/// Parent hashes of a commit-ish (empty for a root commit).
817fn rev_parse_parents(commitish: &str, dir: &str) -> Result<Vec<String>> {
818   let output = git_command()
819      .args(["rev-parse", &format!("{commitish}^@")])
820      .current_dir(dir)
821      .output()
822      .map_err(|e| CommitGenError::git(format!("Failed to resolve parents of {commitish}: {e}")))?;
823
824   if !output.status.success() {
825      let stderr = String::from_utf8_lossy(&output.stderr);
826      return Err(CommitGenError::git(format!("git rev-parse {commitish}^@ failed: {stderr}")));
827   }
828
829   Ok(String::from_utf8_lossy(&output.stdout)
830      .lines()
831      .map(str::to_string)
832      .collect())
833}
834
835#[tracing::instrument(target = "lgit", name = "git.read_tree_into_index", skip_all, fields(dir, treeish, index = %index_file.display()))]
836pub fn read_tree_into_index(index_file: &Path, treeish: &str, dir: &str) -> Result<()> {
837   let output = git_command_with_index(index_file)
838      .arg("read-tree")
839      .arg(treeish)
840      .current_dir(dir)
841      .output()
842      .map_err(|e| CommitGenError::git(format!("Failed to read tree into temporary index: {e}")))?;
843
844   if !output.status.success() {
845      let stderr = String::from_utf8_lossy(&output.stderr);
846      return Err(CommitGenError::git(format!("git read-tree {treeish} failed: {stderr}")));
847   }
848
849   Ok(())
850}
851
852#[tracing::instrument(target = "lgit", name = "git.write_index_tree", skip_all, fields(dir, index = %index_file.display()))]
853pub fn write_index_tree(index_file: &Path, dir: &str) -> Result<String> {
854   let output = git_command_with_index(index_file)
855      .arg("write-tree")
856      .current_dir(dir)
857      .output()
858      .map_err(|e| CommitGenError::git(format!("Failed to write temporary index tree: {e}")))?;
859
860   if !output.status.success() {
861      let stderr = String::from_utf8_lossy(&output.stderr);
862      return Err(CommitGenError::git(format!(
863         "git write-tree failed for temporary index: {stderr}"
864      )));
865   }
866
867   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
868}
869
870#[tracing::instrument(
871   target = "lgit",
872   name = "git.commit_tree",
873   skip_all,
874   fields(dir, parents = parents.len(), tree, sign)
875)]
876pub fn commit_tree(
877   tree: &str,
878   parents: &[&str],
879   message: &str,
880   dir: &str,
881   sign: bool,
882) -> Result<String> {
883   let mut cmd = git_command();
884   cmd.arg("commit-tree");
885   if sign {
886      cmd.arg("-S");
887   }
888   cmd.arg(tree);
889   for parent in parents {
890      cmd.arg("-p").arg(parent);
891   }
892   cmd.arg("-F").arg("-");
893
894   let mut child = cmd
895      .current_dir(dir)
896      .stdin(Stdio::piped())
897      .stdout(Stdio::piped())
898      .stderr(Stdio::piped())
899      .spawn()
900      .map_err(|e| CommitGenError::git(format!("Failed to spawn git commit-tree: {e}")))?;
901
902   {
903      let Some(mut stdin) = child.stdin.take() else {
904         return Err(CommitGenError::git("Failed to open git commit-tree stdin".to_string()));
905      };
906      stdin
907         .write_all(message.as_bytes())
908         .map_err(|e| CommitGenError::git(format!("Failed to write commit message: {e}")))?;
909   }
910
911   let output = child
912      .wait_with_output()
913      .map_err(|e| CommitGenError::git(format!("Failed to wait for git commit-tree: {e}")))?;
914
915   if !output.status.success() {
916      let stderr = String::from_utf8_lossy(&output.stderr);
917      return Err(CommitGenError::git(format!("git commit-tree failed: {stderr}")));
918   }
919
920   let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
921   if hash.is_empty() {
922      return Err(CommitGenError::git("git commit-tree returned an empty hash".to_string()));
923   }
924
925   Ok(hash)
926}
927
928#[tracing::instrument(
929   target = "lgit",
930   name = "git.update_ref_checked",
931   skip_all,
932   fields(dir, refname, new, old)
933)]
934pub fn update_ref_checked(refname: &str, new: &str, old: &str, dir: &str) -> Result<()> {
935   let output = git_command()
936      .args(["update-ref", refname, new, old])
937      .current_dir(dir)
938      .output()
939      .map_err(|e| CommitGenError::git(format!("Failed to update {refname}: {e}")))?;
940
941   if !output.status.success() {
942      let stderr = String::from_utf8_lossy(&output.stderr);
943      return Err(CommitGenError::git(format!("git update-ref failed for {refname}: {stderr}")));
944   }
945
946   Ok(())
947}
948
949#[tracing::instrument(target = "lgit", name = "git.reset_mixed", skip_all, fields(dir, treeish))]
950pub fn reset_mixed_to(treeish: &str, dir: &str) -> Result<()> {
951   let output = git_command()
952      .args(["reset", "--mixed", "-q", treeish])
953      .current_dir(dir)
954      .output()
955      .map_err(|e| CommitGenError::git(format!("Failed to reset index to {treeish}: {e}")))?;
956
957   if !output.status.success() {
958      let stderr = String::from_utf8_lossy(&output.stderr);
959      return Err(CommitGenError::git(format!("git reset --mixed failed: {stderr}")));
960   }
961
962   Ok(())
963}
964
965/// Reset the index entries for `paths` to their state in `treeish`, leaving
966/// every other index entry and the worktree untouched.
967///
968/// Used after compose when the real index drifted mid-run: the committed
969/// snapshot paths are refreshed while anything staged during the run stays
970/// staged.
971#[tracing::instrument(target = "lgit", name = "git.reset_paths", skip_all, fields(dir, treeish, path_count = paths.len()))]
972pub fn reset_paths_to(treeish: &str, paths: &[String], dir: &str) -> Result<()> {
973   if paths.is_empty() {
974      return Ok(());
975   }
976
977   let output = git_command()
978      .args(["reset", "-q", treeish, "--"])
979      .args(paths)
980      .current_dir(dir)
981      .output()
982      .map_err(|e| CommitGenError::git(format!("Failed to reset paths to {treeish}: {e}")))?;
983
984   if !output.status.success() {
985      let stderr = String::from_utf8_lossy(&output.stderr);
986      return Err(CommitGenError::git(format!("git reset {treeish} -- <paths> failed: {stderr}")));
987   }
988
989   Ok(())
990}
991
992#[tracing::instrument(target = "lgit", name = "git.append_signoff", skip_all, fields(dir))]
993pub fn append_signoff_trailer(message: &str, dir: &str) -> Result<String> {
994   let output = git_command()
995      .args(["var", "GIT_COMMITTER_IDENT"])
996      .current_dir(dir)
997      .output()
998      .map_err(|e| CommitGenError::git(format!("Failed to read committer identity: {e}")))?;
999
1000   if !output.status.success() {
1001      let stderr = String::from_utf8_lossy(&output.stderr);
1002      return Err(CommitGenError::git(format!("git var GIT_COMMITTER_IDENT failed: {stderr}")));
1003   }
1004
1005   let ident = String::from_utf8_lossy(&output.stdout);
1006   let Some(end) = ident.find('>') else {
1007      return Err(CommitGenError::git(format!(
1008         "Could not parse committer identity: {}",
1009         ident.trim()
1010      )));
1011   };
1012   let signer = ident[..=end].trim();
1013   let trailer = format!("Signed-off-by: {signer}");
1014   let trimmed = message.trim_end();
1015   let mut signed = String::with_capacity(trimmed.len() + trailer.len() + 3);
1016   signed.push_str(trimmed);
1017   signed.push_str("\n\n");
1018   signed.push_str(&trailer);
1019   Ok(signed)
1020}
1021
1022// === History Rewrite Operations ===
1023
1024/// Get list of commit hashes to rewrite (in chronological order)
1025#[tracing::instrument(target = "lgit", name = "git.commit_list", skip_all, fields(dir, start_ref = ?start_ref))]
1026pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
1027   let mut args = vec!["rev-list", "--reverse"];
1028   let range;
1029   if let Some(start) = start_ref {
1030      range = format!("{start}..HEAD");
1031      args.push(&range);
1032   } else {
1033      args.push("HEAD");
1034   }
1035
1036   let output = git_command()
1037      .args(&args)
1038      .current_dir(dir)
1039      .output()
1040      .map_err(|e| CommitGenError::git(format!("Failed to run git rev-list: {e}")))?;
1041
1042   if !output.status.success() {
1043      let stderr = String::from_utf8_lossy(&output.stderr);
1044      return Err(CommitGenError::git(format!("git rev-list failed: {stderr}")));
1045   }
1046
1047   let stdout = String::from_utf8_lossy(&output.stdout);
1048   Ok(stdout.lines().map(|s| s.to_string()).collect())
1049}
1050
1051/// Extract complete metadata for a commit (for rewriting)
1052#[tracing::instrument(target = "lgit", name = "git.commit_metadata", skip_all, fields(dir, hash))]
1053pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
1054   // Format: author_name\0author_email\0author_date\0committer_name\
1055   // 0committer_email\0committer_date\0message
1056   let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
1057
1058   let info_output = git_command()
1059      .args(["show", "-s", &format!("--format={format_str}"), hash])
1060      .current_dir(dir)
1061      .output()
1062      .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
1063
1064   if !info_output.status.success() {
1065      let stderr = String::from_utf8_lossy(&info_output.stderr);
1066      return Err(CommitGenError::git(format!("git show failed for {hash}: {stderr}")));
1067   }
1068
1069   let info = String::from_utf8_lossy(&info_output.stdout);
1070   let parts: Vec<&str> = info.splitn(7, '\0').collect();
1071
1072   if parts.len() < 7 {
1073      return Err(CommitGenError::git(format!("Failed to parse commit metadata for {hash}")));
1074   }
1075
1076   // Get tree hash
1077   let tree_output = git_command()
1078      .args(["rev-parse", &format!("{hash}^{{tree}}")])
1079      .current_dir(dir)
1080      .output()
1081      .map_err(|e| CommitGenError::git(format!("Failed to get tree hash: {e}")))?;
1082   let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
1083      .trim()
1084      .to_string();
1085
1086   // Get parent hashes
1087   let parents_output = git_command()
1088      .args(["rev-list", "--parents", "-n", "1", hash])
1089      .current_dir(dir)
1090      .output()
1091      .map_err(|e| CommitGenError::git(format!("Failed to get parent hashes: {e}")))?;
1092   let parents_line = String::from_utf8_lossy(&parents_output.stdout);
1093   let parent_hashes: Vec<String> = parents_line
1094      .split_whitespace()
1095      .skip(1) // First is the commit itself
1096      .map(|s| s.to_string())
1097      .collect();
1098
1099   Ok(CommitMetadata {
1100      hash: hash.to_string(),
1101      author_name: parts[0].to_string(),
1102      author_email: parts[1].to_string(),
1103      author_date: parts[2].to_string(),
1104      committer_name: parts[3].to_string(),
1105      committer_email: parts[4].to_string(),
1106      committer_date: parts[5].to_string(),
1107      message: parts[6].trim().to_string(),
1108      parent_hashes,
1109      tree_hash,
1110   })
1111}
1112
1113/// Check if working directory is clean
1114#[tracing::instrument(target = "lgit", name = "git.check_worktree_clean", skip_all, fields(dir))]
1115pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
1116   let output = git_command()
1117      .args(["status", "--porcelain"])
1118      .current_dir(dir)
1119      .output()
1120      .map_err(|e| CommitGenError::git(format!("Failed to check working tree: {e}")))?;
1121
1122   Ok(output.stdout.is_empty())
1123}
1124
1125/// Create timestamped backup branch
1126#[tracing::instrument(target = "lgit", name = "git.create_backup_branch", skip_all, fields(dir))]
1127pub fn create_backup_branch(dir: &str) -> Result<String> {
1128   use chrono::Local;
1129
1130   let timestamp = Local::now().format("%Y%m%d-%H%M%S");
1131   let backup_name = format!("backup-rewrite-{timestamp}");
1132
1133   let output = git_command()
1134      .args(["branch", &backup_name])
1135      .current_dir(dir)
1136      .output()
1137      .map_err(|e| CommitGenError::git(format!("Failed to create backup branch: {e}")))?;
1138
1139   if !output.status.success() {
1140      let stderr = String::from_utf8_lossy(&output.stderr);
1141      return Err(CommitGenError::git(format!("git branch failed: {stderr}")));
1142   }
1143
1144   Ok(backup_name)
1145}
1146
1147/// Get recent commit messages for style consistency (last N commits)
1148#[tracing::instrument(target = "lgit", name = "git.recent_commits", skip_all, fields(dir, count))]
1149pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
1150   let output = git_command()
1151      .args(["log", &format!("-{count}"), "--pretty=format:%s"])
1152      .current_dir(dir)
1153      .output()
1154      .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1155
1156   if !output.status.success() {
1157      let stderr = String::from_utf8_lossy(&output.stderr);
1158      return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1159   }
1160
1161   let stdout = String::from_utf8_lossy(&output.stdout);
1162   Ok(stdout.lines().map(|s| s.to_string()).collect())
1163}
1164
1165/// Extract common scopes from git history by parsing commit messages
1166#[tracing::instrument(target = "lgit", name = "git.common_scopes", skip_all, fields(dir, limit))]
1167pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
1168   let output = git_command()
1169      .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
1170      .current_dir(dir)
1171      .output()
1172      .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1173
1174   if !output.status.success() {
1175      let stderr = String::from_utf8_lossy(&output.stderr);
1176      return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1177   }
1178
1179   let stdout = String::from_utf8_lossy(&output.stdout);
1180   let mut scope_counts: HashMap<String, usize> = HashMap::new();
1181
1182   // Parse conventional commit format: type(scope): message
1183   for line in stdout.lines() {
1184      if let Some(scope) = extract_scope_from_commit(line) {
1185         *scope_counts.entry(scope).or_insert(0) += 1;
1186      }
1187   }
1188
1189   // Sort by frequency (descending)
1190   let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
1191   scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1192
1193   Ok(scopes)
1194}
1195
1196/// Extract scope from a conventional commit message
1197fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
1198   // Match pattern: type(scope): message
1199   let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
1200   if parts.len() < 2 {
1201      return None;
1202   }
1203
1204   let prefix = parts[0];
1205   if let Some(scope_start) = prefix.find('(')
1206      && let Some(scope_end) = prefix.find(')')
1207      && scope_start < scope_end
1208   {
1209      return Some(prefix[scope_start + 1..scope_end].to_string());
1210   }
1211
1212   None
1213}
1214
1215/// Quantified style patterns extracted from commit history
1216#[derive(Debug, Clone)]
1217pub struct StylePatterns {
1218   /// Percentage of commits using scopes (0.0-100.0)
1219   pub scope_usage_pct: f32,
1220   /// Common verbs with counts (sorted by count descending)
1221   pub common_verbs:    Vec<(String, usize)>,
1222   /// Average summary length in chars
1223   pub avg_length:      usize,
1224   /// Summary length range (min, max)
1225   pub length_range:    (usize, usize),
1226   /// Percentage of commits starting with lowercase (0.0-100.0)
1227   pub lowercase_pct:   f32,
1228   /// Top scopes with counts (sorted by count descending)
1229   pub top_scopes:      Vec<(String, usize)>,
1230}
1231
1232impl StylePatterns {
1233   /// Format patterns for prompt injection
1234   pub fn format_for_prompt(&self) -> String {
1235      let mut lines = Vec::new();
1236
1237      lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
1238
1239      if !self.common_verbs.is_empty() {
1240         let verbs: Vec<_> = self
1241            .common_verbs
1242            .iter()
1243            .take(5)
1244            .map(|(v, c)| format!("{v} ({c})"))
1245            .collect();
1246         lines.push(format!("Common verbs: {}", verbs.join(", ")));
1247      }
1248
1249      lines.push(format!(
1250         "Average length: {} chars (range: {}-{})",
1251         self.avg_length, self.length_range.0, self.length_range.1
1252      ));
1253
1254      lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
1255
1256      if !self.top_scopes.is_empty() {
1257         let scopes: Vec<_> = self
1258            .top_scopes
1259            .iter()
1260            .take(5)
1261            .map(|(s, c)| format!("{s} ({c})"))
1262            .collect();
1263         lines.push(format!("Top scopes: {}", scopes.join(", ")));
1264      }
1265
1266      lines.join("\n")
1267   }
1268}
1269
1270/// Extract style patterns from commit history
1271pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
1272   if commits.is_empty() {
1273      return None;
1274   }
1275
1276   let mut scope_count = 0;
1277   let mut lowercase_count = 0;
1278   let mut verb_counts: HashMap<String, usize> = HashMap::new();
1279   let mut scope_counts: HashMap<String, usize> = HashMap::new();
1280   let mut lengths = Vec::new();
1281
1282   for commit in commits {
1283      // Parse: type(scope): summary
1284      if let Some(colon_pos) = commit.find(':') {
1285         let prefix = &commit[..colon_pos];
1286         let summary = commit[colon_pos + 1..].trim();
1287
1288         // Check for scope
1289         if let Some(paren_start) = prefix.find('(')
1290            && let Some(paren_end) = prefix.find(')')
1291         {
1292            scope_count += 1;
1293            let scope = &prefix[paren_start + 1..paren_end];
1294            *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
1295         }
1296
1297         // Check capitalization of summary
1298         if let Some(first_char) = summary.chars().next() {
1299            if first_char.is_lowercase() {
1300               lowercase_count += 1;
1301            }
1302
1303            // Extract first word as verb
1304            let first_word = summary.split_whitespace().next().unwrap_or("");
1305            if !first_word.is_empty() {
1306               *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
1307            }
1308         }
1309
1310         lengths.push(summary.len());
1311      }
1312   }
1313
1314   let total = commits.len();
1315   let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
1316   let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
1317
1318   // Sort verbs by count
1319   let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
1320   common_verbs.sort_by_key(|verb| std::cmp::Reverse(verb.1));
1321
1322   // Sort scopes by count
1323   let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
1324   top_scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1325
1326   // Calculate length stats
1327   let avg_length = if lengths.is_empty() {
1328      0
1329   } else {
1330      lengths.iter().sum::<usize>() / lengths.len()
1331   };
1332   let length_range = if lengths.is_empty() {
1333      (0, 0)
1334   } else {
1335      (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
1336   };
1337
1338   Some(StylePatterns {
1339      scope_usage_pct,
1340      common_verbs,
1341      avg_length,
1342      length_range,
1343      lowercase_pct,
1344      top_scopes,
1345   })
1346}
1347
1348/// Rewrite git history with new commit messages
1349#[tracing::instrument(target = "lgit", name = "git.rewrite_history", skip_all, fields(dir, commit_count = commits.len()))]
1350pub fn rewrite_history(
1351   commits: &[CommitMetadata],
1352   new_messages: &[String],
1353   dir: &str,
1354) -> Result<()> {
1355   if commits.len() != new_messages.len() {
1356      return Err(CommitGenError::Other("Commit count mismatch".to_string()));
1357   }
1358
1359   // Get current branch
1360   let branch_output = git_command()
1361      .args(["rev-parse", "--abbrev-ref", "HEAD"])
1362      .current_dir(dir)
1363      .output()
1364      .map_err(|e| CommitGenError::git(format!("Failed to get current branch: {e}")))?;
1365   let current_branch = String::from_utf8_lossy(&branch_output.stdout)
1366      .trim()
1367      .to_string();
1368
1369   // Map old commit hashes to new ones
1370   let mut parent_map: HashMap<String, String> = HashMap::new();
1371   let mut new_head: Option<String> = None;
1372
1373   for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
1374      // Map old parents to new parents
1375      let new_parents: Vec<String> = commit
1376         .parent_hashes
1377         .iter()
1378         .map(|old_parent| {
1379            parent_map
1380               .get(old_parent)
1381               .cloned()
1382               .unwrap_or_else(|| old_parent.clone())
1383         })
1384         .collect();
1385
1386      // Build commit-tree command
1387      let mut cmd = git_command();
1388      cmd.arg("commit-tree")
1389         .arg(&commit.tree_hash)
1390         .arg("-m")
1391         .arg(new_msg)
1392         .current_dir(dir);
1393
1394      for parent in &new_parents {
1395         cmd.arg("-p").arg(parent);
1396      }
1397
1398      // Preserve original author/committer metadata
1399      cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
1400         .env("GIT_AUTHOR_EMAIL", &commit.author_email)
1401         .env("GIT_AUTHOR_DATE", &commit.author_date)
1402         .env("GIT_COMMITTER_NAME", &commit.committer_name)
1403         .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
1404         .env("GIT_COMMITTER_DATE", &commit.committer_date);
1405
1406      let output = cmd
1407         .output()
1408         .map_err(|e| CommitGenError::git(format!("Failed to run git commit-tree: {e}")))?;
1409
1410      if !output.status.success() {
1411         let stderr = String::from_utf8_lossy(&output.stderr);
1412         return Err(CommitGenError::git(format!(
1413            "commit-tree failed for {}: {}",
1414            commit.hash, stderr
1415         )));
1416      }
1417
1418      let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
1419
1420      parent_map.insert(commit.hash.clone(), new_hash.clone());
1421      new_head = Some(new_hash);
1422
1423      // Progress reporting
1424      if (idx + 1) % 50 == 0 {
1425         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
1426      }
1427   }
1428
1429   // Update branch to new head
1430   if let Some(head) = new_head {
1431      let update_output = git_command()
1432         .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
1433         .current_dir(dir)
1434         .output()
1435         .map_err(|e| CommitGenError::git(format!("Failed to update ref: {e}")))?;
1436
1437      if !update_output.status.success() {
1438         let stderr = String::from_utf8_lossy(&update_output.stderr);
1439         return Err(CommitGenError::git(format!("git update-ref failed: {stderr}")));
1440      }
1441
1442      let reset_output = git_command()
1443         .args(["reset", "--hard", &head])
1444         .current_dir(dir)
1445         .output()
1446         .map_err(|e| CommitGenError::git(format!("Failed to reset: {e}")))?;
1447
1448      if !reset_output.status.success() {
1449         let stderr = String::from_utf8_lossy(&reset_output.stderr);
1450         return Err(CommitGenError::git(format!("git reset failed: {stderr}")));
1451      }
1452   }
1453
1454   Ok(())
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459   use super::*;
1460
1461   #[test]
1462   fn test_git_command_applies_background_feature_overrides_when_enabled() {
1463      let cmd =
1464         git_command_with_settings(GitCommandSettings { disable_git_background_features: true });
1465      let args: Vec<String> = cmd
1466         .get_args()
1467         .map(|arg| arg.to_string_lossy().into_owned())
1468         .collect();
1469
1470      assert_eq!(args, vec![
1471         "-c".to_string(),
1472         "core.fsmonitor=false".to_string(),
1473         "-c".to_string(),
1474         "core.untrackedCache=false".to_string(),
1475      ]);
1476   }
1477
1478   fn run_test_git(dir: &tempfile::TempDir, args: &[&str]) -> String {
1479      let output = git_command()
1480         .args(args)
1481         .current_dir(dir.path())
1482         .output()
1483         .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
1484      assert!(
1485         output.status.success(),
1486         "git {:?} failed: {}",
1487         args,
1488         String::from_utf8_lossy(&output.stderr)
1489      );
1490      String::from_utf8_lossy(&output.stdout).to_string()
1491   }
1492
1493   #[test]
1494   fn test_commit_snapshot_tree_commits_snapshot_and_keeps_drifted_staging() {
1495      let dir = tempfile::TempDir::new().unwrap();
1496      let dir_str = dir.path().to_str().unwrap();
1497      run_test_git(&dir, &["init"]);
1498      run_test_git(&dir, &["config", "user.name", "Guard Test"]);
1499      run_test_git(&dir, &["config", "user.email", "guard@test.local"]);
1500      run_test_git(&dir, &["config", "commit.gpgsign", "false"]);
1501      std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
1502      run_test_git(&dir, &["add", "a.txt"]);
1503      run_test_git(&dir, &["commit", "-m", "base"]);
1504
1505      // The analyzed snapshot: a.txt modified and staged.
1506      std::fs::write(dir.path().join("a.txt"), "two\n").unwrap();
1507      run_test_git(&dir, &["add", "a.txt"]);
1508      let snapshot_tree = write_real_index_tree(dir_str).unwrap();
1509
1510      // Mid-run drift: another file gets staged.
1511      std::fs::write(dir.path().join("b.txt"), "drift\n").unwrap();
1512      run_test_git(&dir, &["add", "b.txt"]);
1513
1514      let hash = commit_snapshot_tree("feat: snapshot", &snapshot_tree, dir_str, false, false, false)
1515         .unwrap()
1516         .expect("snapshot differs from HEAD");
1517
1518      // HEAD advanced to exactly the snapshot tree.
1519      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD"]).trim(), hash);
1520      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD^{tree}"]).trim(), snapshot_tree);
1521      assert_eq!(run_test_git(&dir, &["show", "HEAD:a.txt"]), "two\n");
1522      assert!(
1523         !run_test_git(&dir, &["ls-tree", "--name-only", "HEAD"]).contains("b.txt"),
1524         "drifted staging must not enter the commit"
1525      );
1526
1527      // The drifted staging survives, staged for the next commit.
1528      assert_eq!(run_test_git(&dir, &["diff", "--cached", "--name-only"]).trim(), "b.txt");
1529      assert_eq!(std::fs::read_to_string(dir.path().join("b.txt")).unwrap(), "drift\n");
1530
1531      // Re-committing the same snapshot is a no-op.
1532      let again =
1533         commit_snapshot_tree("feat: again", &snapshot_tree, dir_str, false, false, false).unwrap();
1534      assert_eq!(again, None);
1535      assert_eq!(run_test_git(&dir, &["rev-parse", "HEAD"]).trim(), hash);
1536   }
1537
1538   #[test]
1539   fn test_git_command_skips_background_feature_overrides_when_disabled() {
1540      let cmd =
1541         git_command_with_settings(GitCommandSettings { disable_git_background_features: false });
1542      assert!(cmd.get_args().next().is_none());
1543   }
1544}