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
10pub 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 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 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 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 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 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 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
134pub 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 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 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
230pub 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
261pub 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
292pub 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
308pub 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
336pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
338 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 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 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) .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
397pub 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
408pub 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
429pub 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 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 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 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 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 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 if (idx + 1) % 50 == 0 {
505 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
506 }
507 }
508
509 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}