Skip to main content

grit_lib/
offload.rs

1//! Offload large blobs from a repository to a large-object promisor (LOP).
2//!
3//! A server can keep its big binaries out of its main repository: the blobs live
4//! in a second repository, the LOP, and the main repository becomes a partial
5//! repository of it. The main repository's config names the LOP as a promisor
6//! remote (`remote.<lop>.promisor = true`), its remaining objects sit in a
7//! `.promisor` pack, and upload-pack leaves the offloaded blobs out for clients
8//! that accept the LOP through the `promisor-remote` capability. This is the
9//! layout Git's own large-object-promisor tests build with
10//! `git repack -a -d --filter=blob:limit=<n> --filter-to=<lop>`.
11//!
12//! The blobs that stay (smaller than the threshold, or needed by a checkout) are in
13//! that promisor pack too, although only the main repository has them. Git treats
14//! every object a tree in a promisor pack names as promised, and its `repack -a -d`
15//! and `gc` keep only the promised objects they find in promisor packs: a kept blob
16//! in an ordinary pack beside it would be deleted, with nothing to fetch it back
17//! from. The price is that a blob in a promisor pack no longer proves the promisor
18//! can supply it, so offload records the LOP's directory in `grit.lopGitDir`, and
19//! [`crate::maintenance::drop_promised_blobs`] drops a blob only once a promisor
20//! it can read is seen to hold it.
21//!
22//! [`offload_blobs`] moves the blobs a filter leaves out (`blob:limit=<n>`: every
23//! blob of `n` bytes or more) from the main repository to the LOP. It never removes
24//! anything from the main repository before the LOP holds it, and blobs any checkout
25//! of the main repository needs stay where they are. Running it again offloads blobs
26//! pushed since, and folds the main repository's packs back into one promisor pack.
27
28use std::path::{Path, PathBuf};
29
30use crate::config::{ConfigFile, ConfigScope, ConfigSet};
31use crate::environment::RepositoryOptions;
32use crate::error::{Error, Result};
33use crate::index_pack::{install_pack_path, IngestPackOptions};
34use crate::maintenance::{GcLock, GcLockAttempt, GcLockHolder, RepackOptions};
35use crate::objects::{
36    oid_hash_set, CommitRef, HashAlgo, ObjectId, ObjectKind, OidHashMap, OidHashSet, TreeRefIter,
37};
38use crate::odb::Odb;
39use crate::pack_index::PackIndex;
40use crate::pack_objects::PackBuildOptions;
41use crate::partial_clone::{stored_remote_filter, ObjectFilterSpec};
42use crate::repo::Repository;
43
44/// Message written into the `.keep` marker of every pack offload adds to a LOP.
45pub const LOP_KEEP_MESSAGE: &str = "grit offload: large-object promisor pack";
46
47/// Multi-valued config key [`offload_blobs`] adds to the main repository: the
48/// absolute git directory of each LOP it moved blobs to.
49///
50/// Its presence marks a repository whose promisor pack holds blobs only it has, so
51/// [`crate::maintenance::drop_promised_blobs`] drops a blob there only after seeing
52/// it in a promisor it can read (one of these directories, or a promisor remote
53/// with a local URL).
54pub const LOP_GIT_DIR_KEY: &str = "grit.lopGitDir";
55
56/// Options for [`offload_blobs`].
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct OffloadOptions {
60    /// The promisor remote the blobs move to; `remote.<name>.url` must be set in the
61    /// main repository.
62    pub remote: String,
63    /// Which blobs move: those this filter leaves out (`blob:limit=<n>`: blobs of `n`
64    /// bytes or more; `blob:none`: every blob). `None` uses
65    /// `remote.<name>.partialCloneFilter`, the same value clients receive through
66    /// `promisor.sendFields`.
67    pub filter: Option<ObjectFilterSpec>,
68    /// The LOP's git directory on this machine. Needed when `remote.<name>.url` is
69    /// not a local path, for example an `https://` URL advertised to clients for a
70    /// repository the server also has on disk.
71    pub lop_git_dir: Option<PathBuf>,
72    /// Also set `uploadpack.allowFilter` and `uploadpack.allowAnySHA1InWant` in the
73    /// LOP, so it can answer the lazy fetches clients send it.
74    pub configure_lop: bool,
75    /// Work out what would move without writing anything.
76    pub dry_run: bool,
77    /// Current time in Unix seconds, for the `gc.pid` maintenance lock.
78    pub now_unix: i64,
79    /// Pack writing and indexing threads (`None` reads `pack.threads`).
80    pub threads: Option<usize>,
81}
82
83impl OffloadOptions {
84    /// Options that offload to `remote` with its configured filter, at time `now_unix`.
85    #[must_use]
86    pub fn new(remote: impl Into<String>, now_unix: i64) -> Self {
87        Self {
88            remote: remote.into(),
89            filter: None,
90            lop_git_dir: None,
91            configure_lop: false,
92            dry_run: false,
93            now_unix,
94            threads: None,
95        }
96    }
97}
98
99/// Why [`offload_blobs`] refused to run.
100#[derive(Debug, thiserror::Error)]
101#[non_exhaustive]
102pub enum OffloadError {
103    /// `remote.<name>.url` is not set in the main repository.
104    #[error("remote '{remote}' has no url configured")]
105    RemoteNotConfigured {
106        /// The remote named in [`OffloadOptions::remote`].
107        remote: String,
108    },
109    /// The remote's URL is not a local path and no [`OffloadOptions::lop_git_dir`]
110    /// was given.
111    #[error("remote '{remote}' is not a local repository ({url}); give its git directory")]
112    LopNotLocal {
113        /// The remote name.
114        remote: String,
115        /// Its configured URL (credentials removed).
116        url: String,
117    },
118    /// The LOP location is not a Git repository.
119    #[error("{} is not a git repository", path.display())]
120    LopNotARepository {
121        /// Where the LOP was looked for.
122        path: PathBuf,
123    },
124    /// The LOP is the main repository itself, or stores its objects in the same
125    /// directory (a linked worktree of it, or an `objects/` linked to its).
126    #[error("the large-object promisor is this repository itself")]
127    LopIsMainRepository,
128    /// The two repositories use different object formats.
129    #[error(
130        "object formats differ: this repository uses {}, the promisor {}",
131        main.name(),
132        lop.name()
133    )]
134    HashAlgoMismatch {
135        /// The main repository's object format.
136        main: HashAlgo,
137        /// The LOP's object format.
138        lop: HashAlgo,
139    },
140    /// No filter was given and the remote has no `partialCloneFilter`.
141    #[error("no size threshold: give a filter or set remote.{remote}.partialCloneFilter")]
142    NoThreshold {
143        /// The remote name.
144        remote: String,
145    },
146    /// A blob copied to the LOP cannot be found there afterwards; nothing was
147    /// removed from the main repository.
148    #[error("blob {oid} is not in the promisor after copying it there")]
149    NotInLopAfterCopy {
150        /// The blob.
151        oid: ObjectId,
152    },
153    /// An ordinary `.keep` pack (one without `.promisor`) of the main repository
154    /// holds the only copy of an object that a commit, tree or tag going into the
155    /// promisor pack names. Git counts every object a promisor-pack object names as
156    /// promised, and its `repack -a -d` keeps a promised object only in a promisor
157    /// pack: once the marker goes, it would delete this one, which no promisor has.
158    /// Offload does not rewrite kept packs, and a copy in the promisor pack does
159    /// not help (Git's repack leaves out objects a kept pack holds), so it refuses
160    /// before changing anything. Removing the marker lets offload fold the pack
161    /// in; marking the pack `.promisor` keeps it as it is.
162    #[error(
163        "{oid} is only in kept pack {}, and {referrer} names it",
164        pack.display()
165    )]
166    KeptPackObjectPromised {
167        /// The kept pack holding the object.
168        pack: PathBuf,
169        /// The object.
170        oid: ObjectId,
171        /// An object going into the promisor pack that names it.
172        referrer: ObjectId,
173    },
174    /// Another maintenance run holds the main repository's `gc.pid` lock.
175    #[error("maintenance is already running on this repository")]
176    MaintenanceRunning {
177        /// Who holds the lock.
178        holder: GcLockHolder,
179    },
180}
181
182/// Which repository a [`OffloadConfigChange`] was made in.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum OffloadRepo {
186    /// The repository the blobs moved out of.
187    Main,
188    /// The large-object promisor.
189    Lop,
190}
191
192/// One config value [`offload_blobs`] set.
193#[derive(Debug, Clone, PartialEq, Eq)]
194#[non_exhaustive]
195pub struct OffloadConfigChange {
196    /// The repository whose local config changed.
197    pub repo: OffloadRepo,
198    /// The key, as written (for example `remote.lop.promisor`).
199    pub key: String,
200    /// The new value.
201    pub value: String,
202}
203
204/// Whether a LOP can answer the lazy fetches of Git clients.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206#[non_exhaustive]
207pub struct LopReadiness {
208    /// `uploadpack.allowFilter` is true, so it accepts `filter blob:none`.
209    pub allow_filter: bool,
210    /// `uploadpack.allowAnySHA1InWant` is true, so it serves blobs no ref points at.
211    pub allow_any_sha1_in_want: bool,
212}
213
214impl LopReadiness {
215    /// Both settings are on.
216    #[must_use]
217    pub fn is_ready(&self) -> bool {
218        self.allow_filter && self.allow_any_sha1_in_want
219    }
220
221    fn from_config(config: &ConfigSet) -> Self {
222        let flag = |key: &str| config.get_bool(key).and_then(|v| v.ok()).unwrap_or(false);
223        Self {
224            allow_filter: flag("uploadpack.allowfilter"),
225            allow_any_sha1_in_want: flag("uploadpack.allowanysha1inwant"),
226        }
227    }
228}
229
230/// What [`offload_blobs`] did (or, with [`OffloadOptions::dry_run`], would do).
231#[derive(Debug, Clone, PartialEq, Eq)]
232#[non_exhaustive]
233pub struct OffloadReport {
234    /// The promisor remote the blobs moved to.
235    pub remote: String,
236    /// The LOP's git directory.
237    pub lop_git_dir: PathBuf,
238    /// The filter that chose the blobs.
239    pub filter: ObjectFilterSpec,
240    /// True when nothing was written.
241    pub dry_run: bool,
242    /// Blobs stored in the main repository that the filter selects and that may move.
243    pub candidates: usize,
244    /// Their total size, uncompressed.
245    pub candidate_bytes: u64,
246    /// Candidates copied into a new LOP pack.
247    pub copied_to_lop: usize,
248    /// Candidates the LOP already had in a pack an earlier offload wrote there (its
249    /// `.keep` marker holds [`LOP_KEEP_MESSAGE`]). A loose or unkept copy there
250    /// does not count (the LOP's `gc` may prune it), nor does one in a pack another
251    /// writer keeps for a while (`receive-pack`, `fetch-pack`), nor one it reaches
252    /// through alternates; those candidates are copied.
253    pub already_in_lop: usize,
254    /// Candidates no longer stored in the main repository afterwards.
255    pub dropped_from_main: usize,
256    /// Selected blobs kept because a checkout of the main repository needs them
257    /// (a worktree's index or `HEAD`).
258    pub skipped_checked_out: usize,
259    /// Selected blobs kept because they are in a `.keep` pack (or the only tree
260    /// naming them is), which offload does not rewrite.
261    pub skipped_kept_pack: usize,
262    /// The pack added to the LOP, holding the blobs copied there, if any.
263    pub lop_pack: Option<OffloadPack>,
264    /// The promisor pack the main repository's objects now live in (its commits,
265    /// trees, tags and the blobs it keeps), if rewritten.
266    pub main_pack: Option<OffloadPack>,
267    /// Bytes of the main repository's local packs and loose objects before.
268    pub main_bytes_before: u64,
269    /// The same afterwards (equal to the before value on a dry run).
270    pub main_bytes_after: u64,
271    /// Config values set, in the order they were written.
272    pub config_changes: Vec<OffloadConfigChange>,
273    /// The LOP's upload-pack settings after the run.
274    pub lop_ready: LopReadiness,
275    /// `promisor.advertise` is true in the main repository, so its upload-pack tells
276    /// clients about the LOP.
277    pub advertised: bool,
278}
279
280impl OffloadReport {
281    /// The packs the run wrote, in the order they were written: the LOP pack, then
282    /// the main repository's promisor pack.
283    pub fn packs(&self) -> impl Iterator<Item = &OffloadPack> {
284        [&self.lop_pack, &self.main_pack].into_iter().flatten()
285    }
286}
287
288/// Which of the packs [`offload_blobs`] writes an [`OffloadPack`] is.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290#[non_exhaustive]
291pub enum OffloadPackRole {
292    /// The LOP's new pack of the blobs that moved, with a `.keep` marker.
293    Lop,
294    /// The main repository's `.promisor` pack of every object it keeps.
295    MainPromisor,
296}
297
298/// A pack [`offload_blobs`] wrote.
299#[derive(Debug, Clone, PartialEq, Eq)]
300#[non_exhaustive]
301pub struct OffloadPack {
302    /// Which pack this is.
303    pub role: OffloadPackRole,
304    /// The `.pack` file (its `.idx` and other sidecars are beside it).
305    pub path: PathBuf,
306    /// Objects in the pack.
307    pub objects: usize,
308    /// Size of the `.pack` file in bytes.
309    pub bytes: u64,
310}
311
312impl OffloadPack {
313    /// Describe the pack at `path`, reading its size from disk.
314    fn written(role: OffloadPackRole, path: PathBuf, objects: usize) -> Result<Self> {
315        let bytes = std::fs::metadata(&path)?.len();
316        Ok(Self {
317            role,
318            path,
319            objects,
320            bytes,
321        })
322    }
323}
324
325/// Move the blobs [`OffloadOptions::filter`] selects from `repo` to the large-object
326/// promisor [`OffloadOptions::remote`].
327///
328/// The steps run in an order that never loses an object:
329///
330/// 1. Blobs reachable from the refs, reflogs and `HEAD`s, stored in `repo`, and left
331///    out by the filter are the candidates. Those a checkout needs, and those in
332///    `.keep` packs, stay.
333/// 2. The candidates the LOP does not already hold in a pack an earlier offload
334///    wrote there are copied into one new pack there, whole (no deltas), with a
335///    `.keep` marker holding [`LOP_KEEP_MESSAGE`] written before the pack appears:
336///    nothing in the LOP refers to them, so an unprotected pack could be pruned.
337///    A pack another writer keeps only for a while (`receive-pack`, `fetch-pack`)
338///    does not count. Every candidate is then checked to be in such a pack of the
339///    LOP, and the new pack, index and marker are flushed to disk.
340/// 3. The main repository's config gets `remote.<name>.promisor = true`, when the
341///    filter came from `opts` `remote.<name>.partialCloneFilter`, and
342///    `repack.writeBitmaps = false`. A reachability bitmap (pack or multi-pack)
343///    needs every object its commits reach in the packs it covers, and the
344///    offloaded blobs are in none of them. Git's repack keeps promisor and other
345///    objects in separate packs, so once a push adds a commit outside the
346///    promisor pack, Git 2.50 and 2.56 fail with "Packfile doesn't have full
347///    closure": `git gc` exits 128 on a bare repository, where bitmaps are on by
348///    default, and `git repack -a -d -b --write-midx` fails too. Grit's own repack
349///    leaves the bitmap out in that case, so the setting is for Git.
350///    `promisor.advertise` and `extensions.partialClone` are left to the caller;
351///    the report says whether clients are told about the LOP.
352///    It also gets the LOP's git directory in `grit.lopGitDir` (once per LOP),
353///    which tells `drop-large-blobs` where to check that a blob it would drop
354///    exists in the LOP.
355/// 4. The main repository's local packs and loose objects are rewritten into one
356///    `.promisor` pack without the candidates; its trees keep the offloaded blobs
357///    promised. The blobs that stay go into it too: Git's repack and `gc` keep a
358///    promised object only when it is in a promisor pack (see the module
359///    documentation). This happens on every run, even when no blob moves, so
360///    objects pushed since the last run (trees naming offloaded blobs, new small
361///    blobs) join the promisor pack, and a repository whose kept blobs sit in an
362///    ordinary pack is folded back. The new pack is flushed to disk before the old
363///    packs are removed. `.keep` packs are not rewritten (see
364///    [`OffloadError::KeptPackObjectPromised`]).
365///
366/// # Parameters
367///
368/// - `repo`: the main repository (files-backed object store).
369/// - `opts`: what to move and where.
370///
371/// # Errors
372///
373/// [`Error::Offload`] for the [`OffloadError`] cases, [`Error::UnsupportedObjectStore`]
374/// without a files-backed object store, and read, write and config failures.
375pub fn offload_blobs(repo: &Repository, opts: &OffloadOptions) -> Result<OffloadReport> {
376    let odb = repo.odb();
377    odb.require_files_primary("offload")?;
378    let config = repo.config()?;
379    let remote = opts.remote.as_str();
380    let url = config
381        .get(&format!("remote.{remote}.url"))
382        .filter(|u| !u.trim().is_empty())
383        .ok_or_else(|| OffloadError::RemoteNotConfigured {
384            remote: remote.to_owned(),
385        })?;
386    let filter = opts
387        .filter
388        .or_else(|| stored_remote_filter(&config, remote))
389        .ok_or_else(|| OffloadError::NoThreshold {
390            remote: remote.to_owned(),
391        })?;
392    let lop = open_lop(repo, opts, &url)?;
393
394    let _lock = if opts.dry_run {
395        None
396    } else {
397        match GcLock::try_acquire(repo, opts.now_unix)? {
398            GcLockAttempt::Acquired(lock) => Some(lock),
399            GcLockAttempt::Held(holder) => {
400                return Err(OffloadError::MaintenanceRunning { holder }.into())
401            }
402        }
403    };
404
405    let main_bytes_before = local_object_bytes(odb)?;
406    let selection = select_candidates(repo, filter)?;
407    let candidates: Vec<ObjectId> = selection.candidates.keys().copied().collect();
408    if let Some(refusal) = kept_object_the_promisor_pack_names(odb, &selection.candidates)? {
409        return Err(refusal.into());
410    }
411    let in_lop = held_in_lop_keep_packs(lop.odb(), &candidates)?;
412    let missing_in_lop: Vec<ObjectId> = candidates
413        .iter()
414        .zip(&in_lop)
415        .filter_map(|(oid, has)| (!has).then_some(*oid))
416        .collect();
417
418    let mut report = OffloadReport {
419        remote: remote.to_owned(),
420        lop_git_dir: lop.git_dir().to_path_buf(),
421        filter,
422        dry_run: opts.dry_run,
423        candidates: candidates.len(),
424        candidate_bytes: selection.candidates.values().sum(),
425        copied_to_lop: 0,
426        already_in_lop: candidates.len() - missing_in_lop.len(),
427        dropped_from_main: 0,
428        skipped_checked_out: selection.skipped_checked_out,
429        skipped_kept_pack: selection.skipped_kept_pack,
430        lop_pack: None,
431        main_pack: None,
432        main_bytes_before,
433        main_bytes_after: main_bytes_before,
434        config_changes: Vec::new(),
435        lop_ready: LopReadiness::from_config(&lop_config(&lop)?),
436        advertised: config
437            .get_bool("promisor.advertise")
438            .and_then(|v| v.ok())
439            .unwrap_or(false),
440    };
441    if opts.dry_run {
442        return Ok(report);
443    }
444
445    if !missing_in_lop.is_empty() {
446        report.lop_pack = copy_to_lop(repo, &lop, &missing_in_lop, opts)?
447            .map(|(path, objects)| OffloadPack::written(OffloadPackRole::Lop, path, objects))
448            .transpose()?;
449        report.copied_to_lop = missing_in_lop.len();
450    }
451    lop.odb().invalidate_packs();
452    let present = held_in_lop_keep_packs(lop.odb(), &candidates)?;
453    if let Some((oid, _)) = candidates.iter().zip(&present).find(|(_, has)| !**has) {
454        return Err(OffloadError::NotInLopAfterCopy { oid: *oid }.into());
455    }
456    if let Some(pack) = &report.lop_pack {
457        // The main repository is about to drop its copies: the LOP's must be on disk.
458        crate::index_pack::sync_installed_pack(&pack.path)?;
459    }
460
461    let mut wanted: Vec<(String, String)> = Vec::new();
462    let promisor_key = format!("remote.{remote}.promisor");
463    if config.get_bool(&promisor_key).and_then(|v| v.ok()) != Some(true) {
464        wanted.push((promisor_key, "true".to_owned()));
465    }
466    let filter_key = format!("remote.{remote}.partialCloneFilter");
467    if opts.filter.is_some() && config.get(&filter_key).is_none() {
468        wanted.push((filter_key, filter.to_config_value()));
469    }
470    // No pack or multi-pack-index holds the offloaded blobs, so no bitmap can have
471    // full closure; once a push lands outside the promisor pack, Git's `gc` fails
472    // trying (bitmaps are on by default in bare repositories).
473    let bitmaps_key = "repack.writeBitmaps";
474    if config.get_bool(bitmaps_key).and_then(|v| v.ok()) != Some(false) {
475        wanted.push((bitmaps_key.to_owned(), "false".to_owned()));
476    }
477    // The promisor pack is about to hold blobs the LOP does not have; record where
478    // the LOP is, so dropping promised blobs can check it first.
479    let mut added: Vec<(String, String)> = Vec::new();
480    let lop_dir = lop_dir_value(lop.git_dir());
481    if !config
482        .get_all(LOP_GIT_DIR_KEY)
483        .iter()
484        .any(|v| same_dir(Path::new(v), lop.git_dir()))
485    {
486        added.push((LOP_GIT_DIR_KEY.to_owned(), lop_dir));
487    }
488    write_config(repo.git_dir(), &wanted, &added)?;
489    report
490        .config_changes
491        .extend(
492            wanted
493                .into_iter()
494                .chain(added)
495                .map(|(key, value)| OffloadConfigChange {
496                    repo: OffloadRepo::Main,
497                    key,
498                    value,
499                }),
500        );
501    if opts.configure_lop {
502        let lop_changes: Vec<(String, String)> = [
503            ("uploadpack.allowFilter", report.lop_ready.allow_filter),
504            (
505                "uploadpack.allowAnySHA1InWant",
506                report.lop_ready.allow_any_sha1_in_want,
507            ),
508        ]
509        .into_iter()
510        .filter(|(_, already)| !already)
511        .map(|(key, _)| (key.to_owned(), "true".to_owned()))
512        .collect();
513        write_config(lop.git_dir(), &lop_changes, &[])?;
514        report
515            .config_changes
516            .extend(
517                lop_changes
518                    .into_iter()
519                    .map(|(key, value)| OffloadConfigChange {
520                        repo: OffloadRepo::Lop,
521                        key,
522                        value,
523                    }),
524            );
525        report.lop_ready = LopReadiness::from_config(&lop_config(&lop)?);
526    }
527
528    let omit: OidHashSet = candidates.iter().copied().collect();
529    let repack = RepackOptions {
530        threads: opts.threads,
531        environment: repo.environment().clone(),
532        ..RepackOptions::default()
533    };
534    // Fold in what arrived since the last run even when nothing moves now: a
535    // pushed tree naming an offloaded blob outside the promisor pack makes every
536    // promise check for that blob scan all promisor packs.
537    let rewrite = crate::maintenance::rewrite_local_objects_without(
538        repo,
539        &omit,
540        &repack,
541        crate::maintenance::PromisorRewriteWhen::Always,
542    )?;
543    report.main_pack = rewrite
544        .pack_path
545        .map(|path| OffloadPack::written(OffloadPackRole::MainPromisor, path, rewrite.objects))
546        .transpose()?;
547    report.dropped_from_main = odb.missing_stored_oids(candidates.iter().copied())?.len();
548    report.main_bytes_after = local_object_bytes(odb)?;
549    Ok(report)
550}
551
552/// Open the LOP named by `opts` (or by the remote's local `url`) and check it can
553/// take the blobs.
554fn open_lop(repo: &Repository, opts: &OffloadOptions, url: &str) -> Result<Repository> {
555    let path = match &opts.lop_git_dir {
556        Some(path) => path.clone(),
557        None if crate::transport_path::is_local_path_remote_url(url) => {
558            crate::transport_path::resolve_local_remote_git_dir(
559                url,
560                repo.git_dir(),
561                repo.work_tree(),
562            )
563        }
564        None => {
565            return Err(OffloadError::LopNotLocal {
566                remote: opts.remote.clone(),
567                url: crate::transport_path::redact_url_for_display(url),
568            }
569            .into())
570        }
571    };
572    let lop = Repository::open(
573        &path,
574        &RepositoryOptions::with_environment(repo.environment().clone()),
575    )
576    .map_err(|_| OffloadError::LopNotARepository { path: path.clone() })?;
577    if same_object_store(repo, &lop) {
578        return Err(OffloadError::LopIsMainRepository.into());
579    }
580    let (main_algo, lop_algo) = (repo.odb().hash_algo(), lop.odb().hash_algo());
581    if main_algo != lop_algo {
582        return Err(OffloadError::HashAlgoMismatch {
583            main: main_algo,
584            lop: lop_algo,
585        }
586        .into());
587    }
588    lop.odb().require_files_primary("offload")?;
589    Ok(lop)
590}
591
592/// The LOP's own config (system and global included, as its upload-pack sees it).
593fn lop_config(lop: &Repository) -> Result<ConfigSet> {
594    ConfigSet::load(lop.environment(), Some(lop.git_dir()), true)
595}
596
597/// Blobs chosen by [`select_candidates`], with their sizes.
598struct Selection {
599    candidates: OidHashMap<u64>,
600    skipped_checked_out: usize,
601    skipped_kept_pack: usize,
602}
603
604/// Walk every commit, tag and tree reachable from the maintenance roots and pick the
605/// blobs `filter` leaves out that this repository stores and can drop.
606///
607/// A blob qualifies only when a tree naming it will be in the rewritten promisor
608/// pack (stored loose or in a pack without `.keep`): that is what makes it promised
609/// once it is gone. Nothing is fetched; unreadable objects are skipped.
610fn select_candidates(repo: &Repository, filter: ObjectFilterSpec) -> Result<Selection> {
611    let odb = repo.odb();
612    let pack_dir = odb.objects_dir().join("pack");
613    let (kept, rewritten): (Vec<_>, Vec<_>) = odb
614        .local_pack_indexes()?
615        .into_iter()
616        .partition(|idx| pack_dir.join(keep_name(&idx.pack_path)).is_file());
617    let in_any = |indexes: &[std::sync::Arc<PackIndex>], oid: &ObjectId| {
618        indexes.iter().any(|idx| idx.find_position(oid).is_some())
619    };
620    let will_be_rewritten =
621        |oid: &ObjectId| in_any(&rewritten, oid) || odb.local_loose_file_size(oid).is_some();
622    let checked_out = crate::promisor::blobs_needed_by_checkouts(repo)?;
623
624    let mut selection = Selection {
625        candidates: crate::objects::oid_hash_map(),
626        skipped_checked_out: 0,
627        skipped_kept_pack: 0,
628    };
629    // Selected blobs no rewritten tree names yet; settled when one does.
630    let mut unanchored: OidHashMap<u64> = crate::objects::oid_hash_map();
631    let mut blobs_seen = oid_hash_set();
632    let mut walked = oid_hash_set();
633    let mut queue: Vec<ObjectId> = crate::maintenance::collect_maintenance_object_roots(repo)?;
634    let oid_len = odb.hash_algo().len();
635    while let Some(oid) = queue.pop() {
636        if !walked.insert(oid) {
637            continue;
638        }
639        let Some(obj) = odb.try_read_once(&oid)? else {
640            continue;
641        };
642        match obj.kind {
643            ObjectKind::Commit => {
644                let commit = CommitRef::parse(&obj.data)?;
645                queue.push(commit.tree());
646                queue.extend(commit.parents().iter().copied());
647            }
648            ObjectKind::Tag => {
649                if let Some(target) = crate::objects::tag_object_line_oid(&obj.data) {
650                    queue.push(target);
651                }
652            }
653            ObjectKind::Tree => {
654                let anchors = will_be_rewritten(&oid);
655                for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
656                    let entry = entry?;
657                    match entry.mode & 0o170000 {
658                        0o100000 | 0o120000 => {}
659                        0o040000 => {
660                            queue.push(entry.oid);
661                            continue;
662                        }
663                        // Submodule commits are not ours to move.
664                        _ => continue,
665                    }
666                    if anchors {
667                        if let Some(size) = unanchored.remove(&entry.oid) {
668                            selection.candidates.insert(entry.oid, size);
669                        }
670                    }
671                    if !blobs_seen.insert(entry.oid) {
672                        continue;
673                    }
674                    let Some(size) = selected_blob_size(odb, filter, &entry.oid)? else {
675                        continue;
676                    };
677                    if checked_out.contains(&entry.oid) {
678                        selection.skipped_checked_out += 1;
679                    } else if in_any(&kept, &entry.oid) {
680                        selection.skipped_kept_pack += 1;
681                    } else if anchors {
682                        selection.candidates.insert(entry.oid, size);
683                    } else {
684                        unanchored.insert(entry.oid, size);
685                    }
686                }
687            }
688            ObjectKind::Blob => {}
689        }
690    }
691    selection.skipped_kept_pack += unanchored.len();
692    Ok(selection)
693}
694
695/// The refusal [`OffloadError::KeptPackObjectPromised`] when an object going into
696/// the promisor pack (one of the unkept packs or loose objects, except `omit`)
697/// names an object only an ordinary `.keep` pack holds.
698///
699/// Free without such packs. With them, every commit, tree and tag to be rewritten
700/// is read, as the rewrite reads them anyway.
701fn kept_object_the_promisor_pack_names(
702    odb: &Odb,
703    omit: &OidHashMap<u64>,
704) -> Result<Option<OffloadError>> {
705    odb.reprepare_local_packs();
706    let (kept, rewritten): (Vec<_>, Vec<_>) = odb
707        .local_pack_indexes()?
708        .into_iter()
709        .partition(|idx| idx.pack_path.with_extension("keep").is_file());
710    let (kept_promisor, kept_ordinary): (Vec<_>, Vec<_>) =
711        kept.into_iter().partition(|idx| idx.is_promisor);
712    if kept_ordinary.is_empty() {
713        return Ok(None);
714    }
715    let loose: OidHashSet = odb
716        .enumerate_local_loose_objects()?
717        .into_iter()
718        .map(|(oid, _)| oid)
719        .collect();
720    let in_promisor_pack_after = |oid: &ObjectId| {
721        loose.contains(oid)
722            || [&rewritten, &kept_promisor]
723                .into_iter()
724                .flatten()
725                .any(|idx| idx.find_position(oid).is_some())
726    };
727    let mut only_kept: OidHashMap<usize> = crate::objects::oid_hash_map();
728    for (pack_no, idx) in kept_ordinary.iter().enumerate() {
729        for entry in idx.iter() {
730            let oid = ObjectId::from_bytes(entry.oid())?;
731            if !in_promisor_pack_after(&oid) {
732                only_kept.entry(oid).or_insert(pack_no);
733            }
734        }
735    }
736    if only_kept.is_empty() {
737        return Ok(None);
738    }
739    let mut referrers: Vec<ObjectId> = loose.iter().copied().collect();
740    for idx in &rewritten {
741        for entry in idx.iter() {
742            referrers.push(ObjectId::from_bytes(entry.oid())?);
743        }
744    }
745    let oid_len = odb.hash_algo().len();
746    let mut checked = oid_hash_set();
747    for referrer in referrers {
748        if omit.contains_key(&referrer) || !checked.insert(referrer) {
749            continue;
750        }
751        let kind = match odb.read_info_without_fetch(&referrer) {
752            Ok(info) => info.kind,
753            Err(Error::ObjectNotFound(_)) => continue,
754            Err(err) => return Err(err),
755        };
756        if kind == ObjectKind::Blob {
757            continue;
758        }
759        let obj = odb.read(&referrer)?;
760        let mut named: Vec<ObjectId> = Vec::new();
761        match kind {
762            ObjectKind::Commit => {
763                let commit = CommitRef::parse(&obj.data)?;
764                named.push(commit.tree());
765                named.extend(commit.parents().iter().copied());
766            }
767            ObjectKind::Tree => {
768                for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
769                    let entry = entry?;
770                    if entry.mode & 0o170000 != 0o160000 {
771                        named.push(entry.oid);
772                    }
773                }
774            }
775            ObjectKind::Tag => named.extend(crate::objects::tag_object_line_oid(&obj.data)),
776            ObjectKind::Blob => {}
777        }
778        if let Some((oid, &pack_no)) = named.iter().find_map(|oid| only_kept.get_key_value(oid)) {
779            return Ok(Some(OffloadError::KeptPackObjectPromised {
780                pack: kept_ordinary[pack_no].pack_path.clone(),
781                oid: *oid,
782                referrer,
783            }));
784        }
785    }
786    Ok(None)
787}
788
789/// The size of `oid` when it is a blob stored locally that `filter` leaves out.
790fn selected_blob_size(odb: &Odb, filter: ObjectFilterSpec, oid: &ObjectId) -> Result<Option<u64>> {
791    if !odb.try_exists_stored(oid)? {
792        return Ok(None);
793    }
794    let info = match odb.read_info_without_fetch(oid) {
795        Ok(info) => info,
796        Err(Error::ObjectNotFound(_)) => return Ok(None),
797        Err(err) => return Err(err),
798    };
799    if info.kind != ObjectKind::Blob {
800        return Ok(None);
801    }
802    let selected = match filter {
803        ObjectFilterSpec::BlobNone => true,
804        ObjectFilterSpec::BlobLimit(limit) => info.size >= limit,
805    };
806    Ok(selected.then_some(info.size))
807}
808
809fn keep_name(pack_path: &Path) -> PathBuf {
810    PathBuf::from(pack_path.file_name().unwrap_or_default()).with_extension("keep")
811}
812
813/// Copy `blobs` from `repo` into one new `.keep` pack in `lop`; returns the pack and
814/// how many objects it holds.
815fn copy_to_lop(
816    repo: &Repository,
817    lop: &Repository,
818    blobs: &[ObjectId],
819    opts: &OffloadOptions,
820) -> Result<Option<(PathBuf, usize)>> {
821    let cfg = repo.config().ok();
822    let pack_opts = PackBuildOptions {
823        delta: false,
824        window: 0,
825        max_depth: 0,
826        reuse_deltas: false,
827        reuse_objects: true,
828        use_bitmaps: false,
829        thin: false,
830        ..PackBuildOptions::for_local_copy(cfg.as_deref())
831    };
832    let lop_pack_dir = lop.odb().objects_dir().join("pack");
833    let built = crate::maintenance::write_staged_repack_pack(
834        repo.odb(),
835        &lop_pack_dir,
836        blobs,
837        &std::collections::HashMap::new(),
838        &pack_opts,
839        opts.threads,
840        cfg.as_deref(),
841    )?;
842    let Some(staging) = built.staging_path.as_ref() else {
843        return Ok(None);
844    };
845    let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
846    let ingested = install_pack_path(
847        staging,
848        lop.odb(),
849        &IngestPackOptions {
850            threads: opts.threads,
851            environment: lop.environment().clone(),
852            skip_post_index_verify: trusted_rows.is_some(),
853            trusted_index_rows: trusted_rows,
854            keep_message: Some(LOP_KEEP_MESSAGE.to_owned()),
855            ..IngestPackOptions::default()
856        },
857    )?;
858    let objects = ingested.object_ids.len();
859    Ok(ingested.pack_path.map(|path| (path, objects)))
860}
861
862/// For each of `oids`, whether the LOP itself holds it in a pack offload wrote
863/// there (see [`lop_keep_packs`]).
864fn held_in_lop_keep_packs(lop: &Odb, oids: &[ObjectId]) -> Result<Vec<bool>> {
865    let kept = lop_keep_packs(lop)?;
866    Ok(oids
867        .iter()
868        .map(|oid| kept.iter().any(|idx| idx.find_position(oid).is_some()))
869        .collect())
870}
871
872/// The LOP's own packs whose `.keep` marker holds [`LOP_KEEP_MESSAGE`]: the packs
873/// offload wrote there.
874///
875/// Only a copy in one of those is safe to rely on before the main repository drops
876/// its own. Nothing in a ref-less LOP reaches a blob, so a loose object or one in
877/// an unkept pack goes at its next `gc`, and so does one in a pack kept only for a
878/// while: `receive-pack` and `fetch-pack` keep the pack they are writing until
879/// they have updated the refs, then remove the marker. A copy found through the
880/// LOP's alternates (possibly the main repository itself) is not the LOP's at all.
881///
882/// # Errors
883///
884/// Pack directory read failures.
885pub(crate) fn lop_keep_packs(lop: &Odb) -> Result<Vec<std::sync::Arc<PackIndex>>> {
886    Ok(lop
887        .local_pack_indexes()?
888        .into_iter()
889        .filter(|idx| has_lop_keep_marker(&idx.pack_path))
890        .collect())
891}
892
893/// Whether the `.keep` marker next to `pack_path` is one offload wrote.
894fn has_lop_keep_marker(pack_path: &Path) -> bool {
895    std::fs::read(pack_path.with_extension("keep"))
896        .is_ok_and(|contents| contents.trim_ascii_end() == LOP_KEEP_MESSAGE.as_bytes())
897}
898
899/// Set `changes` and add the values of `additions` (keeping earlier ones) in the
900/// local config of the repository at `git_dir`.
901fn write_config(
902    git_dir: &Path,
903    changes: &[(String, String)],
904    additions: &[(String, String)],
905) -> Result<()> {
906    if changes.is_empty() && additions.is_empty() {
907        return Ok(());
908    }
909    let path = git_dir.join("config");
910    let content = match std::fs::read_to_string(&path) {
911        Ok(content) => content,
912        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
913        Err(err) => return Err(Error::Io(err)),
914    };
915    let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
916    for (key, value) in changes {
917        config.set(key, value)?;
918    }
919    for (key, value) in additions {
920        config.add_value(key, value)?;
921    }
922    config.write()
923}
924
925/// `git_dir` as recorded in [`LOP_GIT_DIR_KEY`]: absolute, symlinks resolved when
926/// possible.
927fn lop_dir_value(git_dir: &Path) -> String {
928    use crate::git_path::CanonicalizeExt as _;
929    git_dir
930        .canonicalize_simplified()
931        .unwrap_or_else(|_| git_dir.to_path_buf())
932        .to_string_lossy()
933        .into_owned()
934}
935
936/// Whether two repositories store their objects in the same directory: one is the
937/// other, a linked worktree of it, or has its `objects/` linked to the other's.
938pub(crate) fn same_object_store(a: &Repository, b: &Repository) -> bool {
939    same_dir(a.odb().objects_dir(), b.odb().objects_dir())
940}
941
942/// Whether two paths name the same directory.
943pub(crate) fn same_dir(a: &Path, b: &Path) -> bool {
944    use crate::git_path::CanonicalizeExt as _;
945    match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
946        (Ok(a), Ok(b)) => a == b,
947        _ => a == b,
948    }
949}
950
951/// Bytes of the repository's own packs (with indexes) and loose objects.
952fn local_object_bytes(odb: &Odb) -> Result<u64> {
953    odb.invalidate_packs();
954    let mut total = 0u64;
955    for idx in odb.local_pack_indexes()? {
956        for path in [&idx.pack_path, &idx.idx_path] {
957            total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
958        }
959    }
960    for (_, path) in odb.enumerate_local_loose_objects()? {
961        total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
962    }
963    Ok(total)
964}
965
966#[cfg(test)]
967#[allow(clippy::unwrap_used, clippy::expect_used)]
968mod tests {
969    use super::*;
970    use crate::repo::InitOptions;
971
972    const NOW: i64 = 1_700_000_000;
973
974    /// A bare main repository whose `main` commit holds one blob per entry of
975    /// `sizes` (file `f<i>`), a bare LOP next to it, and `remote.lop` in main.
976    struct Fixture {
977        dir: tempfile::TempDir,
978        blobs: Vec<ObjectId>,
979    }
980
981    /// `size` bytes that do not compress, different for each `seed`.
982    fn blob_data(seed: usize, size: usize) -> Vec<u8> {
983        let mut state = (seed as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
984        (0..size)
985            .map(|_| {
986                state ^= state << 13;
987                state ^= state >> 7;
988                state ^= state << 17;
989                state.to_le_bytes()[0]
990            })
991            .collect()
992    }
993
994    impl Fixture {
995        fn new(sizes: &[usize]) -> Self {
996            let dir = tempfile::tempdir().unwrap();
997            let init = InitOptions::new()
998                .with_bare(true)
999                .with_initial_branch("main");
1000            let main = Repository::init(&dir.path().join("main.git"), &init).unwrap();
1001            Repository::init(&dir.path().join("lop.git"), &init).unwrap();
1002            let odb = main.odb();
1003            let blobs: Vec<ObjectId> = sizes
1004                .iter()
1005                .enumerate()
1006                .map(|(i, &size)| odb.write(ObjectKind::Blob, &blob_data(i, size)).unwrap())
1007                .collect();
1008            let mut entries: Vec<crate::objects::TreeEntry> = blobs
1009                .iter()
1010                .enumerate()
1011                .map(|(i, oid)| crate::objects::TreeEntry {
1012                    mode: 0o100644,
1013                    name: format!("f{i}").into_bytes(),
1014                    oid: *oid,
1015                })
1016                .collect();
1017            entries.sort_by(|a, b| a.name.cmp(&b.name));
1018            let tree = odb
1019                .write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
1020                .unwrap();
1021            let commit =
1022                format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nseed\n");
1023            let commit = odb.write(ObjectKind::Commit, commit.as_bytes()).unwrap();
1024            std::fs::write(
1025                dir.path().join("main.git/refs/heads/main"),
1026                format!("{commit}\n"),
1027            )
1028            .unwrap();
1029            let fx = Self { dir, blobs };
1030            fx.append_main_config("[remote \"lop\"]\n\turl = ../lop.git\n");
1031            fx
1032        }
1033
1034        fn main_dir(&self) -> PathBuf {
1035            self.dir.path().join("main.git")
1036        }
1037
1038        fn lop_dir(&self) -> PathBuf {
1039            self.dir.path().join("lop.git")
1040        }
1041
1042        fn main(&self) -> Repository {
1043            Repository::open(&self.main_dir(), &RepositoryOptions::empty()).unwrap()
1044        }
1045
1046        fn lop(&self) -> Repository {
1047            Repository::open(&self.lop_dir(), &RepositoryOptions::empty()).unwrap()
1048        }
1049
1050        fn append_main_config(&self, text: &str) {
1051            let path = self.main_dir().join("config");
1052            let mut config = std::fs::read_to_string(&path).unwrap();
1053            config.push_str(text);
1054            std::fs::write(path, config).unwrap();
1055        }
1056
1057        fn offload(&self, limit: u64) -> OffloadReport {
1058            let mut opts = OffloadOptions::new("lop", NOW);
1059            opts.filter = Some(ObjectFilterSpec::BlobLimit(limit));
1060            offload_blobs(&self.main(), &opts).expect("offload")
1061        }
1062
1063        fn files(&self, dir: &Path, ext: &str) -> Vec<PathBuf> {
1064            std::fs::read_dir(dir.join("objects/pack"))
1065                .map(|entries| {
1066                    entries
1067                        .flatten()
1068                        .map(|e| e.path())
1069                        .filter(|p| p.extension().is_some_and(|e| e == ext))
1070                        .collect()
1071                })
1072                .unwrap_or_default()
1073        }
1074    }
1075
1076    #[test]
1077    fn blobs_at_the_limit_move_and_smaller_ones_stay() {
1078        let fx = Fixture::new(&[10_000, 9_999, 30_000]);
1079        let report = fx.offload(10_000);
1080        assert_eq!(report.candidates, 2);
1081        assert_eq!(report.candidate_bytes, 40_000);
1082        assert_eq!(report.copied_to_lop, 2);
1083        assert_eq!(report.dropped_from_main, 2);
1084        assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(10_000));
1085
1086        let main = fx.main();
1087        let lop = fx.lop();
1088        for (i, moved) in [(0, true), (1, false), (2, true)] {
1089            let blob = fx.blobs[i];
1090            assert_eq!(!main.odb().exists_stored(&blob), moved, "main f{i}");
1091            assert_eq!(lop.odb().exists_stored(&blob), moved, "lop f{i}");
1092        }
1093        // The rest of main, the kept blob included, lives in one promisor pack; the
1094        // loose objects are gone.
1095        let promisor = fx.files(&fx.main_dir(), "promisor");
1096        assert_eq!(promisor.len(), 1);
1097        assert_eq!(
1098            report.main_pack.as_ref().and_then(|p| p.path.file_name()),
1099            promisor[0].with_extension("pack").file_name()
1100        );
1101        let indexes = main.odb().local_pack_indexes().unwrap();
1102        let promisor_idx = indexes
1103            .iter()
1104            .find(|i| i.is_promisor)
1105            .expect("promisor pack");
1106        assert_eq!(indexes.len(), 1);
1107        assert_eq!(promisor_idx.len(), 3, "the commit, its tree and f1");
1108        assert!(promisor_idx.find_position(&fx.blobs[1]).is_some());
1109        assert!(main
1110            .odb()
1111            .enumerate_local_loose_objects()
1112            .unwrap()
1113            .is_empty());
1114        assert!(report.main_bytes_after < report.main_bytes_before);
1115
1116        // The report describes each pack it wrote.
1117        let roles: Vec<_> = report.packs().map(|p| (p.role, p.objects)).collect();
1118        assert_eq!(
1119            roles,
1120            [
1121                (OffloadPackRole::Lop, 2),
1122                (OffloadPackRole::MainPromisor, 3),
1123            ]
1124        );
1125        for pack in report.packs() {
1126            assert_eq!(pack.bytes, std::fs::metadata(&pack.path).unwrap().len());
1127        }
1128        assert!(report
1129            .lop_pack
1130            .as_ref()
1131            .unwrap()
1132            .path
1133            .starts_with(fx.lop().git_dir()));
1134    }
1135
1136    #[test]
1137    fn lop_pack_is_kept_and_complete() {
1138        let fx = Fixture::new(&[4096]);
1139        let report = fx.offload(1024);
1140        let lop_pack = report.lop_pack.expect("lop pack").path;
1141        assert_eq!(
1142            std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap(),
1143            format!("{LOP_KEEP_MESSAGE}\n")
1144        );
1145        let blob = fx.lop().odb().read(&fx.blobs[0]).unwrap();
1146        assert_eq!(blob.data, blob_data(0, 4096));
1147    }
1148
1149    #[test]
1150    fn config_is_set_in_main_and_optionally_in_the_lop() {
1151        let fx = Fixture::new(&[2048]);
1152        let mut opts = OffloadOptions::new("lop", NOW);
1153        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1154        opts.configure_lop = true;
1155        let report = offload_blobs(&fx.main(), &opts).unwrap();
1156        let lop_dir = lop_dir_value(fx.lop().git_dir());
1157        assert!(Path::new(&lop_dir).is_absolute());
1158        let keys: Vec<(OffloadRepo, &str, &str)> = report
1159            .config_changes
1160            .iter()
1161            .map(|c| (c.repo, c.key.as_str(), c.value.as_str()))
1162            .collect();
1163        assert_eq!(
1164            keys,
1165            [
1166                (OffloadRepo::Main, "remote.lop.promisor", "true"),
1167                (
1168                    OffloadRepo::Main,
1169                    "remote.lop.partialCloneFilter",
1170                    "blob:limit=1024"
1171                ),
1172                (OffloadRepo::Main, "repack.writeBitmaps", "false"),
1173                (OffloadRepo::Main, LOP_GIT_DIR_KEY, lop_dir.as_str()),
1174                (OffloadRepo::Lop, "uploadpack.allowFilter", "true"),
1175                (OffloadRepo::Lop, "uploadpack.allowAnySHA1InWant", "true"),
1176            ]
1177        );
1178        assert!(report.lop_ready.is_ready());
1179        assert!(!report.advertised);
1180        let config = fx.main().config().unwrap();
1181        assert_eq!(config.get("remote.lop.promisor").as_deref(), Some("true"));
1182        assert!(config.get("extensions.partialclone").is_none());
1183        assert_eq!(
1184            crate::partial_clone::stored_remote_filter(&config, "lop"),
1185            Some(ObjectFilterSpec::BlobLimit(1024))
1186        );
1187        assert_eq!(config.get_all(LOP_GIT_DIR_KEY), [lop_dir.clone()]);
1188
1189        // The LOP is recorded once, however often offload runs.
1190        let again = offload_blobs(&fx.main(), &opts).unwrap();
1191        assert!(
1192            again.config_changes.is_empty(),
1193            "{:?}",
1194            again.config_changes
1195        );
1196        assert_eq!(
1197            fx.main().config().unwrap().get_all(LOP_GIT_DIR_KEY),
1198            [lop_dir]
1199        );
1200    }
1201
1202    #[test]
1203    fn stored_filter_is_used_and_lop_is_left_alone_by_default() {
1204        let fx = Fixture::new(&[2048, 10]);
1205        fx.append_main_config("\tpartialCloneFilter = blob:limit=1k\n");
1206        let report = offload_blobs(&fx.main(), &OffloadOptions::new("lop", NOW)).unwrap();
1207        assert_eq!(report.candidates, 1);
1208        assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(1024));
1209        let keys: Vec<&str> = report
1210            .config_changes
1211            .iter()
1212            .map(|c| c.key.as_str())
1213            .collect();
1214        assert_eq!(
1215            keys,
1216            [
1217                "remote.lop.promisor",
1218                "repack.writeBitmaps",
1219                LOP_GIT_DIR_KEY
1220            ]
1221        );
1222        assert_eq!(report.lop_ready, LopReadiness::default());
1223    }
1224
1225    /// Every file under `dir` with its contents, sorted by path.
1226    fn tree_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
1227        let mut out = Vec::new();
1228        let mut stack = vec![dir.to_path_buf()];
1229        while let Some(path) = stack.pop() {
1230            if path.is_dir() {
1231                stack.extend(
1232                    std::fs::read_dir(&path)
1233                        .unwrap()
1234                        .flatten()
1235                        .map(|e| e.path()),
1236                );
1237            } else {
1238                out.push((path.clone(), std::fs::read(&path).unwrap()));
1239            }
1240        }
1241        out.sort();
1242        out
1243    }
1244
1245    #[test]
1246    fn dry_run_writes_nothing() {
1247        let fx = Fixture::new(&[2048]);
1248        let before = tree_snapshot(&fx.main_dir());
1249        let mut opts = OffloadOptions::new("lop", NOW);
1250        opts.filter = Some(ObjectFilterSpec::BlobNone);
1251        opts.dry_run = true;
1252        opts.configure_lop = true;
1253        let report = offload_blobs(&fx.main(), &opts).unwrap();
1254        assert!(report.dry_run);
1255        assert_eq!(report.candidates, 1);
1256        assert_eq!(report.copied_to_lop, 0);
1257        assert!(report.config_changes.is_empty());
1258        assert_eq!(tree_snapshot(&fx.main_dir()), before);
1259        assert!(fx.files(&fx.lop_dir(), "pack").is_empty());
1260        assert!(!fx.main_dir().join("gc.pid").exists());
1261    }
1262
1263    #[test]
1264    fn running_again_is_a_no_op_until_new_blobs_arrive() {
1265        let fx = Fixture::new(&[2048, 10]);
1266        fx.offload(1024);
1267        let again = fx.offload(1024);
1268        assert_eq!(again.candidates, 0);
1269        assert_eq!(again.copied_to_lop, 0);
1270        assert!(again.main_pack.is_none(), "nothing to rewrite");
1271        assert_eq!(fx.files(&fx.lop_dir(), "pack").len(), 1);
1272        assert_eq!(fx.files(&fx.main_dir(), "pack").len(), 1);
1273    }
1274
1275    /// Put `fx`'s first blob into a new LOP pack whose `.keep` marker says `message`.
1276    fn pack_into_lop(fx: &Fixture, message: &str) {
1277        let pack = crate::pack_objects::build_pack_from_send_list(
1278            fx.main().odb(),
1279            &[fx.blobs[0]],
1280            &std::collections::HashSet::new(),
1281            &PackBuildOptions::default(),
1282            &std::collections::HashMap::new(),
1283        )
1284        .unwrap();
1285        crate::index_pack::install_pack_bytes(
1286            pack,
1287            fx.lop().odb(),
1288            &IngestPackOptions {
1289                keep_message: Some(message.to_owned()),
1290                ..IngestPackOptions::default()
1291            },
1292        )
1293        .unwrap();
1294    }
1295
1296    #[test]
1297    fn blobs_the_lop_keeps_are_not_copied_again() {
1298        let fx = Fixture::new(&[2048]);
1299        pack_into_lop(&fx, LOP_KEEP_MESSAGE);
1300        let report = fx.offload(1024);
1301        assert_eq!(report.already_in_lop, 1);
1302        assert_eq!(report.copied_to_lop, 0);
1303        assert!(report.lop_pack.is_none());
1304        assert_eq!(report.dropped_from_main, 1);
1305    }
1306
1307    #[test]
1308    fn a_copy_another_writer_keeps_for_a_while_is_not_trusted() {
1309        // receive-pack and fetch-pack keep the pack they write until the refs are
1310        // updated, then remove the marker; an operator's marker is no promise either.
1311        for message in ["receive-pack 4242 on build-host", "pinned"] {
1312            let fx = Fixture::new(&[2048]);
1313            pack_into_lop(&fx, message);
1314            let report = fx.offload(1024);
1315            assert_eq!(report.already_in_lop, 0, "{message}");
1316            assert_eq!(report.copied_to_lop, 1, "{message}");
1317            let lop_pack = report.lop_pack.expect("copied").path;
1318            let keep = std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap();
1319            assert_eq!(keep.trim_end(), LOP_KEEP_MESSAGE);
1320        }
1321    }
1322
1323    #[cfg(unix)]
1324    #[test]
1325    fn a_lop_sharing_main_objects_is_refused() {
1326        let fx = Fixture::new(&[2048]);
1327        let lop_objects = fx.lop_dir().join("objects");
1328        std::fs::remove_dir_all(&lop_objects).unwrap();
1329        std::os::unix::fs::symlink(fx.main_dir().join("objects"), &lop_objects).unwrap();
1330        let mut opts = OffloadOptions::new("lop", NOW);
1331        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1332        assert!(matches!(
1333            offload_err(&fx, &opts),
1334            OffloadError::LopIsMainRepository
1335        ));
1336        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1337    }
1338
1339    /// Remove the LOP's loose objects, as its `gc --prune=now` would for blobs
1340    /// nothing in it references.
1341    fn prune_lop_loose(fx: &Fixture) {
1342        for (_, path) in fx.lop().odb().enumerate_local_loose_objects().unwrap() {
1343            std::fs::remove_file(path).unwrap();
1344        }
1345    }
1346
1347    #[test]
1348    fn a_loose_copy_in_the_lop_is_not_trusted() {
1349        let fx = Fixture::new(&[2048]);
1350        fx.lop()
1351            .odb()
1352            .write(ObjectKind::Blob, &blob_data(0, 2048))
1353            .unwrap();
1354        let report = fx.offload(1024);
1355        assert_eq!(report.already_in_lop, 0);
1356        assert_eq!(report.copied_to_lop, 1);
1357        assert!(report.lop_pack.is_some());
1358        assert_eq!(report.dropped_from_main, 1);
1359        prune_lop_loose(&fx);
1360        let lop = fx.lop();
1361        assert_eq!(
1362            lop.odb().read(&fx.blobs[0]).unwrap().data,
1363            blob_data(0, 2048),
1364            "the kept pack survives the LOP's prune"
1365        );
1366    }
1367
1368    #[test]
1369    fn a_copy_the_lop_borrows_from_main_is_not_trusted() {
1370        let fx = Fixture::new(&[2048]);
1371        let alternates = fx.lop_dir().join("objects/info/alternates");
1372        std::fs::create_dir_all(alternates.parent().unwrap()).unwrap();
1373        std::fs::write(
1374            &alternates,
1375            format!("{}\n", fx.main_dir().join("objects").display()),
1376        )
1377        .unwrap();
1378        let report = fx.offload(1024);
1379        assert_eq!(report.already_in_lop, 0);
1380        assert_eq!(report.copied_to_lop, 1);
1381        std::fs::remove_file(&alternates).unwrap();
1382        assert_eq!(
1383            fx.lop().odb().read(&fx.blobs[0]).unwrap().data,
1384            blob_data(0, 2048),
1385            "the LOP has its own copy"
1386        );
1387    }
1388
1389    #[test]
1390    fn running_again_folds_pushed_objects_into_the_promisor_pack() {
1391        let fx = Fixture::new(&[2048, 10]);
1392        fx.offload(1024);
1393        // A push since: a loose tree that still names the offloaded blob.
1394        let main = fx.main();
1395        let mut body = b"100644 big\0".to_vec();
1396        body.extend_from_slice(fx.blobs[0].as_bytes());
1397        let tree = main
1398            .odb()
1399            .write_loose_materialize(ObjectKind::Tree, &body)
1400            .unwrap();
1401        let again = fx.offload(1024);
1402        assert_eq!(again.candidates, 0);
1403        let pack = again.main_pack.expect("folded").path;
1404        let promisor: Vec<_> = fx
1405            .files(&fx.main_dir(), "promisor")
1406            .iter()
1407            .map(|p| p.file_name().map(|n| n.to_os_string()))
1408            .collect();
1409        assert_eq!(
1410            promisor,
1411            [pack
1412                .with_extension("promisor")
1413                .file_name()
1414                .map(|n| n.to_os_string())]
1415        );
1416        let main = fx.main();
1417        assert!(main
1418            .odb()
1419            .enumerate_local_loose_objects()
1420            .unwrap()
1421            .is_empty());
1422        assert!(main.odb().exists_stored(&tree));
1423        assert!(!main.odb().exists_stored(&fx.blobs[0]));
1424    }
1425
1426    #[test]
1427    fn blobs_in_kept_packs_stay() {
1428        let fx = Fixture::new(&[2048]);
1429        let main = fx.main();
1430        let pack = crate::pack_objects::build_pack_from_send_list(
1431            main.odb(),
1432            &[fx.blobs[0]],
1433            &std::collections::HashSet::new(),
1434            &PackBuildOptions::default(),
1435            &std::collections::HashMap::new(),
1436        )
1437        .unwrap();
1438        crate::index_pack::install_pack_bytes(
1439            pack,
1440            main.odb(),
1441            &IngestPackOptions {
1442                keep_message: Some("pinned".into()),
1443                ..IngestPackOptions::default()
1444            },
1445        )
1446        .unwrap();
1447        let report = fx.offload(1024);
1448        assert_eq!(report.candidates, 0);
1449        assert_eq!(report.skipped_kept_pack, 1);
1450        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1451    }
1452
1453    /// Pack `oids` of main into a new pack with a `.keep` marker, dropping their
1454    /// loose copies.
1455    fn keep_in_main(fx: &Fixture, oids: &[ObjectId]) -> PathBuf {
1456        let main = fx.main();
1457        let pack = crate::pack_objects::build_pack_from_send_list(
1458            main.odb(),
1459            oids,
1460            &std::collections::HashSet::new(),
1461            &PackBuildOptions::default(),
1462            &std::collections::HashMap::new(),
1463        )
1464        .unwrap();
1465        let installed = crate::index_pack::install_pack_bytes(
1466            pack,
1467            main.odb(),
1468            &IngestPackOptions {
1469                keep_message: Some("pinned".into()),
1470                ..IngestPackOptions::default()
1471            },
1472        )
1473        .unwrap();
1474        crate::prune_packed::prune_packed_objects(main.odb().objects_dir(), Default::default())
1475            .unwrap();
1476        installed.pack_path.unwrap()
1477    }
1478
1479    #[test]
1480    fn a_kept_object_the_promisor_pack_would_name_stops_offload() {
1481        // f1 (small, staying) has its only copy in an operator's kept pack, and the
1482        // tree naming it is loose: in the promisor pack it would make f1 promised.
1483        let fx = Fixture::new(&[2048, 10]);
1484        let kept = keep_in_main(&fx, &[fx.blobs[1]]);
1485        let mut opts = OffloadOptions::new("lop", NOW);
1486        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1487        for dry_run in [true, false] {
1488            opts.dry_run = dry_run;
1489            match offload_err(&fx, &opts) {
1490                OffloadError::KeptPackObjectPromised { pack, oid, .. } => {
1491                    assert_eq!(pack, kept);
1492                    assert_eq!(oid, fx.blobs[1]);
1493                }
1494                other => panic!("got {other:?}"),
1495            }
1496        }
1497        assert!(fx.files(&fx.lop_dir(), "pack").is_empty(), "nothing copied");
1498        assert!(fx.files(&fx.main_dir(), "promisor").is_empty());
1499        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1500
1501        // Without the marker the pack is folded in like any other.
1502        std::fs::remove_file(kept.with_extension("keep")).unwrap();
1503        let report = fx.offload(1024);
1504        assert_eq!(report.dropped_from_main, 1);
1505        let main = fx.main();
1506        let indexes = main.odb().local_pack_indexes().unwrap();
1507        assert_eq!(indexes.len(), 1);
1508        assert!(indexes[0].is_promisor);
1509        assert!(indexes[0].find_position(&fx.blobs[1]).is_some());
1510    }
1511
1512    #[test]
1513    fn a_kept_pack_nothing_names_does_not_stop_offload() {
1514        let fx = Fixture::new(&[2048]);
1515        let stray = fx
1516            .main()
1517            .odb()
1518            .write(ObjectKind::Blob, b"named by nothing")
1519            .unwrap();
1520        let kept = keep_in_main(&fx, &[stray]);
1521        let report = fx.offload(1024);
1522        assert_eq!(report.dropped_from_main, 1);
1523        assert!(kept.is_file() && kept.with_extension("keep").is_file());
1524        assert!(fx.main().odb().exists_stored(&stray));
1525    }
1526
1527    #[test]
1528    fn blobs_a_checkout_needs_stay() {
1529        let dir = tempfile::tempdir().unwrap();
1530        let work = dir.path().join("work");
1531        let repo =
1532            Repository::init(&work, &InitOptions::new().with_initial_branch("main")).unwrap();
1533        Repository::init(
1534            &dir.path().join("lop.git"),
1535            &InitOptions::new().with_bare(true),
1536        )
1537        .unwrap();
1538        std::fs::write(work.join("big"), vec![b'x'; 4096]).unwrap();
1539        crate::porcelain::add::stage_for_commit(
1540            &repo,
1541            &mut crate::progress::NullProgress,
1542            &crate::progress::NeverCancel,
1543        )
1544        .unwrap();
1545        let ident = "T <t@example.com> 1 +0000".to_string();
1546        let mut req = crate::porcelain::commit::CommitRequest::new("big");
1547        let sig: crate::ident::Signature = ident.parse().unwrap();
1548        req.author = Some(sig.clone());
1549        req.committer = Some(sig);
1550        crate::porcelain::commit::create_commit(
1551            &repo,
1552            &req,
1553            &mut crate::progress::NullProgress,
1554            &crate::progress::NeverCancel,
1555        )
1556        .unwrap();
1557        let config = repo.git_dir().join("config");
1558        let mut text = std::fs::read_to_string(&config).unwrap();
1559        text.push_str(&format!(
1560            "[remote \"lop\"]\n\turl = {}\n",
1561            dir.path().join("lop.git").display()
1562        ));
1563        std::fs::write(&config, text).unwrap();
1564        let repo = Repository::discover(Some(&work), &RepositoryOptions::empty()).unwrap();
1565        let mut opts = OffloadOptions::new("lop", NOW);
1566        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1567        let report = offload_blobs(&repo, &opts).unwrap();
1568        assert_eq!(report.candidates, 0);
1569        assert_eq!(report.skipped_checked_out, 1);
1570    }
1571
1572    fn offload_err(fx: &Fixture, opts: &OffloadOptions) -> OffloadError {
1573        match offload_blobs(&fx.main(), opts) {
1574            Err(Error::Offload(err)) => err,
1575            other => panic!("expected an offload error, got {other:?}"),
1576        }
1577    }
1578
1579    #[test]
1580    fn refusals_are_typed() {
1581        let fx = Fixture::new(&[2048]);
1582        let with_filter = |remote: &str| {
1583            let mut opts = OffloadOptions::new(remote, NOW);
1584            opts.filter = Some(ObjectFilterSpec::BlobNone);
1585            opts
1586        };
1587        assert!(matches!(
1588            offload_err(&fx, &with_filter("nope")),
1589            OffloadError::RemoteNotConfigured { remote } if remote == "nope"
1590        ));
1591        assert!(matches!(
1592            offload_err(&fx, &OffloadOptions::new("lop", NOW)),
1593            OffloadError::NoThreshold { .. }
1594        ));
1595
1596        fx.append_main_config(
1597            "[remote \"web\"]\n\turl = https://user:secret@lop.example/r.git\n\
1598             [remote \"self\"]\n\turl = .\n\
1599             [remote \"gone\"]\n\turl = ../nothing-here.git\n",
1600        );
1601        match offload_err(&fx, &with_filter("web")) {
1602            OffloadError::LopNotLocal { url, .. } => assert!(!url.contains("secret")),
1603            other => panic!("got {other:?}"),
1604        }
1605        assert!(matches!(
1606            offload_err(&fx, &with_filter("self")),
1607            OffloadError::LopIsMainRepository
1608        ));
1609        assert!(matches!(
1610            offload_err(&fx, &with_filter("gone")),
1611            OffloadError::LopNotARepository { .. }
1612        ));
1613
1614        let sha256 = fx.dir.path().join("sha256.git");
1615        Repository::init(
1616            &sha256,
1617            &InitOptions::new()
1618                .with_bare(true)
1619                .with_object_format(HashAlgo::Sha256),
1620        )
1621        .unwrap();
1622        let mut opts = with_filter("web");
1623        opts.lop_git_dir = Some(sha256);
1624        assert!(matches!(
1625            offload_err(&fx, &opts),
1626            OffloadError::HashAlgoMismatch {
1627                main: HashAlgo::Sha1,
1628                lop: HashAlgo::Sha256
1629            }
1630        ));
1631
1632        std::fs::write(fx.main_dir().join("gc.pid"), "1 some-other-host").unwrap();
1633        assert!(matches!(
1634            offload_err(&fx, &with_filter("lop")),
1635            OffloadError::MaintenanceRunning { .. }
1636        ));
1637    }
1638}