organizational_intelligence_plugin/
git.rs

1// Git history analyzer
2// Phase 1: Clone repositories and analyze commit history for defect patterns
3// Toyota Way: Simple local cloning, can evolve to distributed if metrics show need
4
5use anyhow::{anyhow, Result};
6use git2::Repository;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tracing::{debug, info};
10
11/// Information about a single commit with quality metrics
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CommitInfo {
14    pub hash: String,
15    pub message: String,
16    pub author: String,
17    pub timestamp: i64,
18    /// Number of files changed in this commit
19    pub files_changed: usize,
20    /// Lines added
21    pub lines_added: usize,
22    /// Lines removed
23    pub lines_removed: usize,
24}
25
26/// Git repository analyzer
27/// Clones and analyzes git repositories to extract commit history
28pub struct GitAnalyzer {
29    cache_dir: PathBuf,
30}
31
32impl GitAnalyzer {
33    /// Create a new GitAnalyzer with specified cache directory
34    ///
35    /// # Arguments
36    /// * `cache_dir` - Directory to store cloned repositories
37    ///
38    /// # Examples
39    /// ```
40    /// use organizational_intelligence_plugin::git::GitAnalyzer;
41    /// use std::path::PathBuf;
42    ///
43    /// let analyzer = GitAnalyzer::new(PathBuf::from("/tmp/repos"));
44    /// ```
45    pub fn new<P: AsRef<Path>>(cache_dir: P) -> Self {
46        let cache_dir = cache_dir.as_ref().to_path_buf();
47        Self { cache_dir }
48    }
49
50    /// Clone a repository to the cache directory
51    ///
52    /// # Arguments
53    /// * `repo_url` - Git repository URL (https)
54    /// * `name` - Local name for the repository
55    ///
56    /// # Returns
57    /// * `Ok(())` if successful
58    /// * `Err` if clone fails
59    ///
60    /// # Examples
61    /// ```no_run
62    /// # use organizational_intelligence_plugin::git::GitAnalyzer;
63    /// # use std::path::PathBuf;
64    /// # async fn example() -> Result<(), anyhow::Error> {
65    /// let analyzer = GitAnalyzer::new(PathBuf::from("/tmp/repos"));
66    /// analyzer.clone_repository("https://github.com/rust-lang/rust", "rust")?;
67    /// # Ok(())
68    /// # }
69    /// ```
70    pub fn clone_repository(&self, repo_url: &str, name: &str) -> Result<()> {
71        let repo_path = self.cache_dir.join(name);
72
73        // Skip if already cloned
74        if repo_path.exists() {
75            debug!("Repository {} already exists at {:?}", name, repo_path);
76            return Ok(());
77        }
78
79        info!("Cloning repository {} from {}", name, repo_url);
80
81        // Clone the repository
82        Repository::clone(repo_url, &repo_path).map_err(|e| {
83            anyhow!(
84                "Failed to clone repository {} from {}: {}",
85                name,
86                repo_url,
87                e
88            )
89        })?;
90
91        info!("Successfully cloned {} to {:?}", name, repo_path);
92        Ok(())
93    }
94
95    /// Analyze commits in a cloned repository
96    ///
97    /// # Arguments
98    /// * `name` - Repository name (must be already cloned)
99    /// * `limit` - Maximum number of commits to analyze
100    ///
101    /// # Returns
102    /// * `Ok(Vec<CommitInfo>)` with commit information
103    /// * `Err` if repository not found or analysis fails
104    ///
105    /// # Examples
106    /// ```no_run
107    /// # use organizational_intelligence_plugin::git::GitAnalyzer;
108    /// # use std::path::PathBuf;
109    /// # async fn example() -> Result<(), anyhow::Error> {
110    /// let analyzer = GitAnalyzer::new(PathBuf::from("/tmp/repos"));
111    /// analyzer.clone_repository("https://github.com/rust-lang/rust", "rust")?;
112    /// let commits = analyzer.analyze_commits("rust", 100)?;
113    /// # Ok(())
114    /// # }
115    /// ```
116    pub fn analyze_commits(&self, name: &str, limit: usize) -> Result<Vec<CommitInfo>> {
117        let repo_path = self.cache_dir.join(name);
118
119        if !repo_path.exists() {
120            return Err(anyhow!(
121                "Repository {} not found at {:?}. Clone it first.",
122                name,
123                repo_path
124            ));
125        }
126
127        debug!("Opening repository at {:?}", repo_path);
128        let repo = Repository::open(&repo_path)
129            .map_err(|e| anyhow!("Failed to open repository {}: {}", name, e))?;
130
131        let mut revwalk = repo.revwalk()?;
132        revwalk.push_head()?;
133
134        let mut commits = Vec::new();
135
136        for (i, oid) in revwalk.enumerate() {
137            if i >= limit {
138                break;
139            }
140
141            let oid = oid?;
142            let commit = repo.find_commit(oid)?;
143
144            let hash = commit.id().to_string();
145            let message = commit.message().unwrap_or("").to_string();
146            let author = commit.author().email().unwrap_or("unknown").to_string();
147            let timestamp = commit.time().seconds();
148
149            // Get diff stats
150            let (files_changed, lines_added, lines_removed) = if commit.parent_count() > 0 {
151                let parent = commit.parent(0)?;
152                let diff =
153                    repo.diff_tree_to_tree(Some(&parent.tree()?), Some(&commit.tree()?), None)?;
154                let stats = diff.stats()?;
155                (stats.files_changed(), stats.insertions(), stats.deletions())
156            } else {
157                // Initial commit - count all files as changed
158                let tree = commit.tree()?;
159                (tree.len(), 0, 0)
160            };
161
162            commits.push(CommitInfo {
163                hash,
164                message,
165                author,
166                timestamp,
167                files_changed,
168                lines_added,
169                lines_removed,
170            });
171        }
172
173        debug!("Analyzed {} commits from {}", commits.len(), name);
174        Ok(commits)
175    }
176}
177
178/// Analyze commits from an existing Git repository path
179///
180/// Unlike GitAnalyzer which requires cloning repos to a cache directory,
181/// this function works with any existing Git repository.
182///
183/// # Arguments
184/// * `repo_path` - Path to existing Git repository
185/// * `limit` - Maximum number of commits to analyze
186///
187/// # Returns
188/// * `Ok(Vec<CommitInfo>)` with commit information
189/// * `Err` if repository not found or analysis fails
190///
191/// # Examples
192/// ```no_run
193/// use organizational_intelligence_plugin::git::analyze_repository_at_path;
194/// use std::path::PathBuf;
195///
196/// let commits = analyze_repository_at_path(PathBuf::from("/path/to/repo"), 100).unwrap();
197/// ```
198pub fn analyze_repository_at_path<P: AsRef<Path>>(
199    repo_path: P,
200    limit: usize,
201) -> Result<Vec<CommitInfo>> {
202    let repo_path = repo_path.as_ref();
203
204    if !repo_path.exists() {
205        return Err(anyhow!("Repository path does not exist: {:?}", repo_path));
206    }
207
208    debug!("Opening repository at {:?}", repo_path);
209    let repo = Repository::open(repo_path)
210        .map_err(|e| anyhow!("Failed to open repository at {:?}: {}", repo_path, e))?;
211
212    let mut revwalk = repo.revwalk()?;
213    revwalk.push_head()?;
214
215    let mut commits = Vec::new();
216
217    for (i, oid) in revwalk.enumerate() {
218        if i >= limit {
219            break;
220        }
221
222        let oid = oid?;
223        let commit = repo.find_commit(oid)?;
224
225        let hash = commit.id().to_string();
226        let message = commit.message().unwrap_or("").to_string();
227        let author = commit.author().email().unwrap_or("unknown").to_string();
228        let timestamp = commit.time().seconds();
229
230        // Get diff stats
231        let (files_changed, lines_added, lines_removed) = if commit.parent_count() > 0 {
232            let parent = commit.parent(0)?;
233            let diff =
234                repo.diff_tree_to_tree(Some(&parent.tree()?), Some(&commit.tree()?), None)?;
235            let stats = diff.stats()?;
236            (stats.files_changed(), stats.insertions(), stats.deletions())
237        } else {
238            // Initial commit - count all files as changed
239            let tree = commit.tree()?;
240            (tree.len(), 0, 0)
241        };
242
243        commits.push(CommitInfo {
244            hash,
245            message,
246            author,
247            timestamp,
248            files_changed,
249            lines_added,
250            lines_removed,
251        });
252    }
253
254    debug!("Analyzed {} commits", commits.len());
255    Ok(commits)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use tempfile::TempDir;
262
263    #[test]
264    fn test_git_analyzer_can_be_created() {
265        let temp_dir = TempDir::new().unwrap();
266        let _analyzer = GitAnalyzer::new(temp_dir.path());
267    }
268
269    #[test]
270    fn test_commit_info_structure() {
271        let commit = CommitInfo {
272            hash: "abc123".to_string(),
273            message: "fix: null pointer dereference".to_string(),
274            author: "test@example.com".to_string(),
275            timestamp: 1234567890,
276            files_changed: 3,
277            lines_added: 15,
278            lines_removed: 8,
279        };
280
281        assert_eq!(commit.hash, "abc123");
282        assert_eq!(commit.message, "fix: null pointer dereference");
283        assert_eq!(commit.author, "test@example.com");
284        assert_eq!(commit.timestamp, 1234567890);
285        assert_eq!(commit.files_changed, 3);
286        assert_eq!(commit.lines_added, 15);
287        assert_eq!(commit.lines_removed, 8);
288    }
289
290    #[test]
291    fn test_analyze_nonexistent_repo() {
292        let temp_dir = TempDir::new().unwrap();
293        let analyzer = GitAnalyzer::new(temp_dir.path());
294
295        let result = analyzer.analyze_commits("nonexistent-repo", 10);
296
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_commit_info_serialization() {
302        let commit = CommitInfo {
303            hash: "test123".to_string(),
304            message: "test commit".to_string(),
305            author: "test@example.com".to_string(),
306            timestamp: 1234567890,
307            files_changed: 1,
308            lines_added: 10,
309            lines_removed: 5,
310        };
311
312        let json = serde_json::to_string(&commit).unwrap();
313        let deserialized: CommitInfo = serde_json::from_str(&json).unwrap();
314
315        assert_eq!(commit.hash, deserialized.hash);
316        assert_eq!(commit.message, deserialized.message);
317        assert_eq!(commit.author, deserialized.author);
318    }
319
320    #[test]
321    fn test_analyze_local_repo_with_commits() {
322        let temp_dir = TempDir::new().unwrap();
323        let repo_path = temp_dir.path().join("test-repo");
324        std::fs::create_dir(&repo_path).unwrap();
325
326        // Initialize a git repository
327        let repo = Repository::init(&repo_path).unwrap();
328
329        // Create a test file
330        let test_file = repo_path.join("test.txt");
331        std::fs::write(&test_file, "Hello, world!").unwrap();
332
333        // Configure git
334        let mut config = repo.config().unwrap();
335        config.set_str("user.name", "Test User").unwrap();
336        config.set_str("user.email", "test@example.com").unwrap();
337
338        // Add and commit the file
339        let mut index = repo.index().unwrap();
340        index.add_path(Path::new("test.txt")).unwrap();
341        index.write().unwrap();
342
343        let tree_id = index.write_tree().unwrap();
344        let tree = repo.find_tree(tree_id).unwrap();
345        let sig = repo.signature().unwrap();
346
347        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
348            .unwrap();
349
350        // Now analyze the repo
351        let analyzer = GitAnalyzer::new(temp_dir.path());
352        let commits = analyzer.analyze_commits("test-repo", 10).unwrap();
353
354        assert_eq!(commits.len(), 1);
355        assert_eq!(commits[0].message, "Initial commit");
356        assert!(commits[0].files_changed > 0);
357    }
358
359    #[test]
360    fn test_analyze_local_repo_multiple_commits() {
361        let temp_dir = TempDir::new().unwrap();
362        let repo_path = temp_dir.path().join("multi-commit-repo");
363        std::fs::create_dir(&repo_path).unwrap();
364
365        let repo = Repository::init(&repo_path).unwrap();
366
367        let mut config = repo.config().unwrap();
368        config.set_str("user.name", "Test User").unwrap();
369        config.set_str("user.email", "test@example.com").unwrap();
370
371        // Helper to commit a file
372        let commit_file = |name: &str, content: &str, message: &str| {
373            let file_path = repo_path.join(name);
374            std::fs::write(&file_path, content).unwrap();
375
376            let mut index = repo.index().unwrap();
377            index.add_path(Path::new(name)).unwrap();
378            index.write().unwrap();
379
380            let tree_id = index.write_tree().unwrap();
381            let tree = repo.find_tree(tree_id).unwrap();
382            let sig = repo.signature().unwrap();
383
384            let parent = if let Ok(head) = repo.head() {
385                let parent_commit = head.peel_to_commit().unwrap();
386                vec![parent_commit]
387            } else {
388                vec![]
389            };
390
391            let parent_refs: Vec<_> = parent.iter().collect();
392
393            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs)
394                .unwrap();
395        };
396
397        commit_file("file1.txt", "content 1", "Add file1");
398        commit_file("file2.txt", "content 2", "Add file2");
399        commit_file("file3.txt", "content 3", "Add file3");
400
401        // Analyze the repo
402        let analyzer = GitAnalyzer::new(temp_dir.path());
403        let commits = analyzer.analyze_commits("multi-commit-repo", 10).unwrap();
404
405        assert_eq!(commits.len(), 3);
406        assert_eq!(commits[0].message, "Add file3"); // Most recent first
407        assert_eq!(commits[1].message, "Add file2");
408        assert_eq!(commits[2].message, "Add file1");
409    }
410
411    #[test]
412    fn test_analyze_respects_commit_limit() {
413        let temp_dir = TempDir::new().unwrap();
414        let repo_path = temp_dir.path().join("limit-repo");
415        std::fs::create_dir(&repo_path).unwrap();
416
417        let repo = Repository::init(&repo_path).unwrap();
418
419        let mut config = repo.config().unwrap();
420        config.set_str("user.name", "Test").unwrap();
421        config.set_str("user.email", "test@test.com").unwrap();
422
423        // Create 5 commits
424        for i in 0..5 {
425            let file_path = repo_path.join(format!("file{}.txt", i));
426            std::fs::write(&file_path, format!("content {}", i)).unwrap();
427
428            let mut index = repo.index().unwrap();
429            index
430                .add_path(Path::new(&format!("file{}.txt", i)))
431                .unwrap();
432            index.write().unwrap();
433
434            let tree_id = index.write_tree().unwrap();
435            let tree = repo.find_tree(tree_id).unwrap();
436            let sig = repo.signature().unwrap();
437
438            let parent = if let Ok(head) = repo.head() {
439                vec![head.peel_to_commit().unwrap()]
440            } else {
441                vec![]
442            };
443            let parent_refs: Vec<_> = parent.iter().collect();
444
445            repo.commit(
446                Some("HEAD"),
447                &sig,
448                &sig,
449                &format!("Commit {}", i),
450                &tree,
451                &parent_refs,
452            )
453            .unwrap();
454        }
455
456        let analyzer = GitAnalyzer::new(temp_dir.path());
457
458        // Test with limit of 2
459        let commits = analyzer.analyze_commits("limit-repo", 2).unwrap();
460        assert_eq!(commits.len(), 2);
461
462        // Test with limit of 10 (more than available)
463        let commits = analyzer.analyze_commits("limit-repo", 10).unwrap();
464        assert_eq!(commits.len(), 5);
465    }
466
467    // Integration tests that require network access are marked as ignored
468    // Run with: cargo test -- --ignored
469    #[test]
470    #[ignore]
471    fn test_clone_small_repository() {
472        let temp_dir = TempDir::new().unwrap();
473        let analyzer = GitAnalyzer::new(temp_dir.path());
474
475        // Use a very small test repository
476        let result =
477            analyzer.clone_repository("https://github.com/rust-lang/rustlings", "rustlings");
478
479        assert!(result.is_ok());
480    }
481
482    #[test]
483    #[ignore]
484    fn test_analyze_commits_basic() {
485        let temp_dir = TempDir::new().unwrap();
486        let analyzer = GitAnalyzer::new(temp_dir.path());
487
488        analyzer
489            .clone_repository("https://github.com/rust-lang/rustlings", "rustlings")
490            .unwrap();
491
492        let commits = analyzer.analyze_commits("rustlings", 10).unwrap();
493
494        assert!(!commits.is_empty());
495        assert!(commits.len() <= 10);
496
497        let first_commit = &commits[0];
498        assert!(!first_commit.hash.is_empty());
499        assert!(!first_commit.message.is_empty());
500    }
501
502    #[test]
503    #[ignore]
504    fn test_analyze_commits_respects_limit() {
505        let temp_dir = TempDir::new().unwrap();
506        let analyzer = GitAnalyzer::new(temp_dir.path());
507
508        analyzer
509            .clone_repository("https://github.com/rust-lang/rustlings", "rustlings")
510            .unwrap();
511
512        let commits_5 = analyzer.analyze_commits("rustlings", 5).unwrap();
513        assert!(commits_5.len() <= 5);
514
515        let commits_20 = analyzer.analyze_commits("rustlings", 20).unwrap();
516        assert!(commits_20.len() <= 20);
517    }
518
519    #[test]
520    #[ignore]
521    fn test_analyzer_caches_cloned_repos() {
522        let temp_dir = TempDir::new().unwrap();
523        let analyzer = GitAnalyzer::new(temp_dir.path());
524
525        // First clone
526        analyzer
527            .clone_repository("https://github.com/rust-lang/rustlings", "rustlings")
528            .unwrap();
529
530        // Second call should not re-clone
531        let result =
532            analyzer.clone_repository("https://github.com/rust-lang/rustlings", "rustlings");
533        assert!(result.is_ok());
534
535        // Verify we can still analyze
536        let commits = analyzer.analyze_commits("rustlings", 5).unwrap();
537        assert!(!commits.is_empty());
538    }
539
540    #[test]
541    fn test_clone_repository_already_exists() {
542        let temp_dir = TempDir::new().unwrap();
543        let repo_path = temp_dir.path().join("existing-repo");
544        std::fs::create_dir(&repo_path).unwrap();
545
546        // Initialize a repo to simulate already exists
547        Repository::init(&repo_path).unwrap();
548
549        let analyzer = GitAnalyzer::new(temp_dir.path());
550
551        // Should not fail when repo already exists
552        let result = analyzer.clone_repository("https://example.com/repo.git", "existing-repo");
553        assert!(result.is_ok());
554    }
555
556    #[test]
557    fn test_clone_repository_invalid_url() {
558        let temp_dir = TempDir::new().unwrap();
559        let analyzer = GitAnalyzer::new(temp_dir.path());
560
561        // Try to clone from invalid URL
562        let result = analyzer.clone_repository("not-a-valid-url", "test-repo");
563        assert!(result.is_err());
564    }
565
566    #[test]
567    fn test_analyze_commits_with_zero_limit() {
568        let temp_dir = TempDir::new().unwrap();
569        let repo_path = temp_dir.path().join("zero-limit-repo");
570        std::fs::create_dir(&repo_path).unwrap();
571
572        let repo = Repository::init(&repo_path).unwrap();
573
574        let mut config = repo.config().unwrap();
575        config.set_str("user.name", "Test").unwrap();
576        config.set_str("user.email", "test@test.com").unwrap();
577
578        // Create one commit
579        let file_path = repo_path.join("file.txt");
580        std::fs::write(&file_path, "content").unwrap();
581
582        let mut index = repo.index().unwrap();
583        index.add_path(Path::new("file.txt")).unwrap();
584        index.write().unwrap();
585
586        let tree_id = index.write_tree().unwrap();
587        let tree = repo.find_tree(tree_id).unwrap();
588        let sig = repo.signature().unwrap();
589
590        repo.commit(Some("HEAD"), &sig, &sig, "Test commit", &tree, &[])
591            .unwrap();
592
593        let analyzer = GitAnalyzer::new(temp_dir.path());
594
595        // Analyze with limit=0 should return empty
596        let commits = analyzer.analyze_commits("zero-limit-repo", 0).unwrap();
597        assert_eq!(commits.len(), 0);
598    }
599
600    #[test]
601    fn test_analyze_commits_with_modifications() {
602        let temp_dir = TempDir::new().unwrap();
603        let repo_path = temp_dir.path().join("modify-repo");
604        std::fs::create_dir(&repo_path).unwrap();
605
606        let repo = Repository::init(&repo_path).unwrap();
607
608        let mut config = repo.config().unwrap();
609        config.set_str("user.name", "Test User").unwrap();
610        config.set_str("user.email", "test@example.com").unwrap();
611
612        // Initial commit
613        let file_path = repo_path.join("test.txt");
614        std::fs::write(&file_path, "Line 1\nLine 2\nLine 3\n").unwrap();
615
616        let mut index = repo.index().unwrap();
617        index.add_path(Path::new("test.txt")).unwrap();
618        index.write().unwrap();
619
620        let tree_id = index.write_tree().unwrap();
621        let tree = repo.find_tree(tree_id).unwrap();
622        let sig = repo.signature().unwrap();
623
624        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
625            .unwrap();
626
627        // Second commit with modifications
628        std::fs::write(&file_path, "Line 1\nLine 2 modified\nLine 3\nLine 4\n").unwrap();
629
630        let mut index = repo.index().unwrap();
631        index.add_path(Path::new("test.txt")).unwrap();
632        index.write().unwrap();
633
634        let tree_id = index.write_tree().unwrap();
635        let tree = repo.find_tree(tree_id).unwrap();
636
637        let parent = repo.head().unwrap().peel_to_commit().unwrap();
638
639        repo.commit(Some("HEAD"), &sig, &sig, "Modify file", &tree, &[&parent])
640            .unwrap();
641
642        let analyzer = GitAnalyzer::new(temp_dir.path());
643        let commits = analyzer.analyze_commits("modify-repo", 10).unwrap();
644
645        assert_eq!(commits.len(), 2);
646        // Second commit should have lines added/removed
647        assert!(commits[0].lines_added > 0 || commits[0].lines_removed > 0);
648    }
649
650    #[test]
651    fn test_commit_info_clone() {
652        let original = CommitInfo {
653            hash: "abc123".to_string(),
654            message: "test".to_string(),
655            author: "test@example.com".to_string(),
656            timestamp: 1234567890,
657            files_changed: 1,
658            lines_added: 10,
659            lines_removed: 5,
660        };
661
662        let cloned = original.clone();
663
664        assert_eq!(original.hash, cloned.hash);
665        assert_eq!(original.message, cloned.message);
666        assert_eq!(original.author, cloned.author);
667        assert_eq!(original.timestamp, cloned.timestamp);
668        assert_eq!(original.files_changed, cloned.files_changed);
669        assert_eq!(original.lines_added, cloned.lines_added);
670        assert_eq!(original.lines_removed, cloned.lines_removed);
671    }
672
673    #[test]
674    fn test_commit_info_debug_format() {
675        let commit = CommitInfo {
676            hash: "abc123".to_string(),
677            message: "test".to_string(),
678            author: "test@example.com".to_string(),
679            timestamp: 1234567890,
680            files_changed: 1,
681            lines_added: 10,
682            lines_removed: 5,
683        };
684
685        let debug_str = format!("{:?}", commit);
686        assert!(debug_str.contains("abc123"));
687        assert!(debug_str.contains("test"));
688    }
689
690    #[test]
691    fn test_analyzer_with_path_ref() {
692        let temp_dir = TempDir::new().unwrap();
693        let path = temp_dir.path();
694
695        // Test that it accepts AsRef<Path>
696        let _analyzer = GitAnalyzer::new(path);
697        let _analyzer2 = GitAnalyzer::new(path.to_str().unwrap());
698    }
699
700    #[test]
701    fn test_analyze_empty_repository() {
702        let temp_dir = TempDir::new().unwrap();
703        let repo_path = temp_dir.path().join("empty-repo");
704        std::fs::create_dir(&repo_path).unwrap();
705
706        // Initialize empty repository (no commits)
707        Repository::init(&repo_path).unwrap();
708
709        let analyzer = GitAnalyzer::new(temp_dir.path());
710
711        // Analyzing empty repo should return error (no HEAD)
712        let result = analyzer.analyze_commits("empty-repo", 10);
713        assert!(result.is_err());
714    }
715
716    #[test]
717    fn test_analyze_commits_with_large_diff() {
718        let temp_dir = TempDir::new().unwrap();
719        let repo_path = temp_dir.path().join("large-diff-repo");
720        std::fs::create_dir(&repo_path).unwrap();
721
722        let repo = Repository::init(&repo_path).unwrap();
723
724        let mut config = repo.config().unwrap();
725        config.set_str("user.name", "Test").unwrap();
726        config.set_str("user.email", "test@test.com").unwrap();
727
728        // Create a file with many lines
729        let mut content = String::new();
730        for i in 0..1000 {
731            content.push_str(&format!("Line {}\n", i));
732        }
733
734        let file_path = repo_path.join("large.txt");
735        std::fs::write(&file_path, &content).unwrap();
736
737        let mut index = repo.index().unwrap();
738        index.add_path(Path::new("large.txt")).unwrap();
739        index.write().unwrap();
740
741        let tree_id = index.write_tree().unwrap();
742        let tree = repo.find_tree(tree_id).unwrap();
743        let sig = repo.signature().unwrap();
744
745        repo.commit(Some("HEAD"), &sig, &sig, "Large commit", &tree, &[])
746            .unwrap();
747
748        let analyzer = GitAnalyzer::new(temp_dir.path());
749        let commits = analyzer.analyze_commits("large-diff-repo", 10).unwrap();
750
751        assert_eq!(commits.len(), 1);
752        assert_eq!(commits[0].files_changed, 1);
753    }
754
755    #[test]
756    fn test_commit_info_with_empty_strings() {
757        let commit = CommitInfo {
758            hash: "".to_string(),
759            message: "".to_string(),
760            author: "".to_string(),
761            timestamp: 0,
762            files_changed: 0,
763            lines_added: 0,
764            lines_removed: 0,
765        };
766
767        assert_eq!(commit.hash, "");
768        assert_eq!(commit.message, "");
769        assert_eq!(commit.author, "");
770    }
771
772    #[test]
773    fn test_multiple_files_in_single_commit() {
774        let temp_dir = TempDir::new().unwrap();
775        let repo_path = temp_dir.path().join("multi-file-repo");
776        std::fs::create_dir(&repo_path).unwrap();
777
778        let repo = Repository::init(&repo_path).unwrap();
779
780        let mut config = repo.config().unwrap();
781        config.set_str("user.name", "Test").unwrap();
782        config.set_str("user.email", "test@test.com").unwrap();
783
784        // Create multiple files in one commit
785        for i in 0..5 {
786            let file_path = repo_path.join(format!("file{}.txt", i));
787            std::fs::write(&file_path, format!("content {}", i)).unwrap();
788        }
789
790        let mut index = repo.index().unwrap();
791        for i in 0..5 {
792            index
793                .add_path(Path::new(&format!("file{}.txt", i)))
794                .unwrap();
795        }
796        index.write().unwrap();
797
798        let tree_id = index.write_tree().unwrap();
799        let tree = repo.find_tree(tree_id).unwrap();
800        let sig = repo.signature().unwrap();
801
802        repo.commit(Some("HEAD"), &sig, &sig, "Add 5 files", &tree, &[])
803            .unwrap();
804
805        let analyzer = GitAnalyzer::new(temp_dir.path());
806        let commits = analyzer.analyze_commits("multi-file-repo", 10).unwrap();
807
808        assert_eq!(commits.len(), 1);
809        assert_eq!(commits[0].files_changed, 5);
810    }
811
812    #[test]
813    fn test_commit_with_deletions() {
814        let temp_dir = TempDir::new().unwrap();
815        let repo_path = temp_dir.path().join("deletion-repo");
816        std::fs::create_dir(&repo_path).unwrap();
817
818        let repo = Repository::init(&repo_path).unwrap();
819
820        let mut config = repo.config().unwrap();
821        config.set_str("user.name", "Test").unwrap();
822        config.set_str("user.email", "test@test.com").unwrap();
823
824        // First commit with content
825        let file_path = repo_path.join("file.txt");
826        std::fs::write(&file_path, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n").unwrap();
827
828        let mut index = repo.index().unwrap();
829        index.add_path(Path::new("file.txt")).unwrap();
830        index.write().unwrap();
831
832        let tree_id = index.write_tree().unwrap();
833        let tree = repo.find_tree(tree_id).unwrap();
834        let sig = repo.signature().unwrap();
835
836        repo.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])
837            .unwrap();
838
839        // Second commit with deletions
840        std::fs::write(&file_path, "Line 1\nLine 5\n").unwrap();
841
842        let mut index = repo.index().unwrap();
843        index.add_path(Path::new("file.txt")).unwrap();
844        index.write().unwrap();
845
846        let tree_id = index.write_tree().unwrap();
847        let tree = repo.find_tree(tree_id).unwrap();
848
849        let parent = repo.head().unwrap().peel_to_commit().unwrap();
850
851        repo.commit(Some("HEAD"), &sig, &sig, "Delete lines", &tree, &[&parent])
852            .unwrap();
853
854        let analyzer = GitAnalyzer::new(temp_dir.path());
855        let commits = analyzer.analyze_commits("deletion-repo", 10).unwrap();
856
857        assert_eq!(commits.len(), 2);
858        // First commit (most recent) should have deletions
859        assert!(commits[0].lines_removed > 0);
860    }
861
862    #[test]
863    fn test_commit_info_deserialization() {
864        let json = r#"{
865            "hash": "abc123",
866            "message": "test message",
867            "author": "test@example.com",
868            "timestamp": 1234567890,
869            "files_changed": 3,
870            "lines_added": 15,
871            "lines_removed": 8
872        }"#;
873
874        let commit: CommitInfo = serde_json::from_str(json).unwrap();
875
876        assert_eq!(commit.hash, "abc123");
877        assert_eq!(commit.message, "test message");
878        assert_eq!(commit.author, "test@example.com");
879        assert_eq!(commit.timestamp, 1234567890);
880        assert_eq!(commit.files_changed, 3);
881        assert_eq!(commit.lines_added, 15);
882        assert_eq!(commit.lines_removed, 8);
883    }
884}