1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LfsFilterStatus {
25 pub smudge_installed: bool,
27 pub clean_installed: bool,
29 pub smudge_value: Option<String>,
31 pub clean_value: Option<String>,
33}
34
35impl LfsFilterStatus {
36 pub fn is_healthy(&self) -> bool {
38 self.smudge_installed && self.clean_installed
39 }
40
41 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct LfsStatusSummary {
57 pub staged_lfs: Vec<String>,
59 pub staged_not_lfs: Vec<String>,
61 pub unstaged_lfs: Vec<String>,
63 pub untracked_attributes: Vec<String>,
65}
66
67impl LfsStatusSummary {
68 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 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#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum LfsPointerIssue {
107 InvalidPointer { path: String, reason: String },
109 BinaryContent { path: String },
111 Corrupted {
113 path: String,
114 content_preview: String,
115 },
116}
117
118impl LfsPointerIssue {
119 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
155pub struct LfsHealthReport {
156 pub lfs_initialized: bool,
158 pub filter_status: Option<LfsFilterStatus>,
160 pub status_summary: Option<LfsStatusSummary>,
162 pub pointer_issues: Vec<LfsPointerIssue>,
164}
165
166impl LfsHealthReport {
167 pub fn is_healthy(&self) -> bool {
173 if !self.lfs_initialized {
174 return true; }
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 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
214pub fn has_lfs(repo_root: &Path) -> Result<bool> {
216 let git_lfs_dir = repo_root.join(".git/lfs");
218 if git_lfs_dir.is_dir() {
219 return Ok(true);
220 }
221
222 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
233pub 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 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 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
268pub 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 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
345pub 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 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 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 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 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
446pub 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 let metadata = match fs::metadata(&full_path) {
479 Ok(m) => m,
480 Err(_) => {
481 continue;
483 }
484 };
485
486 if metadata.len() > MAX_POINTER_SIZE {
488 continue;
491 }
492
493 let content = match fs::read_to_string(&full_path) {
495 Ok(c) => c,
496 Err(_) => {
497 continue;
499 }
500 };
501
502 let trimmed = content.trim();
503
504 if trimmed.starts_with(LFS_POINTER_PREFIX) {
506 continue;
508 }
509
510 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 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
532pub 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 let filter_status = Some(validate_lfs_filters(repo_root)?);
578 let status_summary = Some(check_lfs_status(repo_root)?);
579
580 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
596pub 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 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 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 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 git_test::init_repo(temp.path()).expect("init repo");
886 std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")
888 .expect("write gitattributes");
889 std::fs::create_dir_all(temp.path().join(".git/lfs")).expect("create lfs dir");
891
892 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}