Skip to main content

ralph/git/
lfs.rs

1//! Git LFS (Large File Storage) operations and validation.
2//!
3//! This module provides functions for detecting, validating, and managing Git LFS
4//! in repositories. It includes health checks, filter validation, and pointer file
5//! validation.
6//!
7//! # Invariants
8//! - Gracefully handles repositories without LFS (returns empty results, not errors)
9//! - LFS pointer files are validated against the spec format
10//!
11//! # What this does NOT handle
12//! - Regular git operations (see git/status.rs, git/commit.rs)
13//! - Repository cleanliness checks (see git/clean.rs)
14
15use crate::constants::defaults::LFS_POINTER_PREFIX;
16use crate::constants::limits::MAX_POINTER_SIZE;
17use crate::git::error::{GitError, git_output};
18use anyhow::{Context, Result};
19use std::fs;
20use std::path::Path;
21
22/// LFS filter configuration status.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LfsFilterStatus {
25    /// Whether the smudge filter is installed.
26    pub smudge_installed: bool,
27    /// Whether the clean filter is installed.
28    pub clean_installed: bool,
29    /// The value of the smudge filter (e.g. "git-lfs smudge %f").
30    pub smudge_value: Option<String>,
31    /// The value of the clean filter (e.g. "git-lfs clean %f").
32    pub clean_value: Option<String>,
33}
34
35impl LfsFilterStatus {
36    /// Returns true if both smudge and clean filters are installed.
37    pub fn is_healthy(&self) -> bool {
38        self.smudge_installed && self.clean_installed
39    }
40
41    /// Returns a human-readable description of any issues.
42    pub fn issues(&self) -> Vec<String> {
43        let mut issues = Vec::new();
44        if !self.smudge_installed {
45            issues.push("LFS smudge filter not configured".to_string());
46        }
47        if !self.clean_installed {
48            issues.push("LFS clean filter not configured".to_string());
49        }
50        issues
51    }
52}
53
54/// Summary of LFS status from `git lfs status`.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct LfsStatusSummary {
57    /// Files staged as LFS pointers (correctly tracked).
58    pub staged_lfs: Vec<String>,
59    /// Files staged that should be LFS but are being committed as regular files.
60    pub staged_not_lfs: Vec<String>,
61    /// Files not staged that have LFS modifications.
62    pub unstaged_lfs: Vec<String>,
63    /// Files with LFS attributes in .gitattributes but not tracked by LFS.
64    pub untracked_attributes: Vec<String>,
65}
66
67impl LfsStatusSummary {
68    /// Returns true if there are no LFS issues.
69    pub fn is_clean(&self) -> bool {
70        self.staged_not_lfs.is_empty()
71            && self.untracked_attributes.is_empty()
72            && self.unstaged_lfs.is_empty()
73    }
74
75    /// Returns a list of human-readable issue descriptions.
76    pub fn issue_descriptions(&self) -> Vec<String> {
77        let mut issues = Vec::new();
78
79        if !self.staged_not_lfs.is_empty() {
80            issues.push(format!(
81                "Files staged as regular files but should be LFS: {}",
82                self.staged_not_lfs.join(", ")
83            ));
84        }
85
86        if !self.untracked_attributes.is_empty() {
87            issues.push(format!(
88                "Files match .gitattributes LFS patterns but are not tracked by LFS: {}",
89                self.untracked_attributes.join(", ")
90            ));
91        }
92
93        if !self.unstaged_lfs.is_empty() {
94            issues.push(format!(
95                "Modified LFS files not staged: {}",
96                self.unstaged_lfs.join(", ")
97            ));
98        }
99
100        issues
101    }
102}
103
104/// Issue detected with an LFS pointer file.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum LfsPointerIssue {
107    /// File is not a valid LFS pointer (missing or invalid header).
108    InvalidPointer { path: String, reason: String },
109    /// File should be an LFS pointer but contains binary content (smudge filter not working).
110    BinaryContent { path: String },
111    /// Pointer file appears corrupted (invalid format).
112    Corrupted {
113        path: String,
114        content_preview: String,
115    },
116}
117
118impl LfsPointerIssue {
119    /// Returns the path of the file with the issue.
120    pub fn path(&self) -> &str {
121        match self {
122            LfsPointerIssue::InvalidPointer { path, .. } => path,
123            LfsPointerIssue::BinaryContent { path } => path,
124            LfsPointerIssue::Corrupted { path, .. } => path,
125        }
126    }
127
128    /// Returns a human-readable description of the issue.
129    pub fn description(&self) -> String {
130        match self {
131            LfsPointerIssue::InvalidPointer { path, reason } => {
132                format!("Invalid LFS pointer for '{}': {}", path, reason)
133            }
134            LfsPointerIssue::BinaryContent { path } => {
135                format!(
136                    "'{}' contains binary content but should be an LFS pointer (smudge filter may not be working)",
137                    path
138                )
139            }
140            LfsPointerIssue::Corrupted {
141                path,
142                content_preview,
143            } => {
144                format!(
145                    "Corrupted LFS pointer for '{}': preview='{}'",
146                    path, content_preview
147                )
148            }
149        }
150    }
151}
152
153/// Comprehensive LFS health check result.
154#[derive(Debug, Clone, PartialEq, Eq, Default)]
155pub struct LfsHealthReport {
156    /// Whether LFS is initialized in the repository.
157    pub lfs_initialized: bool,
158    /// Status of LFS filters.
159    pub filter_status: Option<LfsFilterStatus>,
160    /// Summary from `git lfs status`.
161    pub status_summary: Option<LfsStatusSummary>,
162    /// Pointer validation issues.
163    pub pointer_issues: Vec<LfsPointerIssue>,
164}
165
166impl LfsHealthReport {
167    /// Returns true if LFS is fully healthy.
168    ///
169    /// When `lfs_initialized` is true, missing required sub-results
170    /// (`filter_status` or `status_summary`) are treated as unhealthy
171    /// since they indicate the health check could not complete.
172    pub fn is_healthy(&self) -> bool {
173        if !self.lfs_initialized {
174            return true; // No LFS is also "healthy" (nothing to check)
175        }
176
177        let Some(ref filter) = self.filter_status else {
178            return false;
179        };
180        if !filter.is_healthy() {
181            return false;
182        }
183
184        let Some(ref status) = self.status_summary else {
185            return false;
186        };
187        if !status.is_clean() {
188            return false;
189        }
190
191        self.pointer_issues.is_empty()
192    }
193
194    /// Returns a list of all issues found.
195    pub fn all_issues(&self) -> Vec<String> {
196        let mut issues = Vec::new();
197
198        if let Some(ref filter) = self.filter_status {
199            issues.extend(filter.issues());
200        }
201
202        if let Some(ref status) = self.status_summary {
203            issues.extend(status.issue_descriptions());
204        }
205
206        for issue in &self.pointer_issues {
207            issues.push(issue.description());
208        }
209
210        issues
211    }
212}
213
214/// Detects if Git LFS is initialized in the repository.
215pub fn has_lfs(repo_root: &Path) -> Result<bool> {
216    // Check for .git/lfs directory first
217    let git_lfs_dir = repo_root.join(".git/lfs");
218    if git_lfs_dir.is_dir() {
219        return Ok(true);
220    }
221
222    // Check .gitattributes for LFS filter patterns
223    let gitattributes = repo_root.join(".gitattributes");
224    if gitattributes.is_file() {
225        let content = fs::read_to_string(&gitattributes)
226            .with_context(|| format!("read .gitattributes in {}", repo_root.display()))?;
227        return Ok(content.contains("filter=lfs"));
228    }
229
230    Ok(false)
231}
232
233/// Returns a list of LFS-tracked files in the repository.
234pub fn list_lfs_files(repo_root: &Path) -> Result<Vec<String>> {
235    let output = git_output(repo_root, &["lfs", "ls-files"])
236        .with_context(|| format!("run git lfs ls-files in {}", repo_root.display()))?;
237
238    if !output.status.success() {
239        let stderr = String::from_utf8_lossy(&output.stderr);
240        // If LFS is not installed or initialized, return empty list
241        if stderr.contains("not a git lfs repository")
242            || stderr.contains("git: lfs is not a git command")
243        {
244            return Ok(Vec::new());
245        }
246        return Err(GitError::CommandFailed {
247            args: "lfs ls-files".to_string(),
248            code: output.status.code(),
249            stderr: stderr.trim().to_string(),
250        }
251        .into());
252    }
253
254    let stdout = String::from_utf8_lossy(&output.stdout);
255    let mut files = Vec::new();
256
257    // Parse git lfs ls-files output format:
258    // each line is: "SHA256 * path/to/file"
259    for line in stdout.lines() {
260        if let Some((_, path)) = line.rsplit_once(" * ") {
261            files.push(path.to_string());
262        }
263    }
264
265    Ok(files)
266}
267
268/// Validates that LFS smudge/clean filters are properly installed in git config.
269///
270/// This function checks the git configuration for the required LFS filters:
271/// - `filter.lfs.smudge` should be set (typically to "git-lfs smudge %f")
272/// - `filter.lfs.clean` should be set (typically to "git-lfs clean %f")
273///
274/// # Arguments
275/// * `repo_root` - Path to the repository root
276///
277/// # Returns
278/// * `Ok(LfsFilterStatus)` - The status of LFS filter configuration
279/// * `Err(GitError)` - If git commands fail
280///
281/// # Example
282/// ```
283/// use std::path::Path;
284/// use ralph::git::lfs::validate_lfs_filters;
285///
286/// let status = validate_lfs_filters(Path::new(".")).unwrap();
287/// if !status.is_healthy() {
288///     eprintln!("LFS filters misconfigured: {:?}", status.issues());
289/// }
290/// ```
291pub fn validate_lfs_filters(repo_root: &Path) -> Result<LfsFilterStatus, GitError> {
292    fn parse_config_get_output(
293        args: &str,
294        output: &std::process::Output,
295    ) -> Result<(bool, Option<String>), GitError> {
296        if output.status.success() {
297            let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
298            return Ok((true, Some(value)));
299        }
300
301        // `git config --get` returns exit code 1 with empty stderr when the key is missing.
302        // That is a normal "misconfigured" state for our purposes (not a hard failure).
303        let stderr = String::from_utf8_lossy(&output.stderr);
304        let stderr = stderr.trim();
305        if !stderr.is_empty() {
306            return Err(GitError::CommandFailed {
307                args: args.to_string(),
308                code: output.status.code(),
309                stderr: stderr.to_string(),
310            });
311        }
312
313        Ok((false, None))
314    }
315
316    let smudge_output = git_output(repo_root, &["config", "--get", "filter.lfs.smudge"])
317        .with_context(|| {
318            format!(
319                "run git config --get filter.lfs.smudge in {}",
320                repo_root.display()
321            )
322        })?;
323
324    let clean_output = git_output(repo_root, &["config", "--get", "filter.lfs.clean"])
325        .with_context(|| {
326            format!(
327                "run git config --get filter.lfs.clean in {}",
328                repo_root.display()
329            )
330        })?;
331
332    let (smudge_installed, smudge_value) =
333        parse_config_get_output("config --get filter.lfs.smudge", &smudge_output)?;
334    let (clean_installed, clean_value) =
335        parse_config_get_output("config --get filter.lfs.clean", &clean_output)?;
336
337    Ok(LfsFilterStatus {
338        smudge_installed,
339        clean_installed,
340        smudge_value,
341        clean_value,
342    })
343}
344
345/// Runs `git lfs status` and parses the output to detect LFS issues.
346///
347/// This function detects:
348/// - Files that should be LFS but are being committed as regular files
349/// - Files matching .gitattributes LFS patterns but not tracked
350/// - Modified LFS files that are not staged
351///
352/// # Arguments
353/// * `repo_root` - Path to the repository root
354///
355/// # Returns
356/// * `Ok(LfsStatusSummary)` - Summary of LFS status
357/// * `Err(GitError)` - If git lfs status fails
358///
359/// # Example
360/// ```
361/// use std::path::Path;
362/// use ralph::git::lfs::check_lfs_status;
363///
364/// let status = check_lfs_status(Path::new(".")).unwrap();
365/// if !status.is_clean() {
366///     for issue in status.issue_descriptions() {
367///         eprintln!("LFS issue: {}", issue);
368///     }
369/// }
370/// ```
371pub fn check_lfs_status(repo_root: &Path) -> Result<LfsStatusSummary, GitError> {
372    let output = git_output(repo_root, &["lfs", "status"])
373        .with_context(|| format!("run git lfs status in {}", repo_root.display()))?;
374
375    if !output.status.success() {
376        let stderr = String::from_utf8_lossy(&output.stderr);
377        // If LFS is not installed or initialized, return empty summary
378        if stderr.contains("not a git lfs repository")
379            || stderr.contains("git: lfs is not a git command")
380        {
381            return Ok(LfsStatusSummary::default());
382        }
383        return Err(GitError::CommandFailed {
384            args: "lfs status".to_string(),
385            code: output.status.code(),
386            stderr: stderr.trim().to_string(),
387        });
388    }
389
390    let stdout = String::from_utf8_lossy(&output.stdout);
391    let mut summary = LfsStatusSummary::default();
392
393    // Parse git lfs status output
394    // The output format is:
395    // Objects to be committed:
396    // 	<file> (<status>)
397    // 	...
398    //
399    // Objects not staged for commit:
400    // 	<file> (<status>)
401    // 	...
402    let mut in_staged_section = false;
403    let mut in_unstaged_section = false;
404
405    for line in stdout.lines() {
406        let trimmed = line.trim();
407
408        if trimmed.starts_with("Objects to be committed:") {
409            in_staged_section = true;
410            in_unstaged_section = false;
411            continue;
412        }
413
414        if trimmed.starts_with("Objects not staged for commit:") {
415            in_staged_section = false;
416            in_unstaged_section = true;
417            continue;
418        }
419
420        if trimmed.is_empty() || trimmed.starts_with('(') {
421            continue;
422        }
423
424        // Parse file entries like: "	path/to/file (LFS: some-sha)"
425        // or "	path/to/file (Git: sha)"
426        if let Some((file_path, status)) = trimmed.split_once(" (") {
427            let file_path = file_path.trim();
428            let status = status.trim_end_matches(')');
429
430            if in_staged_section {
431                if status.starts_with("LFS:") {
432                    summary.staged_lfs.push(file_path.to_string());
433                } else if status.starts_with("Git:") {
434                    // File is staged as a regular git object, not LFS
435                    summary.staged_not_lfs.push(file_path.to_string());
436                }
437            } else if in_unstaged_section && status.starts_with("LFS:") {
438                summary.unstaged_lfs.push(file_path.to_string());
439            }
440        }
441    }
442
443    Ok(summary)
444}
445
446/// Validates LFS pointer files for correctness.
447///
448/// This function checks if files that should be LFS pointers are valid:
449/// - Valid LFS pointers start with `version <https://git-lfs.github.com/spec/v1>`
450/// - Detects files that should be pointers but contain binary content
451/// - Detects corrupted pointer files
452///
453/// # Arguments
454/// * `repo_root` - Path to the repository root
455/// * `files` - List of file paths to validate (relative to repo_root)
456///
457/// # Returns
458/// * `Ok(Vec<LfsPointerIssue>)` - List of issues found (empty if all valid)
459/// * `Err(anyhow::Error)` - If file reading fails
460///
461/// # Example
462/// ```
463/// use std::path::Path;
464/// use ralph::git::lfs::validate_lfs_pointers;
465///
466/// let issues = validate_lfs_pointers(Path::new("."), &["large.bin".to_string()]).unwrap();
467/// for issue in issues {
468///     eprintln!("{}", issue.description());
469/// }
470/// ```
471pub fn validate_lfs_pointers(repo_root: &Path, files: &[String]) -> Result<Vec<LfsPointerIssue>> {
472    let mut issues = Vec::new();
473
474    for file_path in files {
475        let full_path = repo_root.join(file_path);
476
477        // Check if file exists
478        let metadata = match fs::metadata(&full_path) {
479            Ok(m) => m,
480            Err(_) => {
481                // File doesn't exist, skip
482                continue;
483            }
484        };
485
486        // LFS pointers are small text files
487        if metadata.len() > MAX_POINTER_SIZE {
488            // File is too large to be a pointer, likely contains binary content
489            // This is expected if the smudge filter is working correctly
490            continue;
491        }
492
493        // Read file content
494        let content = match fs::read_to_string(&full_path) {
495            Ok(c) => c,
496            Err(_) => {
497                // Binary file or unreadable, skip (this is expected for checked-out LFS files)
498                continue;
499            }
500        };
501
502        let trimmed = content.trim();
503
504        // Check if it's a valid LFS pointer
505        if trimmed.starts_with(LFS_POINTER_PREFIX) {
506            // Valid pointer format
507            continue;
508        }
509
510        // Check if it looks like a corrupted pointer (partial LFS content)
511        if trimmed.contains("git-lfs") || trimmed.contains("sha256") {
512            let preview: String = trimmed.chars().take(50).collect();
513            issues.push(LfsPointerIssue::Corrupted {
514                path: file_path.clone(),
515                content_preview: preview,
516            });
517            continue;
518        }
519
520        // File is small but not a valid pointer - might be a corrupted pointer
521        if !trimmed.is_empty() {
522            issues.push(LfsPointerIssue::InvalidPointer {
523                path: file_path.clone(),
524                reason: "File does not match LFS pointer format".to_string(),
525            });
526        }
527    }
528
529    Ok(issues)
530}
531
532/// Performs a comprehensive LFS health check.
533///
534/// This function combines all LFS validation checks:
535/// - Checks if LFS is initialized
536/// - Validates filter configuration
537/// - Checks `git lfs status` for issues
538/// - Validates pointer files for tracked LFS files
539///
540/// # Arguments
541/// * `repo_root` - Path to the repository root
542///
543/// # Returns
544/// * `Ok(LfsHealthReport)` - Complete health report with `lfs_initialized=false`
545///   when LFS is not detected, or full results when LFS is initialized
546/// * `Err(anyhow::Error)` - If an unexpected git/LFS command fails while LFS
547///   is detected. Known non-fatal conditions ("not a git lfs repository",
548///   "git: lfs is not a git command") are handled internally and returned
549///   as empty/default Ok results.
550///
551/// # Example
552/// ```
553/// use std::path::Path;
554/// use ralph::git::lfs::check_lfs_health;
555///
556/// let report = check_lfs_health(Path::new(".")).unwrap();
557/// if !report.is_healthy() {
558///     for issue in report.all_issues() {
559///         eprintln!("LFS issue: {}", issue);
560///     }
561/// }
562/// ```
563pub fn check_lfs_health(repo_root: &Path) -> Result<LfsHealthReport> {
564    let lfs_initialized = has_lfs(repo_root)?;
565
566    if !lfs_initialized {
567        return Ok(LfsHealthReport {
568            lfs_initialized: false,
569            ..LfsHealthReport::default()
570        });
571    }
572
573    // Run all sub-checks and propagate unexpected failures.
574    // The underlying functions (check_lfs_status, list_lfs_files) already
575    // treat certain stderr patterns ("not a git lfs repository", etc.) as
576    // non-fatal and return Ok defaults in those cases.
577    let filter_status = Some(validate_lfs_filters(repo_root)?);
578    let status_summary = Some(check_lfs_status(repo_root)?);
579
580    // Validate pointers for tracked LFS files
581    let lfs_files = list_lfs_files(repo_root)?;
582    let pointer_issues = if !lfs_files.is_empty() {
583        validate_lfs_pointers(repo_root, &lfs_files)?
584    } else {
585        Vec::new()
586    };
587
588    Ok(LfsHealthReport {
589        lfs_initialized: true,
590        filter_status,
591        status_summary,
592        pointer_issues,
593    })
594}
595
596/// Filter status paths to only include LFS-tracked files.
597pub fn filter_modified_lfs_files(status_paths: &[String], lfs_files: &[String]) -> Vec<String> {
598    if status_paths.is_empty() || lfs_files.is_empty() {
599        return Vec::new();
600    }
601
602    let mut lfs_set = std::collections::HashSet::new();
603    for path in lfs_files {
604        lfs_set.insert(path.trim().to_string());
605    }
606
607    let mut matches = Vec::new();
608    for path in status_paths {
609        let trimmed = path.trim();
610        if trimmed.is_empty() {
611            continue;
612        }
613        if lfs_set.contains(trimmed) {
614            matches.push(trimmed.to_string());
615        }
616    }
617
618    matches.sort();
619    matches.dedup();
620    matches
621}
622
623#[cfg(test)]
624mod lfs_validation_tests {
625    use super::*;
626    use crate::testsupport::git as git_test;
627    use tempfile::TempDir;
628
629    #[test]
630    fn lfs_filter_status_is_healthy_when_both_filters_installed() {
631        let status = LfsFilterStatus {
632            smudge_installed: true,
633            clean_installed: true,
634            smudge_value: Some("git-lfs smudge %f".to_string()),
635            clean_value: Some("git-lfs clean %f".to_string()),
636        };
637        assert!(status.is_healthy());
638        assert!(status.issues().is_empty());
639    }
640
641    #[test]
642    fn lfs_filter_status_is_not_healthy_when_smudge_missing() {
643        let status = LfsFilterStatus {
644            smudge_installed: false,
645            clean_installed: true,
646            smudge_value: None,
647            clean_value: Some("git-lfs clean %f".to_string()),
648        };
649        assert!(!status.is_healthy());
650        let issues = status.issues();
651        assert_eq!(issues.len(), 1);
652        assert!(issues[0].contains("smudge"));
653    }
654
655    #[test]
656    fn lfs_filter_status_is_not_healthy_when_clean_missing() {
657        let status = LfsFilterStatus {
658            smudge_installed: true,
659            clean_installed: false,
660            smudge_value: Some("git-lfs smudge %f".to_string()),
661            clean_value: None,
662        };
663        assert!(!status.is_healthy());
664        let issues = status.issues();
665        assert_eq!(issues.len(), 1);
666        assert!(issues[0].contains("clean"));
667    }
668
669    #[test]
670    fn lfs_filter_status_reports_both_issues_when_both_missing() {
671        let status = LfsFilterStatus {
672            smudge_installed: false,
673            clean_installed: false,
674            smudge_value: None,
675            clean_value: None,
676        };
677        assert!(!status.is_healthy());
678        let issues = status.issues();
679        assert_eq!(issues.len(), 2);
680    }
681
682    #[test]
683    fn lfs_status_summary_is_clean_when_empty() {
684        let summary = LfsStatusSummary::default();
685        assert!(summary.is_clean());
686        assert!(summary.issue_descriptions().is_empty());
687    }
688
689    #[test]
690    fn lfs_status_summary_reports_staged_not_lfs_issue() {
691        let summary = LfsStatusSummary {
692            staged_lfs: vec![],
693            staged_not_lfs: vec!["large.bin".to_string()],
694            unstaged_lfs: vec![],
695            untracked_attributes: vec![],
696        };
697        assert!(!summary.is_clean());
698        let issues = summary.issue_descriptions();
699        assert_eq!(issues.len(), 1);
700        assert!(issues[0].contains("large.bin"));
701    }
702
703    #[test]
704    fn lfs_status_summary_reports_untracked_attributes_issue() {
705        let summary = LfsStatusSummary {
706            staged_lfs: vec![],
707            staged_not_lfs: vec![],
708            unstaged_lfs: vec![],
709            untracked_attributes: vec!["data.bin".to_string()],
710        };
711        assert!(!summary.is_clean());
712        let issues = summary.issue_descriptions();
713        assert_eq!(issues.len(), 1);
714        assert!(issues[0].contains("data.bin"));
715    }
716
717    #[test]
718    fn lfs_health_report_is_healthy_when_lfs_not_initialized() {
719        let report = LfsHealthReport {
720            lfs_initialized: false,
721            filter_status: None,
722            status_summary: None,
723            pointer_issues: vec![],
724        };
725        assert!(report.is_healthy());
726    }
727
728    #[test]
729    fn lfs_health_report_is_not_healthy_when_filter_status_missing() {
730        let report = LfsHealthReport {
731            lfs_initialized: true,
732            filter_status: None,
733            status_summary: Some(LfsStatusSummary::default()),
734            pointer_issues: vec![],
735        };
736        assert!(!report.is_healthy());
737    }
738
739    #[test]
740    fn lfs_health_report_is_not_healthy_when_status_summary_missing() {
741        let report = LfsHealthReport {
742            lfs_initialized: true,
743            filter_status: Some(LfsFilterStatus {
744                smudge_installed: true,
745                clean_installed: true,
746                smudge_value: Some("git-lfs smudge %f".to_string()),
747                clean_value: Some("git-lfs clean %f".to_string()),
748            }),
749            status_summary: None,
750            pointer_issues: vec![],
751        };
752        assert!(!report.is_healthy());
753    }
754
755    #[test]
756    fn lfs_health_report_is_not_healthy_with_filter_issues() {
757        let report = LfsHealthReport {
758            lfs_initialized: true,
759            filter_status: Some(LfsFilterStatus {
760                smudge_installed: false,
761                clean_installed: true,
762                smudge_value: None,
763                clean_value: Some("git-lfs clean %f".to_string()),
764            }),
765            status_summary: Some(LfsStatusSummary::default()),
766            pointer_issues: vec![],
767        };
768        assert!(!report.is_healthy());
769        let issues = report.all_issues();
770        assert!(!issues.is_empty());
771    }
772
773    #[test]
774    fn lfs_health_report_is_not_healthy_with_status_issues() {
775        let report = LfsHealthReport {
776            lfs_initialized: true,
777            filter_status: Some(LfsFilterStatus {
778                smudge_installed: true,
779                clean_installed: true,
780                smudge_value: Some("git-lfs smudge %f".to_string()),
781                clean_value: Some("git-lfs clean %f".to_string()),
782            }),
783            status_summary: Some(LfsStatusSummary {
784                staged_lfs: vec![],
785                staged_not_lfs: vec!["file.bin".to_string()],
786                unstaged_lfs: vec![],
787                untracked_attributes: vec![],
788            }),
789            pointer_issues: vec![],
790        };
791        assert!(!report.is_healthy());
792    }
793
794    #[test]
795    fn lfs_health_report_is_not_healthy_with_pointer_issues() {
796        let report = LfsHealthReport {
797            lfs_initialized: true,
798            filter_status: Some(LfsFilterStatus {
799                smudge_installed: true,
800                clean_installed: true,
801                smudge_value: Some("git-lfs smudge %f".to_string()),
802                clean_value: Some("git-lfs clean %f".to_string()),
803            }),
804            status_summary: Some(LfsStatusSummary::default()),
805            pointer_issues: vec![LfsPointerIssue::InvalidPointer {
806                path: "test.bin".to_string(),
807                reason: "Invalid format".to_string(),
808            }],
809        };
810        assert!(!report.is_healthy());
811        let issues = report.all_issues();
812        assert_eq!(issues.len(), 1);
813    }
814
815    #[test]
816    fn validate_lfs_pointers_detects_invalid_pointer() -> Result<()> {
817        let temp = TempDir::new()?;
818        git_test::init_repo(temp.path())?;
819
820        // Create a file that looks like an invalid LFS pointer
821        let pointer_content = "invalid pointer content";
822        std::fs::write(temp.path().join("test.bin"), pointer_content)?;
823
824        let issues = validate_lfs_pointers(temp.path(), &["test.bin".to_string()])?;
825        assert_eq!(issues.len(), 1);
826        assert!(matches!(
827            issues[0],
828            LfsPointerIssue::InvalidPointer { ref path, .. } if path == "test.bin"
829        ));
830        Ok(())
831    }
832
833    #[test]
834    fn validate_lfs_pointers_skips_large_files() -> Result<()> {
835        let temp = TempDir::new()?;
836        git_test::init_repo(temp.path())?;
837
838        // Create a large file (bigger than MAX_POINTER_SIZE)
839        let large_content = vec![0u8; 2048];
840        std::fs::write(temp.path().join("large.bin"), large_content)?;
841
842        let issues = validate_lfs_pointers(temp.path(), &["large.bin".to_string()])?;
843        assert!(issues.is_empty());
844        Ok(())
845    }
846
847    #[test]
848    fn validate_lfs_pointers_accepts_valid_pointer() -> Result<()> {
849        let temp = TempDir::new()?;
850        git_test::init_repo(temp.path())?;
851
852        // Create a valid LFS pointer
853        let pointer_content =
854            "version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 123\n";
855        std::fs::write(temp.path().join("valid.bin"), pointer_content)?;
856
857        let issues = validate_lfs_pointers(temp.path(), &["valid.bin".to_string()])?;
858        assert!(issues.is_empty());
859        Ok(())
860    }
861
862    #[test]
863    fn lfs_pointer_issue_description_contains_path() {
864        let issue = LfsPointerIssue::InvalidPointer {
865            path: "test/file.bin".to_string(),
866            reason: "corrupted".to_string(),
867        };
868        let desc = issue.description();
869        assert!(desc.contains("test/file.bin"));
870        assert!(desc.contains("corrupted"));
871    }
872
873    #[test]
874    fn lfs_pointer_issue_path_returns_correct_path() {
875        let issue = LfsPointerIssue::BinaryContent {
876            path: "binary.bin".to_string(),
877        };
878        assert_eq!(issue.path(), "binary.bin");
879    }
880
881    #[test]
882    fn check_lfs_health_errors_when_lfs_detected_but_git_config_fails() {
883        let temp = TempDir::new().expect("tempdir");
884        // Create a valid git repo
885        git_test::init_repo(temp.path()).expect("init repo");
886        // Create .gitattributes with LFS filter
887        std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")
888            .expect("write gitattributes");
889        // Create a fake .git/lfs directory to trigger LFS detection
890        std::fs::create_dir_all(temp.path().join(".git/lfs")).expect("create lfs dir");
891
892        // Break git by corrupting .git/config. This should cause git config and git lfs
893        // commands to fail unexpectedly.
894        std::fs::write(temp.path().join(".git/config"), "not a valid config")
895            .expect("write invalid config");
896
897        let err = check_lfs_health(temp.path()).unwrap_err();
898        let msg = format!("{err:#}");
899        assert!(
900            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("config"),
901            "unexpected error: {msg}"
902        );
903    }
904}