git_editor/rewrite/
rewrite_specific.rs

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    // Show parent commits
78    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
96// Get user input for what to change
97pub 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                // Get all inputs
159                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    // Confirm changes
215    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 changes
261    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
272// Apply the changes to the selected commit
273fn 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            // This is the commit we want to edit
307            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            // Keep the original committer unless we're changing the timestamp
328            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            // Keep other commits as-is but update parent references
349            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        // Initialize git repo
390        let repo = git2::Repository::init(&repo_path).unwrap();
391
392        // Create multiple commits
393        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        // Get commit info
441        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        // Test that show_commit_details doesn't crash
456        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        // Test the selection validation logic that's used in select_commit
491        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        // Test valid selection range
503        let selection = 1;
504        assert!(selection >= 1 && selection <= commits.len());
505
506        // Test invalid selections
507        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        // Test that the function handles the case where get_commit_history returns commits
529        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        // Get commit info
540        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        // Test EditOptions with different values
555        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        // Test that the options are properly set
565        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        // Test fallback to original values
571        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}