//! LFS operations on a Git repository. use std::fs; use std::path::{Path, PathBuf}; use globset::Glob; use crate::commit::types::CommitOid; use crate::lfs::types::{LfsConfig, LfsEntry, LfsOid, LfsPointer}; use crate::{GitDomain, GitError, GitResult}; impl GitDomain { pub fn lfs_pointer_from_blob(&self, oid: &CommitOid) -> GitResult> { let content = self.blob_content(oid)?; Ok(LfsPointer::from_bytes(&content.content)) } pub fn lfs_is_pointer(&self, oid: &CommitOid) -> GitResult { let content = self.blob_content(oid)?; Ok(LfsPointer::from_bytes(&content.content).is_some()) } pub fn lfs_resolve_oid(&self, oid: &CommitOid) -> GitResult> { let pointer = self.lfs_pointer_from_blob(oid)?; Ok(pointer.map(|p| p.oid)) } pub fn lfs_create_pointer( &self, oid: &LfsOid, size: u64, extra: &[(String, String)], ) -> GitResult { let pointer = LfsPointer { version: "https://git-lfs.github.com/spec/v1".to_string(), oid: oid.clone(), size, extra: extra.to_vec(), }; self.blob_create_from_string(&pointer.to_string()) } pub fn lfs_scan_tree(&self, tree_oid: &CommitOid, recursive: bool) -> GitResult> { let tree_oid = tree_oid .to_oid() .map_err(|_| GitError::InvalidOid(tree_oid.to_string()))?; let tree = self .repo() .find_tree(tree_oid) .map_err(|_| GitError::ObjectNotFound(tree_oid.to_string()))?; let mut entries = Vec::new(); self.lfs_scan_tree_impl(&mut entries, &tree, "", recursive)?; Ok(entries) } fn lfs_scan_tree_impl( &self, out: &mut Vec, tree: &git2::Tree<'_>, prefix: &str, recursive: bool, ) -> GitResult<()> { for entry in tree.iter() { let name = entry.name().unwrap_or(""); let full_path = if prefix.is_empty() { name.to_string() } else { format!("{}/{}", prefix, name) }; let blob_oid = entry.id(); let obj = match self.repo().find_object(blob_oid, None) { Ok(o) => o, Err(_) => continue, }; if obj.kind() == Some(git2::ObjectType::Tree) { if recursive { let sub_tree = self .repo() .find_tree(blob_oid) .map_err(|e| GitError::Internal(e.to_string()))?; self.lfs_scan_tree_impl(out, &sub_tree, &full_path, recursive)?; } } else if let Some(blob) = obj.as_blob() { if let Some(pointer) = LfsPointer::from_bytes(blob.content()) { out.push(LfsEntry { path: full_path, pointer, size: 0, }); } } } Ok(()) } fn gitattributes_path(&self) -> PathBuf { self.repo() .workdir() .unwrap_or_else(|| self.repo().path()) .join(".gitattributes") } pub fn lfs_gitattributes_list(&self) -> GitResult> { let path = self.gitattributes_path(); if !path.exists() { return Ok(Vec::new()); } let content = fs::read_to_string(&path).map_err(|e| GitError::IoError(e.to_string()))?; Ok(content .lines() .filter(|l| l.contains("filter=lfs")) .map(|l| l.trim().to_string()) .collect()) } pub fn lfs_gitattributes_add(&self, pattern: &str) -> GitResult<()> { let line = format!("{} filter=lfs diff=lfs merge=lfs -text", pattern); let path = self.gitattributes_path(); let content = if path.exists() { fs::read_to_string(&path).map_err(|e| GitError::IoError(e.to_string()))? } else { String::new() }; if content.lines().any(|l| l.trim() == line) { return Ok(()); } let new_content = if content.ends_with('\n') || content.is_empty() { format!("{}{}\n", content, line) } else { format!("{}\n{}\n", content, line) }; fs::write(&path, new_content).map_err(|e| GitError::IoError(e.to_string()))?; Ok(()) } pub fn lfs_gitattributes_remove(&self, pattern: &str) -> GitResult { let path = self.gitattributes_path(); if !path.exists() { return Ok(false); } let content = fs::read_to_string(&path).map_err(|e| GitError::IoError(e.to_string()))?; let target = format!("{} filter=lfs diff=lfs merge=lfs -text", pattern); let new_lines: Vec<&str> = content.lines().filter(|l| l.trim() != target).collect(); if new_lines.len() == content.lines().count() { return Ok(false); } let new_content = new_lines.join("\n"); fs::write(&path, new_content).map_err(|e| GitError::IoError(e.to_string()))?; Ok(true) } pub fn lfs_gitattributes_match(&self, path_str: &str) -> GitResult { let patterns = self.lfs_gitattributes_list()?; for pattern in patterns { let glob_str = pattern.split_whitespace().next().unwrap_or(&pattern); if let Ok(glob) = Glob::new(glob_str) { let matcher = glob.compile_matcher(); if matcher.is_match(path_str) { return Ok(true); } } } Ok(false) } fn lfs_objects_dir(&self) -> PathBuf { self.repo().path().join("lfs").join("objects") } /// Validates that the OID is at least 4 characters for path splitting. fn lfs_validate_oid(&self, oid: &LfsOid) -> GitResult<()> { if oid.as_str().len() < 4 { return Err(GitError::Internal(format!( "LFS OID too short for path splitting: {}", oid ))); } Ok(()) } pub fn lfs_object_cached(&self, oid: &LfsOid) -> bool { if oid.as_str().len() < 4 { return false; } let (p1, rest) = oid.as_str().split_at(2); let (p2, _) = rest.split_at(2); self.lfs_objects_dir() .join(p1) .join(p2) .join(oid.as_str()) .exists() } pub fn lfs_object_path(&self, oid: &LfsOid) -> GitResult { self.lfs_validate_oid(oid)?; let (p1, rest) = oid.as_str().split_at(2); let (p2, _) = rest.split_at(2); Ok(self.lfs_objects_dir().join(p1).join(p2).join(oid.as_str())) } pub fn lfs_object_put(&self, oid: &LfsOid, content: &[u8]) -> GitResult { use std::io::Write; let path = self.lfs_object_path(oid)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| GitError::IoError(e.to_string()))?; } let mut file = fs::File::create(&path).map_err(|e| GitError::IoError(e.to_string()))?; file.write_all(content) .map_err(|e| GitError::IoError(e.to_string()))?; Ok(path) } pub fn lfs_object_get(&self, oid: &LfsOid) -> GitResult> { let path = self.lfs_object_path(oid)?; if !path.exists() { return Err(GitError::LfsError(format!( "object {} not found in local cache", oid ))); } fs::read(&path).map_err(|e| GitError::IoError(e.to_string())) } pub fn lfs_object_list(&self) -> GitResult> { let base = self.lfs_objects_dir(); if !base.exists() { return Ok(Vec::new()); } let mut oids = Vec::new(); self.lfs_object_list_impl(&base, &mut oids)?; Ok(oids) } fn lfs_object_list_impl(&self, dir: &Path, oids: &mut Vec) -> GitResult<()> { for entry in fs::read_dir(dir).map_err(|e| GitError::IoError(e.to_string()))? { let entry = entry.map_err(|e| GitError::IoError(e.to_string()))?; let path = entry.path(); if path.is_dir() { self.lfs_object_list_impl(&path, oids)?; } else if path.is_file() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { oids.push(LfsOid::new(name)); } } } Ok(()) } pub fn lfs_object_delete(&self, oid: &LfsOid) -> GitResult { let path = self.lfs_object_path(oid)?; if path.exists() { fs::remove_file(&path).map_err(|e| GitError::IoError(e.to_string()))?; Ok(true) } else { Ok(false) } } pub fn lfs_cache_size(&self) -> GitResult { let oids = self.lfs_object_list()?; let mut total = 0u64; for oid in oids { if let Ok(path) = self.lfs_object_path(&oid) { if let Ok(meta) = fs::metadata(&path) { total += meta.len(); } } } Ok(total) } pub fn lfs_config(&self) -> GitResult { let root = self.repo().workdir().unwrap_or_else(|| self.repo().path()); let path = root.join(".lfsconfig"); if !path.exists() { return Ok(LfsConfig::new()); } let content = fs::read_to_string(&path).map_err(|e| GitError::IoError(e.to_string()))?; let mut config = LfsConfig::new(); for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((k, v)) = line.split_once('=') { let k = k.trim(); let v = v.trim(); if k == "lfs.url" || k == "lfs.endpoint" { config.endpoint = Some(v.to_string()); } else if k == "lfs.accesskey" || k == "lfs.access_token" { config.access_token = Some(v.to_string()); } } } Ok(config) } pub fn lfs_config_set(&self, config: &LfsConfig) -> GitResult<()> { let root = self.repo().workdir().unwrap_or_else(|| self.repo().path()); let path = root.join(".lfsconfig"); let mut lines = Vec::new(); if let Some(ref ep) = config.endpoint { lines.push(format!("lfs.url = {}", ep)); } if let Some(ref tok) = config.access_token { lines.push(format!("lfs.access_token = {}", tok)); } fs::write(&path, lines.join("\n") + "\n").map_err(|e| GitError::IoError(e.to_string()))?; Ok(()) } }