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_base_command};
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_base_command(repo_root)
236        .args(["lfs", "ls-files"])
237        .output()
238        .with_context(|| format!("run git lfs ls-files in {}", repo_root.display()))?;
239
240    if !output.status.success() {
241        let stderr = String::from_utf8_lossy(&output.stderr);
242        // If LFS is not installed or initialized, return empty list
243        if stderr.contains("not a git lfs repository")
244            || stderr.contains("git: lfs is not a git command")
245        {
246            return Ok(Vec::new());
247        }
248        return Err(GitError::CommandFailed {
249            args: "lfs ls-files".to_string(),
250            code: output.status.code(),
251            stderr: stderr.trim().to_string(),
252        }
253        .into());
254    }
255
256    let stdout = String::from_utf8_lossy(&output.stdout);
257    let mut files = Vec::new();
258
259    // Parse git lfs ls-files output format:
260    // each line is: "SHA256 * path/to/file"
261    for line in stdout.lines() {
262        if let Some((_, path)) = line.rsplit_once(" * ") {
263            files.push(path.to_string());
264        }
265    }
266
267    Ok(files)
268}
269
270/// Validates that LFS smudge/clean filters are properly installed in git config.
271///
272/// This function checks the git configuration for the required LFS filters:
273/// - `filter.lfs.smudge` should be set (typically to "git-lfs smudge %f")
274/// - `filter.lfs.clean` should be set (typically to "git-lfs clean %f")
275///
276/// # Arguments
277/// * `repo_root` - Path to the repository root
278///
279/// # Returns
280/// * `Ok(LfsFilterStatus)` - The status of LFS filter configuration
281/// * `Err(GitError)` - If git commands fail
282///
283/// # Example
284/// ```
285/// use std::path::Path;
286/// use ralph::git::lfs::validate_lfs_filters;
287///
288/// let status = validate_lfs_filters(Path::new(".")).unwrap();
289/// if !status.is_healthy() {
290///     eprintln!("LFS filters misconfigured: {:?}", status.issues());
291/// }
292/// ```
293pub fn validate_lfs_filters(repo_root: &Path) -> Result<LfsFilterStatus, GitError> {
294    fn parse_config_get_output(
295        args: &str,
296        output: &std::process::Output,
297    ) -> Result<(bool, Option<String>), GitError> {
298        if output.status.success() {
299            let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
300            return Ok((true, Some(value)));
301        }
302
303        // `git config --get` returns exit code 1 with empty stderr when the key is missing.
304        // That is a normal "misconfigured" state for our purposes (not a hard failure).
305        let stderr = String::from_utf8_lossy(&output.stderr);
306        let stderr = stderr.trim();
307        if !stderr.is_empty() {
308            return Err(GitError::CommandFailed {
309                args: args.to_string(),
310                code: output.status.code(),
311                stderr: stderr.to_string(),
312            });
313        }
314
315        Ok((false, None))
316    }
317
318    let smudge_output = git_base_command(repo_root)
319        .args(["config", "--get", "filter.lfs.smudge"])
320        .output()
321        .with_context(|| {
322            format!(
323                "run git config --get filter.lfs.smudge in {}",
324                repo_root.display()
325            )
326        })?;
327
328    let clean_output = git_base_command(repo_root)
329        .args(["config", "--get", "filter.lfs.clean"])
330        .output()
331        .with_context(|| {
332            format!(
333                "run git config --get filter.lfs.clean in {}",
334                repo_root.display()
335            )
336        })?;
337
338    let (smudge_installed, smudge_value) =
339        parse_config_get_output("config --get filter.lfs.smudge", &smudge_output)?;
340    let (clean_installed, clean_value) =
341        parse_config_get_output("config --get filter.lfs.clean", &clean_output)?;
342
343    Ok(LfsFilterStatus {
344        smudge_installed,
345        clean_installed,
346        smudge_value,
347        clean_value,
348    })
349}
350
351/// Runs `git lfs status` and parses the output to detect LFS issues.
352///
353/// This function detects:
354/// - Files that should be LFS but are being committed as regular files
355/// - Files matching .gitattributes LFS patterns but not tracked
356/// - Modified LFS files that are not staged
357///
358/// # Arguments
359/// * `repo_root` - Path to the repository root
360///
361/// # Returns
362/// * `Ok(LfsStatusSummary)` - Summary of LFS status
363/// * `Err(GitError)` - If git lfs status fails
364///
365/// # Example
366/// ```
367/// use std::path::Path;
368/// use ralph::git::lfs::check_lfs_status;
369///
370/// let status = check_lfs_status(Path::new(".")).unwrap();
371/// if !status.is_clean() {
372///     for issue in status.issue_descriptions() {
373///         eprintln!("LFS issue: {}", issue);
374///     }
375/// }
376/// ```
377pub fn check_lfs_status(repo_root: &Path) -> Result<LfsStatusSummary, GitError> {
378    let output = git_base_command(repo_root)
379        .args(["lfs", "status"])
380        .output()
381        .with_context(|| format!("run git lfs status in {}", repo_root.display()))?;
382
383    if !output.status.success() {
384        let stderr = String::from_utf8_lossy(&output.stderr);
385        // If LFS is not installed or initialized, return empty summary
386        if stderr.contains("not a git lfs repository")
387            || stderr.contains("git: lfs is not a git command")
388        {
389            return Ok(LfsStatusSummary::default());
390        }
391        return Err(GitError::CommandFailed {
392            args: "lfs status".to_string(),
393            code: output.status.code(),
394            stderr: stderr.trim().to_string(),
395        });
396    }
397
398    let stdout = String::from_utf8_lossy(&output.stdout);
399    let mut summary = LfsStatusSummary::default();
400
401    // Parse git lfs status output
402    // The output format is:
403    // Objects to be committed:
404    // 	<file> (<status>)
405    // 	...
406    //
407    // Objects not staged for commit:
408    // 	<file> (<status>)
409    // 	...
410    let mut in_staged_section = false;
411    let mut in_unstaged_section = false;
412
413    for line in stdout.lines() {
414        let trimmed = line.trim();
415
416        if trimmed.starts_with("Objects to be committed:") {
417            in_staged_section = true;
418            in_unstaged_section = false;
419            continue;
420        }
421
422        if trimmed.starts_with("Objects not staged for commit:") {
423            in_staged_section = false;
424            in_unstaged_section = true;
425            continue;
426        }
427
428        if trimmed.is_empty() || trimmed.starts_with('(') {
429            continue;
430        }
431
432        // Parse file entries like: "	path/to/file (LFS: some-sha)"
433        // or "	path/to/file (Git: sha)"
434        if let Some((file_path, status)) = trimmed.split_once(" (") {
435            let file_path = file_path.trim();
436            let status = status.trim_end_matches(')');
437
438            if in_staged_section {
439                if status.starts_with("LFS:") {
440                    summary.staged_lfs.push(file_path.to_string());
441                } else if status.starts_with("Git:") {
442                    // File is staged as a regular git object, not LFS
443                    summary.staged_not_lfs.push(file_path.to_string());
444                }
445            } else if in_unstaged_section && status.starts_with("LFS:") {
446                summary.unstaged_lfs.push(file_path.to_string());
447            }
448        }
449    }
450
451    Ok(summary)
452}
453
454/// Validates LFS pointer files for correctness.
455///
456/// This function checks if files that should be LFS pointers are valid:
457/// - Valid LFS pointers start with `version <https://git-lfs.github.com/spec/v1>`
458/// - Detects files that should be pointers but contain binary content
459/// - Detects corrupted pointer files
460///
461/// # Arguments
462/// * `repo_root` - Path to the repository root
463/// * `files` - List of file paths to validate (relative to repo_root)
464///
465/// # Returns
466/// * `Ok(Vec<LfsPointerIssue>)` - List of issues found (empty if all valid)
467/// * `Err(anyhow::Error)` - If file reading fails
468///
469/// # Example
470/// ```
471/// use std::path::Path;
472/// use ralph::git::lfs::validate_lfs_pointers;
473///
474/// let issues = validate_lfs_pointers(Path::new("."), &["large.bin".to_string()]).unwrap();
475/// for issue in issues {
476///     eprintln!("{}", issue.description());
477/// }
478/// ```
479pub fn validate_lfs_pointers(repo_root: &Path, files: &[String]) -> Result<Vec<LfsPointerIssue>> {
480    let mut issues = Vec::new();
481
482    for file_path in files {
483        let full_path = repo_root.join(file_path);
484
485        // Check if file exists
486        let metadata = match fs::metadata(&full_path) {
487            Ok(m) => m,
488            Err(_) => {
489                // File doesn't exist, skip
490                continue;
491            }
492        };
493
494        // LFS pointers are small text files
495        if metadata.len() > MAX_POINTER_SIZE {
496            // File is too large to be a pointer, likely contains binary content
497            // This is expected if the smudge filter is working correctly
498            continue;
499        }
500
501        // Read file content
502        let content = match fs::read_to_string(&full_path) {
503            Ok(c) => c,
504            Err(_) => {
505                // Binary file or unreadable, skip (this is expected for checked-out LFS files)
506                continue;
507            }
508        };
509
510        let trimmed = content.trim();
511
512        // Check if it's a valid LFS pointer
513        if trimmed.starts_with(LFS_POINTER_PREFIX) {
514            // Valid pointer format
515            continue;
516        }
517
518        // Check if it looks like a corrupted pointer (partial LFS content)
519        if trimmed.contains("git-lfs") || trimmed.contains("sha256") {
520            let preview: String = trimmed.chars().take(50).collect();
521            issues.push(LfsPointerIssue::Corrupted {
522                path: file_path.clone(),
523                content_preview: preview,
524            });
525            continue;
526        }
527
528        // File is small but not a valid pointer - might be a corrupted pointer
529        if !trimmed.is_empty() {
530            issues.push(LfsPointerIssue::InvalidPointer {
531                path: file_path.clone(),
532                reason: "File does not match LFS pointer format".to_string(),
533            });
534        }
535    }
536
537    Ok(issues)
538}
539
540/// Performs a comprehensive LFS health check.
541///
542/// This function combines all LFS validation checks:
543/// - Checks if LFS is initialized
544/// - Validates filter configuration
545/// - Checks `git lfs status` for issues
546/// - Validates pointer files for tracked LFS files
547///
548/// # Arguments
549/// * `repo_root` - Path to the repository root
550///
551/// # Returns
552/// * `Ok(LfsHealthReport)` - Complete health report with `lfs_initialized=false`
553///   when LFS is not detected, or full results when LFS is initialized
554/// * `Err(anyhow::Error)` - If an unexpected git/LFS command fails while LFS
555///   is detected. Known non-fatal conditions ("not a git lfs repository",
556///   "git: lfs is not a git command") are handled internally and returned
557///   as empty/default Ok results.
558///
559/// # Example
560/// ```
561/// use std::path::Path;
562/// use ralph::git::lfs::check_lfs_health;
563///
564/// let report = check_lfs_health(Path::new(".")).unwrap();
565/// if !report.is_healthy() {
566///     for issue in report.all_issues() {
567///         eprintln!("LFS issue: {}", issue);
568///     }
569/// }
570/// ```
571pub fn check_lfs_health(repo_root: &Path) -> Result<LfsHealthReport> {
572    let lfs_initialized = has_lfs(repo_root)?;
573
574    if !lfs_initialized {
575        return Ok(LfsHealthReport {
576            lfs_initialized: false,
577            ..LfsHealthReport::default()
578        });
579    }
580
581    // Run all sub-checks and propagate unexpected failures.
582    // The underlying functions (check_lfs_status, list_lfs_files) already
583    // treat certain stderr patterns ("not a git lfs repository", etc.) as
584    // non-fatal and return Ok defaults in those cases.
585    let filter_status = Some(validate_lfs_filters(repo_root)?);
586    let status_summary = Some(check_lfs_status(repo_root)?);
587
588    // Validate pointers for tracked LFS files
589    let lfs_files = list_lfs_files(repo_root)?;
590    let pointer_issues = if !lfs_files.is_empty() {
591        validate_lfs_pointers(repo_root, &lfs_files)?
592    } else {
593        Vec::new()
594    };
595
596    Ok(LfsHealthReport {
597        lfs_initialized: true,
598        filter_status,
599        status_summary,
600        pointer_issues,
601    })
602}
603
604/// Filter status paths to only include LFS-tracked files.
605pub fn filter_modified_lfs_files(status_paths: &[String], lfs_files: &[String]) -> Vec<String> {
606    if status_paths.is_empty() || lfs_files.is_empty() {
607        return Vec::new();
608    }
609
610    let mut lfs_set = std::collections::HashSet::new();
611    for path in lfs_files {
612        lfs_set.insert(path.trim().to_string());
613    }
614
615    let mut matches = Vec::new();
616    for path in status_paths {
617        let trimmed = path.trim();
618        if trimmed.is_empty() {
619            continue;
620        }
621        if lfs_set.contains(trimmed) {
622            matches.push(trimmed.to_string());
623        }
624    }
625
626    matches.sort();
627    matches.dedup();
628    matches
629}
630
631#[cfg(test)]
632mod lfs_validation_tests {
633    use super::*;
634    use crate::testsupport::git as git_test;
635    use tempfile::TempDir;
636
637    #[test]
638    fn lfs_filter_status_is_healthy_when_both_filters_installed() {
639        let status = LfsFilterStatus {
640            smudge_installed: true,
641            clean_installed: true,
642            smudge_value: Some("git-lfs smudge %f".to_string()),
643            clean_value: Some("git-lfs clean %f".to_string()),
644        };
645        assert!(status.is_healthy());
646        assert!(status.issues().is_empty());
647    }
648
649    #[test]
650    fn lfs_filter_status_is_not_healthy_when_smudge_missing() {
651        let status = LfsFilterStatus {
652            smudge_installed: false,
653            clean_installed: true,
654            smudge_value: None,
655            clean_value: Some("git-lfs clean %f".to_string()),
656        };
657        assert!(!status.is_healthy());
658        let issues = status.issues();
659        assert_eq!(issues.len(), 1);
660        assert!(issues[0].contains("smudge"));
661    }
662
663    #[test]
664    fn lfs_filter_status_is_not_healthy_when_clean_missing() {
665        let status = LfsFilterStatus {
666            smudge_installed: true,
667            clean_installed: false,
668            smudge_value: Some("git-lfs smudge %f".to_string()),
669            clean_value: None,
670        };
671        assert!(!status.is_healthy());
672        let issues = status.issues();
673        assert_eq!(issues.len(), 1);
674        assert!(issues[0].contains("clean"));
675    }
676
677    #[test]
678    fn lfs_filter_status_reports_both_issues_when_both_missing() {
679        let status = LfsFilterStatus {
680            smudge_installed: false,
681            clean_installed: false,
682            smudge_value: None,
683            clean_value: None,
684        };
685        assert!(!status.is_healthy());
686        let issues = status.issues();
687        assert_eq!(issues.len(), 2);
688    }
689
690    #[test]
691    fn lfs_status_summary_is_clean_when_empty() {
692        let summary = LfsStatusSummary::default();
693        assert!(summary.is_clean());
694        assert!(summary.issue_descriptions().is_empty());
695    }
696
697    #[test]
698    fn lfs_status_summary_reports_staged_not_lfs_issue() {
699        let summary = LfsStatusSummary {
700            staged_lfs: vec![],
701            staged_not_lfs: vec!["large.bin".to_string()],
702            unstaged_lfs: vec![],
703            untracked_attributes: vec![],
704        };
705        assert!(!summary.is_clean());
706        let issues = summary.issue_descriptions();
707        assert_eq!(issues.len(), 1);
708        assert!(issues[0].contains("large.bin"));
709    }
710
711    #[test]
712    fn lfs_status_summary_reports_untracked_attributes_issue() {
713        let summary = LfsStatusSummary {
714            staged_lfs: vec![],
715            staged_not_lfs: vec![],
716            unstaged_lfs: vec![],
717            untracked_attributes: vec!["data.bin".to_string()],
718        };
719        assert!(!summary.is_clean());
720        let issues = summary.issue_descriptions();
721        assert_eq!(issues.len(), 1);
722        assert!(issues[0].contains("data.bin"));
723    }
724
725    #[test]
726    fn lfs_health_report_is_healthy_when_lfs_not_initialized() {
727        let report = LfsHealthReport {
728            lfs_initialized: false,
729            filter_status: None,
730            status_summary: None,
731            pointer_issues: vec![],
732        };
733        assert!(report.is_healthy());
734    }
735
736    #[test]
737    fn lfs_health_report_is_not_healthy_when_filter_status_missing() {
738        let report = LfsHealthReport {
739            lfs_initialized: true,
740            filter_status: None,
741            status_summary: Some(LfsStatusSummary::default()),
742            pointer_issues: vec![],
743        };
744        assert!(!report.is_healthy());
745    }
746
747    #[test]
748    fn lfs_health_report_is_not_healthy_when_status_summary_missing() {
749        let report = LfsHealthReport {
750            lfs_initialized: true,
751            filter_status: Some(LfsFilterStatus {
752                smudge_installed: true,
753                clean_installed: true,
754                smudge_value: Some("git-lfs smudge %f".to_string()),
755                clean_value: Some("git-lfs clean %f".to_string()),
756            }),
757            status_summary: None,
758            pointer_issues: vec![],
759        };
760        assert!(!report.is_healthy());
761    }
762
763    #[test]
764    fn lfs_health_report_is_not_healthy_with_filter_issues() {
765        let report = LfsHealthReport {
766            lfs_initialized: true,
767            filter_status: Some(LfsFilterStatus {
768                smudge_installed: false,
769                clean_installed: true,
770                smudge_value: None,
771                clean_value: Some("git-lfs clean %f".to_string()),
772            }),
773            status_summary: Some(LfsStatusSummary::default()),
774            pointer_issues: vec![],
775        };
776        assert!(!report.is_healthy());
777        let issues = report.all_issues();
778        assert!(!issues.is_empty());
779    }
780
781    #[test]
782    fn lfs_health_report_is_not_healthy_with_status_issues() {
783        let report = LfsHealthReport {
784            lfs_initialized: true,
785            filter_status: Some(LfsFilterStatus {
786                smudge_installed: true,
787                clean_installed: true,
788                smudge_value: Some("git-lfs smudge %f".to_string()),
789                clean_value: Some("git-lfs clean %f".to_string()),
790            }),
791            status_summary: Some(LfsStatusSummary {
792                staged_lfs: vec![],
793                staged_not_lfs: vec!["file.bin".to_string()],
794                unstaged_lfs: vec![],
795                untracked_attributes: vec![],
796            }),
797            pointer_issues: vec![],
798        };
799        assert!(!report.is_healthy());
800    }
801
802    #[test]
803    fn lfs_health_report_is_not_healthy_with_pointer_issues() {
804        let report = LfsHealthReport {
805            lfs_initialized: true,
806            filter_status: Some(LfsFilterStatus {
807                smudge_installed: true,
808                clean_installed: true,
809                smudge_value: Some("git-lfs smudge %f".to_string()),
810                clean_value: Some("git-lfs clean %f".to_string()),
811            }),
812            status_summary: Some(LfsStatusSummary::default()),
813            pointer_issues: vec![LfsPointerIssue::InvalidPointer {
814                path: "test.bin".to_string(),
815                reason: "Invalid format".to_string(),
816            }],
817        };
818        assert!(!report.is_healthy());
819        let issues = report.all_issues();
820        assert_eq!(issues.len(), 1);
821    }
822
823    #[test]
824    fn validate_lfs_pointers_detects_invalid_pointer() -> Result<()> {
825        let temp = TempDir::new()?;
826        git_test::init_repo(temp.path())?;
827
828        // Create a file that looks like an invalid LFS pointer
829        let pointer_content = "invalid pointer content";
830        std::fs::write(temp.path().join("test.bin"), pointer_content)?;
831
832        let issues = validate_lfs_pointers(temp.path(), &["test.bin".to_string()])?;
833        assert_eq!(issues.len(), 1);
834        assert!(matches!(
835            issues[0],
836            LfsPointerIssue::InvalidPointer { ref path, .. } if path == "test.bin"
837        ));
838        Ok(())
839    }
840
841    #[test]
842    fn validate_lfs_pointers_skips_large_files() -> Result<()> {
843        let temp = TempDir::new()?;
844        git_test::init_repo(temp.path())?;
845
846        // Create a large file (bigger than MAX_POINTER_SIZE)
847        let large_content = vec![0u8; 2048];
848        std::fs::write(temp.path().join("large.bin"), large_content)?;
849
850        let issues = validate_lfs_pointers(temp.path(), &["large.bin".to_string()])?;
851        assert!(issues.is_empty());
852        Ok(())
853    }
854
855    #[test]
856    fn validate_lfs_pointers_accepts_valid_pointer() -> Result<()> {
857        let temp = TempDir::new()?;
858        git_test::init_repo(temp.path())?;
859
860        // Create a valid LFS pointer
861        let pointer_content =
862            "version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 123\n";
863        std::fs::write(temp.path().join("valid.bin"), pointer_content)?;
864
865        let issues = validate_lfs_pointers(temp.path(), &["valid.bin".to_string()])?;
866        assert!(issues.is_empty());
867        Ok(())
868    }
869
870    #[test]
871    fn lfs_pointer_issue_description_contains_path() {
872        let issue = LfsPointerIssue::InvalidPointer {
873            path: "test/file.bin".to_string(),
874            reason: "corrupted".to_string(),
875        };
876        let desc = issue.description();
877        assert!(desc.contains("test/file.bin"));
878        assert!(desc.contains("corrupted"));
879    }
880
881    #[test]
882    fn lfs_pointer_issue_path_returns_correct_path() {
883        let issue = LfsPointerIssue::BinaryContent {
884            path: "binary.bin".to_string(),
885        };
886        assert_eq!(issue.path(), "binary.bin");
887    }
888
889    #[test]
890    fn check_lfs_health_errors_when_lfs_detected_but_git_config_fails() {
891        let temp = TempDir::new().expect("tempdir");
892        // Create a valid git repo
893        git_test::init_repo(temp.path()).expect("init repo");
894        // Create .gitattributes with LFS filter
895        std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")
896            .expect("write gitattributes");
897        // Create a fake .git/lfs directory to trigger LFS detection
898        std::fs::create_dir_all(temp.path().join(".git/lfs")).expect("create lfs dir");
899
900        // Break git by corrupting .git/config. This should cause git config and git lfs
901        // commands to fail unexpectedly.
902        std::fs::write(temp.path().join(".git/config"), "not a valid config")
903            .expect("write invalid config");
904
905        let err = check_lfs_health(temp.path()).unwrap_err();
906        let msg = format!("{err:#}");
907        assert!(
908            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("config"),
909            "unexpected error: {msg}"
910        );
911    }
912}