1use 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#[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_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 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 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
270pub 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 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
351pub 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 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 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 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 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
454pub 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 let metadata = match fs::metadata(&full_path) {
487 Ok(m) => m,
488 Err(_) => {
489 continue;
491 }
492 };
493
494 if metadata.len() > MAX_POINTER_SIZE {
496 continue;
499 }
500
501 let content = match fs::read_to_string(&full_path) {
503 Ok(c) => c,
504 Err(_) => {
505 continue;
507 }
508 };
509
510 let trimmed = content.trim();
511
512 if trimmed.starts_with(LFS_POINTER_PREFIX) {
514 continue;
516 }
517
518 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 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
540pub 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 let filter_status = Some(validate_lfs_filters(repo_root)?);
586 let status_summary = Some(check_lfs_status(repo_root)?);
587
588 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
604pub 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 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 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 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 git_test::init_repo(temp.path()).expect("init repo");
894 std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")
896 .expect("write gitattributes");
897 std::fs::create_dir_all(temp.path().join(".git/lfs")).expect("create lfs dir");
899
900 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}