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.bool_or(key, 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.bool_or("promisor.advertise", false),
437    };
438    if opts.dry_run {
439        return Ok(report);
440    }
441
442    if !missing_in_lop.is_empty() {
443        report.lop_pack = copy_to_lop(repo, &lop, &missing_in_lop, opts)?
444            .map(|(path, objects)| OffloadPack::written(OffloadPackRole::Lop, path, objects))
445            .transpose()?;
446        report.copied_to_lop = missing_in_lop.len();
447    }
448    lop.odb().invalidate_packs();
449    let present = held_in_lop_keep_packs(lop.odb(), &candidates)?;
450    if let Some((oid, _)) = candidates.iter().zip(&present).find(|(_, has)| !**has) {
451        return Err(OffloadError::NotInLopAfterCopy { oid: *oid }.into());
452    }
453    if let Some(pack) = &report.lop_pack {
454        // The main repository is about to drop its copies: the LOP's must be on disk.
455        crate::index_pack::sync_installed_pack(&pack.path)?;
456    }
457
458    let mut wanted: Vec<(String, String)> = Vec::new();
459    let promisor_key = format!("remote.{remote}.promisor");
460    if !config.bool_or(&promisor_key, false) {
461        wanted.push((promisor_key, "true".to_owned()));
462    }
463    let filter_key = format!("remote.{remote}.partialCloneFilter");
464    if opts.filter.is_some() && config.get(&filter_key).is_none() {
465        wanted.push((filter_key, filter.to_config_value()));
466    }
467    // No pack or multi-pack-index holds the offloaded blobs, so no bitmap can have
468    // full closure; once a push lands outside the promisor pack, Git's `gc` fails
469    // trying (bitmaps are on by default in bare repositories).
470    let bitmaps_key = "repack.writeBitmaps";
471    if config.bool_or(bitmaps_key, true) {
472        wanted.push((bitmaps_key.to_owned(), "false".to_owned()));
473    }
474    // The promisor pack is about to hold blobs the LOP does not have; record where
475    // the LOP is, so dropping promised blobs can check it first.
476    let mut added: Vec<(String, String)> = Vec::new();
477    let lop_dir = lop_dir_value(lop.git_dir());
478    if !config
479        .get_all(LOP_GIT_DIR_KEY)
480        .iter()
481        .any(|v| same_dir(Path::new(v), lop.git_dir()))
482    {
483        added.push((LOP_GIT_DIR_KEY.to_owned(), lop_dir));
484    }
485    write_config(repo.git_dir(), &wanted, &added)?;
486    report
487        .config_changes
488        .extend(
489            wanted
490                .into_iter()
491                .chain(added)
492                .map(|(key, value)| OffloadConfigChange {
493                    repo: OffloadRepo::Main,
494                    key,
495                    value,
496                }),
497        );
498    if opts.configure_lop {
499        let lop_changes: Vec<(String, String)> = [
500            ("uploadpack.allowFilter", report.lop_ready.allow_filter),
501            (
502                "uploadpack.allowAnySHA1InWant",
503                report.lop_ready.allow_any_sha1_in_want,
504            ),
505        ]
506        .into_iter()
507        .filter(|(_, already)| !already)
508        .map(|(key, _)| (key.to_owned(), "true".to_owned()))
509        .collect();
510        write_config(lop.git_dir(), &lop_changes, &[])?;
511        report
512            .config_changes
513            .extend(
514                lop_changes
515                    .into_iter()
516                    .map(|(key, value)| OffloadConfigChange {
517                        repo: OffloadRepo::Lop,
518                        key,
519                        value,
520                    }),
521            );
522        report.lop_ready = LopReadiness::from_config(&lop_config(&lop)?);
523    }
524
525    let omit: OidHashSet = candidates.iter().copied().collect();
526    let repack = RepackOptions {
527        threads: opts.threads,
528        environment: repo.environment().clone(),
529        ..RepackOptions::default()
530    };
531    // Fold in what arrived since the last run even when nothing moves now: a
532    // pushed tree naming an offloaded blob outside the promisor pack makes every
533    // promise check for that blob scan all promisor packs.
534    let rewrite = crate::maintenance::rewrite_local_objects_without(
535        repo,
536        &omit,
537        &repack,
538        crate::maintenance::PromisorRewriteWhen::Always,
539    )?;
540    report.main_pack = rewrite
541        .pack_path
542        .map(|path| OffloadPack::written(OffloadPackRole::MainPromisor, path, rewrite.objects))
543        .transpose()?;
544    report.dropped_from_main = odb.missing_stored_oids(candidates.iter().copied())?.len();
545    report.main_bytes_after = local_object_bytes(odb)?;
546    Ok(report)
547}
548
549/// Open the LOP named by `opts` (or by the remote's local `url`) and check it can
550/// take the blobs.
551fn open_lop(repo: &Repository, opts: &OffloadOptions, url: &str) -> Result<Repository> {
552    let path = match &opts.lop_git_dir {
553        Some(path) => path.clone(),
554        None if crate::transport_path::is_local_path_remote_url(url) => {
555            crate::transport_path::resolve_local_remote_git_dir(
556                url,
557                repo.git_dir(),
558                repo.work_tree(),
559            )
560        }
561        None => {
562            return Err(OffloadError::LopNotLocal {
563                remote: opts.remote.clone(),
564                url: crate::transport_path::redact_url_for_display(url),
565            }
566            .into())
567        }
568    };
569    let lop = Repository::open(
570        &path,
571        &RepositoryOptions::with_environment(repo.environment().clone()),
572    )
573    .map_err(|_| OffloadError::LopNotARepository { path: path.clone() })?;
574    if same_object_store(repo, &lop) {
575        return Err(OffloadError::LopIsMainRepository.into());
576    }
577    let (main_algo, lop_algo) = (repo.odb().hash_algo(), lop.odb().hash_algo());
578    if main_algo != lop_algo {
579        return Err(OffloadError::HashAlgoMismatch {
580            main: main_algo,
581            lop: lop_algo,
582        }
583        .into());
584    }
585    lop.odb().require_files_primary("offload")?;
586    Ok(lop)
587}
588
589/// The LOP's own config (system and global included, as its upload-pack sees it).
590fn lop_config(lop: &Repository) -> Result<ConfigSet> {
591    ConfigSet::load(lop.environment(), Some(lop.git_dir()), true)
592}
593
594/// Blobs chosen by [`select_candidates`], with their sizes.
595struct Selection {
596    candidates: OidHashMap<u64>,
597    skipped_checked_out: usize,
598    skipped_kept_pack: usize,
599}
600
601/// Walk every commit, tag and tree reachable from the maintenance roots and pick the
602/// blobs `filter` leaves out that this repository stores and can drop.
603///
604/// A blob qualifies only when a tree naming it will be in the rewritten promisor
605/// pack (stored loose or in a pack without `.keep`): that is what makes it promised
606/// once it is gone. Nothing is fetched; unreadable objects are skipped.
607fn select_candidates(repo: &Repository, filter: ObjectFilterSpec) -> Result<Selection> {
608    let odb = repo.odb();
609    let pack_dir = odb.objects_dir().join("pack");
610    let (kept, rewritten): (Vec<_>, Vec<_>) = odb
611        .local_pack_indexes()?
612        .into_iter()
613        .partition(|idx| pack_dir.join(keep_name(&idx.pack_path)).is_file());
614    let in_any = |indexes: &[std::sync::Arc<PackIndex>], oid: &ObjectId| {
615        indexes.iter().any(|idx| idx.find_position(oid).is_some())
616    };
617    let will_be_rewritten =
618        |oid: &ObjectId| in_any(&rewritten, oid) || odb.local_loose_file_size(oid).is_some();
619    let checked_out = crate::promisor::blobs_needed_by_checkouts(repo)?;
620
621    let mut selection = Selection {
622        candidates: crate::objects::oid_hash_map(),
623        skipped_checked_out: 0,
624        skipped_kept_pack: 0,
625    };
626    // Selected blobs no rewritten tree names yet; settled when one does.
627    let mut unanchored: OidHashMap<u64> = crate::objects::oid_hash_map();
628    let mut blobs_seen = oid_hash_set();
629    let mut walked = oid_hash_set();
630    let mut queue: Vec<ObjectId> = crate::maintenance::collect_maintenance_object_roots(repo)?;
631    let oid_len = odb.hash_algo().len();
632    while let Some(oid) = queue.pop() {
633        if !walked.insert(oid) {
634            continue;
635        }
636        let Some(obj) = odb.try_read_once(&oid)? else {
637            continue;
638        };
639        match obj.kind {
640            ObjectKind::Commit => {
641                let commit = CommitRef::parse(&obj.data)?;
642                queue.push(commit.tree());
643                queue.extend(commit.parents().iter().copied());
644            }
645            ObjectKind::Tag => {
646                if let Some(target) = crate::objects::tag_object_line_oid(&obj.data) {
647                    queue.push(target);
648                }
649            }
650            ObjectKind::Tree => {
651                let anchors = will_be_rewritten(&oid);
652                for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
653                    let entry = entry?;
654                    match entry.mode & 0o170000 {
655                        0o100000 | 0o120000 => {}
656                        0o040000 => {
657                            queue.push(entry.oid);
658                            continue;
659                        }
660                        // Submodule commits are not ours to move.
661                        _ => continue,
662                    }
663                    if anchors {
664                        if let Some(size) = unanchored.remove(&entry.oid) {
665                            selection.candidates.insert(entry.oid, size);
666                        }
667                    }
668                    if !blobs_seen.insert(entry.oid) {
669                        continue;
670                    }
671                    let Some(size) = selected_blob_size(odb, filter, &entry.oid)? else {
672                        continue;
673                    };
674                    if checked_out.contains(&entry.oid) {
675                        selection.skipped_checked_out += 1;
676                    } else if in_any(&kept, &entry.oid) {
677                        selection.skipped_kept_pack += 1;
678                    } else if anchors {
679                        selection.candidates.insert(entry.oid, size);
680                    } else {
681                        unanchored.insert(entry.oid, size);
682                    }
683                }
684            }
685            ObjectKind::Blob => {}
686        }
687    }
688    selection.skipped_kept_pack += unanchored.len();
689    Ok(selection)
690}
691
692/// The refusal [`OffloadError::KeptPackObjectPromised`] when an object going into
693/// the promisor pack (one of the unkept packs or loose objects, except `omit`)
694/// names an object only an ordinary `.keep` pack holds.
695///
696/// Free without such packs. With them, every commit, tree and tag to be rewritten
697/// is read, as the rewrite reads them anyway.
698fn kept_object_the_promisor_pack_names(
699    odb: &Odb,
700    omit: &OidHashMap<u64>,
701) -> Result<Option<OffloadError>> {
702    odb.reprepare_local_packs();
703    let (kept, rewritten): (Vec<_>, Vec<_>) = odb
704        .local_pack_indexes()?
705        .into_iter()
706        .partition(|idx| idx.pack_path.with_extension("keep").is_file());
707    let (kept_promisor, kept_ordinary): (Vec<_>, Vec<_>) =
708        kept.into_iter().partition(|idx| idx.is_promisor);
709    if kept_ordinary.is_empty() {
710        return Ok(None);
711    }
712    let loose: OidHashSet = odb
713        .enumerate_local_loose_objects()?
714        .into_iter()
715        .map(|(oid, _)| oid)
716        .collect();
717    let in_promisor_pack_after = |oid: &ObjectId| {
718        loose.contains(oid)
719            || [&rewritten, &kept_promisor]
720                .into_iter()
721                .flatten()
722                .any(|idx| idx.find_position(oid).is_some())
723    };
724    let mut only_kept: OidHashMap<usize> = crate::objects::oid_hash_map();
725    for (pack_no, idx) in kept_ordinary.iter().enumerate() {
726        for entry in idx.iter() {
727            let oid = ObjectId::from_bytes(entry.oid())?;
728            if !in_promisor_pack_after(&oid) {
729                only_kept.entry(oid).or_insert(pack_no);
730            }
731        }
732    }
733    if only_kept.is_empty() {
734        return Ok(None);
735    }
736    let mut referrers: Vec<ObjectId> = loose.iter().copied().collect();
737    for idx in &rewritten {
738        for entry in idx.iter() {
739            referrers.push(ObjectId::from_bytes(entry.oid())?);
740        }
741    }
742    let oid_len = odb.hash_algo().len();
743    let mut checked = oid_hash_set();
744    for referrer in referrers {
745        if omit.contains_key(&referrer) || !checked.insert(referrer) {
746            continue;
747        }
748        let kind = match odb.read_info_without_fetch(&referrer) {
749            Ok(info) => info.kind,
750            Err(Error::ObjectNotFound(_)) => continue,
751            Err(err) => return Err(err),
752        };
753        if kind == ObjectKind::Blob {
754            continue;
755        }
756        let obj = odb.read(&referrer)?;
757        let mut named: Vec<ObjectId> = Vec::new();
758        match kind {
759            ObjectKind::Commit => {
760                let commit = CommitRef::parse(&obj.data)?;
761                named.push(commit.tree());
762                named.extend(commit.parents().iter().copied());
763            }
764            ObjectKind::Tree => {
765                for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
766                    let entry = entry?;
767                    if entry.mode & 0o170000 != 0o160000 {
768                        named.push(entry.oid);
769                    }
770                }
771            }
772            ObjectKind::Tag => named.extend(crate::objects::tag_object_line_oid(&obj.data)),
773            ObjectKind::Blob => {}
774        }
775        if let Some((oid, &pack_no)) = named.iter().find_map(|oid| only_kept.get_key_value(oid)) {
776            return Ok(Some(OffloadError::KeptPackObjectPromised {
777                pack: kept_ordinary[pack_no].pack_path.clone(),
778                oid: *oid,
779                referrer,
780            }));
781        }
782    }
783    Ok(None)
784}
785
786/// The size of `oid` when it is a blob stored locally that `filter` leaves out.
787fn selected_blob_size(odb: &Odb, filter: ObjectFilterSpec, oid: &ObjectId) -> Result<Option<u64>> {
788    if !odb.try_exists_stored(oid)? {
789        return Ok(None);
790    }
791    let info = match odb.read_info_without_fetch(oid) {
792        Ok(info) => info,
793        Err(Error::ObjectNotFound(_)) => return Ok(None),
794        Err(err) => return Err(err),
795    };
796    if info.kind != ObjectKind::Blob {
797        return Ok(None);
798    }
799    let selected = match filter {
800        ObjectFilterSpec::BlobNone => true,
801        ObjectFilterSpec::BlobLimit(limit) => info.size >= limit,
802    };
803    Ok(selected.then_some(info.size))
804}
805
806fn keep_name(pack_path: &Path) -> PathBuf {
807    PathBuf::from(pack_path.file_name().unwrap_or_default()).with_extension("keep")
808}
809
810/// Copy `blobs` from `repo` into one new `.keep` pack in `lop`; returns the pack and
811/// how many objects it holds.
812fn copy_to_lop(
813    repo: &Repository,
814    lop: &Repository,
815    blobs: &[ObjectId],
816    opts: &OffloadOptions,
817) -> Result<Option<(PathBuf, usize)>> {
818    let cfg = repo.config().ok();
819    let pack_opts = PackBuildOptions {
820        delta: false,
821        window: 0,
822        max_depth: 0,
823        reuse_deltas: false,
824        reuse_objects: true,
825        use_bitmaps: false,
826        thin: false,
827        ..PackBuildOptions::for_local_copy(cfg.as_deref())
828    };
829    let lop_pack_dir = lop.odb().objects_dir().join("pack");
830    let built = crate::maintenance::write_staged_repack_pack(
831        repo.odb(),
832        &lop_pack_dir,
833        blobs,
834        &std::collections::HashMap::new(),
835        &pack_opts,
836        opts.threads,
837        cfg.as_deref(),
838    )?;
839    let Some(staging) = built.staging_path.as_ref() else {
840        return Ok(None);
841    };
842    let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
843    let ingested = install_pack_path(
844        staging,
845        lop.odb(),
846        &IngestPackOptions {
847            threads: opts.threads,
848            environment: lop.environment().clone(),
849            skip_post_index_verify: trusted_rows.is_some(),
850            trusted_index_rows: trusted_rows,
851            keep_message: Some(LOP_KEEP_MESSAGE.to_owned()),
852            ..IngestPackOptions::default()
853        },
854    )?;
855    let objects = ingested.object_ids.len();
856    Ok(ingested.pack_path.map(|path| (path, objects)))
857}
858
859/// For each of `oids`, whether the LOP itself holds it in a pack offload wrote
860/// there (see [`lop_keep_packs`]).
861fn held_in_lop_keep_packs(lop: &Odb, oids: &[ObjectId]) -> Result<Vec<bool>> {
862    let kept = lop_keep_packs(lop)?;
863    Ok(oids
864        .iter()
865        .map(|oid| kept.iter().any(|idx| idx.find_position(oid).is_some()))
866        .collect())
867}
868
869/// The LOP's own packs whose `.keep` marker holds [`LOP_KEEP_MESSAGE`]: the packs
870/// offload wrote there.
871///
872/// Only a copy in one of those is safe to rely on before the main repository drops
873/// its own. Nothing in a ref-less LOP reaches a blob, so a loose object or one in
874/// an unkept pack goes at its next `gc`, and so does one in a pack kept only for a
875/// while: `receive-pack` and `fetch-pack` keep the pack they are writing until
876/// they have updated the refs, then remove the marker. A copy found through the
877/// LOP's alternates (possibly the main repository itself) is not the LOP's at all.
878///
879/// # Errors
880///
881/// Pack directory read failures.
882pub(crate) fn lop_keep_packs(lop: &Odb) -> Result<Vec<std::sync::Arc<PackIndex>>> {
883    Ok(lop
884        .local_pack_indexes()?
885        .into_iter()
886        .filter(|idx| has_lop_keep_marker(&idx.pack_path))
887        .collect())
888}
889
890/// Whether the `.keep` marker next to `pack_path` is one offload wrote.
891fn has_lop_keep_marker(pack_path: &Path) -> bool {
892    std::fs::read(pack_path.with_extension("keep"))
893        .is_ok_and(|contents| contents.trim_ascii_end() == LOP_KEEP_MESSAGE.as_bytes())
894}
895
896/// Set `changes` and add the values of `additions` (keeping earlier ones) in the
897/// local config of the repository at `git_dir`.
898fn write_config(
899    git_dir: &Path,
900    changes: &[(String, String)],
901    additions: &[(String, String)],
902) -> Result<()> {
903    if changes.is_empty() && additions.is_empty() {
904        return Ok(());
905    }
906    let path = git_dir.join("config");
907    let content = match std::fs::read_to_string(&path) {
908        Ok(content) => content,
909        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
910        Err(err) => return Err(Error::Io(err)),
911    };
912    let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
913    for (key, value) in changes {
914        config.set(key, value)?;
915    }
916    for (key, value) in additions {
917        config.add_value(key, value)?;
918    }
919    config.write()
920}
921
922/// `git_dir` as recorded in [`LOP_GIT_DIR_KEY`]: absolute, symlinks resolved when
923/// possible.
924fn lop_dir_value(git_dir: &Path) -> String {
925    use crate::git_path::CanonicalizeExt as _;
926    git_dir
927        .canonicalize_simplified()
928        .unwrap_or_else(|_| git_dir.to_path_buf())
929        .to_string_lossy()
930        .into_owned()
931}
932
933/// Whether two repositories store their objects in the same directory: one is the
934/// other, a linked worktree of it, or has its `objects/` linked to the other's.
935pub(crate) fn same_object_store(a: &Repository, b: &Repository) -> bool {
936    same_dir(a.odb().objects_dir(), b.odb().objects_dir())
937}
938
939/// Whether two paths name the same directory.
940pub(crate) fn same_dir(a: &Path, b: &Path) -> bool {
941    use crate::git_path::CanonicalizeExt as _;
942    match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
943        (Ok(a), Ok(b)) => a == b,
944        _ => a == b,
945    }
946}
947
948/// Bytes of the repository's own packs (with indexes) and loose objects.
949fn local_object_bytes(odb: &Odb) -> Result<u64> {
950    odb.invalidate_packs();
951    let mut total = 0u64;
952    for idx in odb.local_pack_indexes()? {
953        for path in [&idx.pack_path, &idx.idx_path] {
954            total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
955        }
956    }
957    for (_, path) in odb.enumerate_local_loose_objects()? {
958        total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
959    }
960    Ok(total)
961}
962
963#[cfg(test)]
964#[allow(clippy::unwrap_used, clippy::expect_used)]
965mod tests {
966    use super::*;
967    use crate::repo::InitOptions;
968
969    const NOW: i64 = 1_700_000_000;
970
971    /// A bare main repository whose `main` commit holds one blob per entry of
972    /// `sizes` (file `f<i>`), a bare LOP next to it, and `remote.lop` in main.
973    struct Fixture {
974        dir: tempfile::TempDir,
975        blobs: Vec<ObjectId>,
976    }
977
978    /// `size` bytes that do not compress, different for each `seed`.
979    fn blob_data(seed: usize, size: usize) -> Vec<u8> {
980        let mut state = (seed as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
981        (0..size)
982            .map(|_| {
983                state ^= state << 13;
984                state ^= state >> 7;
985                state ^= state << 17;
986                state.to_le_bytes()[0]
987            })
988            .collect()
989    }
990
991    impl Fixture {
992        fn new(sizes: &[usize]) -> Self {
993            let dir = tempfile::tempdir().unwrap();
994            let init = InitOptions::new()
995                .with_bare(true)
996                .with_initial_branch("main");
997            let main = Repository::init(&dir.path().join("main.git"), &init).unwrap();
998            Repository::init(&dir.path().join("lop.git"), &init).unwrap();
999            let odb = main.odb();
1000            let blobs: Vec<ObjectId> = sizes
1001                .iter()
1002                .enumerate()
1003                .map(|(i, &size)| odb.write(ObjectKind::Blob, &blob_data(i, size)).unwrap())
1004                .collect();
1005            let mut entries: Vec<crate::objects::TreeEntry> = blobs
1006                .iter()
1007                .enumerate()
1008                .map(|(i, oid)| crate::objects::TreeEntry {
1009                    mode: 0o100644,
1010                    name: format!("f{i}").into_bytes(),
1011                    oid: *oid,
1012                })
1013                .collect();
1014            entries.sort_by(|a, b| a.name.cmp(&b.name));
1015            let tree = odb
1016                .write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
1017                .unwrap();
1018            let commit =
1019                format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nseed\n");
1020            let commit = odb.write(ObjectKind::Commit, commit.as_bytes()).unwrap();
1021            std::fs::write(
1022                dir.path().join("main.git/refs/heads/main"),
1023                format!("{commit}\n"),
1024            )
1025            .unwrap();
1026            let fx = Self { dir, blobs };
1027            fx.append_main_config("[remote \"lop\"]\n\turl = ../lop.git\n");
1028            fx
1029        }
1030
1031        fn main_dir(&self) -> PathBuf {
1032            self.dir.path().join("main.git")
1033        }
1034
1035        fn lop_dir(&self) -> PathBuf {
1036            self.dir.path().join("lop.git")
1037        }
1038
1039        fn main(&self) -> Repository {
1040            Repository::open(&self.main_dir(), &RepositoryOptions::empty()).unwrap()
1041        }
1042
1043        fn lop(&self) -> Repository {
1044            Repository::open(&self.lop_dir(), &RepositoryOptions::empty()).unwrap()
1045        }
1046
1047        fn append_main_config(&self, text: &str) {
1048            let path = self.main_dir().join("config");
1049            let mut config = std::fs::read_to_string(&path).unwrap();
1050            config.push_str(text);
1051            std::fs::write(path, config).unwrap();
1052        }
1053
1054        fn offload(&self, limit: u64) -> OffloadReport {
1055            let mut opts = OffloadOptions::new("lop", NOW);
1056            opts.filter = Some(ObjectFilterSpec::BlobLimit(limit));
1057            offload_blobs(&self.main(), &opts).expect("offload")
1058        }
1059
1060        fn files(&self, dir: &Path, ext: &str) -> Vec<PathBuf> {
1061            std::fs::read_dir(dir.join("objects/pack"))
1062                .map(|entries| {
1063                    entries
1064                        .flatten()
1065                        .map(|e| e.path())
1066                        .filter(|p| p.extension().is_some_and(|e| e == ext))
1067                        .collect()
1068                })
1069                .unwrap_or_default()
1070        }
1071    }
1072
1073    #[test]
1074    fn blobs_at_the_limit_move_and_smaller_ones_stay() {
1075        let fx = Fixture::new(&[10_000, 9_999, 30_000]);
1076        let report = fx.offload(10_000);
1077        assert_eq!(report.candidates, 2);
1078        assert_eq!(report.candidate_bytes, 40_000);
1079        assert_eq!(report.copied_to_lop, 2);
1080        assert_eq!(report.dropped_from_main, 2);
1081        assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(10_000));
1082
1083        let main = fx.main();
1084        let lop = fx.lop();
1085        for (i, moved) in [(0, true), (1, false), (2, true)] {
1086            let blob = fx.blobs[i];
1087            assert_eq!(!main.odb().exists_stored(&blob), moved, "main f{i}");
1088            assert_eq!(lop.odb().exists_stored(&blob), moved, "lop f{i}");
1089        }
1090        // The rest of main, the kept blob included, lives in one promisor pack; the
1091        // loose objects are gone.
1092        let promisor = fx.files(&fx.main_dir(), "promisor");
1093        assert_eq!(promisor.len(), 1);
1094        assert_eq!(
1095            report.main_pack.as_ref().and_then(|p| p.path.file_name()),
1096            promisor[0].with_extension("pack").file_name()
1097        );
1098        let indexes = main.odb().local_pack_indexes().unwrap();
1099        let promisor_idx = indexes
1100            .iter()
1101            .find(|i| i.is_promisor)
1102            .expect("promisor pack");
1103        assert_eq!(indexes.len(), 1);
1104        assert_eq!(promisor_idx.len(), 3, "the commit, its tree and f1");
1105        assert!(promisor_idx.find_position(&fx.blobs[1]).is_some());
1106        assert!(main
1107            .odb()
1108            .enumerate_local_loose_objects()
1109            .unwrap()
1110            .is_empty());
1111        assert!(report.main_bytes_after < report.main_bytes_before);
1112
1113        // The report describes each pack it wrote.
1114        let roles: Vec<_> = report.packs().map(|p| (p.role, p.objects)).collect();
1115        assert_eq!(
1116            roles,
1117            [
1118                (OffloadPackRole::Lop, 2),
1119                (OffloadPackRole::MainPromisor, 3),
1120            ]
1121        );
1122        for pack in report.packs() {
1123            assert_eq!(pack.bytes, std::fs::metadata(&pack.path).unwrap().len());
1124        }
1125        assert!(report
1126            .lop_pack
1127            .as_ref()
1128            .unwrap()
1129            .path
1130            .starts_with(fx.lop().git_dir()));
1131    }
1132
1133    #[test]
1134    fn lop_pack_is_kept_and_complete() {
1135        let fx = Fixture::new(&[4096]);
1136        let report = fx.offload(1024);
1137        let lop_pack = report.lop_pack.expect("lop pack").path;
1138        assert_eq!(
1139            std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap(),
1140            format!("{LOP_KEEP_MESSAGE}\n")
1141        );
1142        let blob = fx.lop().odb().read(&fx.blobs[0]).unwrap();
1143        assert_eq!(blob.data, blob_data(0, 4096));
1144    }
1145
1146    #[test]
1147    fn config_is_set_in_main_and_optionally_in_the_lop() {
1148        let fx = Fixture::new(&[2048]);
1149        let mut opts = OffloadOptions::new("lop", NOW);
1150        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1151        opts.configure_lop = true;
1152        let report = offload_blobs(&fx.main(), &opts).unwrap();
1153        let lop_dir = lop_dir_value(fx.lop().git_dir());
1154        assert!(Path::new(&lop_dir).is_absolute());
1155        let keys: Vec<(OffloadRepo, &str, &str)> = report
1156            .config_changes
1157            .iter()
1158            .map(|c| (c.repo, c.key.as_str(), c.value.as_str()))
1159            .collect();
1160        assert_eq!(
1161            keys,
1162            [
1163                (OffloadRepo::Main, "remote.lop.promisor", "true"),
1164                (
1165                    OffloadRepo::Main,
1166                    "remote.lop.partialCloneFilter",
1167                    "blob:limit=1024"
1168                ),
1169                (OffloadRepo::Main, "repack.writeBitmaps", "false"),
1170                (OffloadRepo::Main, LOP_GIT_DIR_KEY, lop_dir.as_str()),
1171                (OffloadRepo::Lop, "uploadpack.allowFilter", "true"),
1172                (OffloadRepo::Lop, "uploadpack.allowAnySHA1InWant", "true"),
1173            ]
1174        );
1175        assert!(report.lop_ready.is_ready());
1176        assert!(!report.advertised);
1177        let config = fx.main().config().unwrap();
1178        assert_eq!(config.get("remote.lop.promisor").as_deref(), Some("true"));
1179        assert!(config.get("extensions.partialclone").is_none());
1180        assert_eq!(
1181            crate::partial_clone::stored_remote_filter(&config, "lop"),
1182            Some(ObjectFilterSpec::BlobLimit(1024))
1183        );
1184        assert_eq!(config.get_all(LOP_GIT_DIR_KEY), [lop_dir.clone()]);
1185
1186        // The LOP is recorded once, however often offload runs.
1187        let again = offload_blobs(&fx.main(), &opts).unwrap();
1188        assert!(
1189            again.config_changes.is_empty(),
1190            "{:?}",
1191            again.config_changes
1192        );
1193        assert_eq!(
1194            fx.main().config().unwrap().get_all(LOP_GIT_DIR_KEY),
1195            [lop_dir]
1196        );
1197    }
1198
1199    #[test]
1200    fn stored_filter_is_used_and_lop_is_left_alone_by_default() {
1201        let fx = Fixture::new(&[2048, 10]);
1202        fx.append_main_config("\tpartialCloneFilter = blob:limit=1k\n");
1203        let report = offload_blobs(&fx.main(), &OffloadOptions::new("lop", NOW)).unwrap();
1204        assert_eq!(report.candidates, 1);
1205        assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(1024));
1206        let keys: Vec<&str> = report
1207            .config_changes
1208            .iter()
1209            .map(|c| c.key.as_str())
1210            .collect();
1211        assert_eq!(
1212            keys,
1213            [
1214                "remote.lop.promisor",
1215                "repack.writeBitmaps",
1216                LOP_GIT_DIR_KEY
1217            ]
1218        );
1219        assert_eq!(report.lop_ready, LopReadiness::default());
1220    }
1221
1222    /// Every file under `dir` with its contents, sorted by path.
1223    fn tree_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
1224        let mut out = Vec::new();
1225        let mut stack = vec![dir.to_path_buf()];
1226        while let Some(path) = stack.pop() {
1227            if path.is_dir() {
1228                stack.extend(
1229                    std::fs::read_dir(&path)
1230                        .unwrap()
1231                        .flatten()
1232                        .map(|e| e.path()),
1233                );
1234            } else {
1235                out.push((path.clone(), std::fs::read(&path).unwrap()));
1236            }
1237        }
1238        out.sort();
1239        out
1240    }
1241
1242    #[test]
1243    fn dry_run_writes_nothing() {
1244        let fx = Fixture::new(&[2048]);
1245        let before = tree_snapshot(&fx.main_dir());
1246        let mut opts = OffloadOptions::new("lop", NOW);
1247        opts.filter = Some(ObjectFilterSpec::BlobNone);
1248        opts.dry_run = true;
1249        opts.configure_lop = true;
1250        let report = offload_blobs(&fx.main(), &opts).unwrap();
1251        assert!(report.dry_run);
1252        assert_eq!(report.candidates, 1);
1253        assert_eq!(report.copied_to_lop, 0);
1254        assert!(report.config_changes.is_empty());
1255        assert_eq!(tree_snapshot(&fx.main_dir()), before);
1256        assert!(fx.files(&fx.lop_dir(), "pack").is_empty());
1257        assert!(!fx.main_dir().join("gc.pid").exists());
1258    }
1259
1260    #[test]
1261    fn running_again_is_a_no_op_until_new_blobs_arrive() {
1262        let fx = Fixture::new(&[2048, 10]);
1263        fx.offload(1024);
1264        let again = fx.offload(1024);
1265        assert_eq!(again.candidates, 0);
1266        assert_eq!(again.copied_to_lop, 0);
1267        assert!(again.main_pack.is_none(), "nothing to rewrite");
1268        assert_eq!(fx.files(&fx.lop_dir(), "pack").len(), 1);
1269        assert_eq!(fx.files(&fx.main_dir(), "pack").len(), 1);
1270    }
1271
1272    /// Put `fx`'s first blob into a new LOP pack whose `.keep` marker says `message`.
1273    fn pack_into_lop(fx: &Fixture, message: &str) {
1274        let pack = crate::pack_objects::build_pack_from_send_list(
1275            fx.main().odb(),
1276            &[fx.blobs[0]],
1277            &std::collections::HashSet::new(),
1278            &PackBuildOptions::default(),
1279            &std::collections::HashMap::new(),
1280        )
1281        .unwrap();
1282        crate::index_pack::install_pack_bytes(
1283            pack,
1284            fx.lop().odb(),
1285            &IngestPackOptions {
1286                keep_message: Some(message.to_owned()),
1287                ..IngestPackOptions::default()
1288            },
1289        )
1290        .unwrap();
1291    }
1292
1293    #[test]
1294    fn blobs_the_lop_keeps_are_not_copied_again() {
1295        let fx = Fixture::new(&[2048]);
1296        pack_into_lop(&fx, LOP_KEEP_MESSAGE);
1297        let report = fx.offload(1024);
1298        assert_eq!(report.already_in_lop, 1);
1299        assert_eq!(report.copied_to_lop, 0);
1300        assert!(report.lop_pack.is_none());
1301        assert_eq!(report.dropped_from_main, 1);
1302    }
1303
1304    #[test]
1305    fn a_copy_another_writer_keeps_for_a_while_is_not_trusted() {
1306        // receive-pack and fetch-pack keep the pack they write until the refs are
1307        // updated, then remove the marker; an operator's marker is no promise either.
1308        for message in ["receive-pack 4242 on build-host", "pinned"] {
1309            let fx = Fixture::new(&[2048]);
1310            pack_into_lop(&fx, message);
1311            let report = fx.offload(1024);
1312            assert_eq!(report.already_in_lop, 0, "{message}");
1313            assert_eq!(report.copied_to_lop, 1, "{message}");
1314            let lop_pack = report.lop_pack.expect("copied").path;
1315            let keep = std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap();
1316            assert_eq!(keep.trim_end(), LOP_KEEP_MESSAGE);
1317        }
1318    }
1319
1320    #[cfg(unix)]
1321    #[test]
1322    fn a_lop_sharing_main_objects_is_refused() {
1323        let fx = Fixture::new(&[2048]);
1324        let lop_objects = fx.lop_dir().join("objects");
1325        std::fs::remove_dir_all(&lop_objects).unwrap();
1326        std::os::unix::fs::symlink(fx.main_dir().join("objects"), &lop_objects).unwrap();
1327        let mut opts = OffloadOptions::new("lop", NOW);
1328        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1329        assert!(matches!(
1330            offload_err(&fx, &opts),
1331            OffloadError::LopIsMainRepository
1332        ));
1333        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1334    }
1335
1336    /// Remove the LOP's loose objects, as its `gc --prune=now` would for blobs
1337    /// nothing in it references.
1338    fn prune_lop_loose(fx: &Fixture) {
1339        for (_, path) in fx.lop().odb().enumerate_local_loose_objects().unwrap() {
1340            std::fs::remove_file(path).unwrap();
1341        }
1342    }
1343
1344    #[test]
1345    fn a_loose_copy_in_the_lop_is_not_trusted() {
1346        let fx = Fixture::new(&[2048]);
1347        fx.lop()
1348            .odb()
1349            .write(ObjectKind::Blob, &blob_data(0, 2048))
1350            .unwrap();
1351        let report = fx.offload(1024);
1352        assert_eq!(report.already_in_lop, 0);
1353        assert_eq!(report.copied_to_lop, 1);
1354        assert!(report.lop_pack.is_some());
1355        assert_eq!(report.dropped_from_main, 1);
1356        prune_lop_loose(&fx);
1357        let lop = fx.lop();
1358        assert_eq!(
1359            lop.odb().read(&fx.blobs[0]).unwrap().data,
1360            blob_data(0, 2048),
1361            "the kept pack survives the LOP's prune"
1362        );
1363    }
1364
1365    #[test]
1366    fn a_copy_the_lop_borrows_from_main_is_not_trusted() {
1367        let fx = Fixture::new(&[2048]);
1368        let alternates = fx.lop_dir().join("objects/info/alternates");
1369        std::fs::create_dir_all(alternates.parent().unwrap()).unwrap();
1370        std::fs::write(
1371            &alternates,
1372            format!("{}\n", fx.main_dir().join("objects").display()),
1373        )
1374        .unwrap();
1375        let report = fx.offload(1024);
1376        assert_eq!(report.already_in_lop, 0);
1377        assert_eq!(report.copied_to_lop, 1);
1378        std::fs::remove_file(&alternates).unwrap();
1379        assert_eq!(
1380            fx.lop().odb().read(&fx.blobs[0]).unwrap().data,
1381            blob_data(0, 2048),
1382            "the LOP has its own copy"
1383        );
1384    }
1385
1386    #[test]
1387    fn running_again_folds_pushed_objects_into_the_promisor_pack() {
1388        let fx = Fixture::new(&[2048, 10]);
1389        fx.offload(1024);
1390        // A push since: a loose tree that still names the offloaded blob.
1391        let main = fx.main();
1392        let mut body = b"100644 big\0".to_vec();
1393        body.extend_from_slice(fx.blobs[0].as_bytes());
1394        let tree = main
1395            .odb()
1396            .write_loose_materialize(ObjectKind::Tree, &body)
1397            .unwrap();
1398        let again = fx.offload(1024);
1399        assert_eq!(again.candidates, 0);
1400        let pack = again.main_pack.expect("folded").path;
1401        let promisor: Vec<_> = fx
1402            .files(&fx.main_dir(), "promisor")
1403            .iter()
1404            .map(|p| p.file_name().map(|n| n.to_os_string()))
1405            .collect();
1406        assert_eq!(
1407            promisor,
1408            [pack
1409                .with_extension("promisor")
1410                .file_name()
1411                .map(|n| n.to_os_string())]
1412        );
1413        let main = fx.main();
1414        assert!(main
1415            .odb()
1416            .enumerate_local_loose_objects()
1417            .unwrap()
1418            .is_empty());
1419        assert!(main.odb().exists_stored(&tree));
1420        assert!(!main.odb().exists_stored(&fx.blobs[0]));
1421    }
1422
1423    #[test]
1424    fn blobs_in_kept_packs_stay() {
1425        let fx = Fixture::new(&[2048]);
1426        let main = fx.main();
1427        let pack = crate::pack_objects::build_pack_from_send_list(
1428            main.odb(),
1429            &[fx.blobs[0]],
1430            &std::collections::HashSet::new(),
1431            &PackBuildOptions::default(),
1432            &std::collections::HashMap::new(),
1433        )
1434        .unwrap();
1435        crate::index_pack::install_pack_bytes(
1436            pack,
1437            main.odb(),
1438            &IngestPackOptions {
1439                keep_message: Some("pinned".into()),
1440                ..IngestPackOptions::default()
1441            },
1442        )
1443        .unwrap();
1444        let report = fx.offload(1024);
1445        assert_eq!(report.candidates, 0);
1446        assert_eq!(report.skipped_kept_pack, 1);
1447        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1448    }
1449
1450    /// Pack `oids` of main into a new pack with a `.keep` marker, dropping their
1451    /// loose copies.
1452    fn keep_in_main(fx: &Fixture, oids: &[ObjectId]) -> PathBuf {
1453        let main = fx.main();
1454        let pack = crate::pack_objects::build_pack_from_send_list(
1455            main.odb(),
1456            oids,
1457            &std::collections::HashSet::new(),
1458            &PackBuildOptions::default(),
1459            &std::collections::HashMap::new(),
1460        )
1461        .unwrap();
1462        let installed = crate::index_pack::install_pack_bytes(
1463            pack,
1464            main.odb(),
1465            &IngestPackOptions {
1466                keep_message: Some("pinned".into()),
1467                ..IngestPackOptions::default()
1468            },
1469        )
1470        .unwrap();
1471        crate::prune_packed::prune_packed_objects(main.odb().objects_dir(), Default::default())
1472            .unwrap();
1473        installed.pack_path.unwrap()
1474    }
1475
1476    #[test]
1477    fn a_kept_object_the_promisor_pack_would_name_stops_offload() {
1478        // f1 (small, staying) has its only copy in an operator's kept pack, and the
1479        // tree naming it is loose: in the promisor pack it would make f1 promised.
1480        let fx = Fixture::new(&[2048, 10]);
1481        let kept = keep_in_main(&fx, &[fx.blobs[1]]);
1482        let mut opts = OffloadOptions::new("lop", NOW);
1483        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1484        for dry_run in [true, false] {
1485            opts.dry_run = dry_run;
1486            match offload_err(&fx, &opts) {
1487                OffloadError::KeptPackObjectPromised { pack, oid, .. } => {
1488                    assert_eq!(pack, kept);
1489                    assert_eq!(oid, fx.blobs[1]);
1490                }
1491                other => panic!("got {other:?}"),
1492            }
1493        }
1494        assert!(fx.files(&fx.lop_dir(), "pack").is_empty(), "nothing copied");
1495        assert!(fx.files(&fx.main_dir(), "promisor").is_empty());
1496        assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1497
1498        // Without the marker the pack is folded in like any other.
1499        std::fs::remove_file(kept.with_extension("keep")).unwrap();
1500        let report = fx.offload(1024);
1501        assert_eq!(report.dropped_from_main, 1);
1502        let main = fx.main();
1503        let indexes = main.odb().local_pack_indexes().unwrap();
1504        assert_eq!(indexes.len(), 1);
1505        assert!(indexes[0].is_promisor);
1506        assert!(indexes[0].find_position(&fx.blobs[1]).is_some());
1507    }
1508
1509    #[test]
1510    fn a_kept_pack_nothing_names_does_not_stop_offload() {
1511        let fx = Fixture::new(&[2048]);
1512        let stray = fx
1513            .main()
1514            .odb()
1515            .write(ObjectKind::Blob, b"named by nothing")
1516            .unwrap();
1517        let kept = keep_in_main(&fx, &[stray]);
1518        let report = fx.offload(1024);
1519        assert_eq!(report.dropped_from_main, 1);
1520        assert!(kept.is_file() && kept.with_extension("keep").is_file());
1521        assert!(fx.main().odb().exists_stored(&stray));
1522    }
1523
1524    #[test]
1525    fn blobs_a_checkout_needs_stay() {
1526        let dir = tempfile::tempdir().unwrap();
1527        let work = dir.path().join("work");
1528        let repo =
1529            Repository::init(&work, &InitOptions::new().with_initial_branch("main")).unwrap();
1530        Repository::init(
1531            &dir.path().join("lop.git"),
1532            &InitOptions::new().with_bare(true),
1533        )
1534        .unwrap();
1535        std::fs::write(work.join("big"), vec![b'x'; 4096]).unwrap();
1536        crate::porcelain::add::stage_for_commit(
1537            &repo,
1538            &mut crate::progress::NullProgress,
1539            &crate::progress::NeverCancel,
1540        )
1541        .unwrap();
1542        let ident = "T <t@example.com> 1 +0000".to_string();
1543        let mut req = crate::porcelain::commit::CommitRequest::new("big");
1544        let sig: crate::ident::Signature = ident.parse().unwrap();
1545        req.author = Some(sig.clone());
1546        req.committer = Some(sig);
1547        crate::porcelain::commit::create_commit(
1548            &repo,
1549            &req,
1550            &mut crate::progress::NullProgress,
1551            &crate::progress::NeverCancel,
1552        )
1553        .unwrap();
1554        let config = repo.git_dir().join("config");
1555        let mut text = std::fs::read_to_string(&config).unwrap();
1556        text.push_str(&format!(
1557            "[remote \"lop\"]\n\turl = {}\n",
1558            dir.path().join("lop.git").display()
1559        ));
1560        std::fs::write(&config, text).unwrap();
1561        let repo = Repository::discover(Some(&work), &RepositoryOptions::empty()).unwrap();
1562        let mut opts = OffloadOptions::new("lop", NOW);
1563        opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1564        let report = offload_blobs(&repo, &opts).unwrap();
1565        assert_eq!(report.candidates, 0);
1566        assert_eq!(report.skipped_checked_out, 1);
1567    }
1568
1569    fn offload_err(fx: &Fixture, opts: &OffloadOptions) -> OffloadError {
1570        match offload_blobs(&fx.main(), opts) {
1571            Err(Error::Offload(err)) => err,
1572            other => panic!("expected an offload error, got {other:?}"),
1573        }
1574    }
1575
1576    #[test]
1577    fn refusals_are_typed() {
1578        let fx = Fixture::new(&[2048]);
1579        let with_filter = |remote: &str| {
1580            let mut opts = OffloadOptions::new(remote, NOW);
1581            opts.filter = Some(ObjectFilterSpec::BlobNone);
1582            opts
1583        };
1584        assert!(matches!(
1585            offload_err(&fx, &with_filter("nope")),
1586            OffloadError::RemoteNotConfigured { remote } if remote == "nope"
1587        ));
1588        assert!(matches!(
1589            offload_err(&fx, &OffloadOptions::new("lop", NOW)),
1590            OffloadError::NoThreshold { .. }
1591        ));
1592
1593        fx.append_main_config(
1594            "[remote \"web\"]\n\turl = https://user:secret@lop.example/r.git\n\
1595             [remote \"self\"]\n\turl = .\n\
1596             [remote \"gone\"]\n\turl = ../nothing-here.git\n",
1597        );
1598        match offload_err(&fx, &with_filter("web")) {
1599            OffloadError::LopNotLocal { url, .. } => assert!(!url.contains("secret")),
1600            other => panic!("got {other:?}"),
1601        }
1602        assert!(matches!(
1603            offload_err(&fx, &with_filter("self")),
1604            OffloadError::LopIsMainRepository
1605        ));
1606        assert!(matches!(
1607            offload_err(&fx, &with_filter("gone")),
1608            OffloadError::LopNotARepository { .. }
1609        ));
1610
1611        let sha256 = fx.dir.path().join("sha256.git");
1612        Repository::init(
1613            &sha256,
1614            &InitOptions::new()
1615                .with_bare(true)
1616                .with_object_format(HashAlgo::Sha256),
1617        )
1618        .unwrap();
1619        let mut opts = with_filter("web");
1620        opts.lop_git_dir = Some(sha256);
1621        assert!(matches!(
1622            offload_err(&fx, &opts),
1623            OffloadError::HashAlgoMismatch {
1624                main: HashAlgo::Sha1,
1625                lop: HashAlgo::Sha256
1626            }
1627        ));
1628
1629        std::fs::write(fx.main_dir().join("gc.pid"), "1 some-other-host").unwrap();
1630        assert!(matches!(
1631            offload_err(&fx, &with_filter("lop")),
1632            OffloadError::MaintenanceRunning { .. }
1633        ));
1634    }
1635}