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
52pub 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 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
249fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
252 if !stderr.contains("index.lock") {
253 return None;
254 }
255
256 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#[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#[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 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#[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 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#[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#[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#[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#[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 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
800fn 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
816fn 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#[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#[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#[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 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 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 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) .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#[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#[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#[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#[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 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 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
1196fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
1198 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#[derive(Debug, Clone)]
1217pub struct StylePatterns {
1218 pub scope_usage_pct: f32,
1220 pub common_verbs: Vec<(String, usize)>,
1222 pub avg_length: usize,
1224 pub length_range: (usize, usize),
1226 pub lowercase_pct: f32,
1228 pub top_scopes: Vec<(String, usize)>,
1230}
1231
1232impl StylePatterns {
1233 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
1270pub 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 if let Some(colon_pos) = commit.find(':') {
1285 let prefix = &commit[..colon_pos];
1286 let summary = commit[colon_pos + 1..].trim();
1287
1288 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 if let Some(first_char) = summary.chars().next() {
1299 if first_char.is_lowercase() {
1300 lowercase_count += 1;
1301 }
1302
1303 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 let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
1320 common_verbs.sort_by_key(|verb| std::cmp::Reverse(verb.1));
1321
1322 let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
1324 top_scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1325
1326 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#[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 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 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 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 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 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 if (idx + 1) % 50 == 0 {
1425 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
1426 }
1427 }
1428
1429 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 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 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 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 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 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}