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        };
458
459        let commits = get_commit_history(&args, false).unwrap();
460        let commit = &commits[0];
461
462        // Test that show_commit_details doesn't crash
463        let result = show_commit_details(commit, &repo);
464        assert!(result.is_ok());
465    }
466
467    #[test]
468    fn test_edit_options_default() {
469        let options = EditOptions::default();
470
471        assert_eq!(options.author_name, None);
472        assert_eq!(options.author_email, None);
473        assert_eq!(options.timestamp, None);
474        assert_eq!(options.message, None);
475    }
476
477    #[test]
478    fn test_edit_options_with_values() {
479        let timestamp =
480            NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
481
482        let options = EditOptions {
483            author_name: Some("New Author".to_string()),
484            author_email: Some("new@example.com".to_string()),
485            timestamp: Some(timestamp),
486            message: Some("New commit message".to_string()),
487        };
488
489        assert_eq!(options.author_name, Some("New Author".to_string()));
490        assert_eq!(options.author_email, Some("new@example.com".to_string()));
491        assert_eq!(options.timestamp, Some(timestamp));
492        assert_eq!(options.message, Some("New commit message".to_string()));
493    }
494
495    #[test]
496    fn test_commit_selection_validation() {
497        // Test the selection validation logic that's used in select_commit
498        let commits = [CommitInfo {
499            oid: git2::Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(),
500            short_hash: "12345678".to_string(),
501            timestamp: NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S")
502                .unwrap(),
503            author_name: "Test User".to_string(),
504            author_email: "test@example.com".to_string(),
505            message: "Test commit".to_string(),
506            parent_count: 0,
507        }];
508
509        // Test valid selection range
510        let selection = 1;
511        assert!(selection >= 1 && selection <= commits.len());
512
513        // Test invalid selections
514        let invalid_selection1 = 0;
515        assert!(invalid_selection1 < 1 || invalid_selection1 > commits.len());
516
517        let invalid_selection2 = commits.len() + 1;
518        assert!(invalid_selection2 < 1 || invalid_selection2 > commits.len());
519    }
520
521    #[test]
522    fn test_rewrite_specific_commits_with_empty_commits() {
523        let (_temp_dir, repo_path) = create_test_repo_with_commits();
524        let args = Args {
525            repo_path: Some(repo_path),
526            email: None,
527            name: None,
528            start: None,
529            end: None,
530            show_history: false,
531            pick_specific_commits: true,
532            range: false,
533            simulate: false,
534            show_diff: false,
535        };
536
537        // Test that the function handles the case where get_commit_history returns commits
538        let commits = get_commit_history(&args, false).unwrap();
539        assert!(!commits.is_empty());
540        assert_eq!(commits.len(), 3);
541    }
542
543    #[test]
544    fn test_apply_commit_changes_logic() {
545        let (_temp_dir, repo_path) = create_test_repo_with_commits();
546        let _repo = Repository::open(&repo_path).unwrap();
547
548        // Get commit info
549        let args = Args {
550            repo_path: Some(repo_path),
551            email: None,
552            name: None,
553            start: None,
554            end: None,
555            show_history: false,
556            pick_specific_commits: false,
557            range: false,
558            simulate: false,
559            show_diff: false,
560        };
561
562        let commits = get_commit_history(&args, false).unwrap();
563        let target_commit = &commits[0];
564
565        // Test EditOptions with different values
566        let options = EditOptions {
567            author_name: Some("New Author".to_string()),
568            author_email: Some("new@example.com".to_string()),
569            timestamp: Some(
570                NaiveDateTime::parse_from_str("2023-01-01 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
571            ),
572            message: Some("New commit message".to_string()),
573        };
574
575        // Test that the options are properly set
576        assert_eq!(options.author_name.as_ref().unwrap(), "New Author");
577        assert_eq!(options.author_email.as_ref().unwrap(), "new@example.com");
578        assert!(options.timestamp.is_some());
579        assert_eq!(options.message.as_ref().unwrap(), "New commit message");
580
581        // Test fallback to original values
582        let partial_options = EditOptions {
583            author_name: None,
584            author_email: None,
585            timestamp: None,
586            message: None,
587        };
588
589        let author_name = partial_options
590            .author_name
591            .as_ref()
592            .unwrap_or(&target_commit.author_name);
593        let author_email = partial_options
594            .author_email
595            .as_ref()
596            .unwrap_or(&target_commit.author_email);
597
598        assert_eq!(author_name, &target_commit.author_name);
599        assert_eq!(author_email, &target_commit.author_email);
600    }
601}