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