1use crate::utils::types::CommitInfo;
2use crate::utils::types::Result;
3use crate::{args::Args, utils::commit_history::get_commit_history};
4use chrono::NaiveDateTime;
5use colored::Colorize;
6use crossterm::{
7 cursor,
8 event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
9 terminal::{self, Clear, ClearType},
10 ExecutableCommand,
11};
12use git2::{Repository, Signature, Sort, Time};
13use std::collections::HashMap;
14use std::io::{self, Write};
15
16#[derive(Debug, Clone)]
17struct CommitEdit {
18 index: usize,
19 original: CommitInfo,
20 author_name: String,
21 author_email: String,
22 timestamp: NaiveDateTime,
23 message: String,
24 is_modified: bool,
25 modifications: ModificationFlags,
26}
27
28#[derive(Debug, Clone, Default)]
29struct ModificationFlags {
30 author_name_changed: bool,
31 author_email_changed: bool,
32 timestamp_changed: bool,
33 message_changed: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
37enum TableColumn {
38 Index = 0,
39 Hash = 1,
40 AuthorName = 2,
41 AuthorEmail = 3,
42 Timestamp = 4,
43 Message = 5,
44}
45
46struct InteractiveTable {
47 commits: Vec<CommitEdit>,
48 current_row: usize,
49 current_col: TableColumn,
50 editing: bool,
51 edit_buffer: String,
52 editable_fields: (bool, bool, bool, bool), }
54
55impl InteractiveTable {
56 fn new(
57 commits: Vec<CommitInfo>,
58 start_idx: usize,
59 end_idx: usize,
60 editable_fields: (bool, bool, bool, bool),
61 ) -> Self {
62 let mut commit_edits = Vec::new();
63
64 for (i, commit) in commits[start_idx..=end_idx].iter().enumerate() {
65 commit_edits.push(CommitEdit {
66 index: start_idx + i,
67 original: commit.clone(),
68 author_name: commit.author_name.clone(),
69 author_email: commit.author_email.clone(),
70 timestamp: commit.timestamp,
71 message: commit.message.clone(), is_modified: false,
73 modifications: ModificationFlags::default(),
74 });
75 }
76
77 let starting_col = if editable_fields.0 {
79 TableColumn::AuthorName
81 } else if editable_fields.1 {
82 TableColumn::AuthorEmail
84 } else if editable_fields.2 {
85 TableColumn::Timestamp
87 } else if editable_fields.3 {
88 TableColumn::Message
90 } else {
91 TableColumn::AuthorName };
93
94 Self {
95 commits: commit_edits,
96 current_row: 0,
97 current_col: starting_col,
98 editing: false,
99 edit_buffer: String::new(),
100 editable_fields,
101 }
102 }
103
104 fn draw_table(&self) {
105 let _ = io::stdout().execute(Clear(ClearType::All));
107 let _ = io::stdout().execute(cursor::MoveTo(0, 0));
108
109 println!(
110 "{}",
111 "Interactive Commit Editor - Range Mode".bold().green()
112 );
113
114 let editable_info = if self.editable_fields == (true, true, true, true) {
116 "All fields editable".to_string()
117 } else {
118 let mut editable = Vec::new();
119 if self.editable_fields.0 || self.editable_fields.1 {
120 editable.push("Author");
121 }
122 if self.editable_fields.2 {
123 editable.push("Time");
124 }
125 if self.editable_fields.3 {
126 editable.push("Message");
127 }
128 format!("Editable: {}", editable.join(", "))
129 };
130 println!("{}", editable_info.cyan());
131 println!(
132 "{}",
133 "Use Arrow Keys to navigate, Enter to edit, Esc to save & exit, Ctrl+C to cancel"
134 .yellow()
135 );
136 println!();
137
138 println!(
140 "{:<4} {:<8} {:<15} {:<20} {:<19} {}",
141 "#".bold().white(),
142 "HASH".bold().white(),
143 "AUTHOR NAME".bold().white(),
144 "AUTHOR EMAIL".bold().white(),
145 "TIMESTAMP".bold().white(),
146 "MESSAGE".bold().white()
147 );
148
149 for (row_idx, commit) in self.commits.iter().enumerate() {
151 let is_current_row = row_idx == self.current_row;
152
153 let index_str = format!("{}", commit.index + 1);
155 let hash_str = self.truncate_text(&commit.original.short_hash, 8);
156 let author_name_str = self.truncate_text(&commit.author_name, 15);
157 let author_email_str = self.truncate_text(&commit.author_email, 20);
158 let timestamp_str = commit.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
159 let first_line_message = commit.message.lines().next().unwrap_or("");
160 let message_str = self.truncate_text(first_line_message, 40);
161
162 let is_current_cell_index =
164 is_current_row && matches!(self.current_col, TableColumn::Index);
165 let is_current_cell_hash =
166 is_current_row && matches!(self.current_col, TableColumn::Hash);
167 let is_current_cell_author_name =
168 is_current_row && matches!(self.current_col, TableColumn::AuthorName);
169 let is_current_cell_author_email =
170 is_current_row && matches!(self.current_col, TableColumn::AuthorEmail);
171 let is_current_cell_timestamp =
172 is_current_row && matches!(self.current_col, TableColumn::Timestamp);
173 let is_current_cell_message =
174 is_current_row && matches!(self.current_col, TableColumn::Message);
175
176 let index_final = index_str; let hash_final = hash_str; let author_name_with_mod = if commit.modifications.author_name_changed {
180 format!("*{author_name_str}")
181 } else {
182 author_name_str
183 };
184 let author_name_final = author_name_with_mod;
185
186 let author_email_with_mod = if commit.modifications.author_email_changed {
187 format!("*{author_email_str}")
188 } else {
189 author_email_str
190 };
191 let author_email_final = author_email_with_mod;
192
193 let timestamp_with_mod = if commit.modifications.timestamp_changed {
194 format!("*{timestamp_str}")
195 } else {
196 timestamp_str
197 };
198 let timestamp_final = timestamp_with_mod;
199
200 let message_with_mod = if commit.modifications.message_changed {
201 format!("*{message_str}")
202 } else {
203 message_str
204 };
205 let message_final = message_with_mod;
206
207 if is_current_row {
209 if self.editing {
210 println!(
211 "{:<4} {:<8} {:<15} {:<20} {:<19} {}",
212 index_final.black().on_yellow(),
213 hash_final.black().on_yellow(),
214 author_name_final.black().on_yellow(),
215 author_email_final.black().on_yellow(),
216 timestamp_final.black().on_yellow(),
217 message_final.black().on_yellow()
218 );
219 } else {
220 let index_styled = if is_current_cell_index {
222 index_final.white().on_blue()
223 } else {
224 index_final.white().on_bright_black()
225 };
226 let hash_styled = if is_current_cell_hash {
227 hash_final.white().on_blue()
228 } else {
229 hash_final.yellow().on_bright_black()
230 };
231 let author_name_styled =
232 if is_current_cell_author_name && self.editable_fields.0 {
233 author_name_final.white().on_blue()
234 } else {
235 author_name_final.cyan().on_bright_black()
236 };
237 let author_email_styled =
238 if is_current_cell_author_email && self.editable_fields.1 {
239 author_email_final.white().on_blue()
240 } else {
241 author_email_final.blue().on_bright_black()
242 };
243 let timestamp_styled = if is_current_cell_timestamp && self.editable_fields.2 {
244 timestamp_final.white().on_blue()
245 } else {
246 timestamp_final.magenta().on_bright_black()
247 };
248 let message_styled = if is_current_cell_message && self.editable_fields.3 {
249 message_final.white().on_blue()
250 } else {
251 message_final.green().on_bright_black()
252 };
253
254 println!(
255 "{index_styled:<4} {hash_styled:<8} {author_name_styled:<15} {author_email_styled:<20} {timestamp_styled:<19} {message_styled}"
256 );
257 }
258 } else {
259 println!(
260 "{:<4} {:<8} {:<15} {:<20} {:<19} {}",
261 index_final.white(),
262 hash_final.yellow(),
263 author_name_final.cyan(),
264 author_email_final.blue(),
265 timestamp_final.magenta(),
266 message_final.green()
267 );
268 }
269 }
270
271 println!();
272
273 if self.editing {
274 println!("{}: {}", "Editing".bold().yellow(), self.edit_buffer);
275 println!("{}", "Press Enter to save, Esc to cancel edit".italic());
276 } else {
277 println!(
278 "{}",
279 "Navigation: ←→↑↓ Edit: Enter Save & Exit: Esc Cancel: Ctrl+C".italic()
280 );
281 println!(
282 "{}",
283 "Tip: Use '*' when selecting range to edit ALL commits at once".dimmed()
284 );
285 }
286 }
287
288 fn truncate_text(&self, text: &str, max_width: usize) -> String {
289 if text.len() > max_width {
290 format!("{}…", &text[..max_width.saturating_sub(1)])
291 } else {
292 text.to_string()
293 }
294 }
295
296 fn handle_navigation_key_input(&mut self, key: KeyCode) -> Result<bool> {
297 match key {
298 KeyCode::Up => {
299 if self.current_row > 0 {
300 self.current_row -= 1;
301 }
302 }
303 KeyCode::Down => {
304 if self.current_row < self.commits.len() - 1 {
305 self.current_row += 1;
306 }
307 }
308 KeyCode::Left => {
309 self.move_to_prev_editable_column();
310 }
311 KeyCode::Right => {
312 self.move_to_next_editable_column();
313 }
314 KeyCode::Char('h') => {
315 self.move_to_prev_editable_column();
317 }
318 KeyCode::Char('l') => {
319 self.move_to_next_editable_column();
321 }
322 KeyCode::Char('k') => {
323 if self.current_row > 0 {
325 self.current_row -= 1;
326 }
327 }
328 KeyCode::Char('j') => {
329 if self.current_row < self.commits.len() - 1 {
331 self.current_row += 1;
332 }
333 }
334 KeyCode::Enter => {
335 self.start_editing();
336 return Ok(true);
337 }
338 KeyCode::Esc => {
339 return Ok(false); }
341 _ => {}
342 }
343 Ok(true)
344 }
345
346 fn is_column_editable(&self, col: &TableColumn) -> bool {
347 match col {
348 TableColumn::Index | TableColumn::Hash => false,
349 TableColumn::AuthorName => self.editable_fields.0,
350 TableColumn::AuthorEmail => self.editable_fields.1,
351 TableColumn::Timestamp => self.editable_fields.2,
352 TableColumn::Message => self.editable_fields.3,
353 }
354 }
355
356 fn move_to_next_editable_column(&mut self) {
357 let columns = [
358 TableColumn::Index,
359 TableColumn::Hash,
360 TableColumn::AuthorName,
361 TableColumn::AuthorEmail,
362 TableColumn::Timestamp,
363 TableColumn::Message,
364 ];
365
366 let current_index = columns
367 .iter()
368 .position(|c| std::mem::discriminant(c) == std::mem::discriminant(&self.current_col))
369 .unwrap_or(0);
370
371 for i in 1..columns.len() {
372 let next_index = (current_index + i) % columns.len();
373 let next_col = &columns[next_index];
374 if self.is_column_editable(next_col) {
375 self.current_col = *next_col;
376 return;
377 }
378 }
379 }
380
381 fn move_to_prev_editable_column(&mut self) {
382 let columns = [
383 TableColumn::Index,
384 TableColumn::Hash,
385 TableColumn::AuthorName,
386 TableColumn::AuthorEmail,
387 TableColumn::Timestamp,
388 TableColumn::Message,
389 ];
390
391 let current_index = columns
392 .iter()
393 .position(|c| std::mem::discriminant(c) == std::mem::discriminant(&self.current_col))
394 .unwrap_or(0);
395
396 for i in 1..columns.len() {
397 let prev_index = if current_index >= i {
398 current_index - i
399 } else {
400 columns.len() - (i - current_index)
401 };
402 let prev_col = &columns[prev_index];
403 if self.is_column_editable(prev_col) {
404 self.current_col = *prev_col;
405 return;
406 }
407 }
408 }
409
410 fn start_editing(&mut self) {
411 if !self.is_column_editable(&self.current_col) {
412 return; }
414
415 self.editing = true;
416
417 self.edit_buffer = match self.current_col {
419 TableColumn::AuthorName => self.commits[self.current_row].author_name.clone(),
420 TableColumn::AuthorEmail => self.commits[self.current_row].author_email.clone(),
421 TableColumn::Timestamp => self.commits[self.current_row]
422 .timestamp
423 .format("%Y-%m-%d %H:%M:%S")
424 .to_string(),
425 TableColumn::Message => {
426 if self.commits[self.current_row].modifications.message_changed {
428 self.commits[self.current_row].message.clone()
429 } else {
430 self.commits[self.current_row].original.message.clone()
432 }
433 }
434 _ => String::new(),
435 };
436 }
437
438 fn handle_edit_key_input(&mut self, key: KeyCode) -> Result<bool> {
439 match key {
440 KeyCode::Esc => {
441 self.editing = false;
443 self.edit_buffer.clear();
444 }
445 KeyCode::Enter => {
446 if let Err(e) = self.save_current_edit() {
448 self.edit_buffer = format!("Error: {e} (Press Esc to cancel)");
450 return Ok(true);
451 }
452 self.editing = false;
453 self.edit_buffer.clear();
454 }
455 KeyCode::Backspace => {
456 self.edit_buffer.pop();
457 }
458 KeyCode::Char(c) => {
459 self.edit_buffer.push(c);
461 }
462 _ => {}
463 }
464 Ok(true)
465 }
466
467 fn save_current_edit(&mut self) -> Result<()> {
468 let commit = &mut self.commits[self.current_row];
469
470 match self.current_col {
471 TableColumn::AuthorName => {
472 if self.edit_buffer.trim().is_empty() {
473 return Err("Author name cannot be empty".into());
474 }
475 if commit.author_name != self.edit_buffer {
476 commit.author_name = self.edit_buffer.clone();
477 commit.modifications.author_name_changed =
478 commit.original.author_name != commit.author_name;
479 commit.is_modified = true;
480 }
481 }
482 TableColumn::AuthorEmail => {
483 if self.edit_buffer.trim().is_empty() {
484 return Err("Author email cannot be empty".into());
485 }
486 if !self.edit_buffer.contains('@') {
487 return Err("Invalid email format".into());
488 }
489 if commit.author_email != self.edit_buffer {
490 commit.author_email = self.edit_buffer.clone();
491 commit.modifications.author_email_changed =
492 commit.original.author_email != commit.author_email;
493 commit.is_modified = true;
494 }
495 }
496 TableColumn::Timestamp => {
497 let new_timestamp =
498 NaiveDateTime::parse_from_str(&self.edit_buffer, "%Y-%m-%d %H:%M:%S")
499 .map_err(|_| "Invalid timestamp format (use YYYY-MM-DD HH:MM:SS)")?;
500
501 if commit.timestamp != new_timestamp {
502 commit.timestamp = new_timestamp;
503 commit.modifications.timestamp_changed =
504 commit.original.timestamp != commit.timestamp;
505 commit.is_modified = true;
506 }
507 }
508 TableColumn::Message => {
509 if self.edit_buffer.trim().is_empty() {
510 return Err("Commit message cannot be empty".into());
511 }
512 if commit.message != self.edit_buffer {
513 commit.message = self.edit_buffer.clone();
514 commit.modifications.message_changed =
515 commit.original.message != commit.message;
516 commit.is_modified = true;
517 }
518 }
519 _ => {}
520 }
521 Ok(())
522 }
523
524 fn run(&mut self) -> Result<bool> {
525 let result = loop {
526 let _ = terminal::disable_raw_mode();
528 self.draw_table();
529
530 terminal::enable_raw_mode()?;
532
533 if let Event::Key(KeyEvent {
534 code,
535 kind: KeyEventKind::Press,
536 ..
537 }) = event::read()?
538 {
539 let should_continue = if self.editing {
540 match self.handle_edit_key_input(code) {
541 Ok(cont) => cont,
542 Err(_) => break Ok(false),
543 }
544 } else {
545 match self.handle_navigation_key_input(code) {
546 Ok(cont) => cont,
547 Err(_) => break Ok(false),
548 }
549 };
550
551 if !should_continue {
552 break Ok(true); }
554 }
555 };
556
557 self.restore_terminal();
558 result
559 }
560
561 fn restore_terminal(&self) {
562 let _ = terminal::disable_raw_mode();
563 let _ = io::stdout().execute(Clear(ClearType::All));
564 let _ = io::stdout().execute(cursor::MoveTo(0, 0));
565 }
566
567 fn get_modified_commits(&self) -> Vec<&CommitEdit> {
568 self.commits.iter().filter(|c| c.is_modified).collect()
569 }
570}
571
572pub fn parse_range_input(input: &str, total_commits: usize) -> Result<(usize, usize)> {
573 let trimmed_input = input.trim();
574
575 if trimmed_input == "*" {
577 if total_commits == 0 {
578 return Err("No commits available to select".into());
579 }
580 return Ok((1, total_commits)); }
582
583 let parts: Vec<&str> = trimmed_input.split('-').collect();
584
585 if parts.len() != 2 {
586 return Err("Invalid range format. Use format like '5-11' or '*' for all commits".into());
587 }
588
589 let start = parts[0]
590 .trim()
591 .parse::<usize>()
592 .map_err(|_| "Invalid start number in range")?;
593 let end = parts[1]
594 .trim()
595 .parse::<usize>()
596 .map_err(|_| "Invalid end number in range")?;
597
598 if start < 1 {
599 return Err("Start position must be 1 or greater".into());
600 }
601
602 if end < start {
603 return Err("End position must be greater than or equal to start position".into());
604 }
605
606 Ok((start, end))
607}
608
609pub fn select_commit_range(commits: &[CommitInfo]) -> Result<(usize, usize)> {
610 println!("\n{}", "Commit History:".bold().green());
611 println!("{}", "-".repeat(80).cyan());
612
613 for (i, commit) in commits.iter().enumerate() {
614 println!(
615 "{:3}. {} {} {} {}",
616 i + 1,
617 commit.short_hash.yellow().bold(),
618 commit
619 .timestamp
620 .format("%Y-%m-%d %H:%M:%S")
621 .to_string()
622 .blue(),
623 commit.author_name.magenta(),
624 commit.message.lines().next().unwrap_or("").white()
625 );
626 }
627
628 println!("{}", "-".repeat(80).cyan());
629 println!(
630 "\n{}",
631 "Enter range in format 'start-end' (e.g., '5-11') or '*' for all commits:"
632 .bold()
633 .green()
634 );
635 print!("{} ", "Range:".bold());
636 io::stdout().flush()?;
637
638 let mut input = String::new();
639 io::stdin().read_line(&mut input)?;
640
641 let (start, end) = parse_range_input(&input, commits.len())?;
642
643 if start > commits.len() || end > commits.len() {
644 return Err(format!(
645 "Range out of bounds. Available commits: 1-{}",
646 commits.len()
647 )
648 .into());
649 }
650
651 Ok((start - 1, end - 1)) }
653
654pub fn show_range_details(commits: &[CommitInfo], start_idx: usize, end_idx: usize) -> Result<()> {
655 let total_selected = end_idx - start_idx + 1;
656 let is_all_commits = total_selected == commits.len();
657
658 if is_all_commits {
659 println!("\n{}", "Selected All Commits for Editing:".bold().green());
660 } else {
661 println!("\n{}", "Selected Commit Range:".bold().green());
662 }
663 println!("{}", "=".repeat(80).cyan());
664
665 for (idx, commit) in commits[start_idx..=end_idx].iter().enumerate() {
666 println!(
667 "\n{}: {} ({})",
668 format!("Commit {}", start_idx + idx + 1).bold(),
669 commit.short_hash.yellow(),
670 &commit.oid.to_string()[..8]
671 );
672 println!(
673 "{}: {}",
674 "Author".bold(),
675 format!("{} <{}>", commit.author_name, commit.author_email).magenta()
676 );
677 println!(
678 "{}: {}",
679 "Date".bold(),
680 commit
681 .timestamp
682 .format("%Y-%m-%d %H:%M:%S")
683 .to_string()
684 .blue()
685 );
686 println!(
687 "{}: {}",
688 "Message".bold(),
689 commit.message.lines().next().unwrap_or("").white()
690 );
691 }
692
693 println!("\n{}", "=".repeat(80).cyan());
694 if is_all_commits {
695 println!(
696 "{} {} commits selected for editing {}",
697 "Total:".bold(),
698 total_selected.to_string().green(),
699 "(ALL COMMITS)".bold().yellow()
700 );
701 } else {
702 println!(
703 "{} {} commits selected for editing",
704 "Total:".bold(),
705 total_selected.to_string().green()
706 );
707 }
708
709 Ok(())
710}
711
712pub fn get_range_edit_info(args: &Args) -> Result<(String, String, NaiveDateTime, NaiveDateTime)> {
713 println!("\n{}", "Range Edit Configuration:".bold().green());
714
715 let author_name = if let Some(name) = &args.name {
717 name.clone()
718 } else {
719 print!("{} ", "New author name:".bold());
720 io::stdout().flush()?;
721 let mut input = String::new();
722 io::stdin().read_line(&mut input)?;
723 input.trim().to_string()
724 };
725
726 let author_email = if let Some(email) = &args.email {
728 email.clone()
729 } else {
730 print!("{} ", "New author email:".bold());
731 io::stdout().flush()?;
732 let mut input = String::new();
733 io::stdin().read_line(&mut input)?;
734 input.trim().to_string()
735 };
736
737 let start_timestamp = if let Some(start) = &args.start {
739 NaiveDateTime::parse_from_str(start, "%Y-%m-%d %H:%M:%S")
740 .map_err(|_| "Invalid start timestamp format")?
741 } else {
742 print!("{} ", "Start timestamp (YYYY-MM-DD HH:MM:SS):".bold());
743 io::stdout().flush()?;
744 let mut input = String::new();
745 io::stdin().read_line(&mut input)?;
746 NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%d %H:%M:%S")
747 .map_err(|_| "Invalid start timestamp format")?
748 };
749
750 let end_timestamp = if let Some(end) = &args.end {
752 NaiveDateTime::parse_from_str(end, "%Y-%m-%d %H:%M:%S")
753 .map_err(|_| "Invalid end timestamp format")?
754 } else {
755 print!("{} ", "End timestamp (YYYY-MM-DD HH:MM:SS):".bold());
756 io::stdout().flush()?;
757 let mut input = String::new();
758 io::stdin().read_line(&mut input)?;
759 NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%d %H:%M:%S")
760 .map_err(|_| "Invalid end timestamp format")?
761 };
762
763 if end_timestamp <= start_timestamp {
764 return Err("End timestamp must be after start timestamp".into());
765 }
766
767 Ok((author_name, author_email, start_timestamp, end_timestamp))
768}
769
770pub fn generate_range_timestamps(
771 start_time: NaiveDateTime,
772 end_time: NaiveDateTime,
773 count: usize,
774) -> Vec<NaiveDateTime> {
775 if count == 0 {
776 return vec![];
777 }
778
779 if count == 1 {
780 return vec![start_time];
781 }
782
783 let total_duration = end_time.signed_duration_since(start_time);
784 let step_duration = total_duration / (count - 1) as i32;
785
786 (0..count)
787 .map(|i| start_time + step_duration * i as i32)
788 .collect()
789}
790
791pub fn rewrite_range_commits(args: &Args) -> Result<()> {
792 let commits = get_commit_history(args, false)?;
793
794 if commits.is_empty() {
795 println!("{}", "No commits found!".red());
796 return Ok(());
797 }
798
799 let (start_idx, end_idx) = select_commit_range(&commits)?;
800
801 show_range_details(&commits, start_idx, end_idx)?;
803
804 let editable_fields = args.get_editable_fields();
806
807 let mut table = InteractiveTable::new(commits.clone(), start_idx, end_idx, editable_fields);
809 let should_save = table.run()?;
810
811 if !should_save {
812 println!("{}", "Operation cancelled.".yellow());
813 return Ok(());
814 }
815
816 let modified_commits = table.get_modified_commits();
817
818 if modified_commits.is_empty() {
819 println!("{}", "No changes made.".yellow());
820 return Ok(());
821 }
822
823 println!("\n{}", "Summary of Changes:".bold().green());
825 println!("{}", "=".repeat(80).cyan());
826
827 for commit_edit in &modified_commits {
828 println!(
829 "\n{}: {} ({})",
830 format!("Commit {}", commit_edit.index + 1).bold(),
831 commit_edit.original.short_hash.yellow(),
832 &commit_edit.original.oid.to_string()[..8]
833 );
834
835 if commit_edit.modifications.author_name_changed {
836 println!(
837 " {}: {} -> {}",
838 "Author Name".bold(),
839 commit_edit.original.author_name.red(),
840 commit_edit.author_name.green()
841 );
842 }
843
844 if commit_edit.modifications.author_email_changed {
845 println!(
846 " {}: {} -> {}",
847 "Author Email".bold(),
848 commit_edit.original.author_email.red(),
849 commit_edit.author_email.green()
850 );
851 }
852
853 if commit_edit.modifications.timestamp_changed {
854 println!(
855 " {}: {} -> {}",
856 "Timestamp".bold(),
857 commit_edit
858 .original
859 .timestamp
860 .format("%Y-%m-%d %H:%M:%S")
861 .to_string()
862 .red(),
863 commit_edit
864 .timestamp
865 .format("%Y-%m-%d %H:%M:%S")
866 .to_string()
867 .green()
868 );
869 }
870
871 if commit_edit.modifications.message_changed {
872 let original_first_line = commit_edit.original.message.lines().next().unwrap_or("");
873 let new_first_line = commit_edit.message.lines().next().unwrap_or("");
874 println!(
875 " {}: {} -> {}",
876 "Message".bold(),
877 original_first_line.red(),
878 new_first_line.green()
879 );
880 }
881 }
882
883 print!("\n{} (y/n): ", "Apply these changes?".bold());
884 io::stdout().flush()?;
885
886 let mut confirm = String::new();
887 io::stdin().read_line(&mut confirm)?;
888
889 if confirm.trim().to_lowercase() != "y" {
890 println!("{}", "Operation cancelled.".yellow());
891 return Ok(());
892 }
893
894 apply_interactive_range_changes(args, &commits, &table.commits)?;
896
897 println!("\n{}", "✓ Commit range successfully edited!".green().bold());
898
899 if args.show_history {
900 get_commit_history(args, true)?;
901 }
902
903 Ok(())
904}
905
906fn apply_interactive_range_changes(
907 args: &Args,
908 _original_commits: &[CommitInfo],
909 edited_commits: &[CommitEdit],
910) -> Result<()> {
911 let repo = Repository::open(args.repo_path.as_ref().unwrap())?;
912 let head_ref = repo.head()?;
913 let branch_name = head_ref
914 .shorthand()
915 .ok_or("Detached HEAD or invalid branch")?;
916 let full_ref = format!("refs/heads/{branch_name}");
917
918 let mut revwalk = repo.revwalk()?;
919 revwalk.push_head()?;
920 revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
921 let mut orig_oids: Vec<_> = revwalk.filter_map(|id| id.ok()).collect();
922 orig_oids.reverse();
923
924 let mut edit_map: HashMap<usize, &CommitEdit> = HashMap::new();
926 for commit_edit in edited_commits {
927 if commit_edit.is_modified {
928 edit_map.insert(commit_edit.index, commit_edit);
929 }
930 }
931
932 let mut new_map: HashMap<git2::Oid, git2::Oid> = HashMap::new();
933 let mut last_new_oid = None;
934
935 for (commit_idx, &oid) in orig_oids.iter().enumerate() {
936 let orig = repo.find_commit(oid)?;
937 let tree = orig.tree()?;
938
939 let new_parents: Result<Vec<_>> = orig
940 .parent_ids()
941 .map(|pid| {
942 let new_pid = *new_map.get(&pid).unwrap_or(&pid);
943 repo.find_commit(new_pid).map_err(|e| e.into())
944 })
945 .collect();
946
947 let new_oid = if let Some(commit_edit) = edit_map.get(&commit_idx) {
948 let author_sig = Signature::new(
950 &commit_edit.author_name,
951 &commit_edit.author_email,
952 &Time::new(commit_edit.timestamp.and_utc().timestamp(), 0),
953 )?;
954
955 let committer_sig = Signature::new(
956 &commit_edit.author_name,
957 &commit_edit.author_email,
958 &Time::new(commit_edit.timestamp.and_utc().timestamp(), 0),
959 )?;
960
961 let message = if commit_edit.modifications.message_changed {
963 &commit_edit.message
964 } else {
965 orig.message().unwrap_or_default()
966 };
967
968 repo.commit(
969 None,
970 &author_sig,
971 &committer_sig,
972 message,
973 &tree,
974 &new_parents?.iter().collect::<Vec<_>>(),
975 )?
976 } else {
977 let author = orig.author();
979 let committer = orig.committer();
980
981 repo.commit(
982 None,
983 &author,
984 &committer,
985 orig.message().unwrap_or_default(),
986 &tree,
987 &new_parents?.iter().collect::<Vec<_>>(),
988 )?
989 };
990
991 new_map.insert(oid, new_oid);
992 last_new_oid = Some(new_oid);
993 }
994
995 if let Some(new_head) = last_new_oid {
996 repo.reference(
997 &full_ref,
998 new_head,
999 true,
1000 "edited commit range interactively",
1001 )?;
1002 println!(
1003 "{} '{}' -> {}",
1004 "Updated branch".green(),
1005 branch_name.cyan(),
1006 new_head.to_string()[..8].to_string().cyan()
1007 );
1008 }
1009
1010 Ok(())
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016 use std::fs;
1017 use tempfile::TempDir;
1018
1019 fn create_test_repo_with_commits() -> (TempDir, String) {
1020 let temp_dir = TempDir::new().unwrap();
1021 let repo_path = temp_dir.path().to_str().unwrap().to_string();
1022
1023 let repo = git2::Repository::init(&repo_path).unwrap();
1025
1026 for i in 1..=5 {
1028 let file_path = temp_dir.path().join(format!("test{i}.txt"));
1029 fs::write(&file_path, format!("test content {i}")).unwrap();
1030
1031 let mut index = repo.index().unwrap();
1032 index
1033 .add_path(std::path::Path::new(&format!("test{i}.txt")))
1034 .unwrap();
1035 index.write().unwrap();
1036
1037 let tree_id = index.write_tree().unwrap();
1038 let tree = repo.find_tree(tree_id).unwrap();
1039
1040 let sig = git2::Signature::new(
1041 "Test User",
1042 "test@example.com",
1043 &git2::Time::new(1234567890 + i as i64 * 3600, 0),
1044 )
1045 .unwrap();
1046
1047 let parents = if i == 1 {
1048 vec![]
1049 } else {
1050 let head = repo.head().unwrap();
1051 let parent_commit = head.peel_to_commit().unwrap();
1052 vec![parent_commit]
1053 };
1054
1055 repo.commit(
1056 Some("HEAD"),
1057 &sig,
1058 &sig,
1059 &format!("Commit {i}"),
1060 &tree,
1061 &parents.iter().collect::<Vec<_>>(),
1062 )
1063 .unwrap();
1064 }
1065
1066 (temp_dir, repo_path)
1067 }
1068
1069 #[test]
1070 fn test_parse_range_input_valid() {
1071 let result = parse_range_input("5-11", 20);
1072 assert!(result.is_ok());
1073 let (start, end) = result.unwrap();
1074 assert_eq!(start, 5);
1075 assert_eq!(end, 11);
1076 }
1077
1078 #[test]
1079 fn test_parse_range_input_with_spaces() {
1080 let result = parse_range_input(" 3 - 8 ", 20);
1081 assert!(result.is_ok());
1082 let (start, end) = result.unwrap();
1083 assert_eq!(start, 3);
1084 assert_eq!(end, 8);
1085 }
1086
1087 #[test]
1088 fn test_parse_range_input_asterisk() {
1089 let result = parse_range_input("*", 10);
1090 assert!(result.is_ok());
1091 let (start, end) = result.unwrap();
1092 assert_eq!(start, 1);
1093 assert_eq!(end, 10);
1094
1095 let result = parse_range_input("*", 0);
1097 assert!(result.is_err());
1098 }
1099
1100 #[test]
1101 fn test_parse_range_input_invalid_format() {
1102 let result = parse_range_input("5", 20);
1103 assert!(result.is_err());
1104
1105 let result = parse_range_input("5-11-15", 20);
1106 assert!(result.is_err());
1107
1108 let result = parse_range_input("abc-def", 20);
1109 assert!(result.is_err());
1110 }
1111
1112 #[test]
1113 fn test_parse_range_input_invalid_range() {
1114 let result = parse_range_input("11-5", 20);
1115 assert!(result.is_err());
1116
1117 let result = parse_range_input("0-5", 20);
1118 assert!(result.is_err());
1119 }
1120
1121 #[test]
1122 fn test_generate_range_timestamps() {
1123 let start =
1124 NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1125 let end =
1126 NaiveDateTime::parse_from_str("2023-01-01 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1127
1128 let timestamps = generate_range_timestamps(start, end, 5);
1129
1130 assert_eq!(timestamps.len(), 5);
1131 assert_eq!(timestamps[0], start);
1132 assert_eq!(timestamps[4], end);
1133
1134 for i in 1..timestamps.len() {
1136 assert!(timestamps[i] >= timestamps[i - 1]);
1137 }
1138 }
1139
1140 #[test]
1141 fn test_generate_range_timestamps_edge_cases() {
1142 let start =
1143 NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1144 let end =
1145 NaiveDateTime::parse_from_str("2023-01-01 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1146
1147 let timestamps = generate_range_timestamps(start, end, 0);
1149 assert_eq!(timestamps.len(), 0);
1150
1151 let timestamps = generate_range_timestamps(start, end, 1);
1153 assert_eq!(timestamps.len(), 1);
1154 assert_eq!(timestamps[0], start);
1155 }
1156
1157 #[test]
1158 fn test_rewrite_range_commits_with_repo() {
1159 let (_temp_dir, repo_path) = create_test_repo_with_commits();
1160 let args = Args {
1161 repo_path: Some(repo_path),
1162 email: Some("new@example.com".to_string()),
1163 name: Some("New User".to_string()),
1164 start: Some("2023-01-01 00:00:00".to_string()),
1165 end: Some("2023-01-01 10:00:00".to_string()),
1166 show_history: false,
1167 pick_specific_commits: false,
1168 range: false,
1169 simulate: false,
1170 show_diff: false,
1171 edit_message: false,
1172 edit_author: false,
1173 edit_time: false,
1174 };
1175
1176 let commits = get_commit_history(&args, false).unwrap();
1178 assert_eq!(commits.len(), 5);
1179
1180 let (start, end) = (0, 2); assert!(start <= end);
1183 assert!(end < commits.len());
1184
1185 let start_time =
1187 NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1188 let end_time =
1189 NaiveDateTime::parse_from_str("2023-01-01 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
1190 let timestamps = generate_range_timestamps(start_time, end_time, 3);
1191 assert_eq!(timestamps.len(), 3);
1192 }
1193}