llm_git/
git.rs

1use std::{collections::HashMap, process::Command};
2
3use crate::{
4   config::CommitConfig,
5   error::{CommitGenError, Result},
6   types::{CommitMetadata, Mode},
7};
8
9/// Get git diff based on the specified mode
10pub fn get_git_diff(
11   mode: &Mode,
12   target: Option<&str>,
13   dir: &str,
14   config: &CommitConfig,
15) -> Result<String> {
16   let output = match mode {
17      Mode::Staged => Command::new("git")
18         .args(["diff", "--cached"])
19         .current_dir(dir)
20         .output()
21         .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --cached: {e}")))?,
22      Mode::Commit => {
23         let target = target.ok_or_else(|| {
24            CommitGenError::ValidationError("--target required for commit mode".to_string())
25         })?;
26         let mut cmd = Command::new("git");
27         cmd.arg("show");
28         if config.exclude_old_message {
29            cmd.arg("--format=");
30         }
31         cmd.arg(target)
32            .current_dir(dir)
33            .output()
34            .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?
35      },
36      Mode::Unstaged => {
37         // Get diff for tracked files
38         let tracked_output = Command::new("git")
39            .args(["diff"])
40            .current_dir(dir)
41            .output()
42            .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff: {e}")))?;
43
44         if !tracked_output.status.success() {
45            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
46            return Err(CommitGenError::GitError(format!("git diff failed: {stderr}")));
47         }
48
49         let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
50
51         // Get untracked files
52         let untracked_output = Command::new("git")
53            .args(["ls-files", "--others", "--exclude-standard"])
54            .current_dir(dir)
55            .output()
56            .map_err(|e| {
57               CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
58            })?;
59
60         if !untracked_output.status.success() {
61            let stderr = String::from_utf8_lossy(&untracked_output.stderr);
62            return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
63         }
64
65         let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
66         let untracked_files: Vec<&str> =
67            untracked_list.lines().filter(|s| !s.is_empty()).collect();
68
69         if untracked_files.is_empty() {
70            return Ok(tracked_diff);
71         }
72
73         // Generate diffs for untracked files using git diff /dev/null
74         let mut combined_diff = tracked_diff;
75         for file in untracked_files {
76            let file_diff_output = Command::new("git")
77               .args(["diff", "--no-index", "/dev/null", file])
78               .current_dir(dir)
79               .output()
80               .map_err(|e| {
81                  CommitGenError::GitError(format!("Failed to diff untracked file {file}: {e}"))
82               })?;
83
84            // git diff --no-index exits with 1 when files differ (expected)
85            if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
86               let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
87               // Rewrite the diff header to match standard git format
88               let lines: Vec<&str> = file_diff.lines().collect();
89               if lines.len() >= 2 {
90                  use std::fmt::Write;
91                  if !combined_diff.is_empty() {
92                     combined_diff.push('\n');
93                  }
94                  writeln!(combined_diff, "diff --git a/{file} b/{file}").unwrap();
95                  combined_diff.push_str("new file mode 100644\n");
96                  combined_diff.push_str("index 0000000..0000000\n");
97                  combined_diff.push_str("--- /dev/null\n");
98                  writeln!(combined_diff, "+++ b/{file}").unwrap();
99                  // Skip first 2 lines (---/+++ from --no-index) and copy rest
100                  for line in lines.iter().skip(2) {
101                     combined_diff.push_str(line);
102                     combined_diff.push('\n');
103                  }
104               }
105            }
106         }
107
108         return Ok(combined_diff);
109      },
110      Mode::Compose => unreachable!("compose mode handled separately"),
111   };
112
113   if !output.status.success() {
114      let stderr = String::from_utf8_lossy(&output.stderr);
115      return Err(CommitGenError::GitError(format!("Git command failed: {stderr}")));
116   }
117
118   let diff = String::from_utf8_lossy(&output.stdout).to_string();
119
120   if diff.trim().is_empty() {
121      let mode_str = match mode {
122         Mode::Staged => "staged",
123         Mode::Commit => "commit",
124         Mode::Unstaged => "unstaged",
125         Mode::Compose => "compose",
126      };
127      return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
128   }
129
130   Ok(diff)
131}
132
133/// Get git diff --stat to show file-level changes summary
134pub fn get_git_stat(
135   mode: &Mode,
136   target: Option<&str>,
137   dir: &str,
138   config: &CommitConfig,
139) -> Result<String> {
140   let output = match mode {
141      Mode::Staged => Command::new("git")
142         .args(["diff", "--cached", "--stat"])
143         .current_dir(dir)
144         .output()
145         .map_err(|e| {
146            CommitGenError::GitError(format!("Failed to run git diff --cached --stat: {e}"))
147         })?,
148      Mode::Commit => {
149         let target = target.ok_or_else(|| {
150            CommitGenError::ValidationError("--target required for commit mode".to_string())
151         })?;
152         let mut cmd = Command::new("git");
153         cmd.arg("show");
154         if config.exclude_old_message {
155            cmd.arg("--format=");
156         }
157         cmd.arg("--stat")
158            .arg(target)
159            .current_dir(dir)
160            .output()
161            .map_err(|e| CommitGenError::GitError(format!("Failed to run git show --stat: {e}")))?
162      },
163      Mode::Unstaged => {
164         // Get stat for tracked files
165         let tracked_output = Command::new("git")
166            .args(["diff", "--stat"])
167            .current_dir(dir)
168            .output()
169            .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --stat: {e}")))?;
170
171         if !tracked_output.status.success() {
172            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
173            return Err(CommitGenError::GitError(format!("git diff --stat failed: {stderr}")));
174         }
175
176         let mut stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
177
178         // Get untracked files and append to stat
179         let untracked_output = Command::new("git")
180            .args(["ls-files", "--others", "--exclude-standard"])
181            .current_dir(dir)
182            .output()
183            .map_err(|e| {
184               CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
185            })?;
186
187         if !untracked_output.status.success() {
188            let stderr = String::from_utf8_lossy(&untracked_output.stderr);
189            return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
190         }
191
192         let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
193         let untracked_files: Vec<&str> =
194            untracked_list.lines().filter(|s| !s.is_empty()).collect();
195
196         if !untracked_files.is_empty() {
197            use std::fmt::Write;
198            for file in untracked_files {
199               use std::fs;
200               if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
201                  let lines = if metadata.is_file() {
202                     fs::read_to_string(format!("{dir}/{file}"))
203                        .map(|content| content.lines().count())
204                        .unwrap_or(0)
205                  } else {
206                     0
207                  };
208                  if !stat.is_empty() && !stat.ends_with('\n') {
209                     stat.push('\n');
210                  }
211                  writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
212               }
213            }
214         }
215
216         return Ok(stat);
217      },
218      Mode::Compose => unreachable!("compose mode handled separately"),
219   };
220
221   if !output.status.success() {
222      let stderr = String::from_utf8_lossy(&output.stderr);
223      return Err(CommitGenError::GitError(format!("Git stat command failed: {stderr}")));
224   }
225
226   Ok(String::from_utf8_lossy(&output.stdout).to_string())
227}
228
229/// Execute git commit with the given message
230pub fn git_commit(message: &str, dry_run: bool, dir: &str) -> Result<()> {
231   if dry_run {
232      println!("\n{}", "=".repeat(60));
233      println!("DRY RUN - Would execute:");
234      println!("git commit -m \"{}\"", message.replace('\n', "\\n"));
235      println!("{}", "=".repeat(60));
236      return Ok(());
237   }
238
239   let output = Command::new("git")
240      .args(["commit", "-m", message])
241      .current_dir(dir)
242      .output()
243      .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit: {e}")))?;
244
245   if !output.status.success() {
246      let stderr = String::from_utf8_lossy(&output.stderr);
247      let stdout = String::from_utf8_lossy(&output.stdout);
248      return Err(CommitGenError::GitError(format!(
249         "Git commit failed:\nstderr: {stderr}\nstdout: {stdout}"
250      )));
251   }
252
253   let stdout = String::from_utf8_lossy(&output.stdout);
254   println!("\n{stdout}");
255   println!("✓ Successfully committed!");
256
257   Ok(())
258}
259
260/// Get the current HEAD commit hash
261pub fn get_head_hash(dir: &str) -> Result<String> {
262   let output = Command::new("git")
263      .args(["rev-parse", "HEAD"])
264      .current_dir(dir)
265      .output()
266      .map_err(|e| CommitGenError::GitError(format!("Failed to get HEAD hash: {e}")))?;
267
268   if !output.status.success() {
269      let stderr = String::from_utf8_lossy(&output.stderr);
270      return Err(CommitGenError::GitError(format!("git rev-parse HEAD failed: {stderr}")));
271   }
272
273   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
274}
275
276// === History Rewrite Operations ===
277
278/// Get list of commit hashes to rewrite (in chronological order)
279pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
280   let mut args = vec!["rev-list", "--reverse"];
281   let range;
282   if let Some(start) = start_ref {
283      range = format!("{start}..HEAD");
284      args.push(&range);
285   } else {
286      args.push("HEAD");
287   }
288
289   let output = Command::new("git")
290      .args(&args)
291      .current_dir(dir)
292      .output()
293      .map_err(|e| CommitGenError::GitError(format!("Failed to run git rev-list: {e}")))?;
294
295   if !output.status.success() {
296      let stderr = String::from_utf8_lossy(&output.stderr);
297      return Err(CommitGenError::GitError(format!("git rev-list failed: {stderr}")));
298   }
299
300   let stdout = String::from_utf8_lossy(&output.stdout);
301   Ok(stdout.lines().map(|s| s.to_string()).collect())
302}
303
304/// Extract complete metadata for a commit (for rewriting)
305pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
306   // Format: author_name\0author_email\0author_date\0committer_name\
307   // 0committer_email\0committer_date\0message
308   let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
309
310   let info_output = Command::new("git")
311      .args(["show", "-s", &format!("--format={format_str}"), hash])
312      .current_dir(dir)
313      .output()
314      .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?;
315
316   if !info_output.status.success() {
317      let stderr = String::from_utf8_lossy(&info_output.stderr);
318      return Err(CommitGenError::GitError(format!("git show failed for {hash}: {stderr}")));
319   }
320
321   let info = String::from_utf8_lossy(&info_output.stdout);
322   let parts: Vec<&str> = info.splitn(7, '\0').collect();
323
324   if parts.len() < 7 {
325      return Err(CommitGenError::GitError(format!("Failed to parse commit metadata for {hash}")));
326   }
327
328   // Get tree hash
329   let tree_output = Command::new("git")
330      .args(["rev-parse", &format!("{hash}^{{tree}}")])
331      .current_dir(dir)
332      .output()
333      .map_err(|e| CommitGenError::GitError(format!("Failed to get tree hash: {e}")))?;
334   let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
335      .trim()
336      .to_string();
337
338   // Get parent hashes
339   let parents_output = Command::new("git")
340      .args(["rev-list", "--parents", "-n", "1", hash])
341      .current_dir(dir)
342      .output()
343      .map_err(|e| CommitGenError::GitError(format!("Failed to get parent hashes: {e}")))?;
344   let parents_line = String::from_utf8_lossy(&parents_output.stdout);
345   let parent_hashes: Vec<String> = parents_line
346      .split_whitespace()
347      .skip(1) // First is the commit itself
348      .map(|s| s.to_string())
349      .collect();
350
351   Ok(CommitMetadata {
352      hash: hash.to_string(),
353      author_name: parts[0].to_string(),
354      author_email: parts[1].to_string(),
355      author_date: parts[2].to_string(),
356      committer_name: parts[3].to_string(),
357      committer_email: parts[4].to_string(),
358      committer_date: parts[5].to_string(),
359      message: parts[6].trim().to_string(),
360      parent_hashes,
361      tree_hash,
362   })
363}
364
365/// Check if working directory is clean
366pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
367   let output = Command::new("git")
368      .args(["status", "--porcelain"])
369      .current_dir(dir)
370      .output()
371      .map_err(|e| CommitGenError::GitError(format!("Failed to check working tree: {e}")))?;
372
373   Ok(output.stdout.is_empty())
374}
375
376/// Create timestamped backup branch
377pub fn create_backup_branch(dir: &str) -> Result<String> {
378   use chrono::Local;
379
380   let timestamp = Local::now().format("%Y%m%d-%H%M%S");
381   let backup_name = format!("backup-rewrite-{timestamp}");
382
383   let output = Command::new("git")
384      .args(["branch", &backup_name])
385      .current_dir(dir)
386      .output()
387      .map_err(|e| CommitGenError::GitError(format!("Failed to create backup branch: {e}")))?;
388
389   if !output.status.success() {
390      let stderr = String::from_utf8_lossy(&output.stderr);
391      return Err(CommitGenError::GitError(format!("git branch failed: {stderr}")));
392   }
393
394   Ok(backup_name)
395}
396
397/// Rewrite git history with new commit messages
398pub fn rewrite_history(
399   commits: &[CommitMetadata],
400   new_messages: &[String],
401   dir: &str,
402) -> Result<()> {
403   if commits.len() != new_messages.len() {
404      return Err(CommitGenError::Other("Commit count mismatch".to_string()));
405   }
406
407   // Get current branch
408   let branch_output = Command::new("git")
409      .args(["rev-parse", "--abbrev-ref", "HEAD"])
410      .current_dir(dir)
411      .output()
412      .map_err(|e| CommitGenError::GitError(format!("Failed to get current branch: {e}")))?;
413   let current_branch = String::from_utf8_lossy(&branch_output.stdout)
414      .trim()
415      .to_string();
416
417   // Map old commit hashes to new ones
418   let mut parent_map: HashMap<String, String> = HashMap::new();
419   let mut new_head: Option<String> = None;
420
421   for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
422      // Map old parents to new parents
423      let new_parents: Vec<String> = commit
424         .parent_hashes
425         .iter()
426         .map(|old_parent| {
427            parent_map
428               .get(old_parent)
429               .cloned()
430               .unwrap_or_else(|| old_parent.clone())
431         })
432         .collect();
433
434      // Build commit-tree command
435      let mut cmd = Command::new("git");
436      cmd.arg("commit-tree")
437         .arg(&commit.tree_hash)
438         .arg("-m")
439         .arg(new_msg)
440         .current_dir(dir);
441
442      for parent in &new_parents {
443         cmd.arg("-p").arg(parent);
444      }
445
446      // Preserve original author/committer metadata
447      cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
448         .env("GIT_AUTHOR_EMAIL", &commit.author_email)
449         .env("GIT_AUTHOR_DATE", &commit.author_date)
450         .env("GIT_COMMITTER_NAME", &commit.committer_name)
451         .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
452         .env("GIT_COMMITTER_DATE", &commit.committer_date);
453
454      let output = cmd
455         .output()
456         .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit-tree: {e}")))?;
457
458      if !output.status.success() {
459         let stderr = String::from_utf8_lossy(&output.stderr);
460         return Err(CommitGenError::GitError(format!(
461            "commit-tree failed for {}: {}",
462            commit.hash, stderr
463         )));
464      }
465
466      let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
467
468      parent_map.insert(commit.hash.clone(), new_hash.clone());
469      new_head = Some(new_hash);
470
471      // Progress reporting
472      if (idx + 1) % 50 == 0 {
473         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
474      }
475   }
476
477   // Update branch to new head
478   if let Some(head) = new_head {
479      let update_output = Command::new("git")
480         .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
481         .current_dir(dir)
482         .output()
483         .map_err(|e| CommitGenError::GitError(format!("Failed to update ref: {e}")))?;
484
485      if !update_output.status.success() {
486         let stderr = String::from_utf8_lossy(&update_output.stderr);
487         return Err(CommitGenError::GitError(format!("git update-ref failed: {stderr}")));
488      }
489
490      let reset_output = Command::new("git")
491         .args(["reset", "--hard", &head])
492         .current_dir(dir)
493         .output()
494         .map_err(|e| CommitGenError::GitError(format!("Failed to reset: {e}")))?;
495
496      if !reset_output.status.success() {
497         let stderr = String::from_utf8_lossy(&reset_output.stderr);
498         return Err(CommitGenError::GitError(format!("git reset failed: {stderr}")));
499      }
500   }
501
502   Ok(())
503}