infiniloom_engine/
git.rs

1//! Git integration for diff/log analysis
2//!
3//! Provides integration with Git for:
4//! - Getting changed files between commits
5//! - Extracting commit history
6//! - Blame information for file importance
7
8use std::path::Path;
9use std::process::Command;
10use thiserror::Error;
11
12/// Git repository wrapper
13pub struct GitRepo {
14    path: String,
15}
16
17/// A git commit entry
18#[derive(Debug, Clone)]
19pub struct Commit {
20    pub hash: String,
21    pub short_hash: String,
22    pub author: String,
23    pub email: String,
24    pub date: String,
25    pub message: String,
26}
27
28/// A file changed in a commit
29#[derive(Debug, Clone)]
30pub struct ChangedFile {
31    /// Current path (or new path for renames)
32    pub path: String,
33    /// Original path for renamed/copied files (None for add/modify/delete)
34    pub old_path: Option<String>,
35    pub status: FileStatus,
36    pub additions: u32,
37    pub deletions: u32,
38}
39
40/// File change status
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FileStatus {
43    Added,
44    Modified,
45    Deleted,
46    Renamed,
47    Copied,
48    Unknown,
49}
50
51impl FileStatus {
52    fn from_char(c: char) -> Self {
53        match c {
54            'A' => Self::Added,
55            'M' => Self::Modified,
56            'D' => Self::Deleted,
57            'R' => Self::Renamed,
58            'C' => Self::Copied,
59            _ => Self::Unknown,
60        }
61    }
62}
63
64/// Blame entry for a line
65#[derive(Debug, Clone)]
66pub struct BlameLine {
67    pub commit: String,
68    pub author: String,
69    pub date: String,
70    pub line_number: u32,
71}
72
73/// Type of line change in a diff
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DiffLineType {
76    /// Line was added
77    Add,
78    /// Line was removed
79    Remove,
80    /// Context line (unchanged)
81    Context,
82}
83
84impl DiffLineType {
85    /// Get string representation
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            Self::Add => "add",
89            Self::Remove => "remove",
90            Self::Context => "context",
91        }
92    }
93}
94
95/// A single line change within a diff hunk
96#[derive(Debug, Clone)]
97pub struct DiffLine {
98    /// Type of change: add, remove, or context
99    pub change_type: DiffLineType,
100    /// Line number in the old file (None for additions)
101    pub old_line: Option<u32>,
102    /// Line number in the new file (None for deletions)
103    pub new_line: Option<u32>,
104    /// The actual line content (without +/- prefix)
105    pub content: String,
106}
107
108/// A diff hunk representing a contiguous block of changes
109#[derive(Debug, Clone)]
110pub struct DiffHunk {
111    /// Starting line in the old file
112    pub old_start: u32,
113    /// Number of lines in the old file section
114    pub old_count: u32,
115    /// Starting line in the new file
116    pub new_start: u32,
117    /// Number of lines in the new file section
118    pub new_count: u32,
119    /// Header line (e.g., "@@ -1,5 +1,7 @@ function name")
120    pub header: String,
121    /// Individual line changes within this hunk
122    pub lines: Vec<DiffLine>,
123}
124
125/// Git errors
126#[derive(Debug, Error)]
127pub enum GitError {
128    #[error("Not a git repository")]
129    NotAGitRepo,
130    #[error("Git command failed: {0}")]
131    CommandFailed(String),
132    #[error("Parse error: {0}")]
133    ParseError(String),
134}
135
136impl GitRepo {
137    /// Open a git repository
138    pub fn open(path: &Path) -> Result<Self, GitError> {
139        let git_dir = path.join(".git");
140        if !git_dir.exists() {
141            return Err(GitError::NotAGitRepo);
142        }
143
144        Ok(Self { path: path.to_string_lossy().to_string() })
145    }
146
147    /// Check if path is a git repository
148    pub fn is_git_repo(path: &Path) -> bool {
149        path.join(".git").exists()
150    }
151
152    /// Get current branch name
153    pub fn current_branch(&self) -> Result<String, GitError> {
154        let output = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
155        Ok(output.trim().to_owned())
156    }
157
158    /// Get current commit hash
159    pub fn current_commit(&self) -> Result<String, GitError> {
160        let output = self.run_git(&["rev-parse", "HEAD"])?;
161        Ok(output.trim().to_owned())
162    }
163
164    /// Get short commit hash
165    pub fn short_hash(&self, commit: &str) -> Result<String, GitError> {
166        let output = self.run_git(&["rev-parse", "--short", commit])?;
167        Ok(output.trim().to_owned())
168    }
169
170    /// Get files changed between two commits
171    pub fn diff_files(&self, from: &str, to: &str) -> Result<Vec<ChangedFile>, GitError> {
172        // First get file status with --name-status (shows A/M/D/R/C status)
173        let status_output = self.run_git(&["diff", "--name-status", from, to])?;
174
175        // Then get line counts with --numstat (shows additions/deletions)
176        let numstat_output = self.run_git(&["diff", "--numstat", from, to])?;
177
178        // Build a map of path -> (additions, deletions) from numstat
179        let mut stats: std::collections::HashMap<String, (u32, u32)> =
180            std::collections::HashMap::new();
181        for line in numstat_output.lines() {
182            if line.is_empty() {
183                continue;
184            }
185            let parts: Vec<&str> = line.split('\t').collect();
186            if parts.len() >= 3 {
187                // numstat format: additions<TAB>deletions<TAB>path
188                // Binary files show "-" for additions/deletions
189                let add = parts[0].parse::<u32>().unwrap_or(0);
190                let del = parts[1].parse::<u32>().unwrap_or(0);
191                let path = parts[2..].join("\t");
192                stats.insert(path, (add, del));
193            }
194        }
195
196        let mut files = Vec::new();
197
198        // Parse name-status output
199        for line in status_output.lines() {
200            if line.is_empty() {
201                continue;
202            }
203
204            let parts: Vec<&str> = line.split('\t').collect();
205            if parts.is_empty() {
206                continue;
207            }
208
209            let status_str = parts[0];
210            let first_char = status_str.chars().next().unwrap_or(' ');
211            let status = FileStatus::from_char(first_char);
212
213            // Handle renamed/copied files: R100 or C100 followed by old_path and new_path
214            let (path, old_path) = if (first_char == 'R' || first_char == 'C') && parts.len() >= 3 {
215                // For renames: parts[1] = old_path, parts[2] = new_path
216                (parts[2].to_owned(), Some(parts[1].to_owned()))
217            } else if parts.len() >= 2 {
218                // For other statuses: parts[1] is the path
219                (parts[1].to_owned(), None)
220            } else {
221                continue;
222            };
223
224            // Look up line statistics
225            let (additions, deletions) = stats.get(&path).copied().unwrap_or((0, 0));
226
227            files.push(ChangedFile { path, old_path, status, additions, deletions });
228        }
229
230        Ok(files)
231    }
232
233    /// Get files changed in working tree
234    ///
235    /// Returns both staged and unstaged changes. For renames, the `old_path`
236    /// field contains the original filename.
237    pub fn status(&self) -> Result<Vec<ChangedFile>, GitError> {
238        let output = self.run_git(&["status", "--porcelain"])?;
239
240        let mut files = Vec::new();
241
242        for line in output.lines() {
243            if line.len() < 3 {
244                continue;
245            }
246
247            // Git status --porcelain format: XY filename
248            // X = staged status, Y = unstaged status
249            let staged_char = line.chars().next().unwrap_or(' ');
250            let unstaged_char = line.chars().nth(1).unwrap_or(' ');
251            let path_part = &line[3..];
252
253            // Determine the effective status (prefer staged, then unstaged)
254            let (status, status_char) = if staged_char != ' ' && staged_char != '?' {
255                // Has staged changes
256                (
257                    match staged_char {
258                        'A' => FileStatus::Added,
259                        'M' => FileStatus::Modified,
260                        'D' => FileStatus::Deleted,
261                        'R' => FileStatus::Renamed,
262                        'C' => FileStatus::Copied,
263                        _ => FileStatus::Unknown,
264                    },
265                    staged_char,
266                )
267            } else {
268                // Only unstaged changes
269                (
270                    match unstaged_char {
271                        '?' | 'A' => FileStatus::Added,
272                        'M' => FileStatus::Modified,
273                        'D' => FileStatus::Deleted,
274                        'R' => FileStatus::Renamed,
275                        _ => FileStatus::Unknown,
276                    },
277                    unstaged_char,
278                )
279            };
280
281            // Handle renames: format is "old_path -> new_path"
282            let (path, old_path) = if status_char == 'R' || status_char == 'C' {
283                if let Some(arrow_pos) = path_part.find(" -> ") {
284                    let old = path_part[..arrow_pos].to_owned();
285                    let new = path_part[arrow_pos + 4..].to_owned();
286                    (new, Some(old))
287                } else {
288                    (path_part.to_owned(), None)
289                }
290            } else {
291                (path_part.to_owned(), None)
292            };
293
294            files.push(ChangedFile { path, old_path, status, additions: 0, deletions: 0 });
295        }
296
297        Ok(files)
298    }
299
300    /// Get recent commits
301    pub fn log(&self, count: usize) -> Result<Vec<Commit>, GitError> {
302        let output = self.run_git(&[
303            "log",
304            &format!("-{}", count),
305            "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
306            "--date=short",
307        ])?;
308
309        let mut commits = Vec::new();
310        let mut lines = output.lines().peekable();
311
312        while lines.peek().is_some() {
313            let hash = lines.next().unwrap_or("").to_owned();
314            if hash.is_empty() {
315                continue;
316            }
317
318            let short_hash = lines.next().unwrap_or("").to_owned();
319            let author = lines.next().unwrap_or("").to_owned();
320            let email = lines.next().unwrap_or("").to_owned();
321            let date = lines.next().unwrap_or("").to_owned();
322            let message = lines.next().unwrap_or("").to_owned();
323
324            // Skip separator
325            while lines.peek().map(|l| *l != "---COMMIT---").unwrap_or(false) {
326                lines.next();
327            }
328            lines.next(); // Skip the separator
329
330            commits.push(Commit { hash, short_hash, author, email, date, message });
331        }
332
333        Ok(commits)
334    }
335
336    /// Get commits that modified a specific file
337    pub fn file_log(&self, path: &str, count: usize) -> Result<Vec<Commit>, GitError> {
338        let output = self.run_git(&[
339            "log",
340            &format!("-{}", count),
341            "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
342            "--date=short",
343            "--follow",
344            "--",
345            path,
346        ])?;
347
348        let mut commits = Vec::new();
349        let commit_blocks: Vec<&str> = output.split("---COMMIT---").collect();
350
351        for block in commit_blocks {
352            let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect();
353            if lines.len() < 6 {
354                continue;
355            }
356
357            commits.push(Commit {
358                hash: lines[0].to_owned(),
359                short_hash: lines[1].to_owned(),
360                author: lines[2].to_owned(),
361                email: lines[3].to_owned(),
362                date: lines[4].to_owned(),
363                message: lines[5].to_owned(),
364            });
365        }
366
367        Ok(commits)
368    }
369
370    /// Get blame information for a file
371    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>, GitError> {
372        let output = self.run_git(&["blame", "--porcelain", path])?;
373
374        let mut lines = Vec::new();
375        let mut current_commit = String::new();
376        let mut current_author = String::new();
377        let mut current_date = String::new();
378        let mut line_number = 0u32;
379
380        for line in output.lines() {
381            if line.starts_with('\t') {
382                // This is the actual line content, create blame entry
383                lines.push(BlameLine {
384                    commit: current_commit.clone(),
385                    author: current_author.clone(),
386                    date: current_date.clone(),
387                    line_number,
388                });
389            } else if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
390                // New commit hash line
391                let parts: Vec<&str> = line.split_whitespace().collect();
392                if !parts.is_empty() {
393                    current_commit = parts[0][..8.min(parts[0].len())].to_string();
394                    if parts.len() >= 3 {
395                        line_number = parts[2].parse().unwrap_or(0);
396                    }
397                }
398            } else if let Some(author) = line.strip_prefix("author ") {
399                current_author = author.to_owned();
400            } else if let Some(time) = line.strip_prefix("author-time ") {
401                // Convert Unix timestamp to date
402                if let Ok(ts) = time.parse::<i64>() {
403                    current_date = format_timestamp(ts);
404                }
405            }
406        }
407
408        Ok(lines)
409    }
410
411    /// Get list of files tracked by git
412    pub fn ls_files(&self) -> Result<Vec<String>, GitError> {
413        let output = self.run_git(&["ls-files"])?;
414        Ok(output.lines().map(String::from).collect())
415    }
416
417    /// Get diff content between two commits for a file
418    pub fn diff_content(&self, from: &str, to: &str, path: &str) -> Result<String, GitError> {
419        self.run_git(&["diff", from, to, "--", path])
420    }
421
422    /// Get diff content for uncommitted changes (working tree vs HEAD)
423    /// Includes both staged and unstaged changes.
424    pub fn uncommitted_diff(&self, path: &str) -> Result<String, GitError> {
425        // Get both staged and unstaged changes combined
426        self.run_git(&["diff", "HEAD", "--", path])
427    }
428
429    /// Get diff content for all uncommitted changes
430    /// Returns combined diff for all changed files.
431    pub fn all_uncommitted_diffs(&self) -> Result<String, GitError> {
432        self.run_git(&["diff", "HEAD"])
433    }
434
435    /// Check if a file has uncommitted changes
436    pub fn has_changes(&self, path: &str) -> Result<bool, GitError> {
437        let output = self.run_git(&["status", "--porcelain", "--", path])?;
438        Ok(!output.trim().is_empty())
439    }
440
441    /// Get the commit where a file was last modified
442    pub fn last_modified_commit(&self, path: &str) -> Result<Commit, GitError> {
443        let commits = self.file_log(path, 1)?;
444        commits
445            .into_iter()
446            .next()
447            .ok_or_else(|| GitError::ParseError("No commits found".to_owned()))
448    }
449
450    /// Calculate file importance based on recent changes
451    pub fn file_change_frequency(&self, path: &str, days: u32) -> Result<u32, GitError> {
452        let output = self.run_git(&[
453            "log",
454            &format!("--since={} days ago", days),
455            "--oneline",
456            "--follow",
457            "--",
458            path,
459        ])?;
460
461        Ok(output.lines().count() as u32)
462    }
463
464    /// Get file content at a specific git ref (commit, branch, tag)
465    ///
466    /// Uses `git show <ref>:<path>` to retrieve file content at that revision.
467    ///
468    /// # Arguments
469    /// * `path` - File path relative to repository root
470    /// * `git_ref` - Git ref (commit hash, branch name, tag, HEAD~n, etc.)
471    ///
472    /// # Returns
473    /// File content as string, or error if file doesn't exist at that ref
474    ///
475    /// # Example
476    /// ```ignore
477    /// let repo = GitRepo::open(Path::new("."))?;
478    /// let content = repo.file_at_ref("src/main.rs", "HEAD~5")?;
479    /// ```
480    pub fn file_at_ref(&self, path: &str, git_ref: &str) -> Result<String, GitError> {
481        self.run_git(&["show", &format!("{}:{}", git_ref, path)])
482    }
483
484    /// Parse diff between two refs into structured hunks
485    ///
486    /// Returns detailed hunk information including line numbers for each change.
487    ///
488    /// # Arguments
489    /// * `from_ref` - Starting ref (e.g., "main", "HEAD~5", commit hash)
490    /// * `to_ref` - Ending ref (e.g., "HEAD", "feature-branch")
491    /// * `path` - Optional file path to filter to a single file
492    ///
493    /// # Returns
494    /// Vec of DiffHunk with structured line-level information
495    pub fn diff_hunks(
496        &self,
497        from_ref: &str,
498        to_ref: &str,
499        path: Option<&str>,
500    ) -> Result<Vec<DiffHunk>, GitError> {
501        let output = match path {
502            Some(p) => self.run_git(&["diff", "-U3", from_ref, to_ref, "--", p])?,
503            None => self.run_git(&["diff", "-U3", from_ref, to_ref])?,
504        };
505
506        parse_diff_hunks(&output)
507    }
508
509    /// Parse uncommitted changes (working tree vs HEAD) into structured hunks
510    ///
511    /// # Arguments
512    /// * `path` - Optional file path to filter to a single file
513    ///
514    /// # Returns
515    /// Vec of DiffHunk for uncommitted changes
516    pub fn uncommitted_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
517        let output = match path {
518            Some(p) => self.run_git(&["diff", "-U3", "HEAD", "--", p])?,
519            None => self.run_git(&["diff", "-U3", "HEAD"])?,
520        };
521
522        parse_diff_hunks(&output)
523    }
524
525    /// Parse staged changes into structured hunks
526    ///
527    /// # Arguments
528    /// * `path` - Optional file path to filter to a single file
529    ///
530    /// # Returns
531    /// Vec of DiffHunk for staged changes only
532    pub fn staged_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
533        let output = match path {
534            Some(p) => self.run_git(&["diff", "-U3", "--staged", "--", p])?,
535            None => self.run_git(&["diff", "-U3", "--staged"])?,
536        };
537
538        parse_diff_hunks(&output)
539    }
540
541    /// Run a git command and return output
542    fn run_git(&self, args: &[&str]) -> Result<String, GitError> {
543        let output = Command::new("git")
544            .current_dir(&self.path)
545            .args(args)
546            .output()
547            .map_err(|e| GitError::CommandFailed(e.to_string()))?;
548
549        if !output.status.success() {
550            let stderr = String::from_utf8_lossy(&output.stderr);
551            return Err(GitError::CommandFailed(stderr.to_string()));
552        }
553
554        String::from_utf8(output.stdout).map_err(|e| GitError::ParseError(e.to_string()))
555    }
556}
557
558/// Format Unix timestamp as YYYY-MM-DD
559fn format_timestamp(ts: i64) -> String {
560    // Simple formatting without chrono
561    let secs_per_day = 86400;
562    let days_since_epoch = ts / secs_per_day;
563
564    // Approximate calculation (doesn't account for leap seconds)
565    let mut year = 1970;
566    let mut remaining_days = days_since_epoch;
567
568    loop {
569        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
570        if remaining_days < days_in_year {
571            break;
572        }
573        remaining_days -= days_in_year;
574        year += 1;
575    }
576
577    let days_in_months = if is_leap_year(year) {
578        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
579    } else {
580        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
581    };
582
583    let mut month = 1;
584    for days in days_in_months {
585        if remaining_days < days {
586            break;
587        }
588        remaining_days -= days;
589        month += 1;
590    }
591
592    let day = remaining_days + 1;
593
594    format!("{:04}-{:02}-{:02}", year, month, day)
595}
596
597fn is_leap_year(year: i64) -> bool {
598    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
599}
600
601/// Parse unified diff output into structured hunks
602///
603/// Handles the standard unified diff format with hunk headers like:
604/// `@@ -start,count +start,count @@ optional context`
605fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>, GitError> {
606    let mut hunks = Vec::new();
607    let mut current_hunk: Option<DiffHunk> = None;
608    let mut old_line = 0u32;
609    let mut new_line = 0u32;
610
611    for line in diff_output.lines() {
612        // Check for hunk header: @@ -old_start,old_count +new_start,new_count @@ context
613        if line.starts_with("@@") {
614            // Save previous hunk if exists
615            if let Some(hunk) = current_hunk.take() {
616                hunks.push(hunk);
617            }
618
619            // Parse hunk header
620            if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
621                old_line = old_start;
622                new_line = new_start;
623
624                current_hunk = Some(DiffHunk {
625                    old_start,
626                    old_count,
627                    new_start,
628                    new_count,
629                    header: line.to_owned(),
630                    lines: Vec::new(),
631                });
632            }
633        } else if let Some(ref mut hunk) = current_hunk {
634            // Parse line within a hunk
635            if let Some(first_char) = line.chars().next() {
636                let (change_type, content) = match first_char {
637                    '+' => (DiffLineType::Add, line[1..].to_owned()),
638                    '-' => (DiffLineType::Remove, line[1..].to_owned()),
639                    ' ' => (DiffLineType::Context, line[1..].to_owned()),
640                    '\\' => continue, // "\ No newline at end of file"
641                    _ => continue,    // Skip diff headers (diff --git, index, ---, +++)
642                };
643
644                let (old_ln, new_ln) = match change_type {
645                    DiffLineType::Add => {
646                        let nl = new_line;
647                        new_line += 1;
648                        (None, Some(nl))
649                    },
650                    DiffLineType::Remove => {
651                        let ol = old_line;
652                        old_line += 1;
653                        (Some(ol), None)
654                    },
655                    DiffLineType::Context => {
656                        let ol = old_line;
657                        let nl = new_line;
658                        old_line += 1;
659                        new_line += 1;
660                        (Some(ol), Some(nl))
661                    },
662                };
663
664                hunk.lines.push(DiffLine {
665                    change_type,
666                    old_line: old_ln,
667                    new_line: new_ln,
668                    content,
669                });
670            }
671        }
672    }
673
674    // Push final hunk
675    if let Some(hunk) = current_hunk {
676        hunks.push(hunk);
677    }
678
679    Ok(hunks)
680}
681
682/// Parse a hunk header line into (old_start, old_count, new_start, new_count)
683///
684/// Format: @@ -old_start,old_count +new_start,new_count @@ optional_context
685/// Note: count defaults to 1 if omitted (e.g., @@ -5 +5,2 @@)
686fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
687    // Find the range specifications between @@ markers
688    let header = header.strip_prefix("@@")?;
689    let end_idx = header.find("@@")?;
690    let range_part = header[..end_idx].trim();
691
692    let parts: Vec<&str> = range_part.split_whitespace().collect();
693    if parts.len() < 2 {
694        return None;
695    }
696
697    // Parse old range: -start,count or -start
698    let old_part = parts[0].strip_prefix('-')?;
699    let (old_start, old_count) = parse_range(old_part)?;
700
701    // Parse new range: +start,count or +start
702    let new_part = parts[1].strip_prefix('+')?;
703    let (new_start, new_count) = parse_range(new_part)?;
704
705    Some((old_start, old_count, new_start, new_count))
706}
707
708/// Parse a range specification like "5,3" or "5" into (start, count)
709fn parse_range(range: &str) -> Option<(u32, u32)> {
710    if let Some((start_str, count_str)) = range.split_once(',') {
711        let start = start_str.parse().ok()?;
712        let count = count_str.parse().ok()?;
713        Some((start, count))
714    } else {
715        let start = range.parse().ok()?;
716        Some((start, 1)) // Default count is 1
717    }
718}
719
720#[cfg(test)]
721#[allow(clippy::str_to_string)]
722mod tests {
723    use super::*;
724    use std::process::Command;
725    use tempfile::TempDir;
726
727    fn init_test_repo() -> TempDir {
728        let temp = TempDir::new().unwrap();
729
730        // Initialize git repo
731        Command::new("git")
732            .current_dir(temp.path())
733            .args(["init"])
734            .output()
735            .unwrap();
736
737        // Configure git
738        Command::new("git")
739            .current_dir(temp.path())
740            .args(["config", "user.email", "test@test.com"])
741            .output()
742            .unwrap();
743
744        Command::new("git")
745            .current_dir(temp.path())
746            .args(["config", "user.name", "Test"])
747            .output()
748            .unwrap();
749
750        // Create a file and commit
751        std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
752
753        Command::new("git")
754            .current_dir(temp.path())
755            .args(["add", "."])
756            .output()
757            .unwrap();
758
759        Command::new("git")
760            .current_dir(temp.path())
761            .args(["commit", "-m", "Initial commit"])
762            .output()
763            .unwrap();
764
765        temp
766    }
767
768    #[test]
769    fn test_open_repo() {
770        let temp = init_test_repo();
771        let repo = GitRepo::open(temp.path());
772        assert!(repo.is_ok());
773    }
774
775    #[test]
776    fn test_not_a_repo() {
777        let temp = TempDir::new().unwrap();
778        let repo = GitRepo::open(temp.path());
779        assert!(matches!(repo, Err(GitError::NotAGitRepo)));
780    }
781
782    #[test]
783    fn test_current_branch() {
784        let temp = init_test_repo();
785        let repo = GitRepo::open(temp.path()).unwrap();
786        let branch = repo.current_branch().unwrap();
787        // Branch could be "main" or "master" depending on git config
788        assert!(!branch.is_empty());
789    }
790
791    #[test]
792    fn test_log() {
793        let temp = init_test_repo();
794        let repo = GitRepo::open(temp.path()).unwrap();
795        let commits = repo.log(10).unwrap();
796        assert!(!commits.is_empty());
797        assert_eq!(commits[0].message, "Initial commit");
798    }
799
800    #[test]
801    fn test_ls_files() {
802        let temp = init_test_repo();
803        let repo = GitRepo::open(temp.path()).unwrap();
804        let files = repo.ls_files().unwrap();
805        assert!(files.contains(&"test.txt".to_string()));
806    }
807
808    #[test]
809    fn test_format_timestamp() {
810        // 2024-01-01 00:00:00 UTC
811        let ts = 1704067200;
812        let date = format_timestamp(ts);
813        assert_eq!(date, "2024-01-01");
814    }
815
816    #[test]
817    fn test_file_at_ref() {
818        let temp = init_test_repo();
819        let repo = GitRepo::open(temp.path()).unwrap();
820
821        // Get file content at HEAD
822        let content = repo.file_at_ref("test.txt", "HEAD").unwrap();
823        assert_eq!(content.trim(), "hello");
824
825        // Modify the file and commit
826        std::fs::write(temp.path().join("test.txt"), "world").unwrap();
827        Command::new("git")
828            .current_dir(temp.path())
829            .args(["add", "."])
830            .output()
831            .unwrap();
832        Command::new("git")
833            .current_dir(temp.path())
834            .args(["commit", "-m", "Update"])
835            .output()
836            .unwrap();
837
838        // Check current HEAD has new content
839        let new_content = repo.file_at_ref("test.txt", "HEAD").unwrap();
840        assert_eq!(new_content.trim(), "world");
841
842        // Check HEAD~1 still has old content
843        let old_content = repo.file_at_ref("test.txt", "HEAD~1").unwrap();
844        assert_eq!(old_content.trim(), "hello");
845    }
846
847    #[test]
848    fn test_parse_hunk_header() {
849        // Standard case
850        let result = parse_hunk_header("@@ -1,5 +1,7 @@ fn main()");
851        assert_eq!(result, Some((1, 5, 1, 7)));
852
853        // No count (defaults to 1)
854        let result = parse_hunk_header("@@ -1 +1 @@");
855        assert_eq!(result, Some((1, 1, 1, 1)));
856
857        // Mixed
858        let result = parse_hunk_header("@@ -10,3 +15 @@");
859        assert_eq!(result, Some((10, 3, 15, 1)));
860
861        // Invalid
862        let result = parse_hunk_header("not a header");
863        assert_eq!(result, None);
864    }
865
866    #[test]
867    fn test_parse_diff_hunks() {
868        let diff = r#"diff --git a/test.txt b/test.txt
869index abc123..def456 100644
870--- a/test.txt
871+++ b/test.txt
872@@ -1,3 +1,4 @@
873 line 1
874-old line 2
875+new line 2
876+added line
877 line 3
878"#;
879
880        let hunks = parse_diff_hunks(diff).unwrap();
881        assert_eq!(hunks.len(), 1);
882
883        let hunk = &hunks[0];
884        assert_eq!(hunk.old_start, 1);
885        assert_eq!(hunk.old_count, 3);
886        assert_eq!(hunk.new_start, 1);
887        assert_eq!(hunk.new_count, 4);
888        assert_eq!(hunk.lines.len(), 5);
889
890        // Check line types
891        assert_eq!(hunk.lines[0].change_type, DiffLineType::Context);
892        assert_eq!(hunk.lines[1].change_type, DiffLineType::Remove);
893        assert_eq!(hunk.lines[2].change_type, DiffLineType::Add);
894        assert_eq!(hunk.lines[3].change_type, DiffLineType::Add);
895        assert_eq!(hunk.lines[4].change_type, DiffLineType::Context);
896
897        // Check line numbers
898        assert_eq!(hunk.lines[0].old_line, Some(1));
899        assert_eq!(hunk.lines[0].new_line, Some(1));
900        assert_eq!(hunk.lines[1].old_line, Some(2));
901        assert_eq!(hunk.lines[1].new_line, None);
902        assert_eq!(hunk.lines[2].old_line, None);
903        assert_eq!(hunk.lines[2].new_line, Some(2));
904    }
905
906    #[test]
907    fn test_diff_hunks() {
908        let temp = init_test_repo();
909        let repo = GitRepo::open(temp.path()).unwrap();
910
911        // Modify file and commit
912        std::fs::write(temp.path().join("test.txt"), "hello\nworld\n").unwrap();
913        Command::new("git")
914            .current_dir(temp.path())
915            .args(["add", "."])
916            .output()
917            .unwrap();
918        Command::new("git")
919            .current_dir(temp.path())
920            .args(["commit", "-m", "Add world"])
921            .output()
922            .unwrap();
923
924        // Get hunks between commits
925        let hunks = repo.diff_hunks("HEAD~1", "HEAD", Some("test.txt")).unwrap();
926        assert!(!hunks.is_empty());
927
928        // Verify we got structured data
929        let hunk = &hunks[0];
930        assert!(hunk.old_start > 0);
931        assert!(!hunk.header.is_empty());
932    }
933
934    #[test]
935    fn test_uncommitted_hunks() {
936        let temp = init_test_repo();
937        let repo = GitRepo::open(temp.path()).unwrap();
938
939        // Make uncommitted change
940        std::fs::write(temp.path().join("test.txt"), "modified content").unwrap();
941
942        let hunks = repo.uncommitted_hunks(Some("test.txt")).unwrap();
943        assert!(!hunks.is_empty());
944
945        // Should have some changes
946        let total_changes: usize = hunks.iter().map(|h| h.lines.len()).sum();
947        assert!(total_changes > 0);
948    }
949}