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
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    // Show parent commits
83    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
101// Get user input for what to change
102pub 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                // Get all inputs
164                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    // Confirm changes
220    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 changes
266    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
277// Apply the changes to the selected commit
278fn 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            // This is the commit we want to edit
312            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            // Keep the original committer unless we're changing the timestamp
333            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            // Keep other commits as-is but update parent references
354            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        // Initialize git repo
395        let repo = git2::Repository::init(&repo_path).unwrap();
396
397        // Create multiple commits
398        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        // Get commit info
446        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        // Test that show_commit_details doesn't crash
466        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        // Test the selection validation logic that's used in select_commit
501        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        // Test valid selection range
513        let selection = 1;
514        assert!(selection >= 1 && selection <= commits.len());
515
516        // Test invalid selections
517        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        // Test that the function handles the case where get_commit_history returns commits
544        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        // Get commit info
555        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        // Test EditOptions with different values
575        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        // Test that the options are properly set
585        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        // Test fallback to original values
591        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}