git_editor/utils/
commit_history.rs

1use crate::utils::types::Result;
2use crate::{args::Args, utils::types::CommitInfo};
3use colored::Colorize;
4use git2::{Repository, Sort};
5
6pub fn get_commit_history(args: &Args, print: bool) -> Result<Vec<CommitInfo>> {
7    let repo = Repository::open(args.repo_path.as_ref().unwrap())?;
8
9    let mut revwalk = repo.revwalk()?;
10    revwalk.push_head()?;
11    revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
12
13    // Collect all commits first
14    let mut commits = Vec::new();
15    let mut commit_infos = Vec::new();
16
17    for oid_result in revwalk {
18        let oid = oid_result?;
19        let commit = repo.find_commit(oid)?;
20        let timestamp = commit.time();
21        let datetime = chrono::DateTime::from_timestamp(timestamp.seconds(), 0)
22            .unwrap_or_default()
23            .naive_utc();
24
25        let commit_info = CommitInfo {
26            oid,
27            short_hash: oid.to_string()[..8].to_string(),
28            timestamp: datetime,
29            author_name: commit.author().name().unwrap_or("Unknown").to_string(),
30            author_email: commit
31                .author()
32                .email()
33                .unwrap_or("unknown@email.com")
34                .to_string(),
35            message: commit.message().unwrap_or("(no message)").to_string(),
36            parent_count: commit.parent_count(),
37        };
38
39        if print {
40            commits.push((oid, commit));
41        }
42        commit_infos.push(commit_info);
43    }
44
45    // If print is true, calculate and display statistics
46    if print {
47        let total_commits = commit_infos.len();
48
49        if total_commits > 0 {
50            let timestamps: Vec<_> = commit_infos.iter().map(|c| c.timestamp).collect();
51            let mut sorted_timestamps = timestamps.clone();
52            sorted_timestamps.sort();
53
54            let earliest_date = sorted_timestamps[0];
55            let latest_date = sorted_timestamps[sorted_timestamps.len() - 1];
56            let date_span = latest_date.signed_duration_since(earliest_date).num_days();
57
58            let unique_authors: std::collections::HashSet<String> =
59                commit_infos.iter().map(|c| c.author_name.clone()).collect();
60
61            // Print summary
62            println!("\n{}", "Updated Commit History Summary:".bold().green());
63            println!("{}", "-".repeat(60).cyan());
64            println!(
65                "{}: {}",
66                "Total Commits".bold(),
67                total_commits.to_string().yellow()
68            );
69            println!(
70                "{}: {} days",
71                "Date Span".bold(),
72                date_span.to_string().yellow()
73            );
74            println!(
75                "{}: {} to {}",
76                "Date Range".bold(),
77                earliest_date.format("%Y-%m-%d %H:%M:%S").to_string().blue(),
78                latest_date.format("%Y-%m-%d %H:%M:%S").to_string().blue()
79            );
80            println!(
81                "{}: {}",
82                "Unique Authors".bold(),
83                unique_authors.len().to_string().yellow()
84            );
85            if unique_authors.len() <= 5 {
86                println!(
87                    "{}: {}",
88                    "Authors".bold(),
89                    unique_authors
90                        .iter()
91                        .cloned()
92                        .collect::<Vec<_>>()
93                        .join(", ")
94                        .magenta()
95                );
96            }
97            println!("{}", "=".repeat(60).cyan());
98
99            // Print detailed commit history
100            println!("\n{}", "Detailed Commit History:".bold().green());
101            println!("{}", "-".repeat(60).cyan());
102
103            for commit_info in &commit_infos {
104                println!(
105                    "{} {} {} {}",
106                    commit_info.short_hash.yellow().bold(),
107                    commit_info
108                        .timestamp
109                        .format("%Y-%m-%d %H:%M:%S")
110                        .to_string()
111                        .blue(),
112                    commit_info.author_name.magenta(),
113                    commit_info.message.lines().next().unwrap_or("").white()
114                );
115            }
116
117            println!("{}", "=".repeat(60).cyan());
118        }
119    }
120
121    Ok(commit_infos)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use tempfile::TempDir;
129
130    fn create_test_repo_with_commits() -> (TempDir, String) {
131        let temp_dir = TempDir::new().unwrap();
132        let repo_path = temp_dir.path().to_str().unwrap().to_string();
133
134        // Initialize git repo
135        let repo = git2::Repository::init(&repo_path).unwrap();
136
137        // Create multiple commits
138        for i in 1..=3 {
139            let file_path = temp_dir.path().join(format!("test{i}.txt"));
140            fs::write(&file_path, format!("test content {i}")).unwrap();
141
142            let mut index = repo.index().unwrap();
143            index
144                .add_path(std::path::Path::new(&format!("test{i}.txt")))
145                .unwrap();
146            index.write().unwrap();
147
148            let tree_id = index.write_tree().unwrap();
149            let tree = repo.find_tree(tree_id).unwrap();
150
151            let sig = git2::Signature::new(
152                "Test User",
153                "test@example.com",
154                &git2::Time::new(1234567890 + i as i64 * 3600, 0),
155            )
156            .unwrap();
157
158            let parents = if i == 1 {
159                vec![]
160            } else {
161                let head = repo.head().unwrap();
162                let parent_commit = head.peel_to_commit().unwrap();
163                vec![parent_commit]
164            };
165
166            repo.commit(
167                Some("HEAD"),
168                &sig,
169                &sig,
170                &format!("Commit {i}"),
171                &tree,
172                &parents.iter().collect::<Vec<_>>(),
173            )
174            .unwrap();
175        }
176
177        (temp_dir, repo_path)
178    }
179
180    #[test]
181    fn test_get_commit_history_without_print() {
182        let (_temp_dir, repo_path) = create_test_repo_with_commits();
183        let args = Args {
184            repo_path: Some(repo_path),
185            email: None,
186            name: None,
187            start: None,
188            end: None,
189            show_history: false,
190            pick_specific_commits: false,
191            range: false,
192            simulate: false,
193            show_diff: false,
194            edit_message: false,
195            edit_author: false,
196            edit_time: false,
197        };
198
199        let result = get_commit_history(&args, false);
200        assert!(result.is_ok());
201
202        let commit_infos = result.unwrap();
203        assert_eq!(commit_infos.len(), 3);
204
205        // Check that commits are in reverse chronological order (newest first)
206        assert_eq!(commit_infos[0].message, "Commit 3");
207        assert_eq!(commit_infos[1].message, "Commit 2");
208        assert_eq!(commit_infos[2].message, "Commit 1");
209    }
210
211    #[test]
212    fn test_get_commit_history_with_print() {
213        let (_temp_dir, repo_path) = create_test_repo_with_commits();
214        let args = Args {
215            repo_path: Some(repo_path),
216            email: None,
217            name: None,
218            start: None,
219            end: None,
220            show_history: true,
221            pick_specific_commits: false,
222            range: false,
223            simulate: false,
224            show_diff: false,
225            edit_message: false,
226            edit_author: false,
227            edit_time: false,
228        };
229
230        let result = get_commit_history(&args, true);
231        assert!(result.is_ok());
232
233        let commit_infos = result.unwrap();
234        assert_eq!(commit_infos.len(), 3);
235    }
236
237    #[test]
238    fn test_commit_info_fields() {
239        let (_temp_dir, repo_path) = create_test_repo_with_commits();
240        let args = Args {
241            repo_path: Some(repo_path),
242            email: None,
243            name: None,
244            start: None,
245            end: None,
246            show_history: false,
247            pick_specific_commits: false,
248            range: false,
249            simulate: false,
250            show_diff: false,
251            edit_message: false,
252            edit_author: false,
253            edit_time: false,
254        };
255
256        let result = get_commit_history(&args, false);
257        assert!(result.is_ok());
258
259        let commit_infos = result.unwrap();
260        let first_commit = &commit_infos[0];
261
262        // Check that all fields are populated correctly
263        assert!(!first_commit.short_hash.is_empty());
264        assert_eq!(first_commit.short_hash.len(), 8);
265        assert_eq!(first_commit.author_name, "Test User");
266        assert_eq!(first_commit.author_email, "test@example.com");
267        assert!(!first_commit.message.is_empty());
268        assert_eq!(first_commit.parent_count, 1); // Second and third commits have parents
269    }
270
271    #[test]
272    fn test_get_commit_history_empty_repo() {
273        let temp_dir = TempDir::new().unwrap();
274        let repo_path = temp_dir.path().to_str().unwrap().to_string();
275
276        // Initialize empty git repo
277        git2::Repository::init(&repo_path).unwrap();
278
279        let args = Args {
280            repo_path: Some(repo_path),
281            email: None,
282            name: None,
283            start: None,
284            end: None,
285            show_history: false,
286            pick_specific_commits: false,
287            range: false,
288            simulate: false,
289            show_diff: false,
290            edit_message: false,
291            edit_author: false,
292            edit_time: false,
293        };
294
295        let result = get_commit_history(&args, false);
296        // Empty repo should return error because there's no HEAD
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_get_commit_history_invalid_repo() {
302        let args = Args {
303            repo_path: Some("/nonexistent/path".to_string()),
304            email: None,
305            name: None,
306            start: None,
307            end: None,
308            show_history: false,
309            pick_specific_commits: false,
310            range: false,
311            simulate: false,
312            show_diff: false,
313            edit_message: false,
314            edit_author: false,
315            edit_time: false,
316        };
317
318        let result = get_commit_history(&args, false);
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_commit_info_parent_count() {
324        let (_temp_dir, repo_path) = create_test_repo_with_commits();
325        let args = Args {
326            repo_path: Some(repo_path),
327            email: None,
328            name: None,
329            start: None,
330            end: None,
331            show_history: false,
332            pick_specific_commits: false,
333            range: false,
334            simulate: false,
335            show_diff: false,
336            edit_message: false,
337            edit_author: false,
338            edit_time: false,
339        };
340
341        let result = get_commit_history(&args, false);
342        assert!(result.is_ok());
343
344        let commit_infos = result.unwrap();
345
346        // First commit (chronologically) should have 0 parents
347        assert_eq!(commit_infos[2].parent_count, 0);
348
349        // Second and third commits should have 1 parent each
350        assert_eq!(commit_infos[1].parent_count, 1);
351        assert_eq!(commit_infos[0].parent_count, 1);
352    }
353}