rustic_git/commands/
log.rs

1use crate::types::Hash;
2use crate::utils::git;
3use crate::{Repository, Result};
4use chrono::{DateTime, Utc};
5use std::fmt;
6use std::path::PathBuf;
7
8/// Git log format string for parsing commit information
9/// Format: hash|author_name|author_email|author_timestamp|committer_name|committer_email|committer_timestamp|parent_hashes|subject|body
10const GIT_LOG_FORMAT: &str = "--pretty=format:%H|%an|%ae|%at|%cn|%ce|%ct|%P|%s|%b";
11
12/// Date format for git date filters
13const DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Author {
17    pub name: String,
18    pub email: String,
19    pub timestamp: DateTime<Utc>,
20}
21
22impl fmt::Display for Author {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "{} <{}>", self.name, self.email)
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CommitMessage {
30    pub subject: String,
31    pub body: Option<String>,
32}
33
34impl CommitMessage {
35    pub fn new(subject: String, body: Option<String>) -> Self {
36        Self { subject, body }
37    }
38
39    /// Get the full message (subject + body if present)
40    pub fn full(&self) -> String {
41        match &self.body {
42            Some(body) => format!("{}\n\n{}", self.subject, body),
43            None => self.subject.clone(),
44        }
45    }
46
47    /// Check if message is empty
48    pub fn is_empty(&self) -> bool {
49        self.subject.is_empty()
50    }
51}
52
53impl fmt::Display for CommitMessage {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{}", self.full())
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Commit {
61    pub hash: Hash,
62    pub author: Author,
63    pub committer: Author,
64    pub message: CommitMessage,
65    pub timestamp: DateTime<Utc>,
66    pub parents: Box<[Hash]>,
67}
68
69impl Commit {
70    /// Check if this is a merge commit (has multiple parents)
71    pub fn is_merge(&self) -> bool {
72        self.parents.len() > 1
73    }
74
75    /// Check if this is a root commit (has no parents)
76    pub fn is_root(&self) -> bool {
77        self.parents.is_empty()
78    }
79
80    /// Get the main parent commit hash (first parent for merges)
81    pub fn main_parent(&self) -> Option<&Hash> {
82        self.parents.first()
83    }
84
85    /// Check if commit matches author
86    pub fn is_authored_by(&self, author: &str) -> bool {
87        self.author.name.contains(author) || self.author.email.contains(author)
88    }
89
90    /// Check if commit message contains text
91    pub fn message_contains(&self, text: &str) -> bool {
92        self.message
93            .subject
94            .to_lowercase()
95            .contains(&text.to_lowercase())
96            || self
97                .message
98                .body
99                .as_ref()
100                .is_some_and(|body| body.to_lowercase().contains(&text.to_lowercase()))
101    }
102}
103
104impl fmt::Display for Commit {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(
107            f,
108            "{} {} by {} at {}",
109            self.hash.short(),
110            self.message.subject,
111            self.author.name,
112            self.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
113        )
114    }
115}
116
117#[derive(Debug, Clone, PartialEq)]
118pub struct CommitLog {
119    commits: Box<[Commit]>,
120}
121
122impl CommitLog {
123    /// Create a new CommitLog from a vector of commits
124    pub fn new(commits: Vec<Commit>) -> Self {
125        Self {
126            commits: commits.into_boxed_slice(),
127        }
128    }
129
130    /// Get all commits
131    pub fn all(&self) -> &[Commit] {
132        &self.commits
133    }
134
135    /// Get an iterator over all commits
136    pub fn iter(&self) -> impl Iterator<Item = &Commit> {
137        self.commits.iter()
138    }
139
140    /// Get commits by a specific author
141    pub fn by_author(&self, author: &str) -> impl Iterator<Item = &Commit> {
142        self.commits
143            .iter()
144            .filter(move |c| c.is_authored_by(author))
145    }
146
147    /// Get commits since a specific date
148    pub fn since(&self, date: DateTime<Utc>) -> impl Iterator<Item = &Commit> {
149        self.commits.iter().filter(move |c| c.timestamp >= date)
150    }
151
152    /// Get commits until a specific date
153    pub fn until(&self, date: DateTime<Utc>) -> impl Iterator<Item = &Commit> {
154        self.commits.iter().filter(move |c| c.timestamp <= date)
155    }
156
157    /// Get commits with message containing text
158    pub fn with_message_containing(&self, text: &str) -> impl Iterator<Item = &Commit> {
159        let text = text.to_lowercase();
160        self.commits
161            .iter()
162            .filter(move |c| c.message_contains(&text))
163    }
164
165    /// Get only merge commits
166    pub fn merges_only(&self) -> impl Iterator<Item = &Commit> {
167        self.commits.iter().filter(|c| c.is_merge())
168    }
169
170    /// Get commits excluding merges
171    pub fn no_merges(&self) -> impl Iterator<Item = &Commit> {
172        self.commits.iter().filter(|c| !c.is_merge())
173    }
174
175    /// Find commit by full hash
176    pub fn find_by_hash(&self, hash: &Hash) -> Option<&Commit> {
177        self.commits.iter().find(|c| &c.hash == hash)
178    }
179
180    /// Find commit by short hash
181    pub fn find_by_short_hash(&self, short: &str) -> Option<&Commit> {
182        self.commits.iter().find(|c| c.hash.short() == short)
183    }
184
185    /// Check if the log is empty
186    pub fn is_empty(&self) -> bool {
187        self.commits.is_empty()
188    }
189
190    /// Get the count of commits
191    pub fn len(&self) -> usize {
192        self.commits.len()
193    }
194
195    /// Get the first (most recent) commit
196    pub fn first(&self) -> Option<&Commit> {
197        self.commits.first()
198    }
199
200    /// Get the last (oldest) commit
201    pub fn last(&self) -> Option<&Commit> {
202        self.commits.last()
203    }
204}
205
206impl fmt::Display for CommitLog {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        for commit in &self.commits {
209            writeln!(f, "{}", commit)?;
210        }
211        Ok(())
212    }
213}
214
215#[derive(Debug, Clone, Default)]
216pub struct LogOptions {
217    pub max_count: Option<usize>,
218    pub since: Option<DateTime<Utc>>,
219    pub until: Option<DateTime<Utc>>,
220    pub author: Option<String>,
221    pub committer: Option<String>,
222    pub grep: Option<String>,
223    pub paths: Vec<PathBuf>,
224    pub follow_renames: bool,
225    pub merges_only: bool,
226    pub no_merges: bool,
227}
228
229impl LogOptions {
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Set maximum number of commits to retrieve
235    pub fn max_count(mut self, count: usize) -> Self {
236        self.max_count = Some(count);
237        self
238    }
239
240    /// Filter commits since a date
241    pub fn since(mut self, date: DateTime<Utc>) -> Self {
242        self.since = Some(date);
243        self
244    }
245
246    /// Filter commits until a date
247    pub fn until(mut self, date: DateTime<Utc>) -> Self {
248        self.until = Some(date);
249        self
250    }
251
252    /// Filter by author name or email
253    pub fn author(mut self, author: String) -> Self {
254        self.author = Some(author);
255        self
256    }
257
258    /// Filter by committer name or email
259    pub fn committer(mut self, committer: String) -> Self {
260        self.committer = Some(committer);
261        self
262    }
263
264    /// Filter by commit message content
265    pub fn grep(mut self, pattern: String) -> Self {
266        self.grep = Some(pattern);
267        self
268    }
269
270    /// Filter by file paths
271    pub fn paths(mut self, paths: Vec<PathBuf>) -> Self {
272        self.paths = paths;
273        self
274    }
275
276    /// Follow file renames
277    pub fn follow_renames(mut self, follow: bool) -> Self {
278        self.follow_renames = follow;
279        self
280    }
281
282    /// Show only merge commits
283    pub fn merges_only(mut self, only: bool) -> Self {
284        self.merges_only = only;
285        self
286    }
287
288    /// Exclude merge commits
289    pub fn no_merges(mut self, exclude: bool) -> Self {
290        self.no_merges = exclude;
291        self
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct CommitDetails {
297    pub commit: Commit,
298    pub files_changed: Vec<PathBuf>,
299    pub insertions: usize,
300    pub deletions: usize,
301}
302
303impl CommitDetails {
304    /// Get total changes (insertions + deletions)
305    pub fn total_changes(&self) -> usize {
306        self.insertions + self.deletions
307    }
308
309    /// Check if any files were changed
310    pub fn has_changes(&self) -> bool {
311        !self.files_changed.is_empty()
312    }
313}
314
315impl fmt::Display for CommitDetails {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        writeln!(f, "{}", self.commit)?;
318        writeln!(f, "Files changed: {}", self.files_changed.len())?;
319        writeln!(f, "Insertions: +{}", self.insertions)?;
320        writeln!(f, "Deletions: -{}", self.deletions)?;
321
322        if !self.files_changed.is_empty() {
323            writeln!(f, "\nFiles:")?;
324            for file in &self.files_changed {
325                writeln!(f, "  {}", file.display())?;
326            }
327        }
328
329        Ok(())
330    }
331}
332
333/// Parse git log output with our custom format
334fn parse_log_output(output: &str) -> Result<Vec<Commit>> {
335    let mut commits = Vec::new();
336
337    for line in output.lines() {
338        let line = line.trim();
339        if line.is_empty() {
340            continue;
341        }
342
343        // Parse format: hash|author_name|author_email|author_timestamp|committer_name|committer_email|committer_timestamp|parent_hashes|subject|body
344        let parts: Vec<&str> = line.splitn(10, '|').collect();
345        if parts.len() < 9 {
346            continue; // Skip malformed lines
347        }
348
349        let hash = Hash::from(parts[0].to_string());
350        let author_name = parts[1].to_string();
351        let author_email = parts[2].to_string();
352        let author_timestamp = parse_timestamp(parts[3])?;
353        let committer_name = parts[4].to_string();
354        let committer_email = parts[5].to_string();
355        let committer_timestamp = parse_timestamp(parts[6])?;
356        let parent_hashes = parse_parent_hashes(parts[7]);
357        let subject = parts[8].to_string();
358        let body = if parts.len() > 9 && !parts[9].is_empty() {
359            Some(parts[9].to_string())
360        } else {
361            None
362        };
363
364        let author = Author {
365            name: author_name,
366            email: author_email,
367            timestamp: author_timestamp,
368        };
369
370        let committer = Author {
371            name: committer_name,
372            email: committer_email,
373            timestamp: committer_timestamp,
374        };
375
376        let message = CommitMessage::new(subject, body);
377
378        let commit = Commit {
379            hash,
380            author,
381            committer,
382            message,
383            timestamp: author_timestamp, // Use author timestamp for commit timestamp
384            parents: parent_hashes,
385        };
386
387        commits.push(commit);
388    }
389
390    Ok(commits)
391}
392
393/// Parse Unix timestamp to DateTime<Utc>
394fn parse_timestamp(timestamp_str: &str) -> Result<DateTime<Utc>> {
395    let timestamp: i64 = timestamp_str.parse().map_err(|_| {
396        crate::error::GitError::CommandFailed(format!("Invalid timestamp: {}", timestamp_str))
397    })?;
398
399    DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
400        crate::error::GitError::CommandFailed(format!("Invalid timestamp value: {}", timestamp))
401    })
402}
403
404/// Parse parent hashes from space-separated string
405fn parse_parent_hashes(parents_str: &str) -> Box<[Hash]> {
406    if parents_str.is_empty() {
407        return Box::new([]);
408    }
409
410    parents_str
411        .split_whitespace()
412        .map(|hash| Hash::from(hash.to_string()))
413        .collect::<Vec<_>>()
414        .into_boxed_slice()
415}
416
417impl Repository {
418    /// Get commit history with default options
419    pub fn log(&self) -> Result<CommitLog> {
420        self.log_with_options(&LogOptions::new().max_count(100))
421    }
422
423    /// Get recent N commits
424    pub fn recent_commits(&self, count: usize) -> Result<CommitLog> {
425        self.log_with_options(&LogOptions::new().max_count(count))
426    }
427
428    /// Get commit history with custom options
429    pub fn log_with_options(&self, options: &LogOptions) -> Result<CommitLog> {
430        Self::ensure_git()?;
431
432        // Build all formatted arguments first
433        let mut args_vec: Vec<String> = vec![
434            "log".to_string(),
435            GIT_LOG_FORMAT.to_string(),
436            "--no-show-signature".to_string(),
437        ];
438
439        // Add options to git command
440        if let Some(count) = options.max_count {
441            args_vec.push("-n".to_string());
442            args_vec.push(count.to_string());
443        }
444
445        if let Some(since) = &options.since {
446            args_vec.push(format!("--since={}", since.format(DATE_FORMAT)));
447        }
448
449        if let Some(until) = &options.until {
450            args_vec.push(format!("--until={}", until.format(DATE_FORMAT)));
451        }
452
453        if let Some(author) = &options.author {
454            args_vec.push(format!("--author={}", author));
455        }
456
457        if let Some(committer) = &options.committer {
458            args_vec.push(format!("--committer={}", committer));
459        }
460
461        if let Some(grep) = &options.grep {
462            args_vec.push(format!("--grep={}", grep));
463        }
464
465        // Add boolean flags
466        if options.follow_renames {
467            args_vec.push("--follow".to_string());
468        }
469
470        if options.merges_only {
471            args_vec.push("--merges".to_string());
472        }
473
474        if options.no_merges {
475            args_vec.push("--no-merges".to_string());
476        }
477
478        // Add path filters at the end
479        if !options.paths.is_empty() {
480            args_vec.push("--".to_string());
481            for path in &options.paths {
482                args_vec.push(path.to_string_lossy().to_string());
483            }
484        }
485
486        // Convert to &str slice for git function
487        let all_args: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
488
489        let stdout = git(&all_args, Some(self.repo_path()))?;
490        let commits = parse_log_output(&stdout)?;
491        Ok(CommitLog::new(commits))
492    }
493
494    /// Get commits in a range between two commits
495    pub fn log_range(&self, from: &Hash, to: &Hash) -> Result<CommitLog> {
496        Self::ensure_git()?;
497
498        let range = format!("{}..{}", from.as_str(), to.as_str());
499        let args = vec!["log", GIT_LOG_FORMAT, "--no-show-signature", &range];
500
501        let stdout = git(&args, Some(self.repo_path()))?;
502        let commits = parse_log_output(&stdout)?;
503        Ok(CommitLog::new(commits))
504    }
505
506    /// Get commits that affected specific paths
507    pub fn log_for_paths(&self, paths: &[impl AsRef<std::path::Path>]) -> Result<CommitLog> {
508        let path_bufs: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
509        let options = LogOptions::new().paths(path_bufs);
510        self.log_with_options(&options)
511    }
512
513    /// Get detailed information about a specific commit
514    pub fn show_commit(&self, hash: &Hash) -> Result<CommitDetails> {
515        Self::ensure_git()?;
516
517        // Get commit info
518        let commit_args = vec![
519            "log",
520            GIT_LOG_FORMAT,
521            "--no-show-signature",
522            "-n",
523            "1",
524            hash.as_str(),
525        ];
526
527        let commit_output = git(&commit_args, Some(self.repo_path()))?;
528        let mut commits = parse_log_output(&commit_output)?;
529
530        if commits.is_empty() {
531            return Err(crate::error::GitError::CommandFailed(format!(
532                "Commit not found: {}",
533                hash
534            )));
535        }
536
537        let commit = commits.remove(0);
538
539        // Get diff stats
540        let stats_args = vec!["show", "--stat", "--format=", hash.as_str()];
541
542        let stats_output = git(&stats_args, Some(self.repo_path()))?;
543        let (files_changed, insertions, deletions) = parse_diff_stats(&stats_output);
544
545        Ok(CommitDetails {
546            commit,
547            files_changed,
548            insertions,
549            deletions,
550        })
551    }
552}
553
554/// Parse diff stats from git show --stat output
555fn parse_diff_stats(output: &str) -> (Vec<PathBuf>, usize, usize) {
556    let mut files_changed = Vec::new();
557    let mut total_insertions = 0;
558    let mut total_deletions = 0;
559
560    for line in output.lines() {
561        let line = line.trim();
562        if line.is_empty() {
563            continue;
564        }
565
566        // Parse lines like: "src/main.rs | 15 +++++++++------"
567        if let Some(pipe_pos) = line.find(" | ") {
568            let filename = line[..pipe_pos].trim();
569            files_changed.push(PathBuf::from(filename));
570
571            // Parse insertions/deletions from the rest of the line
572            let stats_part = &line[pipe_pos + 3..];
573            if let Some(space_pos) = stats_part.find(' ')
574                && let Ok(changes) = stats_part[..space_pos].parse::<usize>()
575            {
576                let symbols = &stats_part[space_pos + 1..];
577                let plus_count = symbols.chars().filter(|&c| c == '+').count();
578                let minus_count = symbols.chars().filter(|&c| c == '-').count();
579
580                // Distribute changes based on +/- ratio
581                let total_symbols = plus_count + minus_count;
582                if total_symbols > 0 {
583                    let insertions = (changes * plus_count) / total_symbols;
584                    let deletions = changes - insertions;
585                    total_insertions += insertions;
586                    total_deletions += deletions;
587                }
588            }
589        }
590        // Parse summary line like: "2 files changed, 15 insertions(+), 8 deletions(-)"
591        else if line.contains("files changed") || line.contains("file changed") {
592            if let Some(insertions_pos) = line.find(" insertions(+)")
593                && let Some(start) = line[..insertions_pos].rfind(' ')
594                && let Ok(insertions) = line[start + 1..insertions_pos].parse::<usize>()
595            {
596                total_insertions = insertions;
597            }
598            if let Some(deletions_pos) = line.find(" deletions(-)")
599                && let Some(start) = line[..deletions_pos].rfind(' ')
600                && let Ok(deletions) = line[start + 1..deletions_pos].parse::<usize>()
601            {
602                total_deletions = deletions;
603            }
604        }
605    }
606
607    (files_changed, total_insertions, total_deletions)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use std::fs;
614    use std::path::Path;
615
616    #[test]
617    fn test_author_display() {
618        let author = Author {
619            name: "John Doe".to_string(),
620            email: "john@example.com".to_string(),
621            timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
622        };
623        assert_eq!(format!("{}", author), "John Doe <john@example.com>");
624    }
625
626    #[test]
627    fn test_commit_message_creation() {
628        let msg = CommitMessage::new("Initial commit".to_string(), None);
629        assert_eq!(msg.subject, "Initial commit");
630        assert!(msg.body.is_none());
631        assert_eq!(msg.full(), "Initial commit");
632
633        let msg_with_body = CommitMessage::new(
634            "Add feature".to_string(),
635            Some("This adds a new feature\nwith multiple lines".to_string()),
636        );
637        assert_eq!(
638            msg_with_body.full(),
639            "Add feature\n\nThis adds a new feature\nwith multiple lines"
640        );
641    }
642
643    #[test]
644    fn test_commit_is_merge() {
645        let commit = Commit {
646            hash: Hash::from("abc123".to_string()),
647            author: Author {
648                name: "Test".to_string(),
649                email: "test@example.com".to_string(),
650                timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
651            },
652            committer: Author {
653                name: "Test".to_string(),
654                email: "test@example.com".to_string(),
655                timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
656            },
657            message: CommitMessage::new("Test commit".to_string(), None),
658            timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
659            parents: vec![
660                Hash::from("parent1".to_string()),
661                Hash::from("parent2".to_string()),
662            ]
663            .into_boxed_slice(),
664        };
665
666        assert!(commit.is_merge());
667        assert!(!commit.is_root());
668    }
669
670    #[test]
671    fn test_commit_log_filtering() {
672        let commits = vec![
673            create_test_commit(
674                "abc123",
675                "John Doe",
676                "john@example.com",
677                "Fix bug",
678                1640995200,
679            ),
680            create_test_commit(
681                "def456",
682                "Jane Smith",
683                "jane@example.com",
684                "Add feature",
685                1640995300,
686            ),
687            create_test_commit(
688                "ghi789",
689                "John Doe",
690                "john@example.com",
691                "Update docs",
692                1640995400,
693            ),
694        ];
695
696        let log = CommitLog::new(commits);
697
698        // Test by author
699        let john_commits: Vec<_> = log.by_author("John Doe").collect();
700        assert_eq!(john_commits.len(), 2);
701
702        // Test message search
703        let fix_commits: Vec<_> = log.with_message_containing("fix").collect();
704        assert_eq!(fix_commits.len(), 1);
705        assert_eq!(fix_commits[0].message.subject, "Fix bug");
706    }
707
708    #[test]
709    fn test_parse_timestamp() {
710        let timestamp = parse_timestamp("1640995200").unwrap();
711        assert_eq!(timestamp.timestamp(), 1640995200);
712    }
713
714    #[test]
715    fn test_parse_parent_hashes() {
716        let parents = parse_parent_hashes("abc123 def456 ghi789");
717        assert_eq!(parents.len(), 3);
718        assert_eq!(parents[0].as_str(), "abc123");
719        assert_eq!(parents[1].as_str(), "def456");
720        assert_eq!(parents[2].as_str(), "ghi789");
721
722        let no_parents = parse_parent_hashes("");
723        assert_eq!(no_parents.len(), 0);
724    }
725
726    #[test]
727    fn test_log_options_builder() {
728        let options = LogOptions::new()
729            .max_count(50)
730            .author("john@example.com".to_string())
731            .follow_renames(true);
732
733        assert_eq!(options.max_count, Some(50));
734        assert_eq!(options.author, Some("john@example.com".to_string()));
735        assert!(options.follow_renames);
736    }
737
738    #[test]
739    fn test_parse_diff_stats() {
740        let output = "src/main.rs | 15 +++++++++------\nREADME.md | 3 +++\n 2 files changed, 18 insertions(+), 6 deletions(-)";
741        let (files, insertions, deletions) = parse_diff_stats(output);
742
743        assert_eq!(files.len(), 2);
744        assert_eq!(files[0], PathBuf::from("src/main.rs"));
745        assert_eq!(files[1], PathBuf::from("README.md"));
746        assert_eq!(insertions, 18);
747        assert_eq!(deletions, 6);
748    }
749
750    #[test]
751    fn test_commit_details_display() {
752        let commit = create_test_commit(
753            "abc123",
754            "John Doe",
755            "john@example.com",
756            "Test commit",
757            1640995200,
758        );
759        let details = CommitDetails {
760            commit,
761            files_changed: vec![PathBuf::from("src/main.rs"), PathBuf::from("README.md")],
762            insertions: 15,
763            deletions: 8,
764        };
765
766        assert_eq!(details.total_changes(), 23);
767        assert!(details.has_changes());
768
769        let display_output = format!("{}", details);
770        assert!(display_output.contains("Files changed: 2"));
771        assert!(display_output.contains("Insertions: +15"));
772        assert!(display_output.contains("Deletions: -8"));
773    }
774
775    // Helper function to create test commits
776    fn create_test_commit(
777        hash: &str,
778        author_name: &str,
779        author_email: &str,
780        subject: &str,
781        timestamp: i64,
782    ) -> Commit {
783        Commit {
784            hash: Hash::from(hash.to_string()),
785            author: Author {
786                name: author_name.to_string(),
787                email: author_email.to_string(),
788                timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
789            },
790            committer: Author {
791                name: author_name.to_string(),
792                email: author_email.to_string(),
793                timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
794            },
795            message: CommitMessage::new(subject.to_string(), None),
796            timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
797            parents: Box::new([]),
798        }
799    }
800
801    #[test]
802    fn test_repository_log() {
803        let test_path = "/tmp/test_log_repo";
804
805        // Clean up if exists
806        if Path::new(test_path).exists() {
807            fs::remove_dir_all(test_path).unwrap();
808        }
809
810        // Create a repository with some commits
811        let repo = Repository::init(test_path, false).unwrap();
812
813        // Configure git user for this repository to enable commits
814        repo.config()
815            .set_user("Test User", "test@example.com")
816            .unwrap();
817
818        // Create initial commit
819        std::fs::write(format!("{}/test1.txt", test_path), "content1").unwrap();
820        repo.add(&["test1.txt"]).unwrap();
821        let _hash1 = repo.commit("First commit").unwrap();
822
823        // Create second commit
824        std::fs::write(format!("{}/test2.txt", test_path), "content2").unwrap();
825        repo.add(&["test2.txt"]).unwrap();
826        let _hash2 = repo.commit("Second commit").unwrap();
827
828        // Test log functionality
829        let log = repo.log().unwrap();
830        assert_eq!(log.len(), 2);
831
832        let recent = repo.recent_commits(1).unwrap();
833        assert_eq!(recent.len(), 1);
834        assert_eq!(recent.first().unwrap().message.subject, "Second commit");
835
836        // Clean up
837        fs::remove_dir_all(test_path).unwrap();
838    }
839}