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