llm_git/
git.rs

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