use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{Error, Result};
#[derive(Debug)]
pub struct SparseCheckout {
repo_path: PathBuf,
remote_url: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SparseFilter {
Pattern(String),
Directory(String),
Format(String),
Category(String),
MinSize { width: u32, height: u32 },
Paths(Vec<String>),
}
impl SparseFilter {
pub fn to_patterns(&self) -> Vec<String> {
match self {
Self::Pattern(p) => vec![p.clone()],
Self::Directory(d) => {
let d = d.trim_end_matches('/');
vec![format!("{d}/"), format!("{d}/**")]
}
Self::Format(ext) => {
let ext = ext.trim_start_matches('.');
vec![format!("**/*.{ext}")]
}
Self::Category(cat) => {
vec![
format!("**/{cat}/"),
format!("**/{cat}/**"),
format!("{cat}/"),
format!("{cat}/**"),
]
}
Self::MinSize { .. } => {
vec!["**/*".to_string()]
}
Self::Paths(paths) => paths.clone(),
}
}
}
impl SparseCheckout {
pub fn init(repo_path: impl AsRef<Path>) -> Result<Self> {
let repo_path = repo_path.as_ref().to_path_buf();
run_git(&repo_path, &["sparse-checkout", "init", "--cone"])?;
Ok(Self {
repo_path,
remote_url: None,
})
}
pub fn clone(url: &str, target: impl AsRef<Path>) -> Result<Self> {
let target = target.as_ref();
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
run_git_cwd(
target.parent().unwrap_or(Path::new(".")),
&[
"clone",
"--filter=blob:none",
"--sparse",
"--no-checkout",
url,
&target.file_name().unwrap().to_string_lossy(),
],
)?;
run_git(target, &["sparse-checkout", "init", "--cone"])?;
Ok(Self {
repo_path: target.to_path_buf(),
remote_url: Some(url.to_string()),
})
}
pub fn clone_shallow(url: &str, target: impl AsRef<Path>, depth: u32) -> Result<Self> {
let target = target.as_ref();
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
run_git_cwd(
target.parent().unwrap_or(Path::new(".")),
&[
"clone",
"--filter=blob:none",
"--sparse",
"--no-checkout",
"--depth",
&depth.to_string(),
url,
&target.file_name().unwrap().to_string_lossy(),
],
)?;
run_git(target, &["sparse-checkout", "init", "--cone"])?;
Ok(Self {
repo_path: target.to_path_buf(),
remote_url: Some(url.to_string()),
})
}
pub fn open(repo_path: impl AsRef<Path>) -> Result<Self> {
let repo_path = repo_path.as_ref().to_path_buf();
if !repo_path.join(".git").exists() {
return Err(Error::Corpus(format!(
"Not a git repository: {}",
repo_path.display()
)));
}
let remote_url = run_git(&repo_path, &["remote", "get-url", "origin"]).ok();
Ok(Self {
repo_path,
remote_url,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.repo_path
}
#[must_use]
pub fn remote_url(&self) -> Option<&str> {
self.remote_url.as_deref()
}
pub fn add_paths(&self, paths: &[&str]) -> Result<()> {
let mut args = vec!["sparse-checkout", "add"];
args.extend(paths);
run_git(&self.repo_path, &args)?;
Ok(())
}
pub fn set_paths(&self, paths: &[&str]) -> Result<()> {
let mut args = vec!["sparse-checkout", "set"];
args.extend(paths);
run_git(&self.repo_path, &args)?;
Ok(())
}
pub fn add_filter(&self, filter: &SparseFilter) -> Result<()> {
let patterns = filter.to_patterns();
let refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
self.add_paths(&refs)
}
pub fn set_filters(&self, filters: &[SparseFilter]) -> Result<()> {
let patterns: Vec<String> = filters.iter().flat_map(|f| f.to_patterns()).collect();
let refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
self.set_paths(&refs)
}
pub fn list_patterns(&self) -> Result<Vec<String>> {
let output = run_git(&self.repo_path, &["sparse-checkout", "list"])?;
Ok(output.lines().map(String::from).collect())
}
pub fn checkout(&self) -> Result<()> {
run_git(&self.repo_path, &["checkout"])?;
Ok(())
}
pub fn checkout_ref(&self, reference: &str) -> Result<()> {
run_git(&self.repo_path, &["checkout", reference])?;
Ok(())
}
pub fn fetch(&self) -> Result<()> {
run_git(&self.repo_path, &["fetch", "--filter=blob:none"])?;
Ok(())
}
pub fn pull(&self) -> Result<()> {
self.fetch()?;
run_git(&self.repo_path, &["pull"])?;
Ok(())
}
pub fn disable(&self) -> Result<()> {
run_git(&self.repo_path, &["sparse-checkout", "disable"])?;
Ok(())
}
pub fn reapply(&self) -> Result<()> {
run_git(&self.repo_path, &["sparse-checkout", "reapply"])?;
Ok(())
}
pub fn status(&self) -> Result<SparseStatus> {
let config =
run_git(&self.repo_path, &["config", "core.sparseCheckout"]).unwrap_or_default();
let enabled = config.trim() == "true";
let patterns = if enabled {
self.list_patterns().unwrap_or_default()
} else {
Vec::new()
};
let files_output = run_git(&self.repo_path, &["ls-files"])?;
let checked_out_files = files_output.lines().count();
let total_files = run_git(&self.repo_path, &["ls-tree", "-r", "--name-only", "HEAD"])
.map(|o| o.lines().count())
.ok();
Ok(SparseStatus {
enabled,
patterns,
checked_out_files,
total_files,
})
}
}
#[derive(Debug, Clone)]
pub struct SparseStatus {
pub enabled: bool,
pub patterns: Vec<String>,
pub checked_out_files: usize,
pub total_files: Option<usize>,
}
impl SparseStatus {
#[must_use]
pub fn percentage(&self) -> Option<f64> {
self.total_files.map(|total| {
if total == 0 {
100.0
} else {
(self.checked_out_files as f64 / total as f64) * 100.0
}
})
}
}
fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(["-C", &repo_path.to_string_lossy()])
.args(args)
.output()
.map_err(|e| Error::Corpus(format!("Failed to run git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Corpus(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn run_git_cwd(cwd: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.map_err(|e| Error::Corpus(format!("Failed to run git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Corpus(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn preview_patterns(repo_path: &Path, patterns: &[&str]) -> Result<Vec<String>> {
let all_files = run_git(repo_path, &["ls-tree", "-r", "--name-only", "HEAD"])?;
let mut matched = Vec::new();
for file in all_files.lines() {
for pattern in patterns {
if matches_pattern(file, pattern) {
matched.push(file.to_string());
break;
}
}
}
Ok(matched)
}
fn matches_pattern(path: &str, pattern: &str) -> bool {
if pattern == "**/*" {
return true;
}
if pattern.starts_with("**/") && pattern.ends_with("/**") {
let middle = pattern.trim_start_matches("**/").trim_end_matches("/**");
return path.starts_with(&format!("{middle}/")) || path.contains(&format!("/{middle}/"));
}
if pattern.ends_with("/**") {
let prefix = pattern.trim_end_matches("/**");
return path.starts_with(prefix) || path.starts_with(&format!("{prefix}/"));
}
if pattern.ends_with('/') {
let dir = pattern.trim_end_matches('/');
return path.starts_with(dir) || path.contains(&format!("/{dir}/"));
}
if pattern.starts_with("**/") {
let suffix = pattern.trim_start_matches("**/");
if suffix.contains('*') {
if let Some(ext) = suffix.strip_prefix("*.") {
return path.ends_with(&format!(".{ext}"));
}
}
return path.ends_with(suffix) || path.contains(&format!("/{suffix}"));
}
path == pattern || path.starts_with(&format!("{pattern}/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filter_to_patterns() {
let filter = SparseFilter::Format("png".to_string());
assert_eq!(filter.to_patterns(), vec!["**/*.png"]);
let filter = SparseFilter::Category("photos".to_string());
let patterns = filter.to_patterns();
assert!(patterns.contains(&"**/photos/".to_string()));
assert!(patterns.contains(&"**/photos/**".to_string()));
let filter = SparseFilter::Directory("images/test".to_string());
let patterns = filter.to_patterns();
assert!(patterns.contains(&"images/test/".to_string()));
}
#[test]
fn test_matches_pattern() {
assert!(matches_pattern("images/test.png", "**/*.png"));
assert!(!matches_pattern("images/test.jpg", "**/*.png"));
assert!(matches_pattern("photos/image.png", "photos/"));
assert!(matches_pattern("photos/sub/image.png", "photos/**"));
assert!(matches_pattern("images/photos/test.png", "**/photos/**"));
}
#[test]
fn test_sparse_status_percentage() {
let status = SparseStatus {
enabled: true,
patterns: vec![],
checked_out_files: 50,
total_files: Some(200),
};
assert!((status.percentage().unwrap() - 25.0).abs() < 0.01);
}
}