use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use git_lfs_pointer::Pointer;
use git_lfs_store::Store;
use crate::fetch::{self, FetchCommandError};
#[derive(Debug, thiserror::Error)]
pub enum PullCommandError {
#[error(transparent)]
Fetch(#[from] FetchCommandError),
#[error(transparent)]
Git(#[from] git_lfs_git::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("git ls-files failed: {0}")]
LsFiles(String),
#[error("partial pull: {0} object(s) failed to fetch — working tree not updated")]
FetchFailures(usize),
}
pub fn pull(cwd: &Path, refs: &[String]) -> Result<(), PullCommandError> {
pull_with_filter(cwd, refs, &[], &[])
}
pub fn pull_with_filter(
cwd: &Path,
refs: &[String],
include: &[String],
exclude: &[String],
) -> Result<(), PullCommandError> {
let opts = fetch::FetchOptions {
args: refs,
stdin_lines: &[],
dry_run: false,
json: false,
all: false,
refetch: false,
stdin: false,
prune: false,
include,
exclude,
};
let outcome = fetch::fetch(cwd, &opts)?;
if !outcome.report.failed.is_empty() {
return Err(PullCommandError::FetchFailures(outcome.report.failed.len()));
}
if !smudge_filter_installed(cwd) {
println!(
"Skipping object checkout, Git LFS is not installed for this repository.\n\
Consider installing it with 'git lfs install'."
);
return Ok(());
}
let include_set = fetch::build_pattern_set(cwd, include, "lfs.fetchinclude")?;
let exclude_set = fetch::build_pattern_set(cwd, exclude, "lfs.fetchexclude")?;
let store = Store::new(git_lfs_git::lfs_dir(cwd)?);
let tracked = list_tracked_files(cwd)?;
let mut rewritten_paths: Vec<String> = Vec::new();
for path in tracked {
if !fetch::path_passes_filter(Some(&path), &include_set, &exclude_set) {
continue;
}
let full = cwd.join(&path);
let Ok(meta) = std::fs::metadata(&full) else { continue };
if !meta.is_file() || meta.len() >= git_lfs_pointer::MAX_POINTER_SIZE as u64 {
continue;
}
let content = std::fs::read(&full)?;
let Ok(pointer) = Pointer::parse(&content) else { continue };
if !store.contains_with_size(pointer.oid, pointer.size) {
continue;
}
let mut src = store.open(pointer.oid)?;
let mut dst = std::fs::File::create(&full)?;
std::io::copy(&mut src, &mut dst)?;
rewritten_paths.push(path.to_string_lossy().into_owned());
}
if !rewritten_paths.is_empty() {
refresh_index(cwd, &rewritten_paths)?;
println!("Materialized {} working-tree file(s)", rewritten_paths.len());
}
Ok(())
}
fn refresh_index(cwd: &Path, paths: &[String]) -> Result<(), PullCommandError> {
let mut child = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["update-index", "-q", "--refresh", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
for p in paths {
stdin.write_all(p.as_bytes())?;
stdin.write_all(b"\n")?;
}
}
let _ = child.wait()?;
Ok(())
}
fn smudge_filter_installed(cwd: &Path) -> bool {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--get", "filter.lfs.clean"])
.output();
matches!(out, Ok(o) if o.status.success() && !o.stdout.trim_ascii().is_empty())
}
fn list_tracked_files(cwd: &Path) -> Result<Vec<PathBuf>, PullCommandError> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["ls-files", "-z"])
.output()?;
if !out.status.success() {
return Err(PullCommandError::LsFiles(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
Ok(out
.stdout
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|bytes| PathBuf::from(String::from_utf8_lossy(bytes).into_owned()))
.collect())
}