use std::path::{Path, PathBuf};
use crate::config::{ConfigFile, ConfigScope, ConfigSet};
use crate::environment::RepositoryOptions;
use crate::error::{Error, Result};
use crate::index_pack::{install_pack_path, IngestPackOptions};
use crate::maintenance::{GcLock, GcLockAttempt, GcLockHolder, RepackOptions};
use crate::objects::{
oid_hash_set, CommitRef, HashAlgo, ObjectId, ObjectKind, OidHashMap, OidHashSet, TreeRefIter,
};
use crate::odb::Odb;
use crate::pack_index::PackIndex;
use crate::pack_objects::PackBuildOptions;
use crate::partial_clone::{stored_remote_filter, ObjectFilterSpec};
use crate::repo::Repository;
pub const LOP_KEEP_MESSAGE: &str = "grit offload: large-object promisor pack";
pub const LOP_GIT_DIR_KEY: &str = "grit.lopGitDir";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct OffloadOptions {
pub remote: String,
pub filter: Option<ObjectFilterSpec>,
pub lop_git_dir: Option<PathBuf>,
pub configure_lop: bool,
pub dry_run: bool,
pub now_unix: i64,
pub threads: Option<usize>,
}
impl OffloadOptions {
#[must_use]
pub fn new(remote: impl Into<String>, now_unix: i64) -> Self {
Self {
remote: remote.into(),
filter: None,
lop_git_dir: None,
configure_lop: false,
dry_run: false,
now_unix,
threads: None,
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OffloadError {
#[error("remote '{remote}' has no url configured")]
RemoteNotConfigured {
remote: String,
},
#[error("remote '{remote}' is not a local repository ({url}); give its git directory")]
LopNotLocal {
remote: String,
url: String,
},
#[error("{} is not a git repository", path.display())]
LopNotARepository {
path: PathBuf,
},
#[error("the large-object promisor is this repository itself")]
LopIsMainRepository,
#[error(
"object formats differ: this repository uses {}, the promisor {}",
main.name(),
lop.name()
)]
HashAlgoMismatch {
main: HashAlgo,
lop: HashAlgo,
},
#[error("no size threshold: give a filter or set remote.{remote}.partialCloneFilter")]
NoThreshold {
remote: String,
},
#[error("blob {oid} is not in the promisor after copying it there")]
NotInLopAfterCopy {
oid: ObjectId,
},
#[error(
"{oid} is only in kept pack {}, and {referrer} names it",
pack.display()
)]
KeptPackObjectPromised {
pack: PathBuf,
oid: ObjectId,
referrer: ObjectId,
},
#[error("maintenance is already running on this repository")]
MaintenanceRunning {
holder: GcLockHolder,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OffloadRepo {
Main,
Lop,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct OffloadConfigChange {
pub repo: OffloadRepo,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct LopReadiness {
pub allow_filter: bool,
pub allow_any_sha1_in_want: bool,
}
impl LopReadiness {
#[must_use]
pub fn is_ready(&self) -> bool {
self.allow_filter && self.allow_any_sha1_in_want
}
fn from_config(config: &ConfigSet) -> Self {
let flag = |key: &str| config.get_bool(key).and_then(|v| v.ok()).unwrap_or(false);
Self {
allow_filter: flag("uploadpack.allowfilter"),
allow_any_sha1_in_want: flag("uploadpack.allowanysha1inwant"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct OffloadReport {
pub remote: String,
pub lop_git_dir: PathBuf,
pub filter: ObjectFilterSpec,
pub dry_run: bool,
pub candidates: usize,
pub candidate_bytes: u64,
pub copied_to_lop: usize,
pub already_in_lop: usize,
pub dropped_from_main: usize,
pub skipped_checked_out: usize,
pub skipped_kept_pack: usize,
pub lop_pack: Option<OffloadPack>,
pub main_pack: Option<OffloadPack>,
pub main_bytes_before: u64,
pub main_bytes_after: u64,
pub config_changes: Vec<OffloadConfigChange>,
pub lop_ready: LopReadiness,
pub advertised: bool,
}
impl OffloadReport {
pub fn packs(&self) -> impl Iterator<Item = &OffloadPack> {
[&self.lop_pack, &self.main_pack].into_iter().flatten()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OffloadPackRole {
Lop,
MainPromisor,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct OffloadPack {
pub role: OffloadPackRole,
pub path: PathBuf,
pub objects: usize,
pub bytes: u64,
}
impl OffloadPack {
fn written(role: OffloadPackRole, path: PathBuf, objects: usize) -> Result<Self> {
let bytes = std::fs::metadata(&path)?.len();
Ok(Self {
role,
path,
objects,
bytes,
})
}
}
pub fn offload_blobs(repo: &Repository, opts: &OffloadOptions) -> Result<OffloadReport> {
let odb = repo.odb();
odb.require_files_primary("offload")?;
let config = repo.config()?;
let remote = opts.remote.as_str();
let url = config
.get(&format!("remote.{remote}.url"))
.filter(|u| !u.trim().is_empty())
.ok_or_else(|| OffloadError::RemoteNotConfigured {
remote: remote.to_owned(),
})?;
let filter = opts
.filter
.or_else(|| stored_remote_filter(&config, remote))
.ok_or_else(|| OffloadError::NoThreshold {
remote: remote.to_owned(),
})?;
let lop = open_lop(repo, opts, &url)?;
let _lock = if opts.dry_run {
None
} else {
match GcLock::try_acquire(repo, opts.now_unix)? {
GcLockAttempt::Acquired(lock) => Some(lock),
GcLockAttempt::Held(holder) => {
return Err(OffloadError::MaintenanceRunning { holder }.into())
}
}
};
let main_bytes_before = local_object_bytes(odb)?;
let selection = select_candidates(repo, filter)?;
let candidates: Vec<ObjectId> = selection.candidates.keys().copied().collect();
if let Some(refusal) = kept_object_the_promisor_pack_names(odb, &selection.candidates)? {
return Err(refusal.into());
}
let in_lop = held_in_lop_keep_packs(lop.odb(), &candidates)?;
let missing_in_lop: Vec<ObjectId> = candidates
.iter()
.zip(&in_lop)
.filter_map(|(oid, has)| (!has).then_some(*oid))
.collect();
let mut report = OffloadReport {
remote: remote.to_owned(),
lop_git_dir: lop.git_dir().to_path_buf(),
filter,
dry_run: opts.dry_run,
candidates: candidates.len(),
candidate_bytes: selection.candidates.values().sum(),
copied_to_lop: 0,
already_in_lop: candidates.len() - missing_in_lop.len(),
dropped_from_main: 0,
skipped_checked_out: selection.skipped_checked_out,
skipped_kept_pack: selection.skipped_kept_pack,
lop_pack: None,
main_pack: None,
main_bytes_before,
main_bytes_after: main_bytes_before,
config_changes: Vec::new(),
lop_ready: LopReadiness::from_config(&lop_config(&lop)?),
advertised: config
.get_bool("promisor.advertise")
.and_then(|v| v.ok())
.unwrap_or(false),
};
if opts.dry_run {
return Ok(report);
}
if !missing_in_lop.is_empty() {
report.lop_pack = copy_to_lop(repo, &lop, &missing_in_lop, opts)?
.map(|(path, objects)| OffloadPack::written(OffloadPackRole::Lop, path, objects))
.transpose()?;
report.copied_to_lop = missing_in_lop.len();
}
lop.odb().invalidate_packs();
let present = held_in_lop_keep_packs(lop.odb(), &candidates)?;
if let Some((oid, _)) = candidates.iter().zip(&present).find(|(_, has)| !**has) {
return Err(OffloadError::NotInLopAfterCopy { oid: *oid }.into());
}
if let Some(pack) = &report.lop_pack {
crate::index_pack::sync_installed_pack(&pack.path)?;
}
let mut wanted: Vec<(String, String)> = Vec::new();
let promisor_key = format!("remote.{remote}.promisor");
if config.get_bool(&promisor_key).and_then(|v| v.ok()) != Some(true) {
wanted.push((promisor_key, "true".to_owned()));
}
let filter_key = format!("remote.{remote}.partialCloneFilter");
if opts.filter.is_some() && config.get(&filter_key).is_none() {
wanted.push((filter_key, filter.to_config_value()));
}
let bitmaps_key = "repack.writeBitmaps";
if config.get_bool(bitmaps_key).and_then(|v| v.ok()) != Some(false) {
wanted.push((bitmaps_key.to_owned(), "false".to_owned()));
}
let mut added: Vec<(String, String)> = Vec::new();
let lop_dir = lop_dir_value(lop.git_dir());
if !config
.get_all(LOP_GIT_DIR_KEY)
.iter()
.any(|v| same_dir(Path::new(v), lop.git_dir()))
{
added.push((LOP_GIT_DIR_KEY.to_owned(), lop_dir));
}
write_config(repo.git_dir(), &wanted, &added)?;
report
.config_changes
.extend(
wanted
.into_iter()
.chain(added)
.map(|(key, value)| OffloadConfigChange {
repo: OffloadRepo::Main,
key,
value,
}),
);
if opts.configure_lop {
let lop_changes: Vec<(String, String)> = [
("uploadpack.allowFilter", report.lop_ready.allow_filter),
(
"uploadpack.allowAnySHA1InWant",
report.lop_ready.allow_any_sha1_in_want,
),
]
.into_iter()
.filter(|(_, already)| !already)
.map(|(key, _)| (key.to_owned(), "true".to_owned()))
.collect();
write_config(lop.git_dir(), &lop_changes, &[])?;
report
.config_changes
.extend(
lop_changes
.into_iter()
.map(|(key, value)| OffloadConfigChange {
repo: OffloadRepo::Lop,
key,
value,
}),
);
report.lop_ready = LopReadiness::from_config(&lop_config(&lop)?);
}
let omit: OidHashSet = candidates.iter().copied().collect();
let repack = RepackOptions {
threads: opts.threads,
environment: repo.environment().clone(),
..RepackOptions::default()
};
let rewrite = crate::maintenance::rewrite_local_objects_without(
repo,
&omit,
&repack,
crate::maintenance::PromisorRewriteWhen::Always,
)?;
report.main_pack = rewrite
.pack_path
.map(|path| OffloadPack::written(OffloadPackRole::MainPromisor, path, rewrite.objects))
.transpose()?;
report.dropped_from_main = odb.missing_stored_oids(candidates.iter().copied())?.len();
report.main_bytes_after = local_object_bytes(odb)?;
Ok(report)
}
fn open_lop(repo: &Repository, opts: &OffloadOptions, url: &str) -> Result<Repository> {
let path = match &opts.lop_git_dir {
Some(path) => path.clone(),
None if crate::transport_path::is_local_path_remote_url(url) => {
crate::transport_path::resolve_local_remote_git_dir(
url,
repo.git_dir(),
repo.work_tree(),
)
}
None => {
return Err(OffloadError::LopNotLocal {
remote: opts.remote.clone(),
url: crate::transport_path::redact_url_for_display(url),
}
.into())
}
};
let lop = Repository::open(
&path,
&RepositoryOptions::with_environment(repo.environment().clone()),
)
.map_err(|_| OffloadError::LopNotARepository { path: path.clone() })?;
if same_object_store(repo, &lop) {
return Err(OffloadError::LopIsMainRepository.into());
}
let (main_algo, lop_algo) = (repo.odb().hash_algo(), lop.odb().hash_algo());
if main_algo != lop_algo {
return Err(OffloadError::HashAlgoMismatch {
main: main_algo,
lop: lop_algo,
}
.into());
}
lop.odb().require_files_primary("offload")?;
Ok(lop)
}
fn lop_config(lop: &Repository) -> Result<ConfigSet> {
ConfigSet::load(lop.environment(), Some(lop.git_dir()), true)
}
struct Selection {
candidates: OidHashMap<u64>,
skipped_checked_out: usize,
skipped_kept_pack: usize,
}
fn select_candidates(repo: &Repository, filter: ObjectFilterSpec) -> Result<Selection> {
let odb = repo.odb();
let pack_dir = odb.objects_dir().join("pack");
let (kept, rewritten): (Vec<_>, Vec<_>) = odb
.local_pack_indexes()?
.into_iter()
.partition(|idx| pack_dir.join(keep_name(&idx.pack_path)).is_file());
let in_any = |indexes: &[std::sync::Arc<PackIndex>], oid: &ObjectId| {
indexes.iter().any(|idx| idx.find_position(oid).is_some())
};
let will_be_rewritten =
|oid: &ObjectId| in_any(&rewritten, oid) || odb.local_loose_file_size(oid).is_some();
let checked_out = crate::promisor::blobs_needed_by_checkouts(repo)?;
let mut selection = Selection {
candidates: crate::objects::oid_hash_map(),
skipped_checked_out: 0,
skipped_kept_pack: 0,
};
let mut unanchored: OidHashMap<u64> = crate::objects::oid_hash_map();
let mut blobs_seen = oid_hash_set();
let mut walked = oid_hash_set();
let mut queue: Vec<ObjectId> = crate::maintenance::collect_maintenance_object_roots(repo)?;
let oid_len = odb.hash_algo().len();
while let Some(oid) = queue.pop() {
if !walked.insert(oid) {
continue;
}
let Some(obj) = odb.try_read_once(&oid)? else {
continue;
};
match obj.kind {
ObjectKind::Commit => {
let commit = CommitRef::parse(&obj.data)?;
queue.push(commit.tree());
queue.extend(commit.parents().iter().copied());
}
ObjectKind::Tag => {
if let Some(target) = crate::objects::tag_object_line_oid(&obj.data) {
queue.push(target);
}
}
ObjectKind::Tree => {
let anchors = will_be_rewritten(&oid);
for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
let entry = entry?;
match entry.mode & 0o170000 {
0o100000 | 0o120000 => {}
0o040000 => {
queue.push(entry.oid);
continue;
}
_ => continue,
}
if anchors {
if let Some(size) = unanchored.remove(&entry.oid) {
selection.candidates.insert(entry.oid, size);
}
}
if !blobs_seen.insert(entry.oid) {
continue;
}
let Some(size) = selected_blob_size(odb, filter, &entry.oid)? else {
continue;
};
if checked_out.contains(&entry.oid) {
selection.skipped_checked_out += 1;
} else if in_any(&kept, &entry.oid) {
selection.skipped_kept_pack += 1;
} else if anchors {
selection.candidates.insert(entry.oid, size);
} else {
unanchored.insert(entry.oid, size);
}
}
}
ObjectKind::Blob => {}
}
}
selection.skipped_kept_pack += unanchored.len();
Ok(selection)
}
fn kept_object_the_promisor_pack_names(
odb: &Odb,
omit: &OidHashMap<u64>,
) -> Result<Option<OffloadError>> {
odb.reprepare_local_packs();
let (kept, rewritten): (Vec<_>, Vec<_>) = odb
.local_pack_indexes()?
.into_iter()
.partition(|idx| idx.pack_path.with_extension("keep").is_file());
let (kept_promisor, kept_ordinary): (Vec<_>, Vec<_>) =
kept.into_iter().partition(|idx| idx.is_promisor);
if kept_ordinary.is_empty() {
return Ok(None);
}
let loose: OidHashSet = odb
.enumerate_local_loose_objects()?
.into_iter()
.map(|(oid, _)| oid)
.collect();
let in_promisor_pack_after = |oid: &ObjectId| {
loose.contains(oid)
|| [&rewritten, &kept_promisor]
.into_iter()
.flatten()
.any(|idx| idx.find_position(oid).is_some())
};
let mut only_kept: OidHashMap<usize> = crate::objects::oid_hash_map();
for (pack_no, idx) in kept_ordinary.iter().enumerate() {
for entry in idx.iter() {
let oid = ObjectId::from_bytes(entry.oid())?;
if !in_promisor_pack_after(&oid) {
only_kept.entry(oid).or_insert(pack_no);
}
}
}
if only_kept.is_empty() {
return Ok(None);
}
let mut referrers: Vec<ObjectId> = loose.iter().copied().collect();
for idx in &rewritten {
for entry in idx.iter() {
referrers.push(ObjectId::from_bytes(entry.oid())?);
}
}
let oid_len = odb.hash_algo().len();
let mut checked = oid_hash_set();
for referrer in referrers {
if omit.contains_key(&referrer) || !checked.insert(referrer) {
continue;
}
let kind = match odb.read_info_without_fetch(&referrer) {
Ok(info) => info.kind,
Err(Error::ObjectNotFound(_)) => continue,
Err(err) => return Err(err),
};
if kind == ObjectKind::Blob {
continue;
}
let obj = odb.read(&referrer)?;
let mut named: Vec<ObjectId> = Vec::new();
match kind {
ObjectKind::Commit => {
let commit = CommitRef::parse(&obj.data)?;
named.push(commit.tree());
named.extend(commit.parents().iter().copied());
}
ObjectKind::Tree => {
for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
let entry = entry?;
if entry.mode & 0o170000 != 0o160000 {
named.push(entry.oid);
}
}
}
ObjectKind::Tag => named.extend(crate::objects::tag_object_line_oid(&obj.data)),
ObjectKind::Blob => {}
}
if let Some((oid, &pack_no)) = named.iter().find_map(|oid| only_kept.get_key_value(oid)) {
return Ok(Some(OffloadError::KeptPackObjectPromised {
pack: kept_ordinary[pack_no].pack_path.clone(),
oid: *oid,
referrer,
}));
}
}
Ok(None)
}
fn selected_blob_size(odb: &Odb, filter: ObjectFilterSpec, oid: &ObjectId) -> Result<Option<u64>> {
if !odb.try_exists_stored(oid)? {
return Ok(None);
}
let info = match odb.read_info_without_fetch(oid) {
Ok(info) => info,
Err(Error::ObjectNotFound(_)) => return Ok(None),
Err(err) => return Err(err),
};
if info.kind != ObjectKind::Blob {
return Ok(None);
}
let selected = match filter {
ObjectFilterSpec::BlobNone => true,
ObjectFilterSpec::BlobLimit(limit) => info.size >= limit,
};
Ok(selected.then_some(info.size))
}
fn keep_name(pack_path: &Path) -> PathBuf {
PathBuf::from(pack_path.file_name().unwrap_or_default()).with_extension("keep")
}
fn copy_to_lop(
repo: &Repository,
lop: &Repository,
blobs: &[ObjectId],
opts: &OffloadOptions,
) -> Result<Option<(PathBuf, usize)>> {
let cfg = repo.config().ok();
let pack_opts = PackBuildOptions {
delta: false,
window: 0,
max_depth: 0,
reuse_deltas: false,
reuse_objects: true,
use_bitmaps: false,
thin: false,
..PackBuildOptions::for_local_copy(cfg.as_deref())
};
let lop_pack_dir = lop.odb().objects_dir().join("pack");
let built = crate::maintenance::write_staged_repack_pack(
repo.odb(),
&lop_pack_dir,
blobs,
&std::collections::HashMap::new(),
&pack_opts,
opts.threads,
cfg.as_deref(),
)?;
let Some(staging) = built.staging_path.as_ref() else {
return Ok(None);
};
let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
let ingested = install_pack_path(
staging,
lop.odb(),
&IngestPackOptions {
threads: opts.threads,
environment: lop.environment().clone(),
skip_post_index_verify: trusted_rows.is_some(),
trusted_index_rows: trusted_rows,
keep_message: Some(LOP_KEEP_MESSAGE.to_owned()),
..IngestPackOptions::default()
},
)?;
let objects = ingested.object_ids.len();
Ok(ingested.pack_path.map(|path| (path, objects)))
}
fn held_in_lop_keep_packs(lop: &Odb, oids: &[ObjectId]) -> Result<Vec<bool>> {
let kept = lop_keep_packs(lop)?;
Ok(oids
.iter()
.map(|oid| kept.iter().any(|idx| idx.find_position(oid).is_some()))
.collect())
}
pub(crate) fn lop_keep_packs(lop: &Odb) -> Result<Vec<std::sync::Arc<PackIndex>>> {
Ok(lop
.local_pack_indexes()?
.into_iter()
.filter(|idx| has_lop_keep_marker(&idx.pack_path))
.collect())
}
fn has_lop_keep_marker(pack_path: &Path) -> bool {
std::fs::read(pack_path.with_extension("keep"))
.is_ok_and(|contents| contents.trim_ascii_end() == LOP_KEEP_MESSAGE.as_bytes())
}
fn write_config(
git_dir: &Path,
changes: &[(String, String)],
additions: &[(String, String)],
) -> Result<()> {
if changes.is_empty() && additions.is_empty() {
return Ok(());
}
let path = git_dir.join("config");
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(Error::Io(err)),
};
let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
for (key, value) in changes {
config.set(key, value)?;
}
for (key, value) in additions {
config.add_value(key, value)?;
}
config.write()
}
fn lop_dir_value(git_dir: &Path) -> String {
use crate::git_path::CanonicalizeExt as _;
git_dir
.canonicalize_simplified()
.unwrap_or_else(|_| git_dir.to_path_buf())
.to_string_lossy()
.into_owned()
}
pub(crate) fn same_object_store(a: &Repository, b: &Repository) -> bool {
same_dir(a.odb().objects_dir(), b.odb().objects_dir())
}
pub(crate) fn same_dir(a: &Path, b: &Path) -> bool {
use crate::git_path::CanonicalizeExt as _;
match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn local_object_bytes(odb: &Odb) -> Result<u64> {
odb.invalidate_packs();
let mut total = 0u64;
for idx in odb.local_pack_indexes()? {
for path in [&idx.pack_path, &idx.idx_path] {
total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
}
}
for (_, path) in odb.enumerate_local_loose_objects()? {
total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
}
Ok(total)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::repo::InitOptions;
const NOW: i64 = 1_700_000_000;
struct Fixture {
dir: tempfile::TempDir,
blobs: Vec<ObjectId>,
}
fn blob_data(seed: usize, size: usize) -> Vec<u8> {
let mut state = (seed as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
(0..size)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect()
}
impl Fixture {
fn new(sizes: &[usize]) -> Self {
let dir = tempfile::tempdir().unwrap();
let init = InitOptions::new()
.with_bare(true)
.with_initial_branch("main");
let main = Repository::init(&dir.path().join("main.git"), &init).unwrap();
Repository::init(&dir.path().join("lop.git"), &init).unwrap();
let odb = main.odb();
let blobs: Vec<ObjectId> = sizes
.iter()
.enumerate()
.map(|(i, &size)| odb.write(ObjectKind::Blob, &blob_data(i, size)).unwrap())
.collect();
let mut entries: Vec<crate::objects::TreeEntry> = blobs
.iter()
.enumerate()
.map(|(i, oid)| crate::objects::TreeEntry {
mode: 0o100644,
name: format!("f{i}").into_bytes(),
oid: *oid,
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
let tree = odb
.write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
.unwrap();
let commit =
format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nseed\n");
let commit = odb.write(ObjectKind::Commit, commit.as_bytes()).unwrap();
std::fs::write(
dir.path().join("main.git/refs/heads/main"),
format!("{commit}\n"),
)
.unwrap();
let fx = Self { dir, blobs };
fx.append_main_config("[remote \"lop\"]\n\turl = ../lop.git\n");
fx
}
fn main_dir(&self) -> PathBuf {
self.dir.path().join("main.git")
}
fn lop_dir(&self) -> PathBuf {
self.dir.path().join("lop.git")
}
fn main(&self) -> Repository {
Repository::open(&self.main_dir(), &RepositoryOptions::empty()).unwrap()
}
fn lop(&self) -> Repository {
Repository::open(&self.lop_dir(), &RepositoryOptions::empty()).unwrap()
}
fn append_main_config(&self, text: &str) {
let path = self.main_dir().join("config");
let mut config = std::fs::read_to_string(&path).unwrap();
config.push_str(text);
std::fs::write(path, config).unwrap();
}
fn offload(&self, limit: u64) -> OffloadReport {
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobLimit(limit));
offload_blobs(&self.main(), &opts).expect("offload")
}
fn files(&self, dir: &Path, ext: &str) -> Vec<PathBuf> {
std::fs::read_dir(dir.join("objects/pack"))
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == ext))
.collect()
})
.unwrap_or_default()
}
}
#[test]
fn blobs_at_the_limit_move_and_smaller_ones_stay() {
let fx = Fixture::new(&[10_000, 9_999, 30_000]);
let report = fx.offload(10_000);
assert_eq!(report.candidates, 2);
assert_eq!(report.candidate_bytes, 40_000);
assert_eq!(report.copied_to_lop, 2);
assert_eq!(report.dropped_from_main, 2);
assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(10_000));
let main = fx.main();
let lop = fx.lop();
for (i, moved) in [(0, true), (1, false), (2, true)] {
let blob = fx.blobs[i];
assert_eq!(!main.odb().exists_stored(&blob), moved, "main f{i}");
assert_eq!(lop.odb().exists_stored(&blob), moved, "lop f{i}");
}
let promisor = fx.files(&fx.main_dir(), "promisor");
assert_eq!(promisor.len(), 1);
assert_eq!(
report.main_pack.as_ref().and_then(|p| p.path.file_name()),
promisor[0].with_extension("pack").file_name()
);
let indexes = main.odb().local_pack_indexes().unwrap();
let promisor_idx = indexes
.iter()
.find(|i| i.is_promisor)
.expect("promisor pack");
assert_eq!(indexes.len(), 1);
assert_eq!(promisor_idx.len(), 3, "the commit, its tree and f1");
assert!(promisor_idx.find_position(&fx.blobs[1]).is_some());
assert!(main
.odb()
.enumerate_local_loose_objects()
.unwrap()
.is_empty());
assert!(report.main_bytes_after < report.main_bytes_before);
let roles: Vec<_> = report.packs().map(|p| (p.role, p.objects)).collect();
assert_eq!(
roles,
[
(OffloadPackRole::Lop, 2),
(OffloadPackRole::MainPromisor, 3),
]
);
for pack in report.packs() {
assert_eq!(pack.bytes, std::fs::metadata(&pack.path).unwrap().len());
}
assert!(report
.lop_pack
.as_ref()
.unwrap()
.path
.starts_with(fx.lop().git_dir()));
}
#[test]
fn lop_pack_is_kept_and_complete() {
let fx = Fixture::new(&[4096]);
let report = fx.offload(1024);
let lop_pack = report.lop_pack.expect("lop pack").path;
assert_eq!(
std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap(),
format!("{LOP_KEEP_MESSAGE}\n")
);
let blob = fx.lop().odb().read(&fx.blobs[0]).unwrap();
assert_eq!(blob.data, blob_data(0, 4096));
}
#[test]
fn config_is_set_in_main_and_optionally_in_the_lop() {
let fx = Fixture::new(&[2048]);
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
opts.configure_lop = true;
let report = offload_blobs(&fx.main(), &opts).unwrap();
let lop_dir = lop_dir_value(fx.lop().git_dir());
assert!(Path::new(&lop_dir).is_absolute());
let keys: Vec<(OffloadRepo, &str, &str)> = report
.config_changes
.iter()
.map(|c| (c.repo, c.key.as_str(), c.value.as_str()))
.collect();
assert_eq!(
keys,
[
(OffloadRepo::Main, "remote.lop.promisor", "true"),
(
OffloadRepo::Main,
"remote.lop.partialCloneFilter",
"blob:limit=1024"
),
(OffloadRepo::Main, "repack.writeBitmaps", "false"),
(OffloadRepo::Main, LOP_GIT_DIR_KEY, lop_dir.as_str()),
(OffloadRepo::Lop, "uploadpack.allowFilter", "true"),
(OffloadRepo::Lop, "uploadpack.allowAnySHA1InWant", "true"),
]
);
assert!(report.lop_ready.is_ready());
assert!(!report.advertised);
let config = fx.main().config().unwrap();
assert_eq!(config.get("remote.lop.promisor").as_deref(), Some("true"));
assert!(config.get("extensions.partialclone").is_none());
assert_eq!(
crate::partial_clone::stored_remote_filter(&config, "lop"),
Some(ObjectFilterSpec::BlobLimit(1024))
);
assert_eq!(config.get_all(LOP_GIT_DIR_KEY), [lop_dir.clone()]);
let again = offload_blobs(&fx.main(), &opts).unwrap();
assert!(
again.config_changes.is_empty(),
"{:?}",
again.config_changes
);
assert_eq!(
fx.main().config().unwrap().get_all(LOP_GIT_DIR_KEY),
[lop_dir]
);
}
#[test]
fn stored_filter_is_used_and_lop_is_left_alone_by_default() {
let fx = Fixture::new(&[2048, 10]);
fx.append_main_config("\tpartialCloneFilter = blob:limit=1k\n");
let report = offload_blobs(&fx.main(), &OffloadOptions::new("lop", NOW)).unwrap();
assert_eq!(report.candidates, 1);
assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(1024));
let keys: Vec<&str> = report
.config_changes
.iter()
.map(|c| c.key.as_str())
.collect();
assert_eq!(
keys,
[
"remote.lop.promisor",
"repack.writeBitmaps",
LOP_GIT_DIR_KEY
]
);
assert_eq!(report.lop_ready, LopReadiness::default());
}
fn tree_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(path) = stack.pop() {
if path.is_dir() {
stack.extend(
std::fs::read_dir(&path)
.unwrap()
.flatten()
.map(|e| e.path()),
);
} else {
out.push((path.clone(), std::fs::read(&path).unwrap()));
}
}
out.sort();
out
}
#[test]
fn dry_run_writes_nothing() {
let fx = Fixture::new(&[2048]);
let before = tree_snapshot(&fx.main_dir());
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobNone);
opts.dry_run = true;
opts.configure_lop = true;
let report = offload_blobs(&fx.main(), &opts).unwrap();
assert!(report.dry_run);
assert_eq!(report.candidates, 1);
assert_eq!(report.copied_to_lop, 0);
assert!(report.config_changes.is_empty());
assert_eq!(tree_snapshot(&fx.main_dir()), before);
assert!(fx.files(&fx.lop_dir(), "pack").is_empty());
assert!(!fx.main_dir().join("gc.pid").exists());
}
#[test]
fn running_again_is_a_no_op_until_new_blobs_arrive() {
let fx = Fixture::new(&[2048, 10]);
fx.offload(1024);
let again = fx.offload(1024);
assert_eq!(again.candidates, 0);
assert_eq!(again.copied_to_lop, 0);
assert!(again.main_pack.is_none(), "nothing to rewrite");
assert_eq!(fx.files(&fx.lop_dir(), "pack").len(), 1);
assert_eq!(fx.files(&fx.main_dir(), "pack").len(), 1);
}
fn pack_into_lop(fx: &Fixture, message: &str) {
let pack = crate::pack_objects::build_pack_from_send_list(
fx.main().odb(),
&[fx.blobs[0]],
&std::collections::HashSet::new(),
&PackBuildOptions::default(),
&std::collections::HashMap::new(),
)
.unwrap();
crate::index_pack::install_pack_bytes(
pack,
fx.lop().odb(),
&IngestPackOptions {
keep_message: Some(message.to_owned()),
..IngestPackOptions::default()
},
)
.unwrap();
}
#[test]
fn blobs_the_lop_keeps_are_not_copied_again() {
let fx = Fixture::new(&[2048]);
pack_into_lop(&fx, LOP_KEEP_MESSAGE);
let report = fx.offload(1024);
assert_eq!(report.already_in_lop, 1);
assert_eq!(report.copied_to_lop, 0);
assert!(report.lop_pack.is_none());
assert_eq!(report.dropped_from_main, 1);
}
#[test]
fn a_copy_another_writer_keeps_for_a_while_is_not_trusted() {
for message in ["receive-pack 4242 on build-host", "pinned"] {
let fx = Fixture::new(&[2048]);
pack_into_lop(&fx, message);
let report = fx.offload(1024);
assert_eq!(report.already_in_lop, 0, "{message}");
assert_eq!(report.copied_to_lop, 1, "{message}");
let lop_pack = report.lop_pack.expect("copied").path;
let keep = std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap();
assert_eq!(keep.trim_end(), LOP_KEEP_MESSAGE);
}
}
#[cfg(unix)]
#[test]
fn a_lop_sharing_main_objects_is_refused() {
let fx = Fixture::new(&[2048]);
let lop_objects = fx.lop_dir().join("objects");
std::fs::remove_dir_all(&lop_objects).unwrap();
std::os::unix::fs::symlink(fx.main_dir().join("objects"), &lop_objects).unwrap();
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
assert!(matches!(
offload_err(&fx, &opts),
OffloadError::LopIsMainRepository
));
assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
}
fn prune_lop_loose(fx: &Fixture) {
for (_, path) in fx.lop().odb().enumerate_local_loose_objects().unwrap() {
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn a_loose_copy_in_the_lop_is_not_trusted() {
let fx = Fixture::new(&[2048]);
fx.lop()
.odb()
.write(ObjectKind::Blob, &blob_data(0, 2048))
.unwrap();
let report = fx.offload(1024);
assert_eq!(report.already_in_lop, 0);
assert_eq!(report.copied_to_lop, 1);
assert!(report.lop_pack.is_some());
assert_eq!(report.dropped_from_main, 1);
prune_lop_loose(&fx);
let lop = fx.lop();
assert_eq!(
lop.odb().read(&fx.blobs[0]).unwrap().data,
blob_data(0, 2048),
"the kept pack survives the LOP's prune"
);
}
#[test]
fn a_copy_the_lop_borrows_from_main_is_not_trusted() {
let fx = Fixture::new(&[2048]);
let alternates = fx.lop_dir().join("objects/info/alternates");
std::fs::create_dir_all(alternates.parent().unwrap()).unwrap();
std::fs::write(
&alternates,
format!("{}\n", fx.main_dir().join("objects").display()),
)
.unwrap();
let report = fx.offload(1024);
assert_eq!(report.already_in_lop, 0);
assert_eq!(report.copied_to_lop, 1);
std::fs::remove_file(&alternates).unwrap();
assert_eq!(
fx.lop().odb().read(&fx.blobs[0]).unwrap().data,
blob_data(0, 2048),
"the LOP has its own copy"
);
}
#[test]
fn running_again_folds_pushed_objects_into_the_promisor_pack() {
let fx = Fixture::new(&[2048, 10]);
fx.offload(1024);
let main = fx.main();
let mut body = b"100644 big\0".to_vec();
body.extend_from_slice(fx.blobs[0].as_bytes());
let tree = main
.odb()
.write_loose_materialize(ObjectKind::Tree, &body)
.unwrap();
let again = fx.offload(1024);
assert_eq!(again.candidates, 0);
let pack = again.main_pack.expect("folded").path;
let promisor: Vec<_> = fx
.files(&fx.main_dir(), "promisor")
.iter()
.map(|p| p.file_name().map(|n| n.to_os_string()))
.collect();
assert_eq!(
promisor,
[pack
.with_extension("promisor")
.file_name()
.map(|n| n.to_os_string())]
);
let main = fx.main();
assert!(main
.odb()
.enumerate_local_loose_objects()
.unwrap()
.is_empty());
assert!(main.odb().exists_stored(&tree));
assert!(!main.odb().exists_stored(&fx.blobs[0]));
}
#[test]
fn blobs_in_kept_packs_stay() {
let fx = Fixture::new(&[2048]);
let main = fx.main();
let pack = crate::pack_objects::build_pack_from_send_list(
main.odb(),
&[fx.blobs[0]],
&std::collections::HashSet::new(),
&PackBuildOptions::default(),
&std::collections::HashMap::new(),
)
.unwrap();
crate::index_pack::install_pack_bytes(
pack,
main.odb(),
&IngestPackOptions {
keep_message: Some("pinned".into()),
..IngestPackOptions::default()
},
)
.unwrap();
let report = fx.offload(1024);
assert_eq!(report.candidates, 0);
assert_eq!(report.skipped_kept_pack, 1);
assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
}
fn keep_in_main(fx: &Fixture, oids: &[ObjectId]) -> PathBuf {
let main = fx.main();
let pack = crate::pack_objects::build_pack_from_send_list(
main.odb(),
oids,
&std::collections::HashSet::new(),
&PackBuildOptions::default(),
&std::collections::HashMap::new(),
)
.unwrap();
let installed = crate::index_pack::install_pack_bytes(
pack,
main.odb(),
&IngestPackOptions {
keep_message: Some("pinned".into()),
..IngestPackOptions::default()
},
)
.unwrap();
crate::prune_packed::prune_packed_objects(main.odb().objects_dir(), Default::default())
.unwrap();
installed.pack_path.unwrap()
}
#[test]
fn a_kept_object_the_promisor_pack_would_name_stops_offload() {
let fx = Fixture::new(&[2048, 10]);
let kept = keep_in_main(&fx, &[fx.blobs[1]]);
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
for dry_run in [true, false] {
opts.dry_run = dry_run;
match offload_err(&fx, &opts) {
OffloadError::KeptPackObjectPromised { pack, oid, .. } => {
assert_eq!(pack, kept);
assert_eq!(oid, fx.blobs[1]);
}
other => panic!("got {other:?}"),
}
}
assert!(fx.files(&fx.lop_dir(), "pack").is_empty(), "nothing copied");
assert!(fx.files(&fx.main_dir(), "promisor").is_empty());
assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
std::fs::remove_file(kept.with_extension("keep")).unwrap();
let report = fx.offload(1024);
assert_eq!(report.dropped_from_main, 1);
let main = fx.main();
let indexes = main.odb().local_pack_indexes().unwrap();
assert_eq!(indexes.len(), 1);
assert!(indexes[0].is_promisor);
assert!(indexes[0].find_position(&fx.blobs[1]).is_some());
}
#[test]
fn a_kept_pack_nothing_names_does_not_stop_offload() {
let fx = Fixture::new(&[2048]);
let stray = fx
.main()
.odb()
.write(ObjectKind::Blob, b"named by nothing")
.unwrap();
let kept = keep_in_main(&fx, &[stray]);
let report = fx.offload(1024);
assert_eq!(report.dropped_from_main, 1);
assert!(kept.is_file() && kept.with_extension("keep").is_file());
assert!(fx.main().odb().exists_stored(&stray));
}
#[test]
fn blobs_a_checkout_needs_stay() {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
let repo =
Repository::init(&work, &InitOptions::new().with_initial_branch("main")).unwrap();
Repository::init(
&dir.path().join("lop.git"),
&InitOptions::new().with_bare(true),
)
.unwrap();
std::fs::write(work.join("big"), vec![b'x'; 4096]).unwrap();
crate::porcelain::add::stage_for_commit(
&repo,
&mut crate::progress::NullProgress,
&crate::progress::NeverCancel,
)
.unwrap();
let ident = "T <t@example.com> 1 +0000".to_string();
let mut req = crate::porcelain::commit::CommitRequest::new("big");
let sig: crate::ident::Signature = ident.parse().unwrap();
req.author = Some(sig.clone());
req.committer = Some(sig);
crate::porcelain::commit::create_commit(
&repo,
&req,
&mut crate::progress::NullProgress,
&crate::progress::NeverCancel,
)
.unwrap();
let config = repo.git_dir().join("config");
let mut text = std::fs::read_to_string(&config).unwrap();
text.push_str(&format!(
"[remote \"lop\"]\n\turl = {}\n",
dir.path().join("lop.git").display()
));
std::fs::write(&config, text).unwrap();
let repo = Repository::discover(Some(&work), &RepositoryOptions::empty()).unwrap();
let mut opts = OffloadOptions::new("lop", NOW);
opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
let report = offload_blobs(&repo, &opts).unwrap();
assert_eq!(report.candidates, 0);
assert_eq!(report.skipped_checked_out, 1);
}
fn offload_err(fx: &Fixture, opts: &OffloadOptions) -> OffloadError {
match offload_blobs(&fx.main(), opts) {
Err(Error::Offload(err)) => err,
other => panic!("expected an offload error, got {other:?}"),
}
}
#[test]
fn refusals_are_typed() {
let fx = Fixture::new(&[2048]);
let with_filter = |remote: &str| {
let mut opts = OffloadOptions::new(remote, NOW);
opts.filter = Some(ObjectFilterSpec::BlobNone);
opts
};
assert!(matches!(
offload_err(&fx, &with_filter("nope")),
OffloadError::RemoteNotConfigured { remote } if remote == "nope"
));
assert!(matches!(
offload_err(&fx, &OffloadOptions::new("lop", NOW)),
OffloadError::NoThreshold { .. }
));
fx.append_main_config(
"[remote \"web\"]\n\turl = https://user:secret@lop.example/r.git\n\
[remote \"self\"]\n\turl = .\n\
[remote \"gone\"]\n\turl = ../nothing-here.git\n",
);
match offload_err(&fx, &with_filter("web")) {
OffloadError::LopNotLocal { url, .. } => assert!(!url.contains("secret")),
other => panic!("got {other:?}"),
}
assert!(matches!(
offload_err(&fx, &with_filter("self")),
OffloadError::LopIsMainRepository
));
assert!(matches!(
offload_err(&fx, &with_filter("gone")),
OffloadError::LopNotARepository { .. }
));
let sha256 = fx.dir.path().join("sha256.git");
Repository::init(
&sha256,
&InitOptions::new()
.with_bare(true)
.with_object_format(HashAlgo::Sha256),
)
.unwrap();
let mut opts = with_filter("web");
opts.lop_git_dir = Some(sha256);
assert!(matches!(
offload_err(&fx, &opts),
OffloadError::HashAlgoMismatch {
main: HashAlgo::Sha1,
lop: HashAlgo::Sha256
}
));
std::fs::write(fx.main_dir().join("gc.pid"), "1 some-other-host").unwrap();
assert!(matches!(
offload_err(&fx, &with_filter("lop")),
OffloadError::MaintenanceRunning { .. }
));
}
}