1use crate::utils::types::Result;
2use crate::utils::types::{CommitInfo, EditOptions};
3use crate::{args::Args, utils::commit_history::get_commit_history};
4use chrono::NaiveDateTime;
5use colored::Colorize;
6use git2::{Repository, Signature, Sort, Time};
7use std::collections::HashMap;
8use std::io::{self, Write};
9
10pub fn select_commit(commits: &[CommitInfo]) -> Result<usize> {
11 println!("\n{}", "Commit History:".bold().green());
12 println!("{}", "-".repeat(80).cyan());
13
14 for (i, commit) in commits.iter().enumerate() {
15 println!(
16 "{:3}. {} {} {} {}",
17 i + 1,
18 commit.short_hash.yellow().bold(),
19 commit
20 .timestamp
21 .format("%Y-%m-%d %H:%M:%S")
22 .to_string()
23 .blue(),
24 commit.author_name.magenta(),
25 commit.message.lines().next().unwrap_or("").white()
26 );
27 }
28
29 println!("{}", "-".repeat(80).cyan());
30 print!("\n{} ", "Select commit number to edit:".bold().green());
31 io::stdout().flush()?;
32
33 let mut input = String::new();
34 io::stdin().read_line(&mut input)?;
35
36 let selection = input
37 .trim()
38 .parse::<usize>()
39 .map_err(|_| "Invalid number")?;
40
41 if selection < 1 || selection > commits.len() {
42 return Err("Selection out of range".into());
43 }
44
45 Ok(selection - 1)
46}
47
48pub fn show_commit_details(commit: &CommitInfo, repo: &Repository) -> Result<()> {
49 println!("\n{}", "Selected Commit Details:".bold().green());
50 println!("{}", "=".repeat(80).cyan());
51
52 println!("{}: {}", "Hash".bold(), commit.oid.to_string().yellow());
53 println!("{}: {}", "Short Hash".bold(), commit.short_hash.yellow());
54 println!(
55 "{}: {}",
56 "Author".bold(),
57 format!("{} <{}>", commit.author_name, commit.author_email).magenta()
58 );
59 println!(
60 "{}: {}",
61 "Date".bold(),
62 commit
63 .timestamp
64 .format("%Y-%m-%d %H:%M:%S")
65 .to_string()
66 .blue()
67 );
68 println!(
69 "{}: {}",
70 "Parent Count".bold(),
71 commit.parent_count.to_string().white()
72 );
73
74 println!("\n{}", "Message:".bold());
75 println!("{}", commit.message.white());
76
77 if commit.parent_count > 0 {
79 let git_commit = repo.find_commit(commit.oid)?;
80 println!("\n{}", "Parent Commits:".bold());
81 for (i, parent_id) in git_commit.parent_ids().enumerate() {
82 let parent = repo.find_commit(parent_id)?;
83 println!(
84 " {}: {} - {}",
85 i + 1,
86 parent_id.to_string()[..8].to_string().yellow(),
87 parent.summary().unwrap_or("(no message)").white()
88 );
89 }
90 }
91
92 println!("{}", "=".repeat(80).cyan());
93 Ok(())
94}
95
96pub fn get_edit_options() -> Result<EditOptions> {
98 println!("\n{}", "What would you like to edit?".bold().green());
99 println!("1. Author name");
100 println!("2. Author email");
101 println!("3. Commit timestamp");
102 println!("4. Commit message");
103 println!("5. All of the above");
104
105 print!("\n{} ", "Select option(s) (comma-separated):".bold());
106 io::stdout().flush()?;
107
108 let mut input = String::new();
109 io::stdin().read_line(&mut input)?;
110
111 let selections: Vec<usize> = input
112 .trim()
113 .split(',')
114 .filter_map(|s| s.trim().parse::<usize>().ok())
115 .collect();
116
117 let mut options = EditOptions::default();
118
119 for &selection in &selections {
120 match selection {
121 1 => {
122 print!("{} ", "New author name:".bold());
123 io::stdout().flush()?;
124 let mut name = String::new();
125 io::stdin().read_line(&mut name)?;
126 options.author_name = Some(name.trim().to_string());
127 }
128 2 => {
129 print!("{} ", "New author email:".bold());
130 io::stdout().flush()?;
131 let mut email = String::new();
132 io::stdin().read_line(&mut email)?;
133 options.author_email = Some(email.trim().to_string());
134 }
135 3 => {
136 print!("{} ", "New timestamp (YYYY-MM-DD HH:MM:SS):".bold());
137 io::stdout().flush()?;
138 let mut timestamp = String::new();
139 io::stdin().read_line(&mut timestamp)?;
140 let dt = NaiveDateTime::parse_from_str(timestamp.trim(), "%Y-%m-%d %H:%M:%S")
141 .map_err(|_| "Invalid timestamp format")?;
142 options.timestamp = Some(dt);
143 }
144 4 => {
145 println!("{} ", "New commit message (end with empty line):".bold());
146 let mut message = String::new();
147 loop {
148 let mut line = String::new();
149 io::stdin().read_line(&mut line)?;
150 if line.trim().is_empty() {
151 break;
152 }
153 message.push_str(&line);
154 }
155 options.message = Some(message.trim().to_string());
156 }
157 5 => {
158 print!("{} ", "New author name:".bold());
160 io::stdout().flush()?;
161 let mut name = String::new();
162 io::stdin().read_line(&mut name)?;
163 options.author_name = Some(name.trim().to_string());
164
165 print!("{} ", "New author email:".bold());
166 io::stdout().flush()?;
167 let mut email = String::new();
168 io::stdin().read_line(&mut email)?;
169 options.author_email = Some(email.trim().to_string());
170
171 print!("{} ", "New timestamp (YYYY-MM-DD HH:MM:SS):".bold());
172 io::stdout().flush()?;
173 let mut timestamp = String::new();
174 io::stdin().read_line(&mut timestamp)?;
175 let dt = NaiveDateTime::parse_from_str(timestamp.trim(), "%Y-%m-%d %H:%M:%S")
176 .map_err(|_| "Invalid timestamp format")?;
177 options.timestamp = Some(dt);
178
179 println!("{} ", "New commit message (end with empty line):".bold());
180 let mut message = String::new();
181 loop {
182 let mut line = String::new();
183 io::stdin().read_line(&mut line)?;
184 if line.trim().is_empty() {
185 break;
186 }
187 message.push_str(&line);
188 }
189 options.message = Some(message.trim().to_string());
190 }
191 _ => println!("Invalid option: {selection}"),
192 }
193 }
194
195 Ok(options)
196}
197
198pub fn rewrite_specific_commits(args: &Args) -> Result<()> {
199 let commits = get_commit_history(args, false)?;
200
201 if commits.is_empty() {
202 println!("{}", "No commits found!".red());
203 return Ok(());
204 }
205
206 let selected_index = select_commit(&commits)?;
207 let selected_commit = &commits[selected_index];
208
209 let repo = Repository::open(args.repo_path.as_ref().unwrap())?;
210 show_commit_details(selected_commit, &repo)?;
211
212 let edit_options = get_edit_options()?;
213
214 println!("\n{}", "Planned changes:".bold().yellow());
216 if let Some(ref name) = edit_options.author_name {
217 println!(
218 " Author name: {} -> {}",
219 selected_commit.author_name.red(),
220 name.green()
221 );
222 }
223 if let Some(ref email) = edit_options.author_email {
224 println!(
225 " Author email: {} -> {}",
226 selected_commit.author_email.red(),
227 email.green()
228 );
229 }
230 if let Some(ref timestamp) = edit_options.timestamp {
231 println!(
232 " Timestamp: {} -> {}",
233 selected_commit
234 .timestamp
235 .format("%Y-%m-%d %H:%M:%S")
236 .to_string()
237 .red(),
238 timestamp.format("%Y-%m-%d %H:%M:%S").to_string().green()
239 );
240 }
241 if let Some(ref message) = edit_options.message {
242 println!(
243 " Message: {} -> {}",
244 selected_commit.message.lines().next().unwrap_or("").red(),
245 message.lines().next().unwrap_or("").green()
246 );
247 }
248
249 print!("\n{} (y/n): ", "Proceed with changes?".bold());
250 io::stdout().flush()?;
251
252 let mut confirm = String::new();
253 io::stdin().read_line(&mut confirm)?;
254
255 if confirm.trim().to_lowercase() != "y" {
256 println!("{}", "Operation cancelled.".yellow());
257 return Ok(());
258 }
259
260 apply_commit_changes(&repo, selected_commit, &edit_options)?;
262
263 println!("\n{}", "✓ Commit successfully edited!".green().bold());
264
265 if args.show_history {
266 get_commit_history(args, true)?;
267 }
268
269 Ok(())
270}
271
272fn apply_commit_changes(
274 repo: &Repository,
275 target_commit: &CommitInfo,
276 options: &EditOptions,
277) -> Result<()> {
278 let head_ref = repo.head()?;
279 let branch_name = head_ref
280 .shorthand()
281 .ok_or("Detached HEAD or invalid branch")?;
282 let full_ref = format!("refs/heads/{branch_name}");
283
284 let mut revwalk = repo.revwalk()?;
285 revwalk.push_head()?;
286 revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
287 let mut orig_oids: Vec<_> = revwalk.filter_map(|id| id.ok()).collect();
288 orig_oids.reverse();
289
290 let mut new_map: HashMap<git2::Oid, git2::Oid> = HashMap::new();
291 let mut last_new_oid = None;
292
293 for &oid in orig_oids.iter() {
294 let orig = repo.find_commit(oid)?;
295 let tree = orig.tree()?;
296
297 let new_parents: Result<Vec<_>> = orig
298 .parent_ids()
299 .map(|pid| {
300 let new_pid = *new_map.get(&pid).unwrap_or(&pid);
301 repo.find_commit(new_pid).map_err(|e| e.into())
302 })
303 .collect();
304
305 let new_oid = if oid == target_commit.oid {
306 let author_name = options
308 .author_name
309 .as_ref()
310 .unwrap_or(&target_commit.author_name);
311 let author_email = options
312 .author_email
313 .as_ref()
314 .unwrap_or(&target_commit.author_email);
315 let timestamp = options.timestamp.unwrap_or(target_commit.timestamp);
316 let message = options
317 .message
318 .as_deref()
319 .unwrap_or_else(|| orig.message().unwrap_or_default());
320
321 let author_sig = Signature::new(
322 author_name,
323 author_email,
324 &Time::new(timestamp.and_utc().timestamp(), 0),
325 )?;
326
327 let committer_sig = if options.timestamp.is_some() {
329 author_sig.clone()
330 } else {
331 let committer = orig.committer();
332 Signature::new(
333 committer.name().unwrap_or("Unknown"),
334 committer.email().unwrap_or("unknown@email.com"),
335 &committer.when(),
336 )?
337 };
338
339 repo.commit(
340 None,
341 &author_sig,
342 &committer_sig,
343 message,
344 &tree,
345 &new_parents?.iter().collect::<Vec<_>>(),
346 )?
347 } else {
348 let author = orig.author();
350 let committer = orig.committer();
351
352 repo.commit(
353 None,
354 &author,
355 &committer,
356 orig.message().unwrap_or_default(),
357 &tree,
358 &new_parents?.iter().collect::<Vec<_>>(),
359 )?
360 };
361
362 new_map.insert(oid, new_oid);
363 last_new_oid = Some(new_oid);
364 }
365
366 if let Some(new_head) = last_new_oid {
367 repo.reference(&full_ref, new_head, true, "edited specific commit")?;
368 println!(
369 "{} '{}' -> {}",
370 "Updated branch".green(),
371 branch_name.cyan(),
372 new_head.to_string()[..8].to_string().cyan()
373 );
374 }
375
376 Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::fs;
383 use tempfile::TempDir;
384
385 fn create_test_repo_with_commits() -> (TempDir, String) {
386 let temp_dir = TempDir::new().unwrap();
387 let repo_path = temp_dir.path().to_str().unwrap().to_string();
388
389 let repo = git2::Repository::init(&repo_path).unwrap();
391
392 for i in 1..=3 {
394 let file_path = temp_dir.path().join(format!("test{i}.txt"));
395 fs::write(&file_path, format!("test content {i}")).unwrap();
396
397 let mut index = repo.index().unwrap();
398 index
399 .add_path(std::path::Path::new(&format!("test{i}.txt")))
400 .unwrap();
401 index.write().unwrap();
402
403 let tree_id = index.write_tree().unwrap();
404 let tree = repo.find_tree(tree_id).unwrap();
405
406 let sig = git2::Signature::new(
407 "Test User",
408 "test@example.com",
409 &git2::Time::new(1234567890 + i as i64 * 3600, 0),
410 )
411 .unwrap();
412
413 let parents = if i == 1 {
414 vec![]
415 } else {
416 let head = repo.head().unwrap();
417 let parent_commit = head.peel_to_commit().unwrap();
418 vec![parent_commit]
419 };
420
421 repo.commit(
422 Some("HEAD"),
423 &sig,
424 &sig,
425 &format!("Commit {i}"),
426 &tree,
427 &parents.iter().collect::<Vec<_>>(),
428 )
429 .unwrap();
430 }
431
432 (temp_dir, repo_path)
433 }
434
435 #[test]
436 fn test_show_commit_details() {
437 let (_temp_dir, repo_path) = create_test_repo_with_commits();
438 let repo = Repository::open(&repo_path).unwrap();
439
440 let args = Args {
442 repo_path: Some(repo_path),
443 email: None,
444 name: None,
445 start: None,
446 end: None,
447 show_history: false,
448 pic_specific_commits: false,
449 range: false,
450 };
451
452 let commits = get_commit_history(&args, false).unwrap();
453 let commit = &commits[0];
454
455 let result = show_commit_details(commit, &repo);
457 assert!(result.is_ok());
458 }
459
460 #[test]
461 fn test_edit_options_default() {
462 let options = EditOptions::default();
463
464 assert_eq!(options.author_name, None);
465 assert_eq!(options.author_email, None);
466 assert_eq!(options.timestamp, None);
467 assert_eq!(options.message, None);
468 }
469
470 #[test]
471 fn test_edit_options_with_values() {
472 let timestamp =
473 NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
474
475 let options = EditOptions {
476 author_name: Some("New Author".to_string()),
477 author_email: Some("new@example.com".to_string()),
478 timestamp: Some(timestamp),
479 message: Some("New commit message".to_string()),
480 };
481
482 assert_eq!(options.author_name, Some("New Author".to_string()));
483 assert_eq!(options.author_email, Some("new@example.com".to_string()));
484 assert_eq!(options.timestamp, Some(timestamp));
485 assert_eq!(options.message, Some("New commit message".to_string()));
486 }
487
488 #[test]
489 fn test_commit_selection_validation() {
490 let commits = [CommitInfo {
492 oid: git2::Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(),
493 short_hash: "12345678".to_string(),
494 timestamp: NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S")
495 .unwrap(),
496 author_name: "Test User".to_string(),
497 author_email: "test@example.com".to_string(),
498 message: "Test commit".to_string(),
499 parent_count: 0,
500 }];
501
502 let selection = 1;
504 assert!(selection >= 1 && selection <= commits.len());
505
506 let invalid_selection1 = 0;
508 assert!(invalid_selection1 < 1 || invalid_selection1 > commits.len());
509
510 let invalid_selection2 = commits.len() + 1;
511 assert!(invalid_selection2 < 1 || invalid_selection2 > commits.len());
512 }
513
514 #[test]
515 fn test_rewrite_specific_commits_with_empty_commits() {
516 let (_temp_dir, repo_path) = create_test_repo_with_commits();
517 let args = Args {
518 repo_path: Some(repo_path),
519 email: None,
520 name: None,
521 start: None,
522 end: None,
523 show_history: false,
524 pic_specific_commits: true,
525 range: false,
526 };
527
528 let commits = get_commit_history(&args, false).unwrap();
530 assert!(!commits.is_empty());
531 assert_eq!(commits.len(), 3);
532 }
533
534 #[test]
535 fn test_apply_commit_changes_logic() {
536 let (_temp_dir, repo_path) = create_test_repo_with_commits();
537 let _repo = Repository::open(&repo_path).unwrap();
538
539 let args = Args {
541 repo_path: Some(repo_path),
542 email: None,
543 name: None,
544 start: None,
545 end: None,
546 show_history: false,
547 pic_specific_commits: false,
548 range: false,
549 };
550
551 let commits = get_commit_history(&args, false).unwrap();
552 let target_commit = &commits[0];
553
554 let options = EditOptions {
556 author_name: Some("New Author".to_string()),
557 author_email: Some("new@example.com".to_string()),
558 timestamp: Some(
559 NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
560 ),
561 message: Some("New commit message".to_string()),
562 };
563
564 assert_eq!(options.author_name.as_ref().unwrap(), "New Author");
566 assert_eq!(options.author_email.as_ref().unwrap(), "new@example.com");
567 assert!(options.timestamp.is_some());
568 assert_eq!(options.message.as_ref().unwrap(), "New commit message");
569
570 let partial_options = EditOptions {
572 author_name: None,
573 author_email: None,
574 timestamp: None,
575 message: None,
576 };
577
578 let author_name = partial_options
579 .author_name
580 .as_ref()
581 .unwrap_or(&target_commit.author_name);
582 let author_email = partial_options
583 .author_email
584 .as_ref()
585 .unwrap_or(&target_commit.author_email);
586
587 assert_eq!(author_name, &target_commit.author_name);
588 assert_eq!(author_email, &target_commit.author_email);
589 }
590}