use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use git_lfs_api::ObjectSpec;
use git_lfs_pointer::Oid;
use git_lfs_store::Store;
use git_lfs_transfer::Report;
use crate::LfsFetcher;
#[derive(Debug, thiserror::Error)]
pub enum PushCommandError {
#[error(transparent)]
Git(#[from] git_lfs_git::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("git for-each-ref failed: {0}")]
ForEachRef(String),
#[error("upload failed: {0}")]
Fetch(git_lfs_filter::FetchError),
#[error("{0}")]
Usage(String),
}
#[derive(Debug, Default)]
pub struct PushOutcome {
pub report: Report,
pub aborted: bool,
}
pub struct PushOptions<'a> {
pub args: &'a [String],
pub stdin_lines: &'a [String],
pub dry_run: bool,
pub all: bool,
pub stdin: bool,
pub object_id: bool,
}
pub fn push(
cwd: &Path,
remote: &str,
opts: &PushOptions<'_>,
) -> Result<PushOutcome, PushCommandError> {
if !is_remote_or_url(cwd, remote) {
return Err(PushCommandError::Usage(format!(
"Invalid remote name: {remote:?}"
)));
}
let (effective_args, stdin_overrode_args) = if opts.stdin {
(opts.stdin_lines, !opts.args.is_empty())
} else {
(opts.args, false)
};
if stdin_overrode_args {
eprintln!("Further command line arguments are ignored with --stdin.");
}
if opts.object_id {
return push_by_oid(cwd, remote, effective_args, opts);
}
if opts.all {
let walk_refs = if effective_args.is_empty() {
all_local_refs(cwd)?
} else {
effective_args.to_vec()
};
let ref_strs: Vec<&str> = walk_refs.iter().map(String::as_str).collect();
let excludes_owned = remote_tracking_refs(cwd, remote)?;
let excludes: Vec<&str> = excludes_owned.iter().map(String::as_str).collect();
return upload_in_range(cwd, remote, &ref_strs, &excludes, None, opts.dry_run);
}
if effective_args.is_empty() {
return Err(PushCommandError::Usage(
"At least one ref must be supplied without --all".into(),
));
}
for r in effective_args {
if !is_resolvable_ref(cwd, r) {
return Err(PushCommandError::Usage(format!(
"Invalid ref argument: {r:?}"
)));
}
}
let ref_strs: Vec<&str> = effective_args.iter().map(String::as_str).collect();
let excludes_owned = remote_tracking_refs(cwd, remote)?;
let excludes: Vec<&str> = excludes_owned.iter().map(String::as_str).collect();
upload_in_range(cwd, remote, &ref_strs, &excludes, None, opts.dry_run)
}
fn push_by_oid(
cwd: &Path,
remote: &str,
oids: &[String],
opts: &PushOptions<'_>,
) -> Result<PushOutcome, PushCommandError> {
if oids.is_empty() {
if opts.stdin {
return Ok(PushOutcome::default());
}
return Err(PushCommandError::Usage(
"At least one object ID must be supplied with --object-id".into(),
));
}
let store = Store::new(git_lfs_git::lfs_dir(cwd)?)
.with_references(git_lfs_git::lfs_alternate_dirs(cwd).unwrap_or_default());
let mut to_upload: Vec<ObjectSpec> = Vec::with_capacity(oids.len());
for raw in oids {
let oid = parse_oid(raw)?;
let path = store.object_path(oid);
let size = std::fs::metadata(&path).map(|m| m.len()).map_err(|_| {
PushCommandError::Usage(format!("object {oid} not found in local LFS store"))
})?;
to_upload.push(ObjectSpec {
oid: oid.to_string(),
size,
});
}
if opts.dry_run {
for spec in &to_upload {
println!("push {} => ", spec.oid);
}
return Ok(PushOutcome::default());
}
let mut fetcher = LfsFetcher::from_repo_with_remote(cwd, &store, Some(remote))?;
let _ = &mut fetcher;
let total = to_upload.len();
let total_bytes: u64 = to_upload.iter().map(|s| s.size).sum();
let succeeded_bytes_lookup: HashMap<String, u64> =
to_upload.iter().map(|s| (s.oid.clone(), s.size)).collect();
let report = fetcher
.upload_many(to_upload)
.map_err(PushCommandError::Fetch)?;
fetcher.persist_access_mode();
let succeeded = report.succeeded.len();
let succeeded_bytes: u64 = report
.succeeded
.iter()
.filter_map(|oid| succeeded_bytes_lookup.get(oid).copied())
.sum();
let percent = if total_bytes == 0 {
100
} else {
((succeeded_bytes as u128 * 100) / total_bytes as u128) as u32
};
println!(
"Uploading LFS objects: {percent}% ({succeeded}/{total}), {}",
human_bytes(succeeded_bytes),
);
for (oid, err) in &report.failed {
eprintln!(" {oid}: {err}");
}
Ok(PushOutcome {
report,
aborted: false,
})
}
fn parse_oid(raw: &str) -> Result<Oid, PushCommandError> {
if raw.len() < 64 {
return Err(PushCommandError::Usage(format!(
"too short object ID: {raw:?}"
)));
}
raw.parse::<Oid>()
.map_err(|_| PushCommandError::Usage(format!("invalid object ID: {raw:?}")))
}
fn is_remote_or_url(cwd: &Path, name: &str) -> bool {
if name.contains("://")
|| name.starts_with("git@")
|| name.starts_with("file://")
|| std::path::Path::new(name).is_absolute()
{
return true;
}
let key = format!("remote.{name}.url");
if matches!(git_lfs_git::config::get_effective(cwd, &key), Ok(Some(_))) {
return true;
}
git_lfs_git::endpoint_for_remote(cwd, Some(name)).is_ok()
}
fn is_resolvable_ref(cwd: &Path, r: &str) -> bool {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("{r}^{{commit}}"),
])
.output();
matches!(out, Ok(o) if o.status.success())
}
fn all_local_refs(cwd: &Path) -> Result<Vec<String>, PushCommandError> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args([
"for-each-ref",
"--format=%(refname)",
"refs/heads/",
"refs/tags/",
])
.output()?;
if !out.status.success() {
return Err(PushCommandError::ForEachRef(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
pub(crate) fn upload_in_range(
cwd: &Path,
remote: &str,
includes: &[&str],
excludes: &[&str],
refspec: Option<String>,
dry_run: bool,
) -> Result<PushOutcome, PushCommandError> {
upload_in_range_with_args(cwd, remote, includes, excludes, &[], refspec, dry_run)
}
pub(crate) fn upload_in_range_with_args(
cwd: &Path,
remote: &str,
includes: &[&str],
excludes: &[&str],
extra_rev_list_args: &[&str],
refspec: Option<String>,
dry_run: bool,
) -> Result<PushOutcome, PushCommandError> {
let store = Store::new(git_lfs_git::lfs_dir(cwd)?)
.with_references(git_lfs_git::lfs_alternate_dirs(cwd).unwrap_or_default());
let pointers =
git_lfs_git::scan_pointers_with_args(cwd, includes, excludes, extra_rev_list_args)?;
let mut to_upload: Vec<ObjectSpec> = Vec::new();
let mut paths: HashMap<String, PathBuf> = HashMap::new();
let mut missing: Vec<(ObjectSpec, Option<PathBuf>)> = Vec::new();
let mut corrupt: Vec<(ObjectSpec, Option<PathBuf>)> = Vec::new();
for entry in pointers {
if entry.size == 0 {
continue;
}
let oid_str = entry.oid.to_string();
if let Some(p) = entry.path.clone() {
paths.entry(oid_str.clone()).or_insert(p);
}
let spec = ObjectSpec {
oid: oid_str,
size: entry.size,
};
if store.contains_with_size(entry.oid, entry.size) {
to_upload.push(spec);
} else if store.contains(entry.oid) {
corrupt.push((spec, entry.path));
} else {
missing.push((spec, entry.path));
}
}
if !extra_rev_list_args.is_empty() {
use std::collections::HashSet;
let known: HashSet<git_lfs_pointer::Oid> = to_upload
.iter()
.chain(missing.iter().map(|(s, _)| s))
.chain(corrupt.iter().map(|(s, _)| s))
.filter_map(|s| s.oid.parse().ok())
.collect();
let full = git_lfs_git::scan_pointers_with_args(cwd, includes, excludes, &[])?;
for entry in full {
if known.contains(&entry.oid) {
continue;
}
if entry.size == 0 {
continue;
}
let oid_str = entry.oid.to_string();
if let Some(p) = entry.path.clone() {
paths.entry(oid_str.clone()).or_insert(p);
}
let spec = ObjectSpec {
oid: oid_str,
size: entry.size,
};
if store.contains_with_size(entry.oid, entry.size) {
to_upload.push(spec);
} else if store.contains(entry.oid) {
corrupt.push((spec, entry.path));
} else {
missing.push((spec, entry.path));
}
}
}
if dry_run {
for spec in &to_upload {
if let Some(p) = paths.get(&spec.oid) {
println!("push {} => {}", spec.oid, p.display());
}
}
for (spec, _) in missing.iter().chain(corrupt.iter()) {
if let Some(p) = paths.get(&spec.oid) {
println!("push {} => {}", spec.oid, p.display());
}
}
return Ok(PushOutcome::default());
}
let mut fetcher = LfsFetcher::from_repo_with_remote(cwd, &store, Some(remote))?;
if refspec.is_some() {
fetcher = fetcher.with_refspec(refspec);
}
let endpoint = git_lfs_git::endpoint_for_remote(cwd, Some(remote))
.map_err(|e| std::io::Error::other(e.to_string()))?;
let changed_paths = changed_paths_in_range(cwd, includes, excludes, extra_rev_list_args)?;
let theirs_blockers: Vec<git_lfs_api::Lock> =
match fetcher.preflight_verify_locks(cwd, remote, &endpoint)? {
crate::locks_verify::Outcome::Aborted => {
return Ok(PushOutcome {
report: Report::default(),
aborted: true,
});
}
crate::locks_verify::Outcome::Skipped => Vec::new(),
crate::locks_verify::Outcome::Verified { ours, theirs } => {
if !ours.is_empty() {
let ours_paths: Vec<&str> = ours.iter().map(|l| l.path.as_str()).collect();
let any_ours_pushed = ours_paths
.iter()
.any(|op| changed_paths.iter().any(|p| p == op));
if any_ours_pushed {
eprintln!(
"Consider unlocking your own locked files: \
(`git lfs unlock <path>`)"
);
for op in &ours_paths {
if changed_paths.iter().any(|p| p == op) {
eprintln!("* {op}");
}
}
}
}
theirs
.into_iter()
.filter(|l| changed_paths.iter().any(|p| p == &l.path))
.collect()
}
};
if !theirs_blockers.is_empty() {
eprintln!("Unable to push locked files:");
for l in &theirs_blockers {
let owner = l
.owner
.as_ref()
.map(|o| o.name.as_str())
.unwrap_or("unknown user");
eprintln!("* {} - {}", l.path, owner);
}
eprintln!("Cannot update locked files.");
return Ok(PushOutcome {
report: Report::default(),
aborted: true,
});
}
let truly_missing: Vec<(ObjectSpec, Option<PathBuf>)> = if missing.is_empty() {
Vec::new()
} else {
let server_has = fetcher
.check_server_has(missing.iter().map(|(s, _)| s.clone()).collect())
.map_err(PushCommandError::Fetch)?;
missing
.into_iter()
.filter(|(s, _)| !server_has.contains(&s.oid))
.collect()
};
if !truly_missing.is_empty() {
let allow = allow_incomplete_push(cwd);
if !allow {
for (spec, _) in &truly_missing {
eprintln!(
"tq: stopping batched queue, object \"{}\" missing locally and on remote",
spec.oid,
);
}
eprintln!("LFS upload failed:");
for (spec, path) in &truly_missing {
let path_str = path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
eprintln!(" (missing) {path_str} ({})", spec.oid);
}
return Ok(PushOutcome {
report: Report::default(),
aborted: true,
});
} else {
eprintln!("LFS upload missing objects");
for (spec, path) in &truly_missing {
let path_str = path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
eprintln!(" (missing) {path_str} ({})", spec.oid);
}
}
}
if to_upload.is_empty() {
if !corrupt.is_empty() {
eprintln!("LFS upload failed:");
for (spec, path) in &corrupt {
let path_str = path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
eprintln!(" (corrupt) {path_str} ({})", spec.oid);
}
return Ok(PushOutcome {
report: Report::default(),
aborted: true,
});
}
return Ok(PushOutcome::default());
}
let total = to_upload.len();
let total_bytes: u64 = to_upload.iter().map(|s| s.size).sum();
let succeeded_bytes_lookup: HashMap<String, u64> =
to_upload.iter().map(|s| (s.oid.clone(), s.size)).collect();
let report = fetcher
.upload_many(to_upload)
.map_err(PushCommandError::Fetch)?;
fetcher.persist_access_mode();
let succeeded = report.succeeded.len();
let succeeded_bytes: u64 = report
.succeeded
.iter()
.filter_map(|oid| succeeded_bytes_lookup.get(oid).copied())
.sum();
let percent = if total_bytes == 0 {
100
} else {
((succeeded_bytes as u128 * 100) / total_bytes as u128) as u32
};
println!(
"Uploading LFS objects: {percent}% ({succeeded}/{total}), {}",
human_bytes(succeeded_bytes),
);
for (oid, err) in &report.failed {
eprintln!(" {oid}: {err}");
}
let aborted = !corrupt.is_empty();
if aborted {
eprintln!("LFS upload failed:");
for (spec, path) in &corrupt {
let path_str = path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
eprintln!(" (corrupt) {path_str} ({})", spec.oid);
}
}
Ok(PushOutcome { report, aborted })
}
fn changed_paths_in_range(
cwd: &Path,
includes: &[&str],
excludes: &[&str],
extra_rev_list_args: &[&str],
) -> Result<Vec<String>, PushCommandError> {
if includes.is_empty() {
return Ok(Vec::new());
}
let mut args: Vec<&str> = vec!["log", "-z", "--pretty=format:", "--name-only"];
args.extend(includes.iter().copied());
if !excludes.is_empty() {
args.push("--not");
args.extend(excludes.iter().copied());
}
args.extend(extra_rev_list_args.iter().copied());
args.push("--");
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(&args)
.output()
.map_err(PushCommandError::Io)?;
if !out.status.success() {
return Err(PushCommandError::Usage(format!(
"git log failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for raw in out.stdout.split(|&b| b == 0) {
if raw.is_empty() {
continue;
}
let s = String::from_utf8_lossy(raw);
let trimmed = s.trim();
if !trimmed.is_empty() {
seen.insert(trimmed.to_owned());
}
}
Ok(seen.into_iter().collect())
}
fn allow_incomplete_push(cwd: &Path) -> bool {
git_lfs_git::config::get_effective(cwd, "lfs.allowincompletepush")
.ok()
.flatten()
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
pub(crate) fn human_bytes(n: u64) -> String {
const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB", "EB"];
if n < 1000 {
return format!("{n} B");
}
let mut value = n as f64;
let mut idx = 0;
while value >= 1000.0 && idx < UNITS.len() - 1 {
value /= 1000.0;
idx += 1;
}
format!("{value:.1} {}", UNITS[idx])
}
pub(crate) fn remote_tracking_refs(
cwd: &Path,
remote: &str,
) -> Result<Vec<String>, PushCommandError> {
let pattern = format!("refs/remotes/{remote}/");
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["for-each-ref", "--format=%(refname)", &pattern])
.output()?;
if !out.status.success() {
return Err(PushCommandError::ForEachRef(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}