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(target = "lgit", name = "git.read_tree_into_index", skip_all, fields(dir, treeish, index = %index_file.display()))]
750pub fn read_tree_into_index(index_file: &Path, treeish: &str, dir: &str) -> Result<()> {
751 let output = git_command_with_index(index_file)
752 .arg("read-tree")
753 .arg(treeish)
754 .current_dir(dir)
755 .output()
756 .map_err(|e| CommitGenError::git(format!("Failed to read tree into temporary index: {e}")))?;
757
758 if !output.status.success() {
759 let stderr = String::from_utf8_lossy(&output.stderr);
760 return Err(CommitGenError::git(format!("git read-tree {treeish} failed: {stderr}")));
761 }
762
763 Ok(())
764}
765
766#[tracing::instrument(target = "lgit", name = "git.write_index_tree", skip_all, fields(dir, index = %index_file.display()))]
767pub fn write_index_tree(index_file: &Path, dir: &str) -> Result<String> {
768 let output = git_command_with_index(index_file)
769 .arg("write-tree")
770 .current_dir(dir)
771 .output()
772 .map_err(|e| CommitGenError::git(format!("Failed to write temporary index tree: {e}")))?;
773
774 if !output.status.success() {
775 let stderr = String::from_utf8_lossy(&output.stderr);
776 return Err(CommitGenError::git(format!(
777 "git write-tree failed for temporary index: {stderr}"
778 )));
779 }
780
781 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
782}
783
784#[tracing::instrument(
785 target = "lgit",
786 name = "git.commit_tree",
787 skip_all,
788 fields(dir, parent, tree, sign)
789)]
790pub fn commit_tree(
791 tree: &str,
792 parent: &str,
793 message: &str,
794 dir: &str,
795 sign: bool,
796) -> Result<String> {
797 let mut cmd = git_command();
798 cmd.arg("commit-tree");
799 if sign {
800 cmd.arg("-S");
801 }
802 cmd.arg(tree).arg("-p").arg(parent).arg("-F").arg("-");
803
804 let mut child = cmd
805 .current_dir(dir)
806 .stdin(Stdio::piped())
807 .stdout(Stdio::piped())
808 .stderr(Stdio::piped())
809 .spawn()
810 .map_err(|e| CommitGenError::git(format!("Failed to spawn git commit-tree: {e}")))?;
811
812 {
813 let Some(mut stdin) = child.stdin.take() else {
814 return Err(CommitGenError::git("Failed to open git commit-tree stdin".to_string()));
815 };
816 stdin
817 .write_all(message.as_bytes())
818 .map_err(|e| CommitGenError::git(format!("Failed to write commit message: {e}")))?;
819 }
820
821 let output = child
822 .wait_with_output()
823 .map_err(|e| CommitGenError::git(format!("Failed to wait for git commit-tree: {e}")))?;
824
825 if !output.status.success() {
826 let stderr = String::from_utf8_lossy(&output.stderr);
827 return Err(CommitGenError::git(format!("git commit-tree failed: {stderr}")));
828 }
829
830 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
831 if hash.is_empty() {
832 return Err(CommitGenError::git("git commit-tree returned an empty hash".to_string()));
833 }
834
835 Ok(hash)
836}
837
838#[tracing::instrument(
839 target = "lgit",
840 name = "git.update_ref_checked",
841 skip_all,
842 fields(dir, refname, new, old)
843)]
844pub fn update_ref_checked(refname: &str, new: &str, old: &str, dir: &str) -> Result<()> {
845 let output = git_command()
846 .args(["update-ref", refname, new, old])
847 .current_dir(dir)
848 .output()
849 .map_err(|e| CommitGenError::git(format!("Failed to update {refname}: {e}")))?;
850
851 if !output.status.success() {
852 let stderr = String::from_utf8_lossy(&output.stderr);
853 return Err(CommitGenError::git(format!("git update-ref failed for {refname}: {stderr}")));
854 }
855
856 Ok(())
857}
858
859#[tracing::instrument(target = "lgit", name = "git.reset_mixed", skip_all, fields(dir, treeish))]
860pub fn reset_mixed_to(treeish: &str, dir: &str) -> Result<()> {
861 let output = git_command()
862 .args(["reset", "--mixed", "-q", treeish])
863 .current_dir(dir)
864 .output()
865 .map_err(|e| CommitGenError::git(format!("Failed to reset index to {treeish}: {e}")))?;
866
867 if !output.status.success() {
868 let stderr = String::from_utf8_lossy(&output.stderr);
869 return Err(CommitGenError::git(format!("git reset --mixed failed: {stderr}")));
870 }
871
872 Ok(())
873}
874
875#[tracing::instrument(target = "lgit", name = "git.append_signoff", skip_all, fields(dir))]
876pub fn append_signoff_trailer(message: &str, dir: &str) -> Result<String> {
877 let output = git_command()
878 .args(["var", "GIT_COMMITTER_IDENT"])
879 .current_dir(dir)
880 .output()
881 .map_err(|e| CommitGenError::git(format!("Failed to read committer identity: {e}")))?;
882
883 if !output.status.success() {
884 let stderr = String::from_utf8_lossy(&output.stderr);
885 return Err(CommitGenError::git(format!("git var GIT_COMMITTER_IDENT failed: {stderr}")));
886 }
887
888 let ident = String::from_utf8_lossy(&output.stdout);
889 let Some(end) = ident.find('>') else {
890 return Err(CommitGenError::git(format!(
891 "Could not parse committer identity: {}",
892 ident.trim()
893 )));
894 };
895 let signer = ident[..=end].trim();
896 let trailer = format!("Signed-off-by: {signer}");
897 let trimmed = message.trim_end();
898 let mut signed = String::with_capacity(trimmed.len() + trailer.len() + 3);
899 signed.push_str(trimmed);
900 signed.push_str("\n\n");
901 signed.push_str(&trailer);
902 Ok(signed)
903}
904
905#[tracing::instrument(target = "lgit", name = "git.commit_list", skip_all, fields(dir, start_ref = ?start_ref))]
909pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
910 let mut args = vec!["rev-list", "--reverse"];
911 let range;
912 if let Some(start) = start_ref {
913 range = format!("{start}..HEAD");
914 args.push(&range);
915 } else {
916 args.push("HEAD");
917 }
918
919 let output = git_command()
920 .args(&args)
921 .current_dir(dir)
922 .output()
923 .map_err(|e| CommitGenError::git(format!("Failed to run git rev-list: {e}")))?;
924
925 if !output.status.success() {
926 let stderr = String::from_utf8_lossy(&output.stderr);
927 return Err(CommitGenError::git(format!("git rev-list failed: {stderr}")));
928 }
929
930 let stdout = String::from_utf8_lossy(&output.stdout);
931 Ok(stdout.lines().map(|s| s.to_string()).collect())
932}
933
934#[tracing::instrument(target = "lgit", name = "git.commit_metadata", skip_all, fields(dir, hash))]
936pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
937 let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
940
941 let info_output = git_command()
942 .args(["show", "-s", &format!("--format={format_str}"), hash])
943 .current_dir(dir)
944 .output()
945 .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
946
947 if !info_output.status.success() {
948 let stderr = String::from_utf8_lossy(&info_output.stderr);
949 return Err(CommitGenError::git(format!("git show failed for {hash}: {stderr}")));
950 }
951
952 let info = String::from_utf8_lossy(&info_output.stdout);
953 let parts: Vec<&str> = info.splitn(7, '\0').collect();
954
955 if parts.len() < 7 {
956 return Err(CommitGenError::git(format!("Failed to parse commit metadata for {hash}")));
957 }
958
959 let tree_output = git_command()
961 .args(["rev-parse", &format!("{hash}^{{tree}}")])
962 .current_dir(dir)
963 .output()
964 .map_err(|e| CommitGenError::git(format!("Failed to get tree hash: {e}")))?;
965 let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
966 .trim()
967 .to_string();
968
969 let parents_output = git_command()
971 .args(["rev-list", "--parents", "-n", "1", hash])
972 .current_dir(dir)
973 .output()
974 .map_err(|e| CommitGenError::git(format!("Failed to get parent hashes: {e}")))?;
975 let parents_line = String::from_utf8_lossy(&parents_output.stdout);
976 let parent_hashes: Vec<String> = parents_line
977 .split_whitespace()
978 .skip(1) .map(|s| s.to_string())
980 .collect();
981
982 Ok(CommitMetadata {
983 hash: hash.to_string(),
984 author_name: parts[0].to_string(),
985 author_email: parts[1].to_string(),
986 author_date: parts[2].to_string(),
987 committer_name: parts[3].to_string(),
988 committer_email: parts[4].to_string(),
989 committer_date: parts[5].to_string(),
990 message: parts[6].trim().to_string(),
991 parent_hashes,
992 tree_hash,
993 })
994}
995
996#[tracing::instrument(target = "lgit", name = "git.check_worktree_clean", skip_all, fields(dir))]
998pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
999 let output = git_command()
1000 .args(["status", "--porcelain"])
1001 .current_dir(dir)
1002 .output()
1003 .map_err(|e| CommitGenError::git(format!("Failed to check working tree: {e}")))?;
1004
1005 Ok(output.stdout.is_empty())
1006}
1007
1008#[tracing::instrument(target = "lgit", name = "git.create_backup_branch", skip_all, fields(dir))]
1010pub fn create_backup_branch(dir: &str) -> Result<String> {
1011 use chrono::Local;
1012
1013 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
1014 let backup_name = format!("backup-rewrite-{timestamp}");
1015
1016 let output = git_command()
1017 .args(["branch", &backup_name])
1018 .current_dir(dir)
1019 .output()
1020 .map_err(|e| CommitGenError::git(format!("Failed to create backup branch: {e}")))?;
1021
1022 if !output.status.success() {
1023 let stderr = String::from_utf8_lossy(&output.stderr);
1024 return Err(CommitGenError::git(format!("git branch failed: {stderr}")));
1025 }
1026
1027 Ok(backup_name)
1028}
1029
1030#[tracing::instrument(target = "lgit", name = "git.recent_commits", skip_all, fields(dir, count))]
1032pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
1033 let output = git_command()
1034 .args(["log", &format!("-{count}"), "--pretty=format:%s"])
1035 .current_dir(dir)
1036 .output()
1037 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1038
1039 if !output.status.success() {
1040 let stderr = String::from_utf8_lossy(&output.stderr);
1041 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1042 }
1043
1044 let stdout = String::from_utf8_lossy(&output.stdout);
1045 Ok(stdout.lines().map(|s| s.to_string()).collect())
1046}
1047
1048#[tracing::instrument(target = "lgit", name = "git.common_scopes", skip_all, fields(dir, limit))]
1050pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
1051 let output = git_command()
1052 .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
1053 .current_dir(dir)
1054 .output()
1055 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
1056
1057 if !output.status.success() {
1058 let stderr = String::from_utf8_lossy(&output.stderr);
1059 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
1060 }
1061
1062 let stdout = String::from_utf8_lossy(&output.stdout);
1063 let mut scope_counts: HashMap<String, usize> = HashMap::new();
1064
1065 for line in stdout.lines() {
1067 if let Some(scope) = extract_scope_from_commit(line) {
1068 *scope_counts.entry(scope).or_insert(0) += 1;
1069 }
1070 }
1071
1072 let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
1074 scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1075
1076 Ok(scopes)
1077}
1078
1079fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
1081 let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
1083 if parts.len() < 2 {
1084 return None;
1085 }
1086
1087 let prefix = parts[0];
1088 if let Some(scope_start) = prefix.find('(')
1089 && let Some(scope_end) = prefix.find(')')
1090 && scope_start < scope_end
1091 {
1092 return Some(prefix[scope_start + 1..scope_end].to_string());
1093 }
1094
1095 None
1096}
1097
1098#[derive(Debug, Clone)]
1100pub struct StylePatterns {
1101 pub scope_usage_pct: f32,
1103 pub common_verbs: Vec<(String, usize)>,
1105 pub avg_length: usize,
1107 pub length_range: (usize, usize),
1109 pub lowercase_pct: f32,
1111 pub top_scopes: Vec<(String, usize)>,
1113}
1114
1115impl StylePatterns {
1116 pub fn format_for_prompt(&self) -> String {
1118 let mut lines = Vec::new();
1119
1120 lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
1121
1122 if !self.common_verbs.is_empty() {
1123 let verbs: Vec<_> = self
1124 .common_verbs
1125 .iter()
1126 .take(5)
1127 .map(|(v, c)| format!("{v} ({c})"))
1128 .collect();
1129 lines.push(format!("Common verbs: {}", verbs.join(", ")));
1130 }
1131
1132 lines.push(format!(
1133 "Average length: {} chars (range: {}-{})",
1134 self.avg_length, self.length_range.0, self.length_range.1
1135 ));
1136
1137 lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
1138
1139 if !self.top_scopes.is_empty() {
1140 let scopes: Vec<_> = self
1141 .top_scopes
1142 .iter()
1143 .take(5)
1144 .map(|(s, c)| format!("{s} ({c})"))
1145 .collect();
1146 lines.push(format!("Top scopes: {}", scopes.join(", ")));
1147 }
1148
1149 lines.join("\n")
1150 }
1151}
1152
1153pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
1155 if commits.is_empty() {
1156 return None;
1157 }
1158
1159 let mut scope_count = 0;
1160 let mut lowercase_count = 0;
1161 let mut verb_counts: HashMap<String, usize> = HashMap::new();
1162 let mut scope_counts: HashMap<String, usize> = HashMap::new();
1163 let mut lengths = Vec::new();
1164
1165 for commit in commits {
1166 if let Some(colon_pos) = commit.find(':') {
1168 let prefix = &commit[..colon_pos];
1169 let summary = commit[colon_pos + 1..].trim();
1170
1171 if let Some(paren_start) = prefix.find('(')
1173 && let Some(paren_end) = prefix.find(')')
1174 {
1175 scope_count += 1;
1176 let scope = &prefix[paren_start + 1..paren_end];
1177 *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
1178 }
1179
1180 if let Some(first_char) = summary.chars().next() {
1182 if first_char.is_lowercase() {
1183 lowercase_count += 1;
1184 }
1185
1186 let first_word = summary.split_whitespace().next().unwrap_or("");
1188 if !first_word.is_empty() {
1189 *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
1190 }
1191 }
1192
1193 lengths.push(summary.len());
1194 }
1195 }
1196
1197 let total = commits.len();
1198 let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
1199 let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
1200
1201 let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
1203 common_verbs.sort_by_key(|verb| std::cmp::Reverse(verb.1));
1204
1205 let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
1207 top_scopes.sort_by_key(|scope| std::cmp::Reverse(scope.1));
1208
1209 let avg_length = if lengths.is_empty() {
1211 0
1212 } else {
1213 lengths.iter().sum::<usize>() / lengths.len()
1214 };
1215 let length_range = if lengths.is_empty() {
1216 (0, 0)
1217 } else {
1218 (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
1219 };
1220
1221 Some(StylePatterns {
1222 scope_usage_pct,
1223 common_verbs,
1224 avg_length,
1225 length_range,
1226 lowercase_pct,
1227 top_scopes,
1228 })
1229}
1230
1231#[tracing::instrument(target = "lgit", name = "git.rewrite_history", skip_all, fields(dir, commit_count = commits.len()))]
1233pub fn rewrite_history(
1234 commits: &[CommitMetadata],
1235 new_messages: &[String],
1236 dir: &str,
1237) -> Result<()> {
1238 if commits.len() != new_messages.len() {
1239 return Err(CommitGenError::Other("Commit count mismatch".to_string()));
1240 }
1241
1242 let branch_output = git_command()
1244 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1245 .current_dir(dir)
1246 .output()
1247 .map_err(|e| CommitGenError::git(format!("Failed to get current branch: {e}")))?;
1248 let current_branch = String::from_utf8_lossy(&branch_output.stdout)
1249 .trim()
1250 .to_string();
1251
1252 let mut parent_map: HashMap<String, String> = HashMap::new();
1254 let mut new_head: Option<String> = None;
1255
1256 for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
1257 let new_parents: Vec<String> = commit
1259 .parent_hashes
1260 .iter()
1261 .map(|old_parent| {
1262 parent_map
1263 .get(old_parent)
1264 .cloned()
1265 .unwrap_or_else(|| old_parent.clone())
1266 })
1267 .collect();
1268
1269 let mut cmd = git_command();
1271 cmd.arg("commit-tree")
1272 .arg(&commit.tree_hash)
1273 .arg("-m")
1274 .arg(new_msg)
1275 .current_dir(dir);
1276
1277 for parent in &new_parents {
1278 cmd.arg("-p").arg(parent);
1279 }
1280
1281 cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
1283 .env("GIT_AUTHOR_EMAIL", &commit.author_email)
1284 .env("GIT_AUTHOR_DATE", &commit.author_date)
1285 .env("GIT_COMMITTER_NAME", &commit.committer_name)
1286 .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
1287 .env("GIT_COMMITTER_DATE", &commit.committer_date);
1288
1289 let output = cmd
1290 .output()
1291 .map_err(|e| CommitGenError::git(format!("Failed to run git commit-tree: {e}")))?;
1292
1293 if !output.status.success() {
1294 let stderr = String::from_utf8_lossy(&output.stderr);
1295 return Err(CommitGenError::git(format!(
1296 "commit-tree failed for {}: {}",
1297 commit.hash, stderr
1298 )));
1299 }
1300
1301 let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
1302
1303 parent_map.insert(commit.hash.clone(), new_hash.clone());
1304 new_head = Some(new_hash);
1305
1306 if (idx + 1) % 50 == 0 {
1308 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
1309 }
1310 }
1311
1312 if let Some(head) = new_head {
1314 let update_output = git_command()
1315 .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
1316 .current_dir(dir)
1317 .output()
1318 .map_err(|e| CommitGenError::git(format!("Failed to update ref: {e}")))?;
1319
1320 if !update_output.status.success() {
1321 let stderr = String::from_utf8_lossy(&update_output.stderr);
1322 return Err(CommitGenError::git(format!("git update-ref failed: {stderr}")));
1323 }
1324
1325 let reset_output = git_command()
1326 .args(["reset", "--hard", &head])
1327 .current_dir(dir)
1328 .output()
1329 .map_err(|e| CommitGenError::git(format!("Failed to reset: {e}")))?;
1330
1331 if !reset_output.status.success() {
1332 let stderr = String::from_utf8_lossy(&reset_output.stderr);
1333 return Err(CommitGenError::git(format!("git reset failed: {stderr}")));
1334 }
1335 }
1336
1337 Ok(())
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 #[test]
1345 fn test_git_command_applies_background_feature_overrides_when_enabled() {
1346 let cmd =
1347 git_command_with_settings(GitCommandSettings { disable_git_background_features: true });
1348 let args: Vec<String> = cmd
1349 .get_args()
1350 .map(|arg| arg.to_string_lossy().into_owned())
1351 .collect();
1352
1353 assert_eq!(args, vec![
1354 "-c".to_string(),
1355 "core.fsmonitor=false".to_string(),
1356 "-c".to_string(),
1357 "core.untrackedCache=false".to_string(),
1358 ]);
1359 }
1360
1361 #[test]
1362 fn test_git_command_skips_background_feature_overrides_when_disabled() {
1363 let cmd =
1364 git_command_with_settings(GitCommandSettings { disable_git_background_features: false });
1365 assert!(cmd.get_args().next().is_none());
1366 }
1367}